import { JournalRowStore } from "../journal-row-store.js"; import type { JournalEntry, PullRecord, SyncJournal } from "../types.js"; import { type StateStoreMaintenanceOptions, type StateStoreMaintenanceResult } from "./state-store.js"; type JournalMetadata = { lastSync?: string; pulls?: PullRecord[]; }; export interface AreaLedgerDelta { upserts?: Record; deletes?: readonly string[]; metadata?: JournalMetadata; } /** * Home for any key the configured layout cannot place. * * The area layout is committed exactly once, at the legacy -> area cutover, and * the resolver is then rebuilt from that frozen manifest on every run — "an * authority decision, not a hint". So the layout only ever knows the top-level * directories that existed on cutover day. Create a new one afterwards (an * entirely ordinary thing to do in your own HQ) and every key beneath it * resolves to nothing. * * Before this area existed that threw out of `append()`, which aborted the * WHOLE checkpoint — every unrelated change in the same transaction went with * it — and, because nothing ever rebuilds the layout, it then failed * identically on every later sync. Machines stayed wedged for days and the only * recovery was deleting the directory by hand. * * Routing those keys here instead keeps three properties that matter: the * transaction completes, the rows stay journaled (a dropped row would look * untracked forever and never reconcile), and every routable key stays in its * own area, so per-area append locks still let two processes work in parallel. */ export declare const UNROUTED_AREA_ID = "root:@unrouted"; export interface AreaLedgerOptions { rootDir: string; journalSlug: string; layoutEpoch: number; /** Every configured data area. `@meta` is reserved for the ledger itself. */ areaIds: readonly string[]; /** Returns the one configured area responsible for a canonical journal key. */ resolveArea: (key: string) => string | undefined; /** Used only when an area StateStore is first created; this is not migration. */ initialJournal?: SyncJournal; /** Process-local packed aggregate cache used only by the area-authoritative journal adapter. */ sharedJournalRowsCacheKey?: string; maxRecordsBeforeCompaction?: number; maxWalBytesBeforeCompaction?: number; } interface AreaLedgerTestHooks { afterDataBeforeWatermark?: () => void; afterWatermarkBeforeFinalize?: () => void; onMaterializeRow?: (key: string) => void; } /** Test-only crash seam: data prepares are durable before a meta watermark. */ export declare function setAreaLedgerTestHooksForTest(hooks: AreaLedgerTestHooks | undefined): void; /** * The storage half of the parallel-sync area design. * * Data records are prepared in area stores first. The tiny `@meta` record is * the final watermark. A crash before that watermark leaves a recoverable, * invisible prepare; a crash after it leaves a complete aggregate view. This * makes global metadata data-first and watermark-last without putting a large * aggregate snapshot behind one append lock. */ export declare class AreaLedger { private readonly options; private readonly areaIds; private readonly areas; private meta; private readonly opened; /** Projection used by aggregate readers; area stores remain authoritative. */ private aggregateCache; /** * Which keys the overflow area currently holds, plus the generation the * answer was read at. Convergence has to ask this on every append, so it is * memoized and re-read only when the overflow area actually changes on disk * — an ordinary append against an untouched overflow does no I/O for it. */ private overflowRows; /** Durable identities captured after a local append while the cache is cold. */ private coldCacheIdentities; private aggregateRevision; private constructor(); static open(options: AreaLedgerOptions): AreaLedger; /** Scope identity is stable and human-auditable even though StateStore hashes it on disk. */ scopeId(areaId: string): string; /** Opens one data area only. Useful for a one-path, metadata-free operation. */ readScoped(keys: readonly string[]): SyncJournal; /** * Read only the current values for named keys without materializing their * areas. Reverse-shadow parity uses this after an append, so its cost is * proportional to the delta rather than to every row in a selected area. */ readRows(keys: readonly string[]): Readonly>; /** * Materialize rows equal to or beneath `roots` from the already layout-bound * `areaIds`. Callers supply the bounded area set because AreaLedger owns * persisted state while AreaResolver owns the durable path layout. */ readRowsUnder(roots: readonly string[], areaIds: readonly string[]): Readonly>; private readAreaStates; /** Metadata is stored separately from area rows and is tiny relative to a journal. */ readMetadata(): Readonly>; /** * Monotonic in-process revision for keyed sessions. It advances as soon as * this ledger mutates, even while its aggregate cache is deliberately cold. */ currentRevision(): number; /** * Cheap generation observation for keyed sessions. A local writer already * holds the current revision, so a cold aggregate cache is not a reason to * rebuild its projection. Another process is detected from area/meta state * identities and will be reconciled by the following readCached() call. */ hasChangesSince(revision: number | undefined): boolean; /** Aggregate read. It is the parity view used by legacy SyncJournal callers. */ read(): SyncJournal; /** * Internal aggregate view for long-lived keyed sessions. The returned journal * is owned by this ledger and must not be mutated; `revision` changes whenever * the cached aggregate changes. Public callers must use read(), which returns * a detached snapshot. */ readCached(): Readonly<{ journal: SyncJournal; rows: JournalRowStore["rows"]; revision: number; }>; /** * Commit a partitioned journal delta. A metadata-only update still has a * `@meta` append, while all data-bearing commits use the recoverable prepare * protocol so multiple areas never expose a half transaction to `read()`. */ append(delta: AreaLedgerDelta): void; /** * Finish a transaction whose process died after durable area prepares but * before its meta watermark. Incomplete prepares stay invisible, so a * crash can never synthesize a partial aggregate journal. */ recoverPendingTransactions(): void; /** Test/diagnostic surface proving independent scopes have independent locks. */ stateDirectoriesForTest(): ReadonlyMap; openedAreasForTest(): readonly string[]; /** * Rotate or repair exactly one data area. This is intentionally not an * aggregate operation: the caller schedules one bounded unit at a time so * a healthy area never waits behind an unrelated area's recovery. */ maintainArea(areaId: string, maintenance?: StateStoreMaintenanceOptions): { repair: StateStoreMaintenanceResult; rotated: boolean; }; transactionCountsForTest(): { prepared: number; watermarks: number; finalized: number; }; /** Returns true when this durable append folded in records a peer wrote. */ private appendMeta; private openArea; private openMeta; private initialAreaJournal; private partition; /** * Whether the overflow area is holding a row for `key`. * * Convergence cannot be conditional on some earlier aggregate read having * noticed the duplicate: an area-authoritative scoped pass never builds an * aggregate, and a delete that skips the overflow copy leaves a stale row * that resurfaces and makes the key undeletable. So this is asked directly — * but asked cheaply. The overflow area only ever holds keys no layout could * place, so its materialized key set is small, and it is re-read only when * the store's on-disk generation has actually moved. A vault whose overflow * area never changes therefore pays one read for the life of the instance. */ private overflowHoldsRow; /** * A per-key "does this area hold this row" probe over the area states, with * each area's state read at most once. Row-at-a-time by design — see * `partition` for why a materialized map is not an option here. */ private rowLookup; /** The always-authoritative half of `rowLookup`: every answer read from disk. */ private diskRowLookup; private resolveArea; /** * Where a key's rows belong, and whether the frozen layout placed it there. * `routed: false` means the layout could not place it — the rare case the * overflow area exists for, and the only case that needs a state probe. */ private placeKey; private materializeAllAreas; private assertKnownArea; /** * Resolve a key that materialized in two areas at once. * * Two REAL areas holding one key is corruption and still throws: the layout * gives every routable key exactly one home, so a genuine duplicate means the * on-disk state disagrees with itself and silently picking a winner would * hide it. * * A real area and the OVERFLOW area holding one key is not corruption — it is * the ordinary consequence of the routable set changing under a frozen * layout, and it must never be fatal. It happens whenever a key that used to * route stops routing (6.16.24 excluded the top-level lockfiles, so * `pnpm-lock.yaml` had a live entry in its real area and then took the * overflow route on the next delta that touched it) or the reverse. Before * this method that collision threw out of `combine`, which runs inside * `primeAggregateCache` on the JournalStore open path — so it fired on EVERY * open, wedging the vault permanently and, unlike the append-time failure * UNROUTED_AREA_ID replaced, with no way for the user to recover. * * The real area wins. The overflow area is a fallback for keys the layout * cannot place, so a key it CAN place is governed by its placed entry; the * overflow copy is stale by construction and is dropped from the aggregate. * `resolveArea` keeps a new one from being written (see below), so this is * healing, not a permanent second source of truth. */ private resolveAreaCollision; private combine; /** A watermark publishes only when every named area has prepared or folded it. */ private committedTransactions; /** * Bound replay after publication. Area folds are individually durable; a * folded marker keeps a still-present watermark atomic if the process dies * between peers. The watermark is retired only once every named area has * its folded base snapshot, then a later pass clears the transient markers. */ private finalizeCommittedTransactions; private foldPublishedTransactionsIfNeeded; private primeAggregateCache; /** * Associate this aggregate view with the one packed store for its durable * area generations. Sessions can borrow it without rebuilding every row; * an identity change creates a new packed store before the new aggregate is * exposed, so a later async opener cannot observe stale rows. */ private cachedJournal; /** Generation pointer + WAL size observation takes neither append lock nor readdir. */ private refreshChangedStores; private currentAreaStates; private hasUnpublishedPreparedTransactions; private recoverPendingTransactionsFromStates; private updateCachedArea; /** * Rebuild one area's packed rows from its whole durable journal. * * `updateCachedArea`'s overlay shortcut replays only the changed paths of a * visible-but-unfolded tail, which is right when the packed store already * holds that area's durable BASE. A durable append that had to recover — * the peer-collision case — hands the area store a fresh state detached from * the packed rows, so the base is exactly what the cache is missing. Rebuild * it wholesale instead. Rows are snapshotted first because the incoming * journal may still read through the same backing store this clears. */ private repackCachedArea; /** * Whether an area other than `areaId` also holds `key`. * * Only the OVERFLOW area can legitimately share a key with a real one — two * real areas sharing one is corruption and still throws — so this is an O(1) * lookup in whichever of the two the caller is not refreshing, except when * the overflow area itself is being refreshed. The overflow only ever holds * keys no layout could place, so that scan stays small. */ private sharesKeyWithAnotherArea; /** * Which OTHER area's materialized journal currently holds `key`. A real area * is returned in preference to the overflow, so the answer does not depend on * map iteration order — `resolveAreaCollision` then applies the same rule it * applies inside `combine`. */ private findCachedOwner; private updateCacheMetadata; private replaceCacheIdentities; private currentAreaIdentities; private defaultAreaForKey; private rowOwner; private areaJournalRows; /** Replace an area record with its filtered facade over the shared packed rows. */ private attachAreaJournal; /** * Move decoded area records into the one aggregate store, then discard each * record map in favour of an area-filtered facade over that store. A shared * aggregate bypasses this method entirely, so opening another bridge never * repacks every row merely to throw the new store away. */ private packAreaJournals; private writeAreaRow; private deleteAreaRow; /** A shadow remains durable when its aggregate owner is deleted, so make it visible. */ private promoteShadowAfterOwnerDelete; /** Refresh only identities that changed since this process's own cold-cache append. */ private refreshChangedColdStores; private captureColdCacheIdentities; /** * Keep a warm packed aggregate current without rebuilding every unchanged row. * * `peerAreaIds` names the areas whose durable append folded in records this * process had not observed — a peer committed between the `readCached()` at * the top of `append()` and this process taking the area's lock. The * own-delta shortcut below cannot represent those rows, and the identity * captured at the end would then claim the packed aggregate is current, so * no later `readCached()` would ever reconcile them. Materialize those areas * from their durable state instead, exactly as `readCached()` does for an * externally-changed area. `peerMeta` is the same observation for `@meta`. */ private updateWarmAggregateAfterAppend; private invalidateAggregateCache; } export {}; //# sourceMappingURL=area-ledger.d.ts.map