52 lines
4.4 KiB
Markdown
52 lines
4.4 KiB
Markdown
# 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"`
|