391 lines
14 KiB
Markdown
391 lines
14 KiB
Markdown
# OpenSpec Change: enterprise-migration
|
|
|
|
- **Change ID**: enterprise-migration
|
|
- **Package**: `@suaveplan/migration`
|
|
- **Category**: universal
|
|
- **Target path**: `universal/database/migration`
|
|
- **Status**: PROPOSED
|
|
- **Date**: 2026-04-24
|
|
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/migration.md`
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Deliver `@suaveplan/migration` on the genesis branch from scratch at enterprise quality. The package provides a database-agnostic schema migration runner: a `MigrationRunner` that orchestrates up/down execution, a pluggable `MigrationStoreAdapter` for ledger persistence, a `MigrationLockAdapter` interface (memory + distributed), a dry-run reporter, a status dashboard, an optional batch migrator for multi-target rollouts, and a SurrealDB adapter. Genesis adds structured errors via `@suaveplan/error`, Zod schemas for all public types, OTel instrumentation, `AbortSignal` cancellation, logger injection via `@suaveplan/logger`, event bus integration via `@suaveplan/events`, property-based tests (fast-check), and a mitata benchmark suite. The migration runner previously bundled inside `@suaveplan/surrealdb` is now a standalone package consuming `@suaveplan/db`'s `DatabaseAdapter` interface.
|
|
|
|
---
|
|
|
|
## Current-state audit (genesis)
|
|
|
|
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
|
|
|
|
---
|
|
|
|
## Available workspace deps on genesis
|
|
|
|
| Package | Role |
|
|
|---|---|
|
|
| `@suaveplan/error` (`workspace:^`) | Base class for `MigrationError`; `defineCodes` for `MIGRATION.*` codes |
|
|
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock`, `DatabaseLike` |
|
|
| `@suaveplan/db` (`workspace:^`) | `DatabaseAdapter` interface consumed by `DatabaseMigrationStore` and `SurrealDbAdapter` |
|
|
| `@suaveplan/logger` (`workspace:^`) | `LoggerLike` structured logging throughout the runner |
|
|
| `@suaveplan/events` (`workspace:^`) | `EventBus` for migration lifecycle events (before/after each step) |
|
|
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
|
|
| `@opentelemetry/api` (`catalog:observability`) | Spans + counters on runner, planner, executor |
|
|
| `tslib` (`catalog:build`) | Runtime helpers |
|
|
|
|
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`), `@suaveplan/testing` (`workspace:^`), `@suaveplan/db-sqlite workspace:^` (integration test store backend).
|
|
|
|
---
|
|
|
|
## Preserved public API surface (from main branch)
|
|
|
|
### Runner
|
|
|
|
```ts
|
|
class MigrationRunner {
|
|
constructor(config: MigrationRunnerConfig)
|
|
up(options?: RunOptions): Promise<MigrationResult>
|
|
down(options?: RunOptions & { steps?: number }): Promise<MigrationResult>
|
|
status(): Promise<MigrationStatus[]>
|
|
validate(): Promise<ValidationResult>
|
|
}
|
|
|
|
interface MigrationRunnerConfig {
|
|
migrations: Migration[]
|
|
store: MigrationStoreAdapter
|
|
lock?: MigrationLockAdapter
|
|
logger?: LoggerLike
|
|
dryRun?: boolean
|
|
checksumValidation?: boolean
|
|
}
|
|
|
|
interface RunOptions { signal?: AbortSignal; target?: string }
|
|
```
|
|
|
|
### Types
|
|
|
|
```ts
|
|
interface Migration {
|
|
id: string
|
|
name: string
|
|
up: (ctx: MigrationContext) => Promise<void>
|
|
down?: (ctx: MigrationContext) => Promise<void>
|
|
checksum?: string
|
|
}
|
|
|
|
interface MigrationContext { db: DatabaseAdapter; logger: LoggerLike }
|
|
|
|
type MigrationDirection = 'up' | 'down'
|
|
type MigrationStatus = 'pending' | 'applied' | 'failed' | 'rolled-back'
|
|
|
|
interface MigrationRecord { id: string; name: string; appliedAt: number; checksum?: string; direction: MigrationDirection }
|
|
|
|
interface MigrationPlan { toApply: Migration[]; toRollback: Migration[] }
|
|
|
|
interface MigrationResult { applied: string[]; skipped: string[]; failed: string[]; direction: MigrationDirection; dryRun: boolean }
|
|
|
|
interface ValidationError { migrationId: string; message: string }
|
|
interface ValidationResult { valid: boolean; errors: ValidationError[] }
|
|
```
|
|
|
|
### Store adapters
|
|
|
|
```ts
|
|
interface MigrationStoreAdapter {
|
|
getApplied(): Promise<MigrationRecord[]>
|
|
record(record: MigrationRecord): Promise<void>
|
|
remove(id: string): Promise<void>
|
|
clear(): Promise<void>
|
|
}
|
|
|
|
class MemoryMigrationStore implements MigrationStoreAdapter
|
|
class DatabaseMigrationStore implements MigrationStoreAdapter {
|
|
constructor(config: DatabaseStoreConfig)
|
|
}
|
|
|
|
interface DatabaseStoreConfig { adapter: DatabaseAdapter; tableName?: string }
|
|
interface DatabaseAdapter { query<T>(sql: string, params?: unknown[]): Promise<T[]> }
|
|
```
|
|
|
|
### Lock adapters
|
|
|
|
```ts
|
|
interface MigrationLockAdapter {
|
|
acquire(timeout?: number): Promise<boolean>
|
|
release(): Promise<void>
|
|
isLocked(): Promise<boolean>
|
|
}
|
|
|
|
class MemoryMigrationLock implements MigrationLockAdapter
|
|
class DistributedMigrationLock implements MigrationLockAdapter {
|
|
constructor(config: DistributedLockConfig)
|
|
}
|
|
|
|
interface DistributedLockAdapter { acquire(key: string, ttl: number): Promise<boolean>; release(key: string): Promise<void> }
|
|
interface DistributedLockConfig { adapter: DistributedLockAdapter; key?: string; ttl?: number }
|
|
class InMemoryDistributedLockAdapter implements DistributedLockAdapter
|
|
class RedisDistributedLock implements MigrationLockAdapter {
|
|
constructor(config: RedisLockConfig)
|
|
}
|
|
interface RedisLockClient { set(key: string, value: string, options: object): Promise<string | null>; del(key: string): Promise<number> }
|
|
interface RedisLockConfig { client: RedisLockClient; key?: string; ttl?: number }
|
|
```
|
|
|
|
### Planner
|
|
|
|
```ts
|
|
function planUp(all: Migration[], applied: MigrationRecord[]): MigrationPlan
|
|
function planDown(all: Migration[], applied: MigrationRecord[], steps?: number): MigrationPlan
|
|
function sortMigrations(migrations: Migration[]): Migration[]
|
|
function checksumMigration(migration: Migration): string
|
|
```
|
|
|
|
### Executor
|
|
|
|
```ts
|
|
class MigrationExecutor {
|
|
constructor(options: ExecutorOptions)
|
|
execute(plan: MigrationPlan, direction: MigrationDirection, options?: RunOptions): Promise<MigrationResult>
|
|
}
|
|
```
|
|
|
|
### Dry-run reporter
|
|
|
|
```ts
|
|
class DryRunReporter {
|
|
constructor()
|
|
record(entry: DryRunEntry): void
|
|
report(): DryRunReport
|
|
}
|
|
|
|
interface DryRunEntry { migrationId: string; direction: MigrationDirection; riskLevel: RiskLevel; reason?: string }
|
|
interface DryRunReport { entries: DryRunEntry[]; totalRisk: RiskLevel; summary: string }
|
|
type RiskLevel = 'low' | 'medium' | 'high'
|
|
```
|
|
|
|
### Status dashboard
|
|
|
|
```ts
|
|
class MigrationStatusDashboard {
|
|
constructor(config: StatusConfig)
|
|
getAll(): Promise<ExtendedMigrationStatus[]>
|
|
getSummary(): Promise<MigrationSummary>
|
|
healthCheck(): Promise<MigrationHealthCheck>
|
|
getHistory(id: string): Promise<MigrationHistoryEntry[]>
|
|
}
|
|
```
|
|
|
|
### Batch migrator
|
|
|
|
```ts
|
|
class BatchMigrator {
|
|
constructor(config: BatchMigratorConfig)
|
|
run(options?: RunOptions): Promise<BatchMigrationResult>
|
|
}
|
|
|
|
interface BatchMigratorConfig { targets: Array<{ name: string; runner: MigrationRunner }>; concurrency?: number }
|
|
interface BatchMigrationResult { results: Map<string, MigrationResult>; failed: string[]; succeeded: string[] }
|
|
```
|
|
|
|
### SurrealDB adapter
|
|
|
|
```ts
|
|
class SurrealDbAdapter implements DatabaseAdapter {
|
|
constructor(config: SurrealDbAdapterConfig)
|
|
query<T>(sql: string, params?: unknown[]): Promise<T[]>
|
|
}
|
|
|
|
interface SurrealDbAdapterConfig { db: unknown /* Surreal instance */ }
|
|
```
|
|
|
|
### Events
|
|
|
|
```ts
|
|
interface MigrationEventMap {
|
|
'migration:before': { migration: Migration; direction: MigrationDirection }
|
|
'migration:after': { migration: Migration; direction: MigrationDirection; durationMs: number }
|
|
'migration:failed': { migration: Migration; direction: MigrationDirection; error: Error }
|
|
'migration:plan': { plan: MigrationPlan; direction: MigrationDirection }
|
|
}
|
|
|
|
const migrationEvents: EventBus<MigrationEventMap>
|
|
```
|
|
|
|
### Error codes
|
|
|
|
```ts
|
|
type MigrationErrorCode =
|
|
| 'MIGRATION_FAILED'
|
|
| 'MIGRATION_LOCK_TIMEOUT'
|
|
| 'MIGRATION_CHECKSUM_MISMATCH'
|
|
| 'MIGRATION_NOT_FOUND'
|
|
| 'MIGRATION_STORE_ERROR'
|
|
| 'MIGRATION_PLAN_INVALID'
|
|
| 'MIGRATION_ABORTED'
|
|
| 'MIGRATION_DRY_RUN'
|
|
```
|
|
|
|
---
|
|
|
|
## New additions for genesis
|
|
|
|
### 1. Structured `MigrationError`
|
|
|
|
`MigrationError` extends `SuaveplanError` from `@suaveplan/error` carrying a `MigrationErrorCode` discriminant. Internal throw sites use named factory helpers.
|
|
|
|
### 2. Zod schemas
|
|
|
|
- `MigrationRunnerConfigSchema`
|
|
- `RunOptionsSchema`
|
|
- `BatchMigratorConfigSchema`
|
|
- `DatabaseStoreConfigSchema`
|
|
- `DistributedLockConfigSchema`
|
|
- `RedisLockConfigSchema`
|
|
|
|
### 3. Logger injection
|
|
|
|
`MigrationRunnerConfig.logger` is now typed as `LoggerLike` (from `@suaveplan/types`). All runner, planner, and executor operations emit structured log entries at `debug` / `info` / `error` levels with migration ID, direction, and duration.
|
|
|
|
### 4. Event bus integration
|
|
|
|
`MigrationRunnerConfig` gains an optional `events?: EventBus<MigrationEventMap>`. When provided the runner emits `migration:before`, `migration:after`, `migration:failed`, `migration:plan` events. The exported `migrationEvents` singleton is available for packages that do not control runner construction.
|
|
|
|
### 5. OTel instrumentation
|
|
|
|
| Operation | Signal |
|
|
|---|---|
|
|
| `runner.up` / `runner.down` | span `migration.run`, attribute `migration.direction`, `migration.count` |
|
|
| per-migration execution | span `migration.execute`, attribute `migration.id`, histogram `migration.duration_ms` |
|
|
| `runner.status` | counter `migration.status_query_total` |
|
|
| `batchMigrator.run` | span `migration.batch`, attribute `migration.target_count` |
|
|
|
|
### 6. Checksum validation
|
|
|
|
`checksumMigration` hashes the serialised `up` function body. When `checksumValidation: true` (default on `MigrationRunner`), mismatched checksums between the registered migration and the store record throw `MigrationError(MIGRATION_CHECKSUM_MISMATCH)` before applying.
|
|
|
|
### 7. Property-based tests (fast-check)
|
|
|
|
- `planUp` idempotency: applying `planUp` to an already fully-applied set returns an empty `toApply` list.
|
|
- `sortMigrations` stability: for any permutation of N migrations, `sortMigrations` returns the same order.
|
|
- `checksumMigration` determinism: calling checksum on the same migration object twice returns the same string.
|
|
- `BatchMigrator` partial failure: if K of N targets fail, the result has exactly K failed entries.
|
|
|
|
### 8. Benchmark suite
|
|
|
|
- `planUp_100`: plan for 100 pending migrations.
|
|
- `runner_up_10_memory`: run 10 no-op migrations against `MemoryMigrationStore`.
|
|
- `checksumMigration_1k`: 1000 checksum computations.
|
|
|
|
---
|
|
|
|
## File layout
|
|
|
|
```text
|
|
universal/database/migration/
|
|
package.json
|
|
tsconfig.json
|
|
vitest.config.ts
|
|
src/
|
|
index.ts # Public barrel
|
|
error-codes.ts # MIGRATION.* codes
|
|
migration-events.ts # migrationEvents singleton + MigrationEventMap
|
|
constants/
|
|
index.ts # DEFAULT_TABLE_NAME, DEFAULT_LOCK_TTL
|
|
types/
|
|
migration.types.ts # All public TS interfaces
|
|
migration.types.md
|
|
schemas.ts # Zod schemas
|
|
schemas.md
|
|
planner/
|
|
migration-planner/
|
|
migration-planner.ts # planUp, planDown, sortMigrations, checksumMigration
|
|
migration-planner.md
|
|
migration-planner.test.ts
|
|
index.ts
|
|
runner/
|
|
migration-runner/
|
|
migration-runner.ts # MigrationRunner
|
|
migration-runner.md
|
|
migration-runner.types.ts
|
|
migration-runner.test.ts
|
|
index.ts
|
|
migration-executor/
|
|
migration-executor.ts # MigrationExecutor
|
|
migration-executor.md
|
|
migration-executor.test.ts
|
|
index.ts
|
|
dry-run-reporter/
|
|
dry-run-reporter.ts
|
|
dry-run-reporter.md
|
|
dry-run-reporter.test.ts
|
|
index.ts
|
|
store/
|
|
memory-store/
|
|
memory-store.ts # MemoryMigrationStore
|
|
memory-store.md
|
|
memory-store.test.ts
|
|
index.ts
|
|
database-store/
|
|
database-store.ts # DatabaseMigrationStore
|
|
database-store.md
|
|
database-store.types.ts
|
|
database-store.test.ts
|
|
index.ts
|
|
lock/
|
|
memory-lock/
|
|
memory-lock.ts
|
|
memory-lock.md
|
|
memory-lock.test.ts
|
|
index.ts
|
|
distributed-lock/
|
|
distributed-lock.ts
|
|
distributed-lock.md
|
|
distributed-lock.types.ts
|
|
distributed-lock.test.ts
|
|
index.ts
|
|
redis-lock/
|
|
redis-lock.ts
|
|
redis-lock.md
|
|
redis-lock.types.ts
|
|
redis-lock.test.ts
|
|
index.ts
|
|
status/
|
|
migration-status/
|
|
migration-status.ts # MigrationStatusDashboard
|
|
migration-status.md
|
|
migration-status.types.ts
|
|
migration-status.test.ts
|
|
index.ts
|
|
batch/
|
|
batch-migrator/
|
|
batch-migrator.ts
|
|
batch-migrator.md
|
|
batch-migrator.types.ts
|
|
batch-migrator.test.ts
|
|
index.ts
|
|
adapters/
|
|
surrealdb-adapter/
|
|
surrealdb-adapter.ts
|
|
surrealdb-adapter.md
|
|
surrealdb-adapter.types.ts
|
|
surrealdb-adapter.test.ts
|
|
index.ts
|
|
__benchmarks__/
|
|
migration.bench.ts
|
|
tests/
|
|
integration.test.ts # Full up/down cycle with SQLite store (db-sqlite)
|
|
property.test.ts # fast-check property tests
|
|
```
|
|
|
|
---
|
|
|
|
## Impact
|
|
|
|
**Downstream packages**: any package that manages database schema evolution. `@suaveplan/surrealdb` provides a `SurrealAdapter` that implements `DatabaseAdapter`; `@suaveplan/migration`'s `SurrealDbAdapter` wraps that for the migration store.
|
|
|
|
**Breaking changes from main-branch API**: none. New optional fields (`events`, `signal`) are additive.
|
|
|
|
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/db`, `@suaveplan/logger`, `@suaveplan/events` must be published to Verdaccio before this package can be built or tested.
|