|
1 | | -import { readFileSync, readdirSync } from "node:fs"; |
| 1 | +import { createHash } from "node:crypto"; |
| 2 | +import { |
| 3 | + copyFileSync, |
| 4 | + existsSync, |
| 5 | + readFileSync, |
| 6 | + readdirSync, |
| 7 | + renameSync, |
| 8 | + unlinkSync, |
| 9 | +} from "node:fs"; |
| 10 | +import { tmpdir } from "node:os"; |
| 11 | +import { join } from "node:path"; |
2 | 12 | import { DatabaseSync } from "node:sqlite"; |
3 | 13 |
|
4 | 14 | type BoundValue = string | number | null | Uint8Array; |
5 | 15 |
|
6 | | -// Listing + reading migrations/*.sql (~90 files) on every TestD1Database construction (~1500 call sites |
7 | | -// across the suite) is pure overhead: the file list and contents never change within a worker process's |
8 | | -// lifetime. Cache the concatenated SQL once per process instead of re-reading it every call. |
| 16 | +// Executing the full migrations/*.sql chain (178 files and growing) into a fresh :memory: database on |
| 17 | +// EVERY TestD1Database construction (~1500 call sites across the suite) was the suite's single biggest |
| 18 | +// time sink: ~640ms per construction, ~950s aggregate per full run, and it inflated ordinary tests into |
| 19 | +// the 3s+ range. Instead, build ONE fully-migrated template database file per migration-chain content |
| 20 | +// (shared across every vitest worker), then give each instance its own copyFileSync clone: ~1.5ms per |
| 21 | +// construction including a verified write, identical schema, fully isolated state. |
9 | 22 | // |
10 | | -// NOT using node:sqlite's serialize()/deserialize() here: they would let one migrated template be cloned |
11 | | -// per instance instead of re-executing the SQL each time (a much bigger win), but they don't exist on this |
12 | | -// repo's pinned Node 22 (`.nvmrc`) at all -- confirmed absent from DatabaseSync's prototype on Node 22.23.1, |
13 | | -// present only from Node 24+. An earlier version of this cache used them and passed locally on a newer Node, |
14 | | -// but crashed every test in CI (`db.deserialize is not a function`) since CI runs the pinned Node 22. Stick |
15 | | -// to what Node 22 actually supports. |
16 | | -let migratedSql: string | null = null; |
17 | | -function getMigratedSql(): string { |
18 | | - if (migratedSql) return migratedSql; |
19 | | - migratedSql = readdirSync("migrations") |
| 23 | +// Design constraints this shape answers (each learned the hard way): |
| 24 | +// - node:sqlite's serialize()/deserialize() (the in-memory equivalent of this clone) don't exist on the |
| 25 | +// pinned Node 22 (`.nvmrc`) at all -- absent from DatabaseSync's prototype on 22.23.1, present only |
| 26 | +// from Node 24+. An earlier attempt used them and crashed every test in CI. File copy is Node-22-safe. |
| 27 | +// - The clone file must NOT be unlinked while its database is open: SQLite detects the missing main file |
| 28 | +// and fails every later write with SQLITE_READONLY_DBMOVED ("attempt to write a readonly database"). |
| 29 | +// Clones therefore stay on disk until the single exit sweep below removes them. |
| 30 | +// - Vitest's per-file module isolation re-evaluates this module constantly, so ALL memoization lives on |
| 31 | +// globalThis, never in module-scope state -- module-scope memos would rebuild the template once per |
| 32 | +// test FILE (~640ms each), silently giving back most of the win. |
| 33 | +// - The template is keyed by a hash of the concatenated migration SQL, and built at a `.tmp` sibling |
| 34 | +// then renameSync'd (atomic) into place -- concurrent workers can double-build harmlessly, but no |
| 35 | +// reader can ever copy a half-written template, and a schema change gets a fresh key instead of a |
| 36 | +// stale reuse. page_size=1024 + VACUUM shrink the empty-schema template ~3.4x (1.4MB -> ~410KB), which |
| 37 | +// bounds worst-case tmpdir usage for a full run's clones to a few hundred MB, swept at worker exit. |
| 38 | +type TestD1GlobalState = { |
| 39 | + templatePath?: string; |
| 40 | + cloneCounter: number; |
| 41 | + clonePaths: string[]; |
| 42 | + exitSweepRegistered: boolean; |
| 43 | +}; |
| 44 | + |
| 45 | +function testD1State(): TestD1GlobalState { |
| 46 | + const holder = globalThis as { __loopoverTestD1State?: TestD1GlobalState }; |
| 47 | + holder.__loopoverTestD1State ??= { |
| 48 | + cloneCounter: 0, |
| 49 | + clonePaths: [], |
| 50 | + exitSweepRegistered: false, |
| 51 | + }; |
| 52 | + return holder.__loopoverTestD1State; |
| 53 | +} |
| 54 | + |
| 55 | +function getMigratedTemplatePath(): string { |
| 56 | + const state = testD1State(); |
| 57 | + if (state.templatePath) return state.templatePath; |
| 58 | + const migratedSql = readdirSync("migrations") |
20 | 59 | .filter((file) => file.endsWith(".sql")) |
21 | 60 | .sort() |
22 | 61 | .map((file) => readFileSync(`migrations/${file}`, "utf8")) |
23 | 62 | .join("\n"); |
24 | | - return migratedSql; |
| 63 | + const key = createHash("sha256").update(migratedSql).digest("hex").slice(0, 16); |
| 64 | + const path = join(tmpdir(), `loopover-test-migrated-${key}.sqlite3`); |
| 65 | + if (!existsSync(path)) { |
| 66 | + const buildPath = `${path}.${process.pid}.tmp`; |
| 67 | + const template = new DatabaseSync(buildPath); |
| 68 | + template.exec("PRAGMA page_size=1024;"); |
| 69 | + template.exec(migratedSql); |
| 70 | + template.exec("VACUUM;"); |
| 71 | + template.close(); |
| 72 | + renameSync(buildPath, path); |
| 73 | + } |
| 74 | + state.templatePath = path; |
| 75 | + return path; |
25 | 76 | } |
26 | 77 |
|
27 | 78 | export class TestD1Database { |
28 | | - readonly db = new DatabaseSync(":memory:"); |
| 79 | + readonly db: DatabaseSync; |
29 | 80 |
|
30 | 81 | constructor() { |
31 | | - this.db.exec(getMigratedSql()); |
| 82 | + const state = testD1State(); |
| 83 | + const clonePath = join( |
| 84 | + tmpdir(), |
| 85 | + `loopover-test-clone-${process.pid}-${state.cloneCounter++}-${Math.random().toString(36).slice(2, 8)}.sqlite3`, |
| 86 | + ); |
| 87 | + copyFileSync(getMigratedTemplatePath(), clonePath); |
| 88 | + this.db = new DatabaseSync(clonePath); |
| 89 | + state.clonePaths.push(clonePath); |
| 90 | + if (!state.exitSweepRegistered) { |
| 91 | + state.exitSweepRegistered = true; |
| 92 | + process.on("exit", () => { |
| 93 | + for (const path of state.clonePaths) { |
| 94 | + try { |
| 95 | + unlinkSync(path); |
| 96 | + } catch { |
| 97 | + // best-effort tmp hygiene only |
| 98 | + } |
| 99 | + } |
| 100 | + }); |
| 101 | + } |
32 | 102 | } |
33 | 103 |
|
34 | 104 | prepare(sql: string) { |
|
0 commit comments