Skip to content

Commit da40a5f

Browse files
fix(driver-sql): connect reads PRAGMA auto_vacuum first and sets it only when the file is not already INCREMENTAL (#21744)
Fixes #21734 Clause-②: no `SqlDriver.connect()` now reads `PRAGMA auto_vacuum` first, and runs `PRAGMA auto_vacuum = INCREMENTAL` only when the file does not already answer `2`. This applies to every connect alike, with no read-only-boot special case, per triage's direction. On a file an earlier boot already configured, a connect and a whole `os migrate duplicates` run now leave the file's md5 unchanged. A fresh database still ends up `auto_vacuum=2`. QA source: the 17.7 pre-release run #21721 (`cli.migrate-duplicates-inventory`, outside the item's clauses). ## What changed - `packages/drivers/driver-sql/src/sql-driver.ts`: `applyAutoVacuumIncremental` reads first and sets only on a difference. `autoVacuumOf` parses the reply in the shape of the existing `journalModeOf`. A reply it cannot read counts as "not INCREMENTAL", so an unreadable answer costs the old write but never the mode. The docblock now says what the method does, replacing the false "no-op" sentence. The `connect()` comment names the one rule, which both pragmas follow. - `packages/drivers/driver-sql/src/sql-driver-21734-auto-vacuum-read-first.test.ts` (new) holds pins 1 and 3, plus a control and a legacy-file pin. Details below. - `packages/drivers/driver-sql/src/sql-driver-sqlite-journal-mode.test.ts`: the native-load-failure case pinned the first statement as the setter. It is now the read, `PRAGMA auto_vacuum`, which fails, so the setter is never attempted. - `packages/cli/src/commands/migrate/duplicates.integration.test.ts` holds pin 2 in a new describe. The file's `readState` comment said a plain read-write open rewrites the header counters. Measured, that is false: a plain open is byte-neutral. That fixture moves because its first connect converts it from a rollback journal to WAL. The comment now says so. - `.changeset/21734-driver-sql-auto-vacuum-read-first.md`: `patch` for `@objectstack/driver-sql`, `Clause-②: no`. File surface: the change lands where the claim expected it, in `applyAutoVacuumIncremental`. The measurement found no other producer (H3 below). ## Pins 1. **`connect()` on an `auto_vacuum=2` file leaves the md5 unchanged.** The fixture is a driver connected to a fresh file, with 50 rows written afterwards: `auto_vacuum=2`, `journal_mode=wal`, as asserted. The test takes the md5, runs `connect()` and `disconnect()` on a new driver, and compares the md5 again. - Control, same fixture: an explicit setter followed by disconnect DOES move the bytes, and only within 24 to 27 and 92 to 95, while `auto_vacuum` stays 2. This proves the md5 instrument can see the stamp that pin 1 forbids. 2. **`os migrate duplicates` on such a file leaves the md5 unchanged.** The test runs the real command in-process: `MigrateDuplicates.run(['--database-url', 'file:...'], { root: CLI_ROOT })`, with cwd moved to the fixture project. That covers the oclif parse, the read-only `bootSchemaStack` boot, the probes, the JSON payload and the shutdown. I chose in-process over a spawn because one spawn of the source entry pays a tsx compile of the whole CLI, for an answer that lies entirely inside `run()`. The in-process `MigrateResume.run` calls in `resume.recorded-by.integration.test.ts` are the precedent. The run must really look, so the test also asserts the payload reports the seeded cross-partition duplicate (`CASE-00001`). 3. **A fresh database still ends up `auto_vacuum=2`.** Covered for a fresh file and for `:memory:`. There is also a fourth pin, for unchanged behaviour (H2). A legacy NONE file that already holds tables still receives the setter. It stays NONE, and connect issues no `VACUUM`. ## Reverse verification Run from the committed fix (`061d2fb7fd`), through `scripts/ablation-replace.mjs` in WRAP mode, with a `trap` restore armed. - **Mutation:** the anchor `if (current !== SqlDriver.SQLITE_AUTO_VACUUM_INCREMENTAL) {` became `... || 'ablation-21734') {`. The setter was unconditional again. - On disk: anchor x1 to x0, replacement x0 to x1, blob `0bcf7465c257` to `b4cb6068b13b`. - Then `pnpm --filter @objectstack/driver-sql build`, and `ablation-dist-preflight` found the marker present in `dist/index.js` and `dist/index.mjs`. The CLI pin consumes driver-sql's `dist/`, so the rebuild was required. - **Readings with the setter unconditional:** - pin 1 RED: md5 `5b70462c…` against `736c98da…`; - pin 2 RED: md5 `aa43f8da…` against `d51aba83…`; - pin 3 GREEN (fresh file and `:memory:`); - control GREEN, legacy pin GREEN, the existing journal-mode suite GREEN (14), the existing #8928 integration case GREEN. - **Restore:** `git checkout HEAD -- ABS_PATH`. - The blob after restore equals HEAD (`0bcf7465c257`), `git diff HEAD` is empty, and the whole-tree `git status --porcelain` is empty. - After the rebuild, `ablation-dist-preflight --absent` reports the marker absent from all 6 built files and the tree clean. - Pins re-run green: driver-sql 19/19, CLI file 2/2. ## Mechanism hypotheses, measured - **H1, byte-neutral read: holds.** Measured with better-sqlite3 on copies of an `auto_vacuum=2`, `journal_mode=wal` file holding 200 rows. - These leave the md5 unchanged: open/close, `PRAGMA auto_vacuum`, `PRAGMA journal_mode`, `PRAGMA journal_mode = WAL`, and `SELECT count(*) FROM sqlite_master`. - `PRAGMA auto_vacuum = INCREMENTAL` alone changes offsets 27 and 95, inside 24 to 27 and 92 to 95. - The new read-then-set-if-different leaves the md5 unchanged. - **H2, fresh and legacy files: behaviour unchanged.** - A fresh file reads 0 and gets the setter. Neither the read nor the later `journal_mode = WAL` stops the mode from taking: it ends `{av: 2, wal}`. - A non-empty legacy `auto_vacuum=0` file is left at 0 by the setter alone, read on the same connection and on a fresh one; it moves only with setter plus `VACUUM`. Today's `connect()` runs no `VACUUM`, before and after this change. Only `os db clean` does. The legacy file still receives the setter, which the legacy pin asserts. - A FULL file still moves to INCREMENTAL (1 to 2). - **H3, other connect-time writes: none on an already-configured file.** `connect()` issues these statements: - `ensureDatabaseExists`: a `mkdir`, with no SQL on SQLite; - `PRAGMA auto_vacuum`, then the setter only if it differs; - `PRAGMA journal_mode`, then `PRAGMA journal_mode = WAL` only if it differs (already this shape before the change); - `SELECT count(*) FROM sqlite_master` (the WAL read-through probe). On a configured file each is byte-neutral or not issued. The whole `os migrate duplicates` run is byte-identical there (pin 2). One edge sits outside the card: a file no driver ever connected, still on a rollback journal, gets converted to WAL by the first connect of any command. That writes bytes 18, 19, 27 and 95. It is already the read-then-set shape, so there is nothing to fix here. See Acceptance notes. - **H4, dialects:** - Postgres and MySQL: both pragma steps sit inside `connect()`'s `if (this.isSqlite)` branch, so those paths are untouched. - Turso remote: `connect()` returns before `super.connect()` and sets no `auto_vacuum`, as its own docblock says. - Turso local / replica: its knex client is spelled `better-sqlite3`, so it inherits the rule through `super.connect()`. - WASM SQLite (`SqliteWasmDriver`, `isSqlite` forced true): it inherits the rule, and this measurably helps it. Its dialect marks the in-memory image dirty on any PRAGMA assignment, and the bare read is not one. Connect plus disconnect on an INCREMENTAL image file read `md5 CHANGED, mtime CHANGED` with the unconditional setter (ablation leg) and `md5 UNCHANGED, mtime UNCHANGED` with this change. ## Docs re-read after the fix - Driver docblock. Before: "Harmless / no-op on :memory: and on already-incremental databases." That was untrue, so it is replaced by the "Read first, set only when different" section, which lists the four file shapes and what each one gets. - `content/docs/deployment/cli.mdx` (`os migrate duplicates` row): "Renumbers nothing and writes nothing at all". It is not edited: on a database a serving boot configured it is now true, as pin 2 measures. The never-connected rollback-journal edge is in Acceptance notes. - `content/docs/data-modeling/drivers.mdx` ("The SQL driver connects SQLite with `auto_vacuum=INCREMENTAL`") is still true and not edited. ## Verification (at `6ffd1485a8`, origin/main `316be321ef`; the merge of origin/main was already up to date) - `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2`: 218 files passed, 11 skipped; 3633 tests passed, 206 skipped. - `pnpm --filter @objectstack/driver-sql typecheck`: exit 0. `--listFiles` includes both edited test files. - `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 src/commands/migrate/duplicates.integration.test.ts`: 2/2. The `unit` tier is untouched by this diff and is declared to CI. - `pnpm --filter @objectstack/cli typecheck`, including `check:test-typecheck`: OK (3 files, 28 errors, 6 pinned signatures, unchanged). - Downstream consumers of `connect()`: - `driver-sqlite-wasm` 36 files, 675 tests; - `driver-turso` 88 files, 2373 passed, 33 skipped. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 68 commands. I also ran the 53-family artifact-roster block printed outside that total and the four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`). That is 124 commands, all exit 0, with these exceptions: - `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` first answered exit 3 (PREREQUISITE NOT MET). After `turbo run build`, all four exit 0. The dist-reading gates `check:dts-closure`, `check:lean-entry-closure` and `check:published-files` were re-run after that build: exit 0. - The three PR-context scripts (`check-closing-target-claim.mjs`, `check-partof-closing-keyword.mjs`, `check-single-claim-paths.mjs`) answer exit 2 NOT WIRED without a pull request. Their `--self-test` forms exit 0. `check-partof-closing-keyword.mjs` was also run with `PR_BODY` set to this body. - Reconciliation: `dispatch-gates --ran` reports "68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)". - Lint, as a proven narrowing rather than a repo-wide run: - Scope: the scan population is `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, which covers all four edited `.ts` files. - Count: `eslint --no-inline-config --format json` on them linted 4 files, with 0 errors and 0 warnings. - Why the narrowing is safe: the config never enables type-aware linting (no `parserOptions.project`, as `eslint.config.mjs` states), so this diff cannot move a verdict on an untouched file. Full `pnpm lint` is CI's. ## Acceptance notes - **A never-connected rollback-journal file is converted to WAL by its first connect, a read-only `os migrate duplicates` included.** - Measured through the command's real `run()`, in-process, on a fixture written without connecting (`auto_vacuum=0`, `journal_mode=delete`). The run moved offsets 18, 19, 27 and 95, and the file ended `journal_mode=wal`. On the configured fixture the same run moved nothing. - This is the journal-mode step's one-time configuration, already read-then-set. It only reaches a SQLite file that no current-version driver has ever connected to. It is noted, not changed here: triage ruled "one rule for every connect", and the card's pins cover the already-configured file. The `cli.mdx` sentence "writes nothing at all" is not literally true on such a file. This is raised in the report for the seat to decide. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6afb1b5 commit da40a5f

5 files changed

Lines changed: 344 additions & 18 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
A connect to a SQLite file that is already `auto_vacuum=INCREMENTAL` no longer writes to it. `SqlDriver.connect()` now reads `PRAGMA auto_vacuum` first and runs `PRAGMA auto_vacuum = INCREMENTAL` only when the file answers something else.
6+
7+
Clause-②: no
8+
9+
- **What changed on disk.** On a file that already answered INCREMENTAL, the setter changed no mode, but it still stamped two header counters: the file change counter (bytes 24–27) and the version-valid-for number (bytes 92–95). So a read-only command such as `os migrate duplicates`, which the CLI docs say writes nothing at all, changed the file's md5 on every run. Reading the pragma changes no bytes, so such a file now comes through a connect, and a whole `os migrate duplicates` run, byte-identical.
10+
- **Unchanged.** A fresh file and `:memory:` still come out INCREMENTAL. A legacy NONE file that already holds tables still gets the setter, which leaves it NONE until a `VACUUM` (`os db clean`), exactly as before. The journal-mode step already read first and set only on a difference, and it is untouched. Postgres and MySQL issue no PRAGMA.
11+
- **The WASM SQLite driver** inherits the rule. A connect and disconnect no longer rewrites an already-INCREMENTAL image file, because the setter was what marked the image dirty.
12+
- ⛔ No config key, export, error code or accepted input changes.

‎packages/cli/src/commands/migrate/duplicates.integration.test.ts‎

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@
3838
* Both classes are asserted, separately, over one run.
3939
*/
4040

41-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
42-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
41+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
42+
import { createHash } from 'node:crypto';
43+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4344
import { tmpdir } from 'node:os';
44-
import { join } from 'node:path';
45+
import { dirname, join, resolve } from 'node:path';
46+
import { fileURLToPath } from 'node:url';
4547
import { SqlDriver } from '@objectstack/driver-sql';
4648
import {
4749
resolveSeedTenancyExec,
@@ -53,7 +55,12 @@ import {
5355
SEQUENCES_TABLE,
5456
} from '@objectstack/metadata-protocol';
5557
import { bootSchemaStack } from '../../utils/schema-migrate.js';
56-
import { collectDuplicateIdentifierReport } from './duplicates.js';
58+
import { isExitSignal } from '../../utils/format.js';
59+
import MigrateDuplicates, { collectDuplicateIdentifierReport } from './duplicates.js';
60+
61+
const HERE = dirname(fileURLToPath(import.meta.url));
62+
/** This package's root, where oclif reads its own manifest from. */
63+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
5764

5865
let dir: string;
5966
let dbFile: string;
@@ -63,12 +70,15 @@ const savedEnv: Record<string, string | undefined> = {};
6370
* The fixture's LOGICAL state — the schema plus every row of every table,
6471
* ordered — read with a connection of our own, never the booted stack's.
6572
*
66-
* ⚠️ Deliberately not a hash of the database FILE. SQLite rewrites header
67-
* bytes (the change counter, the version-valid-for cookie) on any read-write
68-
* open, so a file hash reports a difference after a run that only SELECTed and
69-
* would accuse this command of mutating the install it exists to describe —
70-
* measured, and very nearly filed as a defect. What must not change is the
71-
* schema and the rows.
73+
* ⚠️ Deliberately not a hash of the database FILE — not on THIS fixture. It is
74+
* written by a driver that never connected, so it is still on a rollback
75+
* journal, and the boot's first connect converts it to WAL: a persistent
76+
* header change (bytes 18–19, plus the change counter at 24–27 and the
77+
* version-valid-for number at 92–95) that any first connect makes, measured.
78+
* A plain open is byte-neutral, and a file a serving boot already configured
79+
* comes through a whole run byte-identical — the second describe below pins
80+
* that on its own fixture (#21734). What must not change HERE is the schema
81+
* and the rows.
7282
*/
7383
async function readState(): Promise<unknown> {
7484
const probe = new SqlDriver({
@@ -283,3 +293,103 @@ describe('#8928 os migrate duplicates — against a really booted stack', () =>
283293
expect(await readState()).toEqual(before);
284294
}, 120_000);
285295
});
296+
297+
/**
298+
* #21734 — the database FILE, not only its rows, comes out of a run unchanged.
299+
*
300+
* `SqlDriver.connect()` used to run `PRAGMA auto_vacuum = INCREMENTAL` on every
301+
* connect. On a file a serving boot had already configured the setter changes
302+
* no mode, but it stamps two header counters (bytes 24–27 and 92–95), so the
303+
* whole-file md5 moved under a command that only SELECTed. The driver now reads
304+
* the pragma first and sets it only when the file answers differently.
305+
*
306+
* Measured on a fixture shaped the way an install is: a driver CONNECTED to a
307+
* fresh file first — so it is `auto_vacuum=INCREMENTAL` and in WAL, as `os dev`
308+
* leaves it — and the rows written after. The command is the real one, run
309+
* in-process (oclif parse, the read-only boot, the probes, the JSON payload,
310+
* the shutdown) rather than spawned: one spawn of the source entry costs a tsx
311+
* compile of the whole CLI for an answer that lies entirely inside `run()`.
312+
*/
313+
describe('#21734 os migrate duplicates — an already-configured database file is byte-identical after a run', () => {
314+
const OVERRIDING_ENV = ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME'] as const;
315+
let booted: string;
316+
317+
const md5 = (file: string): string => createHash('md5').update(readFileSync(file)).digest('hex');
318+
319+
/** The command's one JSON document and its exit code, from an in-process run. */
320+
async function runDuplicates(argv: string[]): Promise<{ payload: any; exitCode: number }> {
321+
const savedExit = process.exitCode;
322+
const savedCwd = process.cwd();
323+
const saved: Record<string, string | undefined> = {};
324+
for (const key of OVERRIDING_ENV) saved[key] = process.env[key];
325+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
326+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
327+
if (cb) cb();
328+
return true;
329+
}) as typeof process.stdout.write;
330+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
331+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
332+
let thrownExit: number | undefined;
333+
try {
334+
for (const key of OVERRIDING_ENV) delete process.env[key];
335+
// The command has no project-root flag: it boots from the current directory.
336+
process.chdir(dir);
337+
try {
338+
await MigrateDuplicates.run(argv, { root: CLI_ROOT });
339+
} catch (error) {
340+
if (!isExitSignal(error)) throw error;
341+
thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit;
342+
}
343+
const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim();
344+
return { payload: JSON.parse(out), exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0 };
345+
} finally {
346+
process.chdir(savedCwd);
347+
for (const [key, value] of Object.entries(saved)) {
348+
if (value === undefined) delete process.env[key];
349+
else process.env[key] = value;
350+
}
351+
process.exitCode = savedExit;
352+
vi.restoreAllMocks();
353+
}
354+
}
355+
356+
beforeAll(async () => {
357+
booted = join(dir, 'data', 'booted.db');
358+
const d = new SqlDriver({ client: 'better-sqlite3', connection: { filename: booted }, useNullAsDefault: true });
359+
await d.connect();
360+
const k = (d as any).knex;
361+
await k.schema.createTable('crm_case', (t: any) => {
362+
t.string('id').primary();
363+
t.timestamp('created_at');
364+
t.timestamp('updated_at');
365+
t.string('organization_id');
366+
t.string('subject');
367+
t.string('case_number');
368+
});
369+
await k('crm_case').insert([
370+
{ id: 's1', created_at: '2026-01-01T00:00:00.000Z', organization_id: null, subject: 'seeded', case_number: 'CASE-00001' },
371+
{ id: 'a1', created_at: '2026-02-01T00:00:00.000Z', organization_id: 'org_x', subject: 'api', case_number: 'CASE-00001' },
372+
]);
373+
await d.disconnect();
374+
}, 120_000);
375+
376+
it('`os migrate duplicates` leaves the md5 of an auto_vacuum=INCREMENTAL WAL file unchanged', async () => {
377+
// The precondition, read on a connection of our own (a read is byte-neutral).
378+
const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: booted }, useNullAsDefault: true });
379+
try {
380+
const k = (probe as any).knex;
381+
expect((await k.raw('PRAGMA auto_vacuum'))[0].auto_vacuum).toBe(2);
382+
expect(String((await k.raw('PRAGMA journal_mode'))[0].journal_mode).toLowerCase()).toBe('wal');
383+
} finally {
384+
await probe.disconnect();
385+
}
386+
const before = md5(booted);
387+
388+
const { payload, exitCode } = await runDuplicates(['--database-url', `file:${booted}`]);
389+
390+
expect(exitCode).toBe(0);
391+
// It really did look — a run that read nothing would leave the file alone trivially.
392+
expect(payload.duplicates.map((entry: { value: string }) => entry.value)).toEqual(['CASE-00001']);
393+
expect(md5(booted)).toBe(before);
394+
}, 120_000);
395+
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #21734: `SqlDriver.connect()` used to run `PRAGMA auto_vacuum = INCREMENTAL`
4+
// on every connect. On a file that already answers INCREMENTAL the setter
5+
// changes no mode, but it still runs a write transaction that stamps two header
6+
// counters — the file change counter (bytes 24–27) and the version-valid-for
7+
// number (bytes 92–95) — so a connect made only to READ (`os migrate
8+
// duplicates`) left the file's md5 different from how it found it. The driver
9+
// now reads `PRAGMA auto_vacuum` first (byte-neutral) and sets it only when the
10+
// file does not answer INCREMENTAL, on every connect alike.
11+
//
12+
// What is pinned:
13+
// 1. an already-INCREMENTAL WAL file comes out of connect + disconnect
14+
// byte-identical — with a control proving the md5 instrument sees the
15+
// setter's header stamp on the same file;
16+
// 2. a fresh file and `:memory:` still come out INCREMENTAL (ADR-0057);
17+
// 3. a legacy NONE file that already holds tables still gets the setter it
18+
// always got, and stays NONE — this driver runs no VACUUM, before or after;
19+
// 4. the non-SQLite dialects issue no PRAGMA at all.
20+
21+
import { describe, it, expect, afterEach } from 'vitest';
22+
import { createHash } from 'node:crypto';
23+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
24+
import { tmpdir } from 'node:os';
25+
import { join } from 'node:path';
26+
import { SqlDriver } from './sql-driver.js';
27+
28+
const dirs: string[] = [];
29+
const drivers: SqlDriver[] = [];
30+
31+
function tempDb(): string {
32+
const dir = mkdtempSync(join(tmpdir(), 'os-21734-'));
33+
dirs.push(dir);
34+
return join(dir, 'app.db');
35+
}
36+
37+
function make(cfg: Record<string, unknown>): SqlDriver & { knex: any } {
38+
const d = new SqlDriver({ useNullAsDefault: true, ...cfg } as any);
39+
drivers.push(d);
40+
return d as any;
41+
}
42+
43+
function sqlite(filename: string): SqlDriver & { knex: any } {
44+
return make({ client: 'better-sqlite3', connection: { filename } });
45+
}
46+
47+
/** Every statement the driver sends, in order — knex's own `query` event. */
48+
function trace(driver: any): string[] {
49+
const seen: string[] = [];
50+
driver.knex.on('query', (q: { sql?: unknown }) => seen.push(String(q.sql)));
51+
return seen;
52+
}
53+
54+
const md5 = (file: string): string => createHash('md5').update(readFileSync(file)).digest('hex');
55+
56+
/** Byte offsets at which two images differ. */
57+
function differingOffsets(a: Buffer, b: Buffer): number[] {
58+
const out: number[] = [];
59+
for (let i = 0; i < Math.max(a.length, b.length); i++) if (a[i] !== b[i]) out.push(i);
60+
return out;
61+
}
62+
63+
/** `PRAGMA auto_vacuum` / `journal_mode` as a fresh connection of our own reads them. */
64+
async function fileModes(file: string): Promise<{ autoVacuum: number; journalMode: string }> {
65+
const probe = sqlite(file);
66+
try {
67+
const av = await probe.knex.raw('PRAGMA auto_vacuum');
68+
const jm = await probe.knex.raw('PRAGMA journal_mode');
69+
return { autoVacuum: Number(av[0].auto_vacuum), journalMode: String(jm[0].journal_mode).toLowerCase() };
70+
} finally {
71+
await probe.disconnect();
72+
}
73+
}
74+
75+
/** An install as a serving boot leaves it: connected once on a fresh file, then holding rows. */
76+
async function bootedOnce(file: string): Promise<void> {
77+
const d = sqlite(file);
78+
await d.connect();
79+
await d.knex.raw('CREATE TABLE t (id integer primary key, v text)');
80+
for (let i = 0; i < 50; i++) await d.knex.raw('INSERT INTO t (v) VALUES (?)', [`row-${i}`]);
81+
await d.disconnect();
82+
}
83+
84+
afterEach(async () => {
85+
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
86+
dirs.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
87+
});
88+
89+
describe('SqlDriver — auto_vacuum is read first and set only when different (#21734)', () => {
90+
it('a connect to an already-INCREMENTAL file leaves the file byte-identical', async () => {
91+
const file = tempDb();
92+
await bootedOnce(file);
93+
expect(await fileModes(file)).toEqual({ autoVacuum: 2, journalMode: 'wal' });
94+
const before = md5(file);
95+
96+
const d = sqlite(file);
97+
await d.connect();
98+
await d.disconnect();
99+
100+
expect(md5(file)).toBe(before);
101+
});
102+
103+
it('control: the md5 instrument does see the setter stamp the header of that same file', async () => {
104+
const file = tempDb();
105+
await bootedOnce(file);
106+
const image = readFileSync(file);
107+
108+
const d = sqlite(file);
109+
await d.knex.raw('PRAGMA auto_vacuum = INCREMENTAL');
110+
await d.disconnect();
111+
112+
const offsets = differingOffsets(image, readFileSync(file));
113+
// A pin 1 that could not fail would read green here too; it must not.
114+
expect(offsets.length).toBeGreaterThan(0);
115+
// The two header counters, and nothing else: the mode itself did not move.
116+
for (const at of offsets) expect((at >= 24 && at <= 27) || (at >= 92 && at <= 95)).toBe(true);
117+
expect((await fileModes(file)).autoVacuum).toBe(2);
118+
});
119+
120+
it('a fresh file still comes out INCREMENTAL', async () => {
121+
const file = tempDb();
122+
const d = sqlite(file);
123+
await d.connect();
124+
await d.knex.raw('CREATE TABLE t (a integer)');
125+
await d.disconnect();
126+
127+
expect((await fileModes(file)).autoVacuum).toBe(2);
128+
});
129+
130+
it('`:memory:` still comes out INCREMENTAL', async () => {
131+
const d = sqlite(':memory:');
132+
await d.connect();
133+
const av = await d.knex.raw('PRAGMA auto_vacuum');
134+
expect(Number(av[0].auto_vacuum)).toBe(2);
135+
});
136+
137+
it('a legacy NONE file that holds tables still gets the setter, and stays NONE (no VACUUM is run)', async () => {
138+
const file = tempDb();
139+
// Written without ever connecting, so nothing set its mode: NONE, with a table.
140+
const legacy = sqlite(file);
141+
await legacy.knex.raw('CREATE TABLE t (a integer)');
142+
await legacy.knex.raw('INSERT INTO t VALUES (1)');
143+
await legacy.disconnect();
144+
expect((await fileModes(file)).autoVacuum).toBe(0);
145+
146+
const d = sqlite(file);
147+
const seen = trace(d);
148+
await d.connect();
149+
await d.disconnect();
150+
151+
expect(seen).toContain('PRAGMA auto_vacuum = INCREMENTAL');
152+
expect(seen.some((sql) => /^\s*VACUUM\b/i.test(sql))).toBe(false);
153+
expect((await fileModes(file)).autoVacuum).toBe(0);
154+
});
155+
});

‎packages/drivers/driver-sql/src/sql-driver-sqlite-journal-mode.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,9 @@ describe('SqlDriver — SQLite journal mode (#3941)', () => {
291291

292292
await expect(d.connect()).resolves.toBeUndefined();
293293

294-
expect(attempted).toEqual(['PRAGMA auto_vacuum = INCREMENTAL']);
294+
// The first statement is the auto_vacuum READ (#21734); it fails, so not
295+
// even the setter is attempted.
296+
expect(attempted).toEqual(['PRAGMA auto_vacuum']);
295297
expect(warnings).toHaveLength(1);
296298
expect(warnings[0]).toMatch(/native better-sqlite3 unavailable/);
297299
});

0 commit comments

Comments
 (0)