feat(smartmigration): add initial smartmigration package with MongoDB and S3 migration runner

This commit is contained in:
2026-04-07 17:35:05 +00:00
commit d96c6bcee8
33 changed files with 11443 additions and 0 deletions

66
test/helpers/services.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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();

View 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
View 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
View 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();

View 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();