diff --git a/.changeset/24961-readonly-probe-unopenable-path.md b/.changeset/24961-readonly-probe-unopenable-path.md new file mode 100644 index 0000000000..37b710bc3f --- /dev/null +++ b/.changeset/24961-readonly-probe-unopenable-path.md @@ -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) + + + +`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 '': 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. diff --git a/packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts b/packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts index e1f3e3bcd4..a6e64ae37c 100644 --- a/packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts @@ -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'; @@ -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 () => { @@ -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); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index b4393f25d4..dea47a1a37 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -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 @@ -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 @@ -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, @@ -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 }; } @@ -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; diff --git a/packages/drivers/driver-sql/src/sqlite-database-file.ts b/packages/drivers/driver-sql/src/sqlite-database-file.ts index f61d8d5618..fbde48ea8f 100644 --- a/packages/drivers/driver-sql/src/sqlite-database-file.ts +++ b/packages/drivers/driver-sql/src/sqlite-database-file.ts @@ -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'; @@ -75,3 +76,83 @@ export async function makeDatabaseDirectory(dir: string): Promise { } } } + +/** + * 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//` 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; +} diff --git a/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts b/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts new file mode 100644 index 0000000000..44bdc1a71f --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The read-only boot reports a declared SQLite datasource whose file can be + * neither opened nor created as FAILED, as the boot that writes does (#24961). + * + * `os migrate plan` composes its declared datasources through + * `createDefaultDatasourceDriverFactory({ sqliteAbsentFile: 'empty-in-memory' })` + * (`DeclaredDatasourceConnectionPlugin`). Before, driver-sql's absent-file + * judgement asked `existsSync`, which answers `false` for a path under a + * regular file, so the boot opened an empty in-memory database over it: + * `datasource 'archive': connected`, and the plan proposed `create_table` into + * a file that cannot exist. The judgement now refuses the file, and the + * refusal reaches the connection service as the boot's connect failure. + * + * Both rungs of `resolveSqliteDriver` take the judgement from the driver: the + * native driver's constructor applies it, and the wasm rung resolves its + * filename through the same exported `resolveSqliteAbsentFileTarget`. So the + * cases below are asked of the native rung, of the wasm rung (an addon that + * cannot load, faked where `better-sqlite3` loads it, as + * `sqlite-driver-fallback-file-open-failure.test.ts` does), and of production. + * + * Real `better-sqlite3`, real wasm SQLite, real files in a scratch directory. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveSqliteDriver, type ResolvedSqliteDriver } from '../sqlite-driver-fallback.js'; +import { DatasourceConnectionService } from '../datasource-connection-service.js'; +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; + +let dir: string; +const opened: ResolvedSqliteDriver[] = []; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-24961-')); + writeFileSync(join(dir, 'plain'), 'a regular file, not a directory'); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(opened.splice(0).map((r) => r.driver?.disconnect?.().catch(() => {}))); + rmSync(dir, { recursive: true, force: true }); +}); + +/** Every name in the scratch directory: what "nothing was written" is judged against. */ +function listing(): string[] { + return readdirSync(dir).sort(); +} + +async function resolveReadOnly(filename: string, dev: boolean, warn = vi.fn()): Promise { + const resolved = await resolveSqliteDriver({ filename, dev, warn, sqliteAbsentFile: 'empty-in-memory' }); + opened.push(resolved); + return resolved; +} + +/** `promise`'s rejection; a failure naming the engine when it resolves instead. */ +async function rejectionOf(promise: Promise): Promise { + let resolved: ResolvedSqliteDriver; + try { + resolved = await promise; + } catch (error) { + return error; + } + opened.push(resolved); + throw new Error(`resolved to engine '${resolved.engine}' 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); +} + +/** A real `ERR_DLOPEN_FAILED`: Node asked to load, as an addon, a file that is not one. */ +function realDlopenFailure(): Error { + const scratch = mkdtempSync(join(tmpdir(), 'os-24961-addon-')); + const notAnAddon = join(scratch, 'not-an-addon.node'); + writeFileSync(notAnAddon, 'x'); + try { + process.dlopen({ exports: {} }, notAnAddon); + } catch (error) { + return error as Error; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + throw new Error('process.dlopen loaded a file that is not an addon'); +} + +/** + * Make the native addon fail to load where `better-sqlite3` loads it: the knex + * better-sqlite3 client's `acquireRawConnection`. Wasm SQLite's client is + * another class, so the wasm rung is untouched. + */ +async function failNativeAddonLoad(): Promise { + const error = realDlopenFailure(); + expect((error as any).code).toBe('ERR_DLOPEN_FAILED'); + const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + const clientProto = Object.getPrototypeOf((probe as any).knex.client); + await probe.disconnect(); + expect(Object.prototype.hasOwnProperty.call(clientProto, 'acquireRawConnection')).toBe(true); + vi.spyOn(clientProto, 'acquireRawConnection').mockRejectedValue(error); +} + +describe('resolveSqliteDriver under empty-in-memory: a path under a regular file is refused, on every rung (#24961)', () => { + it('development, native rung: rejects with the driver\'s refusal, never an engine, and writes nothing', async () => { + const before = listing(); + const warn = vi.fn(); + const at = join(dir, 'plain', 'archive.db'); + expectFileRefusal(await rejectionOf(resolveReadOnly(at, true, warn)), at, 'ENOTDIR'); + const through = join(dir, 'plain', 'sub', 'archive.db'); + expectFileRefusal(await rejectionOf(resolveReadOnly(through, true, warn)), through, 'ENOTDIR'); + expect(warn).not.toHaveBeenCalled(); + expect(listing()).toEqual(before); + }); + + it('development, wasm rung (the addon cannot load): the same refusal, never wasm or memory', async () => { + await failNativeAddonLoad(); + const before = listing(); + const warn = vi.fn(); + const at = join(dir, 'plain', 'archive.db'); + expectFileRefusal(await rejectionOf(resolveReadOnly(at, true, warn)), at, 'ENOTDIR'); + expect(warn).not.toHaveBeenCalled(); + expect(listing()).toEqual(before); + }); + + it('production: the same refusal — the unprobed native driver is never built over the file', async () => { + const before = listing(); + const at = join(dir, 'plain', 'archive.db'); + expectFileRefusal(await rejectionOf(resolveReadOnly(at, false)), at, 'ENOTDIR'); + expect(listing()).toEqual(before); + }); +}); + +describe('resolveSqliteDriver under empty-in-memory: an absent file that can be created still opens empty in memory (#6743)', () => { + it('development, native rung: an empty in-memory database, and nothing on disk', async () => { + const before = listing(); + for (const filename of [join(dir, 'fresh.db'), join(dir, 'new', 'deeper', 'fresh.db')]) { + const resolved = await resolveReadOnly(filename, true); + expect(resolved.engine).toBe('better-sqlite3'); + expect(resolved.driver.sqliteOpenedEmptyInMemory).toBe(true); + expect(await resolved.driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]); + } + expect(listing()).toEqual(before); + }); + + it('development, wasm rung (the addon cannot load): wasm opens :memory:, and nothing on disk', async () => { + await failNativeAddonLoad(); + const before = listing(); + const resolved = await resolveReadOnly(join(dir, 'new', 'deeper', 'fresh.db'), true); + expect(resolved.engine).toBe('sqlite-wasm'); + expect(await resolved.driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]); + await resolved.driver.disconnect(); + expect(listing()).toEqual(before); + }); +}); + +describe('the read-only boot os migrate plan composes: a declared SQLite datasource under a regular file (#24961)', () => { + it('its factory rejects with the driver\'s refusal — the value the connection service receives', async () => { + const factory = createDefaultDatasourceDriverFactory({ dev: true, sqliteAbsentFile: 'empty-in-memory' }); + const filename = join(dir, 'plain', 'archive.db'); + let refusal: unknown; + try { + const handle = await factory.create({ name: 'archive', driver: 'sqlite', config: { filename } } as any); + opened.push({ driver: handle.driver ?? handle } as any); + } catch (e) { + refusal = e; + } + expectFileRefusal(refusal, filename, 'ENOTDIR'); + }); + + it.each([ + ['development', true], + ['production', false], + ] as const)('%s: the datasource reads failed, naming the file; no driver is registered; nothing is written', async (_label, dev) => { + const registered: string[] = []; + const engine = { + registerDriver: (driver: { name?: string }) => { registered.push(String(driver?.name)); }, + getDriverByName: (name: string) => (registered.includes(name) ? {} : undefined), + }; + const service = new DatasourceConnectionService({ + factory: () => createDefaultDatasourceDriverFactory({ dev, sqliteAbsentFile: 'empty-in-memory' }), + engine: () => engine as any, + }); + const filename = join(dir, 'plain', 'archive.db'); + const before = listing(); + + await service.connectDeclared({ + datasources: [{ name: 'archive', driver: 'sqlite', config: { filename }, autoConnect: true }], + }); + + const state = service.getConnectionState('archive'); + expect(state?.availability).toBe('failed'); + expect(state?.reason).toContain(filename); + // Nothing a plan could be computed against: no driver under the name. + expect(registered).toEqual([]); + expect(listing()).toEqual(before); + }); +});