/** * Phase 4a — lockfile read / atomic-write with fingerprint CAS. * * The lockfile is the source of truth for trust at runtime; multiple * cursell CLI invocations may read+modify it concurrently. This module * provides: * * 1. `findLockfile`: walks up from a directory to find `cursell.lock`. * 2. `readWithFingerprint`: reads the file (or reports it missing) * and captures a fingerprint that the writer can compare against * to detect concurrent modification (compare-and-swap). * 3. `writeIfFingerprintMatches`: atomic temp-file + fsync + rename * writer that aborts with `LockfileConflictError` if the file * changed (or appeared/disappeared) since the read. * * Conflict comparison uses **only** `(exists, contentSha256)` — the * `mtimeMs` and `size` fields in the fingerprint are diagnostic * (logged in error messages) and never cause a false conflict on a * filesystem with coarse mtime resolution. * * The atomic write goes through a temp file with a random suffix * (`..tmp`) so two writers operating on the same * directory can't clash even if they race. Combined with the mutex * (mutex.ts) which serializes the entire CAS region, the write side * has both a mutex and a CAS guard. */ import { type Lockfile } from "./schema.js"; export declare class LockfileConflictError extends Error { constructor(message: string); } export interface Fingerprint { exists: boolean; contentSha256: string | null; mtimeMs: number | null; size: number | null; } export interface ReadResult { data: Lockfile | null; fingerprint: Fingerprint; /** Absolute path that was actually read (may have been a parent of cwd). */ path: string; } /** * Walks up from `cwd` looking for a `cursell.lock` file. Returns the * absolute path of the first match, or `null` if none is found before * hitting the filesystem root. */ export declare function findLockfile(cwd: string): Promise; /** * Read the lockfile at `path` and produce a fingerprint. If the file * does not exist, returns `{data: null, fingerprint: {exists:false, ...}}`. * If the file exists but is malformed (bad JSON or schema), throws * the underlying parser error — the read path expects a valid file * and the caller's error handler should surface it loudly. * * The same return shape supports both "lockfile is present and we * want to update it" (writer) and "lockfile is missing and we want * to create it" (init) flows. */ export declare function readWithFingerprint(path: string): Promise; /** * Atomic write with compare-and-swap against the captured fingerprint. * * Missing-lock semantics — both sides considered: * - captured `exists=false`, on-disk now `exists=false`: ok (init). * - captured `exists=false`, on-disk now `exists=true`: CONFLICT * (someone else just created the file). * - captured `exists=true`, on-disk now `exists=false`: CONFLICT * (someone else deleted the file). * - captured `exists=true`, on-disk now `exists=true`, hash equal: * ok, proceed with the write. * - captured `exists=true`, on-disk now `exists=true`, hash diff: * CONFLICT. * * The write itself uses a unique temp file (`..tmp`) * + fsync + rename, so a crash mid-write leaves either the original * file intact or the new file fully written; never a partial * observable state. Two writers operating on the same path will not * collide because each gets its own random temp suffix. * * Atomicity vs durability: POSIX `rename` guarantees an atomic * directory-entry swap (no torn rename observable to a reader). It * does NOT guarantee durability across power loss / kernel panic * without a follow-up fsync of the parent directory. Phase 4a does * not fsync the parent directory; on crash, the surviving on-disk * state is either the new lockfile or the previous lockfile — never * empty/partial — but a *just-written* update may be lost if the * crash beats the next OS-level flush. This trade-off matches what * `npm`, `yarn`, and other lockfile-based tools do. * * NOTE: the mutex (`mutex.ts`) is what makes CAS truly serializable. * The fingerprint check alone is best-effort (TOCTOU between check * and rename); callers should hold the mutex over the entire region * `read → compute → writeIfFingerprintMatches`. */ export interface WriteOptions { /** * Optional guard invoked AFTER the temp file is fully written * + fsync'd, immediately before the atomic `rename`. Throw to * abort the write — the caller is responsible for cleanup of * its own state. The temp file is always cleaned up * regardless. Used by Phase 4b to verify mutex ownership at * the last possible moment before publishing the rename, so a * concurrent stealer can't race in during the long pre-rename * setup (read/stat/mkdir/open/write/fsync/close). */ beforeRename?: () => Promise; } export declare function writeIfFingerprintMatches(path: string, data: Lockfile, expected: Fingerprint, options?: WriteOptions): Promise;