Schemas as data
A Kumiko entity is a configuration object — a value, fully serialisable, with no
methods on it. There is no Order class, no extends Model, no decorator
machinery. The entity definition is the entity, and the framework does everything else.
export const incidentEntity = createEntity({ fields: { title: createTextField({ required: true, sortable: true, searchable: true }), severity: createSelectField({ options: ["low", "medium", "high", "critical"] as const, default: "medium", filterable: true, }), spentMinutes: createNumberField({ sortable: true }), description: createTextField({ multiline: { rows: 6 } }), },});
r.entity("incident", incidentEntity);This is a value. You can JSON.stringify it, you can hash it, you can hand it to an
AST walker, you can store it in a database, and you can hand it to a designer or an
AI builder for editing. None of those things would be true of a class hierarchy.
Everything else in the system is derived from this object.
What gets derived
Section titled “What gets derived”From one entity definition, Kumiko produces:
| Output | Where it lives |
|---|---|
| A Drizzle table (with columns, types, indexes, FKs) | DB layer, used by the CrudExecutor |
| A Zod schema for validation | Pipeline, runs before any handler |
| A list ViewModel (columns, sort, filter, pagination) | Headless layer, fed to the renderer |
| An edit ViewModel (fields, sections, validation messages) | Headless layer, fed to the renderer |
| Search index documents (per-row) | Meilisearch indexer, only searchable: true fields |
i18n key conventions (<feature>:entity:<entity>:field:<name>) | Translations layer |
| Default access rules (read/write per role, per field) | Pipeline access check |
Adding a new field to the entity adds a column to the table, a key to the Zod schema, a column to the list, a control to the form, an index entry to search, an i18n key to look up, and a default access rule — in one change. Removing the field undoes all of them.
This is the practical meaning of “schema-driven UI”: there is one source of truth, and it is the schema. There is no separate form definition that needs to be kept in sync, no list configuration that drifts, no Zod schema that gets a field added to it weeks after the column appeared. They are all the same value, and they are all read at boot time.
Why values, not classes
Section titled “Why values, not classes”A class is opaque to anything that did not write it. You cannot enumerate its instance methods reliably. You cannot serialise it without losing behaviour. You cannot diff two classes meaningfully. Most importantly, you cannot have a tool — a designer surface, an AI feature builder, a code-mod script — read it, edit it, and write it back.
A configuration object can do all of those.
The constraint pays off in three places:
- Boot validation. The framework can walk the entity tree at boot, check that every
referencefield points at a registered entity, that everyselectField’s default is in itsoptions, that everysearchablefield has a renderable representation, and so on. Misconfigurations crash boot, not production. - Type inference. Field factories like
createSelectField({ options: ["low", "high"] as const })infer the option type from the array, so your handler getsseverity: "low" | "high"automatically. There are noanycasts in the chain; there are also no enums, becauseas constdoes the work without a separate declaration. - External tooling. A designer can render the schema as a form, let a non-developer edit it, and write the changed schema back to the file with an AST patch. An AI builder can generate a candidate schema from a natural-language description and diff it against the current one. Both work because the schema is a syntactically regular value, not a free-form program.
This is also the reason Kumiko features are written inline — r.entity(name, { fields: { … } }) directly in the feature body, not via a builder helper that
constructs the entity at runtime. Tools that read the AST need the literal there.
The field types
Section titled “The field types”Every field has a type, and the type fixes both the DB column and the default UI control. The set is small on purpose:
| Type | Stored as | Default UI | Notes |
|---|---|---|---|
text | text (or varchar with maxLength) | input or textarea | multiline, searchable, maxLength |
number | integer or numeric | numeric input | min, max, decimal places |
boolean | boolean | switch | |
date | date | date picker | |
timestamp | timestamptz (UTC) | datetime picker | rendering uses ctx.tz |
select | text | dropdown | typed options via as const |
reference | <target>_id foreign key | combobox with lookup | resolves to display label at render |
money | integer (minor units) | money input | currency from app config |
embedded | jsonb | nested form section | typed shape |
file, image, files, images | text (storage key) | upload control | provided by core-files |
encrypted | text (ciphertext) | masked input | provided by core-secrets |
Cross-cutting capabilities — custom user-defined fields per tenant, tags, comments, audit trails, soft delete — are not in this list. They arrive through registrar extensions (see Features and composition), so the core list stays small and the extension surface stays open.
Computed values are not a field type
Section titled “Computed values are not a field type”A field that is “title plus author name” or “sum of line totals” is a recurring need.
Kumiko deliberately does not model these as a computed field type. Two reasons:
- Within a row, a
preSavehook computes derived columns from changed inputs and writes them. The result is stored, indexable, and sortable. - Across rows (an invoice’s
totalNetfrom its line items), apostSavehook on the child entity recomputes and writes the parent. The dependency is explicit and visible.
Both fall out of the existing hook mechanism. Adding computed as a first-class field
type would require dependency tracking, recomputation triggers, cross-entity reverse
lookups, and a separate access concept for “you cannot write this”. The hook approach
costs five lines and is debuggable. Kumiko picks the cheaper path until the sample apps
prove the abstraction earns its complexity.
Reference data: when “the data is the code”
Section titled “Reference data: when “the data is the code””Some data isn’t user-input — it ships with the app. Country codes, currencies, vehicle
types, severity levels that are common across tenants. Kumiko expresses this as
r.referenceData(entity, rows):
r.entity("country", { fields: { code: createTextField({ required: true }), name: createTextField({ required: true }), },});
r.referenceData("country", [ { code: "DE", name: "Germany" }, { code: "AT", name: "Austria" }, { code: "CH", name: "Switzerland" },]);At boot, the framework upserts the rows. New rows are inserted. Renamed rows are updated. Rows removed from the code are left in the database (something might reference them). Tenants cannot edit reference data through the UI — to change it, you edit the code and redeploy.
This is the same idea as schemas being values, applied one level out. The ground truth is the source file; the database is a cache that gets reconciled on every boot.
What this gives you
Section titled “What this gives you”The schema-as-data choice is not a stylistic preference. It is the precondition for three things Kumiko cares about:
- A stable AST surface that designer tools, AI builders, and code-mod scripts can read and write without inventing a parallel representation.
- One change for one concept — adding a field is one edit, not seven across DDL, Zod, the form, the list, and the i18n bundle.
- Boot-time validation of the whole feature graph, so misconfiguration is a startup error rather than a runtime surprise.
Each of those compounds. None of them survive the move to opaque classes.
Live example
Section titled “Live example”A typed entity definition — one source from which the framework derives the table, the validation, and the UI:
export const taskEntity = createEntity({ table: "read_sample_tasks", fields: { title: createTextField({ required: true }), description: createTextField(), // sortable: true so the integration test can exercise list-with-sort. status: createTextField({ sortable: true }), isArchived: createBooleanField({ default: false }), }, softDelete: true,});Full source: samples/recipes/basic-entity.
See also
Section titled “See also”- Inline authoring — why entity
definitions stay as literal AST inside
defineFeature. - Events and projections — how state changes flow from the schema through the event log.