Skip to content

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.

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

custom-fields feature preview

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):

Start with recipes-custom-fields-basic for a step-by-step walkthrough with runnable code and integration tests.

  • Requires: none
  • Activation: always on (not toggleable)