Initial Commit
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user