/** * Multi-record atomic transactions. * * Lets an application stage writes across two or more collections (or * vaults) and commit them all-or-nothing. * * ```ts * await db.transaction(async (tx) => { * const inv = tx.vault('acme').collection('invoices') * const pay = tx.vault('acme').collection('payments') * await inv.put(invoiceId, { ...invoice, status: 'paid' }) * await pay.put(paymentId, { invoiceId, amount, paidAt }) * }) * // If the body throws before returning: nothing persisted. * // If the body returns: all puts committed; any CAS mismatch rolls * // the batch back and surfaces as ConflictError. * ``` * * ## Atomicity semantics * * Ops are buffered during the body. On body-return the hub: * * 1. **Pre-flight** — re-reads every touched envelope and enforces * any caller-supplied `expectedVersion`. A mismatch throws * `ConflictError` with *no* writes performed. * 2. **Execute** — calls `Collection.put()` / `.delete()` for each * staged op in declaration order. History snapshots, ledger * appends, and change events fire as normal per op. * 3. **Unwind on failure** — if step 2 throws mid-batch, each * already-committed op is reverted via the raw store (restoring * the captured prior envelope, or deleting if none existed). The * ledger is NOT rewritten — audit history preserves the partial * commit and the revert. * * **Crash window.** Steps 2–3 are not a storage-layer transaction — * if the process dies between two executed ops, the on-disk state is * partial. True all-or-nothing atomicity requires a store that * implements `NoydbStore.tx()` (DynamoDB `TransactWriteItems`, * IndexedDB `readwrite` transaction, …). This executor declares * that future integration point via the `tx?()` method + the * `StoreCapabilities.txAtomic` bit, but does not yet delegate * to it — the cascade into `Fork · Stores` tracks the per-adapter * wire-up. * * ## Not covered * * - Cross-sync-peer atomicity. Transactions commit against the * primary store only; the sync engine pushes on its normal * schedule. For cross-peer two-phase commit use `SyncTransaction` * via `db.transaction(vaultName)`. * - Read-your-writes within the body. `tx.collection().get(id)` * returns the most-recently-staged value for that id when one * exists; if no staged op has touched the id, it reads the current * committed state. Version numbers returned by `get` reflect the * pre-transaction state (staged puts have no version yet). * * @module */ import type { Noydb } from '../../kernel/noydb.js'; import type { Vault } from '../../kernel/vault.js'; import type { Collection } from '../../kernel/collection.js'; import type { EncryptedEnvelope } from '../../kernel/types.js'; import type { TransactionInvariant } from './invariants.js'; /** One op buffered inside a running `TxContext`. @internal */ export interface StagedOp { type: 'put' | 'delete'; vaultName: string; collectionName: string; id: string; record?: unknown; expectedVersion?: number; /** * Optional human-readable tag forwarded to the resulting ledger * entry's `reason` field. Set by callers via * `tx.vault(v).collection(c).put(id, record, { reason })`. */ reason?: string; } /** * One executed op (main staged op or recursive side-effect like a * derivation output) paired with the envelope captured before the write. * `revertExecuted` walks this array in reverse on rollback. * @internal */ export interface ExecutedOp { op: StagedOp; priorEnvelope: EncryptedEnvelope | null; } /** * Options accepted by `db.transaction({ amendment, reason }, fn)`. * Only the amendment variant uses these — a plain `db.transaction(fn)` * never sees this shape. */ export interface AmendmentTxOptions { /** Opt into amendment mode. Required to be `true`. */ readonly amendment: true; /** Human-readable rationale recorded in the ledger entry. Required. */ readonly reason: string; } /** * Transaction handle passed to the user's body. Use * `tx.vault(name).collection(name)` to get a per-collection * facade; its `put`/`delete`/`get` calls stage ops against the tx. */ export declare class TxContext { /** Stable id for this transaction; shared by all writes it performs. */ readonly txId: string; /** @internal */ readonly _ops: StagedOp[]; /** * @internal — write log built up in Phase 2. Each entry records the * envelope captured BEFORE the write so a mid-batch failure can * restore prior state via `revertExecuted`. Side-effect writes (e.g. * recursive derivation outputs fired inside `Collection.put`) are * appended here in execution order so they roll back alongside the * main staged ops. */ readonly _executed: ExecutedOp[]; /** @internal */ readonly _db: Noydb; /** * @internal — true when this TxContext was opened in amendment * mode. Toggles the lazy-`beginAmendment` + role-check path on first * `tx.vault(name)` and unlocks the post-Phase-2 invariant + audit run. */ readonly _amendment: boolean; /** @internal — vaults that have already had `beginAmendment` called. */ readonly _amendmentVaults: Map; /** @internal */ constructor(db: Noydb, amendment?: boolean); /** Scope subsequent `collection()` calls to the named vault. */ vault(name: string): TxVault; } /** Per-vault facade inside a running transaction. */ export declare class TxVault { /** @internal */ readonly _ctx: TxContext; /** @internal */ readonly _vault: Vault; /** @internal */ constructor(ctx: TxContext, vault: Vault); /** Scope subsequent op calls to the named collection. */ collection(name: string): TxCollection; } /** Per-collection facade inside a running transaction. */ export declare class TxCollection { /** @internal */ readonly _ctx: TxContext; /** @internal */ readonly _vault: Vault; /** @internal */ readonly _coll: Collection; /** @internal */ readonly _name: string; /** @internal */ constructor(ctx: TxContext, vault: Vault, coll: Collection, name: string); /** * Read the current committed value, or the most-recently-staged * value from the same transaction if one exists. */ get(id: string): Promise; /** * Stage a put. Does not write until the transaction body returns. * Supply `{ expectedVersion }` to enforce optimistic concurrency * during the commit pre-flight. */ put(id: string, record: T, options?: { expectedVersion?: number; reason?: string; }): void; /** * Stage a delete. Does not write until the transaction body returns. * Supply `{ expectedVersion }` to enforce optimistic concurrency * during the commit pre-flight. */ delete(id: string, options?: { expectedVersion?: number; }): void; } /** * Commit plan: pre-flight check + execution + revert plan. * * @internal — driven by `withTransactions()` (via `tx/active.ts`) for * user-facing `db.transaction(...)` calls and by the `amendment` path * in `noydb.ts`. `Collection.putManyAtomic` runs its own Phase 2 loop * but shares the `_activeTxContext` mechanism (and the `revertExecuted` * helper) so nested side-effect derivation writes get registered for * revert alongside the bulk-put source ops. */ export declare function runTransaction(db: Noydb, fn: (tx: TxContext) => Promise | T, options?: AmendmentTxOptions, txInvariants?: ReadonlyArray): Promise; /** * Phase 3 helper — restore captured prior envelopes via the raw store * to avoid re-firing Collection-level side effects (we don't want a * cascade of change events undoing themselves). The ledger is left * as-is: each committed op appended an entry; the revert is * deliberately NOT recorded as a compensating entry because the * caller-facing contract is "atomic or not at all," not "every write * visible in the audit trail." Auditors who need the intermediate * state can still reconstruct it by walking the ledger through the * failed-tx timestamp. * * Delegates the reverse/best-effort/raw-revert shape to the shared * `bestEffortRevert` helper (`kernel/best-effort-revert.ts`); the cache * invalidation below rides along as that helper's per-leg `compensate` * callback, gated on `db` exactly as before. * * @internal — shared between `runTransaction` and * `Collection.putManyAtomic`. Both register source ops + nested * derivation side-effect ops onto `_executed`; this helper unwinds the * combined list in reverse on rollback. */ export declare function revertExecuted(executed: ReadonlyArray, store: Noydb['_store'], db?: Noydb): Promise;