/** * Twin collapses (T12535): keep a prefixed twin in step with its BARE legacy * table, which stays authoritative while an older CLEO build still writes it. * * ## Why * * The project `cleo.db` carries legacy bare tables beside their prefixed (E6) * twins. For `schema_meta` and `sticky_tags` the runtime wrote the bare table * while the twin held a frozen exodus copy. This build reads and writes only * the twin. The release before it (2026.9.20) is still installed on machines * that share project stores and keeps writing the bare tables, so the collapse * runs at every open: * * - **Initial collapse** (no marker yet): check the free space, snapshot the * store, then fold the bare table into the twin, bare-authoritative. * - **Incremental re-merge** (marker present): carry over only the bare keys * (sticky ids) whose hash changed since the last merge. No snapshot. An * unchanged bare table costs one read of it and no write. * * A later release removes the incremental re-merge once no supported build * writes the bare tables; slice 3 then drops them after a backup. * * ## State and provenance * * The marker row `twin_collapse:` in the twin's key/value table * holds, per key (`schema_meta`) or per sticky id (`sticky_tags`), a sha256 * of the BARE value and of the TWIN value as of the last merge. A bare hash * that moved means the older build wrote it; a twin hash that moved means this * build wrote it. * * ## Rules * * The bare row is authoritative for every key. A monotonic counter FIELD is * merged on its own: the result is the bare value with that field set to * max(bare, twin). A whole twin value never replaces a bare one * ({@link mergeSchemaMetaValue}). * * | `schema_meta` key | Counter field | * |---|---| * | `task_id_sequence` (`{counter,lastId,checksum}` from `sequence/index.ts`, or a plain number) | `counter` (a plain number takes the max of the two numbers) | * | `sqlite_snapshot_gate` (`{generation,prefixes}`, `snapshot-gate.ts`) | `generation` | * | `file_meta` (`FileMeta`; `session/engine-ops.ts` bumps `generation` on session start/end/resume) | `generation` | * | `backfill:*` (the two `t877` guard keys) | not carried: the lineage re-inserts them into every bare table and nothing reads them | * | `twin_collapse*` | not carried: collapse state and failures live only in the twin | * | every other key (`schemaVersion`, `version`, `focus_state`, `focus_state:`, `project_meta`, `project`, `parallel_state`, `activeSession`, `reconcile..release`, any unknown key) | none: the bare value | * * - **Initial collapse**, when the bare table holds at least one carried key: * every bare key is carried under the rules (the bare value wins every key * both hold). A key only the twin holds is KEPT, never dropped (the union * rule, T12535 audit 2026-09-29): it is listed as `kept` in the receipt and * marker. When a key's twin value would be replaced by a different result, * the twin value is copied to `twin_collapse_archive:` in * `tasks_schema_meta` (listed as `archived`; doctor shows it), so nothing * unique is left only in the snapshot. `focus_state` merges instead: the * bare value's current fields win and `sessionNotes` is the union of both, * deduplicated by `(timestamp, note)` ({@link mergeFocusState}); its twin * value is archived as well. The pre-collapse snapshot holds the store * before any change and is pinned (never rotated). * - **Incremental re-merge**, per bare key whose hash changed since the last * merge: * - twin unchanged since the last merge → the bare value (counter fields * maxed); * - twin ALSO changed (this build wrote it too) → CONFLICT: the TWIN value * wins, recorded in the marker and reported by `cleo doctor` as a warning. * A counter key is never a conflict: the twin value is kept with its * counter field maxed against the bare one. * A key the older build deleted is deleted from the twin unless the twin * changed it too (conflict) or it is `task_id_sequence` / * `sqlite_snapshot_gate`. A key the bare table never changed is never * touched. * * `sticky_tags` → `brain_sticky_tags` (rows are `(sticky_id, tag)`, both the * key, so a collision is two identical rows and one is kept): * * - Initial collapse: bare tags are added. A twin-only tag the note's * `tags_json` names is kept (listed as `kept`). One it does not name leaves * the junction (which follows `tags_json`, the value every reader displays) * and is recorded under `twin_collapse_archive:sticky_tags` in * `brain_schema_meta` (listed as `archived`); adding it to `tags_json` would * change the tags the user sees. A bare tag whose note no longer exists is * not carried (`skipped`; it stays in the bare table). * - Incremental re-merge, per sticky id whose bare tag-set hash changed: if * the twin's set for that id is unchanged since the last merge, the twin * set is made equal to the bare set (additions AND removals propagate); * if the twin set changed too, the twin wins and a conflict is recorded. * - After every merge the junction is recomputed from each note's * `tags_json` ({@link syncStickyJunction}). Both builds write `tags_json` * (on the shared `brain_sticky_notes` row) together with their own junction, * so it is the one value both see; the junction is derived from it, and the * tag filter can never disagree with the displayed tags. A conflict is still * recorded for `cleo doctor`. * * `attachments` + `attachment_refs` → `docs_attachments` + `docs_attachment_refs` * merge as ONE pair, in one transaction (a doc and its refs never diverge). * The bare rows are authoritative over the columns both tables share, except * `ref_count`, which is derived: after every merge it is recomputed as the * number of refs in `docs_attachment_refs`, whoever wrote them (never carried, * never max'd). Rows are keyed `d:` and `r:` in the marker hashes. * * - Initial collapse: the twins are made equal to the bare tables, even when * the bare tables are empty (the older build never showed a twin-only row); * frozen twin-only rows are dropped and listed. * - Incremental re-merge, per bare doc whose hash moved (see {@link planDocs}): * twin changed too → conflict, the twin wins; bare row gone → the twin row * goes unless a ref or a supersedes link still names it (`kept`); the * content (sha256) already belongs to another twin row → merged into that * row: the bare id is aliased to it (`twin_collapse_alias:attachments`) and * its refs follow; the slug already belongs to another twin row → carried * under `-` (`renamed`). Natural keys are checked against the state * after the merge, so a merge never fails on them. Per bare ref whose hash * moved: added → added (aliased), removed → removed unless this build * changed it since. * - Change detection: AFTER triggers on the bare tables (`t12535_track_*`) * bump `twin_collapse_seq:attachments` on every write, the older build's * included. An open compares that counter with the marker's instead of * re-hashing every doc; a missing trigger falls back to the full compare. * - Union shape: migration `20260929000000_t12535-docs-attachments-union-shape` * adds the bare-only `display_alias` column and indexes; the UNIQUE slug and * sha256 indexes are dropped and re-created by the merge itself around the * row changes. Every merge ends with them in place, or rolls back. * - The `supersedes` / `superseded_by` self-FKs are checked at commit * (`PRAGMA defer_foreign_keys`), so a chain merges in any row order. * - A bare row that violates a twin CHECK (e.g. a non-ISO `created_at`) fails * the merge; the docs pair opens degraded (docs writes refused, everything * else writable) and `cleo doctor twin-collapse` shows the cause. * - Blobs: after each merge every twin doc's blob gets a keep-link (and is * restored from it if the older build unlinked it), see `blob-keep.ts`. * - Freeze (option (a), D11160): every merge installs BEFORE * INSERT/UPDATE/DELETE triggers on the bare docs tables that abort an * older build's write with the upgrade message ({@link docsFrozenMessage}). * This build never writes them, so there is no bypass. Every open checks the freeze and change * triggers and re-installs missing ones (a rebuild or restore drops them); * `cleo doctor` reports them missing. The (b) defences above cover the time * a freeze is missing. * * `token_usage` → `tasks_token_usage` (T13115) is a DRAIN, not a mirror: token * rows are append-only telemetry with random ids, and since T13111 this build * reads and writes only the twin. Every merge moves each bare row into the twin * (only the columns both tables hold) and deletes it from the bare table, so * the bare table is the empty inbox of whatever an older build writes. A row * the twin cannot hold verbatim, or holds a different copy of, stays there, * listed as a conflict, and is not decided again unless it changes. The drain * deletes a bare row only when the twin holds every value of it, so it is * lossless and takes no snapshot; the twin is correct whatever the bare table * holds, so a failed drain degrades nothing (contract 5 does not apply) and is * retried at the next open; and a long backlog drains in bounded transactions. * See `TOKEN_USAGE`. * * `playbook_runs` + `playbook_approvals` → `tasks_playbook_runs` + * `tasks_playbook_approvals` (T12535) is a lossless CARRY then a freeze: this * build reads and writes only the twins (which sync); every bare row the twins * lack is copied (at the initial collapse a differing bare row wins and the * twin's value is archived), nothing is deleted, and the bare tables get the * same freeze triggers as the docs pair. See `PLAYBOOKS`. * * ## Contract * * 1. **Snapshot first** (initial collapse only, only when the merge changes * the twin; never for the lossless `token_usage` drain). The free space is * checked before the `VACUUM INTO`. One snapshot covers every pair * collapsing in this open, registered as a PINNED `migration` backup: * `cleo backup list` shows it, rotation never deletes it, and a snapshot * an earlier build wrote unpinned is pinned at the next open (`cleo * doctor` lists an unpinned one). * 2. **Atomic.** Each pair merges, verifies and writes its marker in one * `BEGIN IMMEDIATE` transaction, the marker re-read under the write lock. * Any failure rolls back: both tables byte-identical. * 3. **Verified.** Every row the merge decided is re-read and compared before * the marker is written; a mismatch rolls back. * 4. **The bare table is never written**, with two sanctioned exceptions: * the `token_usage` drain (above), and {@link mirrorCounterToBare}. When * this build writes a counter key (`task_id_sequence`, * `sqlite_snapshot_gate`, `file_meta`) it raises the bare row's counter * field to the twin's in the same transaction, and the * allocation floors on the bare counter ({@link bareCounterOf}). The * 2026.9.20 build still allocates ids from the bare counter; without the * mirror, an id one build reserved but had not stored yet could be issued * by the other. * 5. **Never locked out** (inside a domain bind, `onFailure: 'degrade'`). A * failed or impossible collapse does not fail the bind. The failure is * recorded (`twin_collapse_failed:`), and the connection is put in a * READ-ONLY-FOR-USERS mode: the merged view the collapse would have * committed is computed in memory and served from TEMP shadow tables of the * same names (unqualified SQL resolves `temp` before `main`), so reads see * bare-authoritative data while `main` stays untouched. Mutating operations * are refused with `E_TWIN_COLLAPSE_FAILED` by the dispatch write guard * ({@link twinCollapseFailureOf}), and the shadows carry TEMP triggers that * abort any write into them ({@link SHADOW_WRITE_REFUSED}), so a direct SDK * write fails instead of being lost. `cleo doctor twin-collapse --retry` * runs the collapse with `onFailure: 'throw'`; on success the shadows and * their triggers are dropped. * * ## Gate 28 * * This module is a sanctioned writer (`scripts/lint-no-raw-table-writes.mjs` * SANCTIONED): it runs on the chokepoint handle inside the domain bind, and the * accessors import the modules that call it. * * @module * @task T12535 */ import type { DatabaseSync } from 'node:sqlite'; import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite'; import { z } from 'zod'; import { CleoError } from '../errors.js'; /** Prefix of the marker key holding a pair's collapse state. */ export declare const TWIN_COLLAPSE_MARKER_PREFIX = "twin_collapse:"; /** Prefix of the key recording a pair's last failed collapse. */ export declare const TWIN_COLLAPSE_FAILURE_PREFIX = "twin_collapse_failed:"; /** `schema_meta` keys whose named field is a monotonic counter (merged as max). */ export declare const SCHEMA_META_COUNTER_FIELDS: Readonly>; /** `schema_meta` keys never deleted from the twin (a counter never moves back). */ export declare const SCHEMA_META_NEVER_DELETED: ReadonlySet; /** What one call did to one pair. */ export interface TwinCollapseReceipt { /** The bare legacy table. */ readonly table: string; /** The prefixed twin. */ readonly twin: string; /** * `initial`: the first collapse ran. `incremental`: bare changes since the * last merge were carried. `unchanged`: the bare table matched the stored * hashes. `degraded`: the collapse failed inside a bind; reads are served * from TEMP shadows and writes are refused. `deferred`: a lossless pair's * merge failed; nothing is degraded, its bare rows wait, and the next open * retries (T13115). `no-bare-table`: a table of the pair is missing. */ readonly status: 'initial' | 'incremental' | 'unchanged' | 'degraded' | 'deferred' | 'no-bare-table'; /** The initial collapse's snapshot, or `null`. */ readonly snapshotPath: string | null; /** Rows added to the twin. */ readonly inserted: number; /** Twin rows whose value was replaced. */ readonly replaced: number; /** * Twin rows deleted (bare deletions, frozen rows dropped initially); for the * `token_usage` drain, bare rows removed once the twin held them. */ readonly deleted: number; /** Bare rows not carried by rule (dead keys, tags of deleted notes). */ readonly skipped: number; /** Twin rows the initial collapse dropped (none since the union rule; kept for old markers). */ readonly dropped: readonly string[]; /** Keys (or sticky ids) both builds changed since the last merge; the twin was kept. */ readonly conflicts: readonly string[]; /** Docs: bare rows merged into the twin row holding the same content (` -> `). */ readonly merged: readonly string[]; /** Docs: rows carried under a free slug because another doc holds theirs. */ readonly renamed: readonly string[]; /** * Rows kept by the union rule: twin-only keys / sticky tags the initial * collapse carried over, and (docs) twin rows the bare side deleted that a * ref or link still names. */ readonly kept: readonly string[]; /** Keys whose replaced twin value was copied to `twin_collapse_archive:`. */ readonly archived: readonly string[]; } /** * The value a `schema_meta` key gets when the bare side is authoritative (the * rule table is in the module comment). * * @param key - The key. * @param bare - Its value in the bare table. * @param twin - Its value in the twin, or `undefined` when absent. * @returns The value to keep, or `null` when the key is not carried. * @task T12535 */ export declare function mergeSchemaMetaValue(key: string, bare: string, twin: string | undefined): string | null; /** * Raise the BARE `schema_meta` row of a counter key to the twin's counter, * right after this build wrote the twin row, on the same connection (so inside * the writer's transaction). * * The one sanctioned write to a bare table (T12535): the 2026.9.20 build still * allocates task ids from the bare `task_id_sequence` and reads the bare * snapshot-gate and `file_meta` generations. Without this, an id this build * reserved but has not stored yet (the counter is advanced before the task * row is inserted) could be issued again by the older build. Only the counter * field moves (the bare row keeps its other fields, see * {@link mergeSchemaMetaValue}); a missing bare row gets the twin value. A * non-counter key, or a store without the bare table, is left alone. The * collapse treats the resulting bare change like any other: a counter key is * never a conflict. * * @param db - The project `cleo.db` connection that wrote the twin row. * @param key - The `schema_meta` key just written. * @task T12535 */ export declare function mirrorCounterToBare(db: DatabaseSync, key: string): void; /** * The counter of a key's BARE `schema_meta` row (the older build's), or 0. * This build's allocation floors on it, so an id the older build reserved but * has not stored yet is never issued here either. * * @param db - The project `cleo.db` connection. * @param key - A counter key. * @returns The bare counter field, or 0 when absent. * @task T12535 */ export declare function bareCounterOf(db: DatabaseSync, key: string): number; /** Prefix of the key holding a twin value the initial collapse replaced. */ export declare const TWIN_COLLAPSE_ARCHIVE_PREFIX = "twin_collapse_archive:"; /** * `focus_state` when both tables hold it: the bare value's current fields * win, and the session-note history is the union of both * (`sessionNotes`, deduplicated by `(timestamp, note)`, sorted by timestamp). * The twin may hold months of notes the bare copy never saw (178 KB on the * cleocode Linux store). A value that is not a JSON object stays the bare one * (the replaced twin value is archived either way). * * @param bare - The bare value. * @param twin - The twin value. * @returns The merged value. * @task T12535 */ export declare function mergeFocusState(bare: string, twin: string): string; /** Key of the bare→twin id aliases (bare rows merged into a twin row by sha256). */ export declare const DOCS_ALIAS_KEY = "twin_collapse_alias:attachments"; /** Key the bare-side change triggers bump on every write to the bare docs tables. */ export declare const DOCS_CHANGE_SEQ_KEY = "twin_collapse_seq:attachments"; /** * The message an older build's write to a frozen bare docs table aborts with. * It names the release that moved the docs, what fails (docs, changesets, and * the IVTR playbook provenance an older build attaches when a playbook run * finalises), and the upgrade command. * * It must never read as contention or corruption to an older build: those * retry on `sqlite_busy` / `database is locked` and restore backups on * `database disk image is malformed`, so none of those words appear (pinned by * a test, see {@link DOCS_FROZEN_FORBIDDEN}). */ export declare function docsFrozenMessage(version: string | null): string; /** Key recording the version of the build that first froze this store's bare docs tables. */ export declare const DOCS_FROZEN_BY_KEY = "twin_collapse_frozen_by:attachments"; /** * The version the freeze message names: the first RELEASE build that froze * (or later opened) this store, recorded under {@link DOCS_FROZEN_BY_KEY}. * * Why recorded, not a literal: the release that ships the freeze was not * known when the code was written (2026.9.23 shipped without it). Why not the * running build's version: a trigger outlives the build that wrote it, and a * development build (a worktree, or the main checkout) reports the PREVIOUS * release's version, which cannot read the store. So only a published build * (running from an installed package, i.e. under `node_modules`) records its * version, and never the unreadable `0.0.0` fallback. Until one does, the * message names no version and points at `@latest`; the first release build * to open the store records itself and the triggers are re-written. * * @param db - The project `cleo.db` connection. * @returns The version to name, or `null` (no release build has frozen or * opened this store yet). * @task T12535 */ export declare function docsFrozenVersion(db: DatabaseSync): string | null; /** * Test only: make this process count as a published build with `version` * (or restore the real detection with `undefined`). * * @param override - The build to pretend to be. * @task T12535 */ export declare function setFreezeBuildForTests(override: { published: boolean; version: string; } | undefined): void; /** Phrases the freeze message must never contain (older builds' retry and recovery matchers). */ export declare const DOCS_FROZEN_FORBIDDEN: readonly string[]; /** * Override the drains' chunk size (token usage and audit log), for tests * (`undefined` restores it). * * @param size - Rows per drain transaction. * @task T13115 */ export declare function setTokenDrainChunkForTests(size: number | undefined): void; /** Key recording the version of the build that first froze the bare playbook tables. */ export declare const PLAYBOOKS_FROZEN_BY_KEY = "twin_collapse_frozen_by:playbook_runs"; /** Twin rows the initial collapse replaced by their bare rows, as `\t` → twin JSON. */ export declare const PLAYBOOKS_ARCHIVE_KEY = "twin_collapse_archive_rows:playbook_runs"; /** * The message an older build's write to a frozen bare playbook table aborts * with. Same rules as {@link docsFrozenMessage}: it names the upgrade and * none of the {@link DOCS_FROZEN_FORBIDDEN} phrases. * * @param version - The release that froze the store, or `null`. * @returns The abort message. * @task T12535 */ export declare function playbooksFrozenMessage(version: string | null): string; /** Why a collapse failed, as carried by `E_TWIN_COLLAPSE_FAILED`. */ export interface TwinCollapseFailure { /** The bare tables whose collapse did not complete. */ readonly tables: readonly string[]; /** The underlying error message. */ readonly cause: string; /** The snapshot this collapse needs (the planned path) or wrote, or `null`. */ readonly snapshotPath: string | null; /** Whether {@link snapshotPath} was written (`false`: only planned). */ readonly snapshotWritten: boolean; /** Free space the snapshot needs, in bytes (0 when none was needed). */ readonly requiredBytes: number; /** Free space measured on the backup filesystem, or `null`. */ readonly availableBytes: number | null; /** When it failed (ISO-8601). */ readonly failedAt: string; } /** * The failure a connection is degraded by, if any: reads on it are served from * TEMP shadows and writes must be refused (the dispatch write guard does). * * @param db - A project `cleo.db` connection. * @returns The failure, or `undefined` when the store is fully collapsed. * @task T12535 */ export declare function twinCollapseFailureOf(db: DatabaseSync): TwinCollapseFailure | undefined; /** * Refuse a write before its first statement when the collapse of the table it * writes failed. The write accessors of the collapsed tables * (`tasks_schema_meta`, sticky notes and tags, the docs tables) call it at * entry, so a degraded store never takes a partial write (e.g. a sticky's * `tags_json` without its tag rows). Only the failed pairs are blocked: a * docs merge failure leaves task and sticky writes alone. The shadows' TEMP * triggers stay as the backstop. * * @param handle - The connection the write would use: the native * `DatabaseSync`, or a drizzle instance (its `$client` is checked). * `null`/`undefined` (no bound handle) is not checked. * @param table - The bare table whose pair the write belongs to. * @throws {CleoError} `E_TWIN_COLLAPSE_FAILED` when that pair's collapse failed. * @task T12535 */ export declare function assertTwinCollapseWritable(handle: DatabaseSync | NodeSQLiteDatabase | null | undefined, table: TwinCollapseTable): void; /** The bare tables a failed collapse can block writes for. */ export type TwinCollapseTable = 'schema_meta' | 'sticky_tags' | 'attachments'; /** * Build the `E_TWIN_COLLAPSE_FAILED` error for a failure. * * @param failure - The failure. * @param cause - The underlying error, when there is one. * @returns The error. * @task T12535 */ export declare function twinCollapseError(failure: TwinCollapseFailure, cause?: unknown): CleoError; /** Message a write into a sealed shadow aborts with. */ export declare const SHADOW_WRITE_REFUSED = "E_TWIN_COLLAPSE_FAILED: store is read-only until the twin collapse succeeds; run cleo doctor twin-collapse"; /** Options for {@link collapseTwinTables}. */ export interface CollapseTwinTablesOptions { /** * `throw` (default): raise `E_TWIN_COLLAPSE_FAILED`. `degrade` (the domain * binds): record the failure, serve reads from TEMP shadows, and let the * dispatch write guard refuse writes. */ readonly onFailure?: 'throw' | 'degrade'; } /** * Collapse every bare/twin pair present in the store: the initial collapse * (snapshot first) where no marker exists yet, an incremental re-merge of * changed bare keys where one does. * * @param nativeDb - The project `cleo.db` connection, outside any transaction. * @param dbPath - Its file path (locates `.cleo/backups/sqlite/`). * @param options - Failure handling. * @returns One receipt per pair. * @throws {CleoError} `E_TWIN_COLLAPSE_FAILED` with `onFailure: 'throw'`. * @task T12535 */ export declare function collapseTwinTables(nativeDb: DatabaseSync, dbPath: string, options?: CollapseTwinTablesOptions): TwinCollapseReceipt[]; /** Read-only status of one pair, for `cleo doctor`. */ export interface TwinCollapseStatus { /** The bare legacy table. */ readonly table: string; /** The prefixed twin. */ readonly twin: string; /** * `collapsed`: marker present, bare side unchanged since the last merge. * `bare-changed`: the older build changed the bare side since; the next * open carries it. `pending`: no marker; the initial collapse runs at the * next open. `failed`: the last attempt failed (see `failure`); reads are * served, writes refused. `no-bare-table`: nothing to collapse. */ readonly state: 'collapsed' | 'bare-changed' | 'pending' | 'failed' | 'no-bare-table'; /** * Whether a pending initial collapse would change the twin and so needs a * snapshot (never for a lossless pair, which takes none). */ readonly wouldChangeTwin: boolean; /** The initial collapse's snapshot (or the planned one of a failed attempt). */ readonly snapshotPath: string | null; /** When the initial collapse ran. */ readonly collapsedAt: string | null; /** When the last merge ran. */ readonly lastMergedAt: string | null; /** Keys (schema_meta) or sticky ids (sticky_tags) the bare side changed since the last merge. */ readonly changedSinceMerge: number; /** Keys / sticky ids both builds changed in the last merge (the twin was kept). */ readonly conflicts: readonly string[]; /** When those conflicts were recorded. */ readonly conflictsAt: string | null; /** Docs: bare rows the last merge folded into the twin row with the same content. */ readonly merged: readonly string[]; /** Docs: rows the last merge carried under a free slug. */ readonly renamed: readonly string[]; /** Twin-only rows the initial collapse carried over (union rule). */ readonly kept: readonly string[]; /** Keys whose replaced twin value is archived under `twin_collapse_archive:`. */ readonly archived: readonly string[]; /** * Whether the pair's triggers on the bare tables (the freeze against older * builds, the change counter) are all in place; `null` when the pair has * none or is not collapsed yet. Missing ones are re-installed at the next open. */ readonly guardsIntact: boolean | null; /** * Whether the collapse snapshot is pinned (never rotated); `null` when there * is no snapshot on disk. */ readonly snapshotPinned: boolean | null; /** * `true` when the marker names a pre-collapse snapshot that is no longer on * disk: the only copy of the store before the collapse is gone. */ readonly snapshotMissing: boolean; /** The recorded failure, when the last attempt failed. */ readonly failure: TwinCollapseFailure | null; } /** * Report every pair's collapse state without writing (for `cleo doctor`). * * @param db - A connection on the project `cleo.db`; read-only is enough. * @returns One status per pair. * @task T12535 */ export declare function inspectTwinCollapse(db: DatabaseSync): TwinCollapseStatus[]; /** Prefix of a recovery receipt key: `twin_collapse_recovery:`. */ export declare const TWIN_COLLAPSE_RECOVERY_PREFIX = "twin_collapse_recovery:"; /** Error code of a recovery or rollback that cannot proceed; nothing was written. */ export declare const E_TWIN_COLLAPSE_RECOVER = "E_TWIN_COLLAPSE_RECOVER"; /** One twin value the recovery archives. */ export interface TwinCollapseRecoveryArchive { /** The `schema_meta` key. */ readonly key: string; /** Where the value is written (`twin_collapse_archive:`, or a suffixed key). */ readonly archiveKey: string; /** `dropped`: only the twin held the key; `replaced`: the bare value won it. */ readonly reason: 'dropped' | 'replaced'; /** Length of the archived value, in characters. */ readonly length: number; } /** What {@link planTwinCollapseRecovery} found, and what an apply would write. */ export interface TwinCollapseRecoveryPlan { /** The pre-collapse snapshot the `schema_meta` marker records; `null`: never collapsed. */ readonly snapshot: string | null; /** Whether that snapshot is on disk. */ readonly snapshotExists: boolean; /** * Whether that snapshot is pinned (its sidecar says `pinned: true`), so * rotation never deletes it. An apply refuses an unpinned snapshot unless * the caller pins it first (`--pin-snapshot`). */ readonly snapshotPinned: boolean; /** Twin `schema_meta` values to archive. */ readonly archive: readonly TwinCollapseRecoveryArchive[]; /** Session notes the live `focus_state` gains from the twin history. */ readonly focusNotesAdded: number; /** Twin-only sticky tags (`sticky_id\ttag`) to archive. */ readonly stickyArchived: readonly string[]; /** Where those tags go: `twin_collapse_archive:sticky_tags`, or a suffixed key. */ readonly stickyArchiveKey: string; /** Receipt keys of earlier applies that were not rolled back. */ readonly receipts: readonly string[]; /** Whether an apply would write anything. */ readonly changes: boolean; /** * The path the markers record, when the store moved (`/mnt` → `/home`, * Linux → Mac) and the snapshot was found by file name in the store's own * backup directory instead (T12788); `null` otherwise. An apply re-points * the markers at {@link snapshot}. */ readonly movedFrom: string | null; } /** Options of {@link planTwinCollapseRecovery}. */ export interface TwinCollapseRecoveryPlanOptions { /** The snapshot file to use in place of the markers' path (a moved store, T12788). */ readonly snapshotPath?: string; } declare const RecoveryReceiptShape: z.ZodObject<{ task: z.ZodLiteral<"T12727">; id: z.ZodString; recoveredAt: z.ZodString; snapshot: z.ZodNullable; archived: z.ZodArray>; focusNotesAdded: z.ZodNumber; stickyArchived: z.ZodArray; stickyArchiveKey: z.ZodString; before: z.ZodObject<{ focusState: z.ZodNullable; stickyArchive: z.ZodNullable; }, z.core.$strip>; after: z.ZodObject<{ focusState: z.ZodNullable; stickyArchive: z.ZodNullable; }, z.core.$strip>; rolledBackAt: z.ZodNullable; }, z.core.$strip>; /** * Receipt an apply records under `twin_collapse_recovery:` * (never over an earlier one). `before` holds the values the apply replaced, * `after` the sha256 of what it wrote, so `--rollback` can undo it. */ export type TwinCollapseRecoveryReceipt = z.infer; /** * Plan the recovery of the twin values a 2026.9.21 collapse dropped or * replaced, from the pre-collapse snapshot its marker records (T12727). * * The 9.21 initial collapse was bare-authoritative: it deleted keys and * sticky tags only the twin held and overwrote twin values with the bare * ones, keeping them only in that snapshot. This compares the snapshot's * twin and bare tables (read-only; a pair whose table the snapshot lacks is * skipped) and plans, for every twin value that is not live and not archived: * - `schema_meta`: the twin value is written to `twin_collapse_archive:` * (a suffixed key when that name already holds another value, so nothing is * overwritten); for `focus_state` the twin's session notes are also merged * into the live value ({@link mergeFocusState}: live current fields win, * notes deduplicated by `(timestamp, note)` and sorted); * - `sticky_tags`: twin-only tags are added to * `twin_collapse_archive:sticky_tags` in `brain_schema_meta` (a suffixed key * when that row is not a list of tags). * The notes are merged once: not again after an apply that was not rolled * back, nor when the twin value is already archived, so notes pruned after a * recovery stay pruned. A second apply plans nothing. * * @param live - The project `cleo.db` connection (read-only is enough). * @param snapshot - The snapshot, opened read-only, or `null` when missing. * @returns The plan. * @task T12727 */ export declare function planTwinCollapseRecovery(live: DatabaseSync, snapshot: DatabaseSync | null, options?: TwinCollapseRecoveryPlanOptions): TwinCollapseRecoveryPlan; /** * Apply a recovery in one `BEGIN IMMEDIATE` transaction on the live store. * * The plan is recomputed INSIDE the transaction from the live values read * under the write lock, so a note written after `plan` was built is merged, * never lost; `plan` is the preview the caller showed. Writes the archive * keys, the merged `focus_state`, the sticky archive, and a new receipt. * Nothing is written when there is nothing to recover. Never touches the * snapshot. * * @param live - The project `cleo.db` connection, outside a transaction. * @param plan - From {@link planTwinCollapseRecovery} on the same connection. * @returns The plan applied and its receipt (`null` when nothing was written). * @throws {CleoError} {@link E_TWIN_COLLAPSE_RECOVER} when the recorded * snapshot is missing (nothing is written). * @task T12727 */ export declare function applyTwinCollapseRecovery(live: DatabaseSync, plan: TwinCollapseRecoveryPlan, onRepoint?: (repoint: TwinCollapseSnapshotRepoint) => void): { readonly plan: TwinCollapseRecoveryPlan; readonly receipt: TwinCollapseRecoveryReceipt | null; readonly repointed: TwinCollapseSnapshotRepoint | null; }; /** Markers re-pointed from a moved store's old snapshot path (T12788). */ export interface TwinCollapseSnapshotRepoint { /** The path the markers recorded. */ readonly from: string; /** The file in the store's own backup directory they name now. */ readonly to: string; /** The marker keys rewritten (`:twin_collapse:
`). */ readonly markers: readonly string[]; } /** The error for a recorded snapshot that is not on disk. */ export declare function missingSnapshot(path: string | null): CleoError; /** The error for a recorded snapshot rotation could still delete. */ export declare function unpinnedSnapshot(path: string | null): CleoError; /** * Pin the recorded pre-collapse snapshot before a recovery * (`--pin-snapshot`): writes its `.meta.json` sidecar only, never the snapshot. * * @param path - The snapshot the marker records. * @returns Whether it is pinned now. * @task T12727 */ export declare function pinRecoverySnapshot(path: string): boolean; /** What {@link rollbackTwinCollapseRecovery} undid. */ export interface TwinCollapseRecoveryRollback { /** The receipt key. */ readonly id: string; /** Archive keys deleted (they still held what the recovery wrote). */ readonly archiveDeleted: readonly string[]; /** Archive keys left alone (changed since; not the recovery's value any more). */ readonly archiveKept: readonly string[]; /** * `restored`: `focus_state` was unchanged since, so the pre-merge value is * back; `notes-removed`: it changed since, so only the notes the recovery * added were removed; `none`: the recovery did not touch it. */ readonly focusState: 'restored' | 'notes-removed' | 'none'; /** Session notes removed from `focus_state`. */ readonly notesRemoved: number; /** Sticky rows removed from the sticky archive. */ readonly stickyRemoved: number; } /** * Undo one recovery apply (`cleo doctor twin-collapse --rollback `), in * one `BEGIN IMMEDIATE` transaction: * - archive keys it wrote are deleted when they still hold that value; * - `focus_state` is restored to its pre-merge value when unchanged since, * else only the notes the recovery added are removed (notes written since * stay); * - the sticky archive is restored when unchanged since, else only the rows * the recovery added are removed; * - the receipt is kept, stamped `rolledBackAt`. * * @param live - The project `cleo.db` connection, outside a transaction. * @param id - The receipt key (`twin_collapse_recovery:`). * @returns What was undone. * @throws {CleoError} {@link E_TWIN_COLLAPSE_RECOVER} when the receipt is * unknown or already rolled back (nothing is written). * @task T12727 */ export declare function rollbackTwinCollapseRecovery(live: DatabaseSync, id: string): TwinCollapseRecoveryRollback; /** Error code of a snapshot release that cannot proceed; nothing was written. */ export declare const E_TWIN_COLLAPSE_RELEASE = "E_TWIN_COLLAPSE_RELEASE"; /** What {@link planSnapshotRelease} found for one pre-collapse snapshot. */ export interface TwinCollapseSnapshotReleasePlan { /** The snapshot path the markers record, or `null` when none matches. */ readonly snapshot: string | null; /** Marker keys that reference it (`:twin_collapse:
`). */ readonly markers: readonly string[]; /** Whether the snapshot is on disk. */ readonly exists: boolean; /** Bytes reclaimed once it rotates (the file plus any `-wal`/`-shm`). */ readonly bytes: number; /** * `recovered`: a recovery apply (not rolled back) read this snapshot and * nothing is left to recover; `no-recovery-needed`: it holds nothing the * live store lacks; `null`: not releasable (see `blockers`). */ readonly basis: 'recovered' | 'no-recovery-needed' | null; /** Recovery receipts (not rolled back) that read this snapshot. */ readonly receipts: readonly string[]; /** Why it cannot be released; empty when it can. */ readonly blockers: readonly string[]; /** The path the markers record when the store moved and {@link snapshot} is the local file (T12788). */ readonly movedFrom: string | null; } /** * Plan releasing a pre-collapse snapshot (T12767): which markers reference * it, how many bytes it holds, and whether it may be released. It may only * after a verified recovery or a no-recovery-needed check: the recovery plan * against it ({@link planTwinCollapseRecovery}) must find nothing left to * recover, and only the `schema_meta` / `sticky_tags` markers (the pairs the * recovery covers) may reference it. * * @param live - The project `cleo.db` connection (read-only is enough). * @param id - The snapshot: its backup id (`migration-`), file name or path. * @param snapshot - The snapshot opened read-only, or `null` when it is not on disk. * @returns The plan. * @task T12767 */ export declare function planSnapshotRelease(live: DatabaseSync, id: string, snapshot: DatabaseSync | null, options?: TwinCollapseRecoveryPlanOptions): TwinCollapseSnapshotReleasePlan; /** The release refusal for a plan with blockers. */ export declare function releaseRefused(id: string, plan: TwinCollapseSnapshotReleasePlan): CleoError; /** What {@link releaseTwinCollapseSnapshot} did. */ export interface TwinCollapseSnapshotRelease { readonly snapshot: string; /** Marker keys whose snapshot reference was cleared. */ readonly markers: readonly string[]; readonly basis: 'recovered' | 'no-recovery-needed'; readonly bytes: number; readonly releasedAt: string; /** The path the markers recorded, when the store moved (T12788); `null` otherwise. */ readonly movedFrom: string | null; } /** * Release a pre-collapse snapshot, in one `BEGIN IMMEDIATE` transaction: the * plan is re-checked under the write lock, each referencing marker's * `snapshot` is cleared (so an open no longer pins it and rotation no longer * keeps it), its sidecar pin is released, and `audit` records the release * before the commit (a failed audit write refuses it). The snapshot file is * never touched; it rotates normally from then on. * * @param live - The project `cleo.db` connection, outside a transaction. * @param id - The snapshot: backup id, file name or path. * @param snapshot - The snapshot opened read-only, or `null`. * @param audit - Appends the audit row; throws to refuse. * @returns What was released. * @throws {CleoError} {@link E_TWIN_COLLAPSE_RELEASE} when it may not be released. * @task T12767 */ export declare function releaseTwinCollapseSnapshot(live: DatabaseSync, id: string, snapshot: DatabaseSync | null, audit: (release: TwinCollapseSnapshotRelease & { receipts: readonly string[]; }) => void, options?: TwinCollapseRecoveryPlanOptions): TwinCollapseSnapshotRelease; export {}; //# sourceMappingURL=twin-collapse.d.ts.map