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.
Prerequisites
Section titled “Prerequisites”- 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 itsaccessrule.
The code
Section titled “The code”Step 1 — opt your app into the runner by pointing it at a directory:
await runProdApp({ features: [...], seedsDir: "./seeds",});Step 2 — scaffold a file (or write one by hand):
bunx kumiko ops seed:new fix-admin-roles# → seeds/2026-05-20-fix-admin-roles.tsStep 3 — fill in the body:
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.euA 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.
Why not just UPDATE the read table
Section titled “Why not just UPDATE the read table”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.
Why not extend the idempotent seeder
Section titled “Why not extend the idempotent seeder”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.
Common gotchas
Section titled “Common gotchas”tenantIdOverridematters 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 — otherwisesystemWriteAsstarts a new stream and the write fails withversion_conflict. Find the stream-tenant viaJOIN kumiko_events e ON e.aggregate_id = m.id AND e.version = 1.- Handler QNs are kebab-case. Write
tenant:write:update-member-roles, notupdateMemberRoles. 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. AddCOPY --from=build /app/seeds ./seedsto 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.
Recovery
Section titled “Recovery”If a migration breaks the boot and you need to ship a fix:
- Add
skippable: trueto the seed file. - Set
KUMIKO_SKIP_ES_OPS_<sanitized-id>=1on the next pod. - The migration is skipped, no marker is written, boot continues.
- 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.
See also
Section titled “See also”- 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.