/** * Capture: persistent pure-SQL triggers that record every change to a * sync-set row into `_sync_capture`, in the writing statement (journal spec * §2.3; S2, `sync.capture`; T12343 AC1 / R4). * * Per sync-set table the generator emits up to five AFTER triggers, all * carrying the `capture` suspension clause (§3.5 Rule 4): * * | Trigger | Fires on | Records | * |---|---|---| * | `_sync_cap__i` | INSERT | full image; identity read from the LIVE row (N4) | * | `_sync_cap__u` | UPDATE OF captured columns, when one changed | changed columns `[old, new]` | * | `_sync_cap__d` | DELETE | full before-image | * | `_sync_cap__k` | UPDATE OF uid / birth_fp of a keyed row, not a fill | the re-key (H5) | * | `_sync_cap__f` | UPDATE OF uid / birth_fp from NULL | patches the latest live I capture (N4, N11) | * * - Values are `enc()`-encoded before any JSON function sees them (`quote()`, * and `'r' || printf('%!.17g')` for REAL: `-0.0` becomes `0.0`, ±Inf is * `Inf` / `-Inf`, L4). * - A reference column records `[enc(local key), target uid]`, the uid read at * write time (H5). JSON-array references are translated at seal time. * - A secret column records only the `` marker, never its value * (§2.7); before-images never hold secret values. `strip` columns are * captured whole and stripped at seal. Local-only columns (claim leases) * and identity columns never fire an update capture. * - Full images are chunked `json_patch(json_object(≤60 pairs), …)` (L3); the * update image uses a `UNION ALL` of per-column terms (no argument limit). * - Each trigger also writes `_sync_undo` for the same seq, but only while * `_sync_meta.undo_enabled` exists (push on, §3.5 Rule 2); S2 runs with it * off (shadow mode). Append-only tables keep only `rk` and `uid` there. * An update's undo holds only the changed columns, old and new (D5 * amendment); a delete's holds the full row. * - I and D images omit NULL columns: the sender's column set is pinned by * its trigger set, so an absent column is NULL. * * The open pass installs the triggers only to match the persisted * `sync.capture` flag ({@link syncCaptureOpenPass}); the kill switch * `CLEO_SYNC_CAPTURE=0` never touches them (M3). `accessor.transaction()` * opens a frame ({@link openCaptureFrame}); finishing it labels the frame's * captures with the connection, frame and kind in one UPDATE * ({@link finishCaptureFrame}). * * @task T12343 * @module store/sync/capture */ import type { DatabaseSync } from 'node:sqlite'; import type { TableScope } from '@cleocode/contracts'; import { syncSetTables } from './flags.js'; /** How one table is captured. Built from the registries, or by tests. */ export interface CaptureTableDef { readonly table: string; /** Local key columns (the table's single-row identity on this device). */ readonly key: readonly string[]; /** Captured columns, in table order: every column except local-only ones. */ readonly columns: readonly string[]; /** Identity columns (recorded, read from the live row on insert; never fire U). */ readonly identity: readonly string[]; /** Secret columns: recorded as `` only. */ readonly secret: ReadonlySet; /** Reference columns: target table and its local key column. */ readonly refs: ReadonlyMap; /** Captured timestamp columns (§1.8): the merge reads them in their canonical wire form. */ readonly timestamps: ReadonlySet; /** Rows never change: undo keeps only `rk` and `uid`. */ readonly appendOnly: boolean; /** * The table's INTEGER PRIMARY KEY (a rowid alias numbered from 1 on every * device, T12896), or its declared TEXT `localKey` (T12915), when it is a * local key: kept out of {@link columns}, so it is never captured, sealed, * hashed or written from the wire, and a received row gets the next local * id. Absent when the table has none, or when it is part of a natural key. */ readonly localRowid?: string; /** A TEXT local key: an applied insert sets it to the row's uid (T12915). */ readonly localKeyIsUid?: boolean; /** * Every column kept out of {@link columns}: the `local-only` overrides and * {@link localRowid}. A received op's values for them are dropped before * planning (T13719), so an op from a build that still sent one applies. */ readonly localOnly?: ReadonlySet; /** * Nullable key columns whose NULL keeps a row local (T12914, * `RowIdentitySpec.localWhenNull`): such a row has no uid and is never * captured. */ readonly localWhenNull?: readonly string[]; /** * A later row of a shared natural key has no uid and is never captured * (T13777, `RowIdentitySpec.localUnlessFirst`). */ readonly uidRequired?: boolean; } /** One generated trigger. */ export interface CaptureTrigger { readonly name: string; readonly table: string; readonly sql: string; /** Every table the trigger's text references (SYNC_TRIGGER_REFS, N3). */ readonly refs: readonly string[]; } /** Largest number of key/value pairs per `json_object` call (L3). */ export declare const JSON_OBJECT_CHUNK = 60; /** The marker a secret column records instead of its value (§2.7). */ export declare const SECRET_MARKER = ""; /** `enc(v)`: lossless text for any SQLite value (§2.3). */ export declare function enc(expr: string): string; /** Wall milliseconds from SQLite's clock, valid on every shipped SQLite (§1.2). */ export declare const AT_MS_SQL = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)"; export { syncSetTables }; /** * The capture definition of a sync-set table in this store, or `undefined` * when the table is absent or not in the sync set. */ export declare function captureTableDef(db: DatabaseSync, scope: TableScope, table: string): CaptureTableDef | undefined; /** `json_patch(json_object(≤60 pairs), …)` over `pairs` (L3). */ export declare function chunkedObject(pairs: ReadonlyArray): string; /** * The SQL images of a repair capture (§4.4, T12987), read from the live row * aliased `row`, in the shapes the triggers write, so the sealer reads a * repair capture like any other: * - `rk`: the row's local key; * - `insert`: the I image ({@link fullImage}); * - `update`: a U image of every updatable column, `[null, after]`. The * before slot is JSON null because only the content hash was kept; a * trigger's before is always an `enc()` text, so null marks a repair. * Secret columns are left out (their `shash` is a follow-up). With * `columns`, only those of them (the rebind reconcile's touched fields). */ export declare function repairImageSql(def: CaptureTableDef, row: string, columns?: ReadonlySet): { readonly rk: string; readonly insert: string; readonly update: string; }; /** * Generate every capture trigger for one table. Pure: the same definition * always yields the same text, so the open pass can compare live text. * * The image contract the sealer reads (§2.3): * - I and D: `{col: value}` for every non-NULL captured column * ({@link fullImage}); a reference is `[localKey, uid]`, a secret * {@link SECRET_MARKER}. * - U: `{col: [before, after]}` for every changed column. Both slots are * always present and never JSON null: a value is an `enc()` text (a SQL * NULL is the text `'NULL'`), a reference a `[localKey, uid]` array, a * secret the marker. * - The ONE exception is a repair capture (§4.4, T12987; * {@link repairImageSql}): its before slot is JSON null, meaning "unknown" * (only the content hash was kept). It is valid only inside a `repair` * frame; anywhere else the sealer quarantines it. * - K: `{uid: [old, new], birth_fp?: [old, new]}`. */ export declare function captureTriggers(def: CaptureTableDef): CaptureTrigger[]; /** * Re-mint captures (T12806 × S2): a captured write that CLEARED a row's * identity (a snapshot-overwrite import: K old → NULL) is followed by a fill * that mints the new uid outside capture (the open-time fill runs with the * capture triggers dropped). For every such row, write the K that journals * the new identity (NULL → new uid, and birth_fp), so the pair nets to * old → new. Call inside the fill's bracket, after the fill. * * A row qualifies when its latest live K capture set the uid to NULL and the * row now has a uid. Undo is written only while `undo_enabled` (Rule 2). * * @returns K captures written, per table. */ export declare function captureRemints(db: DatabaseSync, scope: TableScope): Record; /** * Capture an announcement of a re-key this replica applied from another * replica (T13399, store/sync/collision-settle `announcePlacedRekeys`): a K * `uid: [old, new]`, `birth_fp: [fp, fp]` on the row that already lives at * the new uid. It moves nothing here (the sealer recognises it and leaves * row meta alone) and writes no undo, so a rebase rewind never moves the row * back; receivers record it as this replica's reference boundary. Call inside * a `rekey` frame. * * @param db - The store, inside the caller's transaction. * @param def - The re-keyed row's table. * @param oldUid - The uid before the re-key. * @param newUid - The uid the row lives at. * @param birthFp - The row's birth fingerprint (unchanged by the re-key). * @returns 1 when the capture was written, 0 when no row lives at `newUid`. */ export declare function captureRekeyAnnouncement(db: DatabaseSync, def: CaptureTableDef, oldUid: string, newUid: string, birthFp: string): number; /** Every capture trigger for the store's sync set. */ export declare function generateCaptureTriggers(db: DatabaseSync, scope: TableScope): CaptureTrigger[]; /** SYNC_TRIGGER_REFS: trigger → every table its text references (N3). */ export declare function syncTriggerRefs(db: DatabaseSync, scope: TableScope): Map; /** How the live capture triggers differ from the text generated for the current schema. */ export interface CaptureTriggerDrift { readonly missing: string[]; readonly differing: string[]; readonly extra: string[]; } /** * Live capture triggers against the text generated for the current schema * (`cleo doctor`'s sync-trigger check, and the genesis preconditions). * Read-only. */ export declare function captureTriggerDrift(db: DatabaseSync, scope: TableScope): CaptureTriggerDrift; /** What {@link installCaptureTriggers} changed. */ export interface CaptureInstallReport { readonly installed: string[]; readonly replaced: string[]; readonly dropped: string[]; } /** * Make the live capture triggers exactly the generated set: create the * missing, replace the differing, drop the orphaned. Writes nothing when * they already match. Requires the sync schema. */ export declare function installCaptureTriggers(db: DatabaseSync, scope: TableScope): CaptureInstallReport; /** Drop every capture trigger. Returns their names. */ export declare function dropCaptureTriggers(db: DatabaseSync): string[]; /** * Install the provenance stamp on a chokepoint connection (§2.3). A * persistent trigger cannot read a TEMP table, so the capture triggers write * rows unlabelled; the labels (connection, frame, kind) are written ONCE per * frame by {@link finishCaptureFrame}, over the frame's seq range. Writes are * serialized by the write lock, so every capture from the frame's * `first_seq` on belongs to it. This replaces a TEMP trigger that ran one * UPDATE per capture (S2 insert-path ruling (a)). Also turns * `recursive_triggers` on for this connection (M1; the REPLACE audit is * T12787's zero-tolerance ban plus `recursive-triggers-audit.test.ts`). * * @returns This connection's id. */ export declare function installCaptureStamp(db: DatabaseSync): string; /** Remove the stamp (capture turned off, or the sync tables dropped). */ export declare function removeCaptureStamp(db: DatabaseSync): void; /** Whether this connection carries the capture stamp. */ export declare function hasCaptureStamp(db: DatabaseSync): boolean; /** The kinds a frame can have (§2.3). */ export type FrameKind = 'write' | 'apply' | 'rebase' | 'repair' | 'exodus' | 'import' | 'remint' | 'rekey' /** The rebind reconcile's re-emitted fields (§1.5 N7): sealed as `repair`, `via: 'rebind'` (T13278). */ | 'rebind'; /** * Open a frame for the caller's transaction, right after its * `BEGIN IMMEDIATE`: insert the `_sync_frame` row with the first seq its * captures can take. A connection without the stamp (capture off) gets * `null` and nothing is written. */ export declare function openCaptureFrame(db: DatabaseSync, kind?: FrameKind, actor?: string | null): string | null; /** * Before COMMIT: label the frame's captures and undo rows (one UPDATE each * over its seq range), drop the undo of an `apply` / `rebase` frame (D1), and * delete the frame's row when no capture carries it, so a read-only * transaction leaves nothing (N10). A transaction that commits without this * call leaves its captures unframed, never mislabelled. */ export declare function finishCaptureFrame(db: DatabaseSync, frame: string | null): void; /** * In `finally`: nothing is left to clear (the labels are written by * {@link finishCaptureFrame}); kept so callers need not change. */ export declare function clearCaptureFrame(_db: DatabaseSync, _frame: string | null): void; /** * `cleo sync capture on|off` (the storage primitive): persist the flag and * install or drop the triggers, in one transaction. */ export declare function setCaptureEnabled(db: DatabaseSync, scope: TableScope, on: boolean, options?: { schemaRoot?: string; }): CaptureInstallReport | { dropped: string[]; }; /** What the capture open pass did. */ export type CaptureOpenResult = { readonly capture: 'off'; readonly dropped: string[]; } | { readonly capture: 'on'; readonly report: CaptureInstallReport; readonly conn: string; }; /** * The capture step of the open pass, after migrations: triggers match the * persisted flag. With the flag off and no trigger present it only reads. */ export declare function syncCaptureOpenPass(db: DatabaseSync, scope: TableScope, options?: { schemaRoot?: string; }): CaptureOpenResult; /** * Bring a project store's lazily migrated tables current before a sync step * reads or writes them (T13719). The brain lineage runs when the brain domain * binds, not at the store open, so a joiner that never opened brain refused * every brain op naming a newer column, and a genesis cut taken before brain * opened snapshotted the old shape. Its DDL runs outside a migration bracket, * so the capture triggers are then regenerated for the new columns (a no-op * when they already match). * * @param db - The store, outside any transaction. * @param scope - Its scope; only `project` has lazily migrated tables. * * @example * ```ts * await establishLazySchemas(db, 'project'); * applyStagedTxns(db, opts); * ``` */ export declare function establishLazySchemas(db: DatabaseSync, scope: TableScope): Promise; /** * Migration-runner hooks (§2.3a rules 1 and 5): drop every capture trigger * inside the bracket before a migration's statements, and regenerate them for * the new schema before its COMMIT. Only when capture is on. */ export declare function captureBracketHooks(db: DatabaseSync, scope: TableScope): { suspendCapture?(db: DatabaseSync): void; reinstallCapture?(db: DatabaseSync): void; }; //# sourceMappingURL=capture.d.ts.map