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..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,24 +380,53 @@ 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` | -`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()`: +**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 +{"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. + +**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 @@ -763,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/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..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, @@ -154,10 +157,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 +186,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/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 dbd7ec166..4607ea720 100644 --- a/src/lib/db/types.ts +++ b/src/lib/db/types.ts @@ -848,9 +848,16 @@ 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 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 * 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); }); }); });