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.
Use a seed migration when
Section titled “Use a seed migration when”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 generateandkumiko schema apply. - reports or read-only maintenance. Use a query, route, or separate operator script.
Prerequisites
Section titled “Prerequisites”- Understand Events and projections.
- Have an app
bin/main.tsthat callsrunProdApp. - 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.
Configure the runner
Section titled “Configure the runner”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.
Create a migration
Section titled “Create a migration”The framework repository’s maintainer CLI can scaffold a file:
bun kumiko ops seed:new fix-admin-roles# → seeds/2026-05-20-fix-admin-roles.tsThe 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.
Apply and verify
Section titled “Apply and verify”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:
bun kumiko ops seed:statusThe 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.
Make the body retry-safe
Section titled “Make the body retry-safe”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.
Docker deployment
Section titled “Docker deployment”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 ./seedsThe 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.
Recovery
Section titled “Recovery”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.