Skip to content

Apply one-shot data migrations via the event store

You shipped a feature, ran the seeder, things were fine. Then the domain shifted: roles: ["Admin"] should now be ["Admin", "TenantAdmin"] for the platform admin. The idempotent seeder skips existing rows — it can’t fix what’s already there. You want a one-shot correction that runs once per environment, lands in the event store, and is tracked so the next boot doesn’t repeat it.

That’s what seed-migrations are. Think drizzle-kit migrate, but for aggregate state, written as systemWriteAs calls against your own handlers.

  • You understand Events and projections and know that the truth lives in the event stream, not the read tables.
  • You have a runProdApp({ ... }) boot in your app’s entry point.
  • The handler you need to call (e.g. tenant:write:update-member-roles) exists and accepts the role "system" in its access rule.

Step 1 — opt your app into the runner by pointing it at a directory:

bin/main.ts
await runProdApp({
features: [...],
seedsDir: "./seeds",
});

Step 2 — scaffold a file (or write one by hand):

Terminal window
bunx kumiko ops seed:new fix-admin-roles
# → seeds/2026-05-20-fix-admin-roles.ts

Step 3 — fill in the body:

seeds/2026-05-20-fix-admin-roles.ts
import type { SeedMigration } from "@cosmicdrift/kumiko-framework/es-ops";
export default {
description: "ergänze TenantAdmin-Rolle für admin@publicstatus.eu",
run: async (ctx) => {
const admin = await ctx.findUserByEmail("admin@publicstatus.eu");
if (!admin) return;
for (const m of await ctx.findMembershipsOfUser(admin.id)) {
if (m.roles.includes("TenantAdmin")) continue;
await ctx.systemWriteAs(
"tenant:write:update-member-roles",
{ userId: admin.id, tenantId: m.tenantId, roles: [...m.roles, "TenantAdmin"] },
m.tenantId, // ← stream-tenant override (see gotcha below)
);
}
},
} satisfies SeedMigration;

That’s the whole API. Next boot:

[es-ops/seed-migration] 1/1 pending
[es-ops/seed-migration] dry-run ok — all referenced handler-QNs registered
[es-ops/seed-migration] ✓ 2026-05-20-fix-admin-roles (72ms) — fix admin@publicstatus.eu

A marker lands in kumiko_es_operations, two tenant-membership.updated events land in the event store, the projection picks them up. Boot after that: zero pending, nothing runs.

You can’t. The read table is a projection — replay the events and the correction is gone. The next event-store rebuild silently drops your fix and you find out three months later when the membership flips back to its old roles. Anything that should persist has to go through the event store.

The idempotent seeder runs every boot, but it skips rows it has already created. Changing it to update existing rows turns every boot into an “is the data still right?” check, and you’re now writing events that the seeder didn’t intend to. Seed-migrations isolate the one-time correction in a dated file you can read months later and know exactly what ran.

  • tenantIdOverride matters when stream-tenant ≠ payload-tenant. An aggregate lives in the stream of the tenant that first wrote it. If your app seeded all memberships from one bootstrap tenant but the payloads target different tenants, you need to pass the stream-tenant as the third arg — otherwise systemWriteAs starts a new stream and the write fails with version_conflict. Find the stream-tenant via JOIN kumiko_events e ON e.aggregate_id = m.id AND e.version = 1.
  • Handler QNs are kebab-case. Write tenant:write:update-member-roles, not updateMemberRoles. The Phase 1.5 dry-run validator catches the typo before the migration runs, but only if you actually trigger the validator (boot or local smoke).
  • Seed bodies must be idempotent. The marker is written after the body, in a separate transaction. If your body throws halfway through, the events it already wrote stay committed and the marker doesn’t — next boot retries from scratch. So: existing-check before write (if (m.roles.includes(...)) continue), not blind insert.
  • Docker images must ship the seeds/ tree. Bun bundles dynamic imports with an absolute path resolution at runtime, so the files have to exist on disk. Add COPY --from=build /app/seeds ./seeds to your runtime stage.
  • Smoke-test locally before pushing. Run bun scripts/smoke-seeds.ts (see the recipe) to load each seed module and check that every referenced handler-QN is registered. CI is not your test runner — mock-tests don’t catch QN typos.

If a migration breaks the boot and you need to ship a fix:

  1. Add skippable: true to the seed file.
  2. Set KUMIKO_SKIP_ES_OPS_<sanitized-id>=1 on the next pod.
  3. The migration is skipped, no marker is written, boot continues.
  4. Fix the file, remove the env var, next boot retries.

This is a recovery hatch, not a workflow. If you find yourself reaching for it twice, the migration probably wasn’t idempotent.

  • Seed default data for new tenants — the sibling pattern for per-tenant create-time seeding. Use that for “every new tenant gets a sample incident”, use this for “the existing admin needs an extra role”.
  • samples/recipes/seed-migration/ in the framework repo — runnable end-to-end example with two files (initial-tenants, fix-admin-roles).
  • Events and projections — why the event log is the source of truth and read tables aren’t.