custom-fields
Tenant- and system-scoped custom field definitions with generic value storage on any host entity. Registers the field-definition entity (event-sourced CRUD via define-tenant-field, define-system-field, update-tenant-field, delete-tenant-field, delete-system-field) and two value write-handlers (set-custom-field, clear-custom-field) that emit custom-fields:event:custom-field-set / custom-fields:event:custom-field-cleared events on the host aggregate’s stream. To attach custom fields to your own entity, call wireCustomFieldsFor(r, entityName, entityTable) in the host feature — this wires the JSONB projection, postQuery flattening hook, and search-payload extension. The host entity must declare a customFieldsField() JSONB column.
Quick example
Section titled “Quick example”From recipes-custom-fields-basic — the smallest working mount:
// 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
Live preview
Section titled “Live preview”
How it fits
Section titled “How it fits”What this feature needs to run (Requires, top) and the write commands it provides (Provides, bottom).
flowchart TB
n_custom_fields["custom-fields"]
subgraph how_provides["Provides"]
n_cmd_custom_fields_write_clear_custom_field(["clear-custom-field"])
n_cmd_custom_fields_write_define_system_field(["define-system-field"])
n_cmd_custom_fields_write_define_tenant_field(["define-tenant-field"])
n_cmd_custom_fields_write_delete_system_field(["delete-system-field"])
n_cmd_custom_fields_write_delete_tenant_field(["delete-tenant-field"])
n_cmd_custom_fields_write_set_custom_field(["set-custom-field"])
n_cmd_more(["+1 more"])
end
n_custom_fields --> n_cmd_custom_fields_write_clear_custom_field
n_custom_fields --> n_cmd_custom_fields_write_define_system_field
n_custom_fields --> n_cmd_custom_fields_write_define_tenant_field
n_custom_fields --> n_cmd_custom_fields_write_delete_system_field
n_custom_fields --> n_cmd_custom_fields_write_delete_tenant_field
n_custom_fields --> n_cmd_custom_fields_write_set_custom_field
n_custom_fields --> n_cmd_more
Provides — write commands this feature registers (dispatch them through the command bus):
custom-fields:write:clear-custom-fieldcustom-fields:write:define-system-fieldcustom-fields:write:define-tenant-fieldcustom-fields:write:delete-system-fieldcustom-fields:write:delete-tenant-fieldcustom-fields:write:set-custom-fieldcustom-fields:write:update-tenant-field
Getting started
Section titled “Getting started”Start with recipes-custom-fields-basic for a step-by-step walkthrough with runnable code and integration tests.
Dependencies
Section titled “Dependencies”- Requires: none
- Activation: always on (not toggleable)