6.2 KiB
Capability Spec Delta: api-db-adapter (api-db-adapter)
Package: @suaveplan/api-db-adapter
Status: PROPOSED
ADDED Requirements
Requirement: Admin surface exposes raw DatabaseLike
The package SHALL expose AdminDatabaseLike (per @suaveplan/api-types) with query, execute, transaction, prepare (for engines that support it). Admin surface is used for migrations and operator tooling; it does NOT enforce tenant scoping.
Scenario: admin query returns full result set
- WHEN admin runs
SELECT * FROM usersagainst a fixture seeded with rows across two tenants - THEN the result contains rows from both tenants (no filter applied)
Verified by: src/factory/create-db-adapter.test.ts::"admin surface returns rows across all tenants"
Requirement: Tenant-scoped wrapper rejects unscoped queries
createTenantScopedWrapper(admin, tenantId, options) SHALL return a TenantScopedDatabaseLike that:
- Injects
WHERE tenantColumn = $1(default columntenant_id) on every SELECT/UPDATE/DELETE. - Injects
tenantColumnon every INSERT — if the payload lacks the column ANDautoInjectInserts: true, inject the value; ifautoInjectInserts: false, reject withDB_ADAPTER_TENANT_SCOPE_VIOLATION. - Rejects raw SQL that doesn't reference the tenant column on a tenant-scoped table.
Scenario: SELECT auto-scopes to current tenant
- WHEN a route handler runs
SELECT * FROM usersagainst the scoped wrapper fortenant="acme" - THEN the executed SQL contains
WHERE tenant_id = 'acme'(or equivalent prepared parameter) AND no rows from other tenants are returned
Scenario: cross-tenant write is rejected
- WHEN the scoped wrapper for
tenant="acme"is asked toINSERT INTO users (tenant_id) VALUES ('bigcorp') - THEN the call throws
DB_ADAPTER_TENANT_SCOPE_VIOLATION
Verified by: src/tenant-scoping/create-tenant-scoped-wrapper.test.ts::"SELECT auto-scopes to current tenant" AND src/tenant-scoping/create-tenant-scoped-wrapper.test.ts::"INSERT with conflicting tenant_id is rejected"
Requirement: db.unsafe escape hatch is capability-gated
The scoped wrapper SHALL expose db.unsafe.query(sql, params) which bypasses tenant injection. Calls MUST be allowed ONLY if the plugin's manifest declares the db.unsafe-queries capability; otherwise throw DB_ADAPTER_UNSAFE_NOT_PERMITTED. Every allowed call MUST emit plugin.unsafe_query_total{plugin}.
Scenario: plugin without declaration is blocked
- WHEN a route handler whose plugin's manifest lacks
db.unsafe-queriescallsctx.db.unsafe.query(...) - THEN the call throws
DB_ADAPTER_UNSAFE_NOT_PERMITTEDAND nothing is executed against the engine
Scenario: plugin with declaration is allowed and counted
- WHEN a plugin declaring
db.unsafe-queriescallsctx.db.unsafe.query("SELECT * FROM audit_log") - THEN the query runs AND
plugin.unsafe_query_total{plugin=<name>}increments by 1
Verified by: src/unsafe/unsafe-query-gate.test.ts::"throws when capability not declared" AND src/unsafe/unsafe-query-gate.test.ts::"emits plugin.unsafe_query_total when allowed"
Requirement: Per-engine migration runner
runMigrations(plugin, capabilities) SHALL apply pending migrations from manifest.database.migrations in lexicographic order, recording applied versions in __plugin_migrations(plugin, version, applied_at). Re-running with no new files MUST be a no-op (idempotent).
Supported engines: Postgres, SQLite, Surreal. Each engine SHALL apply migrations within a transaction where the engine supports it; on failure the migration rolls back AND DB_ADAPTER_MIGRATION_FAILED is thrown with the failing file path.
Scenario: forward migration applies once
- WHEN
runMigrationsis called with migrations[001_init.sql, 002_add_invoices.sql]AND__plugin_migrationsis empty - THEN both files run AND
__plugin_migrationsrecords two rows ANDapi.db.migrations_applied_total{plugin}records 2
Scenario: idempotent re-run
- WHEN
runMigrationsis called twice in a row with the same file set - THEN the second call applies zero migrations AND
api.db.migrations_applied_totaldoes not increment on the second call
Scenario: failure rolls back
- WHEN
002_add_invoices.sqlcontains a syntax error - THEN the call throws
DB_ADAPTER_MIGRATION_FAILEDwithdetails.file = "002_add_invoices.sql"AND__plugin_migrationsdoes NOT record version 002
Verified by: src/migrations/run-migrations.test.ts::"applies pending migrations in lexicographic order" AND src/migrations/run-migrations.test.ts::"is idempotent on re-run" AND src/migrations/run-migrations.test.ts::"rolls back and throws on migration failure"
Requirement: Direct and worker-proxy surfaces both pass conformance
The worker-proxy variant MUST preserve ReadableStream-based cursors across the MessagePort boundary (transferable, zero-copy where the runtime supports it).
Scenario: direct conforms
- WHEN
runDbAdapterConformance("db-direct", () => createDbAdapter(...))runs against an in-memory SQLite - THEN every invariant passes
Scenario: worker-proxy conforms with streaming cursors
- WHEN
runDbAdapterConformance("db-worker-proxy", ...)runs over a realMessageChannelAND a query returns a cursor of 10_000 rows - THEN every invariant passes AND the cursor is consumed on the proxy side without buffering all rows in memory at once
Verified by: src/factory/create-db-adapter.spec-conformance.test.ts::"db-direct conforms to Db contract" AND src/proxy/create-db-adapter-worker-proxy.spec-conformance.test.ts::"db-worker-proxy conforms to Db contract with streaming cursors"
Requirement: Engine selection is Zod-validated
DbAdapterOptions.engine MUST be one of "postgres" | "sqlite" | "surrealdb". Any other value MUST throw DB_ADAPTER_ENGINE_UNSUPPORTED at construction.
Scenario: unsupported engine throws
- WHEN
createDbAdapter({ engine: "mongodb" as never, url: "..." }) - THEN the factory throws
DB_ADAPTER_ENGINE_UNSUPPORTEDsynchronously
Verified by: src/options/db-adapter-options.test.ts::"throws on unsupported engine value"