`@push.rocks/smartmigration` is now implemented. 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 exists.** Across the push.rocks ecosystem (`smartdata`, `smartbucket`, `smartdb`, `mongodump`, `smartversion`) there was no migration tooling at the time this plan was written. 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.
awaitmigration.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 `from` matches the previous step's `to` (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
1.**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.)
2.**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 parent `SmartMigration` after `.up()` so multiple steps chain naturally.
3.**Drivers are exposed via context, not wrapped.** Migration `up` functions receive a `MigrationContext` that 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.
4.**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.
5.**Fast on the happy path.** When `currentVersion === targetVersion`, `run()` performs **one** read against the ledger and returns. No driver calls beyond that.
6.**Order is registration order; from/to is for validation.** Steps execute in the order they were registered. The runner verifies that each step's `from` matches the previous step's `to` (or the current ledger version) and errors out on gaps/overlaps. This avoids the complexity of computing a DAG path while still catching mistakes.
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:
```ts
interfaceISmartMigrationLedgerData{
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
```ts
constbackend=
options.ledgerBackend??
(options.db?'mongo':options.bucket?'s3':null);
if(!backend)thrownewSmartMigrationError('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)
- 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.id` must be unique
- Each `step.from` and `step.to` must 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) < 0` for every step
### `computePlan(steps, current, target)`
- Find the step where `from === current`. Take it and all subsequent steps until one has `to === target`. Return that slice.
- If `current` doesn't match any step's `from`, throw with a clear message naming the registered version chain.
- If we walk past `target` without 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
// third-party scope (driver re-exports for type access)
importtype*asmongodbfrom'mongodb';
importtype*asawsSdkfrom'@aws-sdk/client-s3';
exporttype{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`
```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.",
### `.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`):
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`.
-`readme.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.ts`
-`ts/index.ts`
-`ts/plugins.ts`
-`ts/interfaces.ts`
-`ts/types.ts`
-`ts/logger.ts`
-`ts/classes.smartmigration.ts`
-`ts/classes.migrationstep.ts`
-`ts/classes.migrationcontext.ts`
-`ts/classes.versionresolver.ts`
-`ts/ledgers/classes.ledger.ts`
-`ts/ledgers/classes.mongoledger.ts`
-`ts/ledgers/classes.s3ledger.ts`
-`test/test.basic.ts`
-`test/test.builder.ts`
-`test/test.versionresolver.ts`
-`test/test.mongoledger.ts`
-`test/test.run.mongo.ts`
-`test/test.run.checkpoint.ts`
-`test/test.freshinstall.ts`
-`test/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 ledger
-`SmartdataDb.mongoDb` (raw `Db`) and `SmartdataDb.mongoDbClient` (raw `MongoClient`) for `ctx.mongo` and transactional sessions
-`SmartdataDb.startSession()` (`/mnt/data/lossless/push.rocks/smartdata/ts/classes.db.ts`) for `ctx.startSession()`
-`Bucket.fastGet` / `Bucket.fastPut` for the S3 ledger backend
-`Bucket.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 example
-`SmartBucket.storageClient` for `ctx.s3`
-`Smartlog` from `@push.rocks/smartlog` for the module logger
-`compareVersions` from `@push.rocks/smartversion` for semver ordering