@quereus/isolation 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +6 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/isolated-connection.d.ts +32 -0
- package/dist/src/isolated-connection.d.ts.map +1 -0
- package/dist/src/isolated-connection.js +71 -0
- package/dist/src/isolated-connection.js.map +1 -0
- package/dist/src/isolated-table.d.ts +219 -0
- package/dist/src/isolated-table.d.ts.map +1 -0
- package/dist/src/isolated-table.js +785 -0
- package/dist/src/isolated-table.js.map +1 -0
- package/dist/src/isolation-module.d.ts +134 -0
- package/dist/src/isolation-module.d.ts.map +1 -0
- package/dist/src/isolation-module.js +231 -0
- package/dist/src/isolation-module.js.map +1 -0
- package/dist/src/isolation-types.d.ts +34 -0
- package/dist/src/isolation-types.d.ts.map +1 -0
- package/dist/src/isolation-types.js +2 -0
- package/dist/src/isolation-types.js.map +1 -0
- package/dist/src/merge-iterator.d.ts +37 -0
- package/dist/src/merge-iterator.d.ts.map +1 -0
- package/dist/src/merge-iterator.js +98 -0
- package/dist/src/merge-iterator.js.map +1 -0
- package/dist/src/merge-types.d.ts +61 -0
- package/dist/src/merge-types.d.ts.map +1 -0
- package/dist/src/merge-types.js +2 -0
- package/dist/src/merge-types.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import { VirtualTable, compareSqlValues } from '@quereus/quereus';
|
|
2
|
+
import { IsolatedConnection } from './isolated-connection.js';
|
|
3
|
+
import { mergeStreams, createMergeEntry, createTombstone } from './merge-iterator.js';
|
|
4
|
+
/**
|
|
5
|
+
* A table wrapper that provides transaction isolation via an overlay.
|
|
6
|
+
*
|
|
7
|
+
* Each IsolatedTable instance accesses a connection-scoped overlay that is:
|
|
8
|
+
* - Created lazily on first write
|
|
9
|
+
* - Shared across all IsolatedTable instances in the same transaction
|
|
10
|
+
* - Stored in the IsolationModule's connection overlay map
|
|
11
|
+
*
|
|
12
|
+
* This provides true per-connection isolation - each connection's uncommitted
|
|
13
|
+
* changes are invisible to other connections, but visible to all queries
|
|
14
|
+
* within the same connection.
|
|
15
|
+
*
|
|
16
|
+
* Reads merge overlay changes with underlying data.
|
|
17
|
+
* Writes go to overlay only until commit.
|
|
18
|
+
*/
|
|
19
|
+
export class IsolatedTable extends VirtualTable {
|
|
20
|
+
isolationModule;
|
|
21
|
+
underlyingTable;
|
|
22
|
+
registeredConnection = null;
|
|
23
|
+
constructor(db, module, underlyingTable) {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
super(db, module, underlyingTable.schemaName, underlyingTable.tableName);
|
|
26
|
+
this.isolationModule = module;
|
|
27
|
+
this.underlyingTable = underlyingTable;
|
|
28
|
+
// Schema comes from underlying - may be populated lazily by the underlying module
|
|
29
|
+
this.tableSchema = underlyingTable.tableSchema;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Gets the tombstone column name from the module.
|
|
33
|
+
*/
|
|
34
|
+
get tombstoneColumn() {
|
|
35
|
+
return this.isolationModule.tombstoneColumn;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Gets the connection-scoped overlay state, or undefined if no overlay exists yet.
|
|
39
|
+
*/
|
|
40
|
+
getOverlayState() {
|
|
41
|
+
return this.isolationModule.getConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Gets the overlay table, or undefined if no overlay exists yet.
|
|
45
|
+
*/
|
|
46
|
+
get overlayTable() {
|
|
47
|
+
return this.getOverlayState()?.overlayTable;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Gets whether this connection has uncommitted changes.
|
|
51
|
+
*/
|
|
52
|
+
get hasChanges() {
|
|
53
|
+
return this.getOverlayState()?.hasChanges ?? false;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Sets the hasChanges flag in the connection-scoped overlay state.
|
|
57
|
+
*/
|
|
58
|
+
setHasChanges(value) {
|
|
59
|
+
const state = this.getOverlayState();
|
|
60
|
+
if (state) {
|
|
61
|
+
state.hasChanges = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Lazily creates the overlay table on first write.
|
|
66
|
+
*
|
|
67
|
+
* The overlay is stored in connection-scoped storage, so it persists
|
|
68
|
+
* across multiple IsolatedTable instances within the same transaction.
|
|
69
|
+
*
|
|
70
|
+
* The schema is obtained from the underlying table at this point,
|
|
71
|
+
* supporting scenarios where schema is discovered lazily from storage.
|
|
72
|
+
*/
|
|
73
|
+
async ensureOverlay() {
|
|
74
|
+
// Check if overlay already exists for this connection
|
|
75
|
+
const existingState = this.getOverlayState();
|
|
76
|
+
if (existingState) {
|
|
77
|
+
return existingState.overlayTable;
|
|
78
|
+
}
|
|
79
|
+
// Get schema from underlying table (may have been populated lazily)
|
|
80
|
+
const schema = this.underlyingTable.tableSchema;
|
|
81
|
+
if (!schema || schema.columns.length === 0) {
|
|
82
|
+
throw new Error(`Cannot create isolation overlay: underlying table '${this.tableName}' has no schema. ` +
|
|
83
|
+
'Ensure the underlying module provides schema before performing writes.');
|
|
84
|
+
}
|
|
85
|
+
// Update our schema reference in case it was populated lazily
|
|
86
|
+
this.tableSchema = schema;
|
|
87
|
+
// Create overlay schema with tombstone column
|
|
88
|
+
const overlaySchema = this.isolationModule.createOverlaySchema(schema);
|
|
89
|
+
// Create the overlay table
|
|
90
|
+
const overlayTable = await this.isolationModule.overlayModule.create(this.db, overlaySchema);
|
|
91
|
+
// Create indexes on overlay to match underlying
|
|
92
|
+
if (schema.indexes) {
|
|
93
|
+
for (const index of schema.indexes) {
|
|
94
|
+
await overlayTable.createIndex?.(index);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Store in connection-scoped storage
|
|
98
|
+
const state = {
|
|
99
|
+
overlayTable,
|
|
100
|
+
hasChanges: false,
|
|
101
|
+
};
|
|
102
|
+
this.isolationModule.setConnectionOverlay(this.db, this.schemaName, this.tableName, state);
|
|
103
|
+
return overlayTable;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Ensures a connection is registered with the database for transaction coordination.
|
|
107
|
+
* This is called before any read or write operation.
|
|
108
|
+
*/
|
|
109
|
+
async ensureConnection() {
|
|
110
|
+
if (!this.registeredConnection) {
|
|
111
|
+
// Create connection - overlay connection created lazily if needed
|
|
112
|
+
const overlayConn = this.overlayTable
|
|
113
|
+
? await Promise.resolve(this.overlayTable.createConnection?.())
|
|
114
|
+
: undefined;
|
|
115
|
+
this.registeredConnection = new IsolatedConnection(this.tableName, undefined, overlayConn, this);
|
|
116
|
+
}
|
|
117
|
+
return this.registeredConnection;
|
|
118
|
+
}
|
|
119
|
+
// ==================== Connection Management ====================
|
|
120
|
+
/**
|
|
121
|
+
* Creates a new isolated connection for transaction support.
|
|
122
|
+
*/
|
|
123
|
+
createConnection() {
|
|
124
|
+
const underlyingConn = this.underlyingTable.createConnection?.();
|
|
125
|
+
// Overlay connection created lazily - may not exist yet
|
|
126
|
+
const overlayConn = this.overlayTable?.createConnection?.();
|
|
127
|
+
// Handle sync/async connection creation
|
|
128
|
+
if (underlyingConn instanceof Promise || overlayConn instanceof Promise) {
|
|
129
|
+
return this.createConnectionAsync(underlyingConn, overlayConn);
|
|
130
|
+
}
|
|
131
|
+
return new IsolatedConnection(this.tableName, underlyingConn, overlayConn);
|
|
132
|
+
}
|
|
133
|
+
async createConnectionAsync(underlyingConn, overlayConn) {
|
|
134
|
+
const [underlying, overlay] = await Promise.all([
|
|
135
|
+
underlyingConn,
|
|
136
|
+
overlayConn,
|
|
137
|
+
]);
|
|
138
|
+
return new IsolatedConnection(this.tableName, underlying, overlay);
|
|
139
|
+
}
|
|
140
|
+
// ==================== Query Operations ====================
|
|
141
|
+
/**
|
|
142
|
+
* Query the table, merging overlay with underlying.
|
|
143
|
+
*
|
|
144
|
+
* When overlay is empty or doesn't exist, delegates directly to underlying for efficiency.
|
|
145
|
+
* When overlay has changes, merges both streams using the appropriate key order.
|
|
146
|
+
*
|
|
147
|
+
* For primary key scans: merge by PK order
|
|
148
|
+
* For secondary index scans: merge by (indexKey, PK) order
|
|
149
|
+
*/
|
|
150
|
+
query(filterInfo) {
|
|
151
|
+
if (!this.underlyingTable.query) {
|
|
152
|
+
throw new Error('Underlying table does not support query');
|
|
153
|
+
}
|
|
154
|
+
// Fast path: no overlay or no changes, skip merge overhead
|
|
155
|
+
if (!this.overlayTable || !this.hasChanges) {
|
|
156
|
+
return this.underlyingTable.query(filterInfo);
|
|
157
|
+
}
|
|
158
|
+
// Merge overlay with underlying (with connection ensured)
|
|
159
|
+
return this.mergedQueryWithConnection(filterInfo);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Wrapper that ensures connection before merging.
|
|
163
|
+
*/
|
|
164
|
+
async *mergedQueryWithConnection(filterInfo) {
|
|
165
|
+
await this.ensureConnection();
|
|
166
|
+
yield* this.mergedQuery(filterInfo);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Performs merged query combining overlay and underlying data.
|
|
170
|
+
*
|
|
171
|
+
* Both overlay and underlying are queried with the same FilterInfo (for the overlay,
|
|
172
|
+
* adjusted for its schema with tombstone column). Both streams return rows in the
|
|
173
|
+
* same order (by PK for primary scans, by index key for secondary index scans).
|
|
174
|
+
*
|
|
175
|
+
* The merge iterator combines them using the appropriate sort key comparator.
|
|
176
|
+
*/
|
|
177
|
+
async *mergedQuery(filterInfo) {
|
|
178
|
+
const overlay = this.overlayTable;
|
|
179
|
+
if (!overlay) {
|
|
180
|
+
// No overlay - just yield from underlying
|
|
181
|
+
yield* this.underlyingTable.query(filterInfo);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Determine which index is being used
|
|
185
|
+
const indexInfo = this.parseIndexFromFilterInfo(filterInfo);
|
|
186
|
+
// Query overlay with the same constraints (both streams in same order)
|
|
187
|
+
const overlayFilterInfo = this.adaptFilterInfoForOverlay(filterInfo);
|
|
188
|
+
const overlayStream = this.queryOverlayAsMergeEntries(overlay, overlayFilterInfo, indexInfo);
|
|
189
|
+
// Query underlying with original filterInfo
|
|
190
|
+
const underlyingStream = this.underlyingTable.query(filterInfo);
|
|
191
|
+
// Build merge config with appropriate sort key functions
|
|
192
|
+
const mergeConfig = this.buildMergeConfig(indexInfo);
|
|
193
|
+
yield* mergeStreams(overlayStream, underlyingStream, mergeConfig);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Parses FilterInfo to determine which index is being used.
|
|
197
|
+
* Returns null for full table scan or primary key scan, index name for secondary indexes.
|
|
198
|
+
*/
|
|
199
|
+
parseIndexFromFilterInfo(filterInfo) {
|
|
200
|
+
const { idxStr } = filterInfo;
|
|
201
|
+
if (!idxStr) {
|
|
202
|
+
return { type: 'primary' };
|
|
203
|
+
}
|
|
204
|
+
// Parse idxStr format: "idx=indexName(n);plan=2;..."
|
|
205
|
+
const params = new Map();
|
|
206
|
+
idxStr.split(';').forEach(part => {
|
|
207
|
+
const [key, value] = part.split('=', 2);
|
|
208
|
+
if (key && value !== undefined)
|
|
209
|
+
params.set(key, value);
|
|
210
|
+
});
|
|
211
|
+
const idxMatch = params.get('idx')?.match(/^(.*?)\((\d+)\)$/);
|
|
212
|
+
if (!idxMatch) {
|
|
213
|
+
return { type: 'primary' };
|
|
214
|
+
}
|
|
215
|
+
const indexName = idxMatch[1];
|
|
216
|
+
if (indexName === '_primary_') {
|
|
217
|
+
return { type: 'primary' };
|
|
218
|
+
}
|
|
219
|
+
// Secondary index scan
|
|
220
|
+
return {
|
|
221
|
+
type: 'secondary',
|
|
222
|
+
indexName,
|
|
223
|
+
columnIndices: this.getIndexColumnIndices(indexName),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Gets the column indices for a secondary index.
|
|
228
|
+
*/
|
|
229
|
+
getIndexColumnIndices(indexName) {
|
|
230
|
+
const schema = this.tableSchema;
|
|
231
|
+
if (!schema?.indexes)
|
|
232
|
+
return [];
|
|
233
|
+
const index = schema.indexes.find(idx => idx.name.toLowerCase() === indexName.toLowerCase());
|
|
234
|
+
if (!index)
|
|
235
|
+
return [];
|
|
236
|
+
return index.columns.map(col => col.index);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Adapts FilterInfo for the overlay table schema (which has an extra tombstone column).
|
|
240
|
+
* The constraints and index references remain the same since the overlay has matching indexes.
|
|
241
|
+
*/
|
|
242
|
+
adaptFilterInfoForOverlay(filterInfo) {
|
|
243
|
+
// The overlay table has the same schema plus a tombstone column at the end.
|
|
244
|
+
// Column indices for data columns are the same, so FilterInfo constraints work as-is.
|
|
245
|
+
// The overlay module will interpret the constraints correctly.
|
|
246
|
+
return filterInfo;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Queries the overlay table and converts rows to MergeEntry format.
|
|
250
|
+
*
|
|
251
|
+
* Uses the same FilterInfo as the underlying query so both streams are in the same order.
|
|
252
|
+
* For secondary index scans, the sort key includes both the index key and primary key.
|
|
253
|
+
*/
|
|
254
|
+
async *queryOverlayAsMergeEntries(overlay, filterInfo, indexInfo) {
|
|
255
|
+
if (!overlay.query) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
259
|
+
const tombstoneIndex = this.getTombstoneColumnIndex(overlay);
|
|
260
|
+
// Query overlay with the same filter constraints
|
|
261
|
+
for await (const overlayRow of overlay.query(filterInfo)) {
|
|
262
|
+
const isTombstone = overlayRow[tombstoneIndex] === 1;
|
|
263
|
+
const pk = pkIndices.map(i => overlayRow[i]);
|
|
264
|
+
// Build sort key based on index type
|
|
265
|
+
const sortKey = this.buildSortKey(overlayRow, pkIndices, indexInfo);
|
|
266
|
+
if (isTombstone) {
|
|
267
|
+
yield createTombstone(pk, sortKey);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Remove the tombstone column from the row before yielding
|
|
271
|
+
const dataRow = overlayRow.slice(0, tombstoneIndex);
|
|
272
|
+
yield createMergeEntry(dataRow, pk, sortKey);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Builds the sort key for a row based on the index being scanned.
|
|
278
|
+
*
|
|
279
|
+
* For primary key scans: sort key is the PK
|
|
280
|
+
* For secondary index scans: sort key is [indexKeyParts..., pkParts...]
|
|
281
|
+
*/
|
|
282
|
+
buildSortKey(row, pkIndices, indexInfo) {
|
|
283
|
+
if (indexInfo.type === 'primary') {
|
|
284
|
+
return pkIndices.map(i => row[i]);
|
|
285
|
+
}
|
|
286
|
+
// Secondary index: combine index key columns with PK columns
|
|
287
|
+
const indexKey = indexInfo.columnIndices.map(i => row[i]);
|
|
288
|
+
const pk = pkIndices.map(i => row[i]);
|
|
289
|
+
return [...indexKey, ...pk];
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Builds the merge configuration using this table's key functions.
|
|
293
|
+
*
|
|
294
|
+
* For primary key scans: compare by PK
|
|
295
|
+
* For secondary index scans: compare by (indexKey, PK) using underlying's comparator
|
|
296
|
+
*
|
|
297
|
+
* @param indexInfo Which index is being scanned. Defaults to primary key scan.
|
|
298
|
+
*/
|
|
299
|
+
buildMergeConfig(indexInfo = { type: 'primary' }) {
|
|
300
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
301
|
+
const extractPK = (row) => pkIndices.map(i => row[i]);
|
|
302
|
+
const comparePK = this.getComparePK();
|
|
303
|
+
if (indexInfo.type === 'primary') {
|
|
304
|
+
// Primary key scan - sort key equals PK
|
|
305
|
+
return {
|
|
306
|
+
extractPK,
|
|
307
|
+
comparePK,
|
|
308
|
+
// No need for separate sort key functions - defaults to PK
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// Secondary index scan - sort key is (indexKey, PK)
|
|
312
|
+
const indexColIndices = indexInfo.columnIndices;
|
|
313
|
+
const extractSortKey = (row) => {
|
|
314
|
+
const indexKey = indexColIndices.map(i => row[i]);
|
|
315
|
+
const pk = pkIndices.map(i => row[i]);
|
|
316
|
+
return [...indexKey, ...pk];
|
|
317
|
+
};
|
|
318
|
+
// Try to use the underlying table's index comparator if available
|
|
319
|
+
const indexComparator = this.underlyingTable.getIndexComparator?.(indexInfo.indexName);
|
|
320
|
+
const compareSortKey = this.buildCompareSortKey(indexColIndices.length, comparePK, indexComparator);
|
|
321
|
+
return {
|
|
322
|
+
extractPK,
|
|
323
|
+
comparePK,
|
|
324
|
+
extractSortKey,
|
|
325
|
+
compareSortKey,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Gets the primary key comparator, preferring the underlying table's comparator.
|
|
330
|
+
*/
|
|
331
|
+
getComparePK() {
|
|
332
|
+
// Use underlying table's comparator if available for consistent ordering
|
|
333
|
+
if (this.underlyingTable.comparePrimaryKey) {
|
|
334
|
+
return this.underlyingTable.comparePrimaryKey.bind(this.underlyingTable);
|
|
335
|
+
}
|
|
336
|
+
// Fallback to default comparator
|
|
337
|
+
return (a, b) => {
|
|
338
|
+
for (let i = 0; i < a.length; i++) {
|
|
339
|
+
const cmp = compareSqlValues(a[i], b[i]);
|
|
340
|
+
if (cmp !== 0)
|
|
341
|
+
return cmp;
|
|
342
|
+
}
|
|
343
|
+
return 0;
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Builds a sort key comparator for secondary index scans.
|
|
348
|
+
*
|
|
349
|
+
* Compares by index key columns first, then by PK columns.
|
|
350
|
+
*/
|
|
351
|
+
buildCompareSortKey(indexKeyLength, comparePK, _indexComparator) {
|
|
352
|
+
return (a, b) => {
|
|
353
|
+
// Compare index key portion first
|
|
354
|
+
for (let i = 0; i < indexKeyLength; i++) {
|
|
355
|
+
const cmp = compareSqlValues(a[i], b[i]);
|
|
356
|
+
if (cmp !== 0)
|
|
357
|
+
return cmp;
|
|
358
|
+
}
|
|
359
|
+
// Index keys equal - compare PK portion
|
|
360
|
+
const pkA = a.slice(indexKeyLength);
|
|
361
|
+
const pkB = b.slice(indexKeyLength);
|
|
362
|
+
return comparePK(pkA, pkB);
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Gets the index of the tombstone column in overlay rows.
|
|
367
|
+
*/
|
|
368
|
+
getTombstoneColumnIndex(overlay) {
|
|
369
|
+
const schema = overlay.tableSchema;
|
|
370
|
+
if (!schema) {
|
|
371
|
+
throw new Error('Overlay table has no schema');
|
|
372
|
+
}
|
|
373
|
+
const idx = schema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
|
|
374
|
+
if (idx === undefined) {
|
|
375
|
+
throw new Error(`Tombstone column '${this.tombstoneColumn}' not found in overlay schema`);
|
|
376
|
+
}
|
|
377
|
+
return idx;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Gets the primary key column indices from the underlying table schema.
|
|
381
|
+
*/
|
|
382
|
+
getPrimaryKeyIndices() {
|
|
383
|
+
const schema = this.tableSchema;
|
|
384
|
+
if (!schema)
|
|
385
|
+
return [];
|
|
386
|
+
return schema.primaryKeyDefinition.map(pkDef => pkDef.index);
|
|
387
|
+
}
|
|
388
|
+
// ==================== Write Operations ====================
|
|
389
|
+
/**
|
|
390
|
+
* Performs INSERT, UPDATE, or DELETE on the overlay.
|
|
391
|
+
* Changes are not visible to underlying until commit.
|
|
392
|
+
*
|
|
393
|
+
* The overlay is created lazily on first write, using schema from the underlying table.
|
|
394
|
+
*/
|
|
395
|
+
async update(args) {
|
|
396
|
+
// Ensure connection is registered for transaction coordination
|
|
397
|
+
await this.ensureConnection();
|
|
398
|
+
// Lazily create overlay on first write
|
|
399
|
+
const overlay = await this.ensureOverlay();
|
|
400
|
+
// Mark that we have changes
|
|
401
|
+
this.setHasChanges(true);
|
|
402
|
+
const { operation, values, oldKeyValues } = args;
|
|
403
|
+
const tombstoneIndex = this.getTombstoneColumnIndex(overlay);
|
|
404
|
+
switch (operation) {
|
|
405
|
+
case 'insert': {
|
|
406
|
+
// Insert into overlay with tombstone = 0
|
|
407
|
+
const overlayRow = [...(values ?? []), 0]; // Append tombstone = 0
|
|
408
|
+
return overlay.update({
|
|
409
|
+
...args,
|
|
410
|
+
values: overlayRow,
|
|
411
|
+
}).then(result => result?.slice(0, tombstoneIndex)); // Strip tombstone from result
|
|
412
|
+
}
|
|
413
|
+
case 'update': {
|
|
414
|
+
// For updates, we need to handle the case where the row exists in:
|
|
415
|
+
// 1. Overlay only (previous insert) - update the overlay row
|
|
416
|
+
// 2. Underlying only - insert into overlay with new values
|
|
417
|
+
// 3. Both (previous update) - update the overlay row
|
|
418
|
+
// First, try to find in overlay
|
|
419
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
420
|
+
const targetPK = oldKeyValues ?? (values ? pkIndices.map(i => values[i]) : undefined);
|
|
421
|
+
if (!targetPK || !values) {
|
|
422
|
+
throw new Error('UPDATE requires oldKeyValues or values with primary key');
|
|
423
|
+
}
|
|
424
|
+
// Check if row exists in overlay
|
|
425
|
+
const existsInOverlay = await this.rowExistsInOverlay(overlay, targetPK);
|
|
426
|
+
if (existsInOverlay) {
|
|
427
|
+
// Update existing overlay row
|
|
428
|
+
const overlayRow = [...values, 0]; // tombstone = 0
|
|
429
|
+
// oldKeyValues should only contain PK columns, not tombstone
|
|
430
|
+
return overlay.update({
|
|
431
|
+
...args,
|
|
432
|
+
values: overlayRow,
|
|
433
|
+
oldKeyValues: targetPK,
|
|
434
|
+
}).then(result => result?.slice(0, tombstoneIndex));
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
// Insert new overlay row (shadows underlying)
|
|
438
|
+
const overlayRow = [...values, 0];
|
|
439
|
+
return overlay.update({
|
|
440
|
+
operation: 'insert',
|
|
441
|
+
values: overlayRow,
|
|
442
|
+
onConflict: args.onConflict,
|
|
443
|
+
}).then(result => result?.slice(0, tombstoneIndex));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
case 'delete': {
|
|
447
|
+
// For deletes, insert a tombstone into overlay
|
|
448
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
449
|
+
const targetPK = oldKeyValues ?? (values ? pkIndices.map(i => values[i]) : undefined);
|
|
450
|
+
if (!targetPK) {
|
|
451
|
+
throw new Error('DELETE requires oldKeyValues or values with primary key');
|
|
452
|
+
}
|
|
453
|
+
// Check if row exists in overlay
|
|
454
|
+
const existsInOverlay = await this.rowExistsInOverlay(overlay, targetPK);
|
|
455
|
+
if (existsInOverlay) {
|
|
456
|
+
// Check if it's already a tombstone
|
|
457
|
+
const overlayRow = await this.getOverlayRow(overlay, targetPK);
|
|
458
|
+
if (overlayRow) {
|
|
459
|
+
const isTombstone = overlayRow[tombstoneIndex] === 1;
|
|
460
|
+
if (isTombstone) {
|
|
461
|
+
// Already deleted, nothing to do
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
// Convert to tombstone by updating the tombstone flag
|
|
465
|
+
const tombstoneRow = [...overlayRow.slice(0, tombstoneIndex), 1];
|
|
466
|
+
// oldKeyValues should only contain PK columns, not the full row
|
|
467
|
+
await overlay.update({
|
|
468
|
+
operation: 'update',
|
|
469
|
+
values: tombstoneRow,
|
|
470
|
+
oldKeyValues: targetPK,
|
|
471
|
+
onConflict: args.onConflict,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
// Insert tombstone to shadow underlying row
|
|
477
|
+
// Build a minimal row with PK values and tombstone = 1
|
|
478
|
+
const schema = this.tableSchema;
|
|
479
|
+
if (!schema)
|
|
480
|
+
throw new Error('No table schema');
|
|
481
|
+
const tombstoneRow = new Array(schema.columns.length + 1).fill(null);
|
|
482
|
+
pkIndices.forEach((colIdx, i) => {
|
|
483
|
+
tombstoneRow[colIdx] = targetPK[i];
|
|
484
|
+
});
|
|
485
|
+
tombstoneRow[tombstoneIndex] = 1; // Set tombstone flag
|
|
486
|
+
await overlay.update({
|
|
487
|
+
operation: 'insert',
|
|
488
|
+
values: tombstoneRow,
|
|
489
|
+
onConflict: args.onConflict,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
default:
|
|
495
|
+
throw new Error(`Unknown operation: ${operation}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Checks if a row with the given primary key exists in the overlay.
|
|
500
|
+
*/
|
|
501
|
+
async rowExistsInOverlay(overlay, pk) {
|
|
502
|
+
const row = await this.getOverlayRow(overlay, pk);
|
|
503
|
+
return row !== undefined;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Gets a row from the overlay by primary key.
|
|
507
|
+
*/
|
|
508
|
+
async getOverlayRow(overlay, pk) {
|
|
509
|
+
if (!overlay.query)
|
|
510
|
+
return undefined;
|
|
511
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
512
|
+
const mergeConfig = this.buildMergeConfig();
|
|
513
|
+
// Scan overlay looking for matching PK
|
|
514
|
+
// This is inefficient but correct; optimization can use index later
|
|
515
|
+
for await (const row of overlay.query(this.createFullScanFilterInfo())) {
|
|
516
|
+
const rowPK = pkIndices.map(i => row[i]);
|
|
517
|
+
if (mergeConfig.comparePK(rowPK, pk) === 0) {
|
|
518
|
+
return row;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Creates a FilterInfo for a full table scan (no constraints).
|
|
525
|
+
*/
|
|
526
|
+
createFullScanFilterInfo() {
|
|
527
|
+
return {
|
|
528
|
+
idxNum: 0,
|
|
529
|
+
idxStr: null,
|
|
530
|
+
constraints: [],
|
|
531
|
+
args: [],
|
|
532
|
+
indexInfoOutput: {
|
|
533
|
+
nConstraint: 0,
|
|
534
|
+
aConstraint: [],
|
|
535
|
+
nOrderBy: 0,
|
|
536
|
+
aOrderBy: [],
|
|
537
|
+
colUsed: 0n,
|
|
538
|
+
aConstraintUsage: [],
|
|
539
|
+
idxNum: 0,
|
|
540
|
+
idxStr: null,
|
|
541
|
+
orderByConsumed: false,
|
|
542
|
+
estimatedCost: 1000000,
|
|
543
|
+
estimatedRows: 1000000n,
|
|
544
|
+
idxFlags: 0,
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
// ==================== Transaction Lifecycle ====================
|
|
549
|
+
async begin() {
|
|
550
|
+
await this.underlyingTable.begin?.();
|
|
551
|
+
await this.overlayTable?.begin?.();
|
|
552
|
+
}
|
|
553
|
+
async sync() {
|
|
554
|
+
await this.underlyingTable.sync?.();
|
|
555
|
+
await this.overlayTable?.sync?.();
|
|
556
|
+
}
|
|
557
|
+
async commit() {
|
|
558
|
+
const overlay = this.overlayTable;
|
|
559
|
+
if (this.hasChanges && overlay) {
|
|
560
|
+
await this.flushOverlayToUnderlying(overlay);
|
|
561
|
+
}
|
|
562
|
+
await this.underlyingTable.commit?.();
|
|
563
|
+
await this.clearOverlay();
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Flushes all overlay changes to the underlying table.
|
|
567
|
+
* Called during commit to persist changes.
|
|
568
|
+
*
|
|
569
|
+
* This method manages the underlying table's transaction lifecycle independently
|
|
570
|
+
* to ensure that flushed data is committed and won't be rolled back by subsequent
|
|
571
|
+
* transaction rollbacks.
|
|
572
|
+
*/
|
|
573
|
+
async flushOverlayToUnderlying(overlay) {
|
|
574
|
+
if (!overlay.query)
|
|
575
|
+
return;
|
|
576
|
+
const tombstoneIndex = this.getTombstoneColumnIndex(overlay);
|
|
577
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
578
|
+
// Collect all overlay entries first
|
|
579
|
+
const overlayEntries = [];
|
|
580
|
+
for await (const overlayRow of overlay.query(this.createFullScanFilterInfo())) {
|
|
581
|
+
const isTombstone = overlayRow[tombstoneIndex] === 1;
|
|
582
|
+
const pk = pkIndices.map(i => overlayRow[i]);
|
|
583
|
+
const dataRow = overlayRow.slice(0, tombstoneIndex);
|
|
584
|
+
overlayEntries.push({ row: overlayRow, isTombstone, pk, dataRow });
|
|
585
|
+
}
|
|
586
|
+
if (overlayEntries.length === 0)
|
|
587
|
+
return;
|
|
588
|
+
// Begin a transaction on the underlying table for the flush
|
|
589
|
+
await this.underlyingTable.begin?.();
|
|
590
|
+
try {
|
|
591
|
+
// Apply all overlay entries to underlying
|
|
592
|
+
for (const entry of overlayEntries) {
|
|
593
|
+
if (entry.isTombstone) {
|
|
594
|
+
// Delete from underlying
|
|
595
|
+
await this.underlyingTable.update({
|
|
596
|
+
operation: 'delete',
|
|
597
|
+
values: undefined,
|
|
598
|
+
oldKeyValues: entry.pk,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
// Check if row exists in underlying to decide insert vs update
|
|
603
|
+
const existsInUnderlying = await this.rowExistsInUnderlying(entry.pk);
|
|
604
|
+
if (existsInUnderlying) {
|
|
605
|
+
await this.underlyingTable.update({
|
|
606
|
+
operation: 'update',
|
|
607
|
+
values: entry.dataRow,
|
|
608
|
+
oldKeyValues: entry.pk,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
await this.underlyingTable.update({
|
|
613
|
+
operation: 'insert',
|
|
614
|
+
values: entry.dataRow,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// Commit the underlying table's transaction
|
|
620
|
+
await this.underlyingTable.commit?.();
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
// Rollback underlying on error
|
|
624
|
+
await this.underlyingTable.rollback?.();
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Checks if a row with the given primary key exists in the underlying table.
|
|
630
|
+
*/
|
|
631
|
+
async rowExistsInUnderlying(pk) {
|
|
632
|
+
if (!this.underlyingTable.query)
|
|
633
|
+
return false;
|
|
634
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
635
|
+
const mergeConfig = this.buildMergeConfig();
|
|
636
|
+
for await (const row of this.underlyingTable.query(this.createFullScanFilterInfo())) {
|
|
637
|
+
const rowPK = pkIndices.map(i => row[i]);
|
|
638
|
+
if (mergeConfig.comparePK(rowPK, pk) === 0) {
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
async rollback() {
|
|
645
|
+
await this.underlyingTable.rollback?.();
|
|
646
|
+
await this.clearOverlay();
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Clears the connection-scoped overlay and resets state.
|
|
650
|
+
*/
|
|
651
|
+
async clearOverlay() {
|
|
652
|
+
const state = this.getOverlayState();
|
|
653
|
+
if (!state)
|
|
654
|
+
return;
|
|
655
|
+
const overlay = state.overlayTable;
|
|
656
|
+
if (overlay.query) {
|
|
657
|
+
const pkIndices = this.getPrimaryKeyIndices();
|
|
658
|
+
// Collect all PKs first to avoid modifying while iterating
|
|
659
|
+
const pksToDelete = [];
|
|
660
|
+
for await (const row of overlay.query(this.createFullScanFilterInfo())) {
|
|
661
|
+
// Extract just the PK values (same columns as underlying table)
|
|
662
|
+
const pk = pkIndices.map(i => row[i]);
|
|
663
|
+
pksToDelete.push(pk);
|
|
664
|
+
}
|
|
665
|
+
// Delete each row from overlay by PK
|
|
666
|
+
for (const pk of pksToDelete) {
|
|
667
|
+
await overlay.update({
|
|
668
|
+
operation: 'delete',
|
|
669
|
+
values: undefined,
|
|
670
|
+
oldKeyValues: pk,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// Clear the connection overlay state
|
|
675
|
+
this.isolationModule.clearConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
676
|
+
}
|
|
677
|
+
// ==================== Savepoints ====================
|
|
678
|
+
async savepoint(index) {
|
|
679
|
+
await this.underlyingTable.savepoint?.(index);
|
|
680
|
+
await this.overlayTable?.savepoint?.(index);
|
|
681
|
+
}
|
|
682
|
+
async release(index) {
|
|
683
|
+
await this.underlyingTable.release?.(index);
|
|
684
|
+
await this.overlayTable?.release?.(index);
|
|
685
|
+
}
|
|
686
|
+
async rollbackTo(index) {
|
|
687
|
+
await this.underlyingTable.rollbackTo?.(index);
|
|
688
|
+
await this.overlayTable?.rollbackTo?.(index);
|
|
689
|
+
}
|
|
690
|
+
// ==================== Schema Operations ====================
|
|
691
|
+
async disconnect() {
|
|
692
|
+
// Don't disconnect overlay or underlying - they're connection-scoped/shared
|
|
693
|
+
}
|
|
694
|
+
async rename(newName) {
|
|
695
|
+
await this.underlyingTable.rename?.(newName);
|
|
696
|
+
}
|
|
697
|
+
async alterSchema(changeInfo) {
|
|
698
|
+
// DDL bypasses overlay, goes directly to underlying
|
|
699
|
+
await this.underlyingTable.alterSchema?.(changeInfo);
|
|
700
|
+
// Update our schema reference
|
|
701
|
+
this.tableSchema = this.underlyingTable.tableSchema;
|
|
702
|
+
// Clear any existing overlay - it will be recreated with new schema on next write
|
|
703
|
+
this.isolationModule.clearConnectionOverlay(this.db, this.schemaName, this.tableName);
|
|
704
|
+
}
|
|
705
|
+
async createIndex(indexInfo) {
|
|
706
|
+
await this.underlyingTable.createIndex?.(indexInfo);
|
|
707
|
+
// Update schema reference
|
|
708
|
+
this.tableSchema = this.underlyingTable.tableSchema;
|
|
709
|
+
// If overlay exists, add index to it too
|
|
710
|
+
await this.overlayTable?.createIndex?.(indexInfo);
|
|
711
|
+
}
|
|
712
|
+
async dropIndex(indexName) {
|
|
713
|
+
await this.underlyingTable.dropIndex?.(indexName);
|
|
714
|
+
// Update schema reference
|
|
715
|
+
this.tableSchema = this.underlyingTable.tableSchema;
|
|
716
|
+
await this.overlayTable?.dropIndex?.(indexName);
|
|
717
|
+
}
|
|
718
|
+
// ==================== Internal Helpers ====================
|
|
719
|
+
/**
|
|
720
|
+
* Gets the underlying table for direct access (testing/debugging).
|
|
721
|
+
* @internal
|
|
722
|
+
*/
|
|
723
|
+
getUnderlyingTable() {
|
|
724
|
+
return this.underlyingTable;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Gets the overlay table for direct access (testing/debugging).
|
|
728
|
+
* Returns undefined if overlay hasn't been created yet.
|
|
729
|
+
* @internal
|
|
730
|
+
*/
|
|
731
|
+
getOverlayTable() {
|
|
732
|
+
return this.overlayTable;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Returns whether there are pending uncommitted changes.
|
|
736
|
+
*/
|
|
737
|
+
hasPendingChanges() {
|
|
738
|
+
return this.hasChanges;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Gets the tombstone column name.
|
|
742
|
+
* @internal
|
|
743
|
+
*/
|
|
744
|
+
getTombstoneColumn() {
|
|
745
|
+
return this.tombstoneColumn;
|
|
746
|
+
}
|
|
747
|
+
// ==================== IsolatedTableCallback Implementation ====================
|
|
748
|
+
/**
|
|
749
|
+
* Called by IsolatedConnection when the database commits.
|
|
750
|
+
* Flushes overlay to underlying and clears overlay.
|
|
751
|
+
*/
|
|
752
|
+
async onConnectionCommit() {
|
|
753
|
+
const overlay = this.overlayTable;
|
|
754
|
+
if (this.hasChanges && overlay) {
|
|
755
|
+
await this.flushOverlayToUnderlying(overlay);
|
|
756
|
+
}
|
|
757
|
+
await this.clearOverlay();
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Called by IsolatedConnection when the database rolls back.
|
|
761
|
+
* Clears overlay without flushing.
|
|
762
|
+
*/
|
|
763
|
+
async onConnectionRollback() {
|
|
764
|
+
await this.clearOverlay();
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Called by IsolatedConnection when a savepoint is created.
|
|
768
|
+
*/
|
|
769
|
+
async onConnectionSavepoint(index) {
|
|
770
|
+
await this.overlayTable?.savepoint?.(index);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Called by IsolatedConnection when a savepoint is released.
|
|
774
|
+
*/
|
|
775
|
+
async onConnectionReleaseSavepoint(index) {
|
|
776
|
+
await this.overlayTable?.release?.(index);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Called by IsolatedConnection when rolling back to a savepoint.
|
|
780
|
+
*/
|
|
781
|
+
async onConnectionRollbackToSavepoint(index) {
|
|
782
|
+
await this.overlayTable?.rollbackTo?.(index);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
//# sourceMappingURL=isolated-table.js.map
|