From 3370501f94ddab6019942a6cc2086c37ecf14da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:13:59 +0000 Subject: [PATCH 1/5] fix(driver-sql): the read-only absent-file judgement refuses a SQLite path that can be neither opened nor created resolveSqliteAbsentFileTarget answered ':memory:' whenever existsSync was false, and existsSync is false for every path it cannot stat: a path under a regular file (ENOTDIR), a directory this process may not search (EACCES). So a read-only boot (os migrate plan, sqliteAbsentFile: 'empty-in-memory') opened an empty in-memory database over a file that cannot exist and reported the datasource connected, while the boot that writes refuses the same path. The judgement now asks the operating system without writing anything (probeAbsentSqliteDatabaseFile, the read-only twin of makeDatabaseDirectory) and throws SqliteDatabaseFileError naming the file and the system's answer. knexConfigFor asks it for a SQLite client only. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .../src/sql-driver-sqlite-absent-file.test.ts | 147 +++++++++++++++++- packages/drivers/driver-sql/src/sql-driver.ts | 34 +++- .../driver-sql/src/sqlite-database-file.ts | 86 +++++++++- 3 files changed, 260 insertions(+), 7 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts b/packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts index e1f3e3bcd49..a6e64ae37cf 100644 --- a/packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts @@ -16,7 +16,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs'; +import { mkdtempSync, rmSync, existsSync, readdirSync, writeFileSync, mkdirSync, chmodSync, lstatSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { SqlDriver, resolveSqliteAbsentFileTarget } from './index.js'; @@ -160,6 +160,15 @@ describe('SqlDriver({ sqliteAbsentFile }) — opening must not create (#6743)', } as any); expect(((pg as any).config as any).sqliteAbsentFile).toBeUndefined(); expect(pg.sqliteOpenedEmptyInMemory).toBe(false); + + // A server dialect's connection STRING is a URL, not a filename (#24961): + // it is never handed to the absent-file judgement, so it is neither + // rewritten to `:memory:` nor walked on disk. + const url = 'postgres://app@db.example:5432/main'; + const pgUrl = new SqlDriver({ client: 'pg', connection: url, sqliteAbsentFile: 'empty-in-memory' } as any); + expect((pgUrl as any).knex.client.config.connection).toMatchObject({ connectionString: url }); + expect(pgUrl.sqliteOpenedEmptyInMemory).toBe(false); + await pgUrl.disconnect(); }); it('a directory that already exists is not disturbed', async () => { @@ -175,3 +184,139 @@ describe('SqlDriver({ sqliteAbsentFile }) — opening must not create (#6743)', expect(readdirSync(dataDir)).toEqual(['unrelated.txt']); }); }); + +/** + * An unopenable path is not an absent one (#24961). + * + * `existsSync` answers `false` for every path it cannot stat, so the judgement + * used to read a path under a regular file as an absent file and open an empty + * `:memory:` database over it: `os migrate plan` reported the datasource + * connected and planned tables into a file that cannot exist, while the boot + * that writes refused the same path. The judgement now refuses it, naming the + * file and the operating system's answer — and still writes nothing to find + * out. + */ +describe('resolveSqliteAbsentFileTarget — a file that can be neither opened nor created is refused (#24961)', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-24961-')); + writeFileSync(join(dir, 'plain'), 'a regular file, not a directory'); + }); + afterEach(() => { + try { chmodSync(join(dir, 'locked'), 0o755); } catch { /* not every case makes it */ } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + /** What `fn` threw; a failure naming its answer when it threw nothing. */ + function thrownBy(fn: () => unknown): any { + let answer: unknown; + try { + answer = fn(); + } catch (error) { + return error; + } + throw new Error(`answered ${JSON.stringify(answer)} for a file that can be neither opened nor created`); + } + + /** The driver's refusal of the file: its own error name, the system's code, and the file. */ + function expectFileRefusal(error: any, filename: string, code: string): void { + expect(error?.name).toBe('SqliteDatabaseFileError'); + expect(error?.code).toBe(code); + expect(error?.filename).toBe(filename); + } + + it('refuses a path under a regular file with ENOTDIR, at either depth, and writes nothing', () => { + const before = readdirSync(dir).sort(); + + const at = join(dir, 'plain', 'archive.db'); + expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(at, 'empty-in-memory')), at, 'ENOTDIR'); + const through = join(dir, 'plain', 'sub', 'archive.db'); + expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(through, 'empty-in-memory')), through, 'ENOTDIR'); + + expect(readdirSync(dir).sort()).toEqual(before); + }); + + it('refuses a directory whose name a dangling symbolic link holds, and leaves the link dangling', () => { + const link = join(dir, 'link'); + symlinkSync(join(dir, 'nowhere'), link); + + const filename = join(link, 'archive.db'); + expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory')), filename, 'ENOENT'); + + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(existsSync(join(dir, 'nowhere'))).toBe(false); + }); + + // Permission is judged as the process holds it, so the case splits on who + // runs it: a superuser may create in a 0555 directory, and the writing boot + // does — so for it the absent answer is the consistent one. + const superuser = typeof process.getuid === 'function' && process.getuid() === 0; + + it.skipIf(superuser)('refuses an absent file in a directory this process may not write, with EACCES', () => { + const locked = join(dir, 'locked'); + mkdirSync(locked); + chmodSync(locked, 0o555); + + const filename = join(locked, 'archive.db'); + expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory')), filename, 'EACCES'); + const deeper = join(locked, 'sub', 'archive.db'); + expectFileRefusal(thrownBy(() => resolveSqliteAbsentFileTarget(deeper, 'empty-in-memory')), deeper, 'EACCES'); + + expect(readdirSync(locked)).toEqual([]); + }); + + it.runIf(superuser)('as the superuser, an absent file in a 0555 directory is absent: the writing boot can create it', () => { + const locked = join(dir, 'locked'); + mkdirSync(locked); + chmodSync(locked, 0o555); + + expect(resolveSqliteAbsentFileTarget(join(locked, 'archive.db'), 'empty-in-memory')) + .toEqual({ filename: ':memory:', openedEmptyInMemory: true }); + expect(readdirSync(locked)).toEqual([]); + }); + + it('CONTROL: an absent file in a directory that exists, or can be made, is still absent and writes nothing', () => { + const before = readdirSync(dir).sort(); + expect(resolveSqliteAbsentFileTarget(join(dir, 'fresh.db'), 'empty-in-memory')) + .toEqual({ filename: ':memory:', openedEmptyInMemory: true }); + expect(resolveSqliteAbsentFileTarget(join(dir, 'new', 'deeper', 'fresh.db'), 'empty-in-memory')) + .toEqual({ filename: ':memory:', openedEmptyInMemory: true }); + expect(readdirSync(dir).sort()).toEqual(before); + }); + + it('only empty-in-memory asks: the default mode hands the path on for connect() to refuse', () => { + const at = join(dir, 'plain', 'archive.db'); + expect(resolveSqliteAbsentFileTarget(at, 'create')).toEqual({ filename: at, openedEmptyInMemory: false }); + expect(resolveSqliteAbsentFileTarget(at, undefined)).toEqual({ filename: at, openedEmptyInMemory: false }); + }); + + it('SqlDriver under empty-in-memory refuses at construction with the same error the writing boot\'s connect() names the file with', async () => { + const before = readdirSync(dir).sort(); + const at = join(dir, 'plain', 'archive.db'); + const build = (mode?: 'empty-in-memory') => + new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: at }, + useNullAsDefault: true, + ...(mode ? { sqliteAbsentFile: mode } : {}), + } as any); + + // The read-only boot: no driver over a file that cannot exist. + expectFileRefusal(thrownBy(() => build('empty-in-memory')), at, 'ENOTDIR'); + + // The boot that may write, over the same path: its connect() refuses it, + // as it did before (#23696) — so the two boots now agree. + const writing = build(); + let refusal: unknown; + try { + await writing.connect().catch((e: unknown) => { refusal = e; }); + } finally { + await writing.disconnect(); + } + expect((refusal as any)?.name).toBe('SqliteDatabaseFileError'); + expect((refusal as any)?.filename).toBe(at); + + expect(readdirSync(dir).sort()).toEqual(before); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index b4393f25d41..5f1bc97e078 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -156,13 +156,12 @@ import { type UndeliveredAttribute, } from './builtin-column-collision.js'; import { recoverUnencodedJsonText } from './unencoded-json-text.js'; -import { makeDatabaseDirectory, SqliteDatabaseFileError } from './sqlite-database-file.js'; +import { makeDatabaseDirectory, probeAbsentSqliteDatabaseFile, SqliteDatabaseFileError } from './sqlite-database-file.js'; import { jsonMembershipPredicate } from '@objectstack/core'; import knex, { Knex } from 'knex'; import { nanoid } from 'nanoid'; import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash } from 'node:crypto'; -import { existsSync } from 'node:fs'; import { currentPerfTiming, perfNow, type PerfTiming } from '@objectstack/observability'; // [#21007] The JSON-column gate's operator set and refusal text — shared with // `@objectstack/objectql`'s per-aggregation `filter`, which refuses the same @@ -5182,6 +5181,23 @@ export type SqliteAbsentFileMode = 'create' | 'empty-in-memory'; * * Pseudo-filenames (`:memory:` and anything else `:`-prefixed) and non-file * dialects are already fileless, so they are returned untouched. + * + * ## Absent is not the same as unopenable (#24961) + * + * Only a file a boot that may write could bring into existence is absent. One + * that can be neither opened nor created — a path under a regular file, a + * directory this process may not write — is refused here with the + * `SqliteDatabaseFileError` naming the file and the operating system's answer, + * the refusal `SqlDriver.connect()` gives the same path on a boot that writes. + * The judgement writes nothing: see {@link probeAbsentSqliteDatabaseFile}, + * which also names the one shape it cannot see without a write. It used to be + * `existsSync`, which answers `false` for every path it cannot stat, so a + * read-only boot opened an empty in-memory database over a file that cannot + * exist and reported the datasource connected. + * + * @throws SqliteDatabaseFileError under `'empty-in-memory'`, for a file that + * can be neither opened nor created. Both callers get the same answer: the + * driver's constructor, and the service layer's wasm rung. */ export function resolveSqliteAbsentFileTarget( filename: string, @@ -5191,7 +5207,7 @@ export function resolveSqliteAbsentFileTarget( if (typeof filename !== 'string' || filename === '' || filename.startsWith(':')) { return { filename, openedEmptyInMemory: false }; } - if (existsSync(filename)) return { filename, openedEmptyInMemory: false }; + if (probeAbsentSqliteDatabaseFile(filename) === 'exists') return { filename, openedEmptyInMemory: false }; return { filename: ':memory:', openedEmptyInMemory: true }; } @@ -6437,12 +6453,22 @@ export class SqlDriver implements IDataDriver { * the Knex instance at construction, and better-sqlite3 opens (and therefore * creates) the file on the first statement — which for this driver is the * `PRAGMA auto_vacuum` in {@link connect}. + * + * A file the judgement refuses — one that can be neither opened nor created + * (#24961) — throws its `SqliteDatabaseFileError` out of the constructor, so + * no Knex instance is ever built over it. + * + * Only a SQLite client is asked (#24961). A server dialect's `connection` + * string is a URL, never a filename: handed to the judgement it read as an + * absent file, and a `pg` driver given this option had its connection string + * rewritten to `:memory:` — against {@link SqlDriverConfig.sqliteAbsentFile}'s + * "ignored for non-SQLite dialects". */ private knexConfigFor( knexConfig: Knex.Config, mode: SqliteAbsentFileMode | undefined, ): Knex.Config { - if (mode !== 'empty-in-memory') return knexConfig; + if (mode !== 'empty-in-memory' || !this.isSqlite) return knexConfig; const conn = (knexConfig as { connection?: unknown }).connection; const declared = typeof conn === 'string' ? conn : (conn as { filename?: unknown })?.filename; if (typeof declared !== 'string') return knexConfig; diff --git a/packages/drivers/driver-sql/src/sqlite-database-file.ts b/packages/drivers/driver-sql/src/sqlite-database-file.ts index f61d8d5618e..4e4cd51584e 100644 --- a/packages/drivers/driver-sql/src/sqlite-database-file.ts +++ b/packages/drivers/driver-sql/src/sqlite-database-file.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { accessSync, constants, lstatSync, statSync } from 'node:fs'; import { mkdir, stat } from 'node:fs/promises'; import { dirname, isAbsolute, resolve } from 'node:path'; @@ -9,8 +10,11 @@ import { dirname, isAbsolute, resolve } from 'node:path'; * Thrown by `SqlDriver.connect()`, so the boot's connect refusal (ADR-0062 D5, * `datasource '': connect failed — `) carries the path and the * operating system's own answer instead of waiting for a plugin start timeout. - * `code` is the underlying error's (`ENOENT`, `EACCES`, `ENOTDIR`, - * `SQLITE_CANTOPEN`, …) and `cause` the error itself. + * Under `sqliteAbsentFile: 'empty-in-memory'` it is thrown earlier, by + * {@link probeAbsentSqliteDatabaseFile} — so by `resolveSqliteAbsentFileTarget` + * and the `SqlDriver` constructor — for a file that can be neither opened nor + * created (#24961). `code` is the underlying error's (`ENOENT`, `EACCES`, + * `ENOTDIR`, `SQLITE_CANTOPEN`, …) and `cause` the error itself. */ export class SqliteDatabaseFileError extends Error { readonly code: string | undefined; @@ -75,3 +79,81 @@ export async function makeDatabaseDirectory(dir: string): Promise { } } } + +/** + * Does the SQLite database file `filename` exist, or is it absent in a way a + * boot that may write could bring into existence? Answered WITHOUT writing + * anything, and thrown as a {@link SqliteDatabaseFileError} when it is neither + * (#24961). + * + * The read-only twin of {@link makeDatabaseDirectory}, for + * `sqliteAbsentFile: 'empty-in-memory'`, whose contract is that the filesystem + * is left untouched — so neither the directory nor the file may be created to + * find out. The same walk, with the question the operating system answers + * without a write in place of each `mkdir`: + * + * - `stat(filename)` succeeds: `'exists'`. + * - `stat(filename)` answers anything but `ENOENT` — `ENOTDIR` (a regular + * file holds a level of the path), `EACCES` (a level this process may not + * search), `ELOOP`, `ENAMETOOLONG` — the file cannot exist, and that answer + * is the refusal. `existsSync` answers `false` for every one of these, which + * is how a read-only boot used to open an empty in-memory database over a + * path its writing twin refuses, and report the datasource connected. + * - `ENOENT`: walk up to the nearest level that exists. A level whose name a + * dangling symbolic link holds cannot be made (`mkdir` answers `EEXIST`, + * and nothing can be created through it): refused. Otherwise the level is a + * directory — a non-directory would have answered `ENOTDIR` above — and + * creating the next level, or the file, needs write and search permission + * on it: `access(W_OK | X_OK)`, whose refusal (`EACCES`; `EROFS` on a + * read-only mount) is the answer. Otherwise `'absent'`. + * + * What it cannot tell without a write: a directory whose `access` succeeds but + * whose `mkdir` fails anyway. `/proc//` as root answers `access` with + * success and `mkdir` with `ENOENT`, so such a path still reads `'absent'` here; + * `SqlDriver.connect()` refuses it on a boot that may write. + */ +export function probeAbsentSqliteDatabaseFile(filename: string): 'exists' | 'absent' { + try { + statSync(filename); + return 'exists'; + } catch (e) { + if (errorCode(e) !== 'ENOENT') throw new SqliteDatabaseFileError(filename, 'cannot be opened or created', e); + } + const dir = dirname(resolve(filename)); + const unmakeable = `cannot be created: its directory '${dirname(filename)}' cannot be made`; + let level = dir; + for (;;) { + try { + statSync(level); + break; + } catch (e) { + if (errorCode(e) !== 'ENOENT') throw new SqliteDatabaseFileError(filename, unmakeable, e); + if (isDanglingLink(level)) { + throw new SqliteDatabaseFileError(filename, `${unmakeable} ('${level}' is a symbolic link to a path that does not exist)`, e); + } + const parent = dirname(level); + if (parent === level) break; + level = parent; + } + } + try { + accessSync(level, constants.W_OK | constants.X_OK); + } catch (e) { + throw new SqliteDatabaseFileError(filename, level === dir ? 'cannot be opened or created' : unmakeable, e); + } + return 'absent'; +} + +/** A name `stat` answered `ENOENT` for, that `lstat` still finds: a dangling link. */ +function isDanglingLink(path: string): boolean { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} + +function errorCode(e: unknown): unknown { + return (e as { code?: unknown } | null | undefined)?.code; +} From 28ea203e54b678abf76af30167a18b949c77a7b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:18:10 +0000 Subject: [PATCH 2/5] test(service-datasource): pin the read-only boot's refusal of an unopenable SQLite path on every rung; changeset The read-only boot os migrate plan composes (createDefaultDatasourceDriverFactory with sqliteAbsentFile: 'empty-in-memory') reports a declared SQLite datasource under a regular file as failed, naming the file, and registers no driver; the native rung, the wasm rung and production all reject with the driver's SqliteDatabaseFileError (ENOTDIR), and an absent file that can be created still opens empty in memory on both rungs with nothing written. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .../24961-readonly-probe-unopenable-path.md | 18 ++ packages/drivers/driver-sql/src/sql-driver.ts | 10 +- ...river-fallback-readonly-unopenable.test.ts | 201 ++++++++++++++++++ 3 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 .changeset/24961-readonly-probe-unopenable-path.md create mode 100644 packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts diff --git a/.changeset/24961-readonly-probe-unopenable-path.md b/.changeset/24961-readonly-probe-unopenable-path.md new file mode 100644 index 00000000000..cc5f34b7e8b --- /dev/null +++ b/.changeset/24961-readonly-probe-unopenable-path.md @@ -0,0 +1,18 @@ +--- +'@objectstack/driver-sql': patch +--- + +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 + +Clause-②: no (narrowing) + + + +`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 '': connected` for a declared datasource on such a path and planned `create_table` into it, while the boot that writes refused the same path. + +**BREAKING** (the read-only boot's accept set narrows), shipped as `patch`: no exported declaration changes. + +- **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. +- **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. +- **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:`). +- **Fix:** if a read-only boot now refuses a SQLite datasource, correct its `filename`: the refusal names the path and the reason. diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 5f1bc97e078..dea47a1a378 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -5158,7 +5158,10 @@ export type SqliteJournalMode = 'wal' | 'delete'; * database instead, which introspects as a database with zero tables — the * same thing a freshly-created empty file introspects as. For a read-only * caller (`os migrate plan`) the report is therefore identical while the - * filesystem is left untouched. + * filesystem is left untouched. Only a file that `'create'` could bring into + * existence counts as absent: one that can be neither opened nor created is + * refused, as `'create'`'s connect refuses it (#24961) — see + * {@link resolveSqliteAbsentFileTarget}. * * `'empty-in-memory'` is for callers that only ever READ. A caller that may * write MUST leave it at `'create'`: writes land in the ephemeral database and @@ -5189,8 +5192,9 @@ export type SqliteAbsentFileMode = 'create' | 'empty-in-memory'; * directory this process may not write — is refused here with the * `SqliteDatabaseFileError` naming the file and the operating system's answer, * the refusal `SqlDriver.connect()` gives the same path on a boot that writes. - * The judgement writes nothing: see {@link probeAbsentSqliteDatabaseFile}, - * which also names the one shape it cannot see without a write. It used to be + * The judgement writes nothing: see `probeAbsentSqliteDatabaseFile` + * (`sqlite-database-file.ts`), which also names the one shape it cannot see + * without a write. It used to be * `existsSync`, which answers `false` for every path it cannot stat, so a * read-only boot opened an empty in-memory database over a file that cannot * exist and reported the datasource connected. diff --git a/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts b/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts new file mode 100644 index 00000000000..17e2f82fc3b --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The read-only boot reports a declared SQLite datasource whose file can be + * neither opened nor created as FAILED, as the boot that writes does (#24961). + * + * `os migrate plan` composes its declared datasources through + * `createDefaultDatasourceDriverFactory({ sqliteAbsentFile: 'empty-in-memory' })` + * (`DeclaredDatasourceConnectionPlugin`). Before, driver-sql's absent-file + * judgement asked `existsSync`, which answers `false` for a path under a + * regular file, so the boot opened an empty in-memory database over it: + * `datasource 'archive': connected`, and the plan proposed `create_table` into + * a file that cannot exist. The judgement now refuses the file, and the + * refusal reaches the connection service as the boot's connect failure. + * + * Both rungs of `resolveSqliteDriver` take the judgement from the driver: the + * native driver's constructor applies it, and the wasm rung resolves its + * filename through the same exported `resolveSqliteAbsentFileTarget`. So the + * cases below are asked of the native rung, of the wasm rung (an addon that + * cannot load, faked where `better-sqlite3` loads it, as + * `sqlite-driver-fallback-file-open-failure.test.ts` does), and of production. + * + * Real `better-sqlite3`, real wasm SQLite, real files in a scratch directory. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveSqliteDriver, type ResolvedSqliteDriver } from '../sqlite-driver-fallback.js'; +import { DatasourceConnectionService } from '../datasource-connection-service.js'; +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; + +let dir: string; +const opened: ResolvedSqliteDriver[] = []; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-24961-')); + writeFileSync(join(dir, 'plain'), 'a regular file, not a directory'); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(opened.splice(0).map((r) => r.driver?.disconnect?.().catch(() => {}))); + rmSync(dir, { recursive: true, force: true }); +}); + +/** Every name in the scratch directory: what "nothing was written" is judged against. */ +function listing(): string[] { + return readdirSync(dir).sort(); +} + +async function resolveReadOnly(filename: string, dev: boolean, warn = vi.fn()): Promise { + const resolved = await resolveSqliteDriver({ filename, dev, warn, sqliteAbsentFile: 'empty-in-memory' }); + opened.push(resolved); + return resolved; +} + +/** `promise`'s rejection; a failure naming the engine when it resolves instead. */ +async function rejectionOf(promise: Promise): Promise { + let resolved: ResolvedSqliteDriver; + try { + resolved = await promise; + } catch (error) { + return error; + } + opened.push(resolved); + throw new Error(`resolved to engine '${resolved.engine}' for a file that can be neither opened nor created`); +} + +/** The driver's refusal of the file: its own error name, the system's code, and the file. */ +function expectFileRefusal(error: any, filename: string, code: string): void { + expect(error?.name).toBe('SqliteDatabaseFileError'); + expect(error?.code).toBe(code); + expect(error?.filename).toBe(filename); +} + +/** A real `ERR_DLOPEN_FAILED`: Node asked to load, as an addon, a file that is not one. */ +function realDlopenFailure(): Error { + const scratch = mkdtempSync(join(tmpdir(), 'os-24961-addon-')); + const notAnAddon = join(scratch, 'not-an-addon.node'); + writeFileSync(notAnAddon, 'x'); + try { + process.dlopen({ exports: {} }, notAnAddon); + } catch (error) { + return error as Error; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + throw new Error('process.dlopen loaded a file that is not an addon'); +} + +/** + * Make the native addon fail to load where `better-sqlite3` loads it: the knex + * better-sqlite3 client's `acquireRawConnection`. Wasm SQLite's client is + * another class, so the wasm rung is untouched. + */ +async function failNativeAddonLoad(): Promise { + const error = realDlopenFailure(); + expect((error as any).code).toBe('ERR_DLOPEN_FAILED'); + const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + const clientProto = Object.getPrototypeOf((probe as any).knex.client); + await probe.disconnect(); + expect(Object.prototype.hasOwnProperty.call(clientProto, 'acquireRawConnection')).toBe(true); + vi.spyOn(clientProto, 'acquireRawConnection').mockRejectedValue(error); +} + +describe('resolveSqliteDriver under empty-in-memory: a path under a regular file is refused, on every rung (#24961)', () => { + it('development, native rung: rejects with the driver\'s refusal, never an engine, and writes nothing', async () => { + const before = listing(); + const warn = vi.fn(); + const at = join(dir, 'plain', 'archive.db'); + expectFileRefusal(await rejectionOf(resolveReadOnly(at, true, warn)), at, 'ENOTDIR'); + const through = join(dir, 'plain', 'sub', 'archive.db'); + expectFileRefusal(await rejectionOf(resolveReadOnly(through, true, warn)), through, 'ENOTDIR'); + expect(warn).not.toHaveBeenCalled(); + expect(listing()).toEqual(before); + }); + + it('development, wasm rung (the addon cannot load): the same refusal, never wasm or memory', async () => { + await failNativeAddonLoad(); + const before = listing(); + const warn = vi.fn(); + const at = join(dir, 'plain', 'archive.db'); + expectFileRefusal(await rejectionOf(resolveReadOnly(at, true, warn)), at, 'ENOTDIR'); + expect(warn).not.toHaveBeenCalled(); + expect(listing()).toEqual(before); + }); + + it('production: the same refusal — the unprobed native driver is never built over the file', async () => { + const before = listing(); + const at = join(dir, 'plain', 'archive.db'); + expectFileRefusal(await rejectionOf(resolveReadOnly(at, false)), at, 'ENOTDIR'); + expect(listing()).toEqual(before); + }); +}); + +describe('resolveSqliteDriver under empty-in-memory: an absent file that can be created still opens empty in memory (#6743)', () => { + it('development, native rung: an empty in-memory database, and nothing on disk', async () => { + const before = listing(); + for (const filename of [join(dir, 'fresh.db'), join(dir, 'new', 'deeper', 'fresh.db')]) { + const resolved = await resolveReadOnly(filename, true); + expect(resolved.engine).toBe('better-sqlite3'); + expect(resolved.driver.sqliteOpenedEmptyInMemory).toBe(true); + expect(await resolved.driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]); + } + expect(listing()).toEqual(before); + }); + + it('development, wasm rung (the addon cannot load): wasm opens :memory:, and nothing on disk', async () => { + await failNativeAddonLoad(); + const before = listing(); + const resolved = await resolveReadOnly(join(dir, 'new', 'deeper', 'fresh.db'), true); + expect(resolved.engine).toBe('sqlite-wasm'); + expect(await resolved.driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]); + await resolved.driver.disconnect(); + expect(listing()).toEqual(before); + }); +}); + +describe('the read-only boot os migrate plan composes: a declared SQLite datasource under a regular file (#24961)', () => { + it('its factory rejects with the driver\'s refusal — the value the connection service receives', async () => { + const factory = createDefaultDatasourceDriverFactory({ dev: true, sqliteAbsentFile: 'empty-in-memory' }); + const filename = join(dir, 'plain', 'archive.db'); + let refusal: unknown; + await factory + .create({ name: 'archive', driver: 'sqlite', config: { filename } } as any) + .then((handle) => { opened.push({ driver: handle.driver ?? handle } as any); }) + .catch((e: unknown) => { refusal = e; }); + expectFileRefusal(refusal, filename, 'ENOTDIR'); + }); + + it.each([ + ['development', true], + ['production', false], + ] as const)('%s: the datasource reads failed, naming the file; no driver is registered; nothing is written', async (_label, dev) => { + const registered: string[] = []; + const engine = { + registerDriver: (driver: { name?: string }) => { registered.push(String(driver?.name)); }, + getDriverByName: (name: string) => (registered.includes(name) ? {} : undefined), + }; + const service = new DatasourceConnectionService({ + factory: () => createDefaultDatasourceDriverFactory({ dev, sqliteAbsentFile: 'empty-in-memory' }), + engine: () => engine as any, + }); + const filename = join(dir, 'plain', 'archive.db'); + const before = listing(); + + await service.connectDeclared({ + datasources: [{ name: 'archive', driver: 'sqlite', config: { filename }, autoConnect: true }], + }); + + const state = service.getConnectionState('archive'); + expect(state?.availability).toBe('failed'); + expect(state?.reason).toContain(filename); + // Nothing a plan could be computed against: no driver under the name. + expect(registered).toEqual([]); + expect(listing()).toEqual(before); + }); +}); From 02302719e160e3c6ee7405a4539dbf5d3fda8767 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:20:42 +0000 Subject: [PATCH 3/5] docs(driver-sql): say where the read-only judgement's refusal is thrown from on the probe, leaving SqliteDatabaseFileError's own doc as it stands Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .../drivers/driver-sql/src/sqlite-database-file.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/drivers/driver-sql/src/sqlite-database-file.ts b/packages/drivers/driver-sql/src/sqlite-database-file.ts index 4e4cd51584e..fbde48ea8f7 100644 --- a/packages/drivers/driver-sql/src/sqlite-database-file.ts +++ b/packages/drivers/driver-sql/src/sqlite-database-file.ts @@ -10,11 +10,8 @@ import { dirname, isAbsolute, resolve } from 'node:path'; * Thrown by `SqlDriver.connect()`, so the boot's connect refusal (ADR-0062 D5, * `datasource '': connect failed — `) carries the path and the * operating system's own answer instead of waiting for a plugin start timeout. - * Under `sqliteAbsentFile: 'empty-in-memory'` it is thrown earlier, by - * {@link probeAbsentSqliteDatabaseFile} — so by `resolveSqliteAbsentFileTarget` - * and the `SqlDriver` constructor — for a file that can be neither opened nor - * created (#24961). `code` is the underlying error's (`ENOENT`, `EACCES`, - * `ENOTDIR`, `SQLITE_CANTOPEN`, …) and `cause` the error itself. + * `code` is the underlying error's (`ENOENT`, `EACCES`, `ENOTDIR`, + * `SQLITE_CANTOPEN`, …) and `cause` the error itself. */ export class SqliteDatabaseFileError extends Error { readonly code: string | undefined; @@ -84,7 +81,9 @@ export async function makeDatabaseDirectory(dir: string): Promise { * Does the SQLite database file `filename` exist, or is it absent in a way a * boot that may write could bring into existence? Answered WITHOUT writing * anything, and thrown as a {@link SqliteDatabaseFileError} when it is neither - * (#24961). + * (#24961) — so under `sqliteAbsentFile: 'empty-in-memory'` that error comes + * from `resolveSqliteAbsentFileTarget`, and from the `SqlDriver` constructor, + * before any `connect()`. * * The read-only twin of {@link makeDatabaseDirectory}, for * `sqliteAbsentFile: 'empty-in-memory'`, whose contract is that the filesystem From 7c66e98f6479752dfdb8c3798716a4df5144f856 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:27:07 +0000 Subject: [PATCH 4/5] test(service-datasource): await the factory's create in the read-only refusal pin, so tsc reads it Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .../sqlite-driver-fallback-readonly-unopenable.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts b/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts index 17e2f82fc3b..44bdc1a71f0 100644 --- a/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts +++ b/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts @@ -164,10 +164,12 @@ describe('the read-only boot os migrate plan composes: a declared SQLite datasou const factory = createDefaultDatasourceDriverFactory({ dev: true, sqliteAbsentFile: 'empty-in-memory' }); const filename = join(dir, 'plain', 'archive.db'); let refusal: unknown; - await factory - .create({ name: 'archive', driver: 'sqlite', config: { filename } } as any) - .then((handle) => { opened.push({ driver: handle.driver ?? handle } as any); }) - .catch((e: unknown) => { refusal = e; }); + try { + const handle = await factory.create({ name: 'archive', driver: 'sqlite', config: { filename } } as any); + opened.push({ driver: handle.driver ?? handle } as any); + } catch (e) { + refusal = e; + } expectFileRefusal(refusal, filename, 'ENOTDIR'); }); From 86d0cbfb7eae953f633478be25b462a8bc5ae8ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:56:30 +0000 Subject: [PATCH 5/5] chore(changeset): grade the 24961 read-only boot narrowing minor, as the launch window ships a BREAKING accept-set narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset declared Clause-② no (narrowing) and graded driver-sql patch, which the Check Changeset level axis refuses: a narrowing is BREAKING, and during the launch window a BREAKING change ships minor. Raise driver-sql to minor and say so in the BREAKING banner, in the wording the other launch-window narrowings use. No other file changes. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .changeset/24961-readonly-probe-unopenable-path.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/24961-readonly-probe-unopenable-path.md b/.changeset/24961-readonly-probe-unopenable-path.md index cc5f34b7e8b..37b710bc3f5 100644 --- a/.changeset/24961-readonly-probe-unopenable-path.md +++ b/.changeset/24961-readonly-probe-unopenable-path.md @@ -1,5 +1,5 @@ --- -'@objectstack/driver-sql': patch +'@objectstack/driver-sql': minor --- 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 @@ -10,7 +10,7 @@ Clause-②: no (narrowing) `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 '': connected` for a declared datasource on such a path and planned `create_table` into it, while the boot that writes refused the same path. -**BREAKING** (the read-only boot's accept set narrows), shipped as `patch`: no exported declaration changes. +**BREAKING** (the read-only boot's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. - **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. - **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.