Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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 -->