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