Initial Commit
This commit is contained in:
178
openspec/archive/2026-04-23-enterprise-db/proposal.md
Normal file
178
openspec/archive/2026-04-23-enterprise-db/proposal.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# OpenSpec Change: enterprise-db
|
||||
|
||||
- **Change ID**: enterprise-db
|
||||
- **Package**: `@suaveplan/db`
|
||||
- **Category**: universal
|
||||
- **Target path**: `universal/database/db`
|
||||
- **Status**: IN PROGRESS
|
||||
- **Date**: 2026-04-23
|
||||
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/db.md`
|
||||
|
||||
## Summary
|
||||
|
||||
`@suaveplan/db` is the universal relational database interface for the genesis monorepo. It ships the abstract `DatabaseAdapter` contract, structured errors with stable `DB.*` codes, Zod-validated config types, injectable connection-singleton helpers, and an `AbortSignal`-aware migration runner interface. The package is the stable foundation that `@suaveplan/db-sqlite` (and future Postgres / Turso adapters) build upon — its public API surface must be locked before any concrete adapter ships.
|
||||
|
||||
This genesis build replaces the main-branch implementation with a fully enterprise-quality package: `DatabaseAdapter` extends `DatabaseLike` from `@suaveplan/types`, errors extend `@suaveplan/error` with a stable code registry, every async entrypoint accepts an `AbortSignal`, all public types have Zod schemas, and OTel spans / histograms / counters are wired throughout. Connection singletons shed global module state in favour of injectable registry maps so they are safe in test isolation and serverless environments.
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — this is a fresh build. Main branch ships `packages/universal/db` with the surface listed below; genesis rewrites from scratch.
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Ref | Role |
|
||||
|---|---|---|
|
||||
| `@suaveplan/error` | `workspace:^` | Structured error base class + `defineCodes` |
|
||||
| `@suaveplan/types` | `workspace:^` | `DatabaseLike` minimal adapter contract |
|
||||
| `@suaveplan/events` | `workspace:^` | Optional lifecycle event bus (connect / disconnect / error events) |
|
||||
| `zod` | `catalog:forms` | Runtime schema validation |
|
||||
| `@opentelemetry/api` | `catalog:observability` | OTel spans, counters, histograms (no-op when no exporter) |
|
||||
| `tslib` | `catalog:build` | TypeScript helpers |
|
||||
|
||||
Dev deps (all `catalog:testing`): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`.
|
||||
|
||||
## Preserved public API surface (from main branch)
|
||||
|
||||
All of the following symbols are preserved verbatim or as strict supersets.
|
||||
|
||||
### `DatabaseAdapter` (interface)
|
||||
|
||||
```ts
|
||||
// main-branch shape — genesis extends this with DatabaseLike
|
||||
export interface DatabaseAdapter {
|
||||
connect(config?: Partial<DatabaseConfig>): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||
execute(sql: string, params?: unknown[]): Promise<void>;
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
||||
migrate(config?: MigrationConfig): Promise<MigrationResult>;
|
||||
readonly isConnected: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Connection singleton helpers
|
||||
|
||||
```ts
|
||||
getSingleton(
|
||||
name: string,
|
||||
factory: () => DatabaseAdapter,
|
||||
config?: Partial<DatabaseConfig>
|
||||
): Promise<DatabaseAdapter>;
|
||||
|
||||
closeSingleton(name: string): Promise<void>;
|
||||
hasSingleton(name: string): boolean;
|
||||
closeAllSingletons(): Promise<void>;
|
||||
```
|
||||
|
||||
### Errors
|
||||
|
||||
```ts
|
||||
class DatabaseError extends Error {
|
||||
readonly code: string;
|
||||
readonly cause?: unknown;
|
||||
constructor(message: string, code: string, cause?: Error);
|
||||
}
|
||||
|
||||
class MigrationError extends DatabaseError {
|
||||
readonly migration: string;
|
||||
constructor(migration: string, detail: string, cause?: Error);
|
||||
}
|
||||
```
|
||||
|
||||
### Config and result types
|
||||
|
||||
```ts
|
||||
interface DatabaseConfig {
|
||||
filename?: string; // embedded (SQLite, ':memory:')
|
||||
url?: string; // network (postgres://, ...)
|
||||
readonlyMode?: boolean;
|
||||
}
|
||||
|
||||
interface MigrationConfig {
|
||||
migrationsFolder?: string; // default './drizzle'
|
||||
migrationsTable?: string; // default '__drizzle_migrations'
|
||||
}
|
||||
|
||||
interface MigrationStatus {
|
||||
name: string;
|
||||
applied: boolean;
|
||||
appliedAt?: Date;
|
||||
}
|
||||
|
||||
interface MigrationResult {
|
||||
applied: number;
|
||||
migrations: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## New additions for genesis
|
||||
|
||||
### `DatabaseAdapter` extends `DatabaseLike`
|
||||
|
||||
`DatabaseLike` (from `@suaveplan/types`) defines `query`, `execute`, `transaction`, and `close`. The genesis `DatabaseAdapter` extends this contract and adds:
|
||||
|
||||
- `connect(config?, opts?: { signal?: AbortSignal }): Promise<void>` — lifecycle open
|
||||
- `disconnect(opts?: { signal?: AbortSignal }): Promise<void>` — lifecycle close (maps to `DatabaseLike.close`)
|
||||
- `migrate(config?: MigrationConfig, opts?: { signal?: AbortSignal }): Promise<MigrationResult>`
|
||||
- `readonly isConnected: boolean` — connection state predicate
|
||||
|
||||
`query`, `execute`, and `transaction` gain an optional trailing options object `{ signal?: AbortSignal }` while remaining backward-compatible.
|
||||
|
||||
The `transaction` callback receives a `DatabaseLike` (the active transaction handle) matching `DatabaseLike`'s own signature — fixing the main-branch deviation where `fn` accepted no argument.
|
||||
|
||||
### Structured errors extending `@suaveplan/error`
|
||||
|
||||
`DatabaseError` extends `SuaveplanError` (from `@suaveplan/error`) with stable codes registered via `defineCodes`:
|
||||
|
||||
| Code | When thrown |
|
||||
|---|---|
|
||||
| `DB.CONNECTION_FAILED` | `connect()` fails |
|
||||
| `DB.NOT_CONNECTED` | Operation attempted before `connect()` |
|
||||
| `DB.QUERY_FAILED` | `query()` or `execute()` throws |
|
||||
| `DB.TRANSACTION_FAILED` | `transaction()` throws or rolls back |
|
||||
| `DB.MIGRATION_FAILED` | `migrate()` fails on a specific migration |
|
||||
|
||||
`MigrationError` extends `DatabaseError` with `DB.MIGRATION_FAILED` and adds a `migration: string` field (the migration name).
|
||||
|
||||
### Zod schemas
|
||||
|
||||
`DatabaseConfigSchema` and `MigrationConfigSchema` (exported from `*.schemas.ts` siblings). Validated at the boundary of `getSingleton` and `migrate` helpers.
|
||||
|
||||
### Injectable registry for singleton helpers
|
||||
|
||||
`getSingleton`, `closeSingleton`, `hasSingleton`, and `closeAllSingletons` accept an optional `registry: Map<string, DatabaseAdapter>` parameter. When omitted a module-level default map is used (preserving the main-branch behaviour). This allows test suites to pass isolated registries with zero cross-test bleed.
|
||||
|
||||
### OTel instrumentation
|
||||
|
||||
All public async entrypoints are wrapped in `@opentelemetry/api` spans. Additionally:
|
||||
|
||||
- **Histogram** `db.query.duration_ms` — labelled by `db.operation` (`query` / `execute`)
|
||||
- **Counter** `db.connection.count` — incremented on successful `connect`, decremented on `disconnect`
|
||||
- **Counter** `db.transaction.count` — labelled by outcome (`committed` / `rolled_back`)
|
||||
- **Counter** `db.migration.count` — incremented per applied migration
|
||||
|
||||
All instrumentation is lazy-initialised; no global OTel state at module load.
|
||||
|
||||
### AbortSignal propagation
|
||||
|
||||
Every async method (`connect`, `disconnect`, `query`, `execute`, `transaction`, `migrate`) accepts `opts?: { signal?: AbortSignal }`. The concrete adapter is responsible for honouring the signal — the abstract contract documents the expectation. Abort throws `DatabaseError` with code `DB.QUERY_FAILED` (or the relevant operation code) and `cause` set to an `AbortError`.
|
||||
|
||||
### Property-based tests (fast-check)
|
||||
|
||||
Fast-check arbitraries model the connection-state machine:
|
||||
|
||||
- An unconnected adapter may not have `query`, `execute`, `transaction`, or `migrate` succeed (invariant).
|
||||
- `connect → disconnect → connect` is idempotent (no state leak).
|
||||
- `getSingleton` with the same name and registry always returns the same reference.
|
||||
- `closeAllSingletons` leaves the registry empty regardless of concurrency order.
|
||||
|
||||
### Benchmarks (mitata)
|
||||
|
||||
`src/__benchmarks__/adapter.bench.ts` — baseline numbers for singleton lookup, OTel span overhead, schema validation. Published in README.
|
||||
|
||||
## Impact
|
||||
|
||||
- Adds one new package: `universal/database/db`
|
||||
- Adds one new spec capability: `database/adapter`
|
||||
- Unblocks `@suaveplan/db-sqlite` (direct dep) and every higher-layer package that injects `DatabaseLike`
|
||||
- No changes to existing packages; `@suaveplan/types.DatabaseLike` is already shipped
|
||||
132
openspec/archive/2026-04-23-enterprise-db/tasks.md
Normal file
132
openspec/archive/2026-04-23-enterprise-db/tasks.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Tasks: enterprise-db
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create directory `universal/database/db/` with `package.json` (`name: "@suaveplan/db"`, `version: "0.1.0"`, `sideEffects: false`). Add deps: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`. Add dev deps: `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `mitata catalog:testing`.
|
||||
- [x] 1.2 Create `tsconfig.json` extending `@suaveplan/typescript-config/library.json`. Create `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` with `pool: 'forks'`, `provider: 'istanbul'`, thresholds `100/100/100/100`.
|
||||
- [x] 1.3 Add Turborepo pipeline entries (`lint`, `typecheck`, `test`, `test:coverage`, `build`) in `turbo.json` if not already covered by root glob. Verify `universal/database/*` is in the root `package.json` workspace glob.
|
||||
- [x] 1.4 Add `@suaveplan/db` to `CLAUDE.md` package listing and `README.md` table in the repo root.
|
||||
|
||||
## 2. Types and errors
|
||||
|
||||
- [x] 2.1 Create `src/errors/db-codes.ts`: call `defineCodes` from `@suaveplan/error` to register `DB.CONNECTION_FAILED`, `DB.NOT_CONNECTED`, `DB.QUERY_FAILED`, `DB.TRANSACTION_FAILED`, `DB.MIGRATION_FAILED` with stable integer codes and human-readable descriptions.
|
||||
- [x] 2.2 Create `src/errors/database-error.ts`: `DatabaseError extends SuaveplanError` with `readonly code: string` and typed `cause`. Constructor accepts `(message, code, cause?)`. Export from `src/errors/index.ts`.
|
||||
- [x] 2.3 Create `src/errors/migration-error.ts`: `MigrationError extends DatabaseError` with `readonly migration: string`. Constructor sets code `DB.MIGRATION_FAILED` and formats `message` as `"Migration '<name>' failed: <detail>"`. Export from barrel.
|
||||
- [x] 2.4 Write `src/errors/database-error.test.ts` and `src/errors/migration-error.test.ts`: verify `instanceof SuaveplanError`, stable `code` value, `cause` chain, `name` property, and message format. { timeout: 5000 }.
|
||||
- [x] 2.5 Write `src/errors/database-error.md` and `src/errors/migration-error.md` (≥200 words each, all required sections).
|
||||
- [x] 2.6 Create `src/types/database-config.ts`: `DatabaseConfig` interface (`filename?`, `url?`, `readonlyMode?`). Create `src/types/migration-config.ts`: `MigrationConfig` (`migrationsFolder?`, `migrationsTable?`). Create `src/types/migration-result.ts`: `MigrationResult` (`applied: number`, `migrations: string[]`) and `MigrationStatus` (`name: string`, `applied: boolean`, `appliedAt?: Date`). Export all from `src/types/index.ts`.
|
||||
- [x] 2.7 Create `src/types/database-config.schemas.ts` and `src/types/migration-config.schemas.ts` with Zod schemas `DatabaseConfigSchema` and `MigrationConfigSchema`. Include `z.object(...)` with `.optional()` fields and sensible `.default()` coercions where applicable.
|
||||
- [x] 2.8 Write `src/types/types.test.ts`: parse valid and invalid inputs against both schemas; verify Zod error messages and coerced defaults. { timeout: 5000 }.
|
||||
- [x] 2.9 Write `src/types/database-config.md` and `src/types/migration-config.md` (≥200 words each).
|
||||
|
||||
## 3. `DatabaseAdapter` abstract interface
|
||||
|
||||
- [x] 3.1 Create `src/adapter/database-adapter.ts`: define `DatabaseAdapter` extending `DatabaseLike` (from `@suaveplan/types`). Full signature:
|
||||
```ts
|
||||
interface DatabaseAdapter extends DatabaseLike {
|
||||
connect(config?: Partial<DatabaseConfig>, opts?: { signal?: AbortSignal }): Promise<void>;
|
||||
disconnect(opts?: { signal?: AbortSignal }): Promise<void>;
|
||||
query<T = Record<string, unknown>>(sql: string, params?: readonly unknown[], opts?: { signal?: AbortSignal }): Promise<T[]>;
|
||||
execute(sql: string, params?: readonly unknown[], opts?: { signal?: AbortSignal }): Promise<{ rowsAffected: number }>;
|
||||
transaction<T>(fn: (tx: DatabaseLike) => Promise<T>, opts?: { signal?: AbortSignal }): Promise<T>;
|
||||
migrate(config?: MigrationConfig, opts?: { signal?: AbortSignal }): Promise<MigrationResult>;
|
||||
readonly isConnected: boolean;
|
||||
}
|
||||
```
|
||||
Note: `disconnect` maps to `DatabaseLike.close` — `DatabaseAdapter` must satisfy `DatabaseLike` structurally; add a `close(): Promise<void>` alias declaration if needed.
|
||||
- [x] 3.2 Create `src/adapter/index.ts` barrel re-exporting `DatabaseAdapter`.
|
||||
- [x] 3.3 Write `src/adapter/database-adapter.test.ts`: create a minimal in-memory stub (no `vi.mock`) that implements every method; verify structural compatibility with `DatabaseLike`; verify `isConnected` transitions on connect/disconnect; verify `AbortSignal` respected by the stub. { timeout: 5000 }.
|
||||
- [x] 3.4 Write `src/adapter/database-adapter.md` (≥200 words, full API reference, both examples).
|
||||
|
||||
## 4. Connection singleton helpers
|
||||
|
||||
- [x] 4.1 Create `src/connection/singleton.ts`: implement `getSingleton`, `closeSingleton`, `hasSingleton`, `closeAllSingletons`. Each function accepts an optional trailing `registry?: Map<string, DatabaseAdapter>` parameter; when omitted, fall back to a module-level default `Map`. `getSingleton` validates `config` with `DatabaseConfigSchema` before calling `adapter.connect`. Wrap `connect` in a try/catch; throw `DatabaseError` with `DB.CONNECTION_FAILED` on failure.
|
||||
- [x] 4.2 Create `src/connection/index.ts` barrel.
|
||||
- [x] 4.3 Write `src/connection/singleton.test.ts`:
|
||||
- Pass an isolated registry `Map` to every call — no cross-test bleed.
|
||||
- `getSingleton` creates and caches; second call returns same reference.
|
||||
- `hasSingleton` reflects registry state accurately.
|
||||
- `closeSingleton` calls `disconnect` and removes entry.
|
||||
- `closeAllSingletons` drains the registry even when one disconnect rejects (test error-isolation path).
|
||||
- `getSingleton` with already-connected adapter skips `connect`.
|
||||
- Abort via `AbortSignal` propagated to `connect`.
|
||||
- { timeout: 30000 } for async tests.
|
||||
- [x] 4.4 Write `src/connection/singleton.md` (≥200 words, registry-isolation example mandatory in advanced section).
|
||||
|
||||
## 5. Migration types and runner interface
|
||||
|
||||
- [x] 5.1 Create `src/migration/migration-runner.ts`: define `MigrationRunner` interface:
|
||||
```ts
|
||||
interface MigrationRunner {
|
||||
pendingMigrations(config?: MigrationConfig): Promise<MigrationStatus[]>;
|
||||
applyMigration(name: string, sql: string, opts?: { signal?: AbortSignal }): Promise<void>;
|
||||
run(config?: MigrationConfig, opts?: { signal?: AbortSignal }): Promise<MigrationResult>;
|
||||
}
|
||||
```
|
||||
This is the contract `DatabaseAdapter.migrate` delegates to in concrete adapters.
|
||||
- [x] 5.2 Create `src/migration/index.ts` barrel re-exporting `MigrationRunner`, `MigrationStatus`, `MigrationResult`, `MigrationConfig`, and `MigrationError`.
|
||||
- [x] 5.3 Write `src/migration/migration-runner.test.ts`: stub `MigrationRunner` implementation; verify `run` aggregates `applyMigration` calls; verify `MigrationError` thrown with correct `migration` field on failure; verify `AbortSignal` halts mid-run. { timeout: 30000 }.
|
||||
- [x] 5.4 Write `src/migration/migration-runner.md` (≥200 words).
|
||||
|
||||
## 6. OTel instrumentation
|
||||
|
||||
- [x] 6.1 Create `src/telemetry/instruments.ts`: lazy-initialise (using a module-level singleton guard, NOT top-level `getMeter()` calls) the following instruments via `@opentelemetry/api`:
|
||||
- Histogram `db.query.duration_ms` — attributes: `db.operation` (`query` | `execute`)
|
||||
- UpDownCounter `db.connection.count` — attributes: `db.name`
|
||||
- Counter `db.transaction.count` — attributes: `db.outcome` (`committed` | `rolled_back`)
|
||||
- Counter `db.migration.count` — attributes: `db.migration.name`
|
||||
- [x] 6.2 Create `src/telemetry/trace.ts`: helper `withDbSpan<T>(tracer, name, fn, attrs?)` that creates a span, calls `fn(span)`, sets span status on success/error, and ends the span. Used internally by singleton helpers and the migration runner helper.
|
||||
- [x] 6.3 Create `src/telemetry/index.ts` barrel.
|
||||
- [x] 6.4 Write `src/telemetry/instruments.test.ts` and `src/telemetry/trace.test.ts`: verify no OTel error thrown when no SDK is registered (no-op provider); verify span name and attributes passed correctly; verify histogram records timing. { timeout: 5000 }.
|
||||
- [x] 6.5 Write `src/telemetry/instruments.md` and `src/telemetry/trace.md` (≥200 words each).
|
||||
|
||||
## 7. Benchmarks
|
||||
|
||||
- [x] 7.1 Create `src/__benchmarks__/singleton.bench.ts` (mitata): benchmark `getSingleton` cache-hit path (no connect), `hasSingleton` lookup, and `DatabaseConfigSchema.parse` for a valid config. Publish numbers in `README.md`.
|
||||
- [x] 7.2 Create `src/__benchmarks__/otel.bench.ts` (mitata): benchmark `withDbSpan` overhead against a raw async call using the OTel no-op provider.
|
||||
|
||||
## 8. Property-based tests (fast-check)
|
||||
|
||||
- [x] 8.1 Create `src/__tests__/state-machine.property.test.ts`: fast-check arbitraries for:
|
||||
- Connection-state invariant: any sequence of `connect`/`disconnect` on a stub adapter leaves `isConnected` consistent with the last operation.
|
||||
- Registry invariant: any sequence of `getSingleton` / `closeSingleton` / `closeAllSingletons` calls on an isolated registry map never leaves a dangling reference.
|
||||
- `DatabaseConfigSchema` round-trip: arbitrary partial config objects parse without throwing when all present fields are valid.
|
||||
- `getSingleton` idempotency: calling with the same `name` twice on a connected registry returns `===` the same object.
|
||||
- { timeout: 30000 }
|
||||
|
||||
## 9. Docs and README
|
||||
|
||||
- [x] 9.1 Write `README.md` at the package root: description, `bun add @suaveplan/db`, quick-start (getSingleton + query), **Modules** section linking all co-located `.md` files, benchmark table (placeholder — fill after 7.x runs).
|
||||
- [x] 9.2 Verify every implementation `.ts` (non-test, non-barrel, non-types) has a sibling `.md` with ≥200 words and both required code examples.
|
||||
- [x] 9.3 Run the missing-md scan from `openspec/AGENTS.md §11` — zero output required.
|
||||
|
||||
## 10. Barrel and subpath exports
|
||||
|
||||
- [x] 10.1 Create `src/index.ts` top-level barrel re-exporting: `DatabaseAdapter`, `getSingleton`, `hasSingleton`, `closeSingleton`, `closeAllSingletons`, `DatabaseError`, `MigrationError`, `DatabaseConfig`, `MigrationConfig`, `MigrationResult`, `MigrationStatus`, `MigrationRunner`, `DatabaseConfigSchema`, `MigrationConfigSchema`, DB error codes.
|
||||
- [x] 10.2 Add subpath exports to `package.json`:
|
||||
- `"."` — top-level barrel
|
||||
- `"./adapter/database-adapter"` — adapter contract
|
||||
- `"./connection/singleton"` — singleton helpers
|
||||
- `"./errors/database-error"` — DatabaseError + MigrationError
|
||||
- `"./migration/migration-runner"` — MigrationRunner + migration types
|
||||
- `"./telemetry/instruments"` — OTel instruments
|
||||
- `"./telemetry/trace"` — withDbSpan helper
|
||||
- `"./types/database-config"` — DatabaseConfig + schema
|
||||
- `"./types/migration-config"` — MigrationConfig + schema
|
||||
- [x] 10.3 Add a tree-shake test (`src/__tests__/tree-shake.test.ts`) that dynamically imports each subpath and asserts no unexpected side effects (global state mutations, OTel registration). Confirm `sideEffects: false` is set in `package.json`.
|
||||
|
||||
## 11. Verify and publish
|
||||
|
||||
- [x] 11.1 Run the full pre-ship verification sequence from `openspec/AGENTS.md §11`:
|
||||
1. `bunx turbo lint typecheck test --filter=@suaveplan/db` — green
|
||||
2. `bunx turbo test:coverage --filter=@suaveplan/db` — 100/100/100/100
|
||||
3. `bunx turbo build --filter=@suaveplan/db` — clean dist
|
||||
4. Banned-pattern scan — zero matches
|
||||
5. Missing-md scan — zero matches
|
||||
6. README check — present
|
||||
7. Bare-version check — zero bare versions
|
||||
8. Subpath export count matches feature module count
|
||||
- [x] 11.2 Confirm `@suaveplan/db-sqlite` can be started (it depends on this package — interface stability check only; do not build db-sqlite as part of this task).
|
||||
- [x] 11.3 `bun scripts/publish.ts --package @suaveplan/db`
|
||||
- [x] 11.4 Fold spec delta into `openspec/specs/database/adapter/spec.md` (create if absent).
|
||||
- [x] 11.5 Move `openspec/changes/enterprise-db/` → `openspec/archive/2026-04-23-enterprise-db/`.
|
||||
Reference in New Issue
Block a user