24 KiB
@push.rocks/smartmigration — Plan
reread /home/philkunz/.claude/CLAUDE.md before working from this plan
Context
@push.rocks/smartmigration is a brand-new module that does not yet exist (the directory at /mnt/data/lossless/push.rocks/smartmigration is empty except for .git/). Its purpose is to unify migrations across MongoDB and S3 with a small, builder-style API designed to be invoked on SaaS app startup: it inspects the current data version, computes the sequential chain of steps required to reach the app's target version, executes them safely, and stamps progress into a ledger so re-runs are no-ops.
Why this needs to exist. Across the push.rocks ecosystem (smartdata, smartbucket, smartdb, mongodump, smartversion) there is no migration tooling at all — a search for migration|migrate|schemaVersion across the relevant ts/ trees returned zero hits. SaaS apps that ship multiple deploys per week need a deterministic way to evolve persistent state in lockstep with code, and they need it to "just work" when the app boots — not as a separate operator-driven process. Both smartdata and smartbucket already expose their underlying drivers (SmartdataDb.mongoDb / SmartdataDb.mongoDbClient, SmartBucket.storageClient), so smartmigration only needs to provide the runner, ledger, and context plumbing — it does not need to wrap mongo or S3 itself.
Intended outcome. A SaaS app can do this at startup and forget about it:
import { SmartMigration } from '@push.rocks/smartmigration';
import { commitinfo } from './00_commitinfo_data.js';
const migration = new SmartMigration({
targetVersion: commitinfo.version, // app version drives data version
db, // optional SmartdataDb
bucket, // optional SmartBucket Bucket
});
migration
.step('lowercase-emails')
.from('1.0.0').to('1.1.0')
.description('Lowercase all user emails')
.up(async (ctx) => {
await ctx.mongo.collection('users').updateMany(
{},
[{ $set: { email: { $toLower: '$email' } } }],
);
})
.step('reorganize-uploads')
.from('1.1.0').to('2.0.0')
.description('Move uploads/ to media/')
.resumable()
.up(async (ctx) => {
const cursor = ctx.bucket.createCursor('uploads/');
let token = await ctx.checkpoint.read<string>('cursorToken');
if (token) cursor.setToken(token);
while (await cursor.hasMore()) {
for (const key of await cursor.next()) {
await ctx.bucket.fastMove({
sourcePath: key,
destinationPath: 'media/' + key.slice('uploads/'.length),
});
}
await ctx.checkpoint.write('cursorToken', cursor.getToken());
}
});
await migration.run(); // fast no-op if already at target
Confirmed design decisions
These were chosen during planning and are locked in:
- Step ordering: registration order, with from/to validation. Steps execute in the order they were registered. The runner verifies each step's
frommatches the previous step'sto(or the current ledger version) and errors out on gaps/overlaps. No DAG / topological sort. - Rollback: up-only for v1. No
.down()API. Forward-only migrations are simpler and safer; users restore from backup if a migration goes wrong. May be revisited in v2.
Design at a glance
Core principles
- One unified data version. A single semver string represents the combined state of mongo + S3. Steps transition
from→to. (Tracking mongo and S3 versions independently is rejected because it explodes step typing for marginal value — the app's data version is what users actually reason about.) - Builder-style step definition, single options object for the runner. The constructor takes a plain options object (
new SmartMigration({...})); steps are added via a fluent chain (migration.step('id').from('1.0.0').to('1.1.0').up(async ctx => {...})). The chain returns the parentSmartMigrationafter.up()so multiple steps chain naturally. - Drivers are exposed via context, not wrapped. Migration
upfunctions receive aMigrationContextthat hands them both high-level (ctx.db,ctx.bucket) and raw (ctx.mongo,ctx.s3) handles. smartmigration writes no SQL or S3 wrappers of its own. - Idempotent, restartable, lockable. Re-running
migration.run()on already-applied data is a no-op. Steps marked.resumable()get a per-step checkpoint store. A mongo-backed lock prevents concurrent SaaS instances from racing on the same migration. - Fast on the happy path. When
currentVersion === targetVersion,run()performs one read against the ledger and returns. No driver calls beyond that. - Order is registration order; from/to is for validation. Steps execute in the order they were registered. The runner verifies that each step's
frommatches the previous step'sto(or the current ledger version) and errors out on gaps/overlaps. This avoids the complexity of computing a DAG path while still catching mistakes.
Public API surface
// ts/index.ts
export { SmartMigration } from './classes.smartmigration.js';
export type {
ISmartMigrationOptions,
IMigrationStepDefinition,
IMigrationContext,
IMigrationCheckpoint,
IMigrationRunResult,
IMigrationStepResult,
IMigrationLedgerEntry,
} from './interfaces.js';
export type { TMigrationStatus, TLedgerBackend } from './types.js';
export { SmartMigrationError } from './classes.smartmigration.js';
ISmartMigrationOptions
export interface ISmartMigrationOptions {
/** Target version for the data. Typically the app's package.json version. */
targetVersion: string;
/** Optional smartdata instance. Required if any step uses ctx.db / ctx.mongo. */
db?: plugins.smartdata.SmartdataDb;
/** Optional smartbucket Bucket. Required if any step uses ctx.bucket / ctx.s3. */
bucket?: plugins.smartbucket.Bucket;
/** Logical name for this migration ledger. Defaults to "smartmigration". */
ledgerName?: string;
/** Where to persist the ledger. Defaults to "mongo" if db provided, otherwise "s3". */
ledgerBackend?: TLedgerBackend; // 'mongo' | 's3'
/**
* For a fresh install (no ledger AND no app data), jump straight to this version
* instead of running every step from the earliest. Defaults to undefined,
* which means "run every step from earliest from-version".
*/
freshInstallVersion?: string;
/** How long (ms) to wait for a stale lock from another instance. Default 60_000. */
lockWaitMs?: number;
/** How long (ms) before this instance's own lock auto-expires. Default 600_000. */
lockTtlMs?: number;
/** If true, run() returns the plan without executing anything. Default false. */
dryRun?: boolean;
/** Custom logger. Defaults to module logger. */
logger?: plugins.smartlog.Smartlog;
}
IMigrationContext
export interface IMigrationContext {
// High-level
db?: plugins.smartdata.SmartdataDb;
bucket?: plugins.smartbucket.Bucket;
// Raw drivers
mongo?: plugins.mongodb.Db; // db.mongoDb
s3?: plugins.awsSdk.S3Client; // bucket.parentSmartBucket.storageClient
// Step metadata
step: {
id: string;
fromVersion: string;
toVersion: string;
description?: string;
isResumable: boolean;
};
// Convenience
log: plugins.smartlog.Smartlog;
isDryRun: boolean;
/** Only present when step.isResumable === true. */
checkpoint?: IMigrationCheckpoint;
/** Convenience for transactional mongo migrations. Throws if no db configured. */
startSession(): plugins.mongodb.ClientSession;
}
export interface IMigrationCheckpoint {
read<T = unknown>(key: string): Promise<T | undefined>;
write<T = unknown>(key: string, value: T): Promise<void>;
clear(): Promise<void>;
}
MigrationStepBuilder (chained, terminal .up() returns parent SmartMigration)
class MigrationStepBuilder {
from(version: string): this;
to(version: string): this;
description(text: string): this;
resumable(): this; // enables ctx.checkpoint
up(handler: (ctx: IMigrationContext) => Promise<void>): SmartMigration;
}
SmartMigration
class SmartMigration {
public settings: ISmartMigrationOptions;
constructor(options: ISmartMigrationOptions);
/** Begin defining a step. Returns a chainable builder. */
step(id: string): MigrationStepBuilder;
/**
* The startup entry point.
* 1. Acquires the migration lock
* 2. Reads current ledger version (treats null as fresh install)
* 3. Validates the chain of registered steps
* 4. Computes the plan (which steps to run)
* 5. Executes them sequentially, checkpointing each
* 6. Releases the lock
* Returns a result describing what was applied/skipped.
*/
run(): Promise<IMigrationRunResult>;
/** Returns the plan without executing. Useful for `--dry-run` style probes. */
plan(): Promise<IMigrationRunResult>;
/** Returns the current data version from the ledger, or null if uninitialised. */
getCurrentVersion(): Promise<string | null>;
}
IMigrationRunResult
export interface IMigrationRunResult {
currentVersionBefore: string | null;
currentVersionAfter: string;
targetVersion: string;
wasUpToDate: boolean;
wasFreshInstall: boolean;
stepsApplied: IMigrationStepResult[];
stepsSkipped: IMigrationStepResult[]; // populated only on dry-run / when out of range
totalDurationMs: number;
}
export interface IMigrationStepResult {
id: string;
fromVersion: string;
toVersion: string;
status: TMigrationStatus; // 'applied' | 'skipped' | 'failed'
durationMs: number;
startedAt: string; // ISO
finishedAt: string; // ISO
error?: { message: string; stack?: string };
}
Ledger model
The ledger is the source of truth. There are two backends:
Mongo ledger (default when db is provided)
Backed by an EasyStore<TSmartMigrationLedgerData> (smartdata's existing EasyStore, see /mnt/data/lossless/push.rocks/smartdata/ts/classes.easystore.ts:35-101). The nameId is smartmigration:<ledgerName>. Schema of the stored data:
interface ISmartMigrationLedgerData {
currentVersion: string | null;
steps: Record<string, IMigrationLedgerEntry>; // keyed by step id
lock: {
holder: string | null; // random instance UUID
acquiredAt: string | null; // ISO
expiresAt: string | null; // ISO
};
checkpoints: Record<string, Record<string, unknown>>; // stepId -> { key: value }
}
Why EasyStore over a custom collection? EasyStore already exists, is designed for exactly this kind of singleton-config-blob use case, handles its own collection setup lazily, and avoids polluting the user's DB with smartmigration-internal classes. The whole ledger fits in one document, which makes the lock CAS trivial.
Locking implementation. Acquire by calling easyStore.readAll(), checking lock.holder === null || lock.expiresAt < now, then easyStore.writeAll({ lock: { holder: instanceId, ... } }). Re-read after writing to confirm we won the race (last-writer-wins is fine here because we re-check). Loop with backoff until lockWaitMs elapsed. This is admittedly not a true CAS — for v1 it's adequate; v2 can move to findOneAndUpdate against the underlying mongo collection if races become a problem.
S3 ledger (default when only bucket is provided)
A single object at <bucket>/.smartmigration/<ledgerName>.json containing the same ISmartMigrationLedgerData shape. Reads use bucket.fastGet, writes use bucket.fastPut with overwrite: true. Locking is best-effort (S3 has no CAS without conditional writes); we set lock.expiresAt and re-read to detect races. Documented limitation: S3-only deployments should not run multiple SaaS instances against the same ledger simultaneously without external coordination — when both mongo and S3 are present (the common SaaS case), the mongo ledger is used and the lock works correctly.
Selection logic
const backend =
options.ledgerBackend ??
(options.db ? 'mongo' : options.bucket ? 's3' : null);
if (!backend) throw new SmartMigrationError('Either db or bucket must be provided');
Run algorithm
run():
steps = registeredSteps // array, in registration order
validateStepChain(steps) // checks unique ids, no gaps in version chain
acquireLock()
try:
ledger = readLedger()
currentVersion = ledger.currentVersion
if currentVersion === null:
if isFreshInstall() and freshInstallVersion is set:
currentVersion = freshInstallVersion
writeLedger({ currentVersion })
else:
currentVersion = steps[0].from // start from earliest
if compareSemver(currentVersion, targetVersion) === 0:
return { wasUpToDate: true, ... }
plan = computePlan(steps, currentVersion, targetVersion)
if plan.length === 0:
throw "no migration path from X to Y"
for step in plan:
if dryRun:
skipped.push(step); continue
ctx = buildContext(step)
try:
await step.handler(ctx)
ledger.steps[step.id] = { ...result }
ledger.currentVersion = step.toVersion
writeLedger(ledger)
applied.push(step)
catch err:
ledger.steps[step.id] = { ...result, status: 'failed', error }
writeLedger(ledger)
throw err
finally:
releaseLock()
isFreshInstall()
- Mongo:
db.mongoDb.listCollections({}, {nameOnly:true}).toArray()→ if every collection name starts with smartmigration's reserved prefix or matches the EasyStore class name, fresh. - S3: open a
bucket.createCursor('')and ask for one batch — if empty (after excluding.smartmigration/prefix), fresh.
validateStepChain(steps)
- Each
step.idmust be unique - Each
step.fromandstep.tomust be valid semver - For consecutive steps
a, b:a.to === b.from(strict equality, not semver-compare — forces explicit chains) compareSemver(step.from, step.to) < 0for every step
computePlan(steps, current, target)
- Find the step where
from === current. Take it and all subsequent steps until one hasto === target. Return that slice. - If
currentdoesn't match any step'sfrom, throw with a clear message naming the registered version chain. - If we walk past
targetwithout matching, throw.
File layout
Following the flat-classes pattern used by most push.rocks modules (smartproxy's nested layout is the exception, justified only by its size). The new module's ts/ will look like:
ts/
├── 00_commitinfo_data.ts // auto-generated by commitinfo on release
├── index.ts // re-exports the public surface
├── plugins.ts // central import barrel
├── interfaces.ts // I-prefixed public interfaces
├── types.ts // T-prefixed public type aliases
├── logger.ts // module-scoped Smartlog singleton
├── classes.smartmigration.ts // SmartMigration class + SmartMigrationError
├── classes.migrationstep.ts // MigrationStepBuilder + internal MigrationStep
├── classes.migrationcontext.ts // buildContext() factory + checkpoint impl
├── classes.versionresolver.ts // semver-based plan computation + validation
└── ledgers/
├── classes.ledger.ts // abstract Ledger base
├── classes.mongoledger.ts // EasyStore-backed implementation
└── classes.s3ledger.ts // bucket.fastPut/fastGet-backed implementation
ts/plugins.ts content
// node native scope
import { randomUUID } from 'node:crypto';
export { randomUUID };
// pushrocks scope
import * as smartdata from '@push.rocks/smartdata';
import * as smartbucket from '@push.rocks/smartbucket';
import * as smartlog from '@push.rocks/smartlog';
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
import * as smartversion from '@push.rocks/smartversion';
import * as smarttime from '@push.rocks/smarttime';
import * as smartpromise from '@push.rocks/smartpromise';
export {
smartdata, smartbucket, smartlog, smartlogDestinationLocal,
smartversion, smarttime, smartpromise,
};
// third-party scope (driver re-exports for type access)
import type * as mongodb from 'mongodb';
import type * as awsSdk from '@aws-sdk/client-s3';
export type { mongodb, awsSdk };
smartdata and smartbucket are peerDependencies (not direct dependencies) — users will already have one or both, and we don't want to duplicate them. Listed in dependencies: @push.rocks/smartlog, @push.rocks/smartversion, @push.rocks/smarttime, @push.rocks/smartpromise. Listed in peerDependencies (optional): @push.rocks/smartdata, @push.rocks/smartbucket. Listed in devDependencies for tests: both peers + @push.rocks/smartmongo (in-memory mongo).
Project scaffolding files
These mirror the smartproxy conventions, with smartproxy-specific Rust bits removed.
package.json
{
"name": "@push.rocks/smartmigration",
"version": "1.0.0",
"private": false,
"description": "Unified migration runner for MongoDB (smartdata) and S3 (smartbucket) — designed to be invoked at SaaS app startup, with semver-based version tracking, sequential step execution, idempotent re-runs, and per-step resumable checkpoints.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
"author": "Lossless GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/**/test*.ts --verbose --timeout 120 --logfile)",
"build": "(tsbuild tsfolders --allowimplicitany)",
"format": "(gitzone format)",
"buildDocs": "tsdoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.0",
"@push.rocks/smartdata": "^7.1.6",
"@push.rocks/smartbucket": "^4.5.1",
"@push.rocks/smartmongo": "^7.0.0",
"@types/node": "^25.5.0",
"typescript": "^6.0.2"
},
"dependencies": {
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smarttime": "^4.1.1",
"@push.rocks/smartversion": "^3.0.5"
},
"peerDependencies": {
"@push.rocks/smartdata": "^7.1.6",
"@push.rocks/smartbucket": "^4.5.1"
},
"peerDependenciesMeta": {
"@push.rocks/smartdata": { "optional": true },
"@push.rocks/smartbucket": { "optional": true }
},
"files": [
"ts/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
".smartconfig.json",
"readme.md",
"changelog.md"
],
"keywords": [
"migration", "schema migration", "data migration",
"mongodb", "s3", "smartdata", "smartbucket",
"saas", "startup migration", "semver", "idempotent",
"ledger", "rolling deploy"
],
"homepage": "https://code.foss.global/push.rocks/smartmigration#readme",
"repository": {
"type": "git",
"url": "https://code.foss.global/push.rocks/smartmigration.git"
},
"bugs": {
"url": "https://code.foss.global/push.rocks/smartmigration/issues"
},
"pnpm": {
"overrides": {},
"onlyBuiltDependencies": ["mongodb-memory-server"]
}
}
tsconfig.json (verbatim from smartproxy)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true
},
"exclude": ["dist_*/**/*.d.ts"]
}
.smartconfig.json — same shape as smartproxy's, with name/scope/repo updated and the @git.zone/tsrust block omitted. Description and keywords from package.json above.
.gitignore — verbatim from smartproxy minus the rust/target line.
license — MIT, copy from smartproxy.
changelog.md — single entry for 1.0.0 - <today> - Initial release.
readme.hints.md — empty stub.
Test plan
Tests live in test/ and follow the smartproxy/tstest conventions: every file ends with export default tap.start(); and uses expect/tap from @git.zone/tstest/tapbundle. Use @push.rocks/smartmongo to spin up an in-memory mongo for tests; for S3 tests, mock against a small in-process fake (or skip and only test mongo paths in v1).
Files to create:
| File | What it covers |
|---|---|
test/test.basic.ts |
Constructor validates options; throws if neither db nor bucket given; default ledger backend selection |
test/test.builder.ts |
Step builder chains correctly, .from().to().up() registers a step, validation catches duplicate ids and gaps |
test/test.versionresolver.ts |
computePlan returns the right slice for various current/target combinations; throws on missing chain |
test/test.mongoledger.ts |
Read/write/lock against a real mongo via smartmongo; lock expiry; concurrent acquire retries |
test/test.run.mongo.ts |
End-to-end: define 3 steps, run from scratch, verify all applied; re-run is no-op; mid-step failure leaves ledger consistent |
test/test.run.checkpoint.ts |
Resumable step that crashes mid-way; second run resumes from checkpoint |
test/test.freshinstall.ts |
Empty db + freshInstallVersion set → jumps to that version, runs no steps |
test/test.dryrun.ts |
dryRun: true returns plan but does not write |
Critical assertions for the end-to-end mongo test (test/test.run.mongo.ts):
const db = await smartmongo.SmartMongo.createAndStart();
const smartmig = new SmartMigration({ targetVersion: '2.0.0', db: db.smartdataDb });
const log: string[] = [];
smartmig
.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 r1 = await smartmig.run();
expect(r1.stepsApplied).toHaveLength(3);
expect(log).toEqual(['a', 'b', 'c']);
expect(r1.currentVersionAfter).toEqual('2.0.0');
const r2 = await smartmig.run();
expect(r2.wasUpToDate).toBeTrue();
expect(r2.stepsApplied).toHaveLength(0);
expect(log).toEqual(['a', 'b', 'c']); // unchanged
End-to-end verification path (manual smoke after implementation): write a tiny .nogit/debug/saas-startup.ts script that constructs a SmartdataDb against a local mongo, registers two no-op migrations, runs smartmig.run() twice, and prints the result both times — the second run must report wasUpToDate: true.
Critical files to create
Source (under /mnt/data/lossless/push.rocks/smartmigration/):
package.jsontsconfig.json.smartconfig.json.gitignorelicensechangelog.mdreadme.md(full structured README following the smartproxy section template — installation, what is it, quick start, core concepts, common use cases, API reference, troubleshooting, best practices, license)readme.hints.md(stub)ts/00_commitinfo_data.tsts/index.tsts/plugins.tsts/interfaces.tsts/types.tsts/logger.tsts/classes.smartmigration.tsts/classes.migrationstep.tsts/classes.migrationcontext.tsts/classes.versionresolver.tsts/ledgers/classes.ledger.tsts/ledgers/classes.mongoledger.tsts/ledgers/classes.s3ledger.tstest/test.basic.tstest/test.builder.tstest/test.versionresolver.tstest/test.mongoledger.tstest/test.run.mongo.tstest/test.run.checkpoint.tstest/test.freshinstall.tstest/test.dryrun.ts
Reused from existing modules (no need to create — these are already in dependencies):
EasyStore<T>from smartdata (/mnt/data/lossless/push.rocks/smartdata/ts/classes.easystore.ts:9-121) — backs the mongo ledgerSmartdataDb.mongoDb(rawDb) andSmartdataDb.mongoDbClient(rawMongoClient) forctx.mongoand transactional sessionsSmartdataDb.startSession()(/mnt/data/lossless/push.rocks/smartdata/ts/classes.db.ts) forctx.startSession()Bucket.fastGet/Bucket.fastPutfor the S3 ledger backendBucket.createCursor(resumable token-based pagination,/mnt/data/lossless/push.rocks/smartbucket/ts/classes.listcursor.ts) — the canonical pattern for restartable S3 migrations, referenced in the readme exampleSmartBucket.storageClientforctx.s3Smartlogfrom@push.rocks/smartlogfor the module loggercompareVersionsfrom@push.rocks/smartversionfor semver ordering