Files
2026-05-27 23:29:53 +01:00

4.4 KiB

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"