Skip to content

Custom fields

A bundled feature that lets a tenant add their own fields to your entities at runtime — without writing a migration, without rebuilding your handler, without a code change. Self-service tenant customization for the cases your vendor schema doesn’t cover.

Status: ✅ Stable

What: A bundle that turns any entity into a custom-field host. Tenants define fields via CRUD (internalNumber: text, vipFlag: boolean, creditLimit: money), set values per row, and read them back flattened onto the entity — looking exactly like first-class columns.

When you 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 extraField1..extraField5 or push tenants to a request-a-feature queue.

Two storage layers:

  • field-definition — entity managed by the bundle. Holds the per-tenant spec: entityName, fieldKey, type, required, searchable, fieldAccess. CRUD is exposed under custom-fields:write:define-tenant-field and custom-fields:write:define-system-field.
  • customFields jsonb column on the host entity — holds the values per row. You add it to your entity via customFieldsField(). A bundled multi-stream projection (MSP) consumes customField.set / customField.cleared events and writes the values into the jsonb column.

At read-time an entity-postQuery hook flattens the jsonb onto the row root, so consumers see { id, name, internalNumber: "X-2042" } instead of a nested object.

Custom fields use one jsonb column instead of one column per defined field — exactly because runtime ALTER TABLE is operationally hostile (locks on large tables, non-transactional, breaks the boot-time-migration convention). Defining a field is a write, not a DDL.

A field can be defined at two scopes:

  • System scope (SystemAdmin) — applies to all tenants. Use this for vendor-wide compliance fields.
  • Tenant scope (TenantAdmin) — applies to that tenant only.

Pro (entityName, fieldKey) only one definition may exist — either system or tenant. The bundle rejects conflicts with a fieldKey_conflict error.

customField.set and customField.cleared are events on the host aggregate’s stream (property@prop-123, not a separate custom-field-stream). The host aggregate’s apply ignores them — they don’t change Stammfeld-state. A dedicated MSP consumes them and writes the jsonb-projection.

expectedVersion is not checked on customField.set — Last-Wins on concurrent writes to the same field. History keeps every event for audit.

samples/recipes/custom-fields-basic walks the full define → set → read roundtrip in ~30 lines of feature code.

import { runDevApp } from "@cosmicdrift/kumiko-dev-server";
import { createCustomFieldsFeature } from "@cosmicdrift/kumiko-bundled-features/custom-fields";
import { propertyFeature } from "./property-feature";
await runDevApp({
features: [
createCustomFieldsFeature(),
propertyFeature,
],
});

Your feature opts an entity in via customFieldsField() on the entity and wireCustomFieldsFor on the registrar:

import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
import {
customFieldsField,
wireCustomFieldsFor,
} from "@cosmicdrift/kumiko-bundled-features/custom-fields";
const propertyEntity = createEntity({
table: "read_properties",
fields: {
name: createTextField({ required: true }),
customFields: customFieldsField(), // ← jsonb column for the values
},
});
const propertyTable = buildEntityTable("property", propertyEntity);
export const propertyFeature = defineFeature("property-management", (r) => {
r.requires("custom-fields");
r.entity("property", propertyEntity);
wireCustomFieldsFor(r, "property", propertyTable); // ← wires MSP + flatten-hook
// your own write/query handlers …
});

A tenant admin then defines a field at runtime:

// HTTP: custom-fields:write:define-tenant-field
{
"entityName": "property",
"fieldKey": "internalNumber",
"serializedField": { "type": "text" },
"required": false,
"searchable": false,
"displayOrder": 0
}

…and sets a value on a specific row:

// HTTP: custom-fields:write:set-custom-field
{
"entityName": "property",
"entityId": "<property-uuid>",
"fieldKey": "internalNumber",
"value": "X-2042"
}

The next list-query for property returns the row with internalNumber: "X-2042" flattened onto the root.

Every custom-field write goes through the event-store like any other entity write — no extra wiring required. The audit bundle reads the events table directly, so it picks up custom-fields out of the box, tenant-isolated.

ActionEvent type emittedaggregate-type
define-tenant-field / define-system-fieldfield-definition.createdfield-definition
delete-tenant-field / delete-system-fieldcustom-fields:event:field-definition-deletedfield-definition
set-custom-fieldcustom-fields:event:custom-field-sethost entity (e.g. property)
clear-custom-fieldcustom-fields:event:custom-field-clearedhost entity

AuditQueries.list filters by aggregateType, aggregateId, eventType, userId, from/to — same surface as for first-class entity events.

import { AuditQueries } from "@cosmicdrift/kumiko-bundled-features/audit";
const res = await dispatcher.query(AuditQueries.list, {
aggregateType: "property",
eventType: "custom-fields:event:custom-field-set",
}, admin);

Two layers, both opt-in:

Handler-level RBAC (always on): set/clear-custom-field requires TenantAdmin or TenantMember. define-tenant-field requires TenantAdmin; define-system-field requires SystemAdmin.

Per-field write-gate (opt-in via fieldDefinition.serializedField.fieldAccess.write): when set, the calling user must hold at least one of the listed roles in addition to the handler-level RBAC. Rejects with unprocessable + reason: "field_access_denied".

// Tenant-admin defines a field that only Sales can write
{
"entityName": "customer",
"fieldKey": "negotiatedDiscount",
"serializedField": {
"type": "number",
"fieldAccess": { "write": ["Sales"] }
},
...
}

When fieldAccess.write is absent or empty, the handler-level RBAC is the only gate (back-compat).

Three opt-in helpers cover the DSGVO surface for user-owned host entities:

wireCustomFieldsUserDataRightsFor(r, opts) registers a EXT_USER_DATA export hook on the host entity. Every row owned by the user contributes its full customFields jsonb into the export bundle under <entity>.customFields — sensitive or not, completeness wins.

import {
wireCustomFieldsFor,
wireCustomFieldsUserDataRightsFor,
} from "@cosmicdrift/kumiko-bundled-features/custom-fields";
defineFeature("comments", (r) => {
r.entity("comment", commentEntity);
wireCustomFieldsFor(r, "comment", commentTable);
wireCustomFieldsUserDataRightsFor(r, {
entityName: "comment",
entityTable: commentTable,
userIdColumn: "inserted_by_id", // column that holds the owner-user-id
});
});

Same wiring also adds a delete hook. Behavior depends on the data-retention strategy resolved for the host entity:

StrategyAction
deleteno-op — the host entity’s own user-data-rights hook removes the row, jsonb travels with it
anonymizeonly serializedField.sensitive: true keys are stripped from the jsonb; non-sensitive keys stay
// Sensitive field — gets stripped on user-forget with anonymize-strategy
{
"entityName": "comment",
"fieldKey": "email",
"serializedField": {
"type": "text",
"sensitive": true
},
...
}

runCustomFieldsRetention({ db, tenantId, entityName, entityTable, now }) walks the host entity’s rows once and strips/nulls customField values whose host-row modified_at is older than the per-field retention.keepFor. Designed to run inside a daily cron alongside the data-retention bundle’s own cleanup.

// Define field with retention policy
{
"entityName": "session",
"fieldKey": "lastActivity",
"serializedField": {
"type": "date",
"retention": { "keepFor": "90d", "strategy": "delete" }
},
...
}
// Cron in your app
await runCustomFieldsRetention({
db, tenantId,
entityName: "session",
entityTable: sessionTable,
now: Temporal.Now.instant(),
});
// → { rowsScanned, rowsUpdated, removalsByFieldKey: { lastActivity: 14 } }
StrategyEffect on jsonb
deletekey removed
anonymizekey stays, value set to null (schema shape preserved)

Reference timestamp is the host row’s modified_at, not a per-value timestamp — value-level granularity would need a breaking jsonb shape change.

createCustomFieldsFeature({ fieldDefinitionLimitPerTenant: N }) installs a quota-aware define-tenant-field handler. Once a tenant already holds >= N definitions, the next call rejects with unprocessable + reason: "cap_exceeded" plus { capName, limit, current } in the error details.

const customFields = createCustomFieldsFeature({
fieldDefinitionLimitPerTenant: 50, // your tier-pricing call
});

Cap is per-tenant total (not per host entity) — the natural unit for tier-pricing (free: 5, pro: 50, enterprise: ∞ is your app-author choice). Without the option, behavior is unchanged.

The check uses a COUNT(*) against the read-projection rather than the cap-counter bundle, because the projection is the authoritative source (soft-deleted rows already drop out) and we don’t need rolling-window semantics. If pricing wants monthly-roll allowances later, the helper boundary in lib/quota.ts makes that swap one file.

A custom field is read-flat data, not behavior. Don’t reach for it when:

  • You need it to drive business logic (if vipFlag then …) — make it a first-class field instead. Custom-field changes don’t emit per-field hooks (yet); you can’t easily wire workflows on them.
  • You need it indexed for cross-entity DB queries — values live in a jsonb column. The bundle wires Meilisearch for the tenant-facing search path, but Postgres queries against custom fields use GIN-jsonb-ops (slower than first-class columns for large tables).
  • The field is the same for every tenant — define it on the entity. Custom fields are for the per-tenant variance.