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,103 @@
# foundation-fix-gap-4-db-split
- **Status**: PROPOSED
- **Spec deltas**:
- ./specs/api-host/spec.md
- ./specs/api-types/spec.md
## What & why
The post-Stage-4 foundation-coherence audit (Gap #4) flagged that
`HostAdapters.db` was shaped for the narrow `LedgerDb` interface
(`bun:sqlite`-style `prepare(sql)` / `run(sql)`) consumed by the
host-internal `PluginLedger`, while `@suaveplan/api-db-adapter`
produces a multi-engine `AdminDatabaseLike` façade plugins consume.
The two surfaces are type-incompatible — there is no safe way to
share a single `adapters.db` slot. The E2E foundation-stack server
documented the gap explicitly (`@suaveplan/api-db-adapter` was
swapped out for a raw `bun:sqlite` handle to satisfy the ledger).
This change splits the slot:
1. **`HostAdapters.ledgerDb: LedgerDb`** — host-private, narrow
SQLite handle the `PluginLedger` keeps using. Renamed from the
former `HostAdapters.db`.
2. **`HostAdapters.db?: AdminDatabaseLike`** — optional,
consumer-facing. Populated by `createDbAdapter(...).admin` from
`@suaveplan/api-db-adapter`. The host threads it onto
`PluginContext.db` (admin-scoped) and applies
`createTenantScopedWrapper(admin, tenantId)` per request to
expose it on `RequestContext.db` (tenant-scoped).
3. **`PluginContext.db: AdminDatabaseLike | null`** + **`RequestContext.db: TenantScopedDatabaseLike | null`** added as
top-level fields. `null` when (a) the host was booted without
`adapters.db` configured, or (b) for `RequestContext` only, the
tenant resolver chain returned `null` on this request.
4. **E2E foundation-stack** restores `createDbAdapter({ engine:
"sqlite", url: ":memory:" })` as `adapters.db` and adds a
`GET /api/db-ping` route exercising `ctx.db?.execute` +
`ctx.db?.query` through the tenant-scoped wrapper. A
companion test asserts 200 + non-empty rows.
## Impact
### Affected dependents
- `@suaveplan/api-types` — `PluginContext` / `RequestContext` /
`JobContext` / `ConnectionContext` each gain a `db` field. Source-
incompatible for any consumer constructing a `PluginContext`
literal — but the only in-tree literal consumer is the host's own
test harness (`{} as never` mock), which already type-erases.
- `@suaveplan/api-host` —
- `HostAdapters.db` renamed to `ledgerDb`; new optional
`HostAdapters.db: AdminDatabaseLike`.
- Host config Zod schema updated.
- `RequestPipelineOptions` gains optional `db: AdminDatabaseLike`.
- `buildRequestContext` accepts + propagates `db`.
- Request-pipeline driver applies `createTenantScopedWrapper`
when both `adapters.db` is set AND a tenant is resolved.
- New runtime dep `@suaveplan/api-db-adapter`.
- `@suaveplan/e2e-foundation-stack` — wires `createDbAdapter` +
registers an inline `bun:sqlite` engine factory; adds the
`/api/db-ping` route + an E2E test.
Blast radius (atomic update list in this commit):
- `packages/api-stack/api/api-types/src/plugin-context/plugin-context.types.ts`
- `packages/api-stack/api/api-host/src/host/host.types.ts`
- `packages/api-stack/api/api-host/src/host/host.ts`
- `packages/api-stack/api/api-host/src/host/host.test.ts`
- `packages/api-stack/api/api-host/src/request-pipeline/request-pipeline.types.ts`
- `packages/api-stack/api/api-host/src/request-pipeline/request-pipeline-driver.ts`
- `packages/api-stack/api/api-host/src/request-pipeline/request-pipeline-helpers.ts`
- `packages/api-stack/api/api-host/package.json`
- `e2e/foundation-stack/src/server.ts`
- `e2e/foundation-stack/src/fixture-plugin.ts`
- `e2e/foundation-stack/tests/foundation.spec.ts`
- `e2e/foundation-stack/package.json`
### Backward compatibility
The `HostAdapters.db → HostAdapters.ledgerDb` rename is a hard rename
(no compatibility shim) because the genesis branch has no shipped
consumers outside the in-tree atomic update list above.
`PluginContext.db` / `RequestContext.db` are NEW fields, additive at
the type level for any future consumer.
## Parity Checklist
This is a genesis-original fix; no upstream parity table required.
## Verification
`bunx turbo lint typecheck test build --filter=@suaveplan/api-host --filter=@suaveplan/api-types --filter=@suaveplan/api-db-adapter --filter=@suaveplan/e2e-foundation-stack`
→ green on api-host / api-types / api-db-adapter. (The e2e package's
typecheck error is a pre-existing module-resolution issue unrelated
to this change — confirmed via git-stash baseline.)
`bunx turbo test:coverage --filter=@suaveplan/api-host --filter=@suaveplan/api-types`
→ 100% lines on touched modules (statements/branches/functions
unchanged at baseline).
E2E suite: `bun test:e2e` → 11/11 pass, including the new
`db adapter — ctx.db wired through tenant-scoped wrapper` test.

View File

@@ -0,0 +1,51 @@
# Spec Delta — api-host — foundation-fix-gap-4-db-split
## MODIFIED Requirements
### Requirement: `createHost(config)` composes every internal driver
The package SHALL expose a top-level `createHost(config: HostConfig)` entry point that composes the lifecycle driver, reload driver, plugin ledger, capability registry, request pipeline, and admin API into a `Host` whose `fetch(request)` method drives the full §6 pipeline. Config SHALL be Zod-validated at the entry; failures raise `HOST.CONFIG_INVALID`. `host.start()` SHALL topo-sort `config.plugins` via `@suaveplan/api-manifest`'s `resolveInstallOrder` and install each plugin in dependency order. `host.stop(graceMs)` SHALL stop plugins in reverse-topo order with a `HOST.SHUTDOWN_TIMEOUT` deadline. The host SHALL mount the admin API at `config.adminPrefix` (default `/admin`, `null` to disable). It SHALL expose `/internal/livez` (200 when responsive) and `/internal/readyz` (200 + plugin list). `installPlugin`/`uninstallPlugin` SHALL operate at runtime; pre-start fetches SHALL return a 503 envelope.
`HostAdapters` SHALL distinguish the host-private narrow SQLite ledger handle from the consumer-facing multi-engine database surface:
- `HostAdapters.ledgerDb: LedgerDb` — REQUIRED. Narrow `bun:sqlite` / `better-sqlite3`-style handle (`prepare(sql)` / `run(sql)`) consumed exclusively by the host's internal `PluginLedger`. NOT exposed on `PluginContext` / `RequestContext`.
- `HostAdapters.db?: AdminDatabaseLike` — OPTIONAL. The consumer-facing multi-engine façade produced by `createDbAdapter(...).admin` (`@suaveplan/api-db-adapter`). When set, the host SHALL thread the admin surface onto `PluginContext.db` AND per-request SHALL apply `createTenantScopedWrapper(admin, tenantCtx.id)` and expose the result on `RequestContext.db`. When unset, BOTH `PluginContext.db` AND `RequestContext.db` SHALL be `null`. When the per-request tenant resolver chain returns `null`, `RequestContext.db` SHALL be `null` even if `adapters.db` is configured.
The two slots are type-incompatible by design and SHALL NOT be unified.
#### Scenario: empty host boots; livez/readyz respond
- **WHEN** `createHost({adapters, plugins: []})` + `host.start()` runs
- **THEN** `host.fetch(GET /internal/livez)` returns 200 AND `host.fetch(GET /internal/readyz)` returns 200 with `plugins: []`.
#### Scenario: admin disabled via `adminPrefix: null`
- **WHEN** `adminPrefix: null` is configured
- **THEN** a fetch to `/admin/plugins` is treated as an unknown plugin route and returns 404.
#### Scenario: start twice raises HOST.ALREADY_STARTED
- **WHEN** `host.start()` is called twice
- **THEN** the second call rejects with `code: "HOST.ALREADY_STARTED"`.
#### Scenario: stop before start raises HOST.NOT_STARTED
- **WHEN** `host.stop()` is called before `host.start()`
- **THEN** the call rejects with `code: "HOST.NOT_STARTED"`.
#### Scenario: adapters.db is threaded to ctx.db with tenant-scoped wrapper
- **WHEN** the host is booted with `adapters.db = createDbAdapter(...).admin` AND a tenant resolver that binds `{id: "acme", ...}` AND a route handler reads `ctx.db?.query("SELECT * FROM widgets", [])`
- **THEN** the SQL the admin surface sees is rewritten to `SELECT * FROM widgets WHERE tenant_id = ?` AND the appended bind value is `"acme"` AND `ctx.db.tenantId === "acme"`.
#### Scenario: ctx.db is null when adapters.db is absent
- **WHEN** the host is booted WITHOUT `adapters.db` AND a route handler reads `ctx.db`
- **THEN** the value is `null` AND the handler runs to completion.
#### Scenario: ctx.db is null when no tenant resolved
- **WHEN** the host is booted WITH `adapters.db` AND the tenant resolver chain returns `null` AND `defaultTenant` is unset
- **THEN** `RequestContext.db === null` at the handler boundary.
**Verified by:** `src/host/host.test.ts::"boots with zero plugins + serves /internal/livez 200"` AND `src/host/host.test.ts::"/internal/readyz lists empty plugin set"` AND `src/host/host.test.ts::"adminPrefix: null disables admin surface"` AND `src/host/host.test.ts::"start twice → HOST.ALREADY_STARTED"` AND `src/host/host.test.ts::"stop before start → HOST.NOT_STARTED"` AND `src/host/host.test.ts::"validates config via Zod"` AND `src/host/host.test.ts::"adapters.db threaded into RequestContext via tenant-scoped wrapper"` AND `src/host/host.test.ts::"ctx.db === null when adapters.db is undefined"` AND `src/host/host.test.ts::"ctx.db === null when tenant resolver returns null even if adapters.db is set"`

View File

@@ -0,0 +1,26 @@
# Spec Delta — api-types — foundation-fix-gap-4-db-split
## MODIFIED Requirements
### Requirement: Plugin context separation
The package SHALL export `PluginContext<Caps>` and `RequestContext<Caps>`. `RequestContext` SHALL extend `PluginContext` (modulo the differently-typed `db` and `capabilities` slots) and add `request`, `principal`, `tenantId`, `requestId`, `span`. The `db` slot — exposed BOTH as a top-level context field AND as a capability-slice entry when `db` is declared in `Caps` — MUST be typed as `AdminDatabaseLike | null` on `PluginContext` and `TenantScopedDatabaseLike | null` on `RequestContext`. The two types MUST NOT be mutually assignable so an admin-DB consumer cannot accidentally accept a tenant-scoped one without an explicit cast. `null` SHALL be the runtime value when the host has no configured DB adapter, OR — for `RequestContext` only — when the tenant resolver chain returned no tenant on this request.
`JobContext` and `ConnectionContext` SHALL likewise expose a tenant-scoped `db: TenantScopedDatabaseLike | null` top-level field.
#### Scenario: db slot has a different type per context
- **WHEN** a value of type `PluginContext<{db: true}>` and a value of type `RequestContext<{db: true}>` are compared
- **THEN** `expectTypeOf<PluginContext<...>.capabilities.db>().not.toEqualTypeOf<RequestContext<...>.capabilities.db>()` passes
#### Scenario: capability map drives field availability
- **WHEN** `PluginContext<{db: true, "auth.session": true}>` is constructed
- **THEN** `db` and `principal` are required AND `secrets`/`events` are NOT present in the type (unless those capabilities are also declared)
#### Scenario: top-level db is null-permissive
- **WHEN** a host is booted without an `adapters.db` slot
- **THEN** `PluginContext.db === null` AND `RequestContext.db === null` at runtime AND the type system requires every consumer to null-check before use
**Verified by:** `src/_typetests/plugin-context.spec-conformance.test.ts::"db slot type differs between PluginContext and RequestContext"` AND `src/_typetests/plugin-context.spec-conformance.test.ts::"capability map drives ctx field set"` AND host integration in `@suaveplan/api-host`'s `src/host/host.test.ts::"ctx.db === null when adapters.db is undefined"`

View File

@@ -0,0 +1,19 @@
# Tasks — foundation-fix-gap-4-db-split
- [x] Rename `HostAdapters.db: LedgerDb` → `HostAdapters.ledgerDb: LedgerDb` (host-private).
- [x] Add new optional `HostAdapters.db?: AdminDatabaseLike` (consumer-facing).
- [x] Update host config Zod schema to validate the new shape.
- [x] Update host constructor to pass `config.adapters.ledgerDb` into `PluginLedger` and `config.adapters.db` into the request-pipeline options.
- [x] Add optional `db: AdminDatabaseLike` to `RequestPipelineOptions`.
- [x] Apply `createTenantScopedWrapper(admin, tenantCtx.id)` per request in the pipeline driver when both `options.db` and `tenantCtx` are set; thread the result onto `RequestContext.db`. Surface `null` otherwise.
- [x] Add `db: AdminDatabaseLike | null` to `PluginContext`; add `db: TenantScopedDatabaseLike | null` to `RequestContext`, `JobContext`, `ConnectionContext`.
- [x] Update `buildRequestContext` signature + helpers to thread the scoped db onto the constructed context.
- [x] Add `@suaveplan/api-db-adapter` runtime dep to `@suaveplan/api-host/package.json`.
- [x] Atomically update every in-tree consumer: `api-host` tests (`host.test.ts` rename `db → ledgerDb`), E2E foundation-stack (`server.ts`).
- [x] Restore `createDbAdapter({ engine: "sqlite", url: ":memory:" })` as `adapters.db` in the E2E server; keep `bun:sqlite` raw handle on `adapters.ledgerDb`. Register an inline `bun:sqlite`-backed engine factory.
- [x] Add `/api/db-ping` route in `fixture-plugin.ts` exercising `ctx.db?.execute` + `ctx.db?.query` through the tenant-scoped wrapper.
- [x] Add E2E test `db adapter — ctx.db wired through tenant-scoped wrapper` to `tests/foundation.spec.ts`.
- [x] Add api-host unit tests: (a) `adapters.db` threaded through to `ctx.db` with rewriter-applied SQL; (b) `ctx.db === null` when `adapters.db` undefined; (c) `ctx.db === null` when tenant resolver returns null.
- [x] Verify gate: `bunx turbo lint typecheck test build --filter=@suaveplan/api-host --filter=@suaveplan/api-types --filter=@suaveplan/api-db-adapter`.
- [x] Verify E2E: `bun test:e2e` 11/11 green.
- [x] Fold deltas into canonical `openspec/specs/api-host/spec.md` and `openspec/specs/api-types/spec.md`; move folder to `openspec/archive/2026-05-26-foundation-fix-gap-4-db-split/`.