/** * Centralized Atomic File I/O Utilities * * Provides safe, atomic file operations for all JSON persistence in Crewly. * Uses temp-file + fsync + rename to prevent corruption on crash, * and in-process locks to serialize concurrent writes to the same file. * * @module utils/file-io.utils */ /** * Minimal logger interface accepted by safeReadJson / modifyJsonFile. * Compatible with ComponentLogger from LoggerService. */ export interface FileIOLogger { warn(message: string, meta?: Record): void; error?(message: string, meta?: Record): void; debug?(message: string, meta?: Record): void; } /** * A JSON store file exists but cannot be read or parsed, AND it could not be * copied aside. The caller must not overwrite it: that would destroy the only * copy of whatever it still holds (the 2026-10-03 token-ledger loss). */ export declare class CorruptJsonFileError extends Error { /** The store file that is bad */ readonly filePath: string; /** Why it could not be used (parse or read error) */ readonly reason: string; /** * @param filePath - The bad store file * @param reason - Parse / read error text * @param copyError - Why copying it aside failed */ constructor(filePath: string, reason: string, copyError: string); } /** Result of {@link readJsonStore} / {@link readJsonStoreSync}. */ export type JsonStoreRead = { status: 'missing'; } | { status: 'ok'; data: T; } | { status: 'quarantined'; quarantinedTo: string; reason: string; }; /** * Ensures a directory exists, creating it recursively if necessary. * * @param dirPath - Absolute path to the directory */ export declare function ensureDir(dirPath: string): Promise; /** * Serialize concurrent writes to the same file. * * @param lockKey - Unique key for the lock (typically the file path) * @param operation - Async operation to run while holding the lock * @returns The result of the operation */ export declare function withFileLock(lockKey: string, operation: () => Promise): Promise; /** * Serialize read-modify-write cycles on a logical resource. * * Uses a separate lock map from {@link withFileLock} so that callers * can hold an operation lock across a read + write without deadlocking * on the inner file lock. * * @param lockKey - Unique key for the lock * @param operation - Async operation to run while holding the lock * @returns The result of the operation */ export declare function withOperationLock(lockKey: string, operation: () => Promise): Promise; /** Options for the atomic writers. */ export interface AtomicWriteOptions { /** File mode for the new file (e.g. `0o600` for credentials). Default: the process umask. */ mode?: number; } /** * Write a string to a file atomically (temp file in the same directory → * fsync → rename). * * Acquires a per-path file lock so concurrent callers targeting the * same path are serialized. Readers (and a crash at any point) see either * the old file or the new one, never a mix; a shorter write can never leave * the tail of a longer one behind. * * **Precondition:** The parent directory must already exist. This function * does not create intermediate directories — use {@link ensureDir} first * if the directory may not exist. * * @param filePath - Destination file path * @param content - String content to write * @param options - Optional file mode */ export declare function atomicWriteFile(filePath: string, content: string, options?: AtomicWriteOptions): Promise; /** * Serialize a JavaScript value to JSON and write it atomically. * * @param filePath - Destination file path * @param data - Value to serialize (via `JSON.stringify`) * @param options - Optional file mode */ export declare function atomicWriteJson(filePath: string, data: T, options?: AtomicWriteOptions): Promise; /** * Synchronous {@link atomicWriteFile} for stores that persist synchronously * (temp file in the same directory → fsync → rename). * * A failure (ENOSPC, EACCES) leaves the destination untouched, removes the * temp file and rethrows. No in-process lock: a synchronous call cannot * interleave with another one in the same process. * * **Precondition:** the parent directory must already exist. * * @param filePath - Destination file path * @param content - String content to write * @param options - Optional file mode */ export declare function atomicWriteFileSync(filePath: string, content: string, options?: AtomicWriteOptions): void; /** * Where a bad store file is copied: `.corrupt-` * (colons and dots replaced so the name is valid everywhere). * * @param filePath - Store file * @param now - Clock (tests) * @returns Quarantine path */ export declare function quarantinePathFor(filePath: string, now?: Date): string; /** * Copy a store file that cannot be used to `.corrupt-` and log an * error. After this returns, the caller may start empty and overwrite the * original. * * @param filePath - The bad store file * @param reason - Why it cannot be used * @param logger - Optional logger * @returns The quarantine path * @throws CorruptJsonFileError when the copy fails — the caller must then NOT * overwrite the original */ export declare function quarantineCorruptFile(filePath: string, reason: string, logger?: FileIOLogger): Promise; /** * Synchronous {@link quarantineCorruptFile}. * * @param filePath - The bad store file * @param reason - Why it cannot be used * @param logger - Optional logger * @returns The quarantine path * @throws CorruptJsonFileError when the copy fails */ export declare function quarantineCorruptFileSync(filePath: string, reason: string, logger?: FileIOLogger): string; /** * Read a JSON store, telling "missing" apart from "bad". * * - missing (`ENOENT`) → `{ status: 'missing' }` — start fresh; * - parses (and passes `validate`) → `{ status: 'ok', data }`; * - invalid JSON, or rejected by `validate` → copied to * `.corrupt-`, error logged, `{ status: 'quarantined' }`; * - any other read error (EMFILE, EIO, EACCES…) → rethrown. The file may be * good; the caller must not start empty and write over it. * * @param filePath - Store file * @param options.validate - Optional shape check; return an error text to reject * @param options.logger - Optional logger * @returns What was found * @throws CorruptJsonFileError when the file is bad and could not be copied aside * @throws The read error itself for anything but ENOENT */ export declare function readJsonStore(filePath: string, options?: { validate?: (data: unknown) => string | null; logger?: FileIOLogger; }): Promise>; /** * Synchronous {@link readJsonStore}. * * @param filePath - Store file * @param options.validate - Optional shape check; return an error text to reject * @param options.logger - Optional logger * @returns What was found * @throws CorruptJsonFileError when the file is bad and could not be copied aside * @throws The read error itself for anything but ENOENT */ export declare function readJsonStoreSync(filePath: string, options?: { validate?: (data: unknown) => string | null; logger?: FileIOLogger; }): JsonStoreRead; /** * Decode the first JSON object at the start of `raw` and ignore whatever * follows (Python's `json.JSONDecoder.raw_decode`). Recovers a file that holds * a valid object followed by the tail of an older, longer write. * * @param raw - File text * @returns The first object and the number of trailing non-whitespace bytes, or null when `raw` does not start with a complete JSON object */ export declare function decodeFirstJsonObject(raw: string): { value: Record; trailingChars: number; } | null; /** Result of {@link readJsonObjectWithRepairSync}. */ export type JsonRepairRead = { status: 'missing'; } | { status: 'ok'; data: T; repaired: boolean; } | { status: 'damaged'; reason: string; quarantinedTo: string | null; }; /** * Read a JSON object file that holds credentials or other state that must not * be silently dropped. * * - missing → `missing`; * - parses and passes `validate` → `ok`; * - does not parse, but starts with a complete object that passes `validate` * (stale trailing bytes) → the file is rewritten atomically with just that * object, a WARN is logged, `ok` with `repaired: true`; * - anything else → a copy is kept at `.corrupt-` and `damaged` is * returned (the original is left in place; the next atomic write replaces it). * The copy is made once per distinct damaged file content, not on every read. * * Other read errors (EACCES, EIO…) are rethrown: the file may be fine. * * @param filePath - File to read * @param options.validate - Shape check; return an error text to reject * @param options.logger - Optional logger * @param options.mode - Mode for the repaired file * @returns What was found */ export declare function readJsonObjectWithRepairSync(filePath: string, options: { validate: (data: unknown) => string | null; logger?: FileIOLogger; mode?: number; }): JsonRepairRead; /** * Read and parse a JSON file safely. * * - On `ENOENT` → returns `defaultValue` silently. * - On parse error → backs up the corrupt file as `.corrupt-` * and returns `defaultValue`. * - If that backup fails (a full disk, permissions) → throws * {@link CorruptJsonFileError}. Returning the default would let the caller * write it over the only copy of the data. * * @param filePath - Path to the JSON file * @param defaultValue - Value to return when the file is missing or corrupt * @param logger - Optional logger for warnings on corruption * @returns Parsed value or `defaultValue` */ export declare function safeReadJson(filePath: string, defaultValue: T, logger?: FileIOLogger): Promise; /** * Locked read → mutate → atomic write cycle. * * This is the most common persistence pattern: load a JSON file, * apply a mutation, and write it back atomically — all while holding * an operation lock to prevent concurrent read-modify-write races. * * **Important:** If the mutator returns `undefined` (void), the mutated * `data` object is written back. If it returns any other value — including * falsy values like `null`, `0`, or `false` — that value is written instead. * Beware of methods like `Array.push()` which return a number: an accidental * `return records.push(item)` will write the array length to the file. * * @param filePath - Path to the JSON file * @param defaultValue - Value to use if the file is missing or corrupt * @param mutator - Function that receives the current data and mutates it in place (or returns new data) * @param logger - Optional logger * @returns The value returned by `mutator` (or void) */ export declare function modifyJsonFile(filePath: string, defaultValue: T, mutator: (data: T) => R | Promise, logger?: FileIOLogger): Promise; /** * Reset all lock maps. **Test-only** — never call in production. */ export declare function _clearAllLocks(): void; //# sourceMappingURL=file-io.utils.d.ts.map