/** * Trigger classes and the suspension clause (journal spec §3.5 Rule 4, C2, * D4; T12813, T12819, T12827). * * Every trigger in a store has exactly one class. The class decides whether * the trigger carries the suspension clause, which a rebase frame uses to * turn a class off inside its own transaction: * * | Class | Carries the clause | Example | * |---|---|---| * | `capture` | yes, scope `capture` | `_sync_cap_*` (S2 capture triggers) | * | `guard` | yes, scope `guard` | the parent-cycle and status-pipeline guards | * | `side-effect` | yes, scope `side-effect` | claim release, the handoff mirror | * | `derived-maintenance` | no, always active | FTS `_ai`/`_ad`/`_au` | * | `frozen-guard` | no, never modified | triggers on frozen-legacy bare twins | * * `guard` and `side-effect` triggers are OWNED: {@link OWNED_TRIGGERS} names * each one, and its DDL is the last `CREATE TRIGGER` for that name in the * migration lineage from the C2 migration on, or the code-held DDL of * {@link CODE_OWNED_TRIGGER_DDL} ({@link ownedTriggerDdl}). The open pass and * the doctor compare each owned trigger's LIVE text with that DDL and re-run * the DDL when it differs or is missing (C2(b)), but only where every table * the trigger needs exists ({@link OWNED_TRIGGER_REQUIRES}). * * A trigger is `frozen-guard` ONLY because of the table it fires ON: a * `frozen-legacy` bare twin. A rebase never touches a frozen twin, and giving * such a trigger a clause would make 9.24's replace-if-text-differs * (`twin-collapse.ts`) and this build's repair flip its DDL on every open * that alternates builds (D4). Its name never decides: a trigger on a live * table (even one named `t12535_*`) is owned, or unclassified. * * @task T12819 * @task T12827 * @module store/sync/trigger-classes */ import type { DatabaseSync } from 'node:sqlite'; import type { TableScope } from '@cleocode/contracts'; /** A trigger class. */ export type TriggerClass = 'capture' | 'guard' | 'side-effect' | 'derived-maintenance' | 'frozen-guard'; /** The classes that carry the suspension clause. */ export type SuspendableClass = 'capture' | 'guard' | 'side-effect'; /** The schema-owned flag table the suspension clause reads. Never dropped. */ export declare const TRIGGER_SUSPEND_TABLE = "cleo_trigger_suspend"; /** * DDL of the flag table, byte-identical to the C2 migration's first * statement. The open pass runs it (step 0) before migrations. */ export declare const TRIGGER_SUSPEND_TABLE_DDL = "CREATE TABLE IF NOT EXISTS `cleo_trigger_suspend` (\n `scope` TEXT PRIMARY KEY NOT NULL CHECK (`scope` IN ('capture', 'guard', 'side-effect', 'all'))\n)"; /** The C2 migration folder (drizzle-cleo-project). Owned DDL is read from it on. */ export declare const TRIGGER_CLAUSE_MIGRATION = "20260930170000_t12819-trigger-suspend-clause"; /** The lineage the owned triggers live in. */ export declare const OWNED_TRIGGER_LINEAGE = "drizzle-cleo-project"; /** * The suspension clause a trigger of `cls` carries, exactly as written in * migration SQL. Verification compares it whitespace-normalized. * * The table name is unqualified on purpose. A persistent trigger resolves an * unqualified name in its own schema (a TEMP table cannot shadow it), so the * trigger keeps working when the store is ATTACHed under another name; a * `main.` qualifier makes an attached schema unreadable ("cannot reference * objects in database main"). */ export declare function suspendClause(cls: SuspendableClass): string; /** * The owned guard and side-effect triggers (project store), by name. Each * one's DDL comes from the migration lineage or from * {@link CODE_OWNED_TRIGGER_DDL} ({@link ownedTriggerDdl}). */ export declare const OWNED_TRIGGERS: Readonly>; /** * Owned triggers whose DDL is held in code, not in the C2 migration. * * The AC uid graveyard trigger writes `tasks_ac_uid_graveyard`. A store whose * t12341 migration was probe-stamped after only its ADD COLUMNs ran (the * 9.25 live-cleocode shape) has no graveyard table, and the table is created * only by the flag-gated identity heal. A migration that created the trigger * unconditionally would make every acceptance-criterion delete fail there * ("no such table: main.tasks_ac_uid_graveyard"), so the trigger is installed * by the open pass, and only where its table exists. */ export declare const CODE_OWNED_TRIGGER_DDL: Readonly>; /** * Tables an owned trigger needs besides the one it fires ON. Where one is * missing the trigger is not verified or installed, and a live copy is a * finding (`dangling`) that repair drops: it would fail every write that * fires it. */ export declare const OWNED_TRIGGER_REQUIRES: Readonly>; /** Prefix of every capture trigger (S2, §2.3). */ export declare const CAPTURE_TRIGGER_PREFIX = "_sync_cap_"; /** A classified trigger. */ export interface TriggerClassification { readonly name: string; readonly table: string; readonly class: TriggerClass; /** Why it has this class. */ readonly reason: string; } /** * The tables a trigger body writes: `INSERT INTO t`, `UPDATE t`, * `DELETE FROM t`, `REPLACE INTO t`. Lower-cased; quotes stripped. */ export declare function triggerWriteTargets(sql: string): string[]; /** * Classify one trigger. Returns `undefined` for a trigger no rule covers, * which the Gate test reports. * * @param scope - The store the trigger lives in. * @param name - `sqlite_master.name`. * @param table - `sqlite_master.tbl_name`. * @param sql - `sqlite_master.sql`. */ export declare function classifyTrigger(scope: TableScope, name: string, table: string, sql: string): TriggerClassification | undefined; /** Collapse whitespace so text comparisons ignore layout. */ export declare function normalizeSql(sql: string): string; /** * Split migration text into `CREATE TRIGGER` statements, by name. Later * definitions of the same name replace earlier ones. `IF NOT EXISTS` is * removed, as SQLite removes it from `sqlite_master.sql`. */ export declare function createTriggerStatements(migrationSql: string): Map; /** * The owned trigger DDL: {@link CODE_OWNED_TRIGGER_DDL}, then the last * `CREATE TRIGGER` for each owned name in the lineage's migration files from * {@link TRIGGER_CLAUSE_MIGRATION} on, in folder order (a later migration * replaces a code-held text). * * @param folder - The lineage folder (tests pass one; default: installed). * @throws {Error} When an owned trigger has no DDL from C2 on. */ export declare function ownedTriggerDdl(folder?: string): Map; /** One finding of {@link verifyOwnedTriggers}. */ export interface OwnedTriggerFinding { readonly name: string; /** `dangling`: live, but a table it needs is missing (repair drops it). */ readonly problem: 'missing' | 'text-differs' | 'no-clause' | 'dangling'; readonly repaired: boolean; } /** Whether the flag table exists. Read-only. */ export declare function hasTriggerSuspendTable(db: DatabaseSync): boolean; /** * Whether triggers of `cls` are suspended right now (a flag row of `cls` or * `all`, §3.5 Rule 4). False when the flag table is absent. Read-only. * * @param db - A project store handle. * @param cls - The trigger class. */ export declare function isTriggerClassSuspended(db: DatabaseSync, cls: SuspendableClass): boolean; /** * Compare every owned trigger's live `sqlite_master.sql` with its owned DDL, * and optionally repair (drop, then re-run the owned DDL) each one that is * missing or differs. * * Only meaningful on a store whose journal has the C2 migration: before it, * the owned DDL references a table the store may not have. Callers check * {@link hasTriggerSuspendTable} and that the owned tables exist. * * @param db - A project store handle. * @param options - `repair` rewrites; `ddl` overrides the owned DDL (tests). * @returns One finding per trigger that was not identical. */ export declare function verifyOwnedTriggers(db: DatabaseSync, options?: { repair?: boolean; ddl?: Map; }): OwnedTriggerFinding[]; /** * Run DDL as one unit (T13024 MED-2): `BEGIN IMMEDIATE` … `COMMIT` on an idle * connection, a SAVEPOINT inside a caller's transaction. A failure rolls the * whole unit back, so a DROP never commits without its CREATE. */ export declare function atomicDdl(db: DatabaseSync, fn: () => T): T; /** * Classify every trigger of a store. Read-only. * * @returns The classified triggers, and the names no rule covers. */ export declare function classifyStoreTriggers(db: DatabaseSync, scope: TableScope): { classified: TriggerClassification[]; unclassified: string[]; }; /** What the open pass's step 0 did to the flag table. */ export interface TriggerSuspendStepZero { /** The table was missing and was created. */ readonly created: boolean; /** Committed suspension rows found and cleared (should never happen). */ readonly cleared: number; } /** * Open-pass step 0 (C2, round 9): make sure `cleo_trigger_suspend` exists, * BEFORE migrations, and that it is empty. * * Every write to tasks, sessions and acceptance criteria fires an owned * trigger that reads the table, and any `ALTER … RENAME` re-validates every * trigger, so a store missing it cannot be written. A committed row is * impossible through the rebase frame (it deletes and asserts before * COMMIT); if one is found anyway it is cleared, because a committed * suspension would silently disable guards for everyone. * * Writes nothing when the table exists and is empty. */ export declare function ensureTriggerSuspendTable(db: DatabaseSync): TriggerSuspendStepZero; /** * Throw unless `cleo_trigger_suspend` is empty. A frame calls this right * before COMMIT, so a suspension can never be committed (C4). * * @throws {Error} `E_TRIGGER_SUSPEND_NOT_EMPTY` when a row remains. */ export declare function assertTriggerSuspendEmpty(db: DatabaseSync): void; /** * Why a caller suspends triggers. `rewind` and `undo` restore a state the * store already held, so its guards held for it; `forward` is anything else * (apply, replay, import, a derived rewrite). */ export type SuspendPurpose = 'rewind' | 'undo' | 'forward'; /** * Run `fn` with the given trigger classes suspended, inside the caller's * transaction: insert the scope rows, run, delete them, and assert the table * is empty before returning. Other connections never see the rows (WAL), so * their triggers stay active; a crash rolls the rows back. * * Suspending `guard` (or `all`) is allowed only to rewind or undo (T12344 * AC5): a forward apply or replay with the guards off would commit a merged * state they refuse (a dependency or parent cycle) without a word. * * @throws {Error} When called outside a transaction, or * `E_GUARD_SUSPEND_FORWARD` when a forward purpose suspends `guard`. */ export declare function withTriggersSuspended(db: DatabaseSync, scopes: ReadonlyArray, purpose: SuspendPurpose, fn: () => T): T; //# sourceMappingURL=trigger-classes.d.ts.map