From 9ec02e6e9b6df1c7b1de1cb07ceec09eafda7f09 Mon Sep 17 00:00:00 2001 From: cevheri Date: Mon, 31 Aug 2026 23:37:13 +0300 Subject: [PATCH 1/2] chore(toolchain): pin bun 1.4.0, and the coverage hole a pin bump alone would have opened --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/agent-eval.yml | 2 +- .github/workflows/ci.yml | 12 ++--- .github/workflows/distribution-check.yml | 2 +- .github/workflows/docker-build-push.yml | 4 +- .github/workflows/flatpak-smoke.yml | 2 +- .github/workflows/integration-check.yml | 2 +- .github/workflows/npm-publish.yml | 4 +- .github/workflows/release-artifacts.yml | 14 ++--- .github/workflows/security-scan.yml | 2 +- Dockerfile | 2 +- bin/studio.js | 11 ++++ docs/providers/sqlite.md | 20 +++++++ package.json | 2 +- src/lib/db/providers/sql/sqlite-driver.ts | 17 +++++- src/lib/db/types.ts | 9 ++-- tests/unit/db/sqlite-driver.test.ts | 63 +++++++++++++++++------ 17 files changed, 123 insertions(+), 47 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 19bbc3302..c6ab90541 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,7 +30,7 @@ Please provide the following information: - **LibreDB Studio Version**: (e.g., 0.5.4) - **Browser**: (e.g., Chrome 120, Firefox 121, Safari 17) - **OS**: (e.g., macOS 14, Windows 11, Ubuntu 22.04) -- **Node.js/Bun Version**: (e.g., Bun 1.3.14, Node.js 24.16.0) +- **Node.js/Bun Version**: (e.g., Bun 1.4.0, Node.js 24.16.0) - **Database Type**: (e.g., PostgreSQL 15, MySQL 8.0, SQLite 3.42, MongoDB 7.0) - **Database Version**: (e.g., PostgreSQL 15.3) diff --git a/.github/workflows/agent-eval.yml b/.github/workflows/agent-eval.yml index 8d4799f12..c73908a2c 100644 --- a/.github/workflows/agent-eval.yml +++ b/.github/workflows/agent-eval.yml @@ -61,7 +61,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd4df7f20..3232b9dbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -125,7 +125,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -215,7 +215,7 @@ jobs: # here. npm resolves bun through platform tarballs instead, so it needs # no unzip. Keep the version in step with the setup-bun pins elsewhere. - name: Setup Bun - run: npm install -g bun@1.3.14 + run: npm install -g bun@1.4.0 - name: Install dependencies uses: ./.github/actions/bun-install @@ -277,7 +277,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -330,7 +330,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -409,7 +409,7 @@ jobs: # npm rather than oven-sh/setup-bun - see the e2e job's note on unzip. - name: Setup Bun - run: npm install -g bun@1.3.14 + run: npm install -g bun@1.4.0 - name: Install dependencies uses: ./.github/actions/bun-install diff --git a/.github/workflows/distribution-check.yml b/.github/workflows/distribution-check.yml index 152ad3244..8c7e074cc 100644 --- a/.github/workflows/distribution-check.yml +++ b/.github/workflows/distribution-check.yml @@ -46,7 +46,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 9091f6a8c..a8064938f 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -62,7 +62,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -291,7 +291,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install diff --git a/.github/workflows/flatpak-smoke.yml b/.github/workflows/flatpak-smoke.yml index 40d063788..2bc333ed9 100644 --- a/.github/workflows/flatpak-smoke.yml +++ b/.github/workflows/flatpak-smoke.yml @@ -78,7 +78,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install diff --git a/.github/workflows/integration-check.yml b/.github/workflows/integration-check.yml index b90ba0a27..fc6ae47b0 100644 --- a/.github/workflows/integration-check.yml +++ b/.github/workflows/integration-check.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 63abe0e26..f43a461b2 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -93,7 +93,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index b0f3a89cc..d11e19a93 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -143,7 +143,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -214,7 +214,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -266,7 +266,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Setup Go (launcher toolchain) uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -521,7 +521,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install @@ -803,7 +803,7 @@ jobs: if: matrix.arch == 'amd64' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies (channel E2E) if: matrix.arch == 'amd64' @@ -900,7 +900,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Setup Rust toolchain # The runner images ship rustup; the Tauri 2.11 dependency tree needs @@ -1050,7 +1050,7 @@ jobs: if: steps.snapstore.outputs.enabled == 'true' && matrix.arch == 'amd64' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies (channel E2E) if: steps.snapstore.outputs.enabled == 'true' && matrix.arch == 'amd64' diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 0a4621d9e..771372934 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -240,7 +240,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.3.14" + bun-version: "1.4.0" - name: Install dependencies uses: ./.github/actions/bun-install diff --git a/Dockerfile b/Dockerfile index 19c0a7274..eb7f1643d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ # Bun for fast dependency installation, Node.js for build # Bun's JIT compiler segfaults under QEMU emulation (ARM64 cross-build), # so we use Node.js for the Next.js build step. -FROM oven/bun:1.3.14 AS deps +FROM oven/bun:1.4.0 AS deps WORKDIR /usr/src/app RUN apt-get update && apt-get install -y python3 make g++ --no-install-recommends && rm -rf /var/lib/apt/lists/* COPY package.json bun.lock ./ diff --git a/bin/studio.js b/bin/studio.js index ad6bdad95..1a1726739 100644 --- a/bin/studio.js +++ b/bin/studio.js @@ -14,6 +14,17 @@ * ESM in a .js file: bin/package.json sets "type": "module" for this * directory only - the root package.json must stay typeless because the * library dist ships CJS .js files consumed via require(). + * + * The `#!/usr/bin/env node` shebang above is load-bearing, not decoration. + * `bunx` honours it and spawns a real node process, which is why `bunx + * @libredb/studio` works; the server below is then started with + * `process.execPath`, so it inherits that node. Drop the shebang and a bunx + * user gets Bun instead, where `better-sqlite3` - the STORAGE_PROVIDER=sqlite + * backend - segfaults rather than failing cleanly (oven-sh/bun#4290, open + * since 2023: Bun implements N-API but not the V8 C++ API these NAN addons + * link against). `assessNodeRuntime` cannot catch it either, because Bun + * reports a `process.versions.node` well above the floor - 26.3.0 on Bun + * 1.4.0. Only `bunx --bun` reaches that path, and that is the caller asking. */ import { spawn, spawnSync } from "node:child_process"; import * as fs from "node:fs"; diff --git a/docs/providers/sqlite.md b/docs/providers/sqlite.md index 80d0b3a07..5b80375c1 100644 --- a/docs/providers/sqlite.md +++ b/docs/providers/sqlite.md @@ -380,6 +380,26 @@ the same seeded database (200 rows of 4 KB text in `big` with an index on it, 20 | `bun:sqlite` (Bun 1.3.14, SQLite 3.53.0) | `no such table: dbstat` | | `node:sqlite` (Node 24.14.0, SQLite 3.51.2) | `big 823296`, `idx_big 929792`, `small 4096` | +**The divergence closed on Bun 1.4.0.** That Bun bundles SQLite 3.53.2 with +`SQLITE_ENABLE_DBSTAT_VTAB` compiled in, so `bun:sqlite` answers where 1.3.14 raised +`no such table: dbstat`, and the pinned runtime moved to it on 2026-08-31. Re-measured +that day on a freshly seeded file (200 rows of a 4096-character payload in `big` with an +index on `payload`, 200 short rows in `small`), the two drivers returned **byte-identical** +`getTableStats()` output under the same Bun 1.4.0: + +``` +# both LIBREDB_SQLITE_DRIVER unset (bun:sqlite) and LIBREDB_SQLITE_DRIVER=node +{"tableName":"big","rowCount":200,"tableSize":"904 KB","tableSizeBytes":925696, + "indexSize":"908 KB","indexSizeBytes":929792,"totalSize":"1.77 MB","totalSizeBytes":1855488} +{"tableName":"small","rowCount":200,"tableSize":"4 KB","tableSizeBytes":4096, + "indexSize":"0 B","indexSizeBytes":0,"totalSize":"4 KB","totalSizeBytes":4096} +``` + +The bytes differ from the 2026-08-24 row above because the seed is not the same file, not +because the drivers disagree — that comparison is a separate measurement, kept as the +record of what Bun 1.3.14 did. The 1.3.14 row is not history: an install pinned to an +older image still behaves that way, which is why the absent-field arm below stays. + `LIBREDB_SQLITE_DRIVER` is what a user changes to move between them ([§2](#runtime--driver-selection)), so both answers ship, and the same connection reports different things depending on it — verbatim from `getTableStats()`: diff --git a/package.json b/package.json index 29ad1fa4c..0b8e80e4c 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "engines": { "node": ">=24.0.0" }, - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.0", "//trustedDependencies": "Explicit allowlist of the ONLY dependencies permitted to execute code at install time. Without this field bun applies its own default allowlist, which synthesizes a `node-gyp rebuild` for any package shipping a binding.gyp - including better-sqlite3 13, whose `gypfile: false` says not to (it is N-API and ships prebuilts, so npm honours it and bun does not). These three are exactly the packages that ran scripts before the field existed; cpu-features (optional, ssh2) and unrs-resolver stay blocked as they already were.", "trustedDependencies": [ "esbuild", diff --git a/src/lib/db/providers/sql/sqlite-driver.ts b/src/lib/db/providers/sql/sqlite-driver.ts index 26b1a6d0a..580d1b125 100644 --- a/src/lib/db/providers/sql/sqlite-driver.ts +++ b/src/lib/db/providers/sql/sqlite-driver.ts @@ -154,10 +154,23 @@ export async function loadNodeSQLiteDriver( return createNodeSQLiteDriver(sqlite.DatabaseSync); } +/** The real loader for a driver name — the default behind the seam below. */ +async function importDriverForName(name: SQLiteDriverName): Promise { + return name === "bun" ? loadBunDriver() : loadNodeSQLiteDriver(); +} + /** * Load the runtime-appropriate SQLite driver (lazily, cached per driver). + * + * The loader is injectable for the same reason `loadNodeSQLiteDriver`'s importer is: + * the failure arm is otherwise reachable only on a runtime that lacks the module, so a + * test asserting it is really asserting a property of the installed Bun. It was written + * that way once and went quietly unexercised the day Bun 1.4.0 shipped `node:sqlite`. + * Callers outside tests pass nothing. */ -export async function loadSQLiteDriver(): Promise { +export async function loadSQLiteDriver( + loadDriver: (name: SQLiteDriverName) => Promise = importDriverForName, +): Promise { const name = resolveSQLiteDriverName(); const cached = loadedDrivers.get(name); @@ -170,7 +183,7 @@ export async function loadSQLiteDriver(): Promise { } try { - const driver = name === "bun" ? await loadBunDriver() : await loadNodeSQLiteDriver(); + const driver = await loadDriver(name); loadedDrivers.set(name, driver); return driver; } catch (error) { diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts index dbd7ec166..8f62be057 100644 --- a/src/lib/db/types.ts +++ b/src/lib/db/types.ts @@ -848,9 +848,12 @@ export interface TableStats { /** * The table's own bytes, and its formatted spelling. BOTH are omitted when the engine * publishes no per-table size at all: SQLite's per-object page counts live in the - * `dbstat` virtual table, which is a compile-time option - present on node:sqlite and - * absent on bun:sqlite ("no such table: dbstat", measured 2026-08-24 on Bun 1.3.14 / - * SQLite 3.53.0) - so under Bun there is nothing to read. The field used to be + * `dbstat` virtual table, which is a compile-time option the build behind the driver + * decides - present on node:sqlite, absent on bun:sqlite through Bun 1.3.14 ("no such + * table: dbstat", measured 2026-08-24 on SQLite 3.53.0) and present again from Bun + * 1.4.0 / SQLite 3.53.2 (re-measured 2026-08-31, where both drivers return identical + * bytes). So the omission is a property of the build, not of the driver's name, and + * the fields stay optional for every build that still has nothing to read. It used to be * required, and what filled it was `rowCount * 100` ("Assume 100 bytes average per * row"), which the Storage tab then summed into the Data figure it draws beside the * measured database size: a guess presented as a measurement. A `0` would be the diff --git a/tests/unit/db/sqlite-driver.test.ts b/tests/unit/db/sqlite-driver.test.ts index d522b41b7..43d67270c 100644 --- a/tests/unit/db/sqlite-driver.test.ts +++ b/tests/unit/db/sqlite-driver.test.ts @@ -131,6 +131,27 @@ describe("sqlite-driver", () => { db.close(); }); + test("loadNodeSQLiteDriver() falls back to the real node:sqlite import", async () => { + // Bun implements node:sqlite from 1.4.0 - the version this repo pins - so the default + // importer resolves in-process instead of throwing, and this is the first time the + // adapter can be driven against the REAL DatabaseSync rather than a stand-in. On Bun + // 1.3.14 only the injected path above was reachable here. + const Driver = await loadNodeSQLiteDriver(); + const db = new Driver(":memory:"); + + db.exec("CREATE TABLE t (a INTEGER)"); + db.prepare("INSERT INTO t VALUES (?)").run(7); + + // The three bridges the adapter promises, against the real module: get() maps a miss + // to null (node returns undefined), run() narrows bigint changes to number, and all() + // hands back plain rows. + expect(db.prepare("SELECT a FROM t").all()).toEqual([{ a: 7 }]); + expect(db.prepare("SELECT a FROM t WHERE a = ?").get(999)).toBeNull(); + expect(db.prepare("DELETE FROM t").run()).toEqual({ changes: 1 }); + + db.close(); + }); + describe("loadSQLiteDriver()", () => { test("returns the cached constructor on repeat loads", async () => { process.env.LIBREDB_SQLITE_DRIVER = "bun"; @@ -139,29 +160,37 @@ describe("sqlite-driver", () => { expect(second).toBe(first); }); - test("wraps an unavailable driver in DatabaseConfigError and caches the failure", async () => { + // This asserted the failure path by relying on the runtime NOT implementing + // node:sqlite, and skipped itself when the import resolved. Bun 1.4.0 implements it, + // so on the pinned runtime the test passed while the whole catch below went + // unexercised - a green test and an eight-line hole in a 100% gate. The loader is + // injected instead, the way `loadNodeSQLiteDriver` already injects its importer, so + // the arm is a property of this test rather than of whichever Bun is installed. + test("wraps a driver that cannot load in DatabaseConfigError and caches the failure", async () => { process.env.LIBREDB_SQLITE_DRIVER = "node"; - let firstError: unknown; - try { - // Bun does not implement node:sqlite, so the real import path fails here. - // (If a future Bun adds node:sqlite this resolves instead - then the - // error-path assertions below are simply skipped.) - await loadSQLiteDriver(); - } catch (error) { - firstError = error; - } - if (firstError === undefined) return; + + const firstError = await loadSQLiteDriver(() => Promise.reject(new Error("node:sqlite is not available"))).then( + () => undefined, + (error: unknown) => error, + ); expect(firstError).toBeInstanceOf(DatabaseConfigError); expect((firstError as Error).message).toContain('SQLite driver "node" is not available'); + // The underlying reason is carried, not swallowed. + expect((firstError as Error).message).toContain("node:sqlite is not available"); + + // The cached failure is rethrown without consulting the loader a second time. + let secondAttempts = 0; + const secondError = await loadSQLiteDriver(() => { + secondAttempts += 1; + return Promise.reject(new Error("must not be reached")); + }).then( + () => undefined, + (error: unknown) => error, + ); - let secondError: unknown; - try { - await loadSQLiteDriver(); - } catch (error) { - secondError = error; - } expect(secondError).toBe(firstError); + expect(secondAttempts).toBe(0); }); }); }); From 43b7b154df6b05f453ace6d1c9ac8088a66a9271 Mon Sep 17 00:00:00 2001 From: cevheri Date: Mon, 31 Aug 2026 23:54:00 +0300 Subject: [PATCH 2/2] docs(sqlite): scope the dbstat claim to the build that carries it Bun 1.4.0 bundles SQLite 3.53.2 with SQLITE_ENABLE_DBSTAT_VTAB only on its Linux and Windows builds. On macOS bun:sqlite dlopens Apple's /usr/lib/libsqlite3.dylib instead (oven-sh/bun#16717, open, reproduced by upstream triage), so sqlite_version() still reads 3.43.2 there and what dbstat does is Apple's build's answer, unmeasured here. The measurement now records its OS and architecture, and the section that presented driver divergence in the present tense is reframed around the build. Also drops the stale claim, in three comment sites, that Bun cannot import node:sqlite - 1.4.0 can, and the injectable seams exist for deterministic test isolation instead. --- docs/providers/sqlite.md | 43 ++++++++++++++--------- src/lib/db/providers/sql/sqlite-driver.ts | 11 +++--- src/lib/db/providers/sql/sqlite.ts | 17 +++++---- src/lib/db/types.ts | 8 +++-- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/docs/providers/sqlite.md b/docs/providers/sqlite.md index 5b80375c1..75bae33ce 100644 --- a/docs/providers/sqlite.md +++ b/docs/providers/sqlite.md @@ -336,7 +336,7 @@ Minimal by nature — SQLite keeps almost no server-style runtime statistics. | `getPerformanceMetrics()` | — | **no cache-hit ratio, no QPS, no buffer-pool usage** — all three are omitted, so both monitoring tabs show "N/A / Not measured" for them ([§7.1](#71-there-is-no-cache-hit-ratio-and-there-cannot-be)); only `deadlocks: 0` is reported, which is a fact about the engine | | `getSlowQueries()` | — | always `[]` (SQLite has no query stats) | | `getActiveSessions()` | — | the single current process session | -| `getTableStats()` | `COUNT(*)` per table, `dbstat` for the bytes | size is **measured page bytes under `node:sqlite` and absent under `bun:sqlite`** — see [§7.2](#72-per-table-size-depends-on-which-driver-you-run) | +| `getTableStats()` | `COUNT(*)` per table, `dbstat` for the bytes | size is **measured page bytes wherever `dbstat` is compiled in, and absent where it is not** — see [§7.2](#72-per-table-size-depends-on-the-sqlite-build-behind-the-driver) | | `getIndexStats()` | `PRAGMA index_list`/`index_info` | `scans` always `0` (no usage counter); `indexSize` is `N/A` and `indexSizeBytes` is **omitted** — SQLite publishes no per-index size, and a `0` was summed by the Storage tab as an empty index | | `getStorageStats()` | `fs.statSync` on the DB / `-wal` / `-shm` files | per-file sizes (on disk only) | @@ -359,7 +359,7 @@ Nothing SQL-reachable stands in either. On both drivers: | `PRAGMA cache_size` | `-2000` — the *configured* page budget (negative = KiB), not a hit count | | `PRAGMA cache_hit`, `PRAGMA cache_miss` | `[]` — these are not pragmas; SQLite answers an unknown pragma with zero rows rather than an error, so they *look* like empty readings | | `PRAGMA stats` | `[]` | -| `SELECT * FROM dbstat` | `no such table: dbstat` under `bun:sqlite`; available under `node:sqlite` (`ENABLE_DBSTAT_VTAB`), but it reports page layout, not cache hits — which is what [§7.2](#72-per-table-size-depends-on-which-driver-you-run) reads it for | +| `SELECT * FROM dbstat` | Behind `ENABLE_DBSTAT_VTAB`, so it depends on the build: always available under `node:sqlite`, `no such table: dbstat` under `bun:sqlite` through Bun 1.3.14. Either way it reports page layout, not cache hits — which is what [§7.2](#72-per-table-size-depends-on-the-sqlite-build-behind-the-driver) reads it for | So the field is **omitted permanently**, not pending a better query. Through 0.13.1 this provider reported `95` whenever `PRAGMA cache_size` came back truthy — which it always does — and `99` @@ -367,7 +367,7 @@ otherwise, and the Performance panel rated that invented figure "Excellent". A m honest; a populated wrong one is not: the number was this provider's, not SQLite's. `getHealth()` says the same thing in its own string field: `cacheHitRatio` is `N/A`. -### 7.2 Per-table size depends on which driver you run +### 7.2 Per-table size depends on the SQLite build behind the driver SQLite has no catalog column for a table's size. The only source is `dbstat`, a virtual table that reports one row per b-tree page group, and it is behind the compile-time @@ -380,12 +380,12 @@ the same seeded database (200 rows of 4 KB text in `big` with an index on it, 20 | `bun:sqlite` (Bun 1.3.14, SQLite 3.53.0) | `no such table: dbstat` | | `node:sqlite` (Node 24.14.0, SQLite 3.51.2) | `big 823296`, `idx_big 929792`, `small 4096` | -**The divergence closed on Bun 1.4.0.** That Bun bundles SQLite 3.53.2 with -`SQLITE_ENABLE_DBSTAT_VTAB` compiled in, so `bun:sqlite` answers where 1.3.14 raised -`no such table: dbstat`, and the pinned runtime moved to it on 2026-08-31. Re-measured -that day on a freshly seeded file (200 rows of a 4096-character payload in `big` with an -index on `payload`, 200 short rows in `small`), the two drivers returned **byte-identical** -`getTableStats()` output under the same Bun 1.4.0: +**The divergence closed on Bun 1.4.0 — on the builds that carry Bun's own SQLite.** That +Bun bundles SQLite 3.53.2 with `SQLITE_ENABLE_DBSTAT_VTAB` compiled in, so `bun:sqlite` +answers where 1.3.14 raised `no such table: dbstat`, and the pinned runtime moved to it on +2026-08-31. Re-measured that day **on Linux x86_64** on a freshly seeded file (200 rows of a +4096-character payload in `big` with an index on `payload`, 200 short rows in `small`), the +two drivers returned **byte-identical** `getTableStats()` output under the same Bun 1.4.0: ``` # both LIBREDB_SQLITE_DRIVER unset (bun:sqlite) and LIBREDB_SQLITE_DRIVER=node @@ -400,24 +400,33 @@ because the drivers disagree — that comparison is a separate measurement, kept record of what Bun 1.3.14 did. The 1.3.14 row is not history: an install pinned to an older image still behaves that way, which is why the absent-field arm below stays. -`LIBREDB_SQLITE_DRIVER` is what a user changes to move between them ([§2](#runtime--driver-selection)), -so both answers ship, and the same connection reports different things depending on it — verbatim -from `getTableStats()`: +**macOS is not covered by the row above.** There `bun:sqlite` dlopens Apple's +`/usr/lib/libsqlite3.dylib` instead of the amalgamation Bun links on Linux and Windows — +[oven-sh/bun#16717](https://github.com/oven-sh/bun/issues/16717), open, reproduced by Bun's own +triage bot, and the reason `sqlite_version()` still reads `3.43.2` there on 1.4.0. So what +`dbstat` does under `bun:sqlite` on macOS is Apple's build's answer, and nothing in this +document measures it. Read the empty arm below as live on any build without `dbstat`, macOS +included until someone measures it. + +The absent arm is therefore still shipped, and `LIBREDB_SQLITE_DRIVER` +([§2](#runtime--driver-selection)) is what moves a connection between the two drivers. When the +build behind `bun:sqlite` has no `dbstat`, the same connection reports different things depending +on that variable — verbatim from `getTableStats()`, captured on Bun 1.3.14: ``` # LIBREDB_SQLITE_DRIVER=node {"tableName":"big","rowCount":200,"tableSize":"804 KB","tableSizeBytes":823296, "indexSize":"908 KB","indexSizeBytes":929792,"totalSize":"1.67 MB","totalSizeBytes":1753088} -# bun:sqlite (the default under Bun) +# bun:sqlite (the default under Bun), where dbstat is absent {"tableName":"big","rowCount":200,"totalSize":"N/A","totalSizeBytes":0} ``` Under `node:sqlite` an index's pages are added to **its table's** `indexSizeBytes`, implicit `sqlite_autoindex_*` ones included, because the Storage tab builds its index total from the -per-table figure. Under `bun:sqlite` `tableSize` and `tableSizeBytes` are **omitted** — the Storage -tab shows "N/A" for the Tables/Indexes cards and the breakdown, and "-" for each table's share, -rather than a figure. `dbstat` is read once per `getTableStats()` call, since it scans the whole +per-table figure. Where `dbstat` is missing, `tableSize` and `tableSizeBytes` are **omitted** — the +Storage tab shows "N/A" for the Tables/Indexes cards and the breakdown, and "-" for each table's +share, rather than a figure. `dbstat` is read once per `getTableStats()` call, since it scans the whole database file. Through 0.13.3 this was `rowCount * 100` — "Assume 100 bytes average per row" — and the Storage tab @@ -783,7 +792,7 @@ not apply to SQLite ([§3.4](#34-no-transactions-api-no-cancellation-no-pool)). `getIndexStats()`'s per-index size is `N/A`; slow queries are unavailable. - **Per-table size only under `node:sqlite`.** `dbstat` is compiled into that driver and out of `bun:sqlite`, so under Bun the byte fields are omitted rather than estimated - ([§7.2](#72-per-table-size-depends-on-which-driver-you-run)). `getIndexStats()` still reports + ([§7.2](#72-per-table-size-depends-on-the-sqlite-build-behind-the-driver)). `getIndexStats()` still reports `indexSize: "N/A"` per index even where `dbstat` exists — the per-table index bytes it feeds the Storage tab are measured, the per-index rows are not yet. - **`:memory:` is ephemeral** — data is lost on disconnect; intended for trials/tests. diff --git a/src/lib/db/providers/sql/sqlite-driver.ts b/src/lib/db/providers/sql/sqlite-driver.ts index 580d1b125..ad72ae5c1 100644 --- a/src/lib/db/providers/sql/sqlite-driver.ts +++ b/src/lib/db/providers/sql/sqlite-driver.ts @@ -104,7 +104,8 @@ async function loadBunDriver(): Promise { * - `run()` reports `changes` as `number | bigint`; normalize to `number`. * * Exported (with the injectable ctor) so the adapter semantics are unit-testable - * in-process under Bun, where node:sqlite itself cannot be imported. + * in-process against a stand-in, on any runtime, rather than only where node:sqlite + * happens to resolve - Bun could not import it before 1.4.0. */ export function createNodeSQLiteDriver(DatabaseSyncCtor: NodeSQLiteModule["DatabaseSync"]): SQLiteConstructor { class NodeSQLiteDatabase implements SQLiteDatabase { @@ -143,9 +144,11 @@ async function importNodeSQLite(): Promise { } /** - * Load the node:sqlite-backed driver. The module import is injectable so the - * success path is unit-testable under Bun (which lacks node:sqlite); callers - * outside tests use the default importer. + * Load the node:sqlite-backed driver. The module import is injectable for deterministic + * test isolation: the success path is then driven the same way on every runtime instead + * of only where node:sqlite resolves. Bun gained it in 1.4.0, so the real module is + * exercised too - both arms are asserted rather than whichever one the toolchain allows. + * Callers outside tests use the default importer. */ export async function loadNodeSQLiteDriver( importModule: () => Promise = importNodeSQLite, diff --git a/src/lib/db/providers/sql/sqlite.ts b/src/lib/db/providers/sql/sqlite.ts index fefcd95ee..2aee442aa 100644 --- a/src/lib/db/providers/sql/sqlite.ts +++ b/src/lib/db/providers/sql/sqlite.ts @@ -120,9 +120,13 @@ const STATS_TABLES_SQL = ` `; // Per-object page bytes. `dbstat` is a virtual table behind the compile-time -// SQLITE_ENABLE_DBSTAT_VTAB option: node:sqlite carries it, bun:sqlite does not -// ("no such table: dbstat", measured 2026-08-24 on Bun 1.3.14 / SQLite 3.53.0 -// against a working row from node:sqlite 3.51.2 / Node 24.14.0). It is the only +// SQLITE_ENABLE_DBSTAT_VTAB option, so whether it answers is a property of the BUILD +// behind the driver rather than of the driver's name: node:sqlite has carried it +// throughout, bun:sqlite raised "no such table: dbstat" through Bun 1.3.14 (measured +// 2026-08-24 on SQLite 3.53.0 against a working row from node:sqlite 3.51.2 / +// Node 24.14.0) and answers from 1.4.0 / 3.53.2 on the Linux and Windows builds +// (re-measured 2026-08-31 on Linux x86_64). Both arms stay live - see readDbstatSizes. +// It is the only // per-table size SQLite publishes at all - there is no catalog column for it - and // reading it costs a scan of the whole database file, which is acceptable on the // monitoring tab and is why nothing else calls it. @@ -966,9 +970,10 @@ export class SQLiteProvider extends SQLBaseProvider { isUnique: indexMeta?.unique === 1, isPrimary: false, // SQLite auto-creates rowid, explicit PKs are shown differently // SQLite publishes no per-index size: `dbstat` would give page counts but it - // is a compile-time option, present on node:sqlite (ENABLE_DBSTAT_VTAB) and - // absent on bun:sqlite ("no such table: dbstat", measured 2026-08-23), so it - // cannot be relied on. The size string already said so; the companion byte + // is a compile-time option (ENABLE_DBSTAT_VTAB) the build decides - always there + // on node:sqlite, absent on bun:sqlite through Bun 1.3.14 ("no such table: + // dbstat", measured 2026-08-23) - so it cannot be relied on for every install. + // The size string already said so; the companion byte // count said 0, and the Storage tab summed those zeroes into an index total // that read as "every index is empty". The field is optional for this case. indexSize: "N/A", diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts index 8f62be057..4607ea720 100644 --- a/src/lib/db/types.ts +++ b/src/lib/db/types.ts @@ -851,8 +851,12 @@ export interface TableStats { * `dbstat` virtual table, which is a compile-time option the build behind the driver * decides - present on node:sqlite, absent on bun:sqlite through Bun 1.3.14 ("no such * table: dbstat", measured 2026-08-24 on SQLite 3.53.0) and present again from Bun - * 1.4.0 / SQLite 3.53.2 (re-measured 2026-08-31, where both drivers return identical - * bytes). So the omission is a property of the build, not of the driver's name, and + * 1.4.0 / SQLite 3.53.2 (re-measured 2026-08-31 on Linux x86_64, where both drivers + * return identical bytes). Those are Bun's Linux/Windows builds: on macOS `bun:sqlite` + * dlopens Apple's `/usr/lib/libsqlite3.dylib` rather than Bun's own amalgamation + * (oven-sh/bun#16717, open, reproduced upstream), so the SQLite behind it there is + * Apple's and is not measured by any row here. So the omission is a property of the + * build, not of the driver's name, and * the fields stay optional for every build that still has nothing to read. It used to be * required, and what filled it was `rowCount * 100` ("Assume 100 bytes average per * row"), which the Storage tab then summed into the Data figure it draws beside the