Skip to content

Schemas as data

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.

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.

From one entity definition, Kumiko produces:

OutputWhere it lives
A SQL table (columns, types, indexes, FKs)DB layer, used by the CrudExecutor
Zod schemas for standard CRUD payloadsPipeline, runs before generated handlers
Field metadata available to declarative list and edit screensApp 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 mapsPipeline 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.

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:

  1. Boot validation. The framework can walk the entity tree at boot, check that every reference field points at a registered entity, that every selectField’s default is in its options, that every searchable field has a renderable representation, and so on. Misconfigurations crash boot, not production.
  2. Type inference. Field factories like createSelectField({ options: ["low", "high"] as const }) infer the option type from the array, so your handler gets severity: "low" | "high" automatically. There are no any casts in the chain; there are also no enums, because as const does the work without a separate declaration.
  3. 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.

Every field has a type, and the type fixes both the DB column and the default UI control. The set is small on purpose:

TypeStored asDefault UINotes
texttextinput or textareamultiline, searchable, maxLength validation
numberinteger or double precisionnumeric inputinteger, min, max
decimalnumeric(precision, scale)decimal inputfixed precision and scale
bigIntbigint read as a safe JS numbernumeric inputlarge counters and byte sizes
booleanbooleanswitch
datedatedate picker
timestamptimestamptz (UTC)datetime pickerrendering uses ctx.tz
selecttextdropdowntyped options via as const
referenceUUID column or JSON arraycombobox with lookupresolves to a display label at render
moneybigint minor units plus currency columnmoney inputcurrency from field source or entity default
embedded / embedded listjsonbnested object or list editortyped shape
file, image, files, imagesstorage handleupload controlprovided by file features
locatedTimestamptimestamp plus IANA zonedate-time and zone pickerpreserves 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.

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:

  • derivedFields compute 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 preSave hook computes a stored column from changed inputs. The result can be indexed, filtered, and sorted.
  • Across rows (an invoice’s totalNet from its line items), a postSave hook 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.

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.

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.