From f60714feb0c84dd1be9d00f5d260ab5e3da8cbed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:29:32 +0000 Subject: [PATCH 1/4] fix(driver-sql): a sqlite file that cannot be created or opened fails connect at once, naming it Node's recursive mkdir never settles on a path such as /proc/x/ (mkdir ENOENT, parent EEXIST, retry), so a declared sqlite datasource there never finished connecting and the boot waited for a plugin start timeout. The directory is now made by a walk bounded by the path's depth, and a SQLITE_CANTOPEN on the connect's first statement fails the connect instead of being logged and swallowed. Claude-Session: https://claude.ai/code/session_01CxHmGdRW1sJtL25b6ysocg Co-authored-by: Claude --- packages/drivers/driver-sql/src/sql-driver.ts | 22 +++++- .../driver-sql/src/sqlite-database-file.ts | 72 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sqlite-database-file.ts diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 8d6fdad4ac5..4349b8b5bdd 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -155,6 +155,7 @@ import { type UndeliveredAttribute, } from './builtin-column-collision.js'; import { recoverUnencodedJsonText } from './unencoded-json-text.js'; +import { makeDatabaseDirectory, SqliteDatabaseFileError } from './sqlite-database-file.js'; import { jsonMembershipPredicate } from '@objectstack/core'; import knex, { Knex } from 'knex'; import { nanoid } from 'nanoid'; @@ -7001,6 +7002,16 @@ export class SqlDriver implements IDataDriver { // let the step-down message that follows explain the outcome. Any OTHER // PRAGMA failure keeps the full warning (with stack) as before. const code = (e as { code?: string } | null | undefined)?.code; + // The file itself cannot be opened or created (#23696): a directory that + // exists but refuses the file, a path SQLite cannot open. Every later + // statement would answer the same, so the connect fails here, naming the + // file — before, it logged and resolved, and the datasource counted as + // connected while it could not answer one query. + if (code === 'SQLITE_CANTOPEN') { + const declared = (this.config as any).connection; + const filename = this.sqliteFilename() ?? String(typeof declared === 'string' ? declared : declared?.filename); + throw new SqliteDatabaseFileError(filename, 'cannot be opened or created', e); + } const msg = e instanceof Error ? e.message : String(e); const isNativeLoadFailure = code === 'ERR_DLOPEN_FAILED' || @@ -20105,15 +20116,20 @@ export class SqlDriver implements IDataDriver { protected async ensureDatabaseExists() { // SQLite auto-creates database files but NOT parent directories. - // Ensure the directory exists so better-sqlite3 can create the file. + // Ensure the directory exists so better-sqlite3 can create the file — with + // a bounded walk, never Node's recursive mkdir, which loops forever on a + // path like `/proc/x/` (#23696, see `makeDatabaseDirectory`). if (this.isSqlite) { const filename = this.sqliteFilename(); if (filename) { const { dirname } = await import('node:path'); - const { mkdir } = await import('node:fs/promises'); const dir = dirname(filename); if (dir && dir !== '.') { - await mkdir(dir, { recursive: true }); + try { + await makeDatabaseDirectory(dir); + } catch (e) { + throw new SqliteDatabaseFileError(filename, `cannot be created: its directory '${dir}' could not be made`, e); + } } } return; diff --git a/packages/drivers/driver-sql/src/sqlite-database-file.ts b/packages/drivers/driver-sql/src/sqlite-database-file.ts new file mode 100644 index 00000000000..465a760297d --- /dev/null +++ b/packages/drivers/driver-sql/src/sqlite-database-file.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { mkdir, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, resolve } from 'node:path'; + +/** + * A SQLite database file this driver cannot open or create (#23696). + * + * 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. + */ +export class SqliteDatabaseFileError extends Error { + readonly code: string | undefined; + readonly filename: string; + + constructor(filename: string, detail: string, cause: unknown) { + const reason = cause instanceof Error ? cause.message : String(cause); + const code = (cause as { code?: unknown } | null | undefined)?.code; + const where = isAbsolute(filename) ? `'${filename}'` : `'${filename}' (resolved: '${resolve(filename)}')`; + super(`SQLite database file ${where} ${detail}: ${reason}`, { cause }); + this.name = 'SqliteDatabaseFileError'; + this.code = typeof code === 'string' ? code : undefined; + this.filename = filename; + } +} + +/** + * Make `dir` exist, creating each missing level with a NON-recursive `mkdir`, + * and reject at once with the first level the operating system refuses. + * + * ## Why not `mkdir(dir, { recursive: true })` + * + * Node's recursive mkdir (v22.22.0, measured; sync and async alike) never + * settles when a level answers `ENOENT` although its parent exists. On + * `/proc/os-23696` it loops `mkdir("/proc/os-23696") = ENOENT`, + * `mkdir("/proc") = EEXIST`, `statx("/proc")` (a directory, so retry the + * child) without end — about 1,640 failing `mkdir` calls a second, all of + * it system CPU. A declared SQLite datasource whose directory lay on such a + * path never finished its connect, and the boot was refused only by a plugin + * start timeout that named neither the datasource nor the path. + * + * The walk below is bounded by the path's depth: find the nearest level that + * exists, then create the missing ones top-down. `EEXIST` on a level is a + * concurrent creator winning the race and is accepted; any other refusal is + * the answer. + */ +export async function makeDatabaseDirectory(dir: string): Promise { + const missing: string[] = []; + let level = resolve(dir); + for (;;) { + try { + if ((await stat(level)).isDirectory()) break; + throw Object.assign(new Error(`ENOTDIR: not a directory, mkdir '${level}'`), { code: 'ENOTDIR' }); + } catch (e) { + if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e; + missing.push(level); + const parent = dirname(level); + if (parent === level) break; + level = parent; + } + } + for (const each of missing.reverse()) { + try { + await mkdir(each); + } catch (e) { + if ((e as { code?: unknown } | null)?.code !== 'EEXIST') throw e; + } + } +} From fa0158df7dfcf8524acd986126b1c76222e2ed12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:31:54 +0000 Subject: [PATCH 2/4] test(driver-sql): pin the immediate, named refusal of an uncreatable or unopenable sqlite file; changeset Claude-Session: https://claude.ai/code/session_01CxHmGdRW1sJtL25b6ysocg Co-authored-by: Claude --- .changeset/23696-sqlite-uncreatable-path.md | 16 +++ ...iver-23696-sqlite-uncreatable-file.test.ts | 105 ++++++++++++++++++ .../driver-sql/src/sqlite-database-file.ts | 2 +- 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 .changeset/23696-sqlite-uncreatable-path.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts diff --git a/.changeset/23696-sqlite-uncreatable-path.md b/.changeset/23696-sqlite-uncreatable-path.md new file mode 100644 index 00000000000..c65d3310cb2 --- /dev/null +++ b/.changeset/23696-sqlite-uncreatable-path.md @@ -0,0 +1,16 @@ +--- +'@objectstack/driver-sql': patch +--- + +fix(driver-sql): a SQLite database file the driver cannot create or open fails `connect()` at once, naming the file and the operating system's error (#23696) + +Clause-②: no + +A declared SQLite datasource whose `config.filename` lay under a directory the operating system will not make (for example `/proc/x/ds.db`) never finished connecting. The driver made the directory with Node's recursive `mkdir`, which loops without end on such a path: `mkdir` of the level answers `ENOENT`, its parent answers `EEXIST`, and it retries, about 1,600 failing calls a second, all system CPU. The boot was then refused only by a plugin start timeout (30 s, or 120 s when a `datasourceMapping` rule routes to the datasource), and that message named neither the datasource, the path nor the error. The same happened to the host's own database when `OS_DATABASE_URL=file:` named such a path. + +**What changes** + +- The directory is made by a walk bounded by the path's depth, one non-recursive `mkdir` per missing level. The first level the operating system refuses fails `connect()` with a `SqliteDatabaseFileError` that names the file, the directory and the cause (`ENOENT`, `EACCES`, `ENOTDIR`, …). +- A file SQLite itself cannot open (`SQLITE_CANTOPEN`, for example a filename that is a directory) now fails `connect()` the same way. Before, the error was logged as a warning and `connect()` resolved, so the datasource counted as connected while it could not answer one query. A native-addon load failure keeps its warning and the step-down to wasm SQLite. + +The boot's existing connect refusal (ADR-0062 D5) carries this message, so `objectstack serve` on such a config now prints, at once, `datasource 'primary': connect failed — SQLite database file '/proc/x/ds.db' cannot be created: its directory '/proc/x' could not be made: ENOENT: no such file or directory, mkdir '/proc/x'`. No configuration key or accepted value changes. A path the operating system can create still connects. diff --git a/packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts b/packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts new file mode 100644 index 00000000000..5a3d8b95f8a --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A SQLite file the driver cannot create or open fails `connect()` at once, + * naming the file and the operating system's answer (#23696). + * + * Before: a filename under `/proc//` never finished connecting. Node's + * recursive mkdir loops on that path (`mkdir` of the level answers `ENOENT`, + * its parent answers `EEXIST`, retry) and burns system CPU, so a declared + * datasource there was refused only by a plugin start timeout that named + * neither the datasource nor the path. A filename SQLite itself cannot open + * (`SQLITE_CANTOPEN`) was logged and swallowed: `connect()` resolved and the + * datasource counted as connected while it could answer no query. + * + * What the boot prints is `datasource '': connect failed — ` + * (ADR-0062 D5, the connection service); `` is this error's message, + * so the pins here hold the file and the cause in it. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from './sql-driver.js'; +import { SqliteDatabaseFileError } from './sqlite-database-file.js'; + +/** The refusal must arrive within this; before the fix it never arrived. */ +const WITHIN_MS = 1_000; + +const drivers: SqlDriver[] = []; +function make(filename: string): SqlDriver { + const d = new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true }); + drivers.push(d); + return d; +} + +/** `connect()`'s rejection, or a failure naming the wait when it never settles. */ +async function refusalOf(driver: SqlDriver): Promise<{ error: unknown; ms: number }> { + const t0 = Date.now(); + let timer: ReturnType | undefined; + const never = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`connect() had not settled after ${5 * WITHIN_MS} ms`)), 5 * WITHIN_MS); + }); + try { + await Promise.race([driver.connect(), never]); + } catch (error) { + if ((error as Error).message.startsWith('connect() had not settled')) throw error; + return { error, ms: Date.now() - t0 }; + } finally { + clearTimeout(timer); + } + throw new Error('connect() resolved for a file that cannot be created or opened'); +} + +function expectNamedRefusal(error: unknown, filename: string, code: string): void { + expect(error).toBeInstanceOf(SqliteDatabaseFileError); + const e = error as SqliteDatabaseFileError; + expect(e.code).toBe(code); + expect(e.filename).toBe(filename); + expect(e.message).toContain(filename); + expect(e.message).toContain((e.cause as Error).message); +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'os-23696-')); }); +afterEach(async () => { + await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {}))); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('SqlDriver.connect — a SQLite file that cannot be created or opened (#23696)', () => { + it.runIf(existsSync('/proc/self'))( + 'refuses a directory the OS will not make (/proc//) at once — the shape that looped', + async () => { + const filename = `/proc/os-23696-${process.pid}/a/ds.db`; + const { error, ms } = await refusalOf(make(filename)); + expectNamedRefusal(error, filename, 'ENOENT'); + expect(ms).toBeLessThan(WITHIN_MS); + }, + ); + + it('refuses a path through a regular file, naming it', async () => { + writeFileSync(join(dir, 'plain'), ''); + const filename = join(dir, 'plain', 'ds.db'); + const { error, ms } = await refusalOf(make(filename)); + expectNamedRefusal(error, filename, 'ENOTDIR'); + expect(ms).toBeLessThan(WITHIN_MS); + }); + + it('refuses a file SQLite cannot open (SQLITE_CANTOPEN) instead of resolving', async () => { + const filename = join(dir, 'is-a-directory'); + mkdirSync(filename); + const { error, ms } = await refusalOf(make(filename)); + expectNamedRefusal(error, filename, 'SQLITE_CANTOPEN'); + expect(ms).toBeLessThan(WITHIN_MS); + }); + + it('CONTROL: a creatable path three levels deep is made, opened and answers', async () => { + const filename = join(dir, 'x', 'y', 'z', 'ds.db'); + const driver = make(filename); + await driver.connect(); + expect(existsSync(filename)).toBe(true); + expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]); + }); +}); diff --git a/packages/drivers/driver-sql/src/sqlite-database-file.ts b/packages/drivers/driver-sql/src/sqlite-database-file.ts index 465a760297d..09ae5838cab 100644 --- a/packages/drivers/driver-sql/src/sqlite-database-file.ts +++ b/packages/drivers/driver-sql/src/sqlite-database-file.ts @@ -53,7 +53,7 @@ export async function makeDatabaseDirectory(dir: string): Promise { for (;;) { try { if ((await stat(level)).isDirectory()) break; - throw Object.assign(new Error(`ENOTDIR: not a directory, mkdir '${level}'`), { code: 'ENOTDIR' }); + throw Object.assign(new Error(`ENOTDIR: '${level}' exists and is not a directory`), { code: 'ENOTDIR' }); } catch (e) { if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e; missing.push(level); From 7f8a2e317ac93afd25ae0622791ee16d1b5e5e64 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:35:32 +0000 Subject: [PATCH 3/4] fix(driver-sql): carry the refusal's cause as a declared field (the package lib predates ES2022 Error cause) Claude-Session: https://claude.ai/code/session_01CxHmGdRW1sJtL25b6ysocg Co-authored-by: Claude --- packages/drivers/driver-sql/src/sqlite-database-file.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/drivers/driver-sql/src/sqlite-database-file.ts b/packages/drivers/driver-sql/src/sqlite-database-file.ts index 09ae5838cab..a9bdb6ef1be 100644 --- a/packages/drivers/driver-sql/src/sqlite-database-file.ts +++ b/packages/drivers/driver-sql/src/sqlite-database-file.ts @@ -15,15 +15,18 @@ import { dirname, isAbsolute, resolve } from 'node:path'; export class SqliteDatabaseFileError extends Error { readonly code: string | undefined; readonly filename: string; + /** The error the operating system or SQLite answered. */ + readonly cause: unknown; constructor(filename: string, detail: string, cause: unknown) { const reason = cause instanceof Error ? cause.message : String(cause); const code = (cause as { code?: unknown } | null | undefined)?.code; const where = isAbsolute(filename) ? `'${filename}'` : `'${filename}' (resolved: '${resolve(filename)}')`; - super(`SQLite database file ${where} ${detail}: ${reason}`, { cause }); + super(`SQLite database file ${where} ${detail}: ${reason}`); this.name = 'SqliteDatabaseFileError'; this.code = typeof code === 'string' ? code : undefined; this.filename = filename; + this.cause = cause; } } From 9f89d446c622c9a29a4e5082f814d7f3bff8f1f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:55:54 +0000 Subject: [PATCH 4/4] fix(driver-sql): let the OS answer for a file holding a directory's name, rather than stamping an invented ENOTDIR Claude-Session: https://claude.ai/code/session_01CxHmGdRW1sJtL25b6ysocg Co-authored-by: Claude --- ...ql-driver-23696-sqlite-uncreatable-file.test.ts | 14 ++++++++++---- .../drivers/driver-sql/src/sqlite-database-file.ts | 8 +++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts b/packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts index 5a3d8b95f8a..7608c1c90ee 100644 --- a/packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts @@ -81,10 +81,16 @@ describe('SqlDriver.connect — a SQLite file that cannot be created or opened ( it('refuses a path through a regular file, naming it', async () => { writeFileSync(join(dir, 'plain'), ''); - const filename = join(dir, 'plain', 'ds.db'); - const { error, ms } = await refusalOf(make(filename)); - expectNamedRefusal(error, filename, 'ENOTDIR'); - expect(ms).toBeLessThan(WITHIN_MS); + const through = join(dir, 'plain', 'sub', 'ds.db'); + const throughRefusal = await refusalOf(make(through)); + expectNamedRefusal(throughRefusal.error, through, 'ENOTDIR'); + expect(throughRefusal.ms).toBeLessThan(WITHIN_MS); + + // The file holds the directory's own name. + const at = join(dir, 'plain', 'ds.db'); + const atRefusal = await refusalOf(make(at)); + expectNamedRefusal(atRefusal.error, at, 'EEXIST'); + expect(atRefusal.ms).toBeLessThan(WITHIN_MS); }); it('refuses a file SQLite cannot open (SQLITE_CANTOPEN) instead of resolving', async () => { diff --git a/packages/drivers/driver-sql/src/sqlite-database-file.ts b/packages/drivers/driver-sql/src/sqlite-database-file.ts index a9bdb6ef1be..f61d8d5618e 100644 --- a/packages/drivers/driver-sql/src/sqlite-database-file.ts +++ b/packages/drivers/driver-sql/src/sqlite-database-file.ts @@ -48,15 +48,17 @@ export class SqliteDatabaseFileError extends Error { * The walk below is bounded by the path's depth: find the nearest level that * exists, then create the missing ones top-down. `EEXIST` on a level is a * concurrent creator winning the race and is accepted; any other refusal is - * the answer. + * the answer. When a file holds the nearest existing name, the operating + * system answers for it too: `ENOTDIR` from the first missing level under it, + * or `EEXIST` from asking for `dir` itself. */ export async function makeDatabaseDirectory(dir: string): Promise { const missing: string[] = []; let level = resolve(dir); for (;;) { try { - if ((await stat(level)).isDirectory()) break; - throw Object.assign(new Error(`ENOTDIR: '${level}' exists and is not a directory`), { code: 'ENOTDIR' }); + if (!(await stat(level)).isDirectory() && missing.length === 0) await mkdir(level); + break; } catch (e) { if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e; missing.push(level);