Seed default data for new tenants
A new tenant lands on an empty workspace. You want to give them something to work with: default categories, a sample incident, a welcome note. The seed has to run inside the new tenant’s scope, not the system scope, and it has to fire exactly when the tenant is created — not at boot.
Prerequisites
Section titled “Prerequisites”- You’ve read Multi-tenancy so the tenant-as-aggregate idea is clear.
- The
tenantbundled feature is loaded (auto-included inauth-mode).
The code
Section titled “The code”Hook into tenant.created and write through ctx.writeAs(SYSTEM_USER, …)
with the new tenant’s ID:
// In your seed feature's body:import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";import { SYSTEM_USER } from "@cosmicdrift/kumiko-framework/identity";
export const seedFeature = defineFeature("seed", (r) => { r.requires("tenant");
r.hook("postSave", "tenant", async (ctx, { id, isNew }) => { if (!isNew) return;
const systemForTenant = { ...SYSTEM_USER, tenantId: id };
await ctx.writeAs(systemForTenant, "incidents:create", { title: "Welcome — your first incident", severity: "low", body: "This is a sample. Edit or delete it.", });
await ctx.writeAs(systemForTenant, "incidents:create", { title: "Sample: high-severity example", severity: "high", body: "Showing how a critical incident looks.", }); }, { phase: HookPhases.inTransaction });});The hook runs inTransaction — if the tenant create rolls back, the
seed rolls back too. There is no half-seeded tenant.
SYSTEM_USER carries the role "system", which any normal access rule
either grants or ignores. Setting tenantId on the spread copy scopes
the writes into the new tenant — without it, the writes go nowhere
useful.
Note the asymmetry: handler definitions use short names
(r.writeHandler({ name: "create" }) — the framework adds the
incidents: prefix), but cross-feature calls use the qualified
name (ctx.writeAs(systemForTenant, "incidents:create", …)) because
A doesn’t get B’s prefix injected for it. This pattern shows up in
every guide that calls a foreign handler — it’s the right form.
Why not seed at boot
Section titled “Why not seed at boot”r.referenceData(...) exists for global data shipped with the app
(country codes, currencies). It runs at boot and writes once.
Tenant-specific seed data is different: it has to run per tenant
creation, in the new tenant’s scope, with the right tenantId.
A boot-time seed cannot do that — boot doesn’t know which tenants
will exist.
Common gotchas
Section titled “Common gotchas”tenantIdonSYSTEM_USERis mandatory. Without it, the cross- feature write goes through withtenantId: 0and either fails the insert (foreign key violation) or — worse — ends up in the system tenant if you have one.- Seed errors should fail the tenant create. Use
inTransaction, notafterCommit. A tenant whose seed silently failed is a half-broken tenant. - Heavy seeds belong in a job, not a hook. If you need to populate
hundreds of rows, fire a
r.jobfrom the hook and let it run after commit; the hook just records “seed pending”.
See also
Section titled “See also”- Multi-tenancy — the tenant entity and the system identity.
- Lifecycle and hooks —
inTransactionvs.afterCommitsemantics.