Skip to content

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.

  • You’ve read Multi-tenancy so the tenant-as-aggregate idea is clear.
  • The tenant bundled feature is loaded (auto-included in auth-mode).

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.

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.

  • tenantId on SYSTEM_USER is mandatory. Without it, the cross- feature write goes through with tenantId: 0 and 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, not afterCommit. 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.job from the hook and let it run after commit; the hook just records “seed pending”.