Skip to content
18 changes: 18 additions & 0 deletions .changeset/24961-readonly-probe-unopenable-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@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

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `resolveSqliteAbsentFileTarget` gives under `sqliteAbsentFile: 'empty-in-memory'` for a SQLite path that cannot exist: it throws the driver's `SqliteDatabaseFileError` where it answered `:memory:`. The remedy is the datasource's `filename`, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

`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 '<name>': 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 `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.
- **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.
147 changes: 146 additions & 1 deletion packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});
});
40 changes: 35 additions & 5 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -5159,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
Expand All @@ -5182,6 +5184,24 @@ 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 `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.
*
* @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,
Expand All @@ -5191,7 +5211,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 };
}

Expand Down Expand Up @@ -6437,12 +6457,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;
Expand Down
81 changes: 81 additions & 0 deletions packages/drivers/driver-sql/src/sqlite-database-file.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -75,3 +76,83 @@ export async function makeDatabaseDirectory(dir: string): Promise<void> {
}
}
}

/**
* 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) — 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
* 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/<missing>/` 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;
}
Loading
Loading