import type { Database, MaybePromise, Row, TableIndexSchema as IndexSchema, FilterInfo, SchemaChangeInfo, UpdateArgs, VirtualTableConnection, UpdateResult } from '@quereus/quereus'; import { VirtualTable } from '@quereus/quereus'; import type { IsolationModule } from './isolation-module.js'; import { type IsolatedTableCallback } from './isolated-connection.js'; /** * A table wrapper that provides transaction isolation via an overlay. * * Each IsolatedTable instance accesses a connection-scoped overlay that is: * - Created lazily on first write * - Shared across all IsolatedTable instances in the same transaction * - Stored in the IsolationModule's connection overlay map * * This provides true per-connection isolation - each connection's uncommitted * changes are invisible to other connections, but visible to all queries * within the same connection. * * Reads merge overlay changes with underlying data. * Writes go to overlay only until commit. */ export declare class IsolatedTable extends VirtualTable implements IsolatedTableCallback { private readonly isolationModule; private readonly underlyingTable; private readonly readCommitted; private registeredConnection; /** * Lazy cache of compiled partial-UNIQUE predicates. Keyed on the * UniqueConstraintSchema object identity — a new constraint object * after CREATE/DROP INDEX produces a fresh compile, and the WeakMap * lets the GC reclaim entries for retired constraints. */ private readonly predicateCache; /** * Returns the connection-scoped set of savepoint depths that pre-date the overlay. * Stored in IsolationModule (keyed by db+schema+table) so all IsolatedTable instances * for the same connection see the same set — important because each statement creates * a fresh IsolatedTable instance via module.connect(), so instance-local state would * be lost between the createSavepoint callback and the ensureOverlay() call. */ private get savepointsBeforeOverlay(); constructor(db: Database, module: IsolationModule, underlyingTable: VirtualTable, readCommitted?: boolean); /** * Gets the tombstone column name from the module. */ private get tombstoneColumn(); /** * Gets the connection-scoped overlay state, or undefined if no overlay exists yet. */ private getOverlayState; /** * Throws if this connection's overlay was poisoned by a cross-connection ALTER * (see `IsolationModule.alterTable`). A poisoned overlay still holds rows in the * PRE-alter column layout, so it can neither be merged into a read nor flushed to * the now-altered underlying. Called at the data-op chokepoints (write, the merged * read branch, and the commit flush) — never on the committed-snapshot read path, * which bypasses the overlay entirely and stays safe. The connection recovers by * rolling back, which discards the overlay (and its poison). */ private assertOverlayUsable; /** * Gets the overlay table, or undefined if no overlay exists yet. */ private get overlayTable(); /** * Gets whether this connection has uncommitted changes. */ private get hasChanges(); /** * Sets the hasChanges flag in the connection-scoped overlay state. */ private setHasChanges; /** * Lazily creates the overlay table on first write. * * The overlay is stored in connection-scoped storage, so it persists * across multiple IsolatedTable instances within the same transaction. * * The schema is obtained from the underlying table at this point, * supporting scenarios where schema is discovered lazily from storage. */ private ensureOverlay; /** * Ensures a connection is registered with the database for transaction coordination. * This is called before any read or write operation. * * Multiple IsolatedTable instances may be created per transaction (one per getVTable() * call in the runtime). Without reuse, each instance would register a fresh * IsolatedConnection, causing DeferredConstraintQueue.findConnection() to find multiple * covering candidates and throw. We therefore reuse the first covering connection * already registered for this table. * * Concurrent first-reads coalesce through the module-level in-flight memo * (`IsolationModule.coalesceConnectionBuild`, keyed per db+table) rather than a * per-instance one: the runtime connects a FRESH `IsolatedTable` per scan, so * two concurrent scans of one table land on distinct instances and only a memo * that spans all wrappers for the (db, table) can collapse them onto one * registered covering connection. The resolved connection is cached in * `registeredConnection` so subsequent reads on THIS instance fast-path — this * is the only place that field is set when the build was coalesced onto another * instance's in-flight promise (that instance's `buildConnection` ran, ours did * not). */ private ensureConnection; /** * Builds (or reuses) the registered connection for this table. Always called * through `IsolationModule.coalesceConnectionBuild` so concurrent callers — * across all `IsolatedTable` instances for this (db, table) — share one build. * * The covering-reuse check stays INSIDE this coalesced body so a connection * registered by another instance between calls (e.g. after the memo cleared on * settle) is still picked up by the next read. * `registeredConnection` is assigned only on the success paths (covering reuse * or a completed `registerConnection`); a thrown `registerConnection` / * overlay `createConnection` leaves it null and rejects the in-flight promise, * which `coalesceConnectionBuild` clears so a later read rebuilds. */ private buildConnection; /** * Creates a new isolated connection for transaction support. * The connection includes this table as a callback so commit/rollback * operations properly flush/clear the overlay. */ createConnection(): MaybePromise; private createConnectionAsync; /** * Query the table, merging overlay with underlying. * * When overlay is empty or doesn't exist, delegates directly to underlying for efficiency. * When overlay has changes, merges both streams using the appropriate key order. * * For primary key scans: merge by PK order * For secondary index scans: merge by (indexKey, PK) order */ query(filterInfo: FilterInfo): AsyncIterable; /** * Wrapper that ensures connection before merging. */ private mergedQueryWithConnection; /** * Performs merged query combining overlay and underlying data. * * For primary key scans: uses position-based merge since both streams share * the same sort order and overlay entries align with underlying rows by PK. * * For secondary index scans: uses PK-exclusion approach because overlay entries * may have different index key values than the underlying rows they shadow * (tombstones have null non-PK columns; updates may change the indexed column). */ private mergedQuery; /** * Merged query strategy for secondary index scans. * * Instead of position-based merging (which fails when overlay entries have * different index key values than the underlying rows they shadow), this: * 1. Collects all PKs modified in the overlay (full scan) * 2. Queries underlying via secondary index, excluding modified PKs * 3. Queries overlay via secondary index for non-tombstone data rows * 4. Merges the two disjoint, sorted streams by sort key */ private mergedSecondaryIndexQuery; /** * Parses FilterInfo to determine which index is being used. * Returns null for full table scan or primary key scan, index name for secondary indexes. */ private parseIndexFromFilterInfo; /** * Gets the column indices for a secondary index. */ private getIndexColumnIndices; /** * Adapts FilterInfo for the overlay table schema (which has an extra tombstone column). * The constraints and index references remain the same since the overlay has matching indexes. */ private adaptFilterInfoForOverlay; /** * Queries the overlay table and converts rows to MergeEntry format. * * Uses the same FilterInfo as the underlying query so both streams are in the same order. * For secondary index scans, the sort key includes both the index key and primary key. */ private queryOverlayAsMergeEntries; /** * Builds the sort key for a row based on the index being scanned. * * For primary key scans: sort key is the PK * For secondary index scans: sort key is [indexKeyParts..., pkParts...] */ private buildSortKey; /** * Builds the merge configuration using this table's key functions. * * For primary key scans: compare by PK * For secondary index scans: compare by (indexKey, PK) using underlying's comparator * * @param indexInfo Which index is being scanned. Defaults to primary key scan. */ private buildMergeConfig; /** * Gets the primary key comparator, preferring the underlying table's comparator. */ private getComparePK; /** * Builds a sort key comparator for secondary index scans. * * Compares by index key columns first (using per-column comparators that * incorporate DESC ordering and collation), then by PK columns. */ private buildCompareSortKey; /** * Gets the index of the tombstone column in overlay rows. */ private getTombstoneColumnIndex; /** * Gets the primary key column indices from the underlying table schema. */ getPrimaryKeyIndices(): number[]; /** * Performs INSERT, UPDATE, or DELETE on the overlay. * Changes are not visible to underlying until commit. * * The overlay is created lazily on first write, using schema from the underlying table. */ update(args: UpdateArgs): Promise; /** * Strips the tombstone column from an overlay update result. */ private stripTombstoneFromResult; /** * If a REPLACE conflict displaced a row that lives only in the underlying store, * surface it as `replacedRow` on the success result. The overlay-only path * already carries `replacedRow` (the overlay's memory module emits it natively); * this overrides only when we have a store-side displacement to report. */ private attachReplacedUnderlying; /** * Surface internal REPLACE evictions of **underlying** rows (rows at OTHER PKs the * isolation layer's own merged-view detection displaced via a tombstone) as * `evictedRows`, so the DML executor runs the full delete pipeline for each. The * passed rows come from `findMergedUniqueConflict`, which yields live underlying * rows already in user-facing schema, so the tombstone-column slice is a defensive * no-op. Preserves any `replacedRow` already attached and **merges** with any * `evictedRows` already present (overlay-internal evictions propagated by * {@link stripTombstoneFromResult}) — the two eviction sources are disjoint per * write today (the conflicting row lives in the overlay XOR the underlying) but * merging keeps both correct if that ever changes. */ private attachEvicted; /** * Gets a row from the overlay by primary key using O(log n) point lookup. */ private getOverlayRow; /** * Creates a FilterInfo for a full table scan (no constraints). */ private createFullScanFilterInfo; /** * Creates a FilterInfo for a primary key point lookup (equality on all PK columns). * This produces O(log n) lookups instead of O(n) full scans. */ private buildPKPointLookupFilter; /** * Returns the compiled predicate for a partial-UNIQUE constraint, or undefined * when the constraint covers the full table. Compilation is memoized per * UniqueConstraintSchema instance so the hot UNIQUE-check path doesn't recompile. */ private compileFor; private keysEqual; private getUnderlyingRow; /** * Writes a relocated row at `newPK` in the overlay for a PK-changing UPDATE. * * If the overlay already holds a **tombstone** at newPK (a PK that was freed * earlier in this same transaction), overwrite it via `operation: 'update'` * rather than `operation: 'insert'` — the overlay is itself a StoreTable whose * insert path treats a tombstone row at the target key as a live PK conflict * and would throw `_overlay_ PK`. Overwriting the tombstone is the * logical reuse of the freed PK. This mirrors the plain-INSERT tombstone * conversion (~the `existingRow[tombstoneIndex] === 1` branch in `update`). * * A **live** overlay row at newPK is already rejected upstream by * {@link checkMergedPKConflict} (which returns its terminating constraint * result) and by the existing-overlay-row PK-conflict branch, so reaching here * with a non-tombstone overlay row should not happen; if it ever does, fall * through to insert and let the overlay enforce the genuine conflict. */ private writeRelocatedRow; private insertTombstoneForPK; /** * Checks if newPK conflicts with an underlying row not already shadowed in the overlay. * * Returns a discriminated outcome: * - `{}` — no conflict (or overlay is authoritative); proceed. * - `{ terminating }` — short-circuit with this UpdateResult (IGNORE / constraint). * - `{ replacedUnderlyingRow }` — REPLACE applied against a row that lives only * in the underlying store. The caller must surface this row as `replacedRow` * in the final UpdateResult so the DML executor fires ON DELETE cascades for * the displaced parent. The overlay still inserts normally; at flush time the * same-PK collision becomes an UPDATE on the underlying. */ private checkMergedPKConflict; /** * Scans the underlying table for a row conflicting with newRow on `uc.columns`, * excluding selfPks and rows tombstoned in the overlay. For partial UNIQUE, * candidates whose row does not satisfy the predicate are skipped. */ private findMergedUniqueConflict; /** * Checks all non-PK UNIQUE constraints against the merged view. * Returns null when all pass or REPLACE evictions succeed. * * A REPLACE eviction tombstones the conflicting live merged row in the overlay * (so the underlying row is deleted at flush) and pushes that row onto `evicted` * — surfaced to the DML executor via `evictedRows` so it runs the full delete * pipeline (change-tracking, FK cascade, auto-events, and the row-time * covering-MV backing maintenance the isolation layer otherwise never drives). */ private checkMergedUniqueConstraints; begin(): Promise; sync(): Promise; commit(): Promise; /** * Flushes overlay changes to underlying (if any) and discards the overlay. * Shared by commit() and onConnectionCommit(). */ private flushAndClearOverlay; /** * Flushes all overlay changes to the underlying table. * Called during commit to persist changes. * * This method manages the underlying table's transaction lifecycle independently * to ensure that flushed data is committed and won't be rolled back by subsequent * transaction rollbacks. */ private flushOverlayToUnderlying; /** * Asserts that an underlying write performed during the commit flush succeeded. * * The overlay's merged-view pre-checks ({@link checkMergedPKConflict} / * {@link checkMergedUniqueConstraints}) resolve every constraint before commit, so a * `constraint` result returned here means a real invariant was violated *after* those * checks. Historically this result was discarded, silently dropping the colliding * write and surfacing as data corruption. Convert it into a loud INTERNAL error; the * caller's try/catch rolls back the underlying flush transaction and rethrows. */ private assertFlushWriteOk; /** * Checks if a row with the given primary key exists in the underlying table. * Uses O(log n) point lookup via the PK index. */ private rowExistsInUnderlying; rollback(): Promise; /** * Discards the connection-scoped overlay entirely. * The overlay table is per-connection and ephemeral, so simply removing * the reference allows GC to reclaim it. A fresh overlay will be created * lazily via ensureOverlay() on the next write. */ private clearOverlay; savepoint(index: number): Promise; release(index: number): Promise; rollbackTo(index: number): Promise; disconnect(): Promise; rename(newName: string): Promise; alterSchema(changeInfo: SchemaChangeInfo): Promise; createIndex(indexInfo: IndexSchema): Promise; dropIndex(indexName: string): Promise; /** * Returns whether there are pending uncommitted changes. */ hasPendingChanges(): boolean; /** * Called by IsolatedConnection when the database commits. * Flushes overlay to underlying and clears overlay. */ onConnectionCommit(): Promise; /** * Called by IsolatedConnection when the database rolls back. * Clears overlay without flushing. */ onConnectionRollback(): Promise; /** * Called by IsolatedConnection when a savepoint is created. * * When the overlay exists, its own registered MemoryVirtualTableConnection * receives createSavepoint from the database's connection iteration. * Calling overlayTable.savepoint() here too would double-push onto the * same savepointStack, corrupting the depth-to-index mapping. * * When the overlay does NOT exist (savepoint before first write), we * record the depth so that a later rollbackToSavepoint can clear the * overlay if one was created after the savepoint. */ onConnectionSavepoint(index: number): Promise; /** * Called by IsolatedConnection when a savepoint is released. */ onConnectionReleaseSavepoint(index: number): Promise; /** * Called by IsolatedConnection when rolling back to a savepoint. * * If the target savepoint was created before the overlay existed, * the overlay's registered connection has no snapshot to restore — * so we clear the overlay entirely, restoring "no uncommitted changes". */ onConnectionRollbackToSavepoint(index: number): Promise; } //# sourceMappingURL=isolated-table.d.ts.map