/** * Sync journal — tracks per-file state (hash, size, last-synced direction) so * sync/share can detect local edits that would be clobbered by a blind pull. * * ADR-0001 Phase 5: the journal is sharded by company slug and lives in * `~/.hq/`, not inside the HQ content root. One monolithic journal per HQ * install conflates state across companies and forces every runner to * serialize through the same file — splitting it lets `hq-sync-runner * --companies` fan out without contention, and a corrupted shard only affects * one company. * * Path: `{stateDir}/sync-journal.{slug}.json`, where `stateDir` resolves to * `HQ_STATE_DIR` (if set) or `~/.hq`. */ import * as fs from "fs"; import type { SyncJournal, JournalEntry, PullRecord, V3JournalDeltaPayload, V3JournalState } from "./types.js"; import { type JournalRowsView } from "./journal-row-store.js"; export { JOURNAL_CACHE_MAX_ROWS, JOURNAL_DECODE_WINDOW, dropJournalDecodeWindow, emitJournalCacheMetric, journalCacheMetric, journalFingerprintMatchesStat, resetJournalDecodeWindowForTest, } from "./journal-decode-window.js"; import { StateStore, type StateStoreRecoveryProgress, type StateStoreSnapshotDecoder, type StateStoreSnapshotEncoder } from "./sync/state-store.js"; import { AreaLedgerMigration } from "./sync/area-ledger-migration.js"; import { type AreaMaintenanceOutcome } from "./sync/per-area-maintenance.js"; export { AreaLedger, setAreaLedgerTestHooksForTest, type AreaLedgerDelta, type AreaLedgerOptions, } from "./sync/area-ledger.js"; export { AreaLedgerMigration, AREA_LAYOUT_STATE_FORMAT, DEFAULT_MIGRATION_MAX_RECORD_BYTES, DEFAULT_MIGRATION_MAX_ROWS_PER_RECORD, areaLayoutDirectory, areaLayoutManifestPath, assertV3OnlyPinAllowed, readAreaLayoutState, resolveAreaLayoutAuthority, setAreaLedgerMigrationTestHooksForTest, type AreaLedgerMigrationOptions, type AreaLedgerMigrationTestHooks, type LayoutState, type MigrationParity, type SourceCursor, } from "./sync/area-ledger-migration.js"; /** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */ export declare const TOMBSTONE_TTL_MS: number; /** Current journal schema version written by all v2-aware writers. */ export declare const JOURNAL_VERSION_CURRENT: "2"; /** * Retain bounded pull history per company. Scope-shrink logic only needs the * newest record, but a small tail keeps diagnostics useful without letting * long-running sync loops grow journals forever. */ export declare const MAX_PULLS_PER_COMPANY = 50; export interface JournalBaseline { version: SyncJournal["version"]; lastSync: SyncJournal["lastSync"]; /** `JSON.stringify(pulls ?? [])`, the same comparison the delta always used. */ pullsJson: string; rows: Map; } /** Return the lazy internal row view for any journal without exposing its record proxy. */ export declare function journalRows(journal: SyncJournal): JournalRowsView; /** * Release this process's cached area bridge for a journal. The durable stores * have no held file descriptors; disposing only drops process-local handles. */ export declare function closeCachedAreaJournalMigration(slug: string): void; /** * Release all process-local area bridges at runner shutdown. * * A bridge owns one packed aggregate per authoritative journal. It is retained * through ordinary watch passes so a later scoped callback cannot re-pack the * whole journal synchronously. Durable layout/phase changes use the narrower * close helper above; shutdown is the terminal memory boundary. */ export declare function releaseCachedAreaJournalMigrations(): void; /** Path-free count exported solely for the runner heap census. */ export declare function journalCacheCensusSizes(): Record; /** * Remember only scalar metadata and per-row fingerprints for a journal * baseline. This deliberately does not retain `JournalEntry` objects. */ export declare function buildJournalBaseline(journal: SyncJournal): JournalBaseline; /** Exposed only to pin write-path selection at the public boundary. */ export declare function journalDeltaPathForTest(journal: SyncJournal): "dirty-log" | "fingerprint"; /** * Difference a journal against the baseline its reader remembered. * * Same contract as `journalDelta`: everything the reducer needs to rebuild * `next` from the baseline revision is here, and a row omitted from `upserts` * is asserted unchanged. */ export declare function journalDeltaFromBaseline(baseline: JournalBaseline, next: SyncJournal): V3JournalDeltaPayload; /** * The delta `writeJournal` would publish for a journal read in this process. * Exported for the baseline parity tests only; throws for a journal that no * `readJournal` produced. */ export declare function journalDeltaForTest(journal: SyncJournal): V3JournalDeltaPayload; /** * Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and * non-standard installs; otherwise falls back to `~/.hq`. */ export declare function getStateDir(): string; export declare function getJournalPath(slug: string): string; /** * Reserved journal slug for the personal-vault fanout slot in the `--companies` * runner. The vault slot uploads the whole HQ overlay (`.claude/`, `core/`, * `personal/`, …) and journals hq-root-relative keys; its `syncRoot` is the HQ * root itself. * * It MUST NOT share a journal with any real cloud company. Previously the slot * used the literal slug `"personal"`, which collided with the * `companies/personal` company (whose entity slug is also `"personal"`). The * two targets have different sync roots, so the company's whole-tree * `computeDeletePlan` walked the shared `sync-journal.personal.json`, resolved * the vault's hq-root keys against `hqRoot/companies/personal` (where they * don't exist), tombstoned them as "remote already 404", and dropped them from * the journal — only for the vault slot to re-upload them next cycle. ~190 * `.claude/skills/*` files churned every sync. * * This sentinel value can never be produced by a real company slug from the * entity service (which yields URL-safe lowercase slugs without leading * underscores), and it survives `sanitizeSlug` unchanged (only `[a-zA-Z0-9_-]` * chars; the embedded letters keep it off the all-`[_-]` reject path). */ export declare const PERSONAL_VAULT_JOURNAL_SLUG = "__hq_personal_vault__"; /** * One-time seed migration for the personal-vault journal slug. * * Before this fix the personal-vault slot journaled under the slug * `"personal"`. After the fix it journals under * `PERSONAL_VAULT_JOURNAL_SLUG`. Without a seed, the first run under the new * slug would start from an empty journal and re-upload the entire HQ overlay. * * To avoid that mass re-upload, this copies the legacy `sync-journal.personal.json` * to `sync-journal.__hq_personal_vault__.json` exactly once: only when the new * file does NOT exist and the legacy file DOES. Idempotent — a no-op when the * new file already exists or the legacy file is absent. * * The legacy `personal` journal is left untouched (it is still the journal for * the real `companies/personal` company). After the seed, both journals * converge after one cleanup cycle: the legacy `personal` journal tombstones * the now-foreign hq-root keys once; the new vault journal tombstones any * companies/personal-relative keys once. That single convergence pass is * expected and harmless. */ export declare function migratePersonalVaultJournal(): void; /** * Read a per-company journal from disk. * * Back-compat (US-005, v1 → v2): a v1 file on disk is returned as-is with * `version: "1"` and no `pulls` field. The in-place migration to v2 happens * the first time `writeJournal` runs — `migrateToV2` ensures any journal * passed to the writer carries `version: "2"`, `pulls: []`, and the rest of * the v2 shape. This keeps `readJournal` deterministic + cheap and confines * the side effect (schema bump on disk) to writes. * * When the file doesn't exist, we return a fresh v2 journal directly — new * installs never pass through v1 on disk. */ export declare function readJournal(slug: string): SyncJournal; /** * Read the mutable outer shell required by a full pull without detaching every * authoritative-ledger row from its immutable source. * * A full pull only replaces or removes rows. It never edits an existing row, * so it can safely borrow the ledger's frozen packed rows and place only its * own changes in a private overlay. `writeJournal()` drains that overlay's * dirty log, avoiding a full-ledger fingerprint at every download checkpoint. * * Legacy and area-authoritative journals both borrow immutable packed rows and * put their caller-owned mutations in a private overlay. * * @internal Full-pull boundary; not a public journal record API. */ export declare function readJournalForPass(slug: string): SyncJournal; /** * Read a mutable, detached snapshot for named rows in a scoped pass. * * Area-authoritative journals use the ledger's keyed reader. Other journals * retain one refreshed packed projection after the preceding full pass, so a * scoped read is proportional to named keys rather than a fresh snapshot * recovery. Only named delete roots require a linear row scan, with an * ancestor-set lookup per row; it must never be rows x roots. */ export declare function readJournalScoped(slug: string, keys: readonly string[], deleteScopeRoots?: readonly string[]): SyncJournal; /** * `key === root || key.startsWith(`${root}/`)` for some root, evaluated by * walking the key's ancestors (O(depth)) instead of the root list (O(roots)). */ export declare function isUnderAnyRoot(key: string, roots: ReadonlySet): boolean; /** Test-only seam for comparing packed streaming recovery with legacy JSON.parse. */ export declare function setJournalSnapshotDecoderForTest(decoder: StateStoreSnapshotDecoder | null | undefined): void; /** Test-only seam for retaining a legacy v3 writer while exercising its reader. */ export declare function setJournalSnapshotEncoderForTest(encoder: StateStoreSnapshotEncoder | null | undefined): void; /** * Bridge-only access to the v3 container. Normal journal callers must use * JournalStore; the live area migration needs source identities, durable pins, * and authenticated WAL records from the underlying StateStore. */ export declare function openJournalStateStoreForMigration(slug: string): StateStore; export interface JournalStoreDelta { upserts?: Record; deletes?: string[]; /** * Metadata fields this delta CHANGES. Both are individually optional because * `applyDelta` publishes only the ones present, and the store's reducer * preserves whatever a delta omits — which is how a writer avoids * republishing a stale snapshot of the field it did not touch over a * concurrent writer's durable value. */ metadata?: Partial>; } /** * Long-lived keyed projection over the v3 snapshot/WAL authority. * * The first open recovers once. Subsequent refreshes consume only authenticated * complete WAL frames, rebuilding the projection solely after a generation * publication or invalid-tail fallback. */ type JournalStoreScanRow = readonly [string, Readonly]; /** * Re-iterable lazy journal scan with Array#filter compatibility for the watch * loop's directory-delete classifier. Each iteration opens a fresh streaming * pass so a classifier can filter the rows and later revoke every covered * delete intent from the same scan. */ export type JournalStoreScan = Iterable & Pick; export declare class JournalStore { readonly slug: string; private store; private areaMigration?; private files; private usesSharedRows; /** * Durable deltas this keyed session has observed after its base snapshot. * The base can be shared with a full-pass facade, so publishing a scoped * delta here must never mutate it in place. */ private readonly committedOverlay; /** Base keys deleted then reinserted by durable records move to the tail. */ private readonly committedMoved; /** * Pass-local rows that have not reached the shared projection yet. A missing * value represents a staged delete; every other value is a frozen copy of * the caller's entry, so mutating that entry after `set()` cannot alter what * this session commits. */ private readonly overlay; private version; private lastSync; private pulls; private areaJournalRevision; private disposed; constructor(slug: string, store: StateStore | undefined, areaMigration?: AreaLedgerMigration | undefined); get(key: string): Readonly | undefined; scanPrefix(prefix: string): JournalStoreScan; rowCount(): number; /** Stage an upsert in this session without exposing it to other sessions. */ set(key: string, entry: JournalEntry): void; /** Stage a delete in this session. Returns false when no session-visible row exists. */ delete(key: string): boolean; /** Persist this session's staged rows, then release its private overlay. */ commit(): void; /** Test-only visibility into the bounded, caller-owned session state. */ overlaySizeForTest(): number; pullHistory(): readonly Readonly[]; /** Metadata is small and independent of the keyed row projection. */ metadata(): Readonly>; /** * The StateStore aggregate after a durable append. Its row view is immutable * across later deltas, which lets a full-pass facade rebase without exposing * its completed checkpoint to subsequent scoped writers. */ durableJournalForRebase(): Readonly; refresh(): void; /** Yielding lock acquisition for watcher callback work. */ refreshAsync(): Promise; private applyRefresh; applyDelta(delta: JournalStoreDelta): void; /** Yielding lock acquisition for watcher callback work. */ applyDeltaAsync(delta: JournalStoreDelta, shouldApply?: () => boolean): Promise; private applyAppended; private applyCommitted; /** * Packing copies every source row into typed arrays, so this projection never * aliases a source object and needs no structuredClone. A 690k-row vault used * to be deep-copied here on EVERY session open (cached targeted pull, watcher, * pass-local), which is what put the outposts over Node's heap ceiling. */ private replaceFromJournal; private applyRecord; /** Drop this pass-local full-key projection once its operation completes. */ dispose(): void; private assertOpen; } /** Drop one legacy keyed projection after an authoritative area cutover. */ export declare function releaseScopedJournalStore(slug: string): void; /** * Release retained scoped projections when a watch runner stops. Durable state * remains in the StateStore; this only drops process-local decoded snapshots. */ export declare function disposeScopedJournalStores(): void; /** Open one keyed session, seeding a missing v3 store from legacy JSON once. */ export declare function openJournalStoreSession(slug: string): JournalStore; /** * Yielding first-open for watcher callback work. * * A filesystem callback may start this open and must be able to return at * once, so the function reads nothing before its first yield: the area layout * check, the aggregate of every area ledger an authoritative vault builds in * the JournalStore constructor, and the legacy-journal seed all run on a * later turn of the event loop. `setImmediate` rather than a microtask so * the backend's already-pending events are dispatched before this work runs. */ export declare function openJournalStoreSessionAsync(slug: string): Promise; /** One enumerated journal shard: its recovered slug, on-disk path, contents. */ export interface JournalSummary { /** * Slug recovered from the `sync-journal..json` filename — the * sanitized form the engine wrote (e.g. a company slug, * `PERSONAL_VAULT_JOURNAL_SLUG`, or the legacy `"personal"`). */ slug: string; /** Absolute path to the journal file. */ path: string; /** Parsed journal contents. */ journal: SyncJournal; } /** * Enumerate journal locator slugs without opening their journal state. * * Maintenance scheduling uses this deliberately shallow discovery surface so * one corrupt area ledger cannot prevent its own repair chore from being * queued, or blind the healthy locators beside it. */ export declare function listJournalSlugs(): string[]; /** * Enumerate every sync journal present in the state dir. * * The engine SHARDS journals by slug (ADR-0001 Phase 5): the personal-vault * fanout slot under `PERSONAL_VAULT_JOURNAL_SLUG`, one shard per cloud company, * and the legacy `"personal"` shard. A caller that reads a single fixed path * therefore only ever sees one scope — and a caller that mistakes a non-slug * value for a slug (e.g. `hq sync status` passing the HQ-root PATH, which * `sanitizeSlug` mangles into `_Users__hq`) sees a slug the engine never * writes, and reports "no journal" right after a successful sync. Any surface * that wants the COMPLETE local sync picture must read ALL shards via this * helper rather than reconstructing a path. * * Slugs are recovered from each filename. A shard that fails to read or parse * is skipped rather than thrown — one corrupt shard must not blind the caller * to the healthy ones. Results are sorted by slug for deterministic output. */ /** * Read ONE journal shard by slug, with `listJournals()`'s exact tolerances. * * WHY THIS EXISTS: `listJournals()` reads and parses EVERY shard on the * machine. A caller that wants a single scope's ledger — the manifest builder * is the motivating one — therefore paid for the whole machine's journal set, * twice over (`readJournal` deep-clones each one into the baseline WeakMap) and * once per scope in a fanout. On a real vault that measured as roughly 950 MB * of resident growth before the manifest walk had statted a single file. * * Returns `null` when the slug has no shard — a first sync, which is an EMPTY * ledger and emphatically not an unreadable one — and for a shard whose JSON is * corrupt with no usable last-good, matching `listJournals()`'s decision to * skip it rather than blind the caller. Genuine IO/permission failures throw, * so a caller can still tell "unreadable" from "absent". */ export declare function readJournalSummary(slug: string): JournalSummary | null; /** * The six fields a read-only ledger consumer actually uses, and nothing else. * * A {@link JournalEntry} carries a dozen more — `ctimeMs`, `localDeleteIntent`, * `outOfScopeProtected`, the tombstone pair, the divergence and skill-metadata * markers — every one of which exists to serve a WRITE decision on the sync * path. A reporting caller retains them for nothing. Projecting each row down * to this shape as it is read is what lets the parsed source be collected * instead of pinned by the very map that was derived from it. */ export interface LedgerIndexEntry { hash?: string; size: number; mtimeMs?: number; remoteEtag?: string; syncedAt?: string; direction?: "up" | "down"; } /** Compact, read-only view of one shard's ledger rows, keyed by POSIX path. */ export type LedgerIndex = Map; /** * Build a compact ledger index from an already-parsed journal. * * Kept in its own function scope on purpose: the caller retains the returned * Map and nothing else, so the journal object graph this was derived from * becomes collectable the moment the call returns. Retaining the original * `JournalEntry` objects instead — which is what a `Map` * does — pins the whole parse result for the lifetime of the index. */ export declare function toLedgerIndex(journal: SyncJournal): LedgerIndex; /** * Read ONE shard's ledger rows as a compact index, streaming where possible. * * This is the read-only twin of {@link readJournalSummary}, and the surface the * manifest builder uses. It differs in the two ways that matter for a * background step running on a user's laptop: * * 1. It never materialises a `SyncJournal`. When the shard is a legacy JSON * document — the shape that carries the rows inline — it is scanned * incrementally (see `streamJournalLedgerRows`), so peak cost is one * 256 KB chunk plus one row rather than the file's size twice over. * 2. It retains only the six fields a reader uses, with the repeated strings * shared, so the index does not pin the parse it came from. * * The v3 store and area-authoritative layouts keep the ordinary read: their * rows arrive through a keyed store rather than as one document, and reaching * past that abstraction to stream its snapshot would couple this reader to the * store's on-disk encoding. They still get the compact projection. * * Returns `null` for a slug with no shard (a first sync — an EMPTY ledger, not * an unreadable one) and for a corrupt shard with no usable last-good, exactly * as {@link readJournalSummary} does. Genuine IO failures throw. * * PREFER {@link readJournalLedgerIndexResult} in any caller that ACTS on an * empty ledger. Collapsing "no ledger yet" and "the ledger is unreadable" into * the same `null` is exactly the conflation that let a machine with a fully * corrupt v3 store report every one of its 189,000 files as * on-disk-never-tracked and upload that as the truth. */ export declare function readJournalLedgerIndex(slug: string): LedgerIndex | null; /** * Why the ledger read produced what it produced. * * - `ok` — the ledger was read. `index` may still be EMPTY, and that * emptiness is a fact about the machine, not about the read. * - `empty` — this scope has no ledger artifacts at all (a fresh * install, a scope that has never synced). Also an honest * empty ledger, and safe to act on. * - `unreadable` — artifacts exist but none of them could be interpreted (a * truncated legacy shard with no usable last-good, or a v3 * store whose every snapshot/WAL generation is corrupt). * NOTHING may be concluded about what this machine tracks. */ export type JournalLedgerReadStatus = "ok" | "empty" | "unreadable"; export interface JournalLedgerReadResult { status: JournalLedgerReadStatus; /** Always present; empty for every status but `ok`. */ index: LedgerIndex; /** Bare error CLASS name (never a message — those carry vault paths). */ errorClass?: string; } /** * Read one shard's ledger index and say WHY the answer looks the way it does. * * This exists because {@link readJournalLedgerIndex}'s `null` is ambiguous in * the one direction that is dangerous. A reporting caller that treats an * unreadable ledger as an empty one concludes that the machine tracks nothing, * which — for the manifest builder — means every file on disk is * never-tracked drift, and a FULL manifest saying so is a complete statement * the server will act on. `empty` and `unreadable` must therefore be different * values, not the same one. */ export declare function readJournalLedgerIndexResult(slug: string): JournalLedgerReadResult; export declare function listJournals(): JournalSummary[]; /** * Defuse the pre-5.47.2 Windows backslash-key landmine in a journal's `files` * map. Such clients stamped keys with the OS path separator ("\\"), e.g. * `projects\\forecast-development\\x.csv`. A backslash key is a live data-loss * hazard for the cross-machine delete planner (Bug #9): it never matches the * forward-slash remote LIST, so the planner classifies the still-present local * file as remote-deleted, and `path.join(companyRoot, key)` collapses the * backslashes back onto the REAL POSIX file — which the executor then unlinks * (ridge incident, feedback_b8d09d0f: a single pull deleted ~36 live files, * with 587 backslash keys left in the journal as a recurring landmine). * * Rewriting every key to its canonical POSIX form on load removes the hazard * idempotently — a clean (all-POSIX) journal is returned untouched. Merge rule: * if a key's POSIX twin already exists, the POSIX entry is authoritative (it * round-tripped through an up-to-date client) and the malformed duplicate is * dropped; otherwise the entry is moved to its POSIX key. Returns the number of * keys rewritten (0 for a clean journal) for telemetry and test assertions. */ export declare function normalizeJournalKeys(journal: SyncJournal): number; /** * Coerce any-version journal into a v2 shape. Idempotent for v2 inputs. * Mutates the input and returns it for chainable use. Call this immediately * after `readJournal` if your code-path needs the v2 fields. * * v1 → v2 contract: every existing `files[]` entry is preserved as-is; no * tombstone fields are inserted (legacy entries are NOT scope-shrink * tombstones). `pulls` becomes `[]` (empty history → treat last scope as * "all" in the scope-shrink algorithm). */ export declare function migrateToV2(journal: SyncJournal): SyncJournal; /** * Write a journal to disk, migrating to the current schema version in-place. * `migrateToV2` mutates the passed-in object — callers that hold a reference * after the write will see the v2 shape. */ export declare function writeJournal(slug: string, journal: SyncJournal): void; export interface JournalRepairResult { slug: string; recoveredGeneration: number; recoveredWalBytes: number; firstCompactGeneration: number; currentGeneration: number; } export type JournalMaintenanceResult = { status: "missing" | "not-needed"; slug: string; } | ({ status: "repaired"; slug: string; } & Omit); export interface JournalMaintenanceOptions { onProgress?: (event: StateStoreRecoveryProgress) => void; /** Override the ordinary 16 MiB threshold (primarily for scaled tests). */ maxWalBytesBeforeCompaction?: number; } /** * Startup maintenance for one journal shard. The StateStore preflight is a * metadata-only no-op for healthy generations; oversized WALs (or stale large * snapshots with a replacement frame) are recovered once and atomically * replaced by two compact copies. */ export declare function repairJournalStateIfNeeded(slug: string, options?: JournalMaintenanceOptions): JournalMaintenanceResult; /** * Recover one journal shard through the streaming reader, durably write two * compact copies, and only then prune its oversized history. Callers must * quiesce sync first so an in-flight journal object cannot later overwrite the * freshly recovered state. */ export declare function repairJournalState(slug: string): JournalRepairResult; /** * Run one format-preserving maintenance unit for an authoritative area * journal. Legacy journals deliberately return undefined: their existing * startup repair path remains untouched until a layout has opted in. */ export declare function maintainAreaJournalSlice(slug: string, epoch: string): { outcome?: AreaMaintenanceOutcome; complete: boolean; } | undefined; /** * True when a timestamp pair is too coarse to be evidence of anything. * * FAT/exFAT, some FUSE mounts and cached network filesystems round timestamps * to whole seconds (FAT's mtime granularity is two), and may synthesize ctime * from mtime. On such a volume a same-size rewrite inside one tick leaves both * fields equal to the journal's, and a stat gate would strand the edit * forever. Every filesystem the gate can safely run on — APFS, ext4, NTFS — * records sub-second precision, so a pair sitting exactly on the second is * treated as untrustworthy and sent back to hashing. * * The cost of the false positive is one hash: on a real filesystem a file whose * mtime AND ctime both land exactly on a second boundary is rare, and it is * simply hashed as it was before the fast path existed. */ export declare function timestampsTooCoarseToTrust(mtimeMs: number, ctimeMs: number): boolean; /** * True when the plain file described by `lstat` provably has not been written * since `entry` was stamped, judged from stat alone. Shared by the push and * pull planners so both legs skip the same files for the same reason. * * The gate is size + mtime + ctime, and ctime is what makes it safe. mtime is * forgeable: `cp -p`, `touch -r` and `rsync --times` put an old mtime back on * new bytes, so a size+mtime check would skip such an edit forever. ctime is * the inode-change stamp and userspace cannot set it; the very utimes call * that restores mtime moves ctime to now. A file that clears all three has * not been written since we hashed it. * * Fails closed. Anything unusual about the entry — no stat recorded, a * tombstone, a pending local delete, a pull-held divergence, a symlink record, * or a local object that is no longer a plain file — returns false and sends * the caller down the hashing path, which is the pre-fast-path behaviour. * * The three fields are compared exactly, on purpose. `mtimeMs` from `lstat` * carries sub-millisecond precision that `utimes` cannot reproduce, so a * restored timestamp differs from the original anyway — but that is an * accident of precision, not a guarantee, and nothing here leans on it. * `ctimeMs` is the field doing the real work. */ export declare function journalEntryMatchesStat(entry: JournalEntry | undefined, lstat: fs.Stats): boolean; export declare function hashFile(filePath: string): string; /** * Marker prepended to a symlink's target string before hashing for the * journal. Mirrors the wire-side `SYMLINK_BODY_PREFIX` constant in * `s3.ts` — same purpose, different namespace. * * Without this marker, a symlink to `real.md` and a regular file whose * contents are exactly the bytes `real.md` produce identical journal * hashes (both `sha256("real.md")`). When `skipUnchanged` is enabled, * the planner would treat a regular-file → symlink replacement as * "no change" and never upload the new symlink, leaving the remote * representation stale forever — the pull side would then also see no * drift via ETag and never repair. * * Hashing `sha256(prefix + target)` makes the two representations * structurally inequal in journal-hash space, so skip-unchanged can * never confuse them. The hash always varies with the target string, * so target rewrites still re-fire uploads as expected. */ export declare const SYMLINK_HASH_PREFIX = "hq-symlink:"; /** * Compute the journal hash for a symlink. Always use this helper * (never inline `crypto.createHash` with the raw target) so the * push side, the pull-planner, and the post-download stamp stay in * lockstep on the prefixed-hash convention. */ export declare function hashSymlinkTarget(target: string): string; /** * Record a per-file journal entry after a transfer. * * `direction` (`"up"` = pushed / locally authored, `"down"` = pulled) is what * lets scope-shrink tell your own work apart from a mirror of someone else's. * It underpins the US-006 push-only sessions contract: a session transcript * this machine authored and pushed is stamped `direction:"up"`, so even though * `sessions/` is excluded from every pull scope, `buildScopeShrinkPlan` skips * `direction:"up"` entries and never orphans it. (Sessions fetched on demand * via `hq files get` are pulled — `direction:"down"` — but ride the pin union * in the caller's inclusion prefixSet, so they are likewise never pruned.) */ /** * Thrown when a caller tries to journal a path that is not on disk. * * Deliberately loud rather than a silent skip: a caller reaching this has a * real ordering bug, and swallowing it would reintroduce exactly the silent * drift this guard exists to prevent. */ export declare class PrematureJournalEntryError extends Error { readonly relativePath: string; readonly absolutePath: string; constructor(relativePath: string, absolutePath: string); } /** * Optional fields of a journal entry. * * These are an OBJECT rather than trailing positional parameters because they * were four adjacent optionals, two of them `string`, and callers drifted: * two conflict-resolution sites in `cli/sync.ts` passed their `"file" | * "symlink"` value into the `createdBySub` slot, so `entry.kind` was silently * never set on any conflict-resolved entry. That is not cosmetic — `entry.kind` * gates delete-intent minting (`markLocalDeleteIntent` requires it to match, * and the watcher skips any entry without one), so a file that had ever been * through conflict resolution could never be deleted, even with a live watcher. * Named fields make that class of mistake unrepresentable. */ export interface UpdateEntryOptions { remoteEtag?: string; mtimeMs?: number; ctimeMs?: number; /** Object's `created-by-sub` S3 metadata. Download path only. */ createdBySub?: string; kind?: "file" | "symlink"; /** Durable retry marker for post-upload company-skill metadata. */ skillMetadataPending?: boolean; } /** * Record a journal entry for a file that is CONFIRMED present on disk. * * `verifyAbsolutePath` is required, and the entry is written only after an * `lstat` proves the file exists. This is a load-bearing invariant, not a * defensive nicety: a journal entry says "this key was synced and the local * copy is at this hash". Once delete propagation authorizes on ETag currency * alone (no watcher-minted intent), an entry whose file was never actually * written becomes indistinguishable from "the user deleted this file" — and * the next push issues a remote DeleteObject for a file that was only ever * premature bookkeeping. * * Every existing caller already had a `fs.lstatSync` in hand and passed values * derived from it, so this makes an existing convention structural. The point * is that a FUTURE caller cannot get it wrong: with the check inside this * function there is no longer a way to add a call site that records an entry * for a file that is not there. * * `lstat` (not `stat`) so a dangling symlink still counts as present — the * link itself is the synced object, and its target may legitimately be absent. * * @throws PrematureJournalEntryError when nothing exists at `verifyAbsolutePath`. */ export declare function updateEntry(journal: SyncJournal, relativePath: string, hash: string, size: number, direction: "up" | "down", verifyAbsolutePath: string, opts?: UpdateEntryOptions): void; /** * S3 returns ETags wrapped in literal double-quotes (e.g. `"d41d8cd9..."`). * Strip them so equality comparisons across HEAD / GET / PUT responses are * stable regardless of which AWS SDK call surfaced the value. */ export declare function normalizeEtag(etag: string): string; export declare function getEntry(journal: SyncJournal, relativePath: string): JournalEntry | undefined; export declare function removeEntry(journal: SyncJournal, relativePath: string): void; /** * Generate a ULID-shaped 26-char identifier without adding a runtime dep. * Format: 10-char base32 of the current millisecond timestamp + 16-char * base32 of random bytes. Lexically sortable, time-prefixed — same property * that makes ULIDs useful for `pulls[]` ordering. * * We don't need full ULID spec compliance (monotonic counter, randomness * spec) — just sortable + collision-resistant enough that two pulls * issued in the same millisecond by different processes don't clash. * 80 bits of randomness is plenty. */ export declare function generatePullId(now?: number): string; /** * Find the most-recent `PullRecord` for a company in the journal. Returns * `undefined` when no record exists — scope-shrink callers treat that as * "no prior scope; nothing to shrink". * * Order by `completedAt` descending — `pullId` is lexically sortable but * `completedAt` is what semantically represents "most recent successful * pull state at last close". */ export declare function lastPullRecord(journal: SyncJournal, companyUid: string): PullRecord | undefined; export declare function trimPullRecords(journal: SyncJournal): void; /** Append a `PullRecord` (mutates `journal.pulls`) and cap retained history. */ export declare function appendPullRecord(journal: SyncJournal, record: PullRecord): void; /** * Write a journal tombstone entry for `relativePath`. Used by the scope- * shrink algorithm (US-005) and by `hq sync narrow --apply` (US-007). * * Tombstones intentionally keep the old `hash` / `size` / `syncedAt` / * `direction` so a recovery flow could see what was there before pruning. * They are GC'd after `TOMBSTONE_TTL_MS` via `gcTombstones`. */ export declare function tombstoneEntry(journal: SyncJournal, relativePath: string, reason: "scope_shrink" | "narrow_apply" | "manual" | "local-delete", now?: string): void; /** * Mark a journal entry for a user-authorized local delete. Callers must * capture the local hash and kind before removing the object; an absent path * alone is never deletion intent. */ export declare function markLocalDeleteIntent(journal: SyncJournal, relativePath: string, localHash: string, localKind: "file" | "symlink", expectedRemoteEtag: string): boolean; /** Invalidate a prior delete authorization after a watcher-observed recreate. */ export declare function clearLocalDeleteIntent(journal: SyncJournal, relativePath: string): boolean; /** True if the entry is a tombstone (set by `tombstoneEntry`). */ export declare function isTombstone(entry: JournalEntry | undefined): boolean; /** * Garbage-collect tombstones older than `TOMBSTONE_TTL_MS` from * `journal.files`. Returns the number removed. Cheap — single pass over * the files map, no I/O. Safe to call at the start AND end of every * `pullAll` per-company leg; both runs are idempotent. */ export declare function gcTombstones(journal: SyncJournal, now?: number): number; //# sourceMappingURL=journal.d.ts.map