Skip to content

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 generieren
r.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 Subscriber
r.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-Pages
await 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-Feature
const 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 payload
const 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 Tenants
r.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:

  1. @kumiko/framework/ui-types — Schema-Definitionen (EntityListScreenDefinition, EntityEditScreenDefinition, FieldDefinition).
  2. @kumiko/headless — Pure ViewModel-Builder (Schema + Daten → ListColumnViewModel/EditFieldViewModel). Plattform-agnostisch.
  3. @kumiko/renderer-web (oder renderer-rn kommend) — 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-Sort
export 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 Sections
export 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:

PatternBeispielWo gerendert
<feature>:nav.<id>assets:nav.listSidebar-Eintrag
screen:<id>.titlescreen:asset-list.titleScreen-Header
<feature>:entity:<entity>:field:<name>assets:entity:asset:field:statusForm-Label, Spalten-Header
<feature>:entity:<entity>:field:<name>:option:<value>assets:entity:asset:field:status:option:lentSelect-Cell, Dropdown-Option
<feature>:section.<id>assets:section.basicsForm-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-Event
import { 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 Access
r.writeHandler({
qn: "users:create",
access: { roles: ["Admin", "Sysadmin"] },
handler: async (ctx, payload) => { /* ... */ },
});
// Public-Endpoint für Status-Page
r.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.

See also

Where do I find…Here
Runnable examples per conceptRecipes
Ready-made features (auth, audit, notifications, …)Bundled features
Deployment + hostingPlatform
Pattern reference (r.entity, r.hook, …)Patterns
Error reference (reason codes)Errors
Internal design docsArchitecture (plan snapshots)