/** * Integrity-Guarded Atomic Write Utilities * * Wraps {@link atomicWriteJson} with a write-time integrity invariant * (a "decrease-guard"): rejects suspicious writes that would persist a * collection that has shrunk too aggressively versus the on-disk state. * * **Why this exists** * * On 2026-05-03, a backend restart corrupted `~/.crewly/teams.json` and * every per-project `teams/{id}/config.json` to `teams: []`. Atomic writes * already covered the *transactional* side of the persistence layer, but * the gap was at the *semantic* level — any code path that emitted * `teams: []` (whether via race during shutdown, destructive migration, or * cache nuke + flush) would happily atomic-write the empty state and * overwrite the single-snapshot backup as well. * * This utility closes that gap by reading the existing on-disk count * before every aggregate write and aborting if the count would drop from * N>0 to 0 (or by an unacceptable percentage) without explicit operator * intent. Callers who legitimately want to clear a collection pass * `allowExplicitDelete: true`. * * **Design contract** * * - **Failure mode**: integrity violations throw {@link IntegrityViolationError} * so the failure surfaces loudly instead of being silently swallowed. * Callers that prefer "log + continue" semantics should wrap with their * own try/catch. * - **First-ever write** (no prior file) is always allowed — `prevCount = 0`, * no decrease. * - **Read failure**: if the prior file exists but cannot be read or parsed, * the guard treats `prevCount` as `null` and proceeds with the write * (a corrupted prior file should not block recovery). * * @module utils/integrity-guarded-write.utils */ /** * Thrown when a guarded write is rejected because it would shrink the * persisted collection past the decrease threshold without explicit * operator opt-in. * * Carries diagnostic metadata (path, prevCount, nextCount, reason) for * structured logging and debugging. */ export declare class IntegrityViolationError extends Error { /** Absolute path of the file the rejected write targeted */ readonly path: string; /** Item count read from the prior on-disk state */ readonly prevCount: number; /** Item count of the rejected next state */ readonly nextCount: number; /** Machine-readable reason code */ readonly reason: 'collapse-to-empty' | 'decrease-exceeds-threshold'; /** * @param message - Human-readable error message * @param details - Diagnostic metadata */ constructor(message: string, details: { path: string; prevCount: number; nextCount: number; reason: IntegrityViolationError['reason']; }); } /** * Options for {@link atomicWriteJsonWithGuard}. */ export interface IntegrityGuardOptions { /** * Function that extracts the "item count" from a value (parsed prior * state or proposed next state). Defaults to `(d) => Array.isArray(d) ? d.length : 0`, * suitable for plain array-shaped collections. */ countOf?: (data: T) => number; /** * Maximum allowed decrease as a percentage of the prior count. * Default 50 — i.e., next count must be ≥ ceil(prevCount / 2). * Pass 100 to allow any decrease (effectively only the * collapse-to-empty branch fires). */ maxDecreasePct?: number; /** * If true, bypass the decrease and collapse-to-empty guards. Use this * for legitimate "clear all" or "delete N" operations where the caller * has confirmed operator intent. */ allowExplicitDelete?: boolean; } /** * Atomically write a JSON file, rejecting writes that would shrink an * existing collection past the decrease threshold without explicit opt-in. * * **Behavior**: * 1. If the destination file does not exist → first-ever write → proceed. * 2. If the prior file exists but cannot be read/parsed → log warning, * proceed (corrupted prior should not block recovery). * 3. If `prevCount > 0 && nextCount === 0 && !allowExplicitDelete` * → throw {@link IntegrityViolationError} with `reason: 'collapse-to-empty'`. * 4. If `nextCount < prevCount * (1 - maxDecreasePct/100) && !allowExplicitDelete` * → throw {@link IntegrityViolationError} with `reason: 'decrease-exceeds-threshold'`. * 5. Otherwise → call {@link atomicWriteJson}. * * @param filePath - Destination JSON file path * @param next - Value to serialize and write * @param opts - Guard configuration * @throws {IntegrityViolationError} if the guard rejects the write * @throws The underlying fs error if the atomic write itself fails * * @example * ```typescript * await atomicWriteJsonWithGuard(teamsPath, teams, { * countOf: (t) => t.length, * maxDecreasePct: 50, * }); * ``` */ export declare function atomicWriteJsonWithGuard(filePath: string, next: T, opts?: IntegrityGuardOptions): Promise; //# sourceMappingURL=integrity-guarded-write.utils.d.ts.map