import { tap, expect } from '@git.zone/tstest/tapbundle'; import { makeTestDb } from './helpers/services.js'; import type * as smartdata from '@push.rocks/smartdata'; import { SmartMigration, SmartMigrationError } from '../ts/index.js'; // Shared db across the file (see test.mongoledger.ts for the rationale). // Each test uses a unique ledgerName for isolation. let db: smartdata.SmartdataDb; let cleanup: () => Promise; async function setLedgerVersion(ledgerName: string, currentVersion: string): Promise { await db.mongoDb.collection('SmartdataEasyStore').updateOne( { nameId: `smartmigration:${ledgerName}` }, { $set: { nameId: `smartmigration:${ledgerName}`, data: { currentVersion, steps: {}, lock: { holder: null, acquiredAt: null, expiresAt: null }, checkpoints: {}, }, }, }, { upsert: true }, ); } tap.test('setup: connect shared db', async () => { const r = await makeTestDb('run_mongo'); db = r.db; cleanup = r.cleanup; }); tap.test('run: applies all steps from scratch', async () => { const m = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'scratch' }); const log: string[] = []; m .step('a').from('1.0.0').to('1.1.0').up(async () => { log.push('a'); }) .step('b').from('1.1.0').to('1.5.0').up(async () => { log.push('b'); }) .step('c').from('1.5.0').to('2.0.0').up(async () => { log.push('c'); }); const r = await m.run(); expect(r.stepsApplied).toHaveLength(3); expect(log).toEqual(['a', 'b', 'c']); expect(r.currentVersionAfter).toEqual('2.0.0'); expect(r.wasUpToDate).toBeFalse(); expect(r.targetVersion).toEqual('2.0.0'); }); tap.test('run: second run is a no-op', async () => { const m = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'idempotent' }); const log: string[] = []; m .step('a').from('1.0.0').to('1.1.0').up(async () => { log.push('a'); }) .step('b').from('1.1.0').to('2.0.0').up(async () => { log.push('b'); }); const r1 = await m.run(); expect(r1.stepsApplied).toHaveLength(2); expect(log).toEqual(['a', 'b']); // Re-run with a freshly-constructed runner using the same ledger. const m2 = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'idempotent' }); m2 .step('a').from('1.0.0').to('1.1.0').up(async () => { log.push('a-again'); }) .step('b').from('1.1.0').to('2.0.0').up(async () => { log.push('b-again'); }); const r2 = await m2.run(); expect(r2.wasUpToDate).toBeTrue(); expect(r2.stepsApplied).toHaveLength(0); expect(log).toEqual(['a', 'b']); // no new entries }); tap.test('run: failing step throws and persists failure', async () => { const m = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'failure' }); let aCalled = false; m .step('a').from('1.0.0').to('1.1.0').up(async () => { aCalled = true; }) .step('b').from('1.1.0').to('2.0.0').up(async () => { throw new Error('boom'); }); let caught: SmartMigrationError | undefined; try { await m.run(); } catch (err) { caught = err as SmartMigrationError; } expect(caught).toBeInstanceOf(SmartMigrationError); expect(caught!.code).toEqual('STEP_FAILED'); expect(aCalled).toBeTrue(); // Ledger should be at 1.1.0 (after step a, before failed step b). const current = await m.getCurrentVersion(); expect(current).toEqual('1.1.0'); }); tap.test('run: ctx exposes db, mongo, and step metadata', async () => { const m = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'ctx' }); let observed: { hasDb: boolean; hasMongo: boolean; stepId: string; from: string; to: string } | null = null; m.step('check-ctx').from('1.0.0').to('1.1.0').up(async (ctx) => { observed = { hasDb: !!ctx.db, hasMongo: !!ctx.mongo, stepId: ctx.step.id, from: ctx.step.fromVersion, to: ctx.step.toVersion, }; // exercise raw mongo await ctx.mongo!.collection('users_ctx_test').insertOne({ name: 'alice' }); }); await m.run(); expect(observed).not.toBeNull(); expect(observed!.hasDb).toBeTrue(); expect(observed!.hasMongo).toBeTrue(); expect(observed!.stepId).toEqual('check-ctx'); expect(observed!.from).toEqual('1.0.0'); expect(observed!.to).toEqual('1.1.0'); // verify the data the step wrote const found = await db.mongoDb.collection('users_ctx_test').findOne({ name: 'alice' }); expect(found).not.toBeNull(); }); tap.test('run: ctx.startSession works inside a step', async () => { const m = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'session' }); let sessionWasUsed = false; m.step('with-session').from('1.0.0').to('1.1.0').up(async (ctx) => { const session = ctx.startSession(); try { sessionWasUsed = !!session.id; await session.endSession(); } catch (err) { await session.endSession(); throw err; } }); await m.run(); expect(sessionWasUsed).toBeTrue(); }); tap.test('run: lock heartbeat prevents concurrent execution of a slow step', async () => { let activeSteps = 0; let maxActiveSteps = 0; let totalCalls = 0; const createRunner = () => { const m = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'lock_heartbeat', lockTtlMs: 100, lockWaitMs: 1_500, }); m.step('slow-step').from('1.0.0').to('1.1.0').up(async () => { totalCalls++; activeSteps++; maxActiveSteps = Math.max(maxActiveSteps, activeSteps); await new Promise((resolve) => setTimeout(resolve, 900)); activeSteps--; }); return m; }; const firstRun = createRunner().run(); await new Promise((resolve) => setTimeout(resolve, 20)); const secondRun = createRunner().run(); const [firstResult, secondResult] = await Promise.all([firstRun, secondRun]); expect(firstResult.stepsApplied).toHaveLength(1); expect(secondResult.wasUpToDate).toBeTrue(); expect(totalCalls).toEqual(1); expect(maxActiveSteps).toEqual(1); }); tap.test('run: strict target strategy rejects target beyond registered chain', async () => { await setLedgerVersion('strict_target_gap', '2.0.0'); const m = new SmartMigration({ targetVersion: '3.0.0', db, ledgerName: 'strict_target_gap' }); m.step('a').from('1.0.0').to('2.0.0').up(async () => {}); let caught: SmartMigrationError | undefined; try { await m.run(); } catch (err) { caught = err as SmartMigrationError; } expect(caught).toBeInstanceOf(SmartMigrationError); expect(caught!.code).toEqual('TARGET_NOT_REACHABLE'); }); tap.test('run: bridge target strategy stamps from past chain end to target', async () => { await setLedgerVersion('bridge_from_past_chain_end', '2.5.0'); let realStepCalled = false; const m = new SmartMigration({ targetVersion: '3.0.0', db, ledgerName: 'bridge_from_past_chain_end', targetVersionStrategy: 'bridge', }); m.step('a').from('1.0.0').to('2.0.0').up(async () => { realStepCalled = true; }); const r = await m.run(); expect(realStepCalled).toBeFalse(); expect(r.stepsApplied).toHaveLength(1); expect(r.stepsApplied[0].id).toEqual('smartmigration-auto-bridge-to-3.0.0'); expect(r.stepsApplied[0].fromVersion).toEqual('2.0.0'); expect(r.currentVersionAfter).toEqual('3.0.0'); expect(await m.getCurrentVersion()).toEqual('3.0.0'); }); tap.test('run: bridge target strategy runs real migrations before bridge', async () => { await setLedgerVersion('bridge_after_real_steps', '1.0.0'); const log: string[] = []; const m = new SmartMigration({ targetVersion: '3.0.0', db, ledgerName: 'bridge_after_real_steps', targetVersionStrategy: 'bridge', }); m.step('a').from('1.0.0').to('2.0.0').up(async () => { log.push('a'); }); const r = await m.run(); expect(log).toEqual(['a']); expect(r.stepsApplied.map((step) => step.id)).toEqual([ 'a', 'smartmigration-auto-bridge-to-3.0.0', ]); expect(r.currentVersionAfter).toEqual('3.0.0'); }); tap.test('plan: bridge target strategy is read-only', async () => { await setLedgerVersion('bridge_plan_readonly', '2.5.0'); const m = new SmartMigration({ targetVersion: '3.0.0', db, ledgerName: 'bridge_plan_readonly', targetVersionStrategy: 'bridge', }); m.step('a').from('1.0.0').to('2.0.0').up(async () => {}); const r = await m.plan(); expect(r.stepsSkipped).toHaveLength(1); expect(r.stepsSkipped[0].id).toEqual('smartmigration-auto-bridge-to-3.0.0'); expect(r.currentVersionAfter).toEqual('3.0.0'); expect(await m.getCurrentVersion()).toEqual('2.5.0'); }); tap.test('run: future real migration applies after a bridged version stamp', async () => { await setLedgerVersion('bridge_future_migration', '2.5.0'); const bridgeRunner = new SmartMigration({ targetVersion: '3.0.0', db, ledgerName: 'bridge_future_migration', targetVersionStrategy: 'bridge', }); bridgeRunner.step('a').from('1.0.0').to('2.0.0').up(async () => {}); await bridgeRunner.run(); let futureStepCalled = false; const futureRunner = new SmartMigration({ targetVersion: '4.0.0', db, ledgerName: 'bridge_future_migration', targetVersionStrategy: 'bridge', }); futureRunner .step('a').from('1.0.0').to('2.0.0').up(async () => {}) .step('future').from('2.0.0').to('4.0.0').up(async () => { futureStepCalled = true; }); const r = await futureRunner.run(); expect(futureStepCalled).toBeTrue(); expect(r.stepsApplied.map((step) => step.id)).toEqual(['future']); expect(r.currentVersionAfter).toEqual('4.0.0'); }); tap.test('cleanup: close shared db', async () => { await cleanup(); }); export default tap.start();