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 { createSystemUser, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
export const seedFeature = defineFeature("seed", (r) => {
r.requires("tenant");
r.hook("postSave", "tenant", async (ctx, { id, isNew }) => {
if (!isNew) return;
const systemForTenant = createSystemUser(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.

createSystemUser builds a SessionUser with id 00000000-… (SYSTEM), the reserved role "system", and the given tenantId. Passing the new tenant’s id 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.

  • The tenantId passed to createSystemUser 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”.