From a1161db9bf960e3056de907772be38b82f4fa13c Mon Sep 17 00:00:00 2001 From: onmax Date: Sun, 1 Feb 2026 17:12:48 +0100 Subject: [PATCH 1/6] feat: fetch capabilities from db-compat --- docs/1.guide/2.capabilities.md | 112 +++++++++++++++++++++++ docs/1.guide/_capabilities-table.md | 14 +++ package.json | 7 +- scripts/gen-capabilities-docs.ts | 88 ++++++++++++++++++ src/capabilities.ts | 49 ++++++++++ src/connectors/_internal/capabilities.ts | 18 ++++ src/database.ts | 5 + src/index.ts | 2 + src/integrations/drizzle/_utils.ts | 5 +- src/types.ts | 20 ++++ test/connectors/_tests.ts | 18 ++++ tsconfig.json | 5 +- 12 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 docs/1.guide/2.capabilities.md create mode 100644 docs/1.guide/_capabilities-table.md create mode 100644 scripts/gen-capabilities-docs.ts create mode 100644 src/capabilities.ts create mode 100644 src/connectors/_internal/capabilities.ts diff --git a/docs/1.guide/2.capabilities.md b/docs/1.guide/2.capabilities.md new file mode 100644 index 00000000..d6d96634 --- /dev/null +++ b/docs/1.guide/2.capabilities.md @@ -0,0 +1,112 @@ +--- +icon: ph:check-circle-duotone +--- + +# Database Capabilities + +> The `db.capabilities` property exposes database feature support at runtime. + +Use capabilities to write portable code that adapts to the underlying database. + +## Usage + +You can access capabilities directly on the database instance: + +```ts +import { createDatabase } from "db0"; +import sqlite from "db0/connectors/better-sqlite3"; + +const db = createDatabase(sqlite({})); + +console.log(db.capabilities); +// { supportsJSON: true, supportsBooleans: false, supportsArrays: false, ... } + +if (db.capabilities.supportsArrays) { + // Use PostgreSQL array syntax +} else { + // Use JSON or comma-separated values +} +``` + +## Available Flags + +| Flag | Description | +| ----------------------- | ------------------------------------------------------------- | +| `supportsJSON` | The database supports native JSON column types and functions. | +| `supportsBooleans` | The database supports native boolean types (not 0/1). | +| `supportsArrays` | The database supports native array column types. | +| `supportsDates` | The database supports native date/timestamp types. | +| `supportsUUIDs` | The database supports native UUID column types. | +| `supportsTransactions` | The database supports transactions. | +| `supportsBatch` | The database supports batch execution of statements. | + +## Capabilities by Connector + +> [!TIP] +> See [db-compat.onmax.me](https://db-compat.onmax.me) for a comprehensive database feature comparison. + + + + +| Connector | JSON | Bool | Array | Date | UUID | Tx | Batch | +| :--------:|:---:|:---:|:----:|:---:|:---:|:-:|:----: | +| better-sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | +| sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | +| bun-sqlite | ✓ | — | — | — | — | ✓ | ✓ | +| node-sqlite | ✓ | — | — | — | — | ✓ | ✓ | +| libsql | ✓ | — | — | — | — | ✓ | ✓ | +| cloudflare-d1 | ✓ | — | — | — | — | ✓ | ✓ | +| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | + + + +> [!NOTE] +> Cloudflare Hyperdrive connectors inherit the capabilities of their underlying database (PostgreSQL or MySQL). + +## Use Cases + +### Conditional Feature Usage + +You can adapt your queries based on database support: + +```ts +function storeList(db: Database, items: string[]) { + if (db.capabilities.supportsArrays) { + return db.sql`INSERT INTO data (items) VALUES (${items})`; + } else { + return db.sql`INSERT INTO data (items) VALUES (${JSON.stringify(items)})`; + } +} +``` + +### Runtime Validation + +You can validate that the database meets your application's requirements: + +```ts +function initDatabase(db: Database) { + if (!db.capabilities.supportsTransactions) { + throw new Error("This application requires transaction support"); + } +} +``` + +### Feature Detection in Libraries + +You can build database-agnostic libraries that adapt automatically: + +```ts +export function createRepository(db: Database) { + return { + saveTags(id: string, tags: string[]) { + const value = db.capabilities.supportsArrays + ? tags + : tags.join(","); + return db.sql`UPDATE items SET tags = ${value} WHERE id = ${id}`; + }, + }; +} +``` diff --git a/docs/1.guide/_capabilities-table.md b/docs/1.guide/_capabilities-table.md new file mode 100644 index 00000000..543e8fb3 --- /dev/null +++ b/docs/1.guide/_capabilities-table.md @@ -0,0 +1,14 @@ + +| Connector | JSON | Bool | Array | Date | UUID | Tx | Batch | +| :--------:|:---:|:---:|:----:|:---:|:---:|:-:|:----: | +| better-sqlite3 | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | +| sqlite3 | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | +| bun-sqlite | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | +| node-sqlite | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | +| libsql | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | +| cloudflare-d1 | ✓ | — | ✓ | ✓ | ✓ | — | ✓ | +| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| mysql2 | ✓ | ✓ | — | ✓ | — | — | ✓ | +| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | — | ✓ | diff --git a/package.json b/package.json index 9c3cb54e..769bbeb3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ "./connectors/libsql/*": { "types": "./dist/connectors/libsql/*.d.ts", "default": "./dist/connectors/libsql/*.mjs" + }, + "./capabilities": { + "types": "./dist/capabilities.d.ts", + "default": "./dist/capabilities.mjs" } }, "types": "./dist/index.d.mts", @@ -29,8 +33,9 @@ "dist" ], "scripts": { - "build": "pnpm gen-connectors && obuild", + "build": "pnpm gen-connectors && pnpm gen-capabilities && obuild", "gen-connectors": "jiti scripts/gen-connectors.ts", + "gen-capabilities": "jiti scripts/gen-capabilities-docs.ts", "db0": "pnpm jiti src/cli", "dev": "vitest", "lint": "eslint . && prettier -c src test", diff --git a/scripts/gen-capabilities-docs.ts b/scripts/gen-capabilities-docs.ts new file mode 100644 index 00000000..cd12e047 --- /dev/null +++ b/scripts/gen-capabilities-docs.ts @@ -0,0 +1,88 @@ +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import type { DatabaseCapabilities } from "../src/types.ts"; + +const DB_COMPAT_URL = "https://raw.githubusercontent.com/onmax/db-compat/main/packages/data/data.json"; + +const outputFile = fileURLToPath(new URL("../docs/1.guide/_capabilities-table.md", import.meta.url)); + +interface CompatTarget { dialect: string; driver: string; version: string } +interface CompatEntry { support: Record } +interface CompatData { + __meta: { targets: Record }; + sql: { + types: Record; + transactions: Record; + }; +} + +// Map db0 connector names to db-compat target IDs +const targetMapping: Record = { + "cloudflare-hyperdrive-postgresql": "hyperdrive-postgresql", + "cloudflare-hyperdrive-mysql": "hyperdrive-mysql", +}; + +const db0Connectors = [ + "better-sqlite3", "sqlite3", "bun-sqlite", "node-sqlite", "libsql", "cloudflare-d1", + "postgresql", "pglite", "cloudflare-hyperdrive-postgresql", + "mysql2", "cloudflare-hyperdrive-mysql", +]; + +function toDb0Capabilities(data: CompatData, targetId: string): DatabaseCapabilities { + const id = targetMapping[targetId] || targetId; + const target = data.__meta.targets[id]; + const types = data.sql.types; + const tx = data.sql.transactions; + + return { + supportsJSON: types.type_json.support[id].supported, + supportsBooleans: target.dialect !== "sqlite", + supportsArrays: types.type_array.support[id].supported, + supportsDates: types.type_date.support[id].supported, + supportsUUIDs: types.type_uuid.support[id].supported, + supportsTransactions: tx.BEGIN.support[id].supported, + supportsBatch: tx.batch_atomicity.support[id].supported, + }; +} + +const capabilityLabels: Record = { + supportsJSON: "JSON", + supportsBooleans: "Bool", + supportsArrays: "Array", + supportsDates: "Date", + supportsUUIDs: "UUID", + supportsTransactions: "Tx", + supportsBatch: "Batch", +}; + +const check = "✓"; +const cross = "—"; + +function generateTable(connectorCapabilities: Record): string { + const headers = ["Connector", ...Object.values(capabilityLabels)]; + const separator = headers.map((h) => ":".padEnd(h.length, "-") + ":"); + + const rows = Object.entries(connectorCapabilities).map(([name, caps]) => { + const cells = Object.keys(capabilityLabels).map((key) => + caps[key as keyof DatabaseCapabilities] ? check : cross, + ); + return [name, ...cells]; + }); + + const lines = [headers.join(" | "), separator.join("|"), ...rows.map((row) => row.join(" | "))]; + return lines.map((line) => `| ${line} |`).join("\n"); +} + +console.log("Fetching db-compat data..."); +const data: CompatData = await fetch(DB_COMPAT_URL).then((r) => r.json()); + +const connectorCapabilities: Record = Object.fromEntries( + db0Connectors.map((id) => [id, toDb0Capabilities(data, id)]), +); + +const content = ` +${generateTable(connectorCapabilities)} +`; + +await writeFile(outputFile, content, "utf8"); +console.log("Generated capabilities table to", outputFile); diff --git a/src/capabilities.ts b/src/capabilities.ts new file mode 100644 index 00000000..0a446dae --- /dev/null +++ b/src/capabilities.ts @@ -0,0 +1,49 @@ +import type { DatabaseCapabilities, SQLDialect } from "./types.ts"; + +const sqlite: DatabaseCapabilities = { + supportsJSON: true, + supportsBooleans: false, + supportsArrays: false, + supportsDates: false, + supportsUUIDs: false, + supportsTransactions: true, + supportsBatch: true, +}; + +const postgresql: DatabaseCapabilities = { + supportsJSON: true, + supportsBooleans: true, + supportsArrays: true, + supportsDates: true, + supportsUUIDs: true, + supportsTransactions: true, + supportsBatch: true, +}; + +const mysql: DatabaseCapabilities = { + supportsJSON: true, + supportsBooleans: true, + supportsArrays: false, + supportsDates: true, + supportsUUIDs: false, + supportsTransactions: true, + supportsBatch: true, +}; + +export const dialectCapabilities: Record = { + sqlite, + libsql: sqlite, + postgresql, + mysql, +}; + +export function getCapabilities( + dialect: SQLDialect, + overrides?: Partial, +): DatabaseCapabilities { + return overrides + ? { ...dialectCapabilities[dialect], ...overrides } + : dialectCapabilities[dialect]; +} + +export type { DatabaseCapabilities } from "./types.ts"; diff --git a/src/connectors/_internal/capabilities.ts b/src/connectors/_internal/capabilities.ts new file mode 100644 index 00000000..92e0c1d3 --- /dev/null +++ b/src/connectors/_internal/capabilities.ts @@ -0,0 +1,18 @@ +import { dialectCapabilities } from "../../capabilities.ts"; +import type { DatabaseCapabilities } from "../../types.ts"; + +// Connector-to-capabilities mapping for documentation generation +export const connectorCapabilities: Record = { + "better-sqlite3": dialectCapabilities.sqlite, + sqlite3: dialectCapabilities.sqlite, + "bun-sqlite": dialectCapabilities.sqlite, + "node-sqlite": dialectCapabilities.sqlite, + libsql: dialectCapabilities.libsql, + "cloudflare-d1": dialectCapabilities.sqlite, + postgresql: dialectCapabilities.postgresql, + pglite: dialectCapabilities.postgresql, + "cloudflare-hyperdrive-postgresql": dialectCapabilities.postgresql, + mysql2: dialectCapabilities.mysql, + planetscale: dialectCapabilities.mysql, + "cloudflare-hyperdrive-mysql": dialectCapabilities.mysql, +}; diff --git a/src/database.ts b/src/database.ts index f94b69f9..b33abdab 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,3 +1,4 @@ +import { getCapabilities } from "./capabilities.ts"; import { sqlTemplate } from "./template.ts"; import type { Connector, Database, SQLDialect } from "./types.ts"; import type { Primitive } from "./types.ts"; @@ -34,6 +35,10 @@ export function createDatabase( return connector.dialect; }, + get capabilities() { + return getCapabilities(connector.dialect, connector.capabilityOverrides); + }, + get disposed() { return _disposed; }, diff --git a/src/index.ts b/src/index.ts index 2ce54c19..7fd4bb1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ export { createDatabase } from "./database.ts"; +export { dialectCapabilities, getCapabilities } from "./capabilities.ts"; export { connectors } from "./_connectors.ts"; export type { Connector, Database, + DatabaseCapabilities, ExecResult, Primitive, SQLDialect, diff --git a/src/integrations/drizzle/_utils.ts b/src/integrations/drizzle/_utils.ts index 33141d3b..762f01fd 100644 --- a/src/integrations/drizzle/_utils.ts +++ b/src/integrations/drizzle/_utils.ts @@ -28,7 +28,10 @@ export function mapResultRow( } else if (is(field, SQL)) { decoder = "decoder" in field && (field.decoder as any); } else { - decoder = "decoder" in field.sql && (field.sql.decoder as any); + decoder = + "sql" in field && + "decoder" in field.sql && + (field.sql.decoder as any); } let node = result; for (const [pathChunkIndex, pathChunk] of path.entries()) { diff --git a/src/types.ts b/src/types.ts index 4897e0f0..3a2601e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,16 @@ export type Primitive = string | number | boolean | undefined | null; export type SQLDialect = "mysql" | "postgresql" | "sqlite" | "libsql"; +export interface DatabaseCapabilities { + readonly supportsJSON: boolean; + readonly supportsBooleans: boolean; + readonly supportsArrays: boolean; + readonly supportsDates: boolean; + readonly supportsUUIDs: boolean; + readonly supportsTransactions: boolean; + readonly supportsBatch: boolean; +} + export type Statement = { /** * Binds parameters to the statement. @@ -81,6 +91,11 @@ export type Connector = { */ dialect: SQLDialect; + /** + * Override specific database capabilities for this connector. + */ + capabilityOverrides?: Partial; + /** * The client instance used internally. */ @@ -123,6 +138,11 @@ export interface Database< > extends AsyncDisposable { readonly dialect: SQLDialect; + /** + * Database capabilities supported by this connector. + */ + readonly capabilities: DatabaseCapabilities; + /** * Indicates whether the database instance has been disposed/closed. * @returns {boolean} True if the database has been disposed, false otherwise. diff --git a/test/connectors/_tests.ts b/test/connectors/_tests.ts index 7f42d784..f87e5a2c 100644 --- a/test/connectors/_tests.ts +++ b/test/connectors/_tests.ts @@ -2,6 +2,7 @@ import { beforeAll, expect, it } from "vitest"; import { Connector, Database, + DatabaseCapabilities, createDatabase, type SQLDialect, } from "../../src"; @@ -9,6 +10,7 @@ import { export function testConnector(opts: { connector: TConnector; dialect: SQLDialect; + capabilities?: Partial; }) { let db: Database; beforeAll(() => { @@ -37,6 +39,22 @@ export function testConnector(opts: { expect(db.dialect).toBe(opts.dialect); }); + it("capabilities match", () => { + expect(db.capabilities).toBeDefined(); + expect(typeof db.capabilities.supportsJSON).toBe("boolean"); + expect(typeof db.capabilities.supportsBooleans).toBe("boolean"); + expect(typeof db.capabilities.supportsArrays).toBe("boolean"); + expect(typeof db.capabilities.supportsDates).toBe("boolean"); + expect(typeof db.capabilities.supportsUUIDs).toBe("boolean"); + expect(typeof db.capabilities.supportsTransactions).toBe("boolean"); + expect(typeof db.capabilities.supportsBatch).toBe("boolean"); + if (opts.capabilities) { + for (const [key, value] of Object.entries(opts.capabilities)) { + expect(db.capabilities[key as keyof DatabaseCapabilities]).toBe(value); + } + } + }); + it("drop and create table", async () => { await db.sql`DROP TABLE IF EXISTS users`; switch (opts.dialect) { diff --git a/tsconfig.json b/tsconfig.json index 880cb46b..61277639 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,9 +17,8 @@ "noImplicitOverride": true, "noEmit": true, "paths": { - "db0/connectors/*": [ - "./src/connectors/*" - ] + "db0": ["./src/index.ts"], + "db0/connectors/*": ["./src/connectors/*"] } }, "include": [ From bdbb5a2de22cb442c66e7a12da60c2e0eafaa627 Mon Sep 17 00:00:00 2001 From: onmax Date: Sat, 7 Feb 2026 12:56:16 +0100 Subject: [PATCH 2/6] docs: generate capabilities table locally --- docs/1.guide/_capabilities-table.md | 19 +++++----- scripts/gen-capabilities-docs.ts | 55 +++++++---------------------- 2 files changed, 22 insertions(+), 52 deletions(-) diff --git a/docs/1.guide/_capabilities-table.md b/docs/1.guide/_capabilities-table.md index 543e8fb3..195eaeb3 100644 --- a/docs/1.guide/_capabilities-table.md +++ b/docs/1.guide/_capabilities-table.md @@ -1,14 +1,15 @@ - + | Connector | JSON | Bool | Array | Date | UUID | Tx | Batch | | :--------:|:---:|:---:|:----:|:---:|:---:|:-:|:----: | -| better-sqlite3 | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | -| sqlite3 | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | -| bun-sqlite | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | -| node-sqlite | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | -| libsql | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | -| cloudflare-d1 | ✓ | — | ✓ | ✓ | ✓ | — | ✓ | +| better-sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | +| sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | +| bun-sqlite | ✓ | — | — | — | — | ✓ | ✓ | +| node-sqlite | ✓ | — | — | — | — | ✓ | ✓ | +| libsql | ✓ | — | — | — | — | ✓ | ✓ | +| cloudflare-d1 | ✓ | — | — | — | — | ✓ | ✓ | | postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| mysql2 | ✓ | ✓ | — | ✓ | — | — | ✓ | -| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | — | ✓ | +| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | diff --git a/scripts/gen-capabilities-docs.ts b/scripts/gen-capabilities-docs.ts index cd12e047..dd82aaa7 100644 --- a/scripts/gen-capabilities-docs.ts +++ b/scripts/gen-capabilities-docs.ts @@ -1,50 +1,16 @@ import { writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; +import { connectorCapabilities } from "../src/connectors/_internal/capabilities.ts"; import type { DatabaseCapabilities } from "../src/types.ts"; -const DB_COMPAT_URL = "https://raw.githubusercontent.com/onmax/db-compat/main/packages/data/data.json"; - const outputFile = fileURLToPath(new URL("../docs/1.guide/_capabilities-table.md", import.meta.url)); -interface CompatTarget { dialect: string; driver: string; version: string } -interface CompatEntry { support: Record } -interface CompatData { - __meta: { targets: Record }; - sql: { - types: Record; - transactions: Record; - }; -} - -// Map db0 connector names to db-compat target IDs -const targetMapping: Record = { - "cloudflare-hyperdrive-postgresql": "hyperdrive-postgresql", - "cloudflare-hyperdrive-mysql": "hyperdrive-mysql", -}; - const db0Connectors = [ "better-sqlite3", "sqlite3", "bun-sqlite", "node-sqlite", "libsql", "cloudflare-d1", "postgresql", "pglite", "cloudflare-hyperdrive-postgresql", - "mysql2", "cloudflare-hyperdrive-mysql", + "mysql2", "planetscale", "cloudflare-hyperdrive-mysql", ]; -function toDb0Capabilities(data: CompatData, targetId: string): DatabaseCapabilities { - const id = targetMapping[targetId] || targetId; - const target = data.__meta.targets[id]; - const types = data.sql.types; - const tx = data.sql.transactions; - - return { - supportsJSON: types.type_json.support[id].supported, - supportsBooleans: target.dialect !== "sqlite", - supportsArrays: types.type_array.support[id].supported, - supportsDates: types.type_date.support[id].supported, - supportsUUIDs: types.type_uuid.support[id].supported, - supportsTransactions: tx.BEGIN.support[id].supported, - supportsBatch: tx.batch_atomicity.support[id].supported, - }; -} - const capabilityLabels: Record = { supportsJSON: "JSON", supportsBooleans: "Bool", @@ -73,15 +39,18 @@ function generateTable(connectorCapabilities: Record `| ${line} |`).join("\n"); } -console.log("Fetching db-compat data..."); -const data: CompatData = await fetch(DB_COMPAT_URL).then((r) => r.json()); - -const connectorCapabilities: Record = Object.fromEntries( - db0Connectors.map((id) => [id, toDb0Capabilities(data, id)]), +const orderedCapabilities: Record = Object.fromEntries( + db0Connectors.map((id) => { + const caps = connectorCapabilities[id]; + if (!caps) { + throw new Error(`No capabilities mapping found for connector: ${id}`); + } + return [id, caps] as const; + }), ); -const content = ` -${generateTable(connectorCapabilities)} +const content = ` +${generateTable(orderedCapabilities)} `; await writeFile(outputFile, content, "utf8"); From 6aea3852b9d9d8cbfdc2e791e59a1f2541f867cb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 20 Aug 2026 17:35:43 +0000 Subject: [PATCH 3/6] refactor(capabilities): address review feedback - Drop `supportsBatch`: db0 exposes no batch API, and the flag was `true` for every dialect, so it carried no information. - Wire up `capabilityOverrides`, which was previously declared but unused: `cloudflare-d1` now reports `supportsTransactions: false` (D1 rejects explicit `BEGIN`/`COMMIT`; only `D1Database.batch()` is transactional). - Freeze dialect capability objects and compute the merged capabilities once per database, so `db.capabilities` is a stable, immutable snapshot. - Generate the docs table from the connector registry instead of a hardcoded list, keyed by an exhaustive `Record`; `scripts` is now typechecked, so adding a connector without a row fails `pnpm test:types`. Covers all 16 connectors (was 13) and reflects per-connector overrides. - Delete `src/connectors/_internal/capabilities.ts` (only consumed by the docs script, never reached `dist`). - Drop the `db0/capabilities` subpath export: `getCapabilities` and `dialectCapabilities` are already exported from the package root. - Sync the stale table inlined in the docs page, fix the `supportsArrays` examples (`db.sql` only accepts `Primitive` values, not arrays) and correct the dead doc links. - Assert `db.capabilities` deep-equals the expected capabilities in the shared connector suite instead of only checking value types. - Lint/format `scripts` and fix the type error that surfaced there. Co-Authored-By: Claude Opus 5 (1M context) --- build.config.ts | 7 +- docs/1.guide/2.capabilities.md | 85 +++++++++++-------- docs/1.guide/_capabilities-table.md | 35 ++++---- package.json | 8 +- scripts/gen-capabilities-docs.ts | 84 ++++++++++++------ scripts/gen-connectors.ts | 3 +- src/capabilities.ts | 30 +++---- src/connectors/_internal/capabilities.ts | 19 ----- src/connectors/cloudflare-d1.ts | 4 + src/database.ts | 7 +- src/types.ts | 1 - test/connectors/_tests.ts | 20 ++--- .../cloudflare/cloudflare-d1.test.ts | 1 + tsconfig.json | 3 +- 14 files changed, 165 insertions(+), 142 deletions(-) delete mode 100644 src/connectors/_internal/capabilities.ts diff --git a/build.config.ts b/build.config.ts index 2874d84c..59c4dcc9 100644 --- a/build.config.ts +++ b/build.config.ts @@ -15,12 +15,7 @@ const integrationEntries = readdirSync( .map((entry) => `src/integrations/${entry}`) .sort(); -const input = [ - "src/index.ts", - "src/capabilities.ts", - ...connectorEntries, - ...integrationEntries, -]; +const input = ["src/index.ts", ...connectorEntries, ...integrationEntries]; validatePkg(input); diff --git a/docs/1.guide/2.capabilities.md b/docs/1.guide/2.capabilities.md index d6d96634..1aa00627 100644 --- a/docs/1.guide/2.capabilities.md +++ b/docs/1.guide/2.capabilities.md @@ -21,24 +21,26 @@ const db = createDatabase(sqlite({})); console.log(db.capabilities); // { supportsJSON: true, supportsBooleans: false, supportsArrays: false, ... } -if (db.capabilities.supportsArrays) { - // Use PostgreSQL array syntax +if (db.capabilities.supportsBooleans) { + // Use a native boolean column } else { - // Use JSON or comma-separated values + // Fall back to an integer 0/1 column } ``` +Capabilities are derived from the connector's SQL dialect, with per-connector +overrides where a specific backend differs from its dialect (see the table below). + ## Available Flags -| Flag | Description | -| ----------------------- | ------------------------------------------------------------- | -| `supportsJSON` | The database supports native JSON column types and functions. | -| `supportsBooleans` | The database supports native boolean types (not 0/1). | -| `supportsArrays` | The database supports native array column types. | -| `supportsDates` | The database supports native date/timestamp types. | -| `supportsUUIDs` | The database supports native UUID column types. | -| `supportsTransactions` | The database supports transactions. | -| `supportsBatch` | The database supports batch execution of statements. | +| Flag | Description | +| ---------------------- | -------------------------------------------------------------- | +| `supportsJSON` | The database has JSON support (column types and/or functions). | +| `supportsBooleans` | The database supports native boolean types (not 0/1). | +| `supportsArrays` | The database supports native array column types. | +| `supportsDates` | The database supports native date/timestamp types. | +| `supportsUUIDs` | The database supports native UUID column types. | +| `supportsTransactions` | The database supports explicit transactions. | ## Capabilities by Connector @@ -48,40 +50,51 @@ if (db.capabilities.supportsArrays) { -| Connector | JSON | Bool | Array | Date | UUID | Tx | Batch | -| :--------:|:---:|:---:|:----:|:---:|:---:|:-:|:----: | -| better-sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | -| sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | -| bun-sqlite | ✓ | — | — | — | — | ✓ | ✓ | -| node-sqlite | ✓ | — | — | — | — | ✓ | ✓ | -| libsql | ✓ | — | — | — | — | ✓ | ✓ | -| cloudflare-d1 | ✓ | — | — | — | — | ✓ | ✓ | -| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | -| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| Connector | JSON | Bool | Array | Date | UUID | Tx | +| :--------: | :---: | :---: | :----: | :---: | :---: | :-: | +| better-sqlite3 | ✓ | — | — | — | — | ✓ | +| bun-sqlite | ✓ | — | — | — | — | ✓ | +| cloudflare-d1 | ✓ | — | — | — | — | — | +| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | +| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| libsql-core | ✓ | — | — | — | — | ✓ | +| libsql-http | ✓ | — | — | — | — | ✓ | +| libsql-node | ✓ | — | — | — | — | ✓ | +| libsql-web | ✓ | — | — | — | — | ✓ | +| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | +| neon | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| node-sqlite | ✓ | — | — | — | — | ✓ | +| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | +| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| sqlite3 | ✓ | — | — | — | — | ✓ | > [!NOTE] -> Cloudflare Hyperdrive connectors inherit the capabilities of their underlying database (PostgreSQL or MySQL). +> [Cloudflare D1](/connectors/cloudflare) has no explicit transactions — `BEGIN`/`COMMIT` are +> rejected, and only implicit transactions via `D1Database.batch()` are available. ## Use Cases ### Conditional Feature Usage -You can adapt your queries based on database support: +You can adapt your queries to what the database supports: ```ts function storeList(db: Database, items: string[]) { - if (db.capabilities.supportsArrays) { - return db.sql`INSERT INTO data (items) VALUES (${items})`; - } else { - return db.sql`INSERT INTO data (items) VALUES (${JSON.stringify(items)})`; - } + const value = db.capabilities.supportsArrays + ? `{${items.join(",")}}` // PostgreSQL array literal + : JSON.stringify(items); + return db.sql`INSERT INTO data (items) VALUES (${value})`; } ``` +> [!NOTE] +> Values interpolated into `db.sql` must be primitives (`string`, +> `number`, `boolean`, `null` or `undefined`), so arrays and objects have to be serialized +> to a value the target database understands. + ### Runtime Validation You can validate that the database meets your application's requirements: @@ -101,11 +114,11 @@ You can build database-agnostic libraries that adapt automatically: ```ts export function createRepository(db: Database) { return { - saveTags(id: string, tags: string[]) { - const value = db.capabilities.supportsArrays - ? tags - : tags.join(","); - return db.sql`UPDATE items SET tags = ${value} WHERE id = ${id}`; + saveFlag(id: string, enabled: boolean) { + const value = db.capabilities.supportsBooleans + ? enabled + : Number(enabled); + return db.sql`UPDATE items SET enabled = ${value} WHERE id = ${id}`; }, }; } diff --git a/docs/1.guide/_capabilities-table.md b/docs/1.guide/_capabilities-table.md index 06f0e725..05d56c8d 100644 --- a/docs/1.guide/_capabilities-table.md +++ b/docs/1.guide/_capabilities-table.md @@ -1,16 +1,19 @@ - -| Connector | JSON | Bool | Array | Date | UUID | Tx | Batch | -| :--------:|:---:|:---:|:----:|:---:|:---:|:-:|:----: | -| better-sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | -| sqlite3 | ✓ | — | — | — | — | ✓ | ✓ | -| bun-sqlite | ✓ | — | — | — | — | ✓ | ✓ | -| node-sqlite | ✓ | — | — | — | — | ✓ | ✓ | -| libsql | ✓ | — | — | — | — | ✓ | ✓ | -| cloudflare-d1 | ✓ | — | — | — | — | ✓ | ✓ | -| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| neon | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | -| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | -| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | + +| Connector | JSON | Bool | Array | Date | UUID | Tx | +| :--------: | :---: | :---: | :----: | :---: | :---: | :-: | +| better-sqlite3 | ✓ | — | — | — | — | ✓ | +| bun-sqlite | ✓ | — | — | — | — | ✓ | +| cloudflare-d1 | ✓ | — | — | — | — | — | +| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | +| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| libsql-core | ✓ | — | — | — | — | ✓ | +| libsql-http | ✓ | — | — | — | — | ✓ | +| libsql-node | ✓ | — | — | — | — | ✓ | +| libsql-web | ✓ | — | — | — | — | ✓ | +| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | +| neon | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| node-sqlite | ✓ | — | — | — | — | ✓ | +| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | +| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| sqlite3 | ✓ | — | — | — | — | ✓ | diff --git a/package.json b/package.json index d31a3a02..a2d8ca5f 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,6 @@ "./connectors/libsql/*": { "types": "./dist/connectors/libsql/*.d.mts", "default": "./dist/connectors/libsql/*.mjs" - }, - "./capabilities": { - "types": "./dist/capabilities.d.mts", - "default": "./dist/capabilities.mjs" } }, "types": "./dist/index.d.mts", @@ -38,8 +34,8 @@ "gen-capabilities": "jiti scripts/gen-capabilities-docs.ts", "db0": "pnpm jiti src/cli", "dev": "vitest", - "lint": "prettier -c src test", - "fmt": "prettier -w src test", + "lint": "prettier -c src test scripts", + "fmt": "prettier -w src test scripts", "prepack": "pnpm build", "release": "pnpm test && changelogen --release --push && pnpm publish", "test": "pnpm lint && pnpm test:types && vitest run --coverage && pnpm test:bun", diff --git a/scripts/gen-capabilities-docs.ts b/scripts/gen-capabilities-docs.ts index 7b7fb527..aa80c233 100644 --- a/scripts/gen-capabilities-docs.ts +++ b/scripts/gen-capabilities-docs.ts @@ -1,15 +1,40 @@ import { writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -import { connectorCapabilities } from "../src/connectors/_internal/capabilities.ts"; +import { connectors, type ConnectorName } from "../src/_connectors.ts"; +import { dialectCapabilities, getCapabilities } from "../src/capabilities.ts"; import type { DatabaseCapabilities } from "../src/types.ts"; -const outputFile = fileURLToPath(new URL("../docs/1.guide/_capabilities-table.md", import.meta.url)); +const outputFile = fileURLToPath( + new URL("../docs/1.guide/_capabilities-table.md", import.meta.url), +); -const db0Connectors = [ - "better-sqlite3", "sqlite3", "bun-sqlite", "node-sqlite", "libsql", "cloudflare-d1", - "postgresql", "pglite", "neon", "cloudflare-hyperdrive-postgresql", - "mysql2", "planetscale", "cloudflare-hyperdrive-mysql", -]; +/** + * Capabilities of every connector, mirroring its `dialect` and `capabilityOverrides`. + * + * Typed as an exhaustive `Record` so that `pnpm test:types` + * fails when a connector is added without a row here. + */ +const connectorCapabilities: Record = { + "better-sqlite3": dialectCapabilities.sqlite, + sqlite3: dialectCapabilities.sqlite, + "bun-sqlite": dialectCapabilities.sqlite, + bun: dialectCapabilities.sqlite, + "node-sqlite": dialectCapabilities.sqlite, + sqlite: dialectCapabilities.sqlite, + "libsql-core": dialectCapabilities.libsql, + "libsql-node": dialectCapabilities.libsql, + libsql: dialectCapabilities.libsql, + "libsql-http": dialectCapabilities.libsql, + "libsql-web": dialectCapabilities.libsql, + "cloudflare-d1": getCapabilities("sqlite", { supportsTransactions: false }), + postgresql: dialectCapabilities.postgresql, + pglite: dialectCapabilities.postgresql, + neon: dialectCapabilities.postgresql, + "cloudflare-hyperdrive-postgresql": dialectCapabilities.postgresql, + mysql2: dialectCapabilities.mysql, + planetscale: dialectCapabilities.mysql, + "cloudflare-hyperdrive-mysql": dialectCapabilities.mysql, +}; const capabilityLabels: Record = { supportsJSON: "JSON", @@ -18,39 +43,46 @@ const capabilityLabels: Record = { supportsDates: "Date", supportsUUIDs: "UUID", supportsTransactions: "Tx", - supportsBatch: "Batch", }; const check = "✓"; const cross = "—"; -function generateTable(connectorCapabilities: Record): string { +/** Canonical connector names, in registry order, with aliases removed. */ +function canonicalConnectorNames(): ConnectorName[] { + const seen = new Set(); + return (Object.keys(connectors) as ConnectorName[]).filter((name) => { + const subpath = connectors[name]; + if (seen.has(subpath)) { + return false; + } + seen.add(subpath); + return true; + }); +} + +function generateTable(names: ConnectorName[]): string { const headers = ["Connector", ...Object.values(capabilityLabels)]; const separator = headers.map((h) => ":".padEnd(h.length, "-") + ":"); - const rows = Object.entries(connectorCapabilities).map(([name, caps]) => { - const cells = Object.keys(capabilityLabels).map((key) => - caps[key as keyof DatabaseCapabilities] ? check : cross, - ); + const rows = names.map((name) => { + const caps = connectorCapabilities[name]; + const cells = ( + Object.keys(capabilityLabels) as (keyof DatabaseCapabilities)[] + ).map((key) => (caps[key] ? check : cross)); return [name, ...cells]; }); - const lines = [headers.join(" | "), separator.join("|"), ...rows.map((row) => row.join(" | "))]; + const lines = [ + headers.join(" | "), + separator.join(" | "), + ...rows.map((row) => row.join(" | ")), + ]; return lines.map((line) => `| ${line} |`).join("\n"); } -const orderedCapabilities: Record = Object.fromEntries( - db0Connectors.map((id) => { - const caps = connectorCapabilities[id]; - if (!caps) { - throw new Error(`No capabilities mapping found for connector: ${id}`); - } - return [id, caps] as const; - }), -); - -const content = ` -${generateTable(orderedCapabilities)} +const content = ` +${generateTable(canonicalConnectorNames())} `; await writeFile(outputFile, content, "utf8"); diff --git a/scripts/gen-connectors.ts b/scripts/gen-connectors.ts index e69f34e6..59a3710c 100644 --- a/scripts/gen-connectors.ts +++ b/scripts/gen-connectors.ts @@ -64,7 +64,8 @@ for (const entry of connectorEntries) { const safeName = camelCase(name).replace(/db/i, "DB").replace(/sql/i, "SQL"); - const alternativeNames: string[] = aliases[name] || []; + const alternativeNames: readonly string[] = + aliases[name as keyof typeof aliases] || []; const names = [...new Set([name, ...alternativeNames])]; diff --git a/src/capabilities.ts b/src/capabilities.ts index 0a446dae..18f3ed02 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -1,48 +1,46 @@ import type { DatabaseCapabilities, SQLDialect } from "./types.ts"; -const sqlite: DatabaseCapabilities = { +const sqlite: DatabaseCapabilities = Object.freeze({ supportsJSON: true, supportsBooleans: false, supportsArrays: false, supportsDates: false, supportsUUIDs: false, supportsTransactions: true, - supportsBatch: true, -}; +}); -const postgresql: DatabaseCapabilities = { +const postgresql: DatabaseCapabilities = Object.freeze({ supportsJSON: true, supportsBooleans: true, supportsArrays: true, supportsDates: true, supportsUUIDs: true, supportsTransactions: true, - supportsBatch: true, -}; +}); -const mysql: DatabaseCapabilities = { +const mysql: DatabaseCapabilities = Object.freeze({ supportsJSON: true, supportsBooleans: true, supportsArrays: false, supportsDates: true, supportsUUIDs: false, supportsTransactions: true, - supportsBatch: true, -}; +}); -export const dialectCapabilities: Record = { - sqlite, - libsql: sqlite, - postgresql, - mysql, -}; +export const dialectCapabilities: Record = + Object.freeze({ + sqlite, + libsql: sqlite, + postgresql, + mysql, + }); export function getCapabilities( dialect: SQLDialect, overrides?: Partial, ): DatabaseCapabilities { return overrides - ? { ...dialectCapabilities[dialect], ...overrides } + ? Object.freeze({ ...dialectCapabilities[dialect], ...overrides }) : dialectCapabilities[dialect]; } diff --git a/src/connectors/_internal/capabilities.ts b/src/connectors/_internal/capabilities.ts deleted file mode 100644 index eefd73b6..00000000 --- a/src/connectors/_internal/capabilities.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { dialectCapabilities } from "../../capabilities.ts"; -import type { DatabaseCapabilities } from "../../types.ts"; - -// Connector-to-capabilities mapping for documentation generation -export const connectorCapabilities: Record = { - "better-sqlite3": dialectCapabilities.sqlite, - sqlite3: dialectCapabilities.sqlite, - "bun-sqlite": dialectCapabilities.sqlite, - "node-sqlite": dialectCapabilities.sqlite, - libsql: dialectCapabilities.libsql, - "cloudflare-d1": dialectCapabilities.sqlite, - postgresql: dialectCapabilities.postgresql, - pglite: dialectCapabilities.postgresql, - neon: dialectCapabilities.postgresql, - "cloudflare-hyperdrive-postgresql": dialectCapabilities.postgresql, - mysql2: dialectCapabilities.mysql, - planetscale: dialectCapabilities.mysql, - "cloudflare-hyperdrive-mysql": dialectCapabilities.mysql, -}; diff --git a/src/connectors/cloudflare-d1.ts b/src/connectors/cloudflare-d1.ts index f4a36999..07c40f2f 100644 --- a/src/connectors/cloudflare-d1.ts +++ b/src/connectors/cloudflare-d1.ts @@ -27,6 +27,10 @@ export default function cloudflareD1Connector( return { name: "cloudflare-d1", dialect: "sqlite", + // D1 has no explicit transactions (`BEGIN`/`COMMIT` are rejected); + // only implicit ones via `D1Database.batch()`. + // https://developers.cloudflare.com/d1/worker-api/d1-database/#batch + capabilityOverrides: { supportsTransactions: false }, getInstance: () => getDB(), exec: (sql) => getDB().exec(sql), prepare: (sql) => new StatementWrapper(getDB().prepare(sql)), diff --git a/src/database.ts b/src/database.ts index 66af456f..b5fa7f41 100644 --- a/src/database.ts +++ b/src/database.ts @@ -21,6 +21,11 @@ const DISPOSED_ERR = export function createDatabase( connector: TConnector, ): Database { + const capabilities = getCapabilities( + connector.dialect, + connector.capabilityOverrides, + ); + let _disposed = false; const checkDisposed = () => { if (_disposed) { @@ -40,7 +45,7 @@ export function createDatabase( }, get capabilities() { - return getCapabilities(connector.dialect, connector.capabilityOverrides); + return capabilities; }, get disposed() { diff --git a/src/types.ts b/src/types.ts index f896a688..5df18636 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,7 +14,6 @@ export interface DatabaseCapabilities { readonly supportsDates: boolean; readonly supportsUUIDs: boolean; readonly supportsTransactions: boolean; - readonly supportsBatch: boolean; } export type Statement = { diff --git a/test/connectors/_tests.ts b/test/connectors/_tests.ts index f87e5a2c..cf664737 100644 --- a/test/connectors/_tests.ts +++ b/test/connectors/_tests.ts @@ -4,6 +4,7 @@ import { Database, DatabaseCapabilities, createDatabase, + getCapabilities, type SQLDialect, } from "../../src"; @@ -40,19 +41,12 @@ export function testConnector(opts: { }); it("capabilities match", () => { - expect(db.capabilities).toBeDefined(); - expect(typeof db.capabilities.supportsJSON).toBe("boolean"); - expect(typeof db.capabilities.supportsBooleans).toBe("boolean"); - expect(typeof db.capabilities.supportsArrays).toBe("boolean"); - expect(typeof db.capabilities.supportsDates).toBe("boolean"); - expect(typeof db.capabilities.supportsUUIDs).toBe("boolean"); - expect(typeof db.capabilities.supportsTransactions).toBe("boolean"); - expect(typeof db.capabilities.supportsBatch).toBe("boolean"); - if (opts.capabilities) { - for (const [key, value] of Object.entries(opts.capabilities)) { - expect(db.capabilities[key as keyof DatabaseCapabilities]).toBe(value); - } - } + expect(db.capabilities).toEqual( + getCapabilities(opts.dialect, opts.capabilities), + ); + // Capabilities are a stable, immutable snapshot. + expect(db.capabilities).toBe(db.capabilities); + expect(Object.isFrozen(db.capabilities)).toBe(true); }); it("drop and create table", async () => { diff --git a/test/connectors/cloudflare/cloudflare-d1.test.ts b/test/connectors/cloudflare/cloudflare-d1.test.ts index 92797505..800b5bc7 100644 --- a/test/connectors/cloudflare/cloudflare-d1.test.ts +++ b/test/connectors/cloudflare/cloudflare-d1.test.ts @@ -21,6 +21,7 @@ describe("connectors: cloudflare-d1", () => { testConnector({ dialect: "sqlite", + capabilities: { supportsTransactions: false }, connector: cloudflareD1({ bindingName: "test", }), diff --git a/tsconfig.json b/tsconfig.json index d066ea1d..8a26f039 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ } }, "include": [ - "src" + "src", + "scripts" ] } From d751aa41a96941377ab8812fe5db89343c80c64c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 20 Aug 2026 18:03:01 +0000 Subject: [PATCH 4/6] fix(capabilities): correct supportsTransactions and guard docs drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supportsTransactions` reflects the driver's session model, not the SQL dialect, so deriving it from `dialect` produced wrong values for every connector that opens a new session per query — D1 was not the only one: - planetscale: `Client.execute()` builds a fresh `Connection` (and session) per call, so `BEGIN`/`COMMIT` sent as separate statements silently run as unrelated autocommit statements. - libsql-http / libsql-web: `client.execute()` opens a Hrana stream and closes it within the same request, with the same effect. Add `capabilityOverrides` passthrough to the libsql core connector so the HTTP/web variants can declare this, and document that libsql-node and libsql-core report `true` for the local-file case only. Guard the generated docs table with `test/connector-capabilities.test.ts`, which builds each connector and compares against the declared row — the `Record` type only enforced that keys exist, not that values were right. The table data moves to `scripts/_capabilities-data.ts` so the test can import it without triggering the generator's write. Also run `automd` from `gen-capabilities` (the inlined copy in `2.capabilities.md` was never regenerated) and emit a Prettier-stable table so the artifact does not churn. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 17 +++-- docs/1.guide/2.capabilities.md | 77 +++++++++++++-------- docs/1.guide/_capabilities-table.md | 37 +++++----- package.json | 2 +- scripts/_capabilities-data.ts | 45 ++++++++++++ scripts/gen-capabilities-docs.ts | 102 ++++++++++++++-------------- src/capabilities.ts | 2 - src/connectors/libsql/core.ts | 4 +- src/connectors/libsql/http.ts | 4 ++ src/connectors/libsql/web.ts | 4 ++ src/connectors/planetscale.ts | 4 ++ test/connector-capabilities.test.ts | 43 ++++++++++++ 12 files changed, 232 insertions(+), 109 deletions(-) create mode 100644 scripts/_capabilities-data.ts create mode 100644 test/connector-capabilities.test.ts diff --git a/AGENTS.md b/AGENTS.md index 47532b16..71447f26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,13 +35,13 @@ Each connector lazily initializes its underlying client (via `lazyInstance()`) a ## Build & Dev -| Command | Purpose | -|---------|---------| -| `pnpm build` | Generate connector registry + bundled build via `obuild` (index, every connector, and every integration are separate bundle entries with shared chunks; `build.config.ts` also validates `package.json` exports stay in sync) | -| `pnpm dev` | Vitest watch mode | -| `pnpm test` | Lint + typecheck (`tsgo`) + vitest with coverage + bun tests | -| `pnpm vitest run ` | Run a specific test | -| `pnpm lint` / `pnpm fmt` | ESLint + Prettier | +| Command | Purpose | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm build` | Generate connector registry + bundled build via `obuild` (index, every connector, and every integration are separate bundle entries with shared chunks; `build.config.ts` also validates `package.json` exports stay in sync) | +| `pnpm dev` | Vitest watch mode | +| `pnpm test` | Lint + typecheck (`tsgo`) + vitest with coverage + bun tests | +| `pnpm vitest run ` | Run a specific test | +| `pnpm lint` / `pnpm fmt` | ESLint + Prettier | Package manager: **pnpm**. Build tool: **obuild**. Typecheck: **tsgo**. @@ -51,11 +51,14 @@ Tests live in `test/connectors/`. A shared `testConnector()` helper (`test/conne `test/connector-dependencies.test.ts` asserts the generated `connectorDependencies` map matches the `importLib()` calls in each connector source, so declared metadata cannot drift. +`test/connector-capabilities.test.ts` does the same for capabilities: it builds each connector and asserts the docs table in `scripts/_capabilities-data.ts` matches the connector's real `dialect` + `capabilityOverrides`. + ## Key Patterns - **Zero deps** — no runtime deps and no peer deps; backend drivers are imported lazily via dynamic `import()` (see `_internal/utils.ts` `importLib()`), with a `lib` option escape hatch on every connector that needs one - **Modular exports** — `db0/connectors/*`, `db0/integrations/drizzle`, `db0/integrations/kysely` - **All bare imports stay external** — only relative/internal code is bundled, so backend drivers are never inlined - **Dialect-aware** — adjusts SQL behavior (e.g., `RETURNING` support) per `SQLDialect` +- **Capabilities** — `db.capabilities` is a frozen snapshot from `src/capabilities.ts`, derived from the connector's `dialect` and refined by its optional `capabilityOverrides`. Note that `supportsTransactions` tracks the driver's _session model_, not the engine: connectors that open a new session per query (D1, PlanetScale, libsql over HTTP) set it to `false` - **`BoundableStatement`** base class — shared bind/execute logic in `_internal/statement.ts` - **AsyncDisposable** — `await using db = createDatabase(...)` is supported diff --git a/docs/1.guide/2.capabilities.md b/docs/1.guide/2.capabilities.md index 1aa00627..9a7cc9af 100644 --- a/docs/1.guide/2.capabilities.md +++ b/docs/1.guide/2.capabilities.md @@ -33,14 +33,14 @@ overrides where a specific backend differs from its dialect (see the table below ## Available Flags -| Flag | Description | -| ---------------------- | -------------------------------------------------------------- | -| `supportsJSON` | The database has JSON support (column types and/or functions). | -| `supportsBooleans` | The database supports native boolean types (not 0/1). | -| `supportsArrays` | The database supports native array column types. | -| `supportsDates` | The database supports native date/timestamp types. | -| `supportsUUIDs` | The database supports native UUID column types. | -| `supportsTransactions` | The database supports explicit transactions. | +| Flag | Description | +| ---------------------- | -------------------------------------------------------------------------------- | +| `supportsJSON` | The database has JSON support (a native type and/or JSON functions). | +| `supportsBooleans` | The database supports native boolean types (not 0/1). | +| `supportsArrays` | The database supports native array column types. | +| `supportsDates` | The database supports native date/timestamp types. | +| `supportsUUIDs` | The database supports native UUID column types. | +| `supportsTransactions` | Explicit `BEGIN`/`COMMIT` through `db.sql` opens a transaction (see note below). | ## Capabilities by Connector @@ -50,30 +50,45 @@ overrides where a specific backend differs from its dialect (see the table below -| Connector | JSON | Bool | Array | Date | UUID | Tx | -| :--------: | :---: | :---: | :----: | :---: | :---: | :-: | -| better-sqlite3 | ✓ | — | — | — | — | ✓ | -| bun-sqlite | ✓ | — | — | — | — | ✓ | -| cloudflare-d1 | ✓ | — | — | — | — | — | -| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | -| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| libsql-core | ✓ | — | — | — | — | ✓ | -| libsql-http | ✓ | — | — | — | — | ✓ | -| libsql-node | ✓ | — | — | — | — | ✓ | -| libsql-web | ✓ | — | — | — | — | ✓ | -| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | -| neon | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| node-sqlite | ✓ | — | — | — | — | ✓ | -| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | -| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| sqlite3 | ✓ | — | — | — | — | ✓ | + +| Connector | JSON | Bool | Array | Date | UUID | Tx | +| :------------------------------- | :--: | :--: | :---: | :--: | :--: | :-: | +| better-sqlite3 | ✓ | — | — | — | — | ✓ | +| bun-sqlite | ✓ | — | — | — | — | ✓ | +| cloudflare-d1 | ✓ | — | — | — | — | — | +| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | +| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| libsql-core | ✓ | — | — | — | — | ✓ | +| libsql-http | ✓ | — | — | — | — | — | +| libsql-node | ✓ | — | — | — | — | ✓ | +| libsql-web | ✓ | — | — | — | — | — | +| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | +| neon | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| node-sqlite | ✓ | — | — | — | — | ✓ | +| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| planetscale | ✓ | ✓ | — | ✓ | — | — | +| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| sqlite3 | ✓ | — | — | — | — | ✓ | > [!NOTE] -> [Cloudflare D1](/connectors/cloudflare) has no explicit transactions — `BEGIN`/`COMMIT` are -> rejected, and only implicit transactions via `D1Database.batch()` are available. +> `supportsTransactions` describes whether `BEGIN`/`COMMIT` sent through `db.sql` actually +> open a transaction. It is a property of the driver's session model rather than of the SQL +> dialect, so several connectors report `false` even though their database engine supports +> transactions: +> +> - [Cloudflare D1](/connectors/cloudflare) rejects `BEGIN`/`COMMIT` outright; only implicit +> transactions via `D1Database.batch()` are available. +> - [PlanetScale](/connectors/planetscale) opens a new HTTP session per query, so consecutive +> statements never share a transaction. Use `Client.transaction()` on the underlying client. +> - [libsql-http and libsql-web](/connectors/libsql) open and close a Hrana stream within a +> single request per query. Use `client.transaction()` on the underlying client. +> +> `libsql-node` and `libsql-core` report `true` because they are commonly used against a local +> file, where a single connection is held open. Pointed at a remote `libsql:`/`http:` URL they +> behave like `libsql-http`, and this flag cannot detect that statically — reach for +> `client.transaction()` if you are unsure. ## Use Cases @@ -82,6 +97,8 @@ overrides where a specific backend differs from its dialect (see the table below You can adapt your queries to what the database supports: ```ts +import type { Database } from "db0"; + function storeList(db: Database, items: string[]) { const value = db.capabilities.supportsArrays ? `{${items.join(",")}}` // PostgreSQL array literal @@ -100,6 +117,8 @@ function storeList(db: Database, items: string[]) { You can validate that the database meets your application's requirements: ```ts +import type { Database } from "db0"; + function initDatabase(db: Database) { if (!db.capabilities.supportsTransactions) { throw new Error("This application requires transaction support"); @@ -112,6 +131,8 @@ function initDatabase(db: Database) { You can build database-agnostic libraries that adapt automatically: ```ts +import type { Database } from "db0"; + export function createRepository(db: Database) { return { saveFlag(id: string, enabled: boolean) { diff --git a/docs/1.guide/_capabilities-table.md b/docs/1.guide/_capabilities-table.md index 05d56c8d..83dea8c1 100644 --- a/docs/1.guide/_capabilities-table.md +++ b/docs/1.guide/_capabilities-table.md @@ -1,19 +1,20 @@ -| Connector | JSON | Bool | Array | Date | UUID | Tx | -| :--------: | :---: | :---: | :----: | :---: | :---: | :-: | -| better-sqlite3 | ✓ | — | — | — | — | ✓ | -| bun-sqlite | ✓ | — | — | — | — | ✓ | -| cloudflare-d1 | ✓ | — | — | — | — | — | -| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | -| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| libsql-core | ✓ | — | — | — | — | ✓ | -| libsql-http | ✓ | — | — | — | — | ✓ | -| libsql-node | ✓ | — | — | — | — | ✓ | -| libsql-web | ✓ | — | — | — | — | ✓ | -| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | -| neon | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| node-sqlite | ✓ | — | — | — | — | ✓ | -| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| planetscale | ✓ | ✓ | — | ✓ | — | ✓ | -| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| sqlite3 | ✓ | — | — | — | — | ✓ | + +| Connector | JSON | Bool | Array | Date | UUID | Tx | +| :------------------------------- | :--: | :--: | :---: | :--: | :--: | :-: | +| better-sqlite3 | ✓ | — | — | — | — | ✓ | +| bun-sqlite | ✓ | — | — | — | — | ✓ | +| cloudflare-d1 | ✓ | — | — | — | — | — | +| cloudflare-hyperdrive-mysql | ✓ | ✓ | — | ✓ | — | ✓ | +| cloudflare-hyperdrive-postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| libsql-core | ✓ | — | — | — | — | ✓ | +| libsql-http | ✓ | — | — | — | — | — | +| libsql-node | ✓ | — | — | — | — | ✓ | +| libsql-web | ✓ | — | — | — | — | — | +| mysql2 | ✓ | ✓ | — | ✓ | — | ✓ | +| neon | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| node-sqlite | ✓ | — | — | — | — | ✓ | +| pglite | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| planetscale | ✓ | ✓ | — | ✓ | — | — | +| postgresql | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| sqlite3 | ✓ | — | — | — | — | ✓ | diff --git a/package.json b/package.json index a2d8ca5f..b3a060f0 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "scripts": { "build": "pnpm gen-connectors && pnpm gen-capabilities && obuild", "gen-connectors": "jiti scripts/gen-connectors.ts", - "gen-capabilities": "jiti scripts/gen-capabilities-docs.ts", + "gen-capabilities": "jiti scripts/gen-capabilities-docs.ts && automd --dir docs --input 1.guide/2.capabilities.md", "db0": "pnpm jiti src/cli", "dev": "vitest", "lint": "prettier -c src test scripts", diff --git a/scripts/_capabilities-data.ts b/scripts/_capabilities-data.ts new file mode 100644 index 00000000..4af00269 --- /dev/null +++ b/scripts/_capabilities-data.ts @@ -0,0 +1,45 @@ +import { dialectCapabilities, getCapabilities } from "../src/capabilities.ts"; +import type { ConnectorName } from "../src/_connectors.ts"; +import type { DatabaseCapabilities } from "../src/types.ts"; + +/** + * Capabilities of every connector, mirroring its `dialect` and `capabilityOverrides`. + * + * Typed as an exhaustive `Record` so that `pnpm test:types` + * fails when a connector is added without a row here. The *values* are checked + * against the real connectors by `test/connector-capabilities.test.ts`, which + * imports each one and compares — keep both in mind when editing. + */ +export const connectorCapabilities: Record< + ConnectorName, + DatabaseCapabilities +> = { + "better-sqlite3": dialectCapabilities.sqlite, + sqlite3: dialectCapabilities.sqlite, + "bun-sqlite": dialectCapabilities.sqlite, + bun: dialectCapabilities.sqlite, + "node-sqlite": dialectCapabilities.sqlite, + sqlite: dialectCapabilities.sqlite, + "libsql-core": dialectCapabilities.libsql, + "libsql-node": dialectCapabilities.libsql, + libsql: dialectCapabilities.libsql, + "libsql-http": getCapabilities("libsql", { supportsTransactions: false }), + "libsql-web": getCapabilities("libsql", { supportsTransactions: false }), + "cloudflare-d1": getCapabilities("sqlite", { supportsTransactions: false }), + postgresql: dialectCapabilities.postgresql, + pglite: dialectCapabilities.postgresql, + neon: dialectCapabilities.postgresql, + "cloudflare-hyperdrive-postgresql": dialectCapabilities.postgresql, + mysql2: dialectCapabilities.mysql, + planetscale: getCapabilities("mysql", { supportsTransactions: false }), + "cloudflare-hyperdrive-mysql": dialectCapabilities.mysql, +}; + +export const capabilityLabels: Record = { + supportsJSON: "JSON", + supportsBooleans: "Bool", + supportsArrays: "Array", + supportsDates: "Date", + supportsUUIDs: "UUID", + supportsTransactions: "Tx", +}; diff --git a/scripts/gen-capabilities-docs.ts b/scripts/gen-capabilities-docs.ts index aa80c233..415da438 100644 --- a/scripts/gen-capabilities-docs.ts +++ b/scripts/gen-capabilities-docs.ts @@ -1,50 +1,16 @@ import { writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { connectors, type ConnectorName } from "../src/_connectors.ts"; -import { dialectCapabilities, getCapabilities } from "../src/capabilities.ts"; +import { + capabilityLabels, + connectorCapabilities, +} from "./_capabilities-data.ts"; import type { DatabaseCapabilities } from "../src/types.ts"; const outputFile = fileURLToPath( new URL("../docs/1.guide/_capabilities-table.md", import.meta.url), ); -/** - * Capabilities of every connector, mirroring its `dialect` and `capabilityOverrides`. - * - * Typed as an exhaustive `Record` so that `pnpm test:types` - * fails when a connector is added without a row here. - */ -const connectorCapabilities: Record = { - "better-sqlite3": dialectCapabilities.sqlite, - sqlite3: dialectCapabilities.sqlite, - "bun-sqlite": dialectCapabilities.sqlite, - bun: dialectCapabilities.sqlite, - "node-sqlite": dialectCapabilities.sqlite, - sqlite: dialectCapabilities.sqlite, - "libsql-core": dialectCapabilities.libsql, - "libsql-node": dialectCapabilities.libsql, - libsql: dialectCapabilities.libsql, - "libsql-http": dialectCapabilities.libsql, - "libsql-web": dialectCapabilities.libsql, - "cloudflare-d1": getCapabilities("sqlite", { supportsTransactions: false }), - postgresql: dialectCapabilities.postgresql, - pglite: dialectCapabilities.postgresql, - neon: dialectCapabilities.postgresql, - "cloudflare-hyperdrive-postgresql": dialectCapabilities.postgresql, - mysql2: dialectCapabilities.mysql, - planetscale: dialectCapabilities.mysql, - "cloudflare-hyperdrive-mysql": dialectCapabilities.mysql, -}; - -const capabilityLabels: Record = { - supportsJSON: "JSON", - supportsBooleans: "Bool", - supportsArrays: "Array", - supportsDates: "Date", - supportsUUIDs: "UUID", - supportsTransactions: "Tx", -}; - const check = "✓"; const cross = "—"; @@ -61,27 +27,59 @@ function canonicalConnectorNames(): ConnectorName[] { }); } +/** Pads `value` to `width`, matching how Prettier lays out markdown tables. */ +function pad(value: string, width: number, align: "left" | "center"): string { + const total = width - [...value].length; + if (align === "left") { + return value + " ".repeat(total); + } + const left = Math.floor(total / 2); + return " ".repeat(left) + value + " ".repeat(total - left); +} + function generateTable(names: ConnectorName[]): string { + const keys = Object.keys(capabilityLabels) as (keyof DatabaseCapabilities)[]; const headers = ["Connector", ...Object.values(capabilityLabels)]; - const separator = headers.map((h) => ":".padEnd(h.length, "-") + ":"); - const rows = names.map((name) => { - const caps = connectorCapabilities[name]; - const cells = ( - Object.keys(capabilityLabels) as (keyof DatabaseCapabilities)[] - ).map((key) => (caps[key] ? check : cross)); - return [name, ...cells]; - }); + // The connector column reads as a label, the flag columns as marks. + const aligns = headers.map((_, index) => + index === 0 ? ("left" as const) : ("center" as const), + ); - const lines = [ - headers.join(" | "), - separator.join(" | "), - ...rows.map((row) => row.join(" | ")), - ]; - return lines.map((line) => `| ${line} |`).join("\n"); + const body = names.map((name) => [ + name, + ...keys.map((key) => (connectorCapabilities[name][key] ? check : cross)), + ]); + + // `:-:` is the narrowest legal separator, so it sets the minimum column width. + const widths = headers.map((header, index) => + Math.max( + 3, + ...[header, ...body.map((row) => row[index]!)].map( + (cell) => [...cell].length, + ), + ), + ); + + const separator = widths.map((width, index) => + aligns[index] === "left" + ? ":" + "-".repeat(width - 1) + : ":" + "-".repeat(width - 2) + ":", + ); + + const rows = [headers, separator, ...body].map((row, index) => + index === 1 + ? `| ${row.join(" | ")} |` + : `| ${row + .map((cell, column) => pad(cell, widths[column]!, aligns[column]!)) + .join(" | ")} |`, + ); + + return rows.join("\n"); } const content = ` + ${generateTable(canonicalConnectorNames())} `; diff --git a/src/capabilities.ts b/src/capabilities.ts index 18f3ed02..3fb03b93 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -43,5 +43,3 @@ export function getCapabilities( ? Object.freeze({ ...dialectCapabilities[dialect], ...overrides }) : dialectCapabilities[dialect]; } - -export type { DatabaseCapabilities } from "./types.ts"; diff --git a/src/connectors/libsql/core.ts b/src/connectors/libsql/core.ts index 9099ca8a..67a7ca00 100644 --- a/src/connectors/libsql/core.ts +++ b/src/connectors/libsql/core.ts @@ -1,10 +1,11 @@ import type { Client, InStatement } from "@libsql/client"; -import type { Connector, Primitive } from "db0"; +import type { Connector, DatabaseCapabilities, Primitive } from "db0"; import { BoundableStatement } from "../_internal/statement.ts"; export type ConnectorOptions = { getClient: () => Client | Promise; name?: string; + capabilityOverrides?: Partial; dispose?: () => void | Promise; }; @@ -19,6 +20,7 @@ export default function libSqlCoreConnector( return { name: opts.name || "libsql-core", dialect: "libsql", + capabilityOverrides: opts.capabilityOverrides, getInstance: async () => opts.getClient(), exec: (sql) => query(sql), prepare: (sql) => new StatementWrapper(sql, query), diff --git a/src/connectors/libsql/http.ts b/src/connectors/libsql/http.ts index 5a903546..70b4275d 100644 --- a/src/connectors/libsql/http.ts +++ b/src/connectors/libsql/http.ts @@ -39,6 +39,10 @@ export default function libSqlConnector( return libSqlCore({ name: CONNECTOR_NAME, + // Every `client.execute()` opens a fresh Hrana stream and closes it in the + // same request, so `BEGIN`/`COMMIT` sent as separate statements never share + // a stream. Transactions require `client.transaction()`. + capabilityOverrides: { supportsTransactions: false }, getClient, dispose: async () => { const client = await getClient.current; diff --git a/src/connectors/libsql/web.ts b/src/connectors/libsql/web.ts index f32ff31b..e1c10e99 100644 --- a/src/connectors/libsql/web.ts +++ b/src/connectors/libsql/web.ts @@ -39,6 +39,10 @@ export default function libSqlConnector( return libSqlCore({ name: CONNECTOR_NAME, + // Every `client.execute()` opens a fresh Hrana stream and closes it in the + // same request, so `BEGIN`/`COMMIT` sent as separate statements never share + // a stream. Transactions require `client.transaction()`. + capabilityOverrides: { supportsTransactions: false }, getClient, dispose: async () => { const client = await getClient.current; diff --git a/src/connectors/planetscale.ts b/src/connectors/planetscale.ts index 24a6b2fb..1c1586c6 100644 --- a/src/connectors/planetscale.ts +++ b/src/connectors/planetscale.ts @@ -52,6 +52,10 @@ export default function planetscaleConnector( return { name: "planetscale", dialect: "mysql", + // `Client.execute()` opens a new `Connection` (and a new session) per query, + // so `BEGIN`/`COMMIT` issued as separate statements never share a session. + // Transactions require `Client.transaction()`. + capabilityOverrides: { supportsTransactions: false }, getInstance: () => getClient(), exec: (sql) => query(sql), prepare: (sql) => new StatementWrapper(sql, query), diff --git a/test/connector-capabilities.test.ts b/test/connector-capabilities.test.ts new file mode 100644 index 00000000..a63d8a9b --- /dev/null +++ b/test/connector-capabilities.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { connectors, type ConnectorName } from "../src/_connectors"; +import { getCapabilities } from "../src/capabilities"; +import { connectorCapabilities } from "../scripts/_capabilities-data"; + +/** + * `bun-sqlite` statically imports `bun:sqlite`, which cannot be resolved under + * Node — it is covered by `test/connectors/bun-test.ts` instead. + */ +const NOT_LOADABLE_IN_NODE = new Set(["bun-sqlite", "bun"]); + +describe("connector capabilities", () => { + for (const name of Object.keys(connectors) as ConnectorName[]) { + it.skipIf(NOT_LOADABLE_IN_NODE.has(name))(name, async () => { + const specifier = connectors[name].replace( + "db0/connectors/", + "../src/connectors/", + ); + const { default: createConnector } = await import(specifier); + + // Connectors are lazy: building one never touches the underlying driver, + // so `{}` is enough to read its declared `dialect`/`capabilityOverrides`. + const connector = createConnector({}); + + expect( + getCapabilities(connector.dialect, connector.capabilityOverrides), + `${name}: docs table is out of sync with the connector`, + ).toEqual(connectorCapabilities[name]); + }); + } + + it("aliases share the same capabilities", () => { + expect(connectorCapabilities["libsql"]).toEqual( + connectorCapabilities["libsql-node"], + ); + expect(connectorCapabilities["bun"]).toEqual( + connectorCapabilities["bun-sqlite"], + ); + expect(connectorCapabilities["sqlite"]).toEqual( + connectorCapabilities["node-sqlite"], + ); + }); +}); From d251aeb5078ffd380565021bf077a08a4975ac70 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 20 Aug 2026 18:17:32 +0000 Subject: [PATCH 5/6] fix(capabilities): align planetscale test and boolean flag docs The planetscale connector declares `supportsTransactions: false`, but its `testConnector()` call still expected the plain mysql dialect defaults, so the new "capabilities match" assertion failed whenever PLANETSCALE_* creds were present (the suite is skipped without them, hiding the failure). Also reword `supportsBooleans` in the docs: MySQL reports `true` but maps `BOOLEAN` to `TINYINT(1)` and mysql2 reads it back as 0/1, contradicting the previous "(not 0/1)" wording. Co-Authored-By: Claude Opus 5 (1M context) --- docs/1.guide/2.capabilities.md | 2 +- test/connectors/planetscale.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/1.guide/2.capabilities.md b/docs/1.guide/2.capabilities.md index 9a7cc9af..6f58da36 100644 --- a/docs/1.guide/2.capabilities.md +++ b/docs/1.guide/2.capabilities.md @@ -36,7 +36,7 @@ overrides where a specific backend differs from its dialect (see the table below | Flag | Description | | ---------------------- | -------------------------------------------------------------------------------- | | `supportsJSON` | The database has JSON support (a native type and/or JSON functions). | -| `supportsBooleans` | The database supports native boolean types (not 0/1). | +| `supportsBooleans` | The database has a boolean column type (MySQL maps it to `TINYINT(1)`). | | `supportsArrays` | The database supports native array column types. | | `supportsDates` | The database supports native date/timestamp types. | | `supportsUUIDs` | The database supports native UUID column types. | diff --git a/test/connectors/planetscale.test.ts b/test/connectors/planetscale.test.ts index a254f9e7..2b175a44 100644 --- a/test/connectors/planetscale.test.ts +++ b/test/connectors/planetscale.test.ts @@ -9,6 +9,7 @@ describe.runIf( )("connectors: planetscale.test", () => { testConnector({ dialect: "mysql", + capabilities: { supportsTransactions: false }, connector: connector({ host: process.env.PLANETSCALE_HOST!, username: process.env.PLANETSCALE_USERNAME!, From 73e73f6be3cff89626d1f44fbd6a12d667afd6a0 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 20 Aug 2026 19:21:45 +0000 Subject: [PATCH 6/6] refactor(capabilities)!: drop `supports` prefix from capability flags `db.capabilities.X` already reads as "supports X", so the prefix was redundant: `supportsJSON` -> `json`, `supportsBooleans` -> `booleans`, `supportsArrays` -> `arrays`, `supportsDates` -> `dates`, `supportsUUIDs` -> `uuids`, `supportsTransactions` -> `transactions`. BREAKING CHANGE: `DatabaseCapabilities` keys are renamed. The capability API is unreleased, so no published consumers are affected. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- docs/1.guide/2.capabilities.md | 30 ++++++++-------- scripts/_capabilities-data.ts | 20 +++++------ src/capabilities.ts | 36 +++++++++---------- src/connectors/cloudflare-d1.ts | 2 +- src/connectors/libsql/http.ts | 2 +- src/connectors/libsql/web.ts | 2 +- src/connectors/planetscale.ts | 2 +- src/types.ts | 12 +++---- .../cloudflare/cloudflare-d1.test.ts | 2 +- test/connectors/planetscale.test.ts | 2 +- 11 files changed, 55 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 71447f26..b9ab5774 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,6 @@ Tests live in `test/connectors/`. A shared `testConnector()` helper (`test/conne - **Modular exports** — `db0/connectors/*`, `db0/integrations/drizzle`, `db0/integrations/kysely` - **All bare imports stay external** — only relative/internal code is bundled, so backend drivers are never inlined - **Dialect-aware** — adjusts SQL behavior (e.g., `RETURNING` support) per `SQLDialect` -- **Capabilities** — `db.capabilities` is a frozen snapshot from `src/capabilities.ts`, derived from the connector's `dialect` and refined by its optional `capabilityOverrides`. Note that `supportsTransactions` tracks the driver's _session model_, not the engine: connectors that open a new session per query (D1, PlanetScale, libsql over HTTP) set it to `false` +- **Capabilities** — `db.capabilities` is a frozen snapshot from `src/capabilities.ts`, derived from the connector's `dialect` and refined by its optional `capabilityOverrides`. Note that `transactions` tracks the driver's _session model_, not the engine: connectors that open a new session per query (D1, PlanetScale, libsql over HTTP) set it to `false` - **`BoundableStatement`** base class — shared bind/execute logic in `_internal/statement.ts` - **AsyncDisposable** — `await using db = createDatabase(...)` is supported diff --git a/docs/1.guide/2.capabilities.md b/docs/1.guide/2.capabilities.md index 6f58da36..c4ed8a38 100644 --- a/docs/1.guide/2.capabilities.md +++ b/docs/1.guide/2.capabilities.md @@ -19,9 +19,9 @@ import sqlite from "db0/connectors/better-sqlite3"; const db = createDatabase(sqlite({})); console.log(db.capabilities); -// { supportsJSON: true, supportsBooleans: false, supportsArrays: false, ... } +// { json: true, booleans: false, arrays: false, ... } -if (db.capabilities.supportsBooleans) { +if (db.capabilities.booleans) { // Use a native boolean column } else { // Fall back to an integer 0/1 column @@ -33,14 +33,14 @@ overrides where a specific backend differs from its dialect (see the table below ## Available Flags -| Flag | Description | -| ---------------------- | -------------------------------------------------------------------------------- | -| `supportsJSON` | The database has JSON support (a native type and/or JSON functions). | -| `supportsBooleans` | The database has a boolean column type (MySQL maps it to `TINYINT(1)`). | -| `supportsArrays` | The database supports native array column types. | -| `supportsDates` | The database supports native date/timestamp types. | -| `supportsUUIDs` | The database supports native UUID column types. | -| `supportsTransactions` | Explicit `BEGIN`/`COMMIT` through `db.sql` opens a transaction (see note below). | +| Flag | Description | +| -------------- | -------------------------------------------------------------------------------- | +| `json` | The database has JSON support (a native type and/or JSON functions). | +| `booleans` | The database has a boolean column type (MySQL maps it to `TINYINT(1)`). | +| `arrays` | The database supports native array column types. | +| `dates` | The database supports native date/timestamp types. | +| `uuids` | The database supports native UUID column types. | +| `transactions` | Explicit `BEGIN`/`COMMIT` through `db.sql` opens a transaction (see note below). | ## Capabilities by Connector @@ -73,7 +73,7 @@ overrides where a specific backend differs from its dialect (see the table below > [!NOTE] -> `supportsTransactions` describes whether `BEGIN`/`COMMIT` sent through `db.sql` actually +> `transactions` describes whether `BEGIN`/`COMMIT` sent through `db.sql` actually > open a transaction. It is a property of the driver's session model rather than of the SQL > dialect, so several connectors report `false` even though their database engine supports > transactions: @@ -100,7 +100,7 @@ You can adapt your queries to what the database supports: import type { Database } from "db0"; function storeList(db: Database, items: string[]) { - const value = db.capabilities.supportsArrays + const value = db.capabilities.arrays ? `{${items.join(",")}}` // PostgreSQL array literal : JSON.stringify(items); return db.sql`INSERT INTO data (items) VALUES (${value})`; @@ -120,7 +120,7 @@ You can validate that the database meets your application's requirements: import type { Database } from "db0"; function initDatabase(db: Database) { - if (!db.capabilities.supportsTransactions) { + if (!db.capabilities.transactions) { throw new Error("This application requires transaction support"); } } @@ -136,9 +136,7 @@ import type { Database } from "db0"; export function createRepository(db: Database) { return { saveFlag(id: string, enabled: boolean) { - const value = db.capabilities.supportsBooleans - ? enabled - : Number(enabled); + const value = db.capabilities.booleans ? enabled : Number(enabled); return db.sql`UPDATE items SET enabled = ${value} WHERE id = ${id}`; }, }; diff --git a/scripts/_capabilities-data.ts b/scripts/_capabilities-data.ts index 4af00269..bc18abbb 100644 --- a/scripts/_capabilities-data.ts +++ b/scripts/_capabilities-data.ts @@ -23,23 +23,23 @@ export const connectorCapabilities: Record< "libsql-core": dialectCapabilities.libsql, "libsql-node": dialectCapabilities.libsql, libsql: dialectCapabilities.libsql, - "libsql-http": getCapabilities("libsql", { supportsTransactions: false }), - "libsql-web": getCapabilities("libsql", { supportsTransactions: false }), - "cloudflare-d1": getCapabilities("sqlite", { supportsTransactions: false }), + "libsql-http": getCapabilities("libsql", { transactions: false }), + "libsql-web": getCapabilities("libsql", { transactions: false }), + "cloudflare-d1": getCapabilities("sqlite", { transactions: false }), postgresql: dialectCapabilities.postgresql, pglite: dialectCapabilities.postgresql, neon: dialectCapabilities.postgresql, "cloudflare-hyperdrive-postgresql": dialectCapabilities.postgresql, mysql2: dialectCapabilities.mysql, - planetscale: getCapabilities("mysql", { supportsTransactions: false }), + planetscale: getCapabilities("mysql", { transactions: false }), "cloudflare-hyperdrive-mysql": dialectCapabilities.mysql, }; export const capabilityLabels: Record = { - supportsJSON: "JSON", - supportsBooleans: "Bool", - supportsArrays: "Array", - supportsDates: "Date", - supportsUUIDs: "UUID", - supportsTransactions: "Tx", + json: "JSON", + booleans: "Bool", + arrays: "Array", + dates: "Date", + uuids: "UUID", + transactions: "Tx", }; diff --git a/src/capabilities.ts b/src/capabilities.ts index 3fb03b93..1282bd1e 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -1,30 +1,30 @@ import type { DatabaseCapabilities, SQLDialect } from "./types.ts"; const sqlite: DatabaseCapabilities = Object.freeze({ - supportsJSON: true, - supportsBooleans: false, - supportsArrays: false, - supportsDates: false, - supportsUUIDs: false, - supportsTransactions: true, + json: true, + booleans: false, + arrays: false, + dates: false, + uuids: false, + transactions: true, }); const postgresql: DatabaseCapabilities = Object.freeze({ - supportsJSON: true, - supportsBooleans: true, - supportsArrays: true, - supportsDates: true, - supportsUUIDs: true, - supportsTransactions: true, + json: true, + booleans: true, + arrays: true, + dates: true, + uuids: true, + transactions: true, }); const mysql: DatabaseCapabilities = Object.freeze({ - supportsJSON: true, - supportsBooleans: true, - supportsArrays: false, - supportsDates: true, - supportsUUIDs: false, - supportsTransactions: true, + json: true, + booleans: true, + arrays: false, + dates: true, + uuids: false, + transactions: true, }); export const dialectCapabilities: Record = diff --git a/src/connectors/cloudflare-d1.ts b/src/connectors/cloudflare-d1.ts index 07c40f2f..caf7d1e8 100644 --- a/src/connectors/cloudflare-d1.ts +++ b/src/connectors/cloudflare-d1.ts @@ -30,7 +30,7 @@ export default function cloudflareD1Connector( // D1 has no explicit transactions (`BEGIN`/`COMMIT` are rejected); // only implicit ones via `D1Database.batch()`. // https://developers.cloudflare.com/d1/worker-api/d1-database/#batch - capabilityOverrides: { supportsTransactions: false }, + capabilityOverrides: { transactions: false }, getInstance: () => getDB(), exec: (sql) => getDB().exec(sql), prepare: (sql) => new StatementWrapper(getDB().prepare(sql)), diff --git a/src/connectors/libsql/http.ts b/src/connectors/libsql/http.ts index 70b4275d..6a2c18e7 100644 --- a/src/connectors/libsql/http.ts +++ b/src/connectors/libsql/http.ts @@ -42,7 +42,7 @@ export default function libSqlConnector( // Every `client.execute()` opens a fresh Hrana stream and closes it in the // same request, so `BEGIN`/`COMMIT` sent as separate statements never share // a stream. Transactions require `client.transaction()`. - capabilityOverrides: { supportsTransactions: false }, + capabilityOverrides: { transactions: false }, getClient, dispose: async () => { const client = await getClient.current; diff --git a/src/connectors/libsql/web.ts b/src/connectors/libsql/web.ts index e1c10e99..8b81b90a 100644 --- a/src/connectors/libsql/web.ts +++ b/src/connectors/libsql/web.ts @@ -42,7 +42,7 @@ export default function libSqlConnector( // Every `client.execute()` opens a fresh Hrana stream and closes it in the // same request, so `BEGIN`/`COMMIT` sent as separate statements never share // a stream. Transactions require `client.transaction()`. - capabilityOverrides: { supportsTransactions: false }, + capabilityOverrides: { transactions: false }, getClient, dispose: async () => { const client = await getClient.current; diff --git a/src/connectors/planetscale.ts b/src/connectors/planetscale.ts index 1c1586c6..6c4106d2 100644 --- a/src/connectors/planetscale.ts +++ b/src/connectors/planetscale.ts @@ -55,7 +55,7 @@ export default function planetscaleConnector( // `Client.execute()` opens a new `Connection` (and a new session) per query, // so `BEGIN`/`COMMIT` issued as separate statements never share a session. // Transactions require `Client.transaction()`. - capabilityOverrides: { supportsTransactions: false }, + capabilityOverrides: { transactions: false }, getInstance: () => getClient(), exec: (sql) => query(sql), prepare: (sql) => new StatementWrapper(sql, query), diff --git a/src/types.ts b/src/types.ts index 5df18636..20d293e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,12 +8,12 @@ export type Primitive = string | number | boolean | undefined | null; export type SQLDialect = "mysql" | "postgresql" | "sqlite" | "libsql"; export interface DatabaseCapabilities { - readonly supportsJSON: boolean; - readonly supportsBooleans: boolean; - readonly supportsArrays: boolean; - readonly supportsDates: boolean; - readonly supportsUUIDs: boolean; - readonly supportsTransactions: boolean; + readonly json: boolean; + readonly booleans: boolean; + readonly arrays: boolean; + readonly dates: boolean; + readonly uuids: boolean; + readonly transactions: boolean; } export type Statement = { diff --git a/test/connectors/cloudflare/cloudflare-d1.test.ts b/test/connectors/cloudflare/cloudflare-d1.test.ts index 800b5bc7..df8ee30e 100644 --- a/test/connectors/cloudflare/cloudflare-d1.test.ts +++ b/test/connectors/cloudflare/cloudflare-d1.test.ts @@ -21,7 +21,7 @@ describe("connectors: cloudflare-d1", () => { testConnector({ dialect: "sqlite", - capabilities: { supportsTransactions: false }, + capabilities: { transactions: false }, connector: cloudflareD1({ bindingName: "test", }), diff --git a/test/connectors/planetscale.test.ts b/test/connectors/planetscale.test.ts index 2b175a44..cf9be0bc 100644 --- a/test/connectors/planetscale.test.ts +++ b/test/connectors/planetscale.test.ts @@ -9,7 +9,7 @@ describe.runIf( )("connectors: planetscale.test", () => { testConnector({ dialect: "mysql", - capabilities: { supportsTransactions: false }, + capabilities: { transactions: false }, connector: connector({ host: process.env.PLANETSCALE_HOST!, username: process.env.PLANETSCALE_USERNAME!,