Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/23696-sqlite-uncreatable-path.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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/<missing>/` 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 '<name>': connect failed — <reason>`
* (ADR-0062 D5, the connection service); `<reason>` 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<typeof setTimeout> | undefined;
const never = new Promise<never>((_, 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/<missing>/) 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 }]);
});
});
22 changes: 19 additions & 3 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' ||
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 77 additions & 0 deletions packages/drivers/driver-sql/src/sqlite-database-file.ts
Original file line number Diff line number Diff line change
@@ -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 '<name>': connect failed — <reason>`) 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<void> {
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;
}
}
}
Loading