# 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`, `release(lockId: string): Promise`, and `extend(lockId: string, ttlMs: number): Promise`. 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`, `append(record: MigrationRecord): Promise`, `markFailed(id: string, error: string): Promise`, `markApplied(id: string): Promise`, and `getLatestVersion(): Promise`. 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` 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`). 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` 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"`