All files / src/utils/fs atomic-write.ts

96.61% Statements 57/59
79.48% Branches 31/39
100% Functions 7/7
96.55% Lines 56/58

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198                                                              3x 1x     2x 2x 2x   2x 2x 2x 4x 4x 2x   2x                                   10x 10x 10x 10x 4x 4x 4x 2x           8x 8x                           3x                 4x 4x   3x 3x                                   2x 4x 4x 4x 4x   3x 3x 2x   1x                         16x 16x     15x 15x   1x 1x 1x                                           16x 16x 16x 16x 16x 16x 16x 16x   3x 3x 2x   1x       16x      
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module Utils/Fs/AtomicWrite
 * @description Idempotent and atomic file write primitives.
 *
 * - `writeFileIfChanged` / `writeFileContent` preserve mtime when the
 *   on-disk bytes already match (idempotency contract for `aws s3 sync`).
 * - `atomicWrite` writes via a unique temp file + rename with retries,
 *   tolerating concurrent writers on Windows-like filesystems.
 * - `resolveUniqueFilePath` produces non-clobbering numeric suffixes.
 */
 
import { randomUUID } from 'crypto';
import fs from 'fs';
import path from 'path';
import { ensureDirectoryExists } from './directory.js';
 
/**
 * Resolve a unique filename by appending a numeric suffix (-2, -3, …) before
 * the file extension when the file already exists.
 *
 * This prevents repeated workflow runs from overwriting previously committed
 * news articles.
 *
 * @param filepath - The preferred file path (e.g. `news/2026-04-02-breaking-en.html`)
 * @returns The original path when the file doesn't exist, or a suffixed
 *          variant (e.g. `news/2026-04-02-breaking-en-2.html`) otherwise.
 */
export function resolveUniqueFilePath(filepath: string): string {
  if (!fs.existsSync(filepath)) {
    return filepath;
  }
 
  const dir = path.dirname(filepath);
  const ext = path.extname(filepath);
  const base = path.basename(filepath, ext);
 
  let suffix = 2;
  const MAX_SUFFIX = 100;
  while (suffix <= MAX_SUFFIX) {
    const candidate = path.join(dir, `${base}-${suffix}${ext}`);
    if (!fs.existsSync(candidate)) {
      return candidate;
    }
    suffix++;
  }
  return path.join(dir, `${base}-${randomUUID().slice(0, 8)}${ext}`);
}
 
/**
 * Write `content` to `filepath` only if the existing on-disk bytes differ.
 *
 * Used to keep `aws s3 sync` (which compares size + mtime) from re-uploading
 * files whose content the build pipeline regenerated identically — see the
 * idempotency contract documented in `.github/workflows/deploy-s3.yml`.
 *
 * @param filepath - Output file path
 * @param content  - Desired file content (UTF-8 string or raw Buffer)
 * @returns `true` when an actual write occurred, `false` when the file
 *          already had byte-identical content and was left untouched.
 */
export function writeFileIfChanged(filepath: string, content: string | Buffer): boolean {
  const dir = path.dirname(filepath);
  ensureDirectoryExists(dir);
  const desired = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
  if (fs.existsSync(filepath)) {
    try {
      const existing = fs.readFileSync(filepath);
      if (existing.equals(desired)) {
        return false;
      }
    } catch {
      // Fall through to overwrite — read failures must not block deploy.
    }
  }
  fs.writeFileSync(filepath, desired);
  return true;
}
 
/**
 * Write content to a file with UTF-8 encoding.
 *
 * Idempotent at the byte level: if the file already exists with identical
 * content, the file is left untouched (mtime preserved). This keeps
 * `aws s3 sync` from re-uploading regenerated-but-identical files.
 *
 * @param filepath - Output file path
 * @param content - File content
 */
export function writeFileContent(filepath: string, content: string): void {
  writeFileIfChanged(filepath, content);
}
 
/**
 * Remove a file, ignoring ENOENT (file already deleted by another writer).
 *
 * @param filepath - Path to the file to remove
 */
function unlinkIfExists(filepath: string): void {
  try {
    fs.unlinkSync(filepath);
  } catch (err: unknown) {
    const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : '';
    Iif (code !== 'ENOENT') {
      throw err;
    }
  }
}
 
/**
 * Attempt to rename `src` to `dest` with a bounded retry loop.
 *
 * On each attempt the existing destination is removed first, then
 * `renameSync` is retried.  `EEXIST`/`EPERM` failures from concurrent
 * writers are tolerated for up to `maxRetries` attempts.
 *
 * @param src - Source (temp) file path
 * @param dest - Final destination path
 * @param maxRetries - Maximum number of unlink-then-rename attempts
 */
function renameWithRetry(src: string, dest: string, maxRetries: number): void {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    unlinkIfExists(dest);
    try {
      fs.renameSync(src, dest);
      return;
    } catch (retryErr: unknown) {
      const retryCode = retryErr instanceof Error ? (retryErr as NodeJS.ErrnoException).code : '';
      if ((retryCode === 'EEXIST' || retryCode === 'EPERM') && attempt < maxRetries - 1) {
        continue;
      }
      throw retryErr;
    }
  }
}
 
/**
 * Best-effort removal of a temporary file.  Ignores ENOENT (the file was
 * already renamed or never created) but logs a warning for other errors
 * (e.g. EBUSY, EACCES) so operators can detect leaked temp files.
 *
 * @param tempPath - Path to the temp file to remove
 */
function cleanupTempFile(tempPath: string): void {
  try {
    fs.unlinkSync(tempPath);
  } catch (unlinkErr: unknown) {
    const errno =
      unlinkErr && typeof unlinkErr === 'object' ? (unlinkErr as NodeJS.ErrnoException) : undefined;
    if (errno?.code !== 'ENOENT') {
      const message =
        errno && typeof errno.message === 'string' ? errno.message : String(unlinkErr);
      const code = errno?.code ?? 'UNKNOWN';
      console.warn(
        `atomicWrite: failed to remove temporary file "${tempPath}" (code: ${code}): ${message}`
      );
    }
  }
}
 
/**
 * Write content to a file atomically.
 *
 * Writes to a uniquely-named temporary file in the same directory first, then
 * renames it to the final path. The temp filename includes the PID and a random
 * UUID so that concurrent callers targeting the same destination never collide
 * on the intermediate file. If the rename fails the temp file is cleaned up in
 * a `finally` block. On platforms where `renameSync` does not overwrite an
 * existing destination (e.g. Windows), the error is caught and the target is
 * removed before retrying the rename.
 *
 * @param filepath - Final output file path
 * @param content - File content to write
 */
export function atomicWrite(filepath: string, content: string): void {
  const dir = path.dirname(filepath);
  ensureDirectoryExists(dir);
  const uniqueSuffix = `${process.pid}-${randomUUID()}`;
  const tempPath = `${filepath}.${uniqueSuffix}.tmp`;
  try {
    fs.writeFileSync(tempPath, content, 'utf-8');
    try {
      fs.renameSync(tempPath, filepath);
    } catch (err: unknown) {
      const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : '';
      if (code === 'EEXIST' || code === 'EPERM') {
        renameWithRetry(tempPath, filepath, 3);
      } else {
        throw err;
      }
    }
  } finally {
    cleanupTempFile(tempPath);
  }
}