Skip to content

Commit d6a5fa3

Browse files
fix(driver-sql): a SQLite database whose header SQLite cannot read, and a dangling-link directory on the boot that writes, fail connect() naming the file (#25080)
Part of #25019 The card keyword above is the seat's: member 3 (the wasm quarantine) stays on #25019 under the seat's ruling 6311031589 (route A, inherited from #24960), dispatched on the card after this PR lands, so this merge does not close the card. Clause-②: no (narrowing) **Narrowing face:** three answers that read success now refuse, each naming the file. `SqlDriver.connect()` rejects a SQLite database whose header or schema SQLite cannot read (`SQLITE_CORRUPT` at the first statement), and a path whose directory is a dangling symbolic link (`ENOENT`), where both used to resolve. The read-only boot (`sqliteAbsentFile: 'empty-in-memory'`) rejects a dangling symbolic link as the file itself when the directory its chain of links ends in does not exist, where it opened an empty in-memory database and read `connected`. Nothing exported changes shape. It owes a `CONTRACT_REVIEW_TIER` review before the queue (the claim's line). Dev session `session_01QK6pdbxKZiHdFApL8RYVh9` (a `mode:subagent` run), dispatched by the `domain:engine` seat 2 under claim 6307297304. This is the SQLite file-failure family's closing card. It builds on PR #25006 (`SQLITE_NOTADB`) and PR #25007 (the read-only boot's unopenable path), both on `main`. ## What changed - **Member 1, `SQLITE_CORRUPT`.** `packages/drivers/driver-sql/src/sql-driver.ts`, only the `applyAutoVacuumIncremental` comment and `SQLITE_FILE_REFUSALS`: one more row, `SQLITE_CORRUPT` with the words "is damaged (SQLite cannot read its header or its schema), and was left as it is". `packages/services/service-datasource/src/sqlite-driver-fallback.ts`: `isSqliteFileOpenFailure` adds the same code, with its doc list and the module note (the cross-lane line declared to `domain:services` on the claim). - **Member 2, a dangling-link directory on the boot that writes.** `packages/drivers/driver-sql/src/sqlite-database-file.ts`, `makeDatabaseDirectory`: the walk now asks the read-only twin's own `isDanglingLink` at each level `stat` answers `ENOENT` for, and refuses that level with `stat`'s error as `code` and the link named. Before, the level went on the "missing" list, `mkdir` answered `EEXIST`, and the creating loop accepted that as a concurrent creator. Both twins now print the link through one helper (`danglingLinkWords`), so their words cannot drift. `ensureDatabaseExists` in `sql-driver.ts` is untouched: it wraps what the walk throws, as it already did. - **The dangling link as the file itself (family note 6305577138): pinned, not left out.** Measured below: the read-only boot read it absent when the directory the link points into does not exist, while the writing boot refuses it with `SQLITE_CANTOPEN`. `probeAbsentSqliteDatabaseFile` now follows a dangling link that is the file to the end of its chain (`danglingLinkEnd`, reading each relative target from the real directory of the link that holds it, as the kernel does) and judges that directory without making it: it must exist, and allow write and search. Otherwise `'absent'`, as before. - **Member 3, the wasm quarantine: measured, not changed.** Closing it means changing what `driver-sqlite-wasm`'s quarantine does, so it is the card's fork, reported to the seat with the four-axis analysis. No line of `packages/drivers/driver-sqlite-wasm` changed. - Changeset `.changeset/25019-sqlite-file-failure-family.md`: `@objectstack/driver-sql` and `@objectstack/service-datasource` `minor`, with the `Clause-②` line, the BREAKING banner (Before, Now, Fix) and the ADR-0087 disposition `not-required (no-migration-prescription)`, as PR #25006's changeset does. - Pin files: `packages/drivers/driver-sql/src/sql-driver-25019-sqlite-damaged-file-and-dangling-link.test.ts` (relative `src` import) and `packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-damaged-file-and-dangling-link.test.ts` (resolves `@objectstack/driver-sql` through `dist/`, an existing `KNOWN_UNALIASED_TEST_IMPORTS` pair). ## Premise, measured on base `d68aab6fd` (better-sqlite3 13.0.3, SQLite 3.53.4, Node v22.22.0) **H1 holds, and both shapes were measured.** Statement by statement, through better-sqlite3 directly, on a database written and then damaged: | shape | first statement (`PRAGMA auto_vacuum`) | `SELECT 1` | `connect()` on base | |---|---|---|---| | truncated to 10 or 16 bytes (inside the magic string) | `SQLITE_NOTADB` | `SQLITE_NOTADB` | refused (PR #25006) | | truncated to 50, 99, 100, 101, 512 or 4095 bytes | `SQLITE_CORRUPT` | `SQLITE_CORRUPT` | **resolved** | | truncated to its first page, or to half its pages | `SQLITE_CORRUPT` | `SQLITE_CORRUPT` | **resolved** | | page 1's b-tree (the schema) overwritten past the header | `SQLITE_CORRUPT` | `SQLITE_CORRUPT` | **resolved** | | in-header page count larger than the file | `SQLITE_CORRUPT` | `SQLITE_CORRUPT` | **resolved** | | one table's page overwritten (damage deeper inside) | answers | answers | resolved; only a read of that table answers `SQLITE_CORRUPT` | So `SQLITE_CORRUPT` at the FIRST statement is a verdict on the whole file: every statement answers the same code, `SELECT 1` included, and nothing can be read. A database damaged deeper inside answers the first statement and connects, and its damage surfaces on the read that reaches it. The refusal is worded for the first kind ("SQLite cannot read its header or its schema"), and a control pins the second (it still connects). No extended code (`SQLITE_CORRUPT_*`) appeared at the first statement. Nothing in the tree reads `SQLITE_CORRUPT` or "disk image is malformed" outside `driver-sqlite-wasm` and two unrelated REST and runtime fixtures. **H2 holds.** On base, the writing boot over `link/archive.db` (a dangling link) resolved `connect()`, then the first statement failed with a code-less `TypeError` ("Cannot open database because the directory does not exist"). Over `link/sub/archive.db` it already refused (`ENOENT` from `mkdir`), without naming the link. The read-only boot refused both naming the link. After the change, both boots answer `SqliteDatabaseFileError`, `code: 'ENOENT'`, naming the file and the link, at both depths. Their words differ by one verb ("could not be made" on the writing boot, "cannot be made" on the read-only one), as they already did for every other shape. A directory that is a link to an existing directory still connects and creates the file in it. **The dangling link as the file.** Base and after, both boots: | file `archive.db` is a link to | writing boot (base and after) | read-only boot, base | read-only boot, after | |---|---|---|---| | `target.db` in a directory that exists (absolute or relative target, direct or through a second link) | connects; creates `target.db` | absent, in memory | absent, in memory (unchanged) | | `missing/target.db` (direct, or through a second link) | refused, `SQLITE_CANTOPEN` | **absent, in memory, `connected`** | refused, `ENOENT`, naming the target | | itself, through a loop | refused, `SQLITE_CANTOPEN` | refused, `ELOOP` | refused, `ELOOP` (unchanged) | Nothing on disk changed in any read-only run. **H3, member 3.** With the #24950 suite's load-failure seam (`acquireRawConnection` answering a real `ERR_DLOPEN_FAILED`), development `resolveSqliteDriver` over a plain-text file and over a header-truncated database, after this change: - the addon loads: both refused (`SQLITE_NOTADB`, `SQLITE_CORRUPT`), the file untouched; - the addon cannot load: both resolve to `sqlite-wasm`, and `data/` holds `archive.db` (an empty database) and `archive.db.corrupt-TIMESTAMP`; - controls: a real database with the addon unable to load steps down to wasm and reads its rows, not renamed; an absent file does the same. The rename happens in `driver-sqlite-wasm`, `WasmSqliteConnection.open()` (`wasm-connection.ts`), which calls `quarantineCorruptFile` when `new SQL.Database(bytes)` or `PRAGMA quick_check(1)` fails. The wasm driver takes no option that could say "refuse"; `resolveSqliteDriver`'s wasm rung also catches every wasm failure and goes on to the in-memory rung. A second door reaches the same quarantine with no step-down at all: a datasource declared `driver: 'sqlite-wasm'` over a plain-text file, through `DatasourceConnectionService.connectDeclared` on the built `dist/`, was renamed and read `available` in development AND in production. Every in-tree construction that reaches the quarantine is a declared datasource: the `default` datasource is one since #3826 (the CLI and the standalone stack hand it to `DefaultDatasourcePlugin`), so "the dev server's own self-heal" and "a declared datasource" are one population. The self-heal was written for torn images, which wasm's atomic flush (`atomicWriteFile`, write then `rename`) has since closed. The fork and its options are in the report to the seat. ## Evidence **Public door, the card's own** (`bin/run-dev.js migrate apply --yes` through `tsx`, a throwaway project whose `objectstack.config.ts` declares `archive` (`driver: 'sqlite'`) and binds `door_note` to it), after `turbo run build --filter='@objectstack/cli^...'` (60 of 60) on the merge head `cc15060f7`: - **Header-truncated `archive.db`** (a database this driver wrote, cut to 50 bytes): `datasource 'archive': connect failed — SQLite database file 'PROJECT/data/archive.db' is damaged (SQLite cannot read its header or its schema), and was left as it is: PRAGMA auto_vacuum - database disk image is malformed. (1 object(s) bind to it explicitly (door_note) …)`, exit 1. The md5 is unchanged (`dc1e7bea…` before and after), and `data/` holds `archive.db` alone. No step-down warning printed. - **Dangling-link directory** (`data/link` pointing at a path that does not exist): `connect failed — SQLite database file 'PROJECT/data/link/archive.db' cannot be created: its directory 'PROJECT/data/link' could not be made: 'PROJECT/data/link' is a symbolic link to a path that does not exist: ENOENT …`, exit 1. No "native better-sqlite3 unavailable" line, no wasm line; the link still dangles and its target was not created. - **Control, a real database:** `datasource 'archive': connected (driver=sqlite, schemaMode=managed)`, exit 0, not renamed. - The card measured the base on the same door: the truncated file was renamed to `archive.db.corrupt-1792539192447` with an empty database in its place and read `connected`; the dangling-link directory read `connected` under the false native-addon warning. **Pins** (both files run inside the whole-package suites on the final head `cc15060f7`, under `os-verify-lock`): - driver-sql, 10 cases: the header-truncated database refused with `SqliteDatabaseFileError`, `code` `SQLITE_CORRUPT`, `filename`, a message naming the file and carrying the cause, under `NODE_ENV=development` and `production`; a database cut to its first page, the same. Each asserts no `.corrupt-*` sibling while the refused connection is open, then, closed, the same bytes and a directory holding only that file. The header still says WAL, so SQLite keeps its `-shm` and an empty `-wal` while that connection is open, and removes both when it closes. CONTROL: a database damaged deeper inside connects, and the read of the damaged table fails with `SQLITE_CORRUPT` as its cause. The dangling-link directory refused on the writing boot (`ENOENT`, file and link named) with the read-only boot's answer for the same path asserted beside it (class, `code`, `filename`, the link words), at both depths, the link still dangling and nothing created. CONTROLS: a linked directory that exists connects; an absent file under makeable directories connects. The dangling file link: refused by the read-only boot (`ENOENT`, the target named), direct and through a second link, with the writing boot's `SQLITE_CANTOPEN` asserted beside it and nothing written; CONTROL: with the target's directory present, the read-only boot reads it absent (`:memory:`) and writes nothing, and the writing boot creates the target through the link (absolute and relative targets). - service-datasource, 10 cases: development `resolveSqliteDriver` over the header-truncated database rejects with the driver's refusal, warns nothing, and leaves the file and its directory as they were; production returns native unprobed and its `connect()` refuses; `DatasourceConnectionService.connectDeclared` reports `availability: 'failed'` with a reason naming the file and registers no driver, in development and production, and moves nothing aside. The dangling-link directory: development `resolveSqliteDriver` rejects (`ENOENT`, file and link named) with no warning, and the read-only resolve over the same path gives the same class, code, file and words; `connectDeclared` reports `failed` naming the file and the link, on the writing and the read-only factory, in development and production; nothing created. CONTROLS: a real database opens native and answers; an absent file in a makeable directory opens native and is created; a native addon that cannot load over a real database still steps down to wasm SQLite, with `NATIVE_SQLITE_WASM_FALLBACK_WARNING`, and reads the same rows, not renamed. - Whole packages on the final head `cc15060f7` (re-run after the worker restart, on a full `turbo run build`, 77 of 77): driver-sql "Test Files 227 passed | 12 skipped (239) · Tests 3712 passed | 210 skipped (3922)" (the skips are the live PG/MySQL cells, not provisioned here); service-datasource "Test Files 53 passed (53) · Tests 930 passed (930)". `tsc --noEmit` exit 0 for both; `tsc --listFiles` includes each new test file (1 hit each). **Ablation** (`scripts/ablation-replace.mjs` WRAP, each leg under `os-verify-lock`; every restore proven by blob equality with `HEAD`, an empty `git diff HEAD`, and an empty `git status --porcelain`; at `281d27bdf`; the only later change to these files is `f7ea1c503`, a type annotation on two rows the damaged-page control reads): - **Leg 1, the driver row.** `['SQLITE_CORRUPT', 'is damaged` became `['ABLATION_25019_CORRUPT', 'is damaged`: anchor 1 to 0, blob `778b08f36ba6` to `7048da65440a`. driver-sql rebuilt; `ablation-dist-preflight.mjs` found the marker in 2 built files. Predicted and observed: driver-sql "3 failed | 7 passed" (the two header-truncated cases and the first-page case; the controls and both link blocks green); service-datasource "4 failed | 6 passed" (development resolved to `sqlite-wasm`; the production refusal was `undefined`; both `connectDeclared` cases read `available`). Restored, rebuilt; preflight `--absent` found the marker in none of the 6 built files, tree clean; both pin files 10/10. - **Leg 2, the step-down code.** `|| code === 'SQLITE_CORRUPT'` became `|| code === 'ABLATION_25019_SD'`: blob `6e42085d4512` to `4e4570fda7a2`. A relative `src` subject, so no dist step. Predicted and observed: service-datasource "2 failed | 8 passed" (the two development cases; production, which the driver alone carries, green). - **Leg 3, the dangling-link directory guard.** The line `if (isDanglingLink(level)) throw danglingLinkRefusal(level, e);` was deleted: blob `adf3e6b62148` to `8c7be7a55c8d`. driver-sql rebuilt; preflight `--absent` on `danglingLinkRefusal(level, e)` found it in none of the built files (the same marker read present in 2 on the pristine build first). Predicted and observed: driver-sql "2 failed | 8 passed" (both depths: the first resolved, the second refused without the link's words); service-datasource "3 failed | 7 passed" (development resolve, and `connectDeclared` in both modes). Restored, rebuilt; preflight found the guard back in 2 built files, tree clean; both pin files 10/10. - **Leg 4, the dangling file link.** `const linkEnd = danglingLinkEnd(filename);` became `const linkEnd = undefined as string | undefined; // ABLATION_25019_FILELINK`: blob `adf3e6b62148` to `ad84f6bf25a0`. Relative `src` subject. Predicted and observed: driver-sql "1 failed | 9 passed" (the refusal; its control green). **Driver ledger** (`pnpm check:driver-conformance`): before, at `d68aab6fd`: "55 covered cell(s), 0 in the DEBT ledger, 0 exempt"; dialect axis 9 suites, 8 matrix and 1 named, 0 in the DIALECT ledger. After, at `cc15060f7`: the same counts. The ledger did not move. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) at `cc15060f7` derived 72 commands. I ran each one once, sequentially, with its exit captured before any pipe. 70 exited 0 on the first pass. Two answered exit 3 (`PREREQUISITE NOT MET`) and were re-run on the same head after their prerequisite: `check:dual-build-cjs-loads` needed every package's `dist/` (a full `turbo run build`, 77 of 77), and `check:type-check-debt` needed a turn on the verify lock for its closure build ("1 ledger entr(ies) re-measured … none above its recorded number"). Both then exited 0. `--ran` reconciliation: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN". No exported symbol was renamed or removed, so `check-platform-checklist.mjs --changed-from` was not owed. CI owns the full farm and `test:repo`. **Dogfood battery** (owed: the `Clause-②` line carries the `(narrowing)` arm): running on `cc15060f7` under `os-verify-lock` as this PR opens; its verdict line and counts are in the dev report on the card. ## Acceptance notes - **Member 3 is the seat's fork, not this PR's.** Measured above: with a native addon that cannot load, a declared file that is not a database or whose header SQLite cannot read is still renamed by wasm SQLite on a development boot; and a datasource declared `driver: 'sqlite-wasm'` over such a file is renamed and reads `available` in development and production. Both live in `WasmSqliteConnection.open()`'s quarantine. The options and their four-axis analysis are in this PR's dev report on the card; this PR changes nothing there. - **A refused connect's connection is never released on the production path.** `DatasourceConnectionService` does not disconnect a handle whose `connect()` rejected, and `connect()` does not close its own pool when it refuses the file. For a damaged file whose header says WAL, SQLite therefore keeps `-shm` and an empty `-wal` beside the file for as long as the process lives (measured at the unit level; the development path closes the probe and leaves nothing). The operator's bytes are not touched. It predates this PR (a `SQLITE_NOTADB` file holds the same open handle, without visible siblings) and is outside this claim's file surface. Observation, not filed; carrier: none. - **The development telemetry sibling** (`packages/cli/src/utils/telemetry-datasource.ts`) still catches every throw, so a damaged telemetry sibling is now neither renamed nor reported. That is its pre-existing best-effort shape (family note 6305864303); out of the claim's scope. - The writing boot answers the dangling file link whose target's directory is missing with SQLite's own `SQLITE_CANTOPEN` ("unable to open database file"), which names the file but not the link. The read-only boot names the target. Both refuse; the words were left as SQLite gives them. - `main` was merged at `157ebd008` (merge head `cc15060f7`). It brought eight commits touching spec, objectql, plugin-security, core, rest, lint, cli and docs, none of this diff's packages; the closure was rebuilt and both whole packages and the pins re-run on the merge head. The worker restarted during the first full build; that run's readings were discarded and the build, both suites and the two prerequisite gates re-run on the same head. - CI owns the full farm and `test:repo`; this diff touches no `repo`-project test. --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d2c5a1 commit d6a5fa3

6 files changed

Lines changed: 644 additions & 13 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/service-datasource': minor
4+
---
5+
6+
fix(driver-sql): a SQLite database whose header SQLite cannot read fails `connect()` at once, naming the file, and a dangling symbolic link as the database's directory is refused on the boot that writes, as the read-only boot already refused it (#25019)
7+
8+
Three more SQLite files a declared datasource cannot use, each now classified by the file's own driver at connect:
9+
10+
- **A database whose header or schema SQLite cannot read** (a file truncated inside its 100-byte header, or short of the pages its schema names). The connection's first statement, `PRAGMA auto_vacuum`, answers `SQLITE_CORRUPT`; `SqlDriver.connect()` logged it as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and resolved over a file no statement could read. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) then stepped down to wasm SQLite, whose open renamed the file to `<file>.corrupt-<timestamp>` and wrote an empty database in its place, under a warning that blamed the native addon. `connect()` now refuses it with a `SqliteDatabaseFileError` (`code: 'SQLITE_CORRUPT'`, `filename`, a message naming the file) in every mode, before anything is written, and `resolveSqliteDriver` re-throws it instead of stepping down. A database damaged deeper inside still connects: its first statement answers, and the damage surfaces on the read that reaches it, as before.
11+
- **A dangling symbolic link as the database's directory, on the boot that writes.** `connect()` took `mkdir`'s `EEXIST` on the link for a concurrent creator and resolved; the first statement then failed with a code-less "directory does not exist", and a development boot stepped down to wasm SQLite under the same false native-addon warning. `connect()` now refuses it with `SqliteDatabaseFileError` (`code: 'ENOENT'`), naming the file and the link, which is the error the read-only boot (`sqliteAbsentFile: 'empty-in-memory'`) gives for the same path.
12+
- **A dangling symbolic link as the database file itself, on the read-only boot.** When the directory its chain of links ends in does not exist, the read-only boot read the file absent and reported the datasource connected, while the boot that writes refuses the same path with `SQLITE_CANTOPEN`. The read-only boot now refuses it too (`code: 'ENOENT'`), naming the link's target. When that directory exists, both boots behave as before: the read-only boot opens an empty in-memory database, and the boot that writes creates the file through the link.
13+
14+
A real database file, an absent file in a directory that exists or can be made, and a native addon that cannot load over a real database keep today's behaviour.
15+
16+
Clause-②: no (narrowing)
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`SqliteDatabaseFileError` keeps its shape; only the set of codes and paths it is raised for grows), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `SqlDriver.connect()` and the read-only judgement give for three SQLite files a datasource cannot use: a rejection where each resolved. The remedy is the datasource's file or its path, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
19+
20+
**BREAKING** (`SqlDriver.connect()`'s accept set and the read-only boot's narrow): a connect that resolved over a SQLite database whose header SQLite cannot read, or over a path whose directory is a dangling symbolic link, now rejects; a read-only boot that read a dangling file link absent although its target's directory does not exist now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
21+
22+
- **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot a damaged file was renamed to `<file>.corrupt-<timestamp>` and replaced with an empty database, and a dangling-link directory stepped down to wasm SQLite under a warning that blamed the native addon. The read-only boot reported a dangling file link connected.
23+
- **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_CORRUPT'` for the damaged file and `code: 'ENOENT'` for the dangling-link directory, naming the file (and the link); the read-only boot rejects the dangling file link with `code: 'ENOENT'`, naming its target. The file is untouched.
24+
- **Fix:** restore the database from a backup, or move the damaged file aside yourself (an absent file is created empty on the next connect); point the dangling link at a directory that exists, or point the datasource at a real path.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The last members of the SQLite file-failure family: a declared SQLite file
5+
* the datasource cannot use is classified by its own driver at connect, naming
6+
* the file, and nothing is written (#25019).
7+
*
8+
* - A SQLite database whose header or schema SQLite cannot read (a file
9+
* truncated inside its 100-byte header, or short of the pages its schema
10+
* names). The connection's first statement, `PRAGMA auto_vacuum`, answers
11+
* `SQLITE_CORRUPT`, which `connect()` logged and swallowed: it resolved
12+
* over a file no statement could read, and on a development boot the
13+
* step-down then moved the file aside. `SQLITE_CANTOPEN` (#23696) and
14+
* `SQLITE_NOTADB` (#24960) were already refused this way.
15+
* - A dangling symbolic link as the file's directory, on the boot that
16+
* writes. `makeDatabaseDirectory` took `mkdir`'s `EEXIST` on the link for
17+
* a concurrent creator, `connect()` resolved, and the first statement
18+
* failed with a code-less "directory does not exist". The read-only boot
19+
* already refused the same path (#24961), so the two boots disagreed.
20+
* - A dangling symbolic link as the file itself, on the read-only boot: it
21+
* read the file absent although the directory the link points into does
22+
* not exist, where the boot that writes refuses it with `SQLITE_CANTOPEN`.
23+
*
24+
* Every refusal is the driver's own, so the first one is pinned under both
25+
* `NODE_ENV` values.
26+
*/
27+
28+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
29+
import {
30+
existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, symlinkSync,
31+
truncateSync, writeSync, closeSync,
32+
} from 'node:fs';
33+
import { tmpdir } from 'node:os';
34+
import { join } from 'node:path';
35+
import { SqlDriver, resolveSqliteAbsentFileTarget } from './index.js';
36+
import { SqliteDatabaseFileError } from './sqlite-database-file.js';
37+
38+
const DANGLING_WORDS = (link: string) => `'${link}' is a symbolic link to a path that does not exist`;
39+
40+
const drivers: SqlDriver[] = [];
41+
function make(filename: string): SqlDriver {
42+
const d = new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true } as any);
43+
drivers.push(d);
44+
return d;
45+
}
46+
47+
/** `connect()`'s rejection, or a failure saying it resolved. */
48+
async function refusalOf(driver: SqlDriver): Promise<any> {
49+
try {
50+
await driver.connect();
51+
} catch (error) {
52+
return error;
53+
}
54+
throw new Error('connect() resolved for a file the datasource cannot use');
55+
}
56+
57+
/** What `fn` threw; a failure naming its answer when it threw nothing. */
58+
function thrownBy(fn: () => unknown): any {
59+
let answer: unknown;
60+
try {
61+
answer = fn();
62+
} catch (error) {
63+
return error;
64+
}
65+
throw new Error(`answered ${JSON.stringify(answer)} for a file that cannot be created`);
66+
}
67+
68+
function expectFileRefusal(error: any, filename: string, code: string): void {
69+
expect(error).toBeInstanceOf(SqliteDatabaseFileError);
70+
expect(error.code).toBe(code);
71+
expect(error.filename).toBe(filename);
72+
expect(error.message).toContain(filename);
73+
}
74+
75+
/** Every path under `root`, each with its kind, so a link, a file or a directory that appears is seen. */
76+
function tree(root: string): string[] {
77+
return readdirSync(root, { recursive: true }).map(String).sort()
78+
.map((name) => `${name}:${lstatSync(join(root, name)).isSymbolicLink() ? 'link' : lstatSync(join(root, name)).isDirectory() ? 'dir' : 'file'}`);
79+
}
80+
81+
/** A real SQLite database, written by this driver, holding one row in `t`. */
82+
async function seedDatabase(filename: string): Promise<void> {
83+
const seed = new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true } as any);
84+
await seed.connect();
85+
await seed.execute('CREATE TABLE t (a INTEGER)');
86+
await seed.execute('INSERT INTO t (a) VALUES (7)');
87+
await seed.disconnect();
88+
}
89+
90+
/**
91+
* The refused file holds the bytes it was found with, and nothing is left
92+
* beside it. The header still says WAL (this driver's journal mode), so SQLite
93+
* keeps its `-shm` and an empty `-wal` while the refused connection is open:
94+
* no `.corrupt-*` sibling then, and nothing at all once it is closed.
95+
*/
96+
async function expectLeftAsItWas(driver: SqlDriver, filename: string, before: Buffer): Promise<void> {
97+
expect(readdirSync(dir).filter((name) => name.includes('.corrupt-'))).toEqual([]);
98+
await driver.disconnect();
99+
expect(readFileSync(filename).equals(before)).toBe(true);
100+
expect(readdirSync(dir)).toEqual(['archive.db']);
101+
}
102+
103+
let dir: string;
104+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'os-25019-')); });
105+
afterEach(async () => {
106+
vi.unstubAllEnvs();
107+
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
108+
rmSync(dir, { recursive: true, force: true });
109+
});
110+
111+
describe('SqlDriver.connect — a declared SQLite file whose header or schema SQLite cannot read (#25019)', () => {
112+
for (const nodeEnv of ['development', 'production'] as const) {
113+
it(`refuses a database truncated inside its header with SQLITE_CORRUPT, naming it, and leaves its bytes untouched (NODE_ENV=${nodeEnv})`, async () => {
114+
const filename = join(dir, 'archive.db');
115+
await seedDatabase(filename);
116+
truncateSync(filename, 50);
117+
const before = readFileSync(filename);
118+
vi.stubEnv('NODE_ENV', nodeEnv);
119+
120+
const driver = make(filename);
121+
const error = await refusalOf(driver);
122+
123+
expectFileRefusal(error, filename, 'SQLITE_CORRUPT');
124+
expect(error.message).toContain((error.cause as Error).message);
125+
await expectLeftAsItWas(driver, filename, before);
126+
});
127+
}
128+
129+
it('refuses a database cut short of the pages its schema names, the same way', async () => {
130+
const filename = join(dir, 'archive.db');
131+
await seedDatabase(filename);
132+
truncateSync(filename, 4096);
133+
const before = readFileSync(filename);
134+
135+
const driver = make(filename);
136+
expectFileRefusal(await refusalOf(driver), filename, 'SQLITE_CORRUPT');
137+
await expectLeftAsItWas(driver, filename, before);
138+
});
139+
140+
it('CONTROL: a database damaged deeper inside still connects, and the read that reaches the damage fails', async () => {
141+
// The refusal is the FIRST statement's verdict, never an integrity check:
142+
// here the header and schema read, and one table's page does not.
143+
const filename = join(dir, 'archive.db');
144+
await seedDatabase(filename);
145+
const probe = make(filename);
146+
await probe.connect();
147+
const [{ rootpage }] = (await probe.execute("SELECT rootpage FROM sqlite_master WHERE name = 't'")) as Array<{ rootpage: number }>;
148+
const [{ page_size: pageSize }] = (await probe.execute('PRAGMA page_size')) as Array<{ page_size: number }>;
149+
await probe.disconnect();
150+
const fd = openSync(filename, 'r+');
151+
writeSync(fd, Buffer.alloc(pageSize, 0xab), 0, pageSize, (rootpage - 1) * pageSize);
152+
closeSync(fd);
153+
154+
const driver = make(filename);
155+
await driver.connect();
156+
const failure = await driver.execute('SELECT a FROM t').then(
157+
() => { throw new Error('a read of the damaged page answered'); },
158+
(error: any) => error,
159+
);
160+
expect(failure?.cause?.code).toBe('SQLITE_CORRUPT');
161+
});
162+
});
163+
164+
describe('SqlDriver.connect — a dangling symbolic link as the directory, on the boot that writes (#25019)', () => {
165+
it('refuses it with the read-only boot\'s code, naming the file and the link, and creates nothing through it', async () => {
166+
const link = join(dir, 'link');
167+
symlinkSync(join(dir, 'nowhere'), link);
168+
const filename = join(link, 'archive.db');
169+
const before = tree(dir);
170+
171+
const writing = await refusalOf(make(filename));
172+
expectFileRefusal(writing, filename, 'ENOENT');
173+
expect(writing.message).toContain(DANGLING_WORDS(link));
174+
175+
// The read-only boot over the same path answers with the same error class,
176+
// code, file and words (#24961).
177+
const readOnly = thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory'));
178+
expectFileRefusal(readOnly, filename, 'ENOENT');
179+
expect(readOnly.message).toContain(DANGLING_WORDS(link));
180+
181+
expect(lstatSync(link).isSymbolicLink()).toBe(true);
182+
expect(existsSync(join(dir, 'nowhere'))).toBe(false);
183+
expect(tree(dir)).toEqual(before);
184+
});
185+
186+
it('refuses a deeper path under the link the same way, naming the link', async () => {
187+
const link = join(dir, 'link');
188+
symlinkSync(join(dir, 'nowhere'), link);
189+
const filename = join(link, 'sub', 'archive.db');
190+
const before = tree(dir);
191+
192+
const writing = await refusalOf(make(filename));
193+
expectFileRefusal(writing, filename, 'ENOENT');
194+
expect(writing.message).toContain(DANGLING_WORDS(link));
195+
expect(tree(dir)).toEqual(before);
196+
});
197+
198+
it('CONTROL: a directory that is a link to an existing directory connects, and the file is created in it', async () => {
199+
mkdirSync(join(dir, 'real'));
200+
symlinkSync(join(dir, 'real'), join(dir, 'link'));
201+
const driver = make(join(dir, 'link', 'archive.db'));
202+
await driver.connect();
203+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
204+
expect(readdirSync(join(dir, 'real'))).toContain('archive.db');
205+
});
206+
207+
it('CONTROL: an absent file under directories that can be made connects, and both are created', async () => {
208+
const filename = join(dir, 'new', 'deeper', 'archive.db');
209+
const driver = make(filename);
210+
await driver.connect();
211+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
212+
expect(existsSync(filename)).toBe(true);
213+
});
214+
});
215+
216+
describe('a dangling symbolic link as the database file itself (#25019)', () => {
217+
it('the read-only boot refuses it when the directory the chain of links ends in does not exist, as the writing boot does', async () => {
218+
const target = join(dir, 'missing', 'target.db');
219+
const direct = join(dir, 'archive.db');
220+
symlinkSync(target, direct);
221+
// The same end, reached through a second link.
222+
symlinkSync(target, join(dir, 'hop.db'));
223+
const chained = join(dir, 'chained.db');
224+
symlinkSync(join(dir, 'hop.db'), chained);
225+
const before = tree(dir);
226+
227+
for (const filename of [direct, chained]) {
228+
const readOnly = thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory'));
229+
expectFileRefusal(readOnly, filename, 'ENOENT');
230+
expect(readOnly.message).toContain(target);
231+
232+
// The writing boot refuses the same path with SQLite's own answer.
233+
expectFileRefusal(await refusalOf(make(filename)), filename, 'SQLITE_CANTOPEN');
234+
}
235+
expect(tree(dir)).toEqual(before);
236+
});
237+
238+
it('CONTROL: when that directory exists, the read-only boot reads it absent and writes nothing, and the writing boot creates the file through the link', async () => {
239+
symlinkSync(join(dir, 'target.db'), join(dir, 'archive.db'));
240+
// A relative target is read from the link's own directory.
241+
symlinkSync('relative-target.db', join(dir, 'relative.db'));
242+
const before = tree(dir);
243+
244+
for (const name of ['archive.db', 'relative.db']) {
245+
expect(resolveSqliteAbsentFileTarget(join(dir, name), 'empty-in-memory'))
246+
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
247+
}
248+
expect(tree(dir)).toEqual(before);
249+
250+
for (const name of ['archive.db', 'relative.db']) {
251+
const driver = make(join(dir, name));
252+
await driver.connect();
253+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
254+
}
255+
expect(existsSync(join(dir, 'target.db'))).toBe(true);
256+
expect(existsSync(join(dir, 'relative-target.db'))).toBe(true);
257+
});
258+
});

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7061,7 +7061,7 @@ export class SqlDriver implements IDataDriver {
70617061
* not merely that space hygiene is unavailable.
70627062
* @throws {SqliteDatabaseFileError} when that first statement's answer is a
70637063
* verdict on the file itself ({@link SQLITE_FILE_REFUSALS}): it cannot be
7064-
* opened, or it is not a SQLite database.
7064+
* opened, it is not a SQLite database, or its header or schema is damaged.
70657065
*/
70667066
private async applyAutoVacuumIncremental(): Promise<boolean> {
70677067
try {
@@ -7097,7 +7097,17 @@ export class SqlDriver implements IDataDriver {
70977097
// used to answer it with wasm SQLite, whose open moved the
70987098
// operator's file aside and wrote an empty database in its place.
70997099
// The refusal is issued before anything is written: the file is left
7100-
// exactly as it was found.
7100+
// exactly as it was found;
7101+
// • SQLITE_CORRUPT (#25019) — the file is a SQLite database SQLite
7102+
// cannot read: its header or its schema is damaged (a file truncated
7103+
// inside its 100-byte header, or short of the pages its schema
7104+
// names). At the FIRST statement this is a verdict on the whole
7105+
// file, measured: `SELECT 1` answers the same code. A database
7106+
// damaged deeper inside answers this read and connects; its damage
7107+
// surfaces on the read that reaches it, as before. A development
7108+
// boot used to take the same step-down to wasm SQLite as for
7109+
// SQLITE_NOTADB, and the same rename; it is refused the same way,
7110+
// before anything is written.
71017111
const refusal = code === undefined ? undefined : SqlDriver.SQLITE_FILE_REFUSALS.get(code);
71027112
if (refusal !== undefined) {
71037113
const declared = (this.config as any).connection;
@@ -7132,6 +7142,7 @@ export class SqlDriver implements IDataDriver {
71327142
private static readonly SQLITE_FILE_REFUSALS: ReadonlyMap<string, string> = new Map([
71337143
['SQLITE_CANTOPEN', 'cannot be opened or created'],
71347144
['SQLITE_NOTADB', 'is not a SQLite database, and was left as it is'],
7145+
['SQLITE_CORRUPT', 'is damaged (SQLite cannot read its header or its schema), and was left as it is'],
71357146
]);
71367147

71377148
/** What `PRAGMA auto_vacuum` answers for INCREMENTAL (0 is NONE, 1 is FULL). */

0 commit comments

Comments
 (0)