Files
suaveplan/openspec/changes/migration-adapters-completion/specs/migration/spec.md
2026-05-27 23:29:53 +01:00

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. (See openspec/AGENTS.md Section 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 true AND a subsequent lock.acquire("migrate-tenant-1", 60000) resolves to false

Scenario: lockId isolation

  • WHEN lock.acquire("a", 60000) resolves to true
  • THEN lock.acquire("b", 60000) also resolves to true

Scenario: extend refreshes TTL

  • WHEN a lock is acquired with ttlMs: 50 and extend(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: 50 and no release or extend is called
  • THEN after 100ms, a fresh acquire(lockId, 60000) resolves to true

Scenario: legacy MigrationLockAdapter alias preserved

  • WHEN consumer code imports MigrationLockAdapter from @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 fresh MemoryMigrationStore
  • 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 then store.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" and markApplied("001") is called
  • THEN list() returns the record with status: "applied"

Scenario: markFailed records error

  • WHEN markFailed("001", "constraint violation") is called against an existing record
  • THEN list() returns the record with status: "failed" AND error: "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 MigrationStoreAdapter from @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's up function is invoked

Scenario: applied event fires on success

  • WHEN a migration's up resolves 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 up rejects
  • 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 onProgress is 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 onProgress for "a" ends in "failed" AND onProgress for "b" ends in "completed"

Scenario: TargetConfigFactory invoked per target

  • WHEN BatchMigratorConfig.configFactory is supplied with targetIds: ["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 getAllRecords that returns a fixed array
  • THEN dashboard.getHistory() returns that array without invoking store.list()

Scenario: fallback to store.list when factory absent

  • WHEN the dashboard is constructed without getAllRecords
  • THEN dashboard.getHistory() invokes store.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 ValidationError from @suaveplan/migration
  • THEN the imported type is structurally identical to MigrationValidationError and assignable in both directions

Scenario: error codes registered after package load

  • WHEN import "@suaveplan/migration" has executed
  • THEN ErrorCodes.has("MIGRATION.LOCK_HELD") returns true for every code in MIGRATION_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 report true

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"