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
20 changes: 20 additions & 0 deletions .changeset/24960-sqlite-notadb-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/driver-sql': minor
'@objectstack/service-datasource': minor
---

fix(driver-sql): a SQLite file whose bytes are not a SQLite database fails `connect()` at once, naming the file, and a development boot no longer moves it aside and replaces it with an empty database (#24960)

`SqlDriver.connect()` runs `PRAGMA auto_vacuum` as the connection's first statement. On a file that is not a SQLite database (plain text, another format, a file encrypted by another engine) SQLite answers `SQLITE_NOTADB`, which `connect()` logged as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and swallowed. It resolved over a file it could not read. In production the first query failed later. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) answered the failed probe by stepping down to wasm SQLite, whose open renamed the file to `<file>.corrupt-<timestamp>`, wrote an empty database in its place and read `connected`, under a warning that blamed the native addon.

`connect()` now refuses such a file with a `SqliteDatabaseFileError` (`code: 'SQLITE_NOTADB'`, `filename`, a message naming the file), as it already did for `SQLITE_CANTOPEN`, in every mode and before anything is written. A declared datasource's boot prints `datasource '<name>': connect failed — <reason>`. `resolveSqliteDriver` classifies `SQLITE_NOTADB` as a file failure and re-throws it instead of stepping down, so the operator's file is left exactly as it was found. A real database file, an empty file and an absent file connect as before, and a native addon that cannot load still steps down to wasm SQLite.

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 (`SqliteDatabaseFileError` keeps its shape; only the set of codes it is raised with grows by one), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `SqlDriver.connect()` gives for a SQLite file that is not a database: a rejection where it resolved. The remedy is the datasource's file, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (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). -->

**BREAKING** (`SqlDriver.connect()`'s accept set narrows): a connect that resolved over a SQLite file that is not a database now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.

- **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot the file was renamed to `<file>.corrupt-<timestamp>` and an empty database was written in its place.
- **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_NOTADB'`, naming the file; the file is untouched.
- **Fix:** point the datasource at a SQLite database file, or move the file aside yourself (an absent file is created empty on the next connect).
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A declared SQLite file whose bytes are not a SQLite database fails
* `connect()` at once, naming the file, and the file is left exactly as it was
* found (#24960).
*
* Before: the connection's first statement (`PRAGMA auto_vacuum`) answered
* `SQLITE_NOTADB`, which `connect()` logged as "Failed to set PRAGMA
* auto_vacuum=INCREMENTAL" and swallowed, so it resolved over a file it could
* not read. In production the first query failed later; on a development boot
* the step-down (`resolveSqliteDriver` in `@objectstack/service-datasource`)
* answered with wasm SQLite, whose open moved the operator's file aside to
* `<file>.corrupt-<timestamp>` and wrote an empty database in its place, and
* the datasource read `connected`. `SQLITE_CANTOPEN` was already refused this
* way (#23696); `SQLITE_NOTADB` was left out.
*
* The refusal is the driver's own, so it holds whatever `NODE_ENV` says: both
* modes are pinned.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, readdirSync, readFileSync, 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';

const PLAIN_TEXT = 'archive notes — this file holds plain text, not a SQLite database\n';

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 saying it resolved. */
async function refusalOf(driver: SqlDriver): Promise<unknown> {
try {
await driver.connect();
} catch (error) {
return error;
}
throw new Error('connect() resolved for a file that is not a SQLite database');
}

let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'os-24960-')); });
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
rmSync(dir, { recursive: true, force: true });
});

describe('SqlDriver.connect — a declared SQLite file that is not a database (#24960)', () => {
for (const nodeEnv of ['development', 'production'] as const) {
it(`refuses a plain-text file with SQLITE_NOTADB, naming it, and leaves its bytes untouched (NODE_ENV=${nodeEnv})`, async () => {
vi.stubEnv('NODE_ENV', nodeEnv);
const filename = join(dir, 'archive.db');
writeFileSync(filename, PLAIN_TEXT);
const before = readFileSync(filename);

const error = await refusalOf(make(filename));

expect(error).toBeInstanceOf(SqliteDatabaseFileError);
const e = error as SqliteDatabaseFileError;
expect(e.code).toBe('SQLITE_NOTADB');
expect(e.filename).toBe(filename);
expect(e.message).toContain(filename);
expect(e.message).toContain((e.cause as Error).message);
// Nothing was written: the same bytes, and no sibling (journal, WAL,
// `.corrupt-*`) beside it.
expect(readFileSync(filename).equals(before)).toBe(true);
expect(readdirSync(dir)).toEqual(['archive.db']);
});
}

it('CONTROL: a real database file still connects and answers, and is not moved', async () => {
const filename = join(dir, 'real.db');
const seed = make(filename);
await seed.connect();
await seed.execute('CREATE TABLE t (a INTEGER)');
await seed.execute('INSERT INTO t (a) VALUES (7)');
await seed.disconnect();

const driver = make(filename);
await driver.connect();
expect(await driver.execute('SELECT a FROM t')).toEqual([{ a: 7 }]);
expect(readdirSync(dir).filter((name) => name.includes('.corrupt-'))).toEqual([]);
});

it('CONTROL: an empty file is an empty SQLite database, and connects', async () => {
const filename = join(dir, 'empty.db');
writeFileSync(filename, '');
const driver = make(filename);
await driver.connect();
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
});
});
39 changes: 32 additions & 7 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7029,6 +7029,9 @@ export class SqlDriver implements IDataDriver {
* @returns whether the connection answered. The read is the first statement
* any connection runs, so `false` says the connection itself is not answering —
* not merely that space hygiene is unavailable.
* @throws {SqliteDatabaseFileError} when that first statement's answer is a
* verdict on the file itself ({@link SQLITE_FILE_REFUSALS}): it cannot be
* opened, or it is not a SQLite database.
*/
private async applyAutoVacuumIncremental(): Promise<boolean> {
try {
Expand All @@ -7052,15 +7055,24 @@ 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') {
// The file itself is refused, and every later statement would answer the
// same, so the connect fails here, naming the file. Before, each of these
// logged and resolved, and the datasource counted as connected while it
// could not answer one query:
// • SQLITE_CANTOPEN (#23696) — a directory that exists but refuses the
// file, a path SQLite cannot open;
// • SQLITE_NOTADB (#24960) — the file exists and opens, but its bytes
// are not a SQLite database (plain text, a foreign format, a file
// encrypted by another engine). On a development boot the step-down
// used to answer it with wasm SQLite, whose open moved the
// operator's file aside and wrote an empty database in its place.
// The refusal is issued before anything is written: the file is left
// exactly as it was found.
const refusal = code === undefined ? undefined : SqlDriver.SQLITE_FILE_REFUSALS.get(code);
if (refusal !== undefined) {
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);
throw new SqliteDatabaseFileError(filename, refusal, e);
}
const msg = e instanceof Error ? e.message : String(e);
const isNativeLoadFailure =
Expand All @@ -7079,6 +7091,19 @@ export class SqlDriver implements IDataDriver {
}
}

/**
* The SQLite codes the connection's first statement can answer that are a
* verdict on the FILE, each with the words {@link SqliteDatabaseFileError}
* names it with. Only a loaded addon answers with a SQLite code, so none of
* these is a native-addon load failure; and every rung of a step-down opens
* the same file, so no other engine can cure one (`resolveSqliteDriver` in
* `@objectstack/service-datasource` re-throws them).
*/
private static readonly SQLITE_FILE_REFUSALS: ReadonlyMap<string, string> = new Map([
['SQLITE_CANTOPEN', 'cannot be opened or created'],
['SQLITE_NOTADB', 'is not a SQLite database, and was left as it is'],
]);

/** What `PRAGMA auto_vacuum` answers for INCREMENTAL (0 is NONE, 1 is FULL). */
private static readonly SQLITE_AUTO_VACUUM_INCREMENTAL = 2;

Expand Down
5 changes: 3 additions & 2 deletions packages/drivers/driver-sql/src/sqlite-database-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ 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).
* A SQLite database file this driver cannot open or create (#23696), or one
* whose bytes are not a SQLite database (#24960).
*
* 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.
* `SQLITE_CANTOPEN`, `SQLITE_NOTADB`, …) and `cause` the error itself.
*/
export class SqliteDatabaseFileError extends Error {
readonly code: string | undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A declared SQLite datasource whose file is not a SQLite database is refused
* at connect, naming the file, in development as in production. On a
* development boot the operator's file is neither renamed nor replaced, and
* nothing steps down to wasm SQLite or the in-memory driver (#24960).
*
* Before: `SqlDriver.connect()` swallowed the file's `SQLITE_NOTADB` and
* resolved, the probe's `SELECT 1` then failed as a `DATABASE_ERROR`, and in
* development `resolveSqliteDriver` stepped down to wasm SQLite. wasm's open
* moved the file aside to `<file>.corrupt-<timestamp>` and wrote an empty
* database in its place, under the warning "native better-sqlite3 unavailable
* (ABI mismatch or not built)", which named the wrong cause; the datasource
* read `connected`. Measured through `os migrate apply --yes` on the card.
*
* Real `better-sqlite3`, real wasm SQLite and real files in a scratch
* directory, as in `./sqlite-driver-fallback-file-open-failure.test.ts`.
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveSqliteDriver, type ResolvedSqliteDriver } from '../sqlite-driver-fallback.js';
import { DatasourceConnectionService } from '../datasource-connection-service.js';
import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js';

const PLAIN_TEXT = 'archive notes — this file holds plain text, not a SQLite database\n';

let dir: string;
let filename: string;
let bytes: Buffer;
const opened: ResolvedSqliteDriver[] = [];

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'os-24960-'));
filename = join(dir, 'archive.db');
writeFileSync(filename, PLAIN_TEXT);
bytes = readFileSync(filename);
});

afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
await Promise.all(opened.splice(0).map((r) => r.driver?.disconnect?.().catch(() => {})));
rmSync(dir, { recursive: true, force: true });
});

/** `promise`'s rejection; a failure naming the engine when it resolves instead. */
async function rejectionOf(promise: Promise<ResolvedSqliteDriver>): Promise<any> {
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 is not a SQLite database`);
}

/** The driver's refusal of the file: its own error name, SQLite's code, and the file. */
function expectNotADatabaseRefusal(error: any): void {
expect(error?.name).toBe('SqliteDatabaseFileError');
expect(error?.code).toBe('SQLITE_NOTADB');
expect(error?.filename).toBe(filename);
expect(String(error?.message)).toContain(filename);
}

/** The operator's file is exactly as it was, and nothing was written beside it. */
function expectFileUntouched(): void {
expect(readFileSync(filename).equals(bytes)).toBe(true);
expect(readdirSync(dir)).toEqual(['archive.db']);
}

describe('resolveSqliteDriver in development: a file that is not a SQLite database (#24960)', () => {
it('rejects with the driver\'s refusal, never wasm or memory, and leaves the file as it was', async () => {
const warn = vi.fn();
expectNotADatabaseRefusal(await rejectionOf(resolveSqliteDriver({ filename, dev: true, warn })));
// No step-down notice: the "native better-sqlite3 unavailable" warning
// named the wrong cause, and no rung was tried.
expect(warn).not.toHaveBeenCalled();
expectFileUntouched();
});
});

describe('resolveSqliteDriver in production: a file that is not a SQLite database (#24960)', () => {
it('returns native unprobed, and its connect() refuses the file', async () => {
const warn = vi.fn();
const resolved = await resolveSqliteDriver({ filename, dev: false, warn });
opened.push(resolved);
expect(resolved.engine).toBe('better-sqlite3');
let refusal: unknown;
await resolved.driver.connect().catch((e: unknown) => { refusal = e; });
expectNotADatabaseRefusal(refusal);
expect(warn).not.toHaveBeenCalled();
expectFileUntouched();
});
});

describe('DatasourceConnectionService: a declared SQLite datasource on a file that is not a database (#24960)', () => {
for (const mode of ['development', 'production'] as const) {
it(`${mode}: reports a failed connection state naming the file, registers no driver, and leaves the file as it was`, async () => {
vi.stubEnv('NODE_ENV', mode);
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: mode === 'development' }),
engine: () => engine as any,
});

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);
expect(registered).toEqual([]);
expectFileUntouched();
});
}
});
Loading
Loading