Skip to content

Commit c5b499c

Browse files
committed
test(service-datasource): pin a declared SQLite file that is not a database as refused and untouched
A development boot of a declared datasource on a plain-text file is refused with the driver's SQLITE_NOTADB refusal, steps down to nothing, warns nothing, and leaves the file's bytes and directory exactly as they were; production refuses at connect too. Adds the patch changeset for both packages. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c9e508 commit c5b499c

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/service-datasource': patch
4+
---
5+
6+
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)
7+
8+
`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.
9+
10+
`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.
11+
12+
Clause-②: no (narrowing)
13+
14+
<!-- 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). -->
15+
16+
**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 `patch`: no public surface changes, and during the launch window breaking-ness is carried by this banner and the disposition above, not by the level.
17+
18+
- **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.
19+
- **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_NOTADB'`, naming the file; the file is untouched.
20+
- **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).
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A declared SQLite datasource whose file is not a SQLite database is refused
5+
* at connect, naming the file, in development as in production. On a
6+
* development boot the operator's file is neither renamed nor replaced, and
7+
* nothing steps down to wasm SQLite or the in-memory driver (#24960).
8+
*
9+
* Before: `SqlDriver.connect()` swallowed the file's `SQLITE_NOTADB` and
10+
* resolved, the probe's `SELECT 1` then failed as a `DATABASE_ERROR`, and in
11+
* development `resolveSqliteDriver` stepped down to wasm SQLite. wasm's open
12+
* moved the file aside to `<file>.corrupt-<timestamp>` and wrote an empty
13+
* database in its place, under the warning "native better-sqlite3 unavailable
14+
* (ABI mismatch or not built)", which named the wrong cause; the datasource
15+
* read `connected`. Measured through `os migrate apply --yes` on the card.
16+
*
17+
* Real `better-sqlite3`, real wasm SQLite and real files in a scratch
18+
* directory, as in `./sqlite-driver-fallback-file-open-failure.test.ts`.
19+
*/
20+
21+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22+
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23+
import { tmpdir } from 'node:os';
24+
import { join } from 'node:path';
25+
import { resolveSqliteDriver, type ResolvedSqliteDriver } from '../sqlite-driver-fallback.js';
26+
import { DatasourceConnectionService } from '../datasource-connection-service.js';
27+
import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js';
28+
29+
const PLAIN_TEXT = 'archive notes — this file holds plain text, not a SQLite database\n';
30+
31+
let dir: string;
32+
let filename: string;
33+
let bytes: Buffer;
34+
const opened: ResolvedSqliteDriver[] = [];
35+
36+
beforeEach(() => {
37+
dir = mkdtempSync(join(tmpdir(), 'os-24960-'));
38+
filename = join(dir, 'archive.db');
39+
writeFileSync(filename, PLAIN_TEXT);
40+
bytes = readFileSync(filename);
41+
});
42+
43+
afterEach(async () => {
44+
vi.restoreAllMocks();
45+
vi.unstubAllEnvs();
46+
await Promise.all(opened.splice(0).map((r) => r.driver?.disconnect?.().catch(() => {})));
47+
rmSync(dir, { recursive: true, force: true });
48+
});
49+
50+
/** `promise`'s rejection; a failure naming the engine when it resolves instead. */
51+
async function rejectionOf(promise: Promise<ResolvedSqliteDriver>): Promise<any> {
52+
let resolved: ResolvedSqliteDriver;
53+
try {
54+
resolved = await promise;
55+
} catch (error) {
56+
return error;
57+
}
58+
opened.push(resolved);
59+
throw new Error(`resolved to engine '${resolved.engine}' for a file that is not a SQLite database`);
60+
}
61+
62+
/** The driver's refusal of the file: its own error name, SQLite's code, and the file. */
63+
function expectNotADatabaseRefusal(error: any): void {
64+
expect(error?.name).toBe('SqliteDatabaseFileError');
65+
expect(error?.code).toBe('SQLITE_NOTADB');
66+
expect(error?.filename).toBe(filename);
67+
expect(String(error?.message)).toContain(filename);
68+
}
69+
70+
/** The operator's file is exactly as it was, and nothing was written beside it. */
71+
function expectFileUntouched(): void {
72+
expect(readFileSync(filename).equals(bytes)).toBe(true);
73+
expect(readdirSync(dir)).toEqual(['archive.db']);
74+
}
75+
76+
describe('resolveSqliteDriver in development: a file that is not a SQLite database (#24960)', () => {
77+
it('rejects with the driver\'s refusal, never wasm or memory, and leaves the file as it was', async () => {
78+
const warn = vi.fn();
79+
expectNotADatabaseRefusal(await rejectionOf(resolveSqliteDriver({ filename, dev: true, warn })));
80+
// No step-down notice: the "native better-sqlite3 unavailable" warning
81+
// named the wrong cause, and no rung was tried.
82+
expect(warn).not.toHaveBeenCalled();
83+
expectFileUntouched();
84+
});
85+
});
86+
87+
describe('resolveSqliteDriver in production: a file that is not a SQLite database (#24960)', () => {
88+
it('returns native unprobed, and its connect() refuses the file', async () => {
89+
const warn = vi.fn();
90+
const resolved = await resolveSqliteDriver({ filename, dev: false, warn });
91+
opened.push(resolved);
92+
expect(resolved.engine).toBe('better-sqlite3');
93+
let refusal: unknown;
94+
await resolved.driver.connect().catch((e: unknown) => { refusal = e; });
95+
expectNotADatabaseRefusal(refusal);
96+
expect(warn).not.toHaveBeenCalled();
97+
expectFileUntouched();
98+
});
99+
});
100+
101+
describe('DatasourceConnectionService: a declared SQLite datasource on a file that is not a database (#24960)', () => {
102+
for (const mode of ['development', 'production'] as const) {
103+
it(`${mode}: reports a failed connection state naming the file, registers no driver, and leaves the file as it was`, async () => {
104+
vi.stubEnv('NODE_ENV', mode);
105+
const registered: string[] = [];
106+
const engine = {
107+
registerDriver: (driver: { name?: string }) => { registered.push(String(driver?.name)); },
108+
getDriverByName: (name: string) => (registered.includes(name) ? {} : undefined),
109+
};
110+
const service = new DatasourceConnectionService({
111+
factory: () => createDefaultDatasourceDriverFactory({ dev: mode === 'development' }),
112+
engine: () => engine as any,
113+
});
114+
115+
await service.connectDeclared({
116+
datasources: [{ name: 'archive', driver: 'sqlite', config: { filename }, autoConnect: true }],
117+
});
118+
119+
const state = service.getConnectionState('archive');
120+
expect(state?.availability).toBe('failed');
121+
expect(state?.reason).toContain(filename);
122+
expect(registered).toEqual([]);
123+
expectFileUntouched();
124+
});
125+
}
126+
});

0 commit comments

Comments
 (0)