diff --git a/CHANGELOG.md b/CHANGELOG.md index fa75f80..b1bb273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changes + +- Add a `durable: false` option to async atomic text and JSON writes so callers can preserve replace semantics while skipping temp-file and parent-directory fsync. + ### Fixes - Harden temp filename prefixes, local-root reads, private store imports, durable queue reads, and regular-file byte caps against Deepsec-reported path traversal, symlink, and oversized-read races. diff --git a/docs/atomic.md b/docs/atomic.md index b3a0522..deb56f0 100644 --- a/docs/atomic.md +++ b/docs/atomic.md @@ -114,6 +114,21 @@ await writeTextAtomic("/srv/workspace/rendered.md", rendered, { }); ``` +Options: + +```ts +type WriteTextAtomicOptions = { + mode?: number; // file mode (default 0o600) + dirMode?: number; // mode for parent dirs created on demand + trailingNewline?: boolean; // append "\n" if missing + durable?: boolean; // default true; false skips temp/parent fsync +}; +``` + +`durable: false` keeps the sibling-temp replace/rename behavior but skips the +temp-file and parent-directory `fsync` calls. Use it only for reconstructible +metadata where lower latency matters more than crash-durability. + ## `movePathWithCopyFallback` Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to diff --git a/docs/json.md b/docs/json.md index a73de03..b5f27ba 100644 --- a/docs/json.md +++ b/docs/json.md @@ -115,9 +115,14 @@ type WriteJsonOptions = { mode?: number; // file mode (default 0o600) dirMode?: number; // mode for parent dirs created on demand trailingNewline?: boolean; // append "\n" if missing (default false) + durable?: boolean; // default true; false skips temp/parent fsync }; ``` +`durable: false` preserves atomic temp-file replacement but skips the temp-file +and parent-directory `fsync` calls. Use it only for reconstructible JSON state +where lower latency matters more than crash-durability. + ### `writeJsonSync(pathname, data)` Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults. diff --git a/src/json.ts b/src/json.ts index cc0d441..75a2ded 100644 --- a/src/json.ts +++ b/src/json.ts @@ -3,7 +3,7 @@ import fsSync from "node:fs"; import path from "node:path"; import { readRegularFile, readRegularFileSync } from "./regular-file.js"; import { openRootFileSync, type RootFileOpenFailure } from "./root-file.js"; -import { writeTextAtomic } from "./text-atomic.js"; +import { writeTextAtomic, type WriteTextAtomicOptions } from "./text-atomic.js"; const JSON_FILE_MODE = 0o600; const JSON_DIR_MODE = 0o700; @@ -289,15 +289,21 @@ export function readJsonSync(filePath: string): T { } } +export type WriteJsonOptions = Pick< + WriteTextAtomicOptions, + "dirMode" | "durable" | "mode" | "trailingNewline" +>; + export async function writeJson( filePath: string, value: unknown, - options?: { mode?: number; trailingNewline?: boolean; dirMode?: number }, + options?: WriteJsonOptions, ) { const text = JSON.stringify(value, null, 2); await writeTextAtomic(filePath, text, { mode: options?.mode, dirMode: options?.dirMode, trailingNewline: options?.trailingNewline, + durable: options?.durable, }); } diff --git a/src/text-atomic.ts b/src/text-atomic.ts index 61b78a6..a5707a0 100644 --- a/src/text-atomic.ts +++ b/src/text-atomic.ts @@ -4,6 +4,13 @@ export type WriteTextAtomicOptions = { mode?: number; dirMode?: number; trailingNewline?: boolean; + /** + * When false, skip the temp-file and parent-directory fsync calls while + * preserving the temp-file replace/rename behavior. + * + * Defaults to true. + */ + durable?: boolean; }; export async function writeTextAtomic( @@ -12,13 +19,14 @@ export async function writeTextAtomic( options?: WriteTextAtomicOptions, ): Promise { const payload = options?.trailingNewline && !content.endsWith("\n") ? `${content}\n` : content; + const durable = options?.durable ?? true; await replaceFileAtomic({ filePath, content: payload, mode: options?.mode ?? 0o600, dirMode: options?.dirMode ?? (0o777 & ~process.umask()), copyFallbackOnPermissionError: true, - syncTempFile: true, - syncParentDir: true, + syncTempFile: durable, + syncParentDir: durable, }); } diff --git a/test/json.test.ts b/test/json.test.ts index a67515f..74ee375 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -28,6 +28,24 @@ afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true }))); }); +function mockOpenForSyncCounting(): { readonly syncCalls: number; restore: () => void } { + let syncCalls = 0; + const openSpy = vi.spyOn(fs, "open").mockImplementation(async () => { + return { + sync: async () => { + syncCalls += 1; + }, + close: async () => undefined, + } as Awaited>; + }); + return { + get syncCalls() { + return syncCalls; + }, + restore: () => openSpy.mockRestore(), + }; +} + describe("json file helpers", () => { it("writes formatted JSON atomically with an optional trailing newline", async () => { const root = await tempRoot("fs-safe-json-"); @@ -59,6 +77,54 @@ describe("json file helpers", () => { } }); + it("syncs temp file and parent directory by default for text writes", async () => { + const root = await tempRoot("fs-safe-json-"); + const filePath = path.join(root, "default-durable.txt"); + const syncCounter = mockOpenForSyncCounting(); + + try { + await writeTextAtomic(filePath, "data"); + } finally { + syncCounter.restore(); + } + + expect(syncCounter.syncCalls).toBe(2); + await expect(fs.readFile(filePath, "utf8")).resolves.toBe("data"); + }); + + it("skips fsync when text writes opt out of durability", async () => { + const root = await tempRoot("fs-safe-json-"); + const filePath = path.join(root, "store.json"); + await fs.writeFile(filePath, "old", "utf8"); + const syncCounter = mockOpenForSyncCounting(); + + try { + await writeTextAtomic(filePath, "new", { durable: false }); + } finally { + syncCounter.restore(); + } + + expect(syncCounter.syncCalls).toBe(0); + await expect(fs.readFile(filePath, "utf8")).resolves.toBe("new"); + const dirEntries = await fs.readdir(root); + expect(dirEntries.some((entry) => entry.endsWith(".tmp"))).toBe(false); + }); + + it("threads durable option through JSON writes", async () => { + const root = await tempRoot("fs-safe-json-"); + const filePath = path.join(root, "state.json"); + const syncCounter = mockOpenForSyncCounting(); + + try { + await writeJson(filePath, { ok: true }, { durable: false }); + } finally { + syncCounter.restore(); + } + + expect(syncCounter.syncCalls).toBe(0); + await expect(fs.readFile(filePath, "utf8")).resolves.toBe("{\n \"ok\": true\n}"); + }); + it("separates nullable and durable read failure semantics", async () => { const root = await tempRoot("fs-safe-json-"); const missing = path.join(root, "missing.json");