Skip to content

Apply one-shot data migrations via the event store

A seed migration is a checked-in file that changes existing aggregate state once. It belongs after initial seeding when the desired state has changed. The runner records applied file IDs in kumiko_es_operations, so a later boot does not run the same file again.

no yes App boot runProdApp({ seedsDir }) Pending? compare seeds/ against kumiko_es_operations Nothing to do boot continues Dry-run validator every referenced handler-QN registered? run(ctx) systemWriteAs against your handlers Events appended the correction is now history Projection catches up Marker written separate transaction — after the body

Use this pattern when an existing record needs an event-store-backed correction, for example adding a role to existing memberships. The normal idempotent seed handles initial data and skips rows that already exist; it does not express this kind of change.

Do not use a seed migration for:

  • initial data on a new database. Use the feature’s declarative or boot-seed mechanism.
  • SQL schema changes. Use kumiko schema generate and kumiko schema apply.
  • reports or read-only maintenance. Use a query, route, or separate operator script.
  • Understand Events and projections.
  • Have an app bin/main.ts that calls runProdApp.
  • Have the target write handler registered and accessible to the system actor.
  • Have a local database or smoke environment where the migration can be tested.

Point runProdApp at the directory containing the files:

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

The runner scans .ts, .mts, and .js files, sorts their IDs by filename, checks kumiko_es_operations, validates inline handler QNs against the registry, and applies pending files during boot. Set KUMIKO_SKIP_ES_OPS=1 only for a recovery boot that deliberately skips all pending seed migrations.

The framework repository’s maintainer CLI can scaffold a file:

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

The command is available in the framework workspace. In an app, copy the scaffold pattern or use the app’s checked-in CLI setup; the production runtime only applies files through runProdApp({ seedsDir }).

Each file exports a default SeedMigration:

import type { SeedMigration } from "@cosmicdrift/kumiko-framework/es-ops";
export default {
description: "Add TenantAdmin to the platform admin memberships",
run: async (ctx) => {
const admin = await ctx.findUserByEmail("admin@example.com");
if (!admin) return;
for (const membership of await ctx.findMembershipsOfUser(admin.id)) {
if (membership.roles.includes("TenantAdmin")) continue;
await ctx.systemWriteAs(
"tenant:write:update-member-roles",
{
userId: admin.id,
tenantId: membership.tenantId,
roles: [...membership.roles, "TenantAdmin"],
},
membership.streamTenantId,
);
}
},
} satisfies SeedMigration;

systemWriteAs calls an existing write handler as the system actor. The write still follows the handler and event-store path; do not write the managed read table directly.

The third argument is tenantIdOverride. Pass the tenant that owns the aggregate’s event stream. findMembershipsOfUser exposes both the payload tenantId and the physical streamTenantId; they can differ when another executor originally created the aggregate. Omitting the override for a tenant-scoped aggregate can produce version_conflict because the executor looks in the wrong stream.

A normal production boot applies pending files after boot seeds and before the server starts. The runner writes a marker after the migration finishes.

With the current framework CLI, seed:status reports pending and applied files:

Terminal window
bun kumiko ops seed:status

The seed:apply command has a dry-run listing in the maintainer CLI; the standalone production bundle exposes schema and consumer operations, while seed migrations are applied by the app boot. Test the actual boot path locally against a disposable or development database.

Expected boot output includes a pending count, a dry-run handler-QN check, and a success line for each applied file. A later boot reports zero pending files.

The marker and systemWriteAs calls do not share one transaction. If a body writes several events and then throws, those successful handler calls can remain committed while the marker is absent. The next boot retries the file. Check before every write, or otherwise make the operation idempotent, so the retry does not duplicate data.

The runner applies files in filename order and stops on the first failure. Fix the failing file and retry before applying later files.

Dynamic seed imports need the source files in the runtime image. If the app has seeds/, its Dockerfile must include:

COPY --from=build --chown=app:app /app/seeds ./seeds

The production bundle’s generated Dockerfile adds this block when scaffolding detects the directory. A missing directory causes seed migrations to work locally and fail after deployment.

Mark a migration skippable: true only when you need a recovery boot:

export default {
description: "...",
skippable: true,
run: async (ctx) => {
// Fix the migration before allowing it to run again.
},
} satisfies SeedMigration;

Set KUMIKO_SKIP_ES_OPS_<SANITIZED_ID>=1 for that boot. The runner skips the file without writing an applied marker, so remove the flag after fixing the migration and test the retry. This is a recovery hatch, not the normal rollout path.