/** * Replica identity: binding a store file to its replica, and rebinding a * copy (journal spec §1.5, H3, N5, N6). * * A replica is one physical store file, not a machine: a worktree copy or a * restored file is a separate replica. The binding is * `(st_ino, st_birthtime, nonce)` plus the stable device id and the device * registry's high-water mark. `st_dev` is NOT used: external volumes, OS * updates, overlay and network mounts renumber it. * * Birthtime (N5): a birthtime of 0, or one equal to the ctime at bind (the * libuv fallback where the filesystem has no birthtime), is stored as NULL, * and birthtimes are compared only when both are non-NULL. * * The open pass rebinds when: * - `file-identity`: the inode differs, or both birthtimes are known and * differ (a copy, `VACUUM INTO` output, a restore to a new file); * - `foreign-device`: the store was bound on another device; * - `nonce-mismatch`: this device registered the replica id with another * nonce; * - `rollback`: for some stream the store's persisted `replicaSeq` is below * the registry's hwm (a restore into the same inode, e.g. `.backup()`). * A store missing from the registry is re-registered, never rebound for that * alone (N6). The server's "seq exists, hash differs" answer is the authority * behind that; S4 reports it through {@link rebindReplica}. * * A rebind retires the old row, mints a new replica id and nonce, carries the * clock forward, marks the old replica's live captures and sealed * transactions `inherited` (T12753, {@link markInheritedRows}), and runs the * registered {@link RebindHook}s in the same transaction. Discarding the pull * cursor, pausing push, the reconcile and the signed retire transaction * arrive with those tables (S4); they plug in as hooks. * * {@link syncOpenPass} is behind the store-level `sync.*` flags: with every * flag off it reads and writes nothing. The one exception is * {@link ensureProjectReplica}, which `cleo project link` calls to bind the * project store with every flag still off (device contract §3.7). * * @task T12342 * @module store/sync/replica */ import type { DatabaseSync } from 'node:sqlite'; import { ReplicaRegistry, type ReplicaRegistryEntry } from './replica-registry.js'; /** Which store a replica is. */ export type ReplicaScope = 'project' | 'global'; /** * How an open treats sync (§1.5). `off` for every non-canonical open * (backups, snapshots, scratch copies, bundle staging): nothing is read or * written. `live` for the canonical project and global stores. `test` for a * scratch-bound replica that must never touch the device registry: the * caller passes its own registry. */ export type SyncOpenMode = 'off' | 'live' | 'test'; /** Why a store was rebound. */ export type RebindReason = 'file-identity' | 'foreign-device' | 'nonce-mismatch' | 'rollback' | 'server-seq-conflict' /** * The Nexus device that held this replica was revoked and the same user * re-enrolled this machine: the server never re-pins a replica, so the * store takes a new id (device contract §3.7, R6). */ | 'device-reenrolled' /** * A vault restore or pull placed a snapshot at this store's path: a new * file holding another device's data, or this store rolled back (§1.5 * rules 1 and 3). The old replica is retired at the same path, so it is a * retire candidate for S4, unlike a copy (T13109). */ | 'vault-restore' /** * Undo reached its budget while the replica could not reach the stream * (§3.5 Rule 2, D5): the next pull to head rebinds, reconciles and retires * the old id (T13278). */ | 'undo-budget'; /** What `stat` reports about a store file, in nanoseconds. */ export interface FileStat { readonly ino: bigint; readonly birthtimeNs: bigint; readonly ctimeNs: bigint; } /** Reads a file's stat. Injectable so tests can simulate filesystems. */ export type StatFn = (path: string) => FileStat; /** The part of a file's identity the binding compares. */ export interface FileIdentity { readonly ino: bigint; /** Birthtime in ns, or null when unknown (N5). */ readonly birth: bigint | null; } /** A `_sync_replica` row. */ export interface ReplicaRow { readonly replicaId: string; readonly scope: ReplicaScope; readonly nonce: string; readonly deviceId: string; readonly fileIno: bigint; readonly fileBirth: bigint | null; readonly boundAt: string; readonly boundWhy: string; readonly retiredAt: string | null; readonly successor: string | null; } /** Context handed to a {@link RebindHook}. */ export interface RebindContext { readonly previous: ReplicaRow; readonly current: ReplicaRow; readonly reasons: readonly RebindReason[]; } /** * Runs inside the rebind transaction, after the new replica is bound and the * old replica's outbox rows are marked inherited. S4 registers the hooks that * mark unpushed segments, discard the cursor and pause push. A throw rolls the * whole rebind back. */ export type RebindHook = (db: DatabaseSync, ctx: RebindContext) => void; /** * Register a hook that runs on every rebind. * * @returns A function that unregisters it. */ export declare function registerRebindHook(hook: RebindHook): () => void; /** `fs.statSync` in bigint mode. */ export declare const defaultStat: StatFn; /** * The binding identity of a store file. A birthtime of 0 or equal to the * ctime is the filesystem saying "unknown", so it reads as null (N5). */ export declare function fileIdentity(path: string, stat?: StatFn): FileIdentity; /** The active (not retired) replica of a store, if bound. Read-only. */ export declare function activeReplica(db: DatabaseSync, scope: ReplicaScope): ReplicaRow | undefined; /** * The active replica id of `scope` in the store at `dbPath`, read without * binding or migrating anything: a read-only open, `null` when the store or * its replica table does not exist yet (T12336: vault reads never write). * * @param dbPath - The `cleo.db` file. * @param scope - Which replica to read. * @returns The replica id, or `null`. */ export declare function readActiveReplicaId(dbPath: string, scope: ReplicaScope): Promise; /** * `_sync_meta` key of a retirement the open pass owes the stream (T13337): a * rollback or cross-filesystem-move rebind recorded it in the rebind's own * transaction, and `settleRetireDue` (store/sync/rebind.ts) turns it into the * signed `retire` transaction and the pending server rebind. */ export declare const RETIRE_DUE_KEY = "sync.retire_due"; /** The value of {@link RETIRE_DUE_KEY}. */ export interface RetireDue { /** The retired replica. */ readonly from: string; /** Its successor. */ readonly to: string; readonly scope: ReplicaScope; readonly reasons: readonly RebindReason[]; /** * The highest replicaSeq of `from` persisted per stream, by the store or * the device registry (which outlives a rollback): every segment of it the * server can hold is at or below this. */ readonly hwm: Readonly>; /** When the store rebound. */ readonly at: string; } /** * `_sync_meta` key of the copy reconcile an open-pass rebind owes (§1.5 N7, * T13335): every rebind the open pass makes (a copy, a move, a rollback, * another device's store) records `{from, to, at}` here in the rebind's own * transaction, discards the inherited pull cursor, and pauses push. The * reconcile against a restored checkpoint pulled to head * (`store/sync/reconcile-copy.ts`) clears it. */ export declare const RECONCILE_DUE_KEY = "sync.reconcile_due"; /** The value of {@link RECONCILE_DUE_KEY}. */ export interface ReconcileDue { /** The retired replica, whose unsent changes are inherited. */ readonly from: string; /** The replica the store is bound to now. */ readonly to: string; readonly scope: ReplicaScope; /** When the store rebound. */ readonly at: string; } /** * The copy reconcile the store still owes, or null. Read-only. * * @param db - The store. * @returns The due reconcile, or null. */ export declare function reconcileDue(db: DatabaseSync): ReconcileDue | null; /** * Whether an open-pass rebind retires the old replica (§1.5 "Retirement", * T13337): a rollback of the same file (rule 3), or a cross-filesystem move * (rule 1 with the registry's old path gone). A copy (its original still at * the registry's path), a nonce mismatch or another device's store retires * nothing: the original replica is still live. * * @param reasons - Why the store rebinds. * @param entry - The device registry's entry for the old replica, if any. * @param realpath - The store file's real path now. * @param exists - Whether a path exists (injectable for tests). * @returns True when the rebind must retire the old replica. */ export declare function rebindRetires(reasons: readonly RebindReason[], entry: ReplicaRegistryEntry | undefined, realpath: string, exists?: (path: string) => boolean): boolean; /** * The retirement the open pass still owes, or null. Read-only. * * @param db - The store. * @returns The due retirement, or null. */ export declare function retireDue(db: DatabaseSync): RetireDue | null; /** Every replica row of a store, oldest first. Read-only. */ export declare function listReplicas(db: DatabaseSync): ReplicaRow[]; /** * The store-side high-water mark: the last `replicaSeq` this store persisted * per stream for a replica. Read-only. */ export declare function storeHwm(db: DatabaseSync, replicaId: string): Record; /** * Record, in the caller's transaction, that a segment with `seq` was * persisted for `stream`. S4 calls this in the segment-persist transaction, * then {@link ReplicaRegistry.advanceHwm} after it commits. The value only * moves up. */ export declare function persistStoreSeq(db: DatabaseSync, replicaId: string, stream: string, seq: number, now?: Date): void; /** * Why a bound store must rebind, if at all. Pure. * * @param row - The store's active replica row. * @param identity - The file's identity now. * @param deviceId - This device. * @param entry - This device's registry entry for the replica, if any. * @param hwm - The store-side hwm for the replica ({@link storeHwm}). */ export declare function rebindReasons(row: ReplicaRow, identity: FileIdentity, deviceId: string, entry: ReplicaRegistryEntry | undefined, hwm: Readonly>): RebindReason[]; /** Options for {@link syncOpenPass} and {@link rebindReplica}. */ export interface SyncOpenOptions { /** Path of the store file the handle has open. */ readonly dbPath: string; readonly scope: ReplicaScope; readonly mode: SyncOpenMode; /** This device. Defaults to `getStableDeviceId()` (live mode only). */ readonly deviceId?: string; /** The device registry. Required in `test` mode; defaults to the device's in `live` mode. */ readonly registry?: ReplicaRegistry; readonly stat?: StatFn; readonly now?: () => Date; /** Sync schema folder override (tests). */ readonly schemaRoot?: string; } /** What an open pass did. */ export type SyncOpenResult = { readonly status: 'off' | 'disabled'; } | { readonly status: 'bound' | 'rebound'; readonly replicaId: string; readonly previousReplicaId?: string; readonly reasons: readonly RebindReason[]; /** Whether the device registry file was written. */ readonly registryWritten: boolean; /** Whether the rebind retires the old replica ({@link RETIRE_DUE_KEY}, T13337). */ readonly retires?: boolean; }; /** * The sync open pass for a canonical store open. * * With `mode: 'off'`, or with every `sync.*` flag off on the store, it reads * nothing beyond the flags and writes nothing. Otherwise, under * `BEGIN IMMEDIATE`: it binds an unbound store, rebinds a copy, a rolled-back * or a foreign store, and heals the clock. Then it brings the device registry * up to date (re-registering a store the registry lost, recording a rename). * * Called by every canonical `live` chokepoint open (`openDualScopeDbAtPath`, T13336). */ export declare function syncOpenPass(db: DatabaseSync, opts: SyncOpenOptions): SyncOpenResult; /** * Bind the canonical project store to a replica if it has none, and return * the active replica id (device contract §3.7, the T12675 minimal slice used * by `cleo project link`). * * It applies the sync schema (local-only bookkeeping and capture-outbox * tables, no triggers) and runs the bind half of {@link syncOpenPass} under * `BEGIN IMMEDIATE`, with the same rebind rules, but sets NO `sync.*` flag: * capture, seal, push and pull stay off. Idempotent in effect: once bound it * keeps the same replica id, but every call still takes the write lock * (waiting on a busy store), may heal the clock row, and refreshes the * device replica registry file. * * @param db - The canonical project store handle (opened through the chokepoint). * @param opts - Store path and overrides; `scope` is always `project`, and * `mode` `off` is refused because a link must bind. * @returns The active replica id, and the retired id when this call rebound a copy. * @throws {Error} With `mode: 'off'`. */ export declare function ensureProjectReplica(db: DatabaseSync, opts: Omit): { replicaId: string; reboundFrom?: string; }; /** * {@link ensureProjectReplica} for the global store (`/cleo.db`): * bind it to one global-scope replica, with no `sync.*` flag set (T12952). * The replica is what the account's `home:` stream knows this device's * global store (the main brain) by. * * @param db - The canonical global store handle (opened through the chokepoint). * @param opts - Store path and overrides; `scope` is always `global`. * @returns The active replica id, and the retired id when this call rebound a copy. * @throws {Error} With `mode: 'off'`. */ export declare function ensureGlobalReplica(db: DatabaseSync, opts: Omit): { replicaId: string; reboundFrom?: string; }; /** Options of {@link rebindReplica}. */ export interface RebindReplicaOptions { /** * Record the retired replica as a retire candidate in the device registry * (default `true`). Even then it is recorded only when it was this device's * own replica of this file: registered here at this path with this nonce, * or, when the registry lost it, bound by this device (T13109 review MED-1). */ readonly recordRetirement?: boolean; /** * The caller proved the store file is the one this replica was bound to * (the vault compares the replaced file's identity), so a registry entry * at another path is this file after a rename: the nonce must still match, * the path need not, and the entry moves to this path (review LOW-A). */ readonly identityProven?: boolean; } /** * Work that must commit with the rebind itself: it runs inside the rebind's * `BEGIN IMMEDIATE`, after the new replica is bound and the old outbox is * marked inherited, and a throw rolls the whole rebind back. */ export type RebindWithin = (db: DatabaseSync, ctx: { readonly previous: ReplicaRow; readonly current: ReplicaRow; }) => T; /** * Force a rebind of a bound store: the hook S4 uses when the server answers a * push with "seq exists, hash differs" (N6: the server hwm is authoritative). * * @throws {Error} When the store has no active replica. */ export declare function rebindReplica(db: DatabaseSync, opts: SyncOpenOptions, reason?: RebindReason, rebindOpts?: RebindReplicaOptions): { replicaId: string; previousReplicaId: string; }; /** * {@link rebindReplica}, with `within` run inside the rebind transaction * (the T13278 rebind at the next pull to head: its reconcile and retire * commit with the rebind, or none of it does). * * @throws {Error} When the store has no active replica. */ export declare function rebindReplicaWith(db: DatabaseSync, opts: SyncOpenOptions, reason: RebindReason, rebindOpts: RebindReplicaOptions, within: RebindWithin): { replicaId: string; previousReplicaId: string; result: T; }; /** What {@link rebindAfterVaultRestore} did. */ export interface VaultRestoreRebind { readonly replicaId: string; readonly previousReplicaId: string; /** * `vault-restore` when the retired replica was this device's replica of the * file the snapshot replaced (a retire candidate); otherwise the open-pass * reason the store carried anyway: `foreign-device` (another device bound * it) or `file-identity` (a copy, bound to another file). */ readonly reason: Extract; } /** * After a vault restore or pull placed a snapshot at `dbPath`, retire the * store's replica and bind a new one. The placed file is a new store instance * (another device's data, or this store rolled back), so it may not continue * the old replica's `replicaSeq` stream (§1.5 rules 1 and 3, N6). Rebinding * here, before any open pass sees the new inode, records why; the server keeps * the retired replica as history until S4 announces its retirement (T13109). * * The retired replica is recorded as `vault-restore` (a retire candidate) only * when it was bound to the file the snapshot replaced (`before`) by this * device. A store that was itself a copy (bound to another file) or came from * another device is rebound for that reason, `file-identity` or * `foreign-device`, and retires nothing (review MED-1). * * A store with no bound replica (a project restored onto this machine for the * first time) is left alone: its first link binds it. * * @param dbPath - The placed `cleo.db`. * @param scope - Which store it is. * @param before - The identity of the file at `dbPath` before the placement, or `null` when there was none. * @param opts - Device id, registry, stat and clock overrides (tests). * @returns What was rebound, or `null` when the store had no replica. */ export declare function rebindAfterVaultRestore(dbPath: string, scope: ReplicaScope, before: FileIdentity | null, opts?: Pick): Promise; /** The current clock of the store's active replica, for diagnostics. Read-only. */ export declare function currentClock(db: DatabaseSync, scope: ReplicaScope): string | undefined; //# sourceMappingURL=replica.d.ts.map