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..7608c1c90ee --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts @@ -0,0 +1,111 @@ +// 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 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 () => { + 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/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..f61d8d5618e --- /dev/null +++ b/packages/drivers/driver-sql/src/sqlite-database-file.ts @@ -0,0 +1,77 @@ +// 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; + /** 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}`); + this.name = 'SqliteDatabaseFileError'; + this.code = typeof code === 'string' ? code : undefined; + this.filename = filename; + this.cause = cause; + } +} + +/** + * 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. 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() && missing.length === 0) await mkdir(level); + break; + } 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; + } + } +}