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.
How it works
Section titled “How it works”Two storage layers:
field-definition— entity managed by the bundle. Holds the per-tenant spec:entityName,fieldKey,type,required,searchable,fieldAccess. CRUD is exposed undercustom-fields:write:define-tenant-fieldandcustom-fields:write:define-system-field.customFieldsjsonb column on the host entity — holds the values per row. You add it to your entity viacustomFieldsField(). A bundled multi-stream projection (MSP) consumescustomField.set/customField.clearedevents 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.
No runtime DDL
Section titled “No runtime DDL”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.
Scopes: system + tenant
Section titled “Scopes: system + tenant”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.
Event-Sourcing model
Section titled “Event-Sourcing model”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.
Recipe
Section titled “Recipe”samples/recipes/custom-fields-basic
walks the full define → set → read roundtrip in ~30 lines of feature
code.
Example
Section titled “Example”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.
| Action | Event type emitted | aggregate-type |
|---|---|---|
define-tenant-field / define-system-field | field-definition.created | field-definition |
delete-tenant-field / delete-system-field | custom-fields:event:field-definition-deleted | field-definition |
set-custom-field | custom-fields:event:custom-field-set | host entity (e.g. property) |
clear-custom-field | custom-fields:event:custom-field-cleared | host 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);Access control
Section titled “Access control”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).
Compliance & DSGVO
Section titled “Compliance & DSGVO”Three opt-in helpers cover the DSGVO surface for user-owned host entities:
Export (Art. 15 + 20)
Section titled “Export (Art. 15 + 20)”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 });});Forget (Art. 17)
Section titled “Forget (Art. 17)”Same wiring also adds a delete hook. Behavior depends on the data-retention strategy resolved for the host entity:
| Strategy | Action |
|---|---|
delete | no-op — the host entity’s own user-data-rights hook removes the row, jsonb travels with it |
anonymize | only 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 }, ...}Retention sweep
Section titled “Retention sweep”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 appawait runCustomFieldsRetention({ db, tenantId, entityName: "session", entityTable: sessionTable, now: Temporal.Now.instant(),});// → { rowsScanned, rowsUpdated, removalsByFieldKey: { lastActivity: 14 } }| Strategy | Effect on jsonb |
|---|---|
delete | key removed |
anonymize | key 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.
When not to use custom fields
Section titled “When not to use custom fields”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.
See also
Section titled “See also”- Bundled-features overview
- Recipe
custom-fields-basic r.useExtension— the pattern custom-fields hooks intor.extendsRegistrar— how the bundle exposes its extension surface