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. Narrowbun:sqlite/better-sqlite3-style handle (prepare(sql)/run(sql)) consumed exclusively by the host's internalPluginLedger. NOT exposed onPluginContext/RequestContext.HostAdapters.db?: AdminDatabaseLike— OPTIONAL. The consumer-facing multi-engine façade produced bycreateDbAdapter(...).admin(@suaveplan/api-db-adapter). When set, the host SHALL thread the admin surface ontoPluginContext.dbAND per-request SHALL applycreateTenantScopedWrapper(admin, tenantCtx.id)and expose the result onRequestContext.db. When unset, BOTHPluginContext.dbANDRequestContext.dbSHALL benull. When the per-request tenant resolver chain returnsnull,RequestContext.dbSHALL benulleven ifadapters.dbis 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 ANDhost.fetch(GET /internal/readyz)returns 200 withplugins: [].
Scenario: admin disabled via adminPrefix: null
- WHEN
adminPrefix: nullis configured - THEN a fetch to
/admin/pluginsis 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 beforehost.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(...).adminAND a tenant resolver that binds{id: "acme", ...}AND a route handler readsctx.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"ANDctx.db.tenantId === "acme".
Scenario: ctx.db is null when adapters.db is absent
- WHEN the host is booted WITHOUT
adapters.dbAND a route handler readsctx.db - THEN the value is
nullAND the handler runs to completion.
Scenario: ctx.db is null when no tenant resolved
- WHEN the host is booted WITH
adapters.dbAND the tenant resolver chain returnsnullANDdefaultTenantis unset - THEN
RequestContext.db === nullat 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"