Recipe: Tenant-Settings
What this shows: how a per-tenant Currency + Locale default fills a
money/locale field on create — without hard-coding "EUR"/"de" as a field
literal, the mistake that cost a sibling project (phronexsis) a retrofit once
a second tenant needed a different default.
Pattern
Section titled “Pattern”import { createEntity, createMoneyField, createSelectField, createTextField, defineFeature,} from "@cosmicdrift/kumiko-framework/engine";import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
export const invoiceEntity = createEntity({ table: "read_invoices", fields: { title: createTextField({ required: true }), amount: createMoneyField({ required: true }), language: createSelectField({ options: ["en", "de", "fr"] as const }), },});
export const invoiceFeature = defineFeature("invoice", (r) => { r.entity("invoice", invoiceEntity); r.writeHandler( defineCreateWithTenantDefaults("invoice", invoiceEntity, { access: { roles: ["Admin"] }, currencyFields: ["amount"], localeField: "language", }), ); // update/delete/list/detail stay the plain defineEntity*Handler factories — // only create needs the tenant-default fill-in.});Mount createTenantSettingsFeature() alongside — it provisions the two
config keys (tenant-settings:config:currency, tenant-settings:config:locale)
that defineCreateWithTenantDefaults reads:
import { createTenantSettingsFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
const features = [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature];createTenantSettingsFeature(opts) takes currencies, defaultCurrency,
defaultLocale, write — an app with its own supported-currency list or
role vocabulary overrides those instead of forking the feature.
Why the schema needs a special case
Section titled “Why the schema needs a special case”buildInsertSchema always makes currency required inside a money field’s
value object, even when the field itself is optional — a caller literally
cannot submit { amount: 1000 } against the plain defineEntityCreateHandler.
defineCreateWithTenantDefaults swaps in its own schema (.extend() over the
same buildInsertSchema output, with just the declared currencyFields
relaxed) so the caller can omit currency, then fills it from
ctx.config(TenantSettingsConfig.currency) before delegating to the same
executor.create() the generic factory uses.
Settings-Hub
Section titled “Settings-Hub”Both keys declare mask, so they surface in the self-populating Settings-Hub
(Tenant-Audience) without a hand-written r.screen/r.nav — same mechanism
as managed-config. A TenantAdmin
changes them from Settings → Tenant → Default Currency / Default Locale.
- No tenant override yet → a new invoice gets the feature’s own default
(
"EUR"/"en", set viacreateTenantSettingsFeature()’s own defaults). - Tenant sets
currencyto"CHF"andlocaleto"de"via the Settings-Hub (orconfig:write:setdirectly). - A new invoice that doesn’t specify
amount.currency/languagepicks up"CHF"/"de"— no entity code changed, no second migration. - A caller that does specify
amount.currencystill wins — the tenant setting only fills gaps, it never overrides an explicit value.
bun test src/__tests__/feature.integration.test.tsCovers the happy path above (tenant defaults fill gaps; explicit caller
values still win). Creating without a tenant-settings mount is not a
supported fallback — mount the feature (or the recipe’s stack) first.
What’s not in this recipe
Section titled “What’s not in this recipe”- Timezone, number-format, week-start — deliberately out of scope; add them as further config keys on the same feature when a real consumer needs them, don’t speculate ahead of a use case.
- A dedicated locale-options select —
localeis a free-text ISO-639-1(-region) field validated by regex, not a fixed enum, because the framework doesn’t own which languages an app supports. An app with a fixed language list builds its owncreateTenantConfig("select", {...})key if it wants a dropdown instead ofTenantSettingsConfig.locale.
Related samples
Section titled “Related samples”- managed-config — the
mask+ Settings-Hub mechanism these keys rely on, explained in depth.
Source code
Section titled “Source code”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):
// Consuming tenant-settings: an `invoice` entity whose `amount` (money) and// `language` (locale) are auto-filled from the tenant's currency/locale// setting when the caller omits them — instead of hard-coding "EUR"/"en" on// the field, the mistake this recipe exists to prevent (solon#P19).
import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";import { createEntity, createMoneyField, createSelectField, createTextField, defineFeature,} from "@cosmicdrift/kumiko-framework/engine";
export const invoiceEntity = createEntity({ table: "read_invoices", fields: { title: createTextField({ required: true }), amount: createMoneyField({ required: true }), language: createSelectField({ options: ["en", "de", "fr"] as const }), },});
const ACCESS = { roles: ["Admin"] } as const;
export const invoiceFeature = defineFeature("invoice", (r) => { r.entity("invoice", invoiceEntity); r.writeHandler( defineCreateWithTenantDefaults("invoice", invoiceEntity, { access: ACCESS, currencyFields: ["amount"], localeField: "language", }), );});📄 On GitHub: samples/recipes/tenant-settings/src/feature.ts