Files
dashcaddy/dashcaddy-api/src/utils/atomic-write.js
T
Hermes 80a82c4cae
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
refactor(persistence): canonical atomic file writer + notifications.json crash-safety (DC-099) [glm-grade=B]
- src/utils/atomic-write.js: single shared tmp+fsync+rename writer
  (exclusive-create 0600, unique tmp names, cleanup-on-failure,
  best-effort parent-dir fsync after rename for swap durability)
- notification-manager: both write paths (load-time canonicalization
  write-back + saveConfig) converted from plain writeFileSync — a
  crash mid-write can no longer truncate notifications.json
- DC-097/098 test seams migrated to the atomic path; new suite pins
  syscall discipline (order, wx flags, tmp naming, error cleanup,
  dir-fsync swallow)
- 121 suites / 2768 tests green
- Judge: GLM-5.3 cold read grade B/ship (deleg_23f7abad); polish items
  folded: dir-fsync added, header copy-count corrected. Remaining:
  migrate invite/user/share-store private _atomicWriteJSON copies as
  they are touched (queued).

URN: pending (recorded post-commit)
2026-08-22 23:28:36 -07:00

102 lines
3.6 KiB
JavaScript

/**
* Canonical atomic file writer (DC-099).
*
* Write discipline: same-directory temp file → write → fsync → close → rename.
* rename() is atomic on POSIX, so a reader (or a crash) can only ever see the
* complete old file or the complete new file — never a truncated mix. fsync
* before rename pins the bytes so a post-rename power loss doesn't leave an
* empty/short file behind (the failure mode plain writeFileSync has).
*
* This is the ONE shared implementation. It replaces the three private
* `_atomicWriteJSON` copies (invite-store, user-store, share-store) as they
* are touched, and mirrors the DC-098 redact tool's write path. Do not add a
* fourth copy — require this module instead.
*/
'use strict';
const fs = require('fs');
const path = require('path');
// Monotonic counter guarantees unique tmp names even for back-to-back writes
// of the same target within one process tick.
let writeCounter = 0;
function tmpPathFor(filePath) {
writeCounter += 1;
const base = path.basename(filePath);
const dir = path.dirname(filePath);
return path.join(dir, `.${base}.tmp-${process.pid}-${Date.now().toString(36)}-${writeCounter}`);
}
/**
* Best-effort durability for the rename itself: fsync the parent directory so
* the swap survives a post-rename power loss. POSIX guarantees the rename is
* atomic *visibly*, but without a dir fsync a crash can leave the old entry —
* a stale-but-complete file, never a torn one, so failure here is not fatal.
*/
function fsyncDir(dirPath) {
let dfd = null;
try {
dfd = fs.openSync(dirPath, 'r');
fs.fsyncSync(dfd);
} catch (_) {
// Some platforms/filesystems reject fsync on directory fds; the payload
// is already durable via the file-level fsync above.
} finally {
if (dfd !== null) {
try { fs.closeSync(dfd); } catch (_) { /* fd already closed */ }
}
}
}
/**
* Atomically replace `filePath` with `contents`.
*
* @param {string} filePath - destination (parent dir must exist)
* @param {string} contents - full file contents
* @param {object} [opts]
* @param {number} [opts.mode=0o600] - mode for a newly created file
* @returns {string} the final path (filePath)
* @throws whatever fs throws (ENOSPC, EACCES, …); on failure the destination
* is untouched and the temp file is removed best-effort.
*/
function atomicWriteFile(filePath, contents, opts = {}) {
const mode = typeof opts.mode === 'number' ? opts.mode : 0o600;
const tmp = tmpPathFor(filePath);
let fd = null;
try {
// 'wx' — fail loudly if the tmp name somehow exists rather than clobber.
fd = fs.openSync(tmp, 'wx', mode);
fs.writeSync(fd, contents, null, 'utf8');
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = null;
fs.renameSync(tmp, filePath);
fsyncDir(path.dirname(filePath));
return filePath;
} catch (err) {
if (fd !== null) {
try { fs.closeSync(fd); } catch (_) { /* fd already closed or broken */ }
}
try { fs.unlinkSync(tmp); } catch (_) { /* nothing to clean up */ }
throw err;
}
}
/**
* Atomically write `data` as JSON. Single serializer for the whole codebase:
* 2-space indent, no trailing newline (matches the notification config's
* byte-for-byte idempotence check in _persistCanonicalForm).
*
* @param {string} filePath
* @param {*} data - JSON.stringify-able value
* @param {object} [opts] - passed through to atomicWriteFile
* @returns {string} the final path (filePath)
*/
function atomicWriteJSON(filePath, data, opts = {}) {
return atomicWriteFile(filePath, JSON.stringify(data, null, 2), opts);
}
module.exports = { atomicWriteFile, atomicWriteJSON, tmpPathFor };