12 KiB
Capability Spec Delta: migration (migration-adapters-completion)
Package: @suaveplan/migration
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: MigrationLockLike interface with lockId-scoped locking
The package SHALL expose a MigrationLockLike interface and a default MemoryMigrationLock implementation that satisfies it. The interface SHALL declare acquire(lockId: string, ttlMs: number): Promise<boolean>, release(lockId: string): Promise<void>, and extend(lockId: string, ttlMs: number): Promise<boolean>. Multiple lockId values held against a single instance MUST be isolated — acquiring lockId "a" SHALL NOT prevent another caller from acquiring lockId "b". TTL expiry SHALL automatically free the lock without an explicit release call.
Scenario: uncontested acquire returns true
- WHEN
lock.acquire("migrate-tenant-1", 60000)is called against an unheld lockId - THEN the call resolves to
trueAND a subsequentlock.acquire("migrate-tenant-1", 60000)resolves tofalse
Scenario: lockId isolation
- WHEN
lock.acquire("a", 60000)resolves totrue - THEN
lock.acquire("b", 60000)also resolves totrue
Scenario: extend refreshes TTL
- WHEN a lock is acquired with
ttlMs: 50andextend(lockId, 5000)is called before expiry - THEN the lock remains held 100ms after the original expiry would have fired
Scenario: TTL expiry auto-releases
- WHEN a lock is acquired with
ttlMs: 50and noreleaseorextendis called - THEN after 100ms, a fresh
acquire(lockId, 60000)resolves totrue
Scenario: legacy MigrationLockAdapter alias preserved
- WHEN consumer code imports
MigrationLockAdapterfrom@suaveplan/migration - THEN the import resolves to the same shape it had before this change (no break)
Verified by: packages/database/migration/src/lock/migration-lock/migration-lock.test.ts::"acquire returns true on uncontested lockId" AND packages/database/migration/src/lock/migration-lock/migration-lock.test.ts::"isolates separate lockIds" AND packages/database/migration/src/lock/migration-lock/migration-lock.test.ts::"extend refreshes TTL" AND packages/database/migration/src/lock/migration-lock/migration-lock.test.ts::"expired lockId auto-releases" AND packages/database/migration/src/lock/migration-lock/migration-lock.spec-conformance.test.ts::"preserves MigrationLockAdapter alias"
Requirement: MigrationStoreLike interface with ledger persistence contract
The package SHALL expose a MigrationStoreLike interface and a default MemoryMigrationStore implementation that satisfies it. The interface SHALL declare list(): Promise<MigrationRecord[]>, append(record: MigrationRecord): Promise<void>, markFailed(id: string, error: string): Promise<void>, markApplied(id: string): Promise<void>, and getLatestVersion(): Promise<string | undefined>. The default in-memory implementation SHALL be deterministic — list() always returns records sorted by MigrationRecord.id ascending.
Scenario: empty store lists no records
- WHEN
store.list()is called on a freshMemoryMigrationStore - THEN the result is
[]
Scenario: append then list returns single record
- WHEN
store.append({ id: "001", name: "init", appliedAt: 0, direction: "up" })is called and thenstore.list()is called - THEN the result is
[{ id: "001", name: "init", appliedAt: 0, direction: "up" }]
Scenario: markApplied flips status
- WHEN a record is appended with status
"pending"andmarkApplied("001")is called - THEN
list()returns the record withstatus: "applied"
Scenario: markFailed records error
- WHEN
markFailed("001", "constraint violation")is called against an existing record - THEN
list()returns the record withstatus: "failed"ANDerror: "constraint violation"
Scenario: getLatestVersion returns max id
- WHEN records
"001","002","003"are appended in any order - THEN
getLatestVersion()returns"003"
Scenario: legacy MigrationStoreAdapter alias preserved
- WHEN consumer code imports
MigrationStoreAdapterfrom@suaveplan/migration - THEN the import resolves to the same shape it had before this change (no break)
Verified by: packages/database/migration/src/store/migration-store/migration-store.test.ts::"empty store returns no records" AND packages/database/migration/src/store/migration-store/migration-store.test.ts::"append then list returns the record" AND packages/database/migration/src/store/migration-store/migration-store.test.ts::"markApplied flips status to applied" AND packages/database/migration/src/store/migration-store/migration-store.test.ts::"markFailed records error string" AND packages/database/migration/src/store/migration-store/migration-store.test.ts::"getLatestVersion returns the highest id" AND packages/database/migration/src/store/migration-store/migration-store.spec-conformance.test.ts::"preserves MigrationStoreAdapter alias"
Requirement: migrationEvents shared event bus with lifecycle keys
The package SHALL export a typed migrationEvents: EventBus<MigrationEventMap> instance built on @suaveplan/events::EventBus. The MigrationEventMap type SHALL include lifecycle-named keys "migration:started", "migration:applied", "migration:failed", "migration:completed", and "migration:rolled-back" alongside the existing "migration:before", "migration:after", "migration:plan" keys. The MigrationExecutor SHALL emit both the legacy and the canonical key for each lifecycle transition until the legacy keys are removed in a follow-up change.
Scenario: started event fires before up()
- WHEN the executor begins applying a migration
- THEN subscribers to
"migration:started"receive{ migration, direction: "up" }BEFORE the migration'supfunction is invoked
Scenario: applied event fires on success
- WHEN a migration's
upresolves successfully - THEN subscribers to
"migration:applied"receive{ migration, direction, durationMs }and"migration:after"continues to fire (back-compat)
Scenario: failed event fires on rejection
- WHEN a migration's
uprejects - THEN subscribers to
"migration:failed"receive{ migration, direction, error }AND the executor still throws
Scenario: completed event fires once per run
- WHEN the runner finishes a
run()invocation (success or failure) - THEN subscribers to
"migration:completed"receive{ summary, durationMs }exactly once
Scenario: listener throwing does not break the bus
- WHEN a registered listener throws synchronously while handling
"migration:started" - THEN the executor continues, subsequent listeners still fire, and the migration runs
Verified by: packages/database/migration/src/migration-events/migration-events.test.ts::"emits migration:started before up" AND packages/database/migration/src/migration-events/migration-events.test.ts::"emits migration:applied on success" AND packages/database/migration/src/migration-events/migration-events.test.ts::"emits migration:failed on rejection" AND packages/database/migration/src/migration-events/migration-events.test.ts::"emits migration:completed once per run" AND packages/database/migration/src/migration-events/migration-events.spec-conformance.test.ts::"listener throwing does not abort emit loop"
Requirement: BatchTargetStatus / BatchTargetProgress / TargetConfigFactory exports
The package SHALL export BatchTargetStatus ("pending" | "running" | "completed" | "failed"), BatchTargetProgress ({ targetId, status, results, error?, startedAt?, completedAt? }), and TargetConfigFactory ((targetId: string) => MigrationRunnerConfig | Promise<MigrationRunnerConfig>). The BatchMigrator SHALL emit a BatchTargetProgress update via an optional onProgress callback at every status transition.
Scenario: progress transitions through pending → running → completed
- WHEN
BatchMigrator.run()processes a target whose runner succeeds - THEN
onProgressis called three times in order with statuses"pending","running","completed"
Scenario: failure isolates from siblings
- WHEN target
"a"rejects and target"b"succeeds in the same batch - THEN
onProgressfor"a"ends in"failed"ANDonProgressfor"b"ends in"completed"
Scenario: TargetConfigFactory invoked per target
- WHEN
BatchMigratorConfig.configFactoryis supplied withtargetIds: ["a", "b"] - THEN the factory is called exactly twice — once per
targetId
Verified by: packages/database/migration/src/batch/batch-migrator/batch-migrator.test.ts::"emits progress through pending, running, completed" AND packages/database/migration/src/batch/batch-migrator/batch-migrator.test.ts::"isolates failure to a single target" AND packages/database/migration/src/batch/batch-migrator/batch-migrator.test.ts::"invokes TargetConfigFactory once per target id"
Requirement: GetAllRecordsFn injectable history retrieval
The package SHALL export GetAllRecordsFn = () => Promise<MigrationRecord[]> and the MigrationStatusDashboard SHALL accept an optional getAllRecords?: GetAllRecordsFn constructor parameter. When the parameter is supplied, the dashboard SHALL call it to retrieve history; when absent, the dashboard SHALL fall back to config.store.list().
Scenario: injected factory used for history
- WHEN the dashboard is constructed with a
getAllRecordsthat returns a fixed array - THEN
dashboard.getHistory()returns that array without invokingstore.list()
Scenario: fallback to store.list when factory absent
- WHEN the dashboard is constructed without
getAllRecords - THEN
dashboard.getHistory()invokesstore.list()and returns its result
Verified by: packages/database/migration/src/status/migration-status/migration-status.test.ts::"uses injected getAllRecords when supplied" AND packages/database/migration/src/status/migration-status/migration-status.test.ts::"falls back to store.list when factory absent"
Requirement: ValidationError type alias and MIGRATION_CODES registration
The package SHALL re-export the existing MigrationValidationError shape under the upstream-canonical name ValidationError from the package root and from types/migration.types. The package SHALL guarantee that MIGRATION_CODES are registered against the shared defineCodes registry from @suaveplan/error on module load (side-effect import) and SHALL expose an idempotent registerMigrationErrorCodes() function for downstream consumers that bundle without side-effect imports. Calling registerMigrationErrorCodes() more than once SHALL NOT throw.
Scenario: ValidationError resolves to MigrationValidationError shape
- WHEN consumer code imports
ValidationErrorfrom@suaveplan/migration - THEN the imported type is structurally identical to
MigrationValidationErrorand assignable in both directions
Scenario: error codes registered after package load
- WHEN
import "@suaveplan/migration"has executed - THEN
ErrorCodes.has("MIGRATION.LOCK_HELD")returnstruefor every code inMIGRATION_CODES
Scenario: re-registration is idempotent
- WHEN
registerMigrationErrorCodes()is called a second time after the side-effect import has already registered the codes - THEN the call does not throw and
ErrorCodes.has(...)continues to reporttrue
Verified by: packages/database/migration/src/errors/migration-error.spec-conformance.test.ts::"ValidationError is structurally MigrationValidationError" AND packages/database/migration/src/errors/migration-error.spec-conformance.test.ts::"ErrorCodes.has resolves every MIGRATION code after package load" AND packages/database/migration/src/errors/migration-error.spec-conformance.test.ts::"registerMigrationErrorCodes is idempotent"