mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
test(sqlite): consolidate snapshot fixtures (#118264)
This commit is contained in:
committed by
GitHub
parent
ee82689ea4
commit
0d04af1231
+170
-374
@@ -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<typeof requireNodeSqlite>,
|
||||
sqlitePath: string,
|
||||
): void {
|
||||
const database = new sqlite.DatabaseSync(sqlitePath);
|
||||
let sqlite: ReturnType<typeof requireNodeSqlite>;
|
||||
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<typeof createVerifiedSqliteSnapshot>[0];
|
||||
|
||||
async function expectSnapshotSuccess(options: SnapshotOptions): Promise<void> {
|
||||
await expect(createVerifiedSqliteSnapshot(options)).resolves.toEqual({
|
||||
path: options.targetPath,
|
||||
userVersion: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSnapshotFailureWithoutTarget(
|
||||
options: SnapshotOptions,
|
||||
pattern: RegExp,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<T>(
|
||||
sqliteModule: ReturnType<typeof requireNodeSqlite>,
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user