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