// Generic migration runner (#51): version-gated, idempotent, resumable,
// fail-stop. Mocked version store + migrations — no live corestore.
import test from 'brittle'
import { runMigrations } from '../lib/services/migrate/runner.js'
// A fake persisted version + a migration set that records what ran.
function harness (start = 0) {
let version = start
const order = []
const mk = (v, name, fn) => ({ version: v, name, up: async (ctx) => { order.push(v); if (fn) await fn(ctx) } })
return {
order,
getVersion: async () => version,
setVersion: async (v) => { version = v },
currentVersion: () => version,
mk
}
}
test('runs only pending migrations, in order, bumping the version', async (t) => {
const h = harness(0)
const r = await runMigrations({
getVersion: h.getVersion, setVersion: h.setVersion,
migrations: [h.mk(2, 'b'), h.mk(1, 'a'), h.mk(3, 'c')] // unsorted on purpose
})
t.alike(h.order, [1, 2, 3], 'ran in version order')
t.alike(r, { startVersion: 0, version: 3, ran: [1, 2, 3] })
t.is(h.currentVersion(), 3, 'version persisted to latest')
})
test('version-gated: skips migrations <= current version', async (t) => {
const h = harness(2) // store already at v2
const r = await runMigrations({
getVersion: h.getVersion, setVersion: h.setVersion,
migrations: [h.mk(1, 'a'), h.mk(2, 'b'), h.mk(3, 'c')]
})
t.alike(h.order, [3], 'only v3 ran (1 and 2 already applied)')
t.alike(r.ran, [3])
})
test('idempotent: re-running at the latest version does nothing', async (t) => {
const h = harness(0)
const migs = [h.mk(1, 'a'), h.mk(2, 'b')]
await runMigrations({ getVersion: h.getVersion, setVersion: h.setVersion, migrations: migs })
h.order.length = 0
const r = await runMigrations({ getVersion: h.getVersion, setVersion: h.setVersion, migrations: migs })
t.alike(h.order, [], 'nothing ran on the second pass')
t.alike(r.ran, [])
t.is(r.version, 2)
})
test('fail-stop + resumable: a throwing up aborts the chain, version stays put', async (t) => {
const h = harness(0)
const boom = { version: 2, name: 'boom', up: async () => { h.order.push(2); throw new Error('kaboom') } }
const migs = [h.mk(1, 'a'), boom, h.mk(3, 'c')]
await t.exception(() => runMigrations({ getVersion: h.getVersion, setVersion: h.setVersion, migrations: migs }), /kaboom/)
t.is(h.currentVersion(), 1, 'stopped at v1 (v2 failed, v3 never ran)')
t.alike(h.order, [1, 2], 'v1 ran, v2 attempted, v3 not reached')
// Resume: fix v2, re-run → resumes from v2 (v1 skipped as applied).
h.order.length = 0
const fixed = [h.mk(1, 'a'), h.mk(2, 'boom-fixed'), h.mk(3, 'c')]
const r = await runMigrations({ getVersion: h.getVersion, setVersion: h.setVersion, migrations: fixed })
t.alike(h.order, [2, 3], 'resumed from v2; v1 not re-run')
t.is(r.version, 3)
})
test('rejects duplicate target versions + malformed migrations', async (t) => {
const h = harness(0)
await t.exception(() => runMigrations({ getVersion: h.getVersion, setVersion: h.setVersion, migrations: [h.mk(1, 'a'), h.mk(1, 'dup')] }), /duplicate/)
await t.exception(() => runMigrations({ getVersion: h.getVersion, setVersion: h.setVersion, migrations: [{ version: 1 }] }), /needs/)
})
test('passes ctx to each migration', async (t) => {
const h = harness(0)
let seen = null
await runMigrations({
getVersion: h.getVersion, setVersion: h.setVersion,
ctx: { corestore: 'CS', orgs: 'ORGS' },
migrations: [{ version: 1, name: 'x', up: async (ctx) => { seen = ctx } }]
})
t.alike(seen, { corestore: 'CS', orgs: 'ORGS' })
})