Schemas as data
In plain words
Section titled “In plain words”Kumiko entities are plain configuration values, not classes. An entity lists fields, types, and options. The framework derives database and validation metadata from that definition. Declarative list and edit screens then refer to the same fields instead of defining a second data shape.
Because the definition is data, tools can inspect and edit it without running the application. The field types are deliberately limited. More specialised behaviour belongs in explicit handlers or derived values rather than in a new general-purpose field type.
Why it exists
Section titled “Why it exists”A Kumiko entity is a typed configuration value rather than an active model
instance. There is no Order class, no extends Model, and no decorator
machinery. Most of the definition is JSON-safe metadata; explicit functions such
as derived-value or anonymization callbacks stay on the server side.
export const incidentEntity = createEntity({ fields: { title: createTextField({ required: true, sortable: true, searchable: true, personal: false, reason: "is_business_data" }), severity: createSelectField({ options: ["low", "medium", "high", "critical"] as const, default: "medium", filterable: true, }), spentMinutes: createNumberField({ sortable: true }), description: createTextField({ multiline: { rows: 6 }, personal: false, reason: "is_business_data" }), },});
r.entity("incident", incidentEntity);Tools can walk this regular source shape, project the JSON-safe parts into the client app schema, and edit the definition through an AST patch. Runtime callbacks remain ordinary TypeScript functions and are not serialized to the browser.
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 SQL table (columns, types, indexes, FKs) | DB layer, used by the CrudExecutor |
| Zod schemas for standard CRUD payloads | Pipeline, runs before generated handlers |
| Field metadata available to declarative list and edit screens | App schema, 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 |
| Declared field and entity access maps | Pipeline access check |
Adding a new field updates storage and validation metadata. A declarative screen can then include that field by name, search can index it when marked searchable, and the renderer resolves its conventional translation key. Removing the field makes stale screen references fail boot validation.
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 stored field model or handler schema that drifts. Screens still choose their columns and layout explicitly, but boot checks those references against the entity.
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 why feature registration stays visible. A standard app keeps the
entity value in schema/ and registers it with r.entity(name, entity) in
feature.ts. Tools can follow that regular source structure without executing
a runtime builder.
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 | input or textarea | multiline, searchable, maxLength validation |
number | integer or double precision | numeric input | integer, min, max |
decimal | numeric(precision, scale) | decimal input | fixed precision and scale |
bigInt | bigint read as a safe JS number | numeric input | large counters and byte sizes |
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 | UUID column or JSON array | combobox with lookup | resolves to a display label at render |
money | bigint minor units plus currency column | money input | currency from field source or entity default |
embedded / embedded list | jsonb | nested object or list editor | typed shape |
file, image, files, images | storage handle | upload control | provided by file features |
locatedTimestamp | timestamp plus IANA zone | date-time and zone picker | preserves the chosen wall-clock time |
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.
Stored and read-time derived values
Section titled “Stored and read-time derived values”A field that is “title plus author name” or “sum of line totals” is a recurring need.
Kumiko distinguishes stored values from read-time derivedFields:
derivedFieldscompute display values for entity list rows. They are not stored, do not appear in entity detail automatically, and do not support server-side sort, filter, or search.- Within a row, a
preSavehook computes a stored column from changed inputs. The result can be indexed, filtered, and sorted. - 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.
Use derivedFields for values that depend only on the row and the supplied
read clock. Use hooks or projections when the value must be stored or depends
on other rows.
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):
const catalog = defineFeature("catalog", (r) => { const country = r.entity("country", { fields: { code: createTextField({ required: true, personal: false, reason: "is_business_data" }), name: createTextField({ required: true, personal: false, reason: "is_business_data" }), }, });
r.referenceData(country, [ { code: "DE", name: "Germany" }, { code: "AT", name: "Austria" }, { code: "CH", name: "Switzerland" }, ]);});The declaration is consumed by seedReferenceData, which an app or dev setup
invokes with the registry definitions and entity tables. New rows are inserted,
changed rows are updated, and rows removed from source are left in the database
because other rows may reference them. Reference data is stored under the
system tenant so every tenant can read it. Changing the source alone has no
effect unless the app runs that seeding step.
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:
isArchived: createBooleanField({ default: false }), }, softDelete: true,});
const editorWrite = { access: { roles: ["Admin", "User"] } } as const;const adminWrite = { access: { roles: ["Admin"] } } as const;const openRead = { access: { openToAll: { reason: "any signed-in user may list and view tasks; writes are still gated by the roles below", }, },} as const;
export const taskFeature = defineFeature("tasks", (r) => { r.crud("task", taskEntity, {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.