feat(smartmigration): add initial smartmigration package with MongoDB and S3 migration runner
This commit is contained in:
66
test/helpers/services.ts
Normal file
66
test/helpers/services.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
import * as smartbucket from '@push.rocks/smartbucket';
|
||||
|
||||
const qenv = new Qenv(process.cwd(), process.cwd() + '/.nogit/');
|
||||
|
||||
/**
|
||||
* Spin up a fresh `SmartdataDb` connected to the local mongo from .nogit/env.json,
|
||||
* scoped to a unique database name so tests cannot collide. Returns the db plus
|
||||
* a `cleanup` function that drops the database and closes the connection.
|
||||
*/
|
||||
export async function makeTestDb(suffix: string) {
|
||||
const baseUrl = await qenv.getEnvVarOnDemandStrict('MONGODB_URL');
|
||||
const dbName = `smartmigration_test_${suffix}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
|
||||
// Replace the path component of the connection URL with our unique db name.
|
||||
// Format from env.json:
|
||||
// mongodb://defaultadmin:defaultpass@localhost:27970/push-rocks-smartmigration?authSource=admin
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = `/${dbName}`;
|
||||
const db = new smartdata.SmartdataDb({
|
||||
mongoDbUrl: url.toString(),
|
||||
mongoDbName: dbName,
|
||||
});
|
||||
await db.init();
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await db.mongoDb.dropDatabase();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return { db, dbName, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the local minio bucket from .nogit/env.json. The bucket is
|
||||
* shared across tests, so each test should namespace its keys with a
|
||||
* unique prefix and clean them up afterwards.
|
||||
*/
|
||||
export async function makeTestBucket() {
|
||||
const sb = new smartbucket.SmartBucket({
|
||||
accessKey: await qenv.getEnvVarOnDemandStrict('S3_ACCESSKEY'),
|
||||
accessSecret: await qenv.getEnvVarOnDemandStrict('S3_SECRETKEY'),
|
||||
endpoint: await qenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
|
||||
port: parseInt(await qenv.getEnvVarOnDemandStrict('S3_PORT'), 10),
|
||||
useSsl: false,
|
||||
});
|
||||
const bucketName = await qenv.getEnvVarOnDemandStrict('S3_BUCKET');
|
||||
|
||||
// The bucket may not exist yet — try to fetch it; if missing, create it.
|
||||
let bucket: smartbucket.Bucket;
|
||||
try {
|
||||
bucket = await sb.getBucketByName(bucketName);
|
||||
} catch {
|
||||
bucket = (await sb.createBucket(bucketName)) as unknown as smartbucket.Bucket;
|
||||
bucket = await sb.getBucketByName(bucketName);
|
||||
}
|
||||
return { sb, bucket, bucketName };
|
||||
}
|
||||
93
test/test.basic.ts
Normal file
93
test/test.basic.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartMigration, SmartMigrationError } from '../ts/index.js';
|
||||
|
||||
// Pure-logic tests for the SmartMigration constructor — no database required.
|
||||
|
||||
tap.test('constructor: rejects empty options', async () => {
|
||||
expect(() => new (SmartMigration as any)()).toThrow();
|
||||
expect(() => new (SmartMigration as any)(null)).toThrow();
|
||||
});
|
||||
|
||||
tap.test('constructor: requires targetVersion', async () => {
|
||||
expect(() => new SmartMigration({} as any)).toThrow();
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects invalid targetVersion', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
// not a real semver
|
||||
new SmartMigration({ targetVersion: 'not-a-version', db: {} as any });
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('INVALID_VERSION');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects when neither db nor bucket given', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
new SmartMigration({ targetVersion: '1.0.0' });
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('NO_RESOURCES');
|
||||
});
|
||||
|
||||
tap.test('constructor: defaults ledgerBackend to "mongo" when db is given', async () => {
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '1.0.0',
|
||||
db: {} as any,
|
||||
});
|
||||
expect(m.settings.ledgerBackend).toEqual('mongo');
|
||||
expect(m.settings.ledgerName).toEqual('smartmigration');
|
||||
expect(m.settings.lockWaitMs).toEqual(60_000);
|
||||
expect(m.settings.lockTtlMs).toEqual(600_000);
|
||||
expect(m.settings.dryRun).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('constructor: defaults ledgerBackend to "s3" when only bucket is given', async () => {
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '1.0.0',
|
||||
bucket: {} as any,
|
||||
});
|
||||
expect(m.settings.ledgerBackend).toEqual('s3');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects mongo backend without db', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
new SmartMigration({
|
||||
targetVersion: '1.0.0',
|
||||
bucket: {} as any,
|
||||
ledgerBackend: 'mongo',
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('LEDGER_BACKEND_MISMATCH');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects s3 backend without bucket', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
new SmartMigration({
|
||||
targetVersion: '1.0.0',
|
||||
db: {} as any,
|
||||
ledgerBackend: 's3',
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('LEDGER_BACKEND_MISMATCH');
|
||||
});
|
||||
|
||||
tap.test('step(): rejects empty id', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '1.0.0', db: {} as any });
|
||||
expect(() => m.step('')).toThrow();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
78
test/test.builder.ts
Normal file
78
test/test.builder.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartMigration, SmartMigrationError } from '../ts/index.js';
|
||||
|
||||
// Tests for the MigrationStepBuilder fluent chain.
|
||||
|
||||
tap.test('step builder: registers a single step', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '1.0.0', db: {} as any });
|
||||
m.step('a').from('1.0.0').to('1.1.0').up(async () => {});
|
||||
const steps = m.getRegisteredSteps();
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0].id).toEqual('a');
|
||||
expect(steps[0].fromVersion).toEqual('1.0.0');
|
||||
expect(steps[0].toVersion).toEqual('1.1.0');
|
||||
expect(steps[0].isResumable).toBeFalse();
|
||||
expect(steps[0].description).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('step builder: chains multiple steps via the parent', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '2.0.0', db: {} as any });
|
||||
m
|
||||
.step('a').from('1.0.0').to('1.1.0').up(async () => {})
|
||||
.step('b').from('1.1.0').to('1.5.0').up(async () => {})
|
||||
.step('c').from('1.5.0').to('2.0.0').up(async () => {});
|
||||
expect(m.getRegisteredSteps()).toHaveLength(3);
|
||||
expect(m.getRegisteredSteps().map((s) => s.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
tap.test('step builder: stores description and resumable', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '1.1.0', db: {} as any });
|
||||
m
|
||||
.step('with-meta')
|
||||
.from('1.0.0')
|
||||
.to('1.1.0')
|
||||
.description('does important things')
|
||||
.resumable()
|
||||
.up(async () => {});
|
||||
const step = m.getRegisteredSteps()[0];
|
||||
expect(step.description).toEqual('does important things');
|
||||
expect(step.isResumable).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('step builder: requires .from() before .up()', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '1.0.0', db: {} as any });
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
(m.step('bad').to('1.1.0') as any).up(async () => {});
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('STEP_MISSING_FROM');
|
||||
});
|
||||
|
||||
tap.test('step builder: requires .to() before .up()', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '1.0.0', db: {} as any });
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
(m.step('bad').from('1.0.0') as any).up(async () => {});
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('STEP_MISSING_TO');
|
||||
});
|
||||
|
||||
tap.test('step builder: rejects non-function handler', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '1.0.0', db: {} as any });
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
(m.step('bad').from('1.0.0').to('1.1.0') as any).up('not-a-function');
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('STEP_HANDLER_NOT_FUNCTION');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
58
test/test.dryrun.ts
Normal file
58
test/test.dryrun.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { makeTestDb } from './helpers/services.js';
|
||||
import type * as smartdata from '@push.rocks/smartdata';
|
||||
import { SmartMigration } from '../ts/index.js';
|
||||
|
||||
let db: smartdata.SmartdataDb;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
tap.test('setup: connect shared db', async () => {
|
||||
const r = await makeTestDb('dryrun');
|
||||
db = r.db;
|
||||
cleanup = r.cleanup;
|
||||
});
|
||||
|
||||
tap.test('dryRun: returns plan without invoking handlers', async () => {
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '2.0.0',
|
||||
db,
|
||||
ledgerName: 'dryrun',
|
||||
dryRun: true,
|
||||
});
|
||||
let stepCalled = false;
|
||||
m
|
||||
.step('a').from('1.0.0').to('1.5.0').up(async () => { stepCalled = true; })
|
||||
.step('b').from('1.5.0').to('2.0.0').up(async () => { stepCalled = true; });
|
||||
|
||||
const r = await m.run();
|
||||
expect(stepCalled).toBeFalse();
|
||||
expect(r.stepsApplied).toHaveLength(0);
|
||||
expect(r.stepsSkipped).toHaveLength(2);
|
||||
expect(r.stepsSkipped.map((s) => s.id)).toEqual(['a', 'b']);
|
||||
|
||||
// The ledger should still be in its initial state (currentVersion = null).
|
||||
const current = await m.getCurrentVersion();
|
||||
expect(current).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('plan(): returns plan without writing or running', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'plan_only' });
|
||||
let stepCalled = false;
|
||||
m
|
||||
.step('a').from('1.0.0').to('2.0.0').up(async () => { stepCalled = true; });
|
||||
|
||||
const planResult = await m.plan();
|
||||
expect(stepCalled).toBeFalse();
|
||||
expect(planResult.stepsSkipped).toHaveLength(1);
|
||||
expect(planResult.stepsApplied).toHaveLength(0);
|
||||
|
||||
// Plan does not modify the ledger.
|
||||
const current = await m.getCurrentVersion();
|
||||
expect(current).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
77
test/test.freshinstall.ts
Normal file
77
test/test.freshinstall.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { makeTestDb } from './helpers/services.js';
|
||||
import type * as smartdata from '@push.rocks/smartdata';
|
||||
import { SmartMigration } from '../ts/index.js';
|
||||
|
||||
// Shared db across the file. Each test uses a unique ledgerName for isolation.
|
||||
// Note: the fresh-install detector checks for any non-reserved collection, so
|
||||
// tests that explicitly create user data must clean it up after themselves
|
||||
// (or other tests would no longer see "fresh"). For simplicity, the
|
||||
// not-fresh test runs LAST and we don't bother cleaning up after it.
|
||||
let db: smartdata.SmartdataDb;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
tap.test('setup: connect shared db', async () => {
|
||||
const r = await makeTestDb('freshinstall');
|
||||
db = r.db;
|
||||
cleanup = r.cleanup;
|
||||
});
|
||||
|
||||
tap.test('freshInstall: jumps to freshInstallVersion when DB is empty', async () => {
|
||||
// Database is empty (only the SmartdataEasyStore collection may exist
|
||||
// from previous tests' ledgers, which is whitelisted by isFreshInstall()).
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '5.0.0',
|
||||
db,
|
||||
ledgerName: 'jump',
|
||||
freshInstallVersion: '5.0.0',
|
||||
});
|
||||
let stepCalled = false;
|
||||
m
|
||||
.step('a').from('1.0.0').to('2.0.0').up(async () => { stepCalled = true; })
|
||||
.step('b').from('2.0.0').to('5.0.0').up(async () => { stepCalled = true; });
|
||||
|
||||
const r = await m.run();
|
||||
expect(stepCalled).toBeFalse();
|
||||
expect(r.currentVersionAfter).toEqual('5.0.0');
|
||||
expect(r.wasFreshInstall).toBeTrue();
|
||||
expect(r.stepsApplied).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('freshInstall: without freshInstallVersion, starts from earliest step', async () => {
|
||||
// Even with freshInstallVersion unset, the db is still empty of user
|
||||
// collections, so the runner should start from the first step's `from`
|
||||
// version and run all steps in order.
|
||||
const m = new SmartMigration({ targetVersion: '2.0.0', db, ledgerName: 'no_freshversion' });
|
||||
const log: string[] = [];
|
||||
m
|
||||
.step('a').from('1.0.0').to('1.5.0').up(async () => { log.push('a'); })
|
||||
.step('b').from('1.5.0').to('2.0.0').up(async () => { log.push('b'); });
|
||||
const r = await m.run();
|
||||
expect(log).toEqual(['a', 'b']);
|
||||
expect(r.currentVersionAfter).toEqual('2.0.0');
|
||||
});
|
||||
|
||||
tap.test('freshInstall: runs steps when DB has user collections (not fresh)', async () => {
|
||||
// Pre-seed the DB with a user collection so isFreshInstall() returns false.
|
||||
await db.mongoDb.collection('users_pre').insertOne({ name: 'preexisting' });
|
||||
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '2.0.0',
|
||||
db,
|
||||
ledgerName: 'not_fresh',
|
||||
freshInstallVersion: '2.0.0',
|
||||
});
|
||||
let aCalled = false;
|
||||
m.step('a').from('1.0.0').to('2.0.0').up(async () => { aCalled = true; });
|
||||
const r = await m.run();
|
||||
expect(aCalled).toBeTrue();
|
||||
expect(r.wasFreshInstall).toBeFalse();
|
||||
expect(r.currentVersionAfter).toEqual('2.0.0');
|
||||
});
|
||||
|
||||
tap.test('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
81
test/test.mongoledger.ts
Normal file
81
test/test.mongoledger.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { makeTestDb } from './helpers/services.js';
|
||||
import type * as smartdata from '@push.rocks/smartdata';
|
||||
import { MongoLedger } from '../ts/ledgers/classes.mongoledger.js';
|
||||
|
||||
// Smartdata's CollectionFactory caches collections by class name globally
|
||||
// (see classes.collection.ts:22), so we MUST share a single db across all
|
||||
// tests in a file. We isolate tests via unique ledger names — the EasyStore
|
||||
// stores data keyed by `nameId`, so distinct ledger names live in the same
|
||||
// underlying mongo collection without interference.
|
||||
|
||||
let db: smartdata.SmartdataDb;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
tap.test('setup: connect shared db', async () => {
|
||||
const r = await makeTestDb('mongoledger');
|
||||
db = r.db;
|
||||
cleanup = r.cleanup;
|
||||
});
|
||||
|
||||
tap.test('MongoLedger: init creates an empty ledger', async () => {
|
||||
const ledger = new MongoLedger(db, 'init-test');
|
||||
await ledger.init();
|
||||
const data = await ledger.read();
|
||||
expect(data.currentVersion).toBeNull();
|
||||
expect(data.steps).toEqual({});
|
||||
expect(data.lock.holder).toBeNull();
|
||||
expect(data.checkpoints).toEqual({});
|
||||
});
|
||||
|
||||
tap.test('MongoLedger: write+read roundtrips', async () => {
|
||||
const ledger = new MongoLedger(db, 'rw-test');
|
||||
await ledger.init();
|
||||
await ledger.write({
|
||||
currentVersion: '1.5.0',
|
||||
steps: { foo: { id: 'foo', fromVersion: '1.0.0', toVersion: '1.5.0', status: 'applied', startedAt: 'a', finishedAt: 'b', durationMs: 12 } },
|
||||
lock: { holder: null, acquiredAt: null, expiresAt: null },
|
||||
checkpoints: { foo: { progress: 42 } },
|
||||
});
|
||||
const round = await ledger.read();
|
||||
expect(round.currentVersion).toEqual('1.5.0');
|
||||
expect(round.steps['foo'].durationMs).toEqual(12);
|
||||
expect((round.checkpoints['foo'] as any).progress).toEqual(42);
|
||||
});
|
||||
|
||||
tap.test('MongoLedger: lock acquire/release', async () => {
|
||||
const ledger = new MongoLedger(db, 'lock-test');
|
||||
await ledger.init();
|
||||
|
||||
const acquired = await ledger.acquireLock('holder-A', 60_000);
|
||||
expect(acquired).toBeTrue();
|
||||
|
||||
// A second acquire from a different holder should fail.
|
||||
const acquired2 = await ledger.acquireLock('holder-B', 60_000);
|
||||
expect(acquired2).toBeFalse();
|
||||
|
||||
// Release lets holder-B in.
|
||||
await ledger.releaseLock('holder-A');
|
||||
const acquired3 = await ledger.acquireLock('holder-B', 60_000);
|
||||
expect(acquired3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('MongoLedger: expired lock can be stolen', async () => {
|
||||
const ledger = new MongoLedger(db, 'expire-test');
|
||||
await ledger.init();
|
||||
|
||||
// Acquire with a 1ms TTL so it's instantly expired.
|
||||
const got = await ledger.acquireLock('stale-holder', 1);
|
||||
expect(got).toBeTrue();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Now another holder should be able to take over (lock is expired).
|
||||
const got2 = await ledger.acquireLock('fresh-holder', 60_000);
|
||||
expect(got2).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
72
test/test.run.checkpoint.ts
Normal file
72
test/test.run.checkpoint.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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';
|
||||
|
||||
let db: smartdata.SmartdataDb;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
tap.test('setup: connect shared db', async () => {
|
||||
const r = await makeTestDb('checkpoint');
|
||||
db = r.db;
|
||||
cleanup = r.cleanup;
|
||||
});
|
||||
|
||||
tap.test('checkpoint: resumable step writes and re-reads progress across runs', async () => {
|
||||
// First runner: simulate a crash after writing the first half of the work.
|
||||
const m1 = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'resume' });
|
||||
let crashed = false;
|
||||
m1.step('big-job').from('1.0.0').to('1.1.0').resumable().up(async (ctx) => {
|
||||
const seen = (await ctx.checkpoint!.read<number>('processed')) ?? 0;
|
||||
// Pretend we processed items 0..4 and then crash.
|
||||
for (let i = seen; i < 5; i++) {
|
||||
await ctx.checkpoint!.write('processed', i + 1);
|
||||
}
|
||||
crashed = true;
|
||||
throw new Error('simulated crash mid-step');
|
||||
});
|
||||
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
await m1.run();
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(crashed).toBeTrue();
|
||||
|
||||
// Second runner: should see the checkpoint and resume from item 5.
|
||||
const m2 = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'resume' });
|
||||
let resumedFrom: number | undefined;
|
||||
let finalCount: number | undefined;
|
||||
m2.step('big-job').from('1.0.0').to('1.1.0').resumable().up(async (ctx) => {
|
||||
resumedFrom = (await ctx.checkpoint!.read<number>('processed')) ?? 0;
|
||||
// Process items 5..9.
|
||||
let n = resumedFrom;
|
||||
while (n < 10) {
|
||||
n++;
|
||||
await ctx.checkpoint!.write('processed', n);
|
||||
}
|
||||
finalCount = n;
|
||||
});
|
||||
const r = await m2.run();
|
||||
expect(resumedFrom).toEqual(5);
|
||||
expect(finalCount).toEqual(10);
|
||||
expect(r.currentVersionAfter).toEqual('1.1.0');
|
||||
});
|
||||
|
||||
tap.test('checkpoint: ctx.checkpoint is undefined for non-resumable steps', async () => {
|
||||
const m = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'noresume' });
|
||||
let observed: boolean | undefined;
|
||||
m.step('plain').from('1.0.0').to('1.1.0').up(async (ctx) => {
|
||||
observed = ctx.checkpoint === undefined;
|
||||
});
|
||||
await m.run();
|
||||
expect(observed).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
124
test/test.run.mongo.ts
Normal file
124
test/test.run.mongo.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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<void>;
|
||||
|
||||
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('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
103
test/test.run.s3.ts
Normal file
103
test/test.run.s3.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { makeTestBucket } from './helpers/services.js';
|
||||
import type * as smartbucket from '@push.rocks/smartbucket';
|
||||
import { SmartMigration } from '../ts/index.js';
|
||||
|
||||
// End-to-end test of an S3-backed migration. Each test uses a unique
|
||||
// `ledgerName` so its `.smartmigration/<name>.json` sidecar object does
|
||||
// not collide with other tests in the file.
|
||||
|
||||
let bucket: smartbucket.Bucket;
|
||||
|
||||
tap.test('setup: connect to bucket', async () => {
|
||||
const r = await makeTestBucket();
|
||||
bucket = r.bucket;
|
||||
// Wipe the smartmigration sidecar prefix from any prior runs.
|
||||
for await (const key of bucket.listAllObjects('.smartmigration/')) {
|
||||
await bucket.fastRemove({ path: key });
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('s3 run: applies an S3 migration step from scratch', async () => {
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '2.0.0',
|
||||
bucket,
|
||||
ledgerName: 'reorganize',
|
||||
});
|
||||
|
||||
// Pre-seed three "uploads/" objects so the migration has work to do.
|
||||
await bucket.fastPut({ path: 'uploads/a.txt', contents: 'a' });
|
||||
await bucket.fastPut({ path: 'uploads/b.txt', contents: 'b' });
|
||||
await bucket.fastPut({ path: 'uploads/c.txt', contents: 'c' });
|
||||
|
||||
m.step('move-uploads').from('1.0.0').to('2.0.0').up(async (ctx) => {
|
||||
for await (const key of ctx.bucket!.listAllObjects('uploads/')) {
|
||||
const newPath = 'media/' + key.slice('uploads/'.length);
|
||||
await ctx.bucket!.fastMove({
|
||||
sourcePath: key,
|
||||
destinationPath: newPath,
|
||||
overwrite: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const r = await m.run();
|
||||
expect(r.stepsApplied).toHaveLength(1);
|
||||
expect(r.currentVersionAfter).toEqual('2.0.0');
|
||||
|
||||
// Verify the data moved.
|
||||
expect(await bucket.fastExists({ path: 'media/a.txt' })).toBeTrue();
|
||||
expect(await bucket.fastExists({ path: 'media/b.txt' })).toBeTrue();
|
||||
expect(await bucket.fastExists({ path: 'media/c.txt' })).toBeTrue();
|
||||
expect(await bucket.fastExists({ path: 'uploads/a.txt' })).toBeFalse();
|
||||
|
||||
// Cleanup so the next test starts clean.
|
||||
for (const k of ['media/a.txt', 'media/b.txt', 'media/c.txt']) {
|
||||
await bucket.fastRemove({ path: k });
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('s3 run: second run is a no-op', async () => {
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '1.1.0',
|
||||
bucket,
|
||||
ledgerName: 'noop',
|
||||
});
|
||||
let calls = 0;
|
||||
m.step('once').from('1.0.0').to('1.1.0').up(async () => { calls++; });
|
||||
|
||||
const r1 = await m.run();
|
||||
expect(r1.stepsApplied).toHaveLength(1);
|
||||
expect(calls).toEqual(1);
|
||||
|
||||
// Re-run with a fresh runner.
|
||||
const m2 = new SmartMigration({ targetVersion: '1.1.0', bucket, ledgerName: 'noop' });
|
||||
m2.step('once').from('1.0.0').to('1.1.0').up(async () => { calls++; });
|
||||
const r2 = await m2.run();
|
||||
expect(r2.wasUpToDate).toBeTrue();
|
||||
expect(calls).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('s3 ctx exposes bucket and s3 client', async () => {
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '1.1.0',
|
||||
bucket,
|
||||
ledgerName: 'ctx',
|
||||
});
|
||||
let observed: { hasBucket: boolean; hasS3: boolean } | null = null;
|
||||
m.step('check-ctx').from('1.0.0').to('1.1.0').up(async (ctx) => {
|
||||
observed = { hasBucket: !!ctx.bucket, hasS3: !!ctx.s3 };
|
||||
});
|
||||
await m.run();
|
||||
expect(observed).not.toBeNull();
|
||||
expect(observed!.hasBucket).toBeTrue();
|
||||
expect(observed!.hasS3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('cleanup: wipe smartmigration sidecars', async () => {
|
||||
for await (const key of bucket.listAllObjects('.smartmigration/')) {
|
||||
await bucket.fastRemove({ path: key });
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
151
test/test.versionresolver.ts
Normal file
151
test/test.versionresolver.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { VersionResolver, SmartMigrationError } from '../ts/index.js';
|
||||
import type { IMigrationStepDefinition } from '../ts/index.js';
|
||||
|
||||
const noopHandler = async () => {};
|
||||
|
||||
function makeStep(id: string, from: string, to: string): IMigrationStepDefinition {
|
||||
return { id, fromVersion: from, toVersion: to, isResumable: false, handler: noopHandler };
|
||||
}
|
||||
|
||||
tap.test('equals / lessThan / greaterThan', async () => {
|
||||
expect(VersionResolver.equals('1.0.0', '1.0.0')).toBeTrue();
|
||||
expect(VersionResolver.equals('1.0.0', '1.0.1')).toBeFalse();
|
||||
expect(VersionResolver.lessThan('1.0.0', '1.0.1')).toBeTrue();
|
||||
expect(VersionResolver.greaterThan('2.0.0', '1.99.99')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('assertValid: throws on garbage', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
VersionResolver.assertValid('not-a-version', 'test');
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('INVALID_VERSION');
|
||||
});
|
||||
|
||||
tap.test('validateChain: empty chain is valid', async () => {
|
||||
VersionResolver.validateChain([]);
|
||||
});
|
||||
|
||||
tap.test('validateChain: catches duplicate ids', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
VersionResolver.validateChain([
|
||||
makeStep('a', '1.0.0', '1.1.0'),
|
||||
makeStep('a', '1.1.0', '1.2.0'),
|
||||
]);
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught!.code).toEqual('DUPLICATE_STEP_ID');
|
||||
});
|
||||
|
||||
tap.test('validateChain: catches non-increasing step', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
VersionResolver.validateChain([makeStep('a', '1.5.0', '1.5.0')]);
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught!.code).toEqual('NON_INCREASING_STEP');
|
||||
});
|
||||
|
||||
tap.test('validateChain: catches gap in chain', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
VersionResolver.validateChain([
|
||||
makeStep('a', '1.0.0', '1.1.0'),
|
||||
makeStep('b', '1.2.0', '1.3.0'), // gap! a.to=1.1.0, b.from=1.2.0
|
||||
]);
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught!.code).toEqual('CHAIN_GAP');
|
||||
});
|
||||
|
||||
tap.test('validateChain: contiguous chain passes', async () => {
|
||||
VersionResolver.validateChain([
|
||||
makeStep('a', '1.0.0', '1.1.0'),
|
||||
makeStep('b', '1.1.0', '1.5.0'),
|
||||
makeStep('c', '1.5.0', '2.0.0'),
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('computePlan: returns empty when current === target', async () => {
|
||||
const steps = [makeStep('a', '1.0.0', '1.1.0')];
|
||||
expect(VersionResolver.computePlan(steps, '1.0.0', '1.0.0')).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('computePlan: returns full chain when starting from beginning', async () => {
|
||||
const steps = [
|
||||
makeStep('a', '1.0.0', '1.1.0'),
|
||||
makeStep('b', '1.1.0', '1.5.0'),
|
||||
makeStep('c', '1.5.0', '2.0.0'),
|
||||
];
|
||||
const plan = VersionResolver.computePlan(steps, '1.0.0', '2.0.0');
|
||||
expect(plan.map((s) => s.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
tap.test('computePlan: returns partial chain when starting in the middle', async () => {
|
||||
const steps = [
|
||||
makeStep('a', '1.0.0', '1.1.0'),
|
||||
makeStep('b', '1.1.0', '1.5.0'),
|
||||
makeStep('c', '1.5.0', '2.0.0'),
|
||||
];
|
||||
const plan = VersionResolver.computePlan(steps, '1.1.0', '2.0.0');
|
||||
expect(plan.map((s) => s.id)).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
tap.test('computePlan: returns sub-slice when target is mid-chain', async () => {
|
||||
const steps = [
|
||||
makeStep('a', '1.0.0', '1.1.0'),
|
||||
makeStep('b', '1.1.0', '1.5.0'),
|
||||
makeStep('c', '1.5.0', '2.0.0'),
|
||||
];
|
||||
const plan = VersionResolver.computePlan(steps, '1.0.0', '1.5.0');
|
||||
expect(plan.map((s) => s.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
tap.test('computePlan: throws when current does not match any step from', async () => {
|
||||
const steps = [
|
||||
makeStep('a', '1.0.0', '1.1.0'),
|
||||
makeStep('b', '1.1.0', '2.0.0'),
|
||||
];
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
VersionResolver.computePlan(steps, '1.0.5', '2.0.0');
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught!.code).toEqual('CHAIN_NOT_AT_CURRENT');
|
||||
});
|
||||
|
||||
tap.test('computePlan: throws when target overshoots', async () => {
|
||||
const steps = [
|
||||
makeStep('a', '1.0.0', '1.1.0'),
|
||||
makeStep('b', '1.1.0', '2.0.0'),
|
||||
];
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
VersionResolver.computePlan(steps, '1.0.0', '1.5.0');
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught!.code).toEqual('TARGET_NOT_REACHABLE');
|
||||
});
|
||||
|
||||
tap.test('computePlan: rejects downgrade', async () => {
|
||||
const steps = [makeStep('a', '1.0.0', '2.0.0')];
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
VersionResolver.computePlan(steps, '2.0.0', '1.0.0');
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught!.code).toEqual('DOWNGRADE_NOT_SUPPORTED');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user