diff --git a/src/infra/sqlite-snapshot.test.ts b/src/infra/sqlite-snapshot.test.ts index eff453555ac8..2b3fb1a9e344 100644 --- a/src/infra/sqlite-snapshot.test.ts +++ b/src/infra/sqlite-snapshot.test.ts @@ -3,7 +3,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { requireNodeSqlite } from "./node-sqlite.js"; import { createPrivateSqliteDirectory } from "./sqlite-private-directory.js"; @@ -46,6 +46,7 @@ function isDirectoryOpen(flags: string | number | undefined): boolean { afterEach(async () => { durabilityTestState.syncOutcome = undefined; + vi.restoreAllMocks(); await Promise.all(tempDirs.splice(0).map((tempDir) => fs.rm(tempDir, { recursive: true }))); }); @@ -151,23 +152,76 @@ function appendSuperJournalPointer(journalPath: string, superJournalPath: string fsSync.appendFileSync(journalPath, trailer); } -function createEmptySqliteDatabase( - sqlite: ReturnType, - sqlitePath: string, -): void { - const database = new sqlite.DatabaseSync(sqlitePath); +let sqlite: ReturnType; +let sourcePath: string; +let targetPath: string; +let tempDir: string; + +beforeEach(async () => { + tempDir = await createTempDir(); + sourcePath = path.join(tempDir, "source.sqlite"); + targetPath = path.join(tempDir, "snapshot.sqlite"); + sqlite = requireNodeSqlite(); + const database = new sqlite.DatabaseSync(sourcePath); try { database.exec("VACUUM;"); } finally { database.close(); } +}); + +type SnapshotOptions = Parameters[0]; + +async function expectSnapshotSuccess(options: SnapshotOptions): Promise { + await expect(createVerifiedSqliteSnapshot(options)).resolves.toEqual({ + path: options.targetPath, + userVersion: 0, + }); +} + +async function expectSnapshotFailureWithoutTarget( + options: SnapshotOptions, + pattern: RegExp, +): Promise { + await expect(createVerifiedSqliteSnapshot(options)).rejects.toThrow(pattern); + await expect(fs.access(options.targetPath)).rejects.toMatchObject({ code: "ENOENT" }); +} + +async function expectRejectedPublicationGuard( + phase: "afterPublish" | "beforePublish", + message: string, +): Promise { + let guarded = false; + const reject = () => { + guarded = true; + throw new Error(message); + }; + const options: SnapshotOptions = + phase === "afterPublish" + ? { sourcePath, targetPath, afterPublish: reject } + : { sourcePath, targetPath, beforePublish: reject }; + await expect(createVerifiedSqliteSnapshot(options)).rejects.toThrow(message); + expect(guarded).toBe(true); + await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); +} + +function withReadOnlySnapshot( + sqliteModule: ReturnType, + snapshotPath: string, + operation: (snapshot: import("node:sqlite").DatabaseSync) => T, +): T { + const snapshot = new sqliteModule.DatabaseSync(snapshotPath, { readOnly: true }); + try { + return operation(snapshot); + } finally { + snapshot.close(); + } } describe("createVerifiedSqliteSnapshot", () => { it.runIf(process.platform === "win32")( "creates private staging directories exclusively under races", async () => { - const tempDir = await createTempDir(); const directoryPath = path.join(tempDir, "private"); const results = await Promise.allSettled([ createPrivateSqliteDirectory(directoryPath), @@ -185,45 +239,32 @@ describe("createVerifiedSqliteSnapshot", () => { it.runIf(process.platform === "win32")( "snapshots when its private staging path exceeds MAX_PATH", async () => { - const tempDir = await createTempDir(); let targetDirectory = tempDir; while (targetDirectory.length < 205) { targetDirectory = path.join(targetDirectory, `segment-${"x".repeat(24)}`); } await fs.mkdir(targetDirectory, { recursive: true }); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(targetDirectory, "snapshot.sqlite"); + const longTargetPath = path.join(targetDirectory, "snapshot.sqlite"); const longestStagingPath = path.join( targetDirectory, `.sqlite-publish-${"0".repeat(36)}-${"0".repeat(36)}`, "database.sqlite", ); - expect(targetPath.length).toBeLessThan(260); + expect(longTargetPath.length).toBeLessThan(260); expect(longestStagingPath.length).toBeGreaterThan(260); - const sqlite = requireNodeSqlite(); const source = new sqlite.DatabaseSync(sourcePath); source.exec("CREATE TABLE records (value TEXT NOT NULL); INSERT INTO records VALUES ('ok');"); source.close(); - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).resolves.toEqual({ - path: targetPath, - userVersion: 0, - }); - const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - try { + await expectSnapshotSuccess({ sourcePath, targetPath: longTargetPath }); + withReadOnlySnapshot(sqlite, longTargetPath, (snapshot) => { expect(snapshot.prepare("SELECT value FROM records").get()).toEqual({ value: "ok" }); - } finally { - snapshot.close(); - } + }); }, ); it("captures committed WAL state and removes deleted page contents", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); const deletedValue = `deleted-secret-${"x".repeat(256)}`; - const sqlite = requireNodeSqlite(); const source = new sqlite.DatabaseSync(sourcePath); try { source.exec(` @@ -241,17 +282,14 @@ describe("createVerifiedSqliteSnapshot", () => { expect(result).toEqual({ path: targetPath, userVersion: 0 }); expect((await fs.readFile(targetPath)).includes(deletedValue)).toBe(false); - const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - try { + withReadOnlySnapshot(sqlite, targetPath, (snapshot) => { expect(snapshot.prepare("SELECT value FROM records").all()).toEqual([ { value: "survivor" }, ]); expect(snapshot.prepare("PRAGMA journal_mode;").get()).toEqual({ journal_mode: "delete", }); - } finally { - snapshot.close(); - } + }); await expect(fs.access(`${targetPath}-wal`)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.access(`${targetPath}-shm`)).rejects.toMatchObject({ code: "ENOENT" }); } finally { @@ -262,23 +300,15 @@ describe("createVerifiedSqliteSnapshot", () => { it.skipIf(process.platform === "win32")( "snapshots committed state from a hot rollback journal without recovering the source", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); createHotRollbackJournal(sourcePath); const sourceBefore = await fs.readFile(sourcePath); const journalBefore = await fs.readFile(`${sourcePath}-journal`); - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).resolves.toEqual({ - path: targetPath, - userVersion: 0, - }); + await expectSnapshotSuccess({ sourcePath, targetPath }); await expect(fs.readFile(sourcePath)).resolves.toEqual(sourceBefore); await expect(fs.readFile(`${sourcePath}-journal`)).resolves.toEqual(journalBefore); - const sqlite = requireNodeSqlite(); - const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - try { + withReadOnlySnapshot(sqlite, targetPath, (snapshot) => { expect( snapshot.prepare("SELECT COUNT(*) AS count FROM records WHERE value = 'committed'").get(), ).toEqual({ count: 256 }); @@ -290,9 +320,7 @@ describe("createVerifiedSqliteSnapshot", () => { expect(snapshot.prepare("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok", }); - } finally { - snapshot.close(); - } + }); await expect(fs.access(`${targetPath}-journal`)).rejects.toMatchObject({ code: "ENOENT" }); }, ); @@ -300,9 +328,6 @@ describe("createVerifiedSqliteSnapshot", () => { it.skipIf(process.platform === "win32")( "rechecks for a hot rollback journal after the direct source open fails", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); createHotRollbackJournal(sourcePath); const canonicalJournalPath = `${fsSync.realpathSync.native(sourcePath)}-journal`; const lstatSync = fsSync.lstatSync.bind(fsSync); @@ -317,26 +342,19 @@ describe("createVerifiedSqliteSnapshot", () => { return lstatSync(pathname, options as never); }) as typeof fsSync.lstatSync); - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).resolves.toEqual({ - path: targetPath, - userVersion: 0, - }); + await expectSnapshotSuccess({ sourcePath, targetPath }); expect(hidJournal).toBe(true); - const sqlite = requireNodeSqlite(); - const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - expect( - snapshot.prepare("SELECT COUNT(*) AS count FROM records WHERE value = 'committed'").get(), - ).toEqual({ count: 256 }); - snapshot.close(); + withReadOnlySnapshot(sqlite, targetPath, (snapshot) => { + expect( + snapshot.prepare("SELECT COUNT(*) AS count FROM records WHERE value = 'committed'").get(), + ).toEqual({ count: 256 }); + }); }, ); it.skipIf(process.platform === "win32")( "refuses private recovery when a hot journal depends on a super-journal", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); const superJournalPath = path.join(tempDir, "source-mj000000900"); createHotRollbackJournal(sourcePath); await fs.writeFile(superJournalPath, "super-journal"); @@ -357,10 +375,6 @@ describe("createVerifiedSqliteSnapshot", () => { ); it("ignores a stale rollback journal without changing the source family", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); const source = new sqlite.DatabaseSync(sourcePath); source.exec("CREATE TABLE records (value TEXT NOT NULL); INSERT INTO records VALUES ('ok');"); source.close(); @@ -368,51 +382,31 @@ describe("createVerifiedSqliteSnapshot", () => { await fs.writeFile(`${sourcePath}-journal`, staleJournal); const sourceBefore = await fs.readFile(sourcePath); - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).resolves.toEqual({ - path: targetPath, - userVersion: 0, - }); + await expectSnapshotSuccess({ sourcePath, targetPath }); await expect(fs.readFile(sourcePath)).resolves.toEqual(sourceBefore); await expect(fs.readFile(`${sourcePath}-journal`)).resolves.toEqual(staleJournal); - const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - expect(snapshot.prepare("SELECT value FROM records").get()).toEqual({ value: "ok" }); - snapshot.close(); + withReadOnlySnapshot(sqlite, targetPath, (snapshot) => { + expect(snapshot.prepare("SELECT value FROM records").get()).toEqual({ value: "ok" }); + }); }); it("uses online backup before compacting the private copy", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - const source = new sqlite.DatabaseSync(sourcePath); - source.exec("CREATE TABLE records (value TEXT NOT NULL); INSERT INTO records VALUES ('ok');"); - source.close(); + const setup = new sqlite.DatabaseSync(sourcePath); + setup.exec("CREATE TABLE records (value TEXT NOT NULL); INSERT INTO records VALUES ('ok');"); + setup.close(); const backupSpy = vi.spyOn(sqlite, "backup"); const prepareSpy = vi.spyOn(sqlite.DatabaseSync.prototype, "prepare"); - try { - await createVerifiedSqliteSnapshot({ sourcePath, targetPath }); - - expect(backupSpy).toHaveBeenCalledTimes(1); - expect(prepareSpy.mock.calls.some(([sql]) => /\bVACUUM\s+INTO\b/iu.test(sql))).toBe(false); - const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - try { - expect(snapshot.prepare("SELECT value FROM records").get()).toEqual({ value: "ok" }); - } finally { - snapshot.close(); - } - } finally { - prepareSpy.mockRestore(); - backupSpy.mockRestore(); - } + await createVerifiedSqliteSnapshot({ sourcePath, targetPath }); + expect(backupSpy).toHaveBeenCalledTimes(1); + expect(prepareSpy.mock.calls.some(([sql]) => /\bVACUUM\s+INTO\b/iu.test(sql))).toBe(false); + withReadOnlySnapshot(sqlite, targetPath, (snapshot) => { + expect(snapshot.prepare("SELECT value FROM records").get()).toEqual({ value: "ok" }); + }); }); it("pins validation and backup to one WAL snapshot", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); const writer = new sqlite.DatabaseSync(sourcePath); writer.exec(` PRAGMA journal_mode = WAL; @@ -422,7 +416,7 @@ describe("createVerifiedSqliteSnapshot", () => { PRAGMA wal_checkpoint(TRUNCATE); `); const backup = sqlite.backup.bind(sqlite); - const backupSpy = vi.spyOn(sqlite, "backup").mockImplementationOnce(async (...args) => { + vi.spyOn(sqlite, "backup").mockImplementationOnce(async (...args) => { writer.prepare("INSERT INTO records VALUES (?)").run("during"); return await backup(...args); }); @@ -434,67 +428,46 @@ describe("createVerifiedSqliteSnapshot", () => { { value: "before" }, { value: "during" }, ]); - const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - try { + withReadOnlySnapshot(sqlite, targetPath, (snapshot) => { expect(snapshot.prepare("SELECT value FROM records ORDER BY rowid").all()).toEqual([ { value: "before" }, ]); - } finally { - snapshot.close(); - } + }); } finally { - backupSpy.mockRestore(); writer.close(); } }); it("rejects unsafe index drift and removes the failed target", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); createUnsafeIndexDrift(sourcePath); - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath }, /integrity_check failed|malformed database schema/iu, ); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("snapshots a zero-byte generic source as an empty SQLite database", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); await fs.writeFile(sourcePath, ""); - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).resolves.toEqual({ - path: targetPath, - userVersion: 0, - }); + await expectSnapshotSuccess({ sourcePath, targetPath }); expect((await fs.stat(targetPath)).size).toBeGreaterThan(0); }); it("rejects a zero-byte source when nonempty input is required", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); await fs.writeFile(sourcePath, ""); - await expect( - createVerifiedSqliteSnapshot({ + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath, requireNonEmptySource: true, - }), - ).rejects.toThrow(/snapshot source must not be empty/u); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, + /snapshot source must not be empty/u, + ); }); it("rejects an existing target without modifying it", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); await fs.writeFile(targetPath, "keep"); await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( @@ -504,12 +477,6 @@ describe("createVerifiedSqliteSnapshot", () => { }); it("preserves a target created while the snapshot is being prepared", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); - await expect( createVerifiedSqliteSnapshot({ sourcePath, @@ -523,14 +490,9 @@ describe("createVerifiedSqliteSnapshot", () => { }); it("rejects staged bytes changed after validation", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalOpen = fs.open.bind(fs); let stagedReadCount = 0; - const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { + vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { const resolvedPath = path.resolve(String(filePath)); if ( flags === "r" && @@ -545,105 +507,49 @@ describe("createVerifiedSqliteSnapshot", () => { return await originalOpen(filePath, flags, mode); }); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( - /size mismatch|hash mismatch/u, - ); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - openSpy.mockRestore(); - } + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath }, + /size mismatch|hash mismatch/u, + ); }); it("runs the final caller guard before publishing the target", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); - let guarded = false; - - await expect( - createVerifiedSqliteSnapshot({ - sourcePath, - targetPath, - beforePublish: () => { - guarded = true; - throw new Error("publication refused"); - }, - }), - ).rejects.toThrow(/publication refused/u); - expect(guarded).toBe(true); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expectRejectedPublicationGuard("beforePublish", "publication refused"); }); it("removes its published target when the caller rejects it", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); - let guarded = false; - - await expect( - createVerifiedSqliteSnapshot({ - sourcePath, - targetPath, - afterPublish: () => { - guarded = true; - throw new Error("published target refused"); - }, - }), - ).rejects.toThrow(/published target refused/u); - expect(guarded).toBe(true); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expectRejectedPublicationGuard("afterPublish", "published target refused"); }); it("rejects an asynchronous after-publication guard", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const asynchronousGuard = (async () => {}) as unknown as () => void; - await expect( - createVerifiedSqliteSnapshot({ + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath, afterPublish: asynchronousGuard, - }), - ).rejects.toThrow(/after-publication guard must be synchronous/u); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, + /after-publication guard must be synchronous/u, + ); }); it("rejects an asynchronous final publication check", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const asynchronousFinalCheck = (async () => {}) as unknown as () => void; - await expect( - createVerifiedSqliteSnapshot({ + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath, afterPublish: (guard) => { guard.assertTargetUnchanged(asynchronousFinalCheck); }, - }), - ).rejects.toThrow(/publication final check must be synchronous/u); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, + /publication final check must be synchronous/u, + ); }); it("preserves a target replaced by the caller after publication", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); - await expect( createVerifiedSqliteSnapshot({ sourcePath, @@ -659,13 +565,8 @@ describe("createVerifiedSqliteSnapshot", () => { }); it("rejects a target replaced after atomic publication", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalLink = fs.link.bind(fs); - const linkSpy = vi.spyOn(fs, "link").mockImplementation(async (source, target) => { + vi.spyOn(fs, "link").mockImplementation(async (source, target) => { await originalLink(source, target); if (path.resolve(String(target)) === targetPath) { await fs.unlink(targetPath); @@ -673,26 +574,17 @@ describe("createVerifiedSqliteSnapshot", () => { } }); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( - /target changed during publication|staging path changed|snapshot file changed/u, - ); - await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("racer"); - } finally { - linkSpy.mockRestore(); - } + await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( + /target changed during publication|staging path changed|snapshot file changed/u, + ); + await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("racer"); }); it.runIf(process.platform !== "win32")( "removes target bytes linked from a replaced staging pathname", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalLink = fs.link.bind(fs); - const linkSpy = vi.spyOn(fs, "link").mockImplementation(async (source, target) => { + vi.spyOn(fs, "link").mockImplementation(async (source, target) => { if (path.resolve(String(target)) === targetPath) { await fs.unlink(source); const replacement = new sqlite.DatabaseSync(String(source)); @@ -702,34 +594,25 @@ describe("createVerifiedSqliteSnapshot", () => { await originalLink(source, target); }); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( - /staging file changed during publication|size mismatch|hash mismatch/u, - ); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - linkSpy.mockRestore(); - } + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath }, + /staging file changed during publication|size mismatch|hash mismatch/u, + ); }, ); it("removes its target when inspection fails after atomic publication", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalLink = fs.link.bind(fs); const originalLstat = fs.lstat.bind(fs); let linked = false; let failedInspection = false; - const linkSpy = vi.spyOn(fs, "link").mockImplementation(async (source, target) => { + vi.spyOn(fs, "link").mockImplementation(async (source, target) => { await originalLink(source, target); if (path.resolve(String(target)) === targetPath) { linked = true; } }); - const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (filePath) => { + vi.spyOn(fs, "lstat").mockImplementation(async (filePath) => { if (linked && !failedInspection && path.resolve(String(filePath)) === targetPath) { failedInspection = true; throw Object.assign(new Error("target inspection failed"), { code: "EIO" }); @@ -737,153 +620,91 @@ describe("createVerifiedSqliteSnapshot", () => { return await originalLstat(filePath); }); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( - /target inspection failed/u, - ); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - lstatSpy.mockRestore(); - linkSpy.mockRestore(); - } + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath }, + /target inspection failed/u, + ); }); it("uses a private sibling staging file for atomic publication", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalOpen = fs.open.bind(fs); const openSpy = vi.spyOn(fs, "open").mockImplementation(originalOpen); - try { - await createVerifiedSqliteSnapshot({ sourcePath, targetPath }); - expect( - openSpy.mock.calls.some( - ([filePath, flags]) => - flags === "wx+" && - path.basename(path.dirname(String(filePath))).startsWith(".sqlite-publish-"), - ), - ).toBe(true); - } finally { - openSpy.mockRestore(); - } + await createVerifiedSqliteSnapshot({ sourcePath, targetPath }); + expect( + openSpy.mock.calls.some( + ([filePath, flags]) => + flags === "wx+" && + path.basename(path.dirname(String(filePath))).startsWith(".sqlite-publish-"), + ), + ).toBe(true); }); it("falls back to an exclusive copy when hard links are unavailable", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); - const linkSpy = vi - .spyOn(fs, "link") - .mockRejectedValue(Object.assign(new Error("hard links unsupported"), { code: "ENOTSUP" })); + vi.spyOn(fs, "link").mockRejectedValue( + Object.assign(new Error("hard links unsupported"), { code: "ENOTSUP" }), + ); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).resolves.toEqual({ - path: targetPath, - userVersion: 0, - }); - const restored = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - restored.close(); - } finally { - linkSpy.mockRestore(); - } + await expectSnapshotSuccess({ sourcePath, targetPath }); + const restored = new sqlite.DatabaseSync(targetPath, { readOnly: true }); + restored.close(); }); it("removes a fallback target whose copied bytes fail verification", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); - const linkSpy = vi.spyOn(fs, "link").mockImplementation(async (source, target) => { + vi.spyOn(fs, "link").mockImplementation(async (source, target) => { if (path.resolve(String(target)) === targetPath) { await fs.appendFile(source, "changed-before-fallback"); } throw Object.assign(new Error("hard links unsupported"), { code: "ENOTSUP" }); }); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( - /size mismatch|hash mismatch/u, - ); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - linkSpy.mockRestore(); - } + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath }, + /size mismatch|hash mismatch/u, + ); }); it("removes its hard link when opening the published target fails", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalLink = fs.link.bind(fs); const originalOpen = fs.open.bind(fs); let linked = false; - const linkSpy = vi.spyOn(fs, "link").mockImplementation(async (source, target) => { + vi.spyOn(fs, "link").mockImplementation(async (source, target) => { await originalLink(source, target); if (path.resolve(String(target)) === targetPath) { linked = true; } }); - const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { + vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { if (linked && path.resolve(String(filePath)) === targetPath && flags === "r") { throw Object.assign(new Error("target open failed"), { code: "EIO" }); } return await originalOpen(filePath, flags, mode); }); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( - /target open failed/u, - ); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - openSpy.mockRestore(); - linkSpy.mockRestore(); - } + await expectSnapshotFailureWithoutTarget({ sourcePath, targetPath }, /target open failed/u); }); it("cleans publication staging when initialization fails", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalChmod = fs.chmod.bind(fs); - const chmodSpy = vi.spyOn(fs, "chmod").mockImplementation(async (filePath, mode) => { + vi.spyOn(fs, "chmod").mockImplementation(async (filePath, mode) => { if (path.basename(String(filePath)).startsWith(".sqlite-publish-")) { throw Object.assign(new Error("chmod refused"), { code: "EACCES" }); } await originalChmod(filePath, mode); }); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( - /chmod refused/u, - ); - expect( - (await fs.readdir(tempDir)).every((name) => !name.startsWith(".sqlite-publish-")), - ).toBe(true); - } finally { - chmodSpy.mockRestore(); - } + await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( + /chmod refused/u, + ); + expect((await fs.readdir(tempDir)).every((name) => !name.startsWith(".sqlite-publish-"))).toBe( + true, + ); }); it("removes its published target when final directory sync fails", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalOpen = fs.open.bind(fs); let targetDirectoryOpenCount = 0; - const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { + vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { if (isDirectoryOpen(flags) && path.resolve(String(filePath)) === tempDir) { targetDirectoryOpenCount += 1; } @@ -893,47 +714,30 @@ describe("createVerifiedSqliteSnapshot", () => { return await originalOpen(filePath, flags, mode); }); - try { - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( - /directory sync failed/u, - ); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - openSpy.mockRestore(); - } + await expectSnapshotFailureWithoutTarget({ sourcePath, targetPath }, /directory sync failed/u); }); it.runIf(process.platform !== "win32")( "removes its published target when directory sync is unsupported", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); durabilityTestState.syncOutcome = { status: "unsupported", code: "ENOTSUP" }; - await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( + await expectSnapshotFailureWithoutTarget( + { sourcePath, targetPath }, /SQLite publication directory does not support crash-durable directory synchronization \(ENOTSUP\)/u, ); - await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); }, ); it.runIf(process.platform !== "win32")( "rejects a transient publication directory replacement during sync", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); const displacedPath = `${tempDir}.displaced`; const replacementPath = `${tempDir}.replacement`; - const sqlite = requireNodeSqlite(); - createEmptySqliteDatabase(sqlite, sourcePath); const originalOpen = fs.open.bind(fs); let targetDirectoryOpenCount = 0; let replaced = false; - const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { + vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { const resolvedPath = path.resolve(String(filePath)); if (isDirectoryOpen(flags) && resolvedPath === tempDir) { targetDirectoryOpenCount += 1; @@ -957,7 +761,6 @@ describe("createVerifiedSqliteSnapshot", () => { expect(replaced).toBe(true); await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); } finally { - openSpy.mockRestore(); await fs.rm(replacementPath, { recursive: true, force: true }); await fs.rename(displacedPath, tempDir).catch(() => undefined); } @@ -965,11 +768,7 @@ describe("createVerifiedSqliteSnapshot", () => { ); it("validates both the source and transformed snapshot", async () => { - const tempDir = await createTempDir(); - const sourcePath = path.join(tempDir, "source.sqlite"); - const targetPath = path.join(tempDir, "snapshot.sqlite"); const removedValue = `removed-secret-${"x".repeat(256)}`; - const sqlite = requireNodeSqlite(); const source = new sqlite.DatabaseSync(sourcePath); source.exec("PRAGMA secure_delete = OFF; CREATE TABLE records (value TEXT NOT NULL);"); source.prepare("INSERT INTO records VALUES (?)").run(removedValue); @@ -988,11 +787,8 @@ describe("createVerifiedSqliteSnapshot", () => { expect(labels).toEqual([sourcePath, targetPath, targetPath]); expect((await fs.readFile(targetPath)).includes(removedValue)).toBe(false); - const snapshot = new sqlite.DatabaseSync(targetPath, { readOnly: true }); - try { + withReadOnlySnapshot(sqlite, targetPath, (snapshot) => { expect(snapshot.prepare("SELECT value FROM records").get()).toEqual({ value: "new" }); - } finally { - snapshot.close(); - } + }); }); });