Skip to content

Commit 4d99158

Browse files
fix(driver-sql): a read-only boot refuses a SQLite datasource whose file can be neither opened nor created, instead of reporting it connected (#25007)
Fixes #24961 Clause-②: no (narrowing) The narrowing face: a read-only boot (`sqliteAbsentFile: 'empty-in-memory'`, which `os migrate plan` and the read-only dry runs use) that answered `datasource 'NAME': connected` for a SQLite path that can be neither opened nor created now reports that datasource failed, naming the file. No exported declaration changes. The review of record is owed at `CONTRACT_REVIEW_TIER` on that face. Dev session `session_01QK6pdbxKZiHdFApL8RYVh9` (a `mode:subagent` run), dispatched by the `domain:engine` seat 2 under claim 6303642231. ## What was wrong `resolveSqliteAbsentFileTarget` (`packages/drivers/driver-sql/src/sql-driver.ts`) is the one place the "does the SQLite target exist?" judgement is made. Under `'empty-in-memory'` it answered `:memory:` whenever `existsSync(filename)` was `false`. `existsSync` is `false` for every path it cannot stat, not only for an absent one. So a path under a regular file read as absent, the read-only boot opened an empty in-memory database over it, and the datasource read `connected`. The boot that writes refuses the same path in `SqlDriver.connect()`. ## What changed - **The judgement asks the operating system, and writes nothing** (`probeAbsentSqliteDatabaseFile`, new and module-private in `packages/drivers/driver-sql/src/sqlite-database-file.ts`, next to `makeDatabaseDirectory`, whose walk it mirrors without the `mkdir`): - `stat(filename)` succeeds: the file exists and is opened as before. - `stat(filename)` answers anything but `ENOENT` (`ENOTDIR`, `EACCES`, `ELOOP`, …): the file cannot exist. That answer is the refusal. - `ENOENT`: walk up to the nearest level that exists. A level whose name a dangling symbolic link holds is refused. Otherwise the level must allow write and search (`access(W_OK | X_OK)`), the permission that creating the next level or the file needs. Its refusal (`EACCES`, or `EROFS` on a read-only mount) is the answer. - **The refusal is the driver's `SqliteDatabaseFileError`**, which names the file and carries the system's `code`. It is the error the writing boot's `connect()` already raises (#23696), and `isSqliteFileOpenFailure` (#24955) already classifies it. `resolveSqliteAbsentFileTarget` throws it, so the `SqlDriver` constructor throws it too, and no Knex instance is built over the file. - **Both callers of the exported judgement get the same answer.** The service layer's `resolveSqliteDriver` resolves the wasm rung's filename through `resolveSqliteAbsentFileTarget` before it builds any rung (`sqlite-driver-fallback.ts`, the line under the "filename the wasm rung must open" note). So the native rung, the wasm rung and production (`dev: false`, native unprobed) all reject with the same error. The factory's `create()` rejects, and `DatasourceConnectionService` records the datasource `failed` under its declared name, through the same `handleFailure` path the writing boot takes. **No line of `sqlite-driver-fallback.ts` changed.** `applyAutoVacuumIncremental` and `isSqliteFileOpenFailure`, which #24960 is changing, are untouched. - **`knexConfigFor` asks the judgement for a SQLite client only.** A server dialect's `connection` string is a URL, not a filename. Measured on base: `new SqlDriver({ client: 'pg', connection: 'postgres://app@db.example:5432/main', sqliteAbsentFile: 'empty-in-memory' })` had its connection string rewritten to `:memory:` and answered `sqliteOpenedEmptyInMemory: true`. That contradicts `SqlDriverConfig.sqliteAbsentFile`'s "ignored for non-SQLite dialects". No producer passes the option to a server dialect, but the judgement now does filesystem work and can throw, so it must not be handed a URL. It sits inside `knexConfigFor`, on this claim's file surface. - **Docs:** `SqliteAbsentFileMode` and `resolveSqliteAbsentFileTarget` say that only a file `'create'` could bring into existence is absent. The `SqliteDatabaseFileError` class doc is left as it stands. The probe's own doc says where the error is thrown from. This avoids a textual conflict with #24960, which edits that block. - One changeset: `@objectstack/driver-sql` `patch`, with the `Clause-②` line, the BREAKING banner, FROM and TO, and the ADR-0087 disposition (`not-required (no-migration-prescription)`). ## H1: what the filesystem answers, measured before the change Measured on Node v22.22.0 with a probe script (deleted), as root and as uid 65534 (`setpriv`). Nothing was written. | path | `existsSync` | `stat(file)` | nearest existing level | `access(W_OK and X_OK)` there | |---|---|---|---|---| | under a regular file (`plain/archive.db`) | false | `ENOTDIR` | n/a | n/a | | deeper (`plain/sub/archive.db`) | false | `ENOTDIR` | n/a | n/a | | file in a `0700` dir that is not ours (uid 65534) | false | `EACCES` | n/a | n/a | | absent file in a `0555` dir | false | `ENOENT` | the dir | root: ok; uid 65534: `EACCES` | | `/proc/os-24961/archive.db` | false | `ENOENT` | `/proc` | root: **ok**, though its `mkdir` answers `ENOENT` (the #23696 reading); uid 65534: `EACCES` | | `/opt/claude-code/os-24961/archive.db` (ro mount) | false | `ENOENT` | `/opt/claude-code` | `EROFS` for both | | absent file, existing or creatable dir | false | `ENOENT` | a writable dir | ok | | a dangling symlink as the directory (measured by its pin) | false | `ENOENT` | `stat` passes over the link to its parent; `lstat` still finds the link | ok on the parent | `stat` already separates "absent" (`ENOENT`) from "cannot exist" (anything else). `access` on the nearest existing level covers permission and read-only mounts. ## Public door, before and after `bin/run-dev.js migrate plan --database-url file:main.db` in a throwaway project. `dist/objectstack.json` declares `archive` (`driver: 'sqlite'`) and binds one object, `os24961_note`, to it. The driver-sql `dist/` was built from base `4b8f597ba` for "before" and from `3370501f9` for "after". The parent directory listing was compared before and after each run. | `archive` filename | before (base) | after | |---|---|---| | `…/plain/archive.db` (under a regular file) | `datasource 'archive': connected`; `Planned 1 declared datasource(s) … archive (…/plain/archive.db) — connected under the declared name`; `+ os24961_note [create_table, 8 column(s)]`; exit 0 | `datasource 'archive': connect failed — SQLite database file '…/plain/archive.db' cannot be opened or created: ENOTDIR: not a directory, stat '…/plain/archive.db'. (1 object(s) bind to it explicitly (os24961_note) … fail-fast per ADR-0062 D5)`; nothing planned; exit 1 | | `…/plain/sub/archive.db` | not run at the door on base | the same refusal, `ENOTDIR`; exit 1 | | `…/link/archive.db` (a dangling symlink as the directory) | `connected`, planned `create_table`, exit 0 | `connect failed — … cannot be created: its directory '…/link' cannot be made ('…/link' is a symbolic link to a path that does not exist): ENOENT …`; exit 1 | | `…/ok/archive.db` (absent, directory exists) | not run at the door on base (the existing behaviour, pinned) | `connected`, `+ os24961_note [create_table, 8 column(s)]`, exit 0, file not created | | `…/ok/new/deeper/archive.db` (absent, directories missing) | not run at the door on base (the existing behaviour, pinned) | `connected`, planned, exit 0, no directory created | Every run, before and after, left the listing unchanged. The writing boot over the same path (`migrate apply --yes`, measured on base; this PR does not touch that path) answers `connect failed — … cannot be created: its directory '…/plain' could not be made: EEXIST …`, exit 1. The two boots now agree. Their codes differ (`ENOTDIR` here, `EEXIST` there) because the read-only judgement asks `stat` where the writing boot asks `mkdir`. Each is the system's own answer. Resolver answers on the built `dist/` after the fix, as uid 65534: a `0555` directory gives `EACCES` ("cannot be opened or created"), one level deeper gives `EACCES` ("its directory … cannot be made"), `/proc/os-24961/…` gives `EACCES`, and the read-only mount gives `EROFS`. As root, the `0555` directory and `/proc/os-24961/…` read absent. Nothing was written in any case. ## Pins `packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts` (relative `src` import), a new describe block: - A path under a regular file, at either depth: `SqliteDatabaseFileError`, `code` `ENOTDIR`, `filename` the file. The directory listing is unchanged. - A dangling symlink as the directory: refused (`ENOENT`). The link is still dangling, and its target was not created. - A `0555` directory as a non-superuser: `EACCES`, at both depths, and nothing created (`skipIf` superuser). As the superuser the same directory reads absent, because the writing boot can create there (`runIf` superuser). CI's runner is not root, so CI runs the first. This container runs as root, so here it ran the second, and the first was measured on `dist/` as uid 65534 (above). - CONTROL: an absent file in an existing directory, and under missing directories, still answers `:memory:` and writes nothing. - `'create'` and the default hand the path on unchanged. - `new SqlDriver({ … sqliteAbsentFile: 'empty-in-memory' })` over the path throws the same error. Without the option, the same driver's `connect()` refuses the same file. Nothing is written. - A `pg` driver with a connection string keeps its string and answers `sqliteOpenedEmptyInMemory: false`. This is an assertion added to the existing "inert for non-sqlite dialects" case. `packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts` (new; it resolves `@objectstack/driver-sql` through `dist/`, a `KNOWN_UNALIASED_TEST_IMPORTS` pair). Real `better-sqlite3`, real wasm SQLite, real files. An addon that cannot load is faked where `better-sqlite3` loads it, the same way as the #24955 file: - `resolveSqliteDriver({ sqliteAbsentFile: 'empty-in-memory' })` under a regular file, on the development native rung, the development wasm rung, and production: each rejects with `SqliteDatabaseFileError` / `ENOTDIR`. It never resolves an engine, never warns, and writes nothing. - An absent file that can be created: the native rung opens empty in memory (`sqliteOpenedEmptyInMemory: true`), and the wasm rung opens `:memory:` (`engine: 'sqlite-wasm'`). Nothing is on disk for either. - The read-only boot `os migrate plan` composes (`createDefaultDatasourceDriverFactory({ sqliteAbsentFile: 'empty-in-memory' })`): `create()` rejects with the refusal (code asserted). Through `DatasourceConnectionService.connectDeclared`, in development and in production, the datasource reads `failed`, its reason names the file, no driver is registered under `archive`, and nothing is written. This pin file is the only `service-datasource` line in the diff. It is a test, outside the package's `files[]`, and the cross-lane declaration to `domain:services` is the seat's. ## Ablation Both legs went through `scripts/ablation-replace.mjs` (WRAP), each with a `trap` restore on absolute paths. Each restore was proven by blob equality with `HEAD`, an empty `git diff HEAD`, and an empty `git status --porcelain`. - **Leg 1, the judgement.** The probe's first answer was replaced by plain `existsSync` semantics: stat succeeds means exists, anything else means absent. The marker identifier was `ABLATION_24961`. Anchor 1 to 0. Blob `fbde48ea8f70` became `fb3ea19400da` and then returned to `fbde48ea8f70`. Then `pnpm --filter @objectstack/driver-sql build`, and `ablation-dist-preflight` found the marker in 2 built files. Predicted: driver-sql 3 failed, 12 passed, 1 skipped; service-datasource 6 failed, 22 passed. **Observed the same.** Red in driver-sql: under a regular file, the dangling link, and the constructor. Red in service-datasource: the native, wasm and production rungs, the factory, and the service in development and in production. The absent-file controls, the #24955 file (9) and `sqlite-driver-fallback.test.ts` (11) stayed green. After the restore: rebuild, and `--absent` found the marker in none of the 6 built files, with the tree clean. The first attempt at this leg was **refused by the tool before anything ran**: its anchor sat inside its own replacement, so the anchor count could not drop. Nothing was built or tested on that attempt. The anchor was changed and the leg re-run. - **Leg 2, the SQLite-client gate in `knexConfigFor`.** `|| !this.isSqlite` was removed. Blob `dea47a1a378e` became `38af381b3aac` and then returned. The subject is a relative `src` import, so this leg has no dist step. Predicted and observed: 1 failed ("is inert for :memory: and for non-sqlite dialects"), 14 passed, 1 skipped. ## Local verification Builds: - Base closure for the before-door: `turbo run build --filter='@objectstack/cli^...' --concurrency=2`, 60 of 60. - After merging `origin/main` (`4fe3af9d7`, merge head `28d5dd67e`): `pnpm install --frozen-lockfile`, then `turbo run build --filter='@objectstack/cli^...' --filter=@objectstack/cli --concurrency=2`, 61 of 61. Tests: - Full driver-sql suite (`pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2`) on `02302719e`: 225 files passed, 12 skipped; 3698 tests passed, 210 skipped. The skips include the live PG and MySQL cells (no live server here) and this file's superuser split. - Full service-datasource suite on `02302719e`: 51 files, 916 passed. - On the merge head `28d5dd67e`: the driver-sql pin file gave 15 passed and 1 skipped; the three service-datasource fallback files gave 28 passed. Merge state: `origin/main` was merged at `4fe3af9d7`. Since then `main` has gained `fbb42d900` (plugin-auth) and `86d236071` (rest), which touch none of this diff's packages; the queue rebuilds on current `main`. Against #24960's branch head `6d3dd6247` (fetched into a ref of my own), `git merge-tree` reports no conflict: that branch edits `SqliteDatabaseFileError`'s doc block, which this diff leaves alone. Typecheck: - `pnpm --filter @objectstack/driver-sql typecheck` passed, and its `tsc --listFiles` includes the edited test file. - `pnpm --filter @objectstack/service-datasource typecheck`: the first run (`02302719e`) failed on the new pin file (a `.then` on `create()`'s union return type). It was fixed in `7c66e98f6`, and it passes there and on `28d5dd67e`. Driver ledger (`pnpm check:driver-conformance`), before (`4b8f597ba`) and after (`28d5dd67e`): `55 covered cell(s), 0 in the DEBT ledger, 0 exempt` both times. The dialect axis is unchanged: 9 suites, 11 of 11 dialect-scored cells matrix-routed. The ledger did not move. Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived **72** commands at the merge head `28d5dd67e`. The derivation's first line names `objectstack-ai/objectstack` at `28d5dd67e`. The PM's list at `e4d604efd` had 55, and the 17 extra come from this diff's changeset and its new service-datasource test. All 72 ran one at a time, each exit code captured before any pipe. - First pass: 70 exited 0, and 2 exited 3 (PREREQUISITE NOT MET). - `node scripts/check-plugin-teardown-shape.mjs --self-test`: its positive-control commit `621a48760788` was outside this shallow clone. I fetched that one commit into a ref of my own (`--depth=1`) and re-ran: exit 0, 48 cases. - `pnpm check:dual-build-cjs-loads`: ten packages outside the CLI closure had no `dist/`. I ran `turbo run build --filter='!@objectstack/docs' --concurrency=2` (76 of 76) and re-ran: exit 0. - `--ran` over the first pass: 72 derived, 70 run, 2 NOT-MEASURED, 0 UNRUN. Over the final record: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN, and the tool exited 0. - After the merge (`packages/spec` moved on `main`'s side), `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date, exit 0. - `test:repo` (the vitest `repo` project) was not run locally: the diff touches none of its tests, and the derivation does not name it. It is declared to CI (`Test Core`). - Every family CI runs is read from this PR's check-runs, not re-run here. ## Acceptance notes - **Boundary, measured: one shape still reads absent without a write.** `/proc/MISSING/…` as root: `access` answers ok, and only `mkdir` (`ENOENT`) would say no. The governing text, "`'empty-in-memory'` leaves the filesystem untouched", rules out finding out by trying. So the read-only boot still opens it in memory, and the writing boot still refuses it. As a non-root user it is refused (`EACCES` on `/proc`). The probe's doc names this. - **Telemetry sibling on a read-only boot.** `provisionTelemetryDatasource` wraps `resolveSqliteDriver` in a best-effort `try`. Over an unopenable sibling path, the read-only boot now gets no sibling, as the writing boot already did, where before it got an in-memory one. The sibling sits next to the primary, and an unopenable primary already fails the boot first. This is by code reading only, not measured at a door. - **`NODE_ENV=production`:** pinned at the service layer (`dev: false`, both the resolver and the connection service). The production entry `bin/run.js` was not driven: NOT MEASURED at that door. - **`os migrate audit-mail-bodies` without `--apply`:** its archive arm exists only on PR #24934, which is unmerged. NOT MEASURED. Its read-only boot builds its datasources through the same factory, so the factory pins are its mechanism. - **Not addressed here, in the reporting seat's hands:** the writing boot over a dangling symlink as a declared datasource's directory. Measured on base and unchanged by this PR: `migrate apply --yes` prints `datasource 'archive': connected`, then a false "native better-sqlite3 unavailable (ABI mismatch or not built)" step-down warning, and exits 1 later. The driver's `makeDatabaseDirectory` accepts `EEXIST` on the link. better-sqlite3's code-less "directory does not exist" `TypeError` is not one of the codes `connect()` refuses on. The report carries it as a finding. --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02e6d73 commit 4d99158

5 files changed

Lines changed: 483 additions & 6 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
---
4+
5+
fix(driver-sql): a read-only boot refuses a SQLite datasource whose file can be neither opened nor created, instead of opening an empty in-memory database over it and reporting it connected
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `resolveSqliteAbsentFileTarget` gives under `sqliteAbsentFile: 'empty-in-memory'` for a SQLite path that cannot exist: it throws the driver's `SqliteDatabaseFileError` where it answered `:memory:`. The remedy is the datasource's `filename`, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (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). -->
10+
11+
`sqliteAbsentFile: 'empty-in-memory'` is the open mode a read-only boot uses (`os migrate plan`, and the dry runs that boot read-only): an absent SQLite file is opened as an empty in-memory database, so nothing is created on disk. Its judgement of "absent" was `existsSync(path) === false`, which is also the answer for a path that cannot exist: a path under a regular file (`ENOTDIR`), a directory the process may not search (`EACCES`). So `os migrate plan` printed `datasource '<name>': connected` for a declared datasource on such a path and planned `create_table` into it, while the boot that writes refused the same path.
12+
13+
**BREAKING** (the read-only boot's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM:** under `'empty-in-memory'`, a SQLite path that can be neither opened nor created read as absent. The driver opened `:memory:`, and the datasource's connect succeeded.
16+
- **TO:** `resolveSqliteAbsentFileTarget`, and so the `SqlDriver` constructor, throws `SqliteDatabaseFileError` naming the file and the operating system's answer: `ENOTDIR` (a path under a regular file), `EACCES` (a directory the process may not write or search), `EROFS` (a read-only mount), or `ENOENT` (a directory whose name a dangling symbolic link holds). The datasource's connect fails with it, as the boot that writes already failed, so `os migrate plan` reports the datasource failed instead of planning into it. The judgement still writes nothing: no directory and no file is created to find out.
17+
- **Unchanged:** an absent file in a directory that exists, or that could be made, still opens as an empty in-memory database and leaves the disk untouched. An existing file opens as before. The default mode (`'create'`) is untouched, and so is every server dialect: the option is now applied to SQLite clients only, as documented (a `pg` driver given it with a connection string used to have that string rewritten to `:memory:`).
18+
- **Fix:** if a read-only boot now refuses a SQLite datasource, correct its `filename`: the refusal names the path and the reason.

‎packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts‎

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
19-
import { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
19+
import { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync, mkdirSync, chmodSync, lstatSync, symlinkSync } from 'node:fs';
2020
import { tmpdir } from 'node:os';
2121
import { join } from 'node:path';
2222
import { SqlDriver, resolveSqliteAbsentFileTarget } from './index.js';
@@ -160,6 +160,15 @@ describe('SqlDriver({ sqliteAbsentFile }) — opening must not create (#6743)',
160160
} as any);
161161
expect(((pg as any).config as any).sqliteAbsentFile).toBeUndefined();
162162
expect(pg.sqliteOpenedEmptyInMemory).toBe(false);
163+
164+
// A server dialect's connection STRING is a URL, not a filename (#24961):
165+
// it is never handed to the absent-file judgement, so it is neither
166+
// rewritten to `:memory:` nor walked on disk.
167+
const url = 'postgres://app@db.example:5432/main';
168+
const pgUrl = new SqlDriver({ client: 'pg', connection: url, sqliteAbsentFile: 'empty-in-memory' } as any);
169+
expect((pgUrl as any).knex.client.config.connection).toMatchObject({ connectionString: url });
170+
expect(pgUrl.sqliteOpenedEmptyInMemory).toBe(false);
171+
await pgUrl.disconnect();
163172
});
164173

165174
it('a directory that already exists is not disturbed', async () => {
@@ -175,3 +184,139 @@ describe('SqlDriver({ sqliteAbsentFile }) — opening must not create (#6743)',
175184
expect(readdirSync(dataDir)).toEqual(['unrelated.txt']);
176185
});
177186
});
187+
188+
/**
189+
* An unopenable path is not an absent one (#24961).
190+
*
191+
* `existsSync` answers `false` for every path it cannot stat, so the judgement
192+
* used to read a path under a regular file as an absent file and open an empty
193+
* `:memory:` database over it: `os migrate plan` reported the datasource
194+
* connected and planned tables into a file that cannot exist, while the boot
195+
* that writes refused the same path. The judgement now refuses it, naming the
196+
* file and the operating system's answer — and still writes nothing to find
197+
* out.
198+
*/
199+
describe('resolveSqliteAbsentFileTarget — a file that can be neither opened nor created is refused (#24961)', () => {
200+
let dir: string;
201+
202+
beforeEach(() => {
203+
dir = mkdtempSync(join(tmpdir(), 'os-24961-'));
204+
writeFileSync(join(dir, 'plain'), 'a regular file, not a directory');
205+
});
206+
afterEach(() => {
207+
try { chmodSync(join(dir, 'locked'), 0o755); } catch { /* not every case makes it */ }
208+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
209+
});
210+
211+
/** What `fn` threw; a failure naming its answer when it threw nothing. */
212+
function thrownBy(fn: () => unknown): any {
213+
let answer: unknown;
214+
try {
215+
answer = fn();
216+
} catch (error) {
217+
return error;
218+
}
219+
throw new Error(`answered ${JSON.stringify(answer)} for a file that can be neither opened nor created`);
220+
}
221+
222+
/** The driver's refusal of the file: its own error name, the system's code, and the file. */
223+
function expectFileRefusal(error: any, filename: string, code: string): void {
224+
expect(error?.name).toBe('SqliteDatabaseFileError');
225+
expect(error?.code).toBe(code);
226+
expect(error?.filename).toBe(filename);
227+
}
228+
229+
it('refuses a path under a regular file with ENOTDIR, at either depth, and writes nothing', () => {
230+
const before = readdirSync(dir).sort();
231+
232+
const at = join(dir, 'plain', 'archive.db');
233+
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(at, 'empty-in-memory')), at, 'ENOTDIR');
234+
const through = join(dir, 'plain', 'sub', 'archive.db');
235+
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(through, 'empty-in-memory')), through, 'ENOTDIR');
236+
237+
expect(readdirSync(dir).sort()).toEqual(before);
238+
});
239+
240+
it('refuses a directory whose name a dangling symbolic link holds, and leaves the link dangling', () => {
241+
const link = join(dir, 'link');
242+
symlinkSync(join(dir, 'nowhere'), link);
243+
244+
const filename = join(link, 'archive.db');
245+
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory')), filename, 'ENOENT');
246+
247+
expect(lstatSync(link).isSymbolicLink()).toBe(true);
248+
expect(existsSync(join(dir, 'nowhere'))).toBe(false);
249+
});
250+
251+
// Permission is judged as the process holds it, so the case splits on who
252+
// runs it: a superuser may create in a 0555 directory, and the writing boot
253+
// does — so for it the absent answer is the consistent one.
254+
const superuser = typeof process.getuid === 'function' && process.getuid() === 0;
255+
256+
it.skipIf(superuser)('refuses an absent file in a directory this process may not write, with EACCES', () => {
257+
const locked = join(dir, 'locked');
258+
mkdirSync(locked);
259+
chmodSync(locked, 0o555);
260+
261+
const filename = join(locked, 'archive.db');
262+
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory')), filename, 'EACCES');
263+
const deeper = join(locked, 'sub', 'archive.db');
264+
expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(deeper, 'empty-in-memory')), deeper, 'EACCES');
265+
266+
expect(readdirSync(locked)).toEqual([]);
267+
});
268+
269+
it.runIf(superuser)('as the superuser, an absent file in a 0555 directory is absent: the writing boot can create it', () => {
270+
const locked = join(dir, 'locked');
271+
mkdirSync(locked);
272+
chmodSync(locked, 0o555);
273+
274+
expect(resolveSqliteAbsentFileTarget(join(locked, 'archive.db'), 'empty-in-memory'))
275+
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
276+
expect(readdirSync(locked)).toEqual([]);
277+
});
278+
279+
it('CONTROL: an absent file in a directory that exists, or can be made, is still absent and writes nothing', () => {
280+
const before = readdirSync(dir).sort();
281+
expect(resolveSqliteAbsentFileTarget(join(dir, 'fresh.db'), 'empty-in-memory'))
282+
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
283+
expect(resolveSqliteAbsentFileTarget(join(dir, 'new', 'deeper', 'fresh.db'), 'empty-in-memory'))
284+
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
285+
expect(readdirSync(dir).sort()).toEqual(before);
286+
});
287+
288+
it('only empty-in-memory asks: the default mode hands the path on for connect() to refuse', () => {
289+
const at = join(dir, 'plain', 'archive.db');
290+
expect(resolveSqliteAbsentFileTarget(at, 'create')).toEqual({ filename: at, openedEmptyInMemory: false });
291+
expect(resolveSqliteAbsentFileTarget(at, undefined)).toEqual({ filename: at, openedEmptyInMemory: false });
292+
});
293+
294+
it('SqlDriver under empty-in-memory refuses at construction with the same error the writing boot\'s connect() names the file with', async () => {
295+
const before = readdirSync(dir).sort();
296+
const at = join(dir, 'plain', 'archive.db');
297+
const build = (mode?: 'empty-in-memory') =>
298+
new SqlDriver({
299+
client: 'better-sqlite3',
300+
connection: { filename: at },
301+
useNullAsDefault: true,
302+
...(mode ? { sqliteAbsentFile: mode } : {}),
303+
} as any);
304+
305+
// The read-only boot: no driver over a file that cannot exist.
306+
expectFileRefusal(thrownBy(() => build('empty-in-memory')), at, 'ENOTDIR');
307+
308+
// The boot that may write, over the same path: its connect() refuses it,
309+
// as it did before (#23696) — so the two boots now agree.
310+
const writing = build();
311+
let refusal: unknown;
312+
try {
313+
await writing.connect().catch((e: unknown) => { refusal = e; });
314+
} finally {
315+
await writing.disconnect();
316+
}
317+
expect((refusal as any)?.name).toBe('SqliteDatabaseFileError');
318+
expect((refusal as any)?.filename).toBe(at);
319+
320+
expect(readdirSync(dir).sort()).toEqual(before);
321+
});
322+
});

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,13 +156,12 @@ import {
156156
type UndeliveredAttribute,
157157
} from './builtin-column-collision.js';
158158
import { recoverUnencodedJsonText } from './unencoded-json-text.js';
159-
import { makeDatabaseDirectory, SqliteDatabaseFileError } from './sqlite-database-file.js';
159+
import { makeDatabaseDirectory, probeAbsentSqliteDatabaseFile, SqliteDatabaseFileError } from './sqlite-database-file.js';
160160
import { jsonMembershipPredicate } from '@objectstack/core';
161161
import knex, { Knex } from 'knex';
162162
import { nanoid } from 'nanoid';
163163
import { AsyncLocalStorage } from 'node:async_hooks';
164164
import { createHash } from 'node:crypto';
165-
import { existsSync } from 'node:fs';
166165
import { currentPerfTiming, perfNow, type PerfTiming } from '@objectstack/observability';
167166
// [#21007] The JSON-column gate's operator set and refusal text — shared with
168167
// `@objectstack/objectql`'s per-aggregation `filter`, which refuses the same
@@ -5159,7 +5158,10 @@ export type SqliteJournalMode = 'wal' | 'delete';
51595158
* database instead, which introspects as a database with zero tables — the
51605159
* same thing a freshly-created empty file introspects as. For a read-only
51615160
* caller (`os migrate plan`) the report is therefore identical while the
5162-
* filesystem is left untouched.
5161+
* filesystem is left untouched. Only a file that `'create'` could bring into
5162+
* existence counts as absent: one that can be neither opened nor created is
5163+
* refused, as `'create'`'s connect refuses it (#24961) — see
5164+
* {@link resolveSqliteAbsentFileTarget}.
51635165
*
51645166
* `'empty-in-memory'` is for callers that only ever READ. A caller that may
51655167
* write MUST leave it at `'create'`: writes land in the ephemeral database and
@@ -5182,6 +5184,24 @@ export type SqliteAbsentFileMode = 'create' | 'empty-in-memory';
51825184
*
51835185
* Pseudo-filenames (`:memory:` and anything else `:`-prefixed) and non-file
51845186
* dialects are already fileless, so they are returned untouched.
5187+
*
5188+
* ## Absent is not the same as unopenable (#24961)
5189+
*
5190+
* Only a file a boot that may write could bring into existence is absent. One
5191+
* that can be neither opened nor created — a path under a regular file, a
5192+
* directory this process may not write — is refused here with the
5193+
* `SqliteDatabaseFileError` naming the file and the operating system's answer,
5194+
* the refusal `SqlDriver.connect()` gives the same path on a boot that writes.
5195+
* The judgement writes nothing: see `probeAbsentSqliteDatabaseFile`
5196+
* (`sqlite-database-file.ts`), which also names the one shape it cannot see
5197+
* without a write. It used to be
5198+
* `existsSync`, which answers `false` for every path it cannot stat, so a
5199+
* read-only boot opened an empty in-memory database over a file that cannot
5200+
* exist and reported the datasource connected.
5201+
*
5202+
* @throws SqliteDatabaseFileError under `'empty-in-memory'`, for a file that
5203+
* can be neither opened nor created. Both callers get the same answer: the
5204+
* driver's constructor, and the service layer's wasm rung.
51855205
*/
51865206
export function resolveSqliteAbsentFileTarget(
51875207
filename: string,
@@ -5191,7 +5211,7 @@ export function resolveSqliteAbsentFileTarget(
51915211
if (typeof filename !== 'string' || filename === '' || filename.startsWith(':')) {
51925212
return { filename, openedEmptyInMemory: false };
51935213
}
5194-
if (existsSync(filename)) return { filename, openedEmptyInMemory: false };
5214+
if (probeAbsentSqliteDatabaseFile(filename) === 'exists') return { filename, openedEmptyInMemory: false };
51955215
return { filename: ':memory:', openedEmptyInMemory: true };
51965216
}
51975217

@@ -6437,12 +6457,22 @@ export class SqlDriver implements IDataDriver {
64376457
* the Knex instance at construction, and better-sqlite3 opens (and therefore
64386458
* creates) the file on the first statement — which for this driver is the
64396459
* `PRAGMA auto_vacuum` in {@link connect}.
6460+
*
6461+
* A file the judgement refuses — one that can be neither opened nor created
6462+
* (#24961) — throws its `SqliteDatabaseFileError` out of the constructor, so
6463+
* no Knex instance is ever built over it.
6464+
*
6465+
* Only a SQLite client is asked (#24961). A server dialect's `connection`
6466+
* string is a URL, never a filename: handed to the judgement it read as an
6467+
* absent file, and a `pg` driver given this option had its connection string
6468+
* rewritten to `:memory:` — against {@link SqlDriverConfig.sqliteAbsentFile}'s
6469+
* "ignored for non-SQLite dialects".
64406470
*/
64416471
private knexConfigFor(
64426472
knexConfig: Knex.Config,
64436473
mode: SqliteAbsentFileMode | undefined,
64446474
): Knex.Config {
6445-
if (mode !== 'empty-in-memory') return knexConfig;
6475+
if (mode !== 'empty-in-memory' || !this.isSqlite) return knexConfig;
64466476
const conn = (knexConfig as { connection?: unknown }).connection;
64476477
const declared = typeof conn === 'string' ? conn : (conn as { filename?: unknown })?.filename;
64486478
if (typeof declared !== 'string') return knexConfig;

‎packages/drivers/driver-sql/src/sqlite-database-file.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { accessSync, constants, lstatSync, statSync } from 'node:fs';
34
import { mkdir, stat } from 'node:fs/promises';
45
import { dirname, isAbsolute, resolve } from 'node:path';
56

@@ -75,3 +76,83 @@ export async function makeDatabaseDirectory(dir: string): Promise<void> {
7576
}
7677
}
7778
}
79+
80+
/**
81+
* Does the SQLite database file `filename` exist, or is it absent in a way a
82+
* boot that may write could bring into existence? Answered WITHOUT writing
83+
* anything, and thrown as a {@link SqliteDatabaseFileError} when it is neither
84+
* (#24961) — so under `sqliteAbsentFile: 'empty-in-memory'` that error comes
85+
* from `resolveSqliteAbsentFileTarget`, and from the `SqlDriver` constructor,
86+
* before any `connect()`.
87+
*
88+
* The read-only twin of {@link makeDatabaseDirectory}, for
89+
* `sqliteAbsentFile: 'empty-in-memory'`, whose contract is that the filesystem
90+
* is left untouched — so neither the directory nor the file may be created to
91+
* find out. The same walk, with the question the operating system answers
92+
* without a write in place of each `mkdir`:
93+
*
94+
* - `stat(filename)` succeeds: `'exists'`.
95+
* - `stat(filename)` answers anything but `ENOENT` — `ENOTDIR` (a regular
96+
* file holds a level of the path), `EACCES` (a level this process may not
97+
* search), `ELOOP`, `ENAMETOOLONG` — the file cannot exist, and that answer
98+
* is the refusal. `existsSync` answers `false` for every one of these, which
99+
* is how a read-only boot used to open an empty in-memory database over a
100+
* path its writing twin refuses, and report the datasource connected.
101+
* - `ENOENT`: walk up to the nearest level that exists. A level whose name a
102+
* dangling symbolic link holds cannot be made (`mkdir` answers `EEXIST`,
103+
* and nothing can be created through it): refused. Otherwise the level is a
104+
* directory — a non-directory would have answered `ENOTDIR` above — and
105+
* creating the next level, or the file, needs write and search permission
106+
* on it: `access(W_OK | X_OK)`, whose refusal (`EACCES`; `EROFS` on a
107+
* read-only mount) is the answer. Otherwise `'absent'`.
108+
*
109+
* What it cannot tell without a write: a directory whose `access` succeeds but
110+
* whose `mkdir` fails anyway. `/proc/<missing>/` as root answers `access` with
111+
* success and `mkdir` with `ENOENT`, so such a path still reads `'absent'` here;
112+
* `SqlDriver.connect()` refuses it on a boot that may write.
113+
*/
114+
export function probeAbsentSqliteDatabaseFile(filename: string): 'exists' | 'absent' {
115+
try {
116+
statSync(filename);
117+
return 'exists';
118+
} catch (e) {
119+
if (errorCode(e) !== 'ENOENT') throw new SqliteDatabaseFileError(filename, 'cannot be opened or created', e);
120+
}
121+
const dir = dirname(resolve(filename));
122+
const unmakeable = `cannot be created: its directory '${dirname(filename)}' cannot be made`;
123+
let level = dir;
124+
for (;;) {
125+
try {
126+
statSync(level);
127+
break;
128+
} catch (e) {
129+
if (errorCode(e) !== 'ENOENT') throw new SqliteDatabaseFileError(filename, unmakeable, e);
130+
if (isDanglingLink(level)) {
131+
throw new SqliteDatabaseFileError(filename, `${unmakeable} ('${level}' is a symbolic link to a path that does not exist)`, e);
132+
}
133+
const parent = dirname(level);
134+
if (parent === level) break;
135+
level = parent;
136+
}
137+
}
138+
try {
139+
accessSync(level, constants.W_OK | constants.X_OK);
140+
} catch (e) {
141+
throw new SqliteDatabaseFileError(filename, level === dir ? 'cannot be opened or created' : unmakeable, e);
142+
}
143+
return 'absent';
144+
}
145+
146+
/** A name `stat` answered `ENOENT` for, that `lstat` still finds: a dangling link. */
147+
function isDanglingLink(path: string): boolean {
148+
try {
149+
lstatSync(path);
150+
return true;
151+
} catch {
152+
return false;
153+
}
154+
}
155+
156+
function errorCode(e: unknown): unknown {
157+
return (e as { code?: unknown } | null | undefined)?.code;
158+
}

0 commit comments

Comments
 (0)