Skip to content

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.

  • 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 consumes customField.set / .cleared events 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-definition for 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.
custom-fields → core bundle: events + write-handlers + MSP +
field-definition entity
property-management → our feature: opts the `property` entity into
custom-fields via wireCustomFieldsFor

The 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.

  1. Tenant admin calls custom-fields:write:define-tenant-field to declare internalNumber: text on property.
  2. App code creates a property row via property-management:write:property:create.
  3. Tenant admin calls custom-fields:write:set-custom-field with the entityId, fieldKey: "internalNumber", value: "X-2042".
  4. The bundle’s MSP consumes the customField.set event and writes the value into the property row’s customFields jsonb column.
  5. The next property-management:query:property:list returns the row with internalNumber: "X-2042" flattened onto the response — the entity-postQuery hook merges the jsonb keys onto the row root.

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.
Terminal window
bun kumiko test integration samples/custom-fields-basic

The feature is ~80 lines (src/feature.ts). The integration test is ~110 lines (src/__tests__/feature.integration.test.ts).


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