Custom fields
Let a tenant add their own fields to your entity at runtime — without
writing a migration, without rebuilding your handler, without a code
change. The recipe wires a property entity into the custom-fields
bundle and drives a full define → set → read roundtrip.
The result: the tenant defines internalNumber: text, sets it on a
property, and reads it back flattened onto the row — looking exactly
like a first-class column.
What it shows
Section titled “What it shows”customFieldsField()— a jsonb column factory that holds the per-row custom field values. Add it to any entity you want custom-field-capable.wireCustomFieldsFor(r, entityName, table)— wires the bundle’s projection: a multi-stream projection consumescustomField.set/.clearedevents and writes them into the jsonb column, and an entity-postQuery hook flattens the jsonb onto the row at read-time.- No migrations at runtime — the schema for custom fields lives
in two bundled tables (
field-definitionfor the spec, jsonb on the host row for the values). Defining a field is a write, not a DDL. - Stammfeld-look on the response — reads return
{ id, name, internalNumber: "X-2042" }. The consumer can’t tell which fields are first-class and which are custom.
Feature composition
Section titled “Feature composition”custom-fields → core bundle: events + write-handlers + MSP + field-definition entityproperty-management → our feature: opts the `property` entity into custom-fields via wireCustomFieldsForThe property-management feature r.requires("custom-fields") — the
bundle is non-optional for this recipe because the wired entity would
otherwise have an empty jsonb column and no way to write into it.
- Tenant admin calls
custom-fields:write:define-tenant-fieldto declareinternalNumber: textonproperty. - App code creates a
propertyrow viaproperty-management:write:property:create. - Tenant admin calls
custom-fields:write:set-custom-fieldwith theentityId,fieldKey: "internalNumber",value: "X-2042". - The bundle’s MSP consumes the
customField.setevent and writes the value into the property row’scustomFieldsjsonb column. - The next
property-management:query:property:listreturns the row withinternalNumber: "X-2042"flattened onto the response — the entity-postQuery hook merges the jsonb keys onto the row root.
When to reach for it
Section titled “When to reach for it”You ship a SaaS where every tenant wants one or two of their own
columns — internal IDs, vendor names, custom flags — and you don’t
want to either ship them all as extraFields1..extraFields5 or push
the tenant to a request-a-feature queue. Custom fields cover the
single-or-handful-of-extra-fields case without engineering
involvement.
If you need the field to drive business logic (if vipFlag then ...),
make it a first-class field instead — see basic-entity.
The integration test under src/__tests__/ walks two scenarios:
- Tenant defines a text field, sets a value, reads it back flat.
- Tenant defines a number field, sets it, reads it back with the number type preserved.
bun kumiko test integration samples/custom-fields-basicSource
Section titled “Source”The feature is ~80 lines (src/feature.ts). The integration test is
~110 lines (src/__tests__/feature.integration.test.ts).
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// kumiko-feature-version: 1// Custom-Fields Basic Sample//// Shows how a feature opts a tenant-owned entity into the custom-fields// extension: tenants can define their own fields at runtime, set values// per row, and read them back flattened onto the entity — without writing// a single migration or extra handler.//// Flow:// 1. App-author defines a `property` entity and wires it via// `wireCustomFieldsFor(r, "property", propertyTable)`.// 2. A tenant admin defines a field at runtime// (e.g. `internalNumber: text`) via the bundle's CRUD.// 3. Values are written via `custom-fields:write:set-custom-field`,// not through the host entity's own write handler.// 4. Reads return the field flattened onto the row — looks like a// first-class column. The flattening is an entity-level postQuery// hook (registered by wireCustomFieldsFor), so it fires for ANY// query whose name maps to this entity — including the hand-written// `property:list` below.
import { customFieldsField, wireCustomFieldsFor,} from "@cosmicdrift/kumiko-bundled-features/custom-fields";import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";import { createEntity, defineFeature } from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";
// --- Entity ---//// The field shape is declared as a plain object literal so the feature is// self-describing Object-Form (what the AI/Designer should emit). The same// literal is passed inline to `r.entity` below. `{ type: "jsonb" }` is// exactly what `customFieldsField()` produces — the jsonb column the// bundle's projection writes into; without it `wireCustomFieldsFor` has// nowhere to land the values.
export const propertyEntity = createEntity({ table: "read_sample_cf_properties", fields: { name: { type: "text", required: true, maxLength: 200 }, customFields: customFieldsField(), },});
export const propertyTable = buildEntityTable("property", propertyEntity);
function propertyExecutor() { return createEventStoreExecutor(propertyTable, propertyEntity, { entityName: "property" });}
// --- Feature ---
export const propertyFeature = defineFeature("property-management", (r) => { r.requires("custom-fields");
r.entity("property", { table: "read_sample_cf_properties", fields: { name: { type: "text", required: true, maxLength: 200 }, customFields: { type: "jsonb" }, }, });
// Opt this entity into the custom-fields extension. This registers the // multi-stream projection (MSP) that consumes customField.set / .cleared // events and writes them into the `customFields` jsonb column, plus the // entity postQuery hook that flattens the jsonb onto the row at read-time. wireCustomFieldsFor(r, "property", propertyTable);
r.writeHandler({ name: "property:create", schema: z.object({ id: z.string(), name: z.string() }), access: { roles: ["TenantAdmin"] }, handler: async (event, ctx) => propertyExecutor().create( { id: event.payload.id, name: event.payload.name, customFields: {} }, event.user, ctx.db, ), });
// List query. Named `property:list` so it maps to the `property` entity // (colon convention) — that mapping is what lets the entity postQuery // hook from wireCustomFieldsFor flatten customFields onto each row. The // handler itself just reads the tenant-scoped read table; ctx.db is // already tenant-scoped. r.queryHandler({ name: "property:list", schema: z.object({}), access: { roles: ["TenantAdmin"] }, handler: async (_query, ctx) => { const rows = await ctx.db.selectMany(propertyTable); return { rows }; }, });});📄 On GitHub: samples/recipes/custom-fields-basic/src/feature.ts