Framework
Framework erklärt die Konzepte hinter defineFeature — wie Commands
durch die Pipeline laufen, wie Tenants isoliert werden, wie Schemas zu
DB-Tabellen UND UI-Forms werden, wie Events das Audit-Log füttern.
Detail-Pages pro Konzept sind in Arbeit (Status-Marker pro Sektion). Bis dahin: jede Sektion hier hat einen Vorab-Überblick + Code-Snippet, plus Verweise auf die Plan-Snapshots im Architecture-Bucket und auf die Recipes mit ausführbarem Beispiel.
Pipeline
Status: ✅ Stable · Detail-Page ⬜ in Arbeit
Wofür: Eine Anfrage durchläuft eine typisierte, fixe Reihenfolge von Stufen — Auth, Validation, Access-Check, Handler, Audit, SSE- Broadcast. Du schreibst nur den Handler-Body, alle anderen Stufen kommen vom Framework.
Wie es funktioniert: Der Dispatcher empfängt Commands (Write) oder Queries (Read), schaut den Handler in der Registry nach, und feuert die Pipeline-Stufen in dieser Reihenfolge ab:
HTTP Request → JWT Auth (Hono Middleware) → ctx.user gesetzt → Dispatcher (qn-Lookup) → Zod Schema Validation → payload typed → Access Check (Entity-Level Roles) → Field-Level Write Check → Validation Hooks → Custom-Logik vor DB → Handler (CrudExecutor → DB) → DEIN Code, alles davor frei → Lifecycle Pipeline (postSave): Feature postSave Hooks System Hooks (nach Priorität): 1000: Search Index (Meilisearch) 1001: SSE Broadcast 1002: Audit Trail → Response (mit Field-Level Read Filtering)Ein Command trägt nur die Änderungen ({ id, changes }), nie das
ganze Objekt. Hooks bekommen den SaveContext mit { id, data, changes, previous, isNew } — sie wissen genau was sich geändert hat ohne nochmal
zu lesen.
Beispiel:
// Eigener preSave-Hook: Slug aus Title generierenr.hook("preSave", "post.create", async (ctx, { data, changes }) => { if (!changes.slug && changes.title) { return { ...data, slug: slugify(changes.title) }; } return data;});
// postSave-Hook: Email an alle Subscriberr.hook("postSave", "incident.create", async (ctx, { id, data }) => { const subscribers = await ctx.dispatcher.query("subscribers:list", {}); for (const sub of subscribers) { await ctx.delivery.send({ to: sub.email, template: "incident-new", data }); }});Plan-Snapshot: architecture/lifecycle, architecture/event-dispatcher.
Multi-Tenant
Status: ✅ Stable · Detail-Page ⬜ in Arbeit
Wofür: Eine App-Instanz bedient N voneinander isolierte Mandanten
(Tenants) ohne dass dein Handler-Code etwas davon merken muss. Keine
hand-geschriebenen WHERE tenant_id = ?-Klauseln, keine Cross-Tenant-
Leak-Risiken. Default-Deny: kein Read kommt ohne Tenant-Kontext durch.
Wie es funktioniert: Jede Entity bekommt beim Boot ein tenantId-
Feld injiziert (außer der Tenant-Entity selbst — tenants.id ist
der tenantId). Der CrudExecutor schreibt es bei Inserts, der
List-Handler scoped Reads automatisch. Die Auth-Middleware setzt
ctx.tenantId aus dem JWT — kein Tenant-Switch ohne neuen Token.
Anonymous-Access: Public-Pages (Status-Page, Marketing-Land-Page)
brauchen keinen Login. Mit anonymousAccess: { tenantResolver } setzt
du den Tenant aus der Subdomain/dem Custom-Header — Reads bleiben
gescoped, Writes sind explizit per Handler roles: ["anonymous"]
freizuschalten.
Beispiel:
// Subdomain-Resolver für Public-Pagesawait runProdApp({ features: [statusFeature], anonymousAccess: { tenantResolver: async ({ db, request }) => { const host = request.headers.get("host") ?? ""; const sub = host.split(".")[0]; const tenant = await db.query("tenant", "bySlug", { slug: sub }); return tenant?.id ?? null; }, },});
// Sysadmin-Tenant: globale roles aus users-Featureconst sysadmin = await ctx.db.read("user", userId);const isSysadmin = sysadmin.roles.includes("Sysadmin");Plan-Snapshot: architecture/tenant-db-context, project: anonymous-access.
Schema-System
Status: ✅ Stable · Detail-Page ⬜ in Arbeit
Wofür: Eine Schema-Definition für alles — DB-Tabelle (Drizzle), Validation (Zod), Form-Felder (Renderer), Listen-Spalten, Filter, i18n-Keys, Search-Indexing. Schreib’s einmal, kriegst alles oben drauf.
Wie es funktioniert: Du nutzt createEntity({ fields: { ... } }) mit
Field-Factories (createTextField, createSelectField,
createNumberField, createDateField, createBooleanField,
createReferenceField). Jede Factory ist typesafe — createSelectField({ options: ["a", "b"] }) infert default als "a" | "b". r.entity()
registriert die Entity, der Boot leitet daraus die Drizzle-Tabelle, die
Zod-Schemas und das ViewModel ab.
Field-Types: text (mit multiline, maxLength, searchable),
number, boolean, date, timestamp, select, reference,
money, embedded, file, image. Jeder Type hat einen Default-
Renderer im Web-Renderer + einen DB-Column-Type.
Beispiel:
import { createEntity, createSelectField, createTextField, createNumberField,} from "@kumiko/framework/engine";
// `as const`-Konstanten exportieren — Seeds + Tests teilen die// Werte ohne Drift.export const TICKET_SEVERITIES = ["low", "medium", "high", "critical"] as const;export type TicketSeverity = (typeof TICKET_SEVERITIES)[number];
export const ticketEntity = createEntity({ fields: { title: createTextField({ required: true, sortable: true, searchable: true }), severity: createSelectField({ options: TICKET_SEVERITIES, default: "medium", filterable: true, }), spentMinutes: createNumberField({ sortable: true }), description: createTextField({ multiline: { rows: 6 } }), },});Plan-Snapshot: architecture/feature-structure, architecture/computed-fields.
Event-Sourcing
Status: ✅ Stable (Phase 2 hardened) · Detail-Page ⬜ in Arbeit
Wofür: Jede State-Änderung als Event in einer append-only-Tabelle.
Audit-Trail ist gratis. Time-Travel (asOf) ist gratis. Zustand
rebuilden bei Schema-Änderung statt Migration. Cross-Aggregate-Reaktionen
ohne lose Tabellen-Trigger.
Wie es funktioniert: Der CrudExecutor schreibt für jeden Write ein
Event auf den Aggregate-Stream der Entity (<tenantId>:<entity>:<id>)
und aktualisiert die Projection (Read-Model-Tabelle) in derselben
Transaktion. Default-Events sind <entity>.created, .updated,
.deleted. Custom-Events deklarierst du via r.defineEvent(...).
Projections sind Read-Modelle:
- Inline-Projection (Default): Read-Model-Row pro Entity-Row, in derselben TX.
- Multi-Stream-Projection: hört auf Events aus mehreren Aggregaten, baut ein Cross-Aggregate-Read-Model auf (z.B. „Open-Incidents pro Component” über component- + incident-Streams).
Snapshots, Upcaster, Time-Travel sind Phase-Features die in
Production laufen — Recipe event-sourcing zeigt das Vollbild.
Beispiel:
// Custom Event mit typed payloadconst incidentResolved = r.defineEvent({ name: "incident.resolved", schema: z.object({ resolution: z.string(), resolvedAt: z.date() }),});
r.writeHandler({ qn: "incident:resolve", handler: async (ctx, { id, resolution }) => { await ctx.appendEvent(incidentResolved, id, { resolution, resolvedAt: new Date(), }); },});
// Multi-Stream-Projection: Stats aggregiert über alle Incidents eines Tenantsr.multiStreamProjection({ name: "incident-stats", streams: [{ aggregateType: "incident" }], handler: async (event, projection) => { if (event.type === "incident.created") return { ...projection, open: projection.open + 1 }; if (event.type === "incident.resolved") return { ...projection, open: projection.open - 1 }; return projection; },});Recipe: samples/recipes/event-sourcing.
Rendering
Status: ✅ Stable · Detail-Page ⬜ in Arbeit
Wofür: Aus dem Schema Forms + Listen + Filter automatisch rendern, ohne pro Feature ein React-Tree zu schreiben. Schema-driven UI ist der Pfad gegen Bubble/Webflow — Code lebt im Repo, Designer + KI editieren denselben Schema-Text.
Wie es funktioniert: Drei Schichten:
@kumiko/framework/ui-types— Schema-Definitionen (EntityListScreenDefinition,EntityEditScreenDefinition,FieldDefinition).@kumiko/headless— Pure ViewModel-Builder (Schema + Daten →ListColumnViewModel/EditFieldViewModel). Plattform-agnostisch.@kumiko/renderer-web(oderrenderer-rnkommend) — nimmt das ViewModel + rendert React-DOM mit shadcn/Tailwind. Inputs, DataTable, Sections, Combobox, Date-Picker, Reference-Lookups.
Custom Components: Pro Spalte / Field kannst du einen
Custom-Renderer registrieren ({ react: { __component: "MyBadge" } }),
der über clientFeatures.columnRenderers aufgelöst wird. Nicht
registriert? Default-Renderer übernimmt + warning im Console.
Translated Select-Options: ListColumnViewModel + EditFieldViewModel
tragen optionLabels (Convention-Key
<feature>:entity:<entity>:field:<field>:option:<value>) — dadurch
rendern Select-Cells und Form-Selects denselben translated Label,
nicht den raw value.
Beispiel:
// Screen-Definition — DataTable mit 9 Spalten + Default-Sortexport const ticketListScreen: EntityListScreenDefinition = { id: "ticket-list", type: "entityList", entity: "ticket", columns: ["title", "severity", "status", "department", "assignee", "dueDate"], pageSize: 25, defaultSort: { field: "severity", dir: "desc" },};
// Edit-Form mit Sectionsexport const ticketEditScreen: EntityEditScreenDefinition = { id: "ticket-edit", type: "entityEdit", entity: "ticket", layout: { sections: [ { title: "ticket:section.basics", columns: 2, fields: ["title", "severity", "status"] }, { title: "ticket:section.tracking", columns: 2, fields: ["assignee", "dueDate"] }, ], },};Plan-Snapshot: architecture/ui-renderer, architecture/ui-architecture.
i18n
Status: ✅ Stable · Detail-Page ⬜ in Arbeit
Wofür: Mehrsprachige Apps ohne pro Feature ein Übersetzungs-File- System neu erfinden. Pro Feature ein i18n-Bundle, Keys folgen einer Convention die Form, List und Nav automatisch übersetzt.
Wie es funktioniert: r.translations({ de: { ... }, en: { ... } })
in der Feature-Definition. Convention für die Keys:
| Pattern | Beispiel | Wo gerendert |
|---|---|---|
<feature>:nav.<id> | assets:nav.list | Sidebar-Eintrag |
screen:<id>.title | screen:asset-list.title | Screen-Header |
<feature>:entity:<entity>:field:<name> | assets:entity:asset:field:status | Form-Label, Spalten-Header |
<feature>:entity:<entity>:field:<name>:option:<value> | assets:entity:asset:field:status:option:lent | Select-Cell, Dropdown-Option |
<feature>:section.<id> | assets:section.basics | Form-Section-Title |
Der ViewModel-Builder zieht die Translations zur Render-Zeit. Missing-
Key fällt auf humanizeSlug(value) zurück (z.B. "to_read" → "To read") — keine harten Crashes auf neue Werte.
Beispiel:
import type { TranslationsByLocale } from "@kumiko/renderer";
export const assetsTranslations: TranslationsByLocale = { de: { "assets:nav.list": "Assets", "assets:section.basics": "Stammdaten", "assets:entity:asset:field:status": "Status", "assets:entity:asset:field:status:option:lent": "Ausgeliehen", "assets:entity:asset:field:status:option:maintenance": "In Wartung", }, en: { "assets:nav.list": "Assets", "assets:section.basics": "Basics", "assets:entity:asset:field:status": "Status", "assets:entity:asset:field:status:option:lent": "Lent out", "assets:entity:asset:field:status:option:maintenance": "Maintenance", },};Recipe: samples/recipes/i18n.
Realtime
Status: ✅ Stable · Detail-Page ⬜ in Arbeit
Wofür: Server-pusht Updates an alle interessierten Browser ohne dass deine Handler etwas davon merken müssen. Eine Bestellung in Tab A ändern, Tab B sieht den neuen Status sofort. Out-of-the-box, nicht Premium-Tier.
Wie es funktioniert: Jeder erfolgreiche Write feuert einen System-
Hook (Priorität 1001) der ein SSE-Event auf den Tenant-Channel
pusht (via Redis-Pub/Sub). Browser hängen über useEntity/useList-
Hooks an einer SSE-Subscription, die nur die Events filtert die für
den aktuellen Screen relevant sind. Refresh ist eine Re-Query,
nicht ein partial-Update — einfacher Code, kein Cache-Invalidation-
Drift.
Optimistic Locking: Jede Entity hat ein automatisches version-
Feld. Stale-Write → version_conflict-Error mit Reload-Hinweis. Zwei
parallele Edits derselben Row mit demselben Version-Stempel sind
unmöglich.
Multi-Instance: Mehrere Bun-Prozesse können dieselbe DB bedienen, SSE wird über Redis-Pub/Sub fan-out — Browser am Process A sieht den Write von Process B.
Beispiel:
// React-Hook: Live-List, re-rendert bei jedem Server-Eventimport { useList } from "@kumiko/headless";
function IncidentList() { const { rows, isLoading } = useList("incidents:list", {});
if (isLoading) return <Skeleton />; return ( <ul> {rows.map((row) => ( <li key={row.id}>{row.title} — {row.status}</li> ))} </ul> );}Recipe: samples/recipes/realtime-sse ·
Plan-Snapshot: architecture/event-dispatcher.
Auth + Permissions
Status: ✅ Stable · Detail-Page ⬜ in Arbeit
Wofür: Wer-darf-was-Schicht ohne pro Handler if/else-Branches. Default-Deny, Role-basiert, mit Field-Level-Granularität (User-Email sehen alle, Telefon nur Admins). Anonymous-Access als first-class- Bürger statt Hack.
Wie es funktioniert: Jeder Handler deklariert
access: { roles: ["Admin", "User"] } oder
access: { openToAll: true } (auth-only) oder
access: { roles: ["anonymous"] } (public). Die Pipeline checkt
vor der Validation ob der aktuelle User die Rolle hat. Field-Level-
Access geht über FieldAccess an einzelnen Fields (Read- + Write-
Sicht-Filter pro Rolle).
Roles: Zwei Quellen:
users.roles(jsonb, global) — z.B.Sysadmin, gilt überall.tenant_memberships.roles— z.B.Admin, nur für diesen Tenant.
Werden beim Login zu einem roles-Anspruch im JWT gemerged. Im
Handler: ctx.user.roles ist das vereinigte Set.
Field-Access: Beispiel users.phoneNumber — nur Admins lesen, alle
schreiben (für Self-Service).
Beispiel:
import { createTextField } from "@kumiko/framework/engine";
createTextField({ // Read: nur Admins sehen das Feld in der Liste // Write: alle (Self-Service via Settings-Page) access: { read: { roles: ["Admin", "Sysadmin"] }, write: { roles: ["Admin", "User"] }, },});
// Handler-Level Accessr.writeHandler({ qn: "users:create", access: { roles: ["Admin", "Sysadmin"] }, handler: async (ctx, payload) => { /* ... */ },});
// Public-Endpoint für Status-Pager.queryHandler({ qn: "publicstatus:components:list", access: { roles: ["anonymous"] }, handler: async (ctx) => ctx.db.list("component", { where: { showOnPage: true } }),});Recipes: samples/recipes/access-control,
samples/recipes/ownership,
samples/recipes/field-access ·
Plan-Snapshot: architecture/permissions.
Siehe auch
| Wo finde ich… | Hier |
|---|---|
| Lauffähige Beispiele pro Konzept | Recipes |
| Fertige Features (Auth, Audit, Notifications, …) | Bundled-Features |
| Deployment + Hosting | Platform |
Pattern-Reference (r.entity, r.hook, …) | Patterns |
| Error-Reference (Reason-Codes) | Errors |
| Tiefen-Design-Docs (intern) | Architecture (Plan-Snapshots) |