Skip to content

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.

From one entity definition, Kumiko produces:

OutputWhere it lives
A Drizzle table (with columns, types, indexes, FKs)DB layer, used by the CrudExecutor
A Zod schema for validationPipeline, 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.

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 the reason Kumiko features are written inliner.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.

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
texttext (or varchar with maxLength)input or textareamultiline, searchable, maxLength
numberinteger or numericnumeric inputmin, max, decimal places
booleanbooleanswitch
datedatedate picker
timestamptimestamptz (UTC)datetime pickerrendering uses ctx.tz
selecttextdropdowntyped options via as const
reference<target>_id foreign keycombobox with lookupresolves to display label at render
moneyinteger (minor units)money inputcurrency from app config
embeddedjsonbnested form sectiontyped shape
file, image, files, imagestext (storage key)upload controlprovided by core-files
encryptedtext (ciphertext)masked inputprovided 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.

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 preSave hook computes derived columns from changed inputs and writes them. The result is stored, indexable, and sortable.
  • 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.

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.

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:

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.