/**
 * Server-side Y.Doc persistence — durable snapshot store across server restarts.
 *
 * Why this exists: before persistence, the server discarded all Y.Doc state on
 * restart and re-seeded each doc from its .md file under a fresh server
 * `clientID`. Existing clients that retained their pre-restart Y.Doc state
 * (their tab stayed open) would merge with the disk-seeded state under the new
 * server clientID, keeping both insertion sets — duplicating the entire
 * document. With persistence, the server reloads the binary Y.Doc state on
 * startup, so reconnecting clients see a server state vector that already
 * includes their ops; sync-step-2 becomes a no-op and the race is closed.
 *
 * Storage: `{rootDir}/{repo}/{entityType}/{entityId}.ydoc`
 * File body:
 *   [1 byte]   schema version
 *   [4 bytes]  meta JSON length, big-endian uint32
 *   [N bytes]  meta JSON (UTF-8)
 *   [...]      Y.encodeStateAsUpdate(ydoc) bytes
 *
 * Atomic write: tmp + fsync(tmp) + rename + best-effort fsync(parent dir).
 *
 * Concurrency: each doc has its own write chain, so async filesystem writes
 * land in enqueue order. Without this, two debounced flushes could complete
 * out-of-order and an older snapshot's rename could clobber a newer one.
 */
import * as Y from 'yjs';
export interface SnapshotMeta {
    schemaVersion: number;
    /** sha256 of ytext.toString() at write time. Used by callers to compare against the live ytext. */
    ytextHash: string;
    /** sha256 of the .md file content the server believes is on disk. Used to disambiguate
     *  "snapshot is crash-stale" (matches mdRevisionHash) from "the .md was edited externally"
     *  (mdRevisionHash differs) on startup reconciliation. */
    mdRevisionHash: string;
    writtenAt: number;
}
export interface LoadResult {
    state: Uint8Array;
    meta: SnapshotMeta;
}
/**
 * Resolver invoked at flush time to read the doc's current `mdRevisionHash`.
 *
 * Capture-at-schedule was racy: a server-side transact (e.g. `applyTextDiff`)
 * fires `ydoc.on('update')` BEFORE the calling code resumes, so the
 * persistence observer schedules with the pre-mutation hash even when the
 * code immediately after the transact intends to bump `entry.mdRevisionHash`
 * to the post-state. Reading via a resolver at flush time closes that gap
 * deterministically — by the time the debounce fires, the post-state hash
 * is in place.
 */
export type HashResolver = () => string;
export declare class YjsPersistence {
    private rootDir;
    /** Per-doc chain — every write appends here so async filesystem writes can't run out of order. */
    private writeChains;
    /** Per-doc debounce timers. */
    private timers;
    /** Latest-scheduled flush payload per doc; the timer captures it on fire. */
    private pending;
    private destroyed;
    constructor(rootDir: string);
    /** Resolve the on-disk path for a docName. Throws on path-traversal-shaped input. */
    private pathFor;
    private encodeFile;
    private decodeFile;
    /**
     * Load the snapshot for a doc. Returns null when no snapshot exists, or when
     * the file is malformed / wrong schema version (logged, treated as missing).
     */
    load(docName: string): Promise<LoadResult | null>;
    /**
     * Encode the Y.Doc state synchronously and append the disk write to the
     * per-doc chain. Resolves when the write lands.
     *
     * `hashOrResolver` accepts either the literal hash string or a resolver
     * function called at write time. Production callers MUST pass the resolver
     * form — see {@link HashResolver}. The string form exists for test
     * ergonomics where the hash isn't tied to a live `entry.mdRevisionHash`.
     *
     * Bypasses the debounce — call this from drain/destroy paths or when the
     * caller specifically wants a synchronous flush. Routine writes go through
     * scheduleWrite().
     */
    write(docName: string, ydoc: Y.Doc, hashOrResolver: string | HashResolver): Promise<void>;
    /**
     * Debounced write. Multiple calls within FLUSH_DEBOUNCE_MS coalesce into one
     * snapshot capturing the last-scheduled state. Use for routine Y.Doc updates.
     *
     * The resolver form ensures the snapshot's `mdRevisionHash` reflects the
     * latest live `entry.mdRevisionHash` at FLUSH time, closing the race where
     * a server-side transact fires this observer before the calling code
     * resumes to bump the hash. String form is for tests.
     */
    scheduleWrite(docName: string, ydoc: Y.Doc, hashOrResolver: string | HashResolver): void;
    /**
     * Cancel any pending debounce for this doc, flush any queued state into the
     * chain, and await the chain to drain. Call before destructive ops on the doc.
     */
    drain(docName: string): Promise<void>;
    /** Delete the snapshot file for a doc. Drains in-flight writes first. */
    delete(docName: string): Promise<void>;
    /**
     * Rename the snapshot file. Drains writes for the old doc first, then moves
     * the file. The caller is responsible for updating any in-memory references
     * (e.g. YjsManager's docs map).
     */
    rename(oldDocName: string, newDocName: string): Promise<void>;
    /** Flush all pending debounced writes and drain all chains. Call on server shutdown. */
    destroy(): Promise<void>;
    /** Append a unit of work to the per-doc chain. Errors are logged but don't break the chain. */
    private appendToChain;
    /** Atomic write: tmp file + fsync(tmp) + rename + best-effort fsync(parent dir). */
    private doWrite;
}
/** Lock-file sentinel format. JSON; backward-compatible with the legacy
 *  plain-pid format via {@link parseLockSentinel}. */
interface LockSentinel {
    pid: number;
    /** Linux clock ticks since boot. 0 when unavailable (non-Linux or read
     *  failed) — comparison is skipped when 0. */
    startTime: number;
    /** Substring of `process.argv.join(' ')`. Used as backup identity when
     *  startTime isn't available on either side. Truncated to 200 chars. */
    cmdline: string;
}
/**
 * Decide whether a lock held by `recorded` is stale (we can take over) or
 * live (we must refuse to start). Exported for tests.
 *
 * Decision tree:
 * 1. Recorded pid not alive → stale.
 * 2. Recorded pid alive AND we recorded a non-zero startTime AND we can read
 *    the live process's startTime → compare. Mismatch → stale (pid was
 *    recycled in a fresh container or on a long-lived host); match → live.
 * 3. Recorded pid alive AND startTime not usable → fall back to cmdline.
 *    Recorded `studiograph` substring AND live cmdline contains `studiograph`
 *    → live. Recorded had it but live doesn't → stale (pid recycled, different
 *    program now). Recorded didn't have it (legacy or non-studiograph) →
 *    treat as live (paranoid default — better to surface the conflict than
 *    silently take over a lock we can't identify).
 *
 * Returns 'stale' to take over, 'live' to refuse, 'self' to no-op (lock is
 * already ours from earlier in this process).
 */
export declare function classifyLockHolder(recorded: LockSentinel): 'stale' | 'live' | 'self';
/**
 * Acquire an advisory single-instance lock at `<rootDir>/.lock`. The lock is a
 * JSON sentinel `{pid, startTime, cmdline}`. Throws if another live process is
 * already using this directory — multi-instance studiograph against the same
 * workspace state would race on snapshot writes and corrupt them, since we
 * don't have cross-instance Yjs pubsub today.
 *
 * Stale locks (recorded pid is dead, or alive but a different process now —
 * common in containers where the pid namespace resets across restarts but the
 * lock file persists on a mounted volume) are taken over. See
 * {@link classifyLockHolder} for the full decision tree.
 *
 * Returns a release function the caller registers in its shutdown hook.
 *
 * Tests opt out by not calling this — each test uses its own temp dir, so the
 * lock would be a no-op anyway. The server bootstrap calls it explicitly.
 */
export declare function acquireYjsStateLock(rootDir: string): () => void;
export {};
