Initial Commit
This commit is contained in:
@@ -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"`
|
||||
@@ -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"`
|
||||
Reference in New Issue
Block a user