Skip to content

Commit 53646fc

Browse files
fix(driver-sql): a sqlite file that cannot be created or opened fails connect at once, naming the file and the OS error (#23696) (#23730)
Fixes #23696 Clause-②: no ## What was wrong (measured, root-caused) A declared SQLite datasource whose `config.filename` lay under a directory the OS will not make (`/proc/os-23696/ds.db`) never finished its `connect()`. The boot was refused only by a plugin start timeout, and that message named neither the datasource, the path nor the error. **Root cause: Node's own recursive `mkdir`, not a knex pool retry (dispatch hypothesis H2 falsified).** `SqlDriver.ensureDatabaseExists()` made the file's directory with `mkdir(dir, { recursive: true })`. On Node v22.22.0 that call never settles when one level answers `ENOENT` although its parent exists. Under `strace`, one second of `fs.mkdir('/proc/os-23696', { recursive: true })` shows this loop: ``` mkdir("/proc/os-23696") = -1 ENOENT mkdir("/proc") = -1 EEXIST statx("/proc") = 0 (a directory, so it retries the child) ... repeat ... ``` That is 1,640 failing `mkdir` and 819 `statx` per second. They are syscalls, which is the system CPU #23649's dev read. The sync form (`mkdirSync`) loops the same way. A bare `node -e` of the async call, with no ObjectStack code, ran at 90% CPU for over 2 minutes before I killed it. The knex pool does not loop. When better-sqlite3 cannot open the file, the acquire rejects at once. But the driver's first statement (`PRAGMA auto_vacuum`) caught `SQLITE_CANTOPEN`, logged it as a warning and returned. So `connect()` **resolved** in 11 ms on `/proc/ds-23696.db` (the directory exists, the file cannot be opened), and the datasource counted as connected while it could answer no query. The issue's Wanted covers this arm too: "cannot be opened or created fails its connect at once, with the open error". ## The change (producer: `packages/drivers/driver-sql`) - `src/sqlite-database-file.ts` (new): - `makeDatabaseDirectory(dir)` walks up to the nearest existing level, then creates each missing level with a non-recursive `mkdir`, top-down. The walk is bounded by the path's depth. An `EEXIST` is accepted only as a concurrent creator winning the race. Any other refusal is the answer, and it is the OS's own: `ENOENT`, `EACCES`, `ENOTDIR`, or `EEXIST` when a file holds the directory's own name. - `SqliteDatabaseFileError` names the file (and its resolved path when relative), the directory, and the cause's message. It carries `code` (the underlying one) and `cause`. - `src/sql-driver.ts`: - `ensureDatabaseExists()` uses the bounded walk and throws `SqliteDatabaseFileError` (`cannot be created: its directory 'X' could not be made: …`). - In the connect's first PRAGMA, `SQLITE_CANTOPEN` now fails `connect()` with `SqliteDatabaseFileError` (`cannot be opened or created: …`). A native-addon load failure keeps its warning and its step-down to wasm. Any other PRAGMA failure keeps its warning, as before. - `AppPlugin` is unchanged. The connection service's existing ADR-0062 D5 refusal (`datasource 'NAME': connect failed — REASON`) carries the driver's message verbatim, so it already names the datasource, the path and the error. **No `packages/runtime` edit and no cross-lane line were needed.** The `objectql:drivers-ready` start-order region is untouched, and there is no timeout change. Every caller of the driver gets this once. Measured: the mapped datasource (`AppPlugin`'s pre-sync connect), the host's default datasource from `OS_DATABASE_URL=file:…` under `serve`, and `SqliteWasmDriver`, whose `super.connect()` runs the walk before its own mkdir and rejects in 3 ms. ## Verification All boot readings use the built CLI (`packages/cli/bin/run.js serve objectstack.config.ts`, `NODE_ENV` unset = production, `OS_DATABASE_URL=file:./ctl.db`). The host config declares `primary` (`driver: 'sqlite'`, `config.filename` as listed), one app object and one host plugin. CPU is the child's `utime` and `stime` from its last `/proc/PID/stat` sample. **H1, before (`f40d877f8`, unmodified):** | shape | wall | outcome | CPU (user / sys) | |:--|:--|:--|:--| | `datasourceMapping: [{ default: true, datasource: 'primary' }]`, filename `/proc/os-23696/ds.db` | 122.7 s | `Plugin com.objectstack.engine.objectql start timeout after 120000ms`. Names no datasource, path or error | 28.6 s / 83.2 s | | H3a: no mapping, `autoConnect: true`, same filename | 33.2 s | `Plugin plugin.app.com.example.fx start timeout after 30000ms` | 10.2 s / 21.0 s | | H3b: no mapping, `OS_DATABASE_URL=file:/proc/os-23696/ctl.db` | 32.5 s | `Plugin com.objectstack.runtime.default-datasource init timeout after 30000ms` | 9.8 s / 20.5 s | | CONTROL: mapping, filename `./ds-primary.db` | 4.2 s | ready banner | 4.9 s / 0.4 s | **After (final head `9f89d446`):** | shape | wall (whole process, CLI load included) | outcome | CPU (user / sys) | |:--|:--|:--|:--| | H1: mapping, `/proc/os-23696/ds.db` | 2.6 s | `datasource 'primary': connect failed — SQLite database file '/proc/os-23696/ds.db' cannot be created: its directory '/proc/os-23696' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-23696'. (71 object(s) are routed to it by a datasourceMapping rule … ⇒ fail-fast per ADR-0062 D5)` | 3.0 s / 0.3 s | | mapping, `/proc/ds-23696.db` (`SQLITE_CANTOPEN`) | 2.8 s | `datasource 'primary': connect failed — SQLite database file '/proc/ds-23696.db' cannot be opened or created: unable to open database file. (…fail-fast per ADR-0062 D5)` | 3.0 s / 0.2 s | | H3b: `OS_DATABASE_URL=file:/proc/os-23696/ctl.db` | 2.7 s | `datasource 'default': connect failed — SQLite database file '/proc/os-23696/ctl.db' cannot be created: … ENOENT … (declared boot-critical by the host … ⇒ fail-fast per ADR-0062 D5)` | 3.0 s / 0.3 s | | H3a: `autoConnect`, unrouted | 3.9 s | ready banner. Not addressed by a mapping, a binding or `bootCritical`, so it degrades per D5 (see the acceptance notes) | 4.3 s / 0.4 s | | CONTROL: mapping, `./ds-primary.db` | 4.4 s | ready banner | 5.0 s / 0.3 s | **At the driver** (the built `dist`, `new SqlDriver({ client: 'better-sqlite3', connection: { filename } }).connect()`): | filename | before | after | |:--|:--|:--| | `/proc/os-23696/ds.db` | unsettled after 20 s | rejected in 1 ms, `ENOENT` | | `/proc/os-23696/a/b/ds.db` | (same loop) | rejected in 2 ms, `ENOENT`, names `mkdir '/proc/os-23696'` | | `/proc/ds-23696.db` | **resolved** in 11 ms | rejected in 7 ms, `SQLITE_CANTOPEN` | | a fresh path three levels deep | connects | connects, creates the file, `select 1` answers | **Pins:** `packages/drivers/driver-sql/src/sql-driver-23696-sqlite-uncreatable-file.test.ts`, 4 tests. Each refusal pin asserts `SqliteDatabaseFileError`, its `code`, its `filename`, a message holding both the file and the cause's message, and arrival within 1,000 ms. A 5 s race turns a regression into a red, not a hang. - `/proc/MISSING/` (`ENOENT`). Runs where `/proc/self` exists, so on Linux CI. This is the exact looping shape. - a path through a regular file (`ENOTDIR`) and a file holding the directory's own name (`EEXIST`). Portable. - a filename that is a directory (`SQLITE_CANTOPEN`). Portable. - CONTROL: a creatable path three levels deep is made and opened, and answers `SELECT 1`. **Ablation** (`scripts/ablation-replace.mjs`, mutation proven on disk, then restored, with blob = HEAD and an empty `git status --porcelain`). The test imports `./sql-driver.js` from `src`, so no `dist` is involved. - Leg A: `await makeDatabaseDirectory(dir)` replaced by `mkdir(dir, { recursive: true })`. 2 failed / 2 passed. The `/proc` pin failed with `connect() had not settled after 5000 ms` (5,007 ms). The regular-file pin failed with `expected 'EEXIST' to be 'ENOTDIR'`. - Leg B: the `SQLITE_CANTOPEN` throw replaced by a no-op. 1 failed / 3 passed: `connect() resolved for a file that cannot be created or opened`. - Both legs ran on `fa0158df`. The two mutated lines are byte-identical on the final head. The later commits only declare `cause` as a field and replace the synthesized `ENOTDIR` with the OS's own answer. **Package:** - `pnpm --filter @objectstack/driver-sql test` on `9f89d446`: 221 files passed, 11 skipped (live dialects). 3,663 tests passed, 208 skipped. Exit 0. - `pnpm --filter @objectstack/driver-sql typecheck` and `build`: exit 0, against a rebuilt `spec/dist` with declarations. `check-dts-emitted`: 1/1. **Gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived on `9f89d446` with no paths. The change set is 4 paths against merge base `f40d877f8`, and it derives 67 commands, the same 67 as at dispatch. Each was run with its exit code captured before any pipe and reconciled with `--ran` (`COMMAND :: exit N`): `✓ dispatch-gates --ran: 67 derived famil(ies) accounted for — 66 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).` - 66 exited 0. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3 `PREREQUISITE NOT MET` (it needs every package built: `studio`, `client-react`, … have no `dist/`). CI's Lint & Repo Gates runs it. - Three families needed a fix or a rebuild before they went green, and all three are recorded: - `check:dispatcher-error-vocabulary` reddened on a code literal that an earlier commit had synthesized (`{ code: 'ENOTDIR' }`). Fixed by letting the OS answer. - `check:dts-closure` first read a CLI closure I had built with `OS_SKIP_DTS=1`. After a full declaration build it reads `161/161 declared declaration file(s) present`. - `check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched in this shallow clone. It is green after that. ADRs grepped for the surface (Prime Directive #13): ADR-0062 D5 and its #4462 amendment ("a `declared-auto` failure is **fatal**") are what the boot now reaches instead of a timeout. ADR-0057's `auto_vacuum=INCREMENTAL` default is unchanged. No recorded decision is reversed. ## Acceptance notes - **Not fixed here, same defect class, different producer:** `os start` with `OS_DATABASE_URL=file:/proc/os-23696/ctl.db` and no config prints `→ Starting server...` and then hangs. It was killed at 25 s. Under `strace`, the respawned server's main thread made 21,443 failing `mkdir("/proc/os-23696")` calls in 12 s. That is the synchronous `mkdirSync(…, { recursive: true })` in `packages/runtime/src/standalone-stack.ts` (`createStandaloneStack`, the sqlite arm, and the same line in the `sqlite-wasm` arm). Because it blocks the event loop, no plugin timeout can fire. ADR-0062's #3826 resolution names that `mkdir` as a host concern. It is outside this card's file surface and gate family, so it is reported to the seat for filing, not fixed here. `packages/cli/src/commands/dev.ts` (`os dev`'s database directory) uses the same call shape and was not measured. - H3a (`autoConnect: true`, no mapping, unaddressed) now degrades instead of hanging, as ADR-0062 D5 prescribes for an unaddressed datasource. Its `degrading (datasource left unconnected)` warning did not appear on `serve`'s stream, even at `OS_LOG_LEVEL=warn`. That visibility question predates this change and is noted only. - `packages/services/service-datasource/src/sqlite-driver-fallback.ts`'s dev probe comment says `connect()` runs "a PRAGMA whose error it swallows". It still swallows everything except `SQLITE_CANTOPEN`. The probe's `catch` treats a throwing `connect()` exactly as it treated the throwing `SELECT 1` that used to follow, so its branch is unchanged. Related: #23649 and PR #23694 (which moved the mapped connect into the engine plugin's start; that is where the 120 s figure comes from). This PR does not change that region. Dispatched by the `domain:engine` seat 1 PM (`session_01GP3comALHRcRBixS2TEnLF`). Developed in `session_01CxHmGdRW1sJtL25b6ysocg`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) _Body refreshed 2026-10-15T23:03Z by `domain:engine` seat 1: the one wording edit the dev's report 6203616771 lists._ --- _Generated by [Claude Code](https://claude.ai/code/session_01CxHmGdRW1sJtL25b6ysocg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3b46855 commit 53646fc

4 files changed

Lines changed: 223 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
**What changes**
12+
13+
- 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`, …).
14+
- 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.
15+
16+
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.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A SQLite file the driver cannot create or open fails `connect()` at once,
5+
* naming the file and the operating system's answer (#23696).
6+
*
7+
* Before: a filename under `/proc/<missing>/` never finished connecting. Node's
8+
* recursive mkdir loops on that path (`mkdir` of the level answers `ENOENT`,
9+
* its parent answers `EEXIST`, retry) and burns system CPU, so a declared
10+
* datasource there was refused only by a plugin start timeout that named
11+
* neither the datasource nor the path. A filename SQLite itself cannot open
12+
* (`SQLITE_CANTOPEN`) was logged and swallowed: `connect()` resolved and the
13+
* datasource counted as connected while it could answer no query.
14+
*
15+
* What the boot prints is `datasource '<name>': connect failed — <reason>`
16+
* (ADR-0062 D5, the connection service); `<reason>` is this error's message,
17+
* so the pins here hold the file and the cause in it.
18+
*/
19+
20+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
21+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
22+
import { tmpdir } from 'node:os';
23+
import { join } from 'node:path';
24+
import { SqlDriver } from './sql-driver.js';
25+
import { SqliteDatabaseFileError } from './sqlite-database-file.js';
26+
27+
/** The refusal must arrive within this; before the fix it never arrived. */
28+
const WITHIN_MS = 1_000;
29+
30+
const drivers: SqlDriver[] = [];
31+
function make(filename: string): SqlDriver {
32+
const d = new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true });
33+
drivers.push(d);
34+
return d;
35+
}
36+
37+
/** `connect()`'s rejection, or a failure naming the wait when it never settles. */
38+
async function refusalOf(driver: SqlDriver): Promise<{ error: unknown; ms: number }> {
39+
const t0 = Date.now();
40+
let timer: ReturnType<typeof setTimeout> | undefined;
41+
const never = new Promise<never>((_, reject) => {
42+
timer = setTimeout(() => reject(new Error(`connect() had not settled after ${5 * WITHIN_MS} ms`)), 5 * WITHIN_MS);
43+
});
44+
try {
45+
await Promise.race([driver.connect(), never]);
46+
} catch (error) {
47+
if ((error as Error).message.startsWith('connect() had not settled')) throw error;
48+
return { error, ms: Date.now() - t0 };
49+
} finally {
50+
clearTimeout(timer);
51+
}
52+
throw new Error('connect() resolved for a file that cannot be created or opened');
53+
}
54+
55+
function expectNamedRefusal(error: unknown, filename: string, code: string): void {
56+
expect(error).toBeInstanceOf(SqliteDatabaseFileError);
57+
const e = error as SqliteDatabaseFileError;
58+
expect(e.code).toBe(code);
59+
expect(e.filename).toBe(filename);
60+
expect(e.message).toContain(filename);
61+
expect(e.message).toContain((e.cause as Error).message);
62+
}
63+
64+
let dir: string;
65+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'os-23696-')); });
66+
afterEach(async () => {
67+
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
68+
rmSync(dir, { recursive: true, force: true });
69+
});
70+
71+
describe('SqlDriver.connect — a SQLite file that cannot be created or opened (#23696)', () => {
72+
it.runIf(existsSync('/proc/self'))(
73+
'refuses a directory the OS will not make (/proc/<missing>/) at once — the shape that looped',
74+
async () => {
75+
const filename = `/proc/os-23696-${process.pid}/a/ds.db`;
76+
const { error, ms } = await refusalOf(make(filename));
77+
expectNamedRefusal(error, filename, 'ENOENT');
78+
expect(ms).toBeLessThan(WITHIN_MS);
79+
},
80+
);
81+
82+
it('refuses a path through a regular file, naming it', async () => {
83+
writeFileSync(join(dir, 'plain'), '');
84+
const through = join(dir, 'plain', 'sub', 'ds.db');
85+
const throughRefusal = await refusalOf(make(through));
86+
expectNamedRefusal(throughRefusal.error, through, 'ENOTDIR');
87+
expect(throughRefusal.ms).toBeLessThan(WITHIN_MS);
88+
89+
// The file holds the directory's own name.
90+
const at = join(dir, 'plain', 'ds.db');
91+
const atRefusal = await refusalOf(make(at));
92+
expectNamedRefusal(atRefusal.error, at, 'EEXIST');
93+
expect(atRefusal.ms).toBeLessThan(WITHIN_MS);
94+
});
95+
96+
it('refuses a file SQLite cannot open (SQLITE_CANTOPEN) instead of resolving', async () => {
97+
const filename = join(dir, 'is-a-directory');
98+
mkdirSync(filename);
99+
const { error, ms } = await refusalOf(make(filename));
100+
expectNamedRefusal(error, filename, 'SQLITE_CANTOPEN');
101+
expect(ms).toBeLessThan(WITHIN_MS);
102+
});
103+
104+
it('CONTROL: a creatable path three levels deep is made, opened and answers', async () => {
105+
const filename = join(dir, 'x', 'y', 'z', 'ds.db');
106+
const driver = make(filename);
107+
await driver.connect();
108+
expect(existsSync(filename)).toBe(true);
109+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
110+
});
111+
});

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ import {
155155
type UndeliveredAttribute,
156156
} from './builtin-column-collision.js';
157157
import { recoverUnencodedJsonText } from './unencoded-json-text.js';
158+
import { makeDatabaseDirectory, SqliteDatabaseFileError } from './sqlite-database-file.js';
158159
import { jsonMembershipPredicate } from '@objectstack/core';
159160
import knex, { Knex } from 'knex';
160161
import { nanoid } from 'nanoid';
@@ -7001,6 +7002,16 @@ export class SqlDriver implements IDataDriver {
70017002
// let the step-down message that follows explain the outcome. Any OTHER
70027003
// PRAGMA failure keeps the full warning (with stack) as before.
70037004
const code = (e as { code?: string } | null | undefined)?.code;
7005+
// The file itself cannot be opened or created (#23696): a directory that
7006+
// exists but refuses the file, a path SQLite cannot open. Every later
7007+
// statement would answer the same, so the connect fails here, naming the
7008+
// file — before, it logged and resolved, and the datasource counted as
7009+
// connected while it could not answer one query.
7010+
if (code === 'SQLITE_CANTOPEN') {
7011+
const declared = (this.config as any).connection;
7012+
const filename = this.sqliteFilename() ?? String(typeof declared === 'string' ? declared : declared?.filename);
7013+
throw new SqliteDatabaseFileError(filename, 'cannot be opened or created', e);
7014+
}
70047015
const msg = e instanceof Error ? e.message : String(e);
70057016
const isNativeLoadFailure =
70067017
code === 'ERR_DLOPEN_FAILED' ||
@@ -20105,15 +20116,20 @@ export class SqlDriver implements IDataDriver {
2010520116

2010620117
protected async ensureDatabaseExists() {
2010720118
// SQLite auto-creates database files but NOT parent directories.
20108-
// Ensure the directory exists so better-sqlite3 can create the file.
20119+
// Ensure the directory exists so better-sqlite3 can create the file — with
20120+
// a bounded walk, never Node's recursive mkdir, which loops forever on a
20121+
// path like `/proc/x/` (#23696, see `makeDatabaseDirectory`).
2010920122
if (this.isSqlite) {
2011020123
const filename = this.sqliteFilename();
2011120124
if (filename) {
2011220125
const { dirname } = await import('node:path');
20113-
const { mkdir } = await import('node:fs/promises');
2011420126
const dir = dirname(filename);
2011520127
if (dir && dir !== '.') {
20116-
await mkdir(dir, { recursive: true });
20128+
try {
20129+
await makeDatabaseDirectory(dir);
20130+
} catch (e) {
20131+
throw new SqliteDatabaseFileError(filename, `cannot be created: its directory '${dir}' could not be made`, e);
20132+
}
2011720133
}
2011820134
}
2011920135
return;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { mkdir, stat } from 'node:fs/promises';
4+
import { dirname, isAbsolute, resolve } from 'node:path';
5+
6+
/**
7+
* A SQLite database file this driver cannot open or create (#23696).
8+
*
9+
* Thrown by `SqlDriver.connect()`, so the boot's connect refusal (ADR-0062 D5,
10+
* `datasource '<name>': connect failed — <reason>`) carries the path and the
11+
* operating system's own answer instead of waiting for a plugin start timeout.
12+
* `code` is the underlying error's (`ENOENT`, `EACCES`, `ENOTDIR`,
13+
* `SQLITE_CANTOPEN`, …) and `cause` the error itself.
14+
*/
15+
export class SqliteDatabaseFileError extends Error {
16+
readonly code: string | undefined;
17+
readonly filename: string;
18+
/** The error the operating system or SQLite answered. */
19+
readonly cause: unknown;
20+
21+
constructor(filename: string, detail: string, cause: unknown) {
22+
const reason = cause instanceof Error ? cause.message : String(cause);
23+
const code = (cause as { code?: unknown } | null | undefined)?.code;
24+
const where = isAbsolute(filename) ? `'${filename}'` : `'${filename}' (resolved: '${resolve(filename)}')`;
25+
super(`SQLite database file ${where} ${detail}: ${reason}`);
26+
this.name = 'SqliteDatabaseFileError';
27+
this.code = typeof code === 'string' ? code : undefined;
28+
this.filename = filename;
29+
this.cause = cause;
30+
}
31+
}
32+
33+
/**
34+
* Make `dir` exist, creating each missing level with a NON-recursive `mkdir`,
35+
* and reject at once with the first level the operating system refuses.
36+
*
37+
* ## Why not `mkdir(dir, { recursive: true })`
38+
*
39+
* Node's recursive mkdir (v22.22.0, measured; sync and async alike) never
40+
* settles when a level answers `ENOENT` although its parent exists. On
41+
* `/proc/os-23696` it loops `mkdir("/proc/os-23696") = ENOENT`,
42+
* `mkdir("/proc") = EEXIST`, `statx("/proc")` (a directory, so retry the
43+
* child) without end — about 1,640 failing `mkdir` calls a second, all of
44+
* it system CPU. A declared SQLite datasource whose directory lay on such a
45+
* path never finished its connect, and the boot was refused only by a plugin
46+
* start timeout that named neither the datasource nor the path.
47+
*
48+
* The walk below is bounded by the path's depth: find the nearest level that
49+
* exists, then create the missing ones top-down. `EEXIST` on a level is a
50+
* concurrent creator winning the race and is accepted; any other refusal is
51+
* the answer. When a file holds the nearest existing name, the operating
52+
* system answers for it too: `ENOTDIR` from the first missing level under it,
53+
* or `EEXIST` from asking for `dir` itself.
54+
*/
55+
export async function makeDatabaseDirectory(dir: string): Promise<void> {
56+
const missing: string[] = [];
57+
let level = resolve(dir);
58+
for (;;) {
59+
try {
60+
if (!(await stat(level)).isDirectory() && missing.length === 0) await mkdir(level);
61+
break;
62+
} catch (e) {
63+
if ((e as { code?: unknown } | null)?.code !== 'ENOENT') throw e;
64+
missing.push(level);
65+
const parent = dirname(level);
66+
if (parent === level) break;
67+
level = parent;
68+
}
69+
}
70+
for (const each of missing.reverse()) {
71+
try {
72+
await mkdir(each);
73+
} catch (e) {
74+
if ((e as { code?: unknown } | null)?.code !== 'EEXIST') throw e;
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)