@tanstack/db-sqlite-persistence-core 0.2.23 → 0.4.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.
Files changed (58) hide show
  1. package/README.md +258 -7
  2. package/dist/cjs/broadcast-coordinator.cjs +1689 -0
  3. package/dist/cjs/broadcast-coordinator.cjs.map +1 -0
  4. package/dist/cjs/broadcast-coordinator.d.cts +116 -0
  5. package/dist/cjs/errors.cjs +73 -0
  6. package/dist/cjs/errors.cjs.map +1 -1
  7. package/dist/cjs/errors.d.cts +44 -0
  8. package/dist/cjs/index.cjs +19 -0
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/index.d.cts +2 -0
  11. package/dist/cjs/persisted.cjs +1782 -394
  12. package/dist/cjs/persisted.cjs.map +1 -1
  13. package/dist/cjs/persisted.d.cts +176 -11
  14. package/dist/cjs/remote-subset-owner.cjs +20 -0
  15. package/dist/cjs/remote-subset-owner.cjs.map +1 -0
  16. package/dist/cjs/remote-subset-owner.d.cts +7 -0
  17. package/dist/cjs/remote-subset-wire.cjs +681 -0
  18. package/dist/cjs/remote-subset-wire.cjs.map +1 -0
  19. package/dist/cjs/remote-subset-wire.d.cts +59 -0
  20. package/dist/cjs/sqlite-core-adapter.cjs +969 -364
  21. package/dist/cjs/sqlite-core-adapter.cjs.map +1 -1
  22. package/dist/cjs/sqlite-core-adapter.d.cts +45 -1
  23. package/dist/cjs/sqlite-value.cjs +31 -0
  24. package/dist/cjs/sqlite-value.cjs.map +1 -0
  25. package/dist/cjs/sqlite-value.d.cts +10 -0
  26. package/dist/esm/broadcast-coordinator.d.ts +116 -0
  27. package/dist/esm/broadcast-coordinator.js +1689 -0
  28. package/dist/esm/broadcast-coordinator.js.map +1 -0
  29. package/dist/esm/errors.d.ts +44 -0
  30. package/dist/esm/errors.js +74 -1
  31. package/dist/esm/errors.js.map +1 -1
  32. package/dist/esm/index.d.ts +2 -0
  33. package/dist/esm/index.js +21 -2
  34. package/dist/esm/index.js.map +1 -1
  35. package/dist/esm/persisted.d.ts +176 -11
  36. package/dist/esm/persisted.js +1784 -396
  37. package/dist/esm/persisted.js.map +1 -1
  38. package/dist/esm/remote-subset-owner.d.ts +7 -0
  39. package/dist/esm/remote-subset-owner.js +20 -0
  40. package/dist/esm/remote-subset-owner.js.map +1 -0
  41. package/dist/esm/remote-subset-wire.d.ts +59 -0
  42. package/dist/esm/remote-subset-wire.js +681 -0
  43. package/dist/esm/remote-subset-wire.js.map +1 -0
  44. package/dist/esm/sqlite-core-adapter.d.ts +45 -1
  45. package/dist/esm/sqlite-core-adapter.js +955 -350
  46. package/dist/esm/sqlite-core-adapter.js.map +1 -1
  47. package/dist/esm/sqlite-value.d.ts +10 -0
  48. package/dist/esm/sqlite-value.js +31 -0
  49. package/dist/esm/sqlite-value.js.map +1 -0
  50. package/package.json +12 -2
  51. package/src/broadcast-coordinator.ts +2456 -0
  52. package/src/errors.ts +120 -0
  53. package/src/index.ts +2 -0
  54. package/src/persisted.ts +2617 -442
  55. package/src/remote-subset-owner.ts +36 -0
  56. package/src/remote-subset-wire.ts +946 -0
  57. package/src/sqlite-core-adapter.ts +1297 -410
  58. package/src/sqlite-value.ts +30 -0
@@ -3,6 +3,108 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const db = require("@tanstack/db");
4
4
  const errors = require("./errors.cjs");
5
5
  const persisted = require("./persisted.cjs");
6
+ const sqliteValue = require("./sqlite-value.cjs");
7
+ const REPLACEMENT_BATCH_SIZE = 100;
8
+ class SharedPersistenceScheduler {
9
+ constructor() {
10
+ this.regularQueue = [];
11
+ this.hydrateQueue = [];
12
+ this.running = false;
13
+ }
14
+ runRegular(task) {
15
+ return this.enqueue(`regular`, task);
16
+ }
17
+ runHydrate(task) {
18
+ return this.enqueue(`hydrate`, task);
19
+ }
20
+ adoptRunningHydrate(completion) {
21
+ if (this.running) return;
22
+ this.running = true;
23
+ const finish = () => {
24
+ this.lastCompletedKind = `hydrate`;
25
+ this.running = false;
26
+ this.drain();
27
+ };
28
+ void completion.then(finish, finish);
29
+ }
30
+ enqueue(kind, task) {
31
+ const result = new Promise((resolve, reject) => {
32
+ const operation = {
33
+ kind,
34
+ task,
35
+ resolve,
36
+ reject
37
+ };
38
+ const queue = kind === `hydrate` ? this.hydrateQueue : this.regularQueue;
39
+ queue.push(operation);
40
+ });
41
+ this.drain();
42
+ return result;
43
+ }
44
+ drain() {
45
+ if (this.running) return;
46
+ const operation = this.takeNext();
47
+ if (!operation) return;
48
+ this.running = true;
49
+ void this.execute(operation);
50
+ }
51
+ async execute(operation) {
52
+ try {
53
+ operation.resolve(await operation.task());
54
+ } catch (error) {
55
+ operation.reject(error);
56
+ } finally {
57
+ this.lastCompletedKind = operation.kind;
58
+ this.running = false;
59
+ this.drain();
60
+ }
61
+ }
62
+ takeNext() {
63
+ if (this.hydrateQueue.length > 0) {
64
+ if (this.regularQueue.length > 0 && this.lastCompletedKind === `hydrate`) {
65
+ return this.regularQueue.shift();
66
+ }
67
+ return this.hydrateQueue.shift();
68
+ }
69
+ return this.regularQueue.shift();
70
+ }
71
+ }
72
+ const sharedPersistenceSchedulers = /* @__PURE__ */ new WeakMap();
73
+ const observedDriverSchedulingKeys = /* @__PURE__ */ new WeakMap();
74
+ function getSharedPersistenceScheduler(key) {
75
+ let scheduler = sharedPersistenceSchedulers.get(key);
76
+ if (!scheduler) {
77
+ scheduler = new SharedPersistenceScheduler();
78
+ sharedPersistenceSchedulers.set(key, scheduler);
79
+ }
80
+ return scheduler;
81
+ }
82
+ function getSharedLogicalSchedulingKey(value) {
83
+ if (typeof value !== `object` && typeof value !== `function` || !value) {
84
+ return void 0;
85
+ }
86
+ const key = value[persisted.SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY];
87
+ return key !== null && (typeof key === `object` || typeof key === `function`) ? key : void 0;
88
+ }
89
+ function observeSharedLogicalSchedulingSupport(driver, onSupport) {
90
+ let observationPending = true;
91
+ const observe = (promise) => {
92
+ if (!observationPending) return promise;
93
+ observationPending = false;
94
+ const key = getSharedLogicalSchedulingKey(promise);
95
+ if (key) onSupport(key);
96
+ return promise;
97
+ };
98
+ return {
99
+ exec: (sql) => observe(driver.exec(sql)),
100
+ query: (sql, params = []) => observe(driver.query(sql, params)),
101
+ run: (sql, params = []) => observe(driver.run(sql, params)),
102
+ transaction: (fn) => observe(driver.transaction(fn)),
103
+ transactionWithDriver: (fn) => observe(
104
+ driver.transactionWithDriver ? driver.transactionWithDriver(fn) : driver.transaction(fn)
105
+ )
106
+ };
107
+ }
6
108
  const DEFAULT_SCHEMA_VERSION = 1;
7
109
  const DEFAULT_PULL_SINCE_RELOAD_THRESHOLD = 128;
8
110
  const DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS = 1e3;
@@ -10,8 +112,6 @@ const DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS = 24 * 60 * 60;
10
112
  const SQLITE_MAX_IN_BATCH_SIZE = 900;
11
113
  const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
12
114
  const FORBIDDEN_SQL_FRAGMENT_PATTERN = /(;|--|\/\*)/;
13
- const PERSISTED_TYPE_TAG = `__tanstack_db_persisted_type__`;
14
- const PERSISTED_VALUE_TAG = `value`;
15
115
  const persistedTaggedValueTypes = /* @__PURE__ */ new Set([
16
116
  `bigint`,
17
117
  `date`,
@@ -22,12 +122,11 @@ const persistedTaggedValueTypes = /* @__PURE__ */ new Set([
22
122
  const orderByObjectIds = /* @__PURE__ */ new WeakMap();
23
123
  let nextOrderByObjectId = 1;
24
124
  function isDuplicateColumnAddError(error, columnName) {
25
- if (!(error instanceof Error)) {
125
+ if (typeof error !== `string` && !(error instanceof Error)) {
26
126
  return false;
27
127
  }
28
- const message = error.message.toLowerCase();
29
- const normalizedColumnName = columnName.toLowerCase();
30
- return message.includes(`duplicate column name`) && message.includes(normalizedColumnName) || message.includes(`already exists`) && message.includes(normalizedColumnName);
128
+ const normalizedMessage = (typeof error === `string` ? error : error.message).toLowerCase();
129
+ return normalizedMessage.includes(columnName.toLowerCase()) && (normalizedMessage.includes(`duplicate column name`) || normalizedMessage.includes(`already exists`));
31
130
  }
32
131
  function quoteIdentifier(identifier) {
33
132
  if (!SAFE_IDENTIFIER_PATTERN.test(identifier)) {
@@ -42,11 +141,11 @@ function isPersistedTaggedValue(value) {
42
141
  return false;
43
142
  }
44
143
  const taggedValue = value;
45
- if (typeof taggedValue[PERSISTED_TYPE_TAG] !== `string` || typeof taggedValue[PERSISTED_VALUE_TAG] !== `string`) {
144
+ if (typeof taggedValue[sqliteValue.PERSISTED_TYPE_TAG] !== `string` || typeof taggedValue[sqliteValue.PERSISTED_VALUE_TAG] !== `string`) {
46
145
  return false;
47
146
  }
48
147
  return persistedTaggedValueTypes.has(
49
- taggedValue[PERSISTED_TYPE_TAG]
148
+ taggedValue[sqliteValue.PERSISTED_TYPE_TAG]
50
149
  );
51
150
  }
52
151
  function encodePersistedJsonValue(value) {
@@ -54,34 +153,31 @@ function encodePersistedJsonValue(value) {
54
153
  return value;
55
154
  }
56
155
  if (typeof value === `bigint`) {
57
- return {
58
- [PERSISTED_TYPE_TAG]: `bigint`,
59
- [PERSISTED_VALUE_TAG]: value.toString()
60
- };
156
+ return sqliteValue.serializeSQLiteBigInt(value);
61
157
  }
62
158
  if (value instanceof Date) {
63
159
  return {
64
- [PERSISTED_TYPE_TAG]: `date`,
65
- [PERSISTED_VALUE_TAG]: value.toISOString()
160
+ [sqliteValue.PERSISTED_TYPE_TAG]: `date`,
161
+ [sqliteValue.PERSISTED_VALUE_TAG]: value.toISOString()
66
162
  };
67
163
  }
68
164
  if (typeof value === `number`) {
69
165
  if (Number.isNaN(value)) {
70
166
  return {
71
- [PERSISTED_TYPE_TAG]: `nan`,
72
- [PERSISTED_VALUE_TAG]: `NaN`
167
+ [sqliteValue.PERSISTED_TYPE_TAG]: `nan`,
168
+ [sqliteValue.PERSISTED_VALUE_TAG]: `NaN`
73
169
  };
74
170
  }
75
171
  if (value === Number.POSITIVE_INFINITY) {
76
172
  return {
77
- [PERSISTED_TYPE_TAG]: `infinity`,
78
- [PERSISTED_VALUE_TAG]: `Infinity`
173
+ [sqliteValue.PERSISTED_TYPE_TAG]: `infinity`,
174
+ [sqliteValue.PERSISTED_VALUE_TAG]: `Infinity`
79
175
  };
80
176
  }
81
177
  if (value === Number.NEGATIVE_INFINITY) {
82
178
  return {
83
- [PERSISTED_TYPE_TAG]: `-infinity`,
84
- [PERSISTED_VALUE_TAG]: `-Infinity`
179
+ [sqliteValue.PERSISTED_TYPE_TAG]: `-infinity`,
180
+ [sqliteValue.PERSISTED_VALUE_TAG]: `-Infinity`
85
181
  };
86
182
  }
87
183
  return value;
@@ -107,11 +203,11 @@ function decodePersistedJsonValue(value) {
107
203
  return value;
108
204
  }
109
205
  if (isPersistedTaggedValue(value)) {
110
- switch (value[PERSISTED_TYPE_TAG]) {
206
+ switch (value[sqliteValue.PERSISTED_TYPE_TAG]) {
111
207
  case `bigint`:
112
- return BigInt(value[PERSISTED_VALUE_TAG]);
208
+ return BigInt(value[sqliteValue.PERSISTED_VALUE_TAG]);
113
209
  case `date`: {
114
- const parsedDate = new Date(value[PERSISTED_VALUE_TAG]);
210
+ const parsedDate = new Date(value[sqliteValue.PERSISTED_VALUE_TAG]);
115
211
  return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
116
212
  }
117
213
  case `nan`:
@@ -155,6 +251,7 @@ function toSqliteParameterValue(value) {
155
251
  return value;
156
252
  }
157
253
  if (typeof value === `bigint`) {
254
+ sqliteValue.assertSQLiteBigIntInRange(value);
158
255
  return value.toString();
159
256
  }
160
257
  if (typeof value === `boolean`) {
@@ -177,56 +274,11 @@ function toSqliteLiteral(value) {
177
274
  }
178
275
  return `'${value.replace(/'/g, `''`)}'`;
179
276
  }
180
- function inlineSqlParams(sql, params) {
181
- let index = 0;
182
- const inlinedSql = sql.replace(/\?/g, () => {
183
- const paramValue = params[index];
184
- index++;
185
- return toSqliteLiteral(paramValue ?? null);
186
- });
187
- if (index !== params.length) {
188
- throw new errors.InvalidPersistedCollectionConfigError(
189
- `Unable to inline SQL params; placeholder count did not match provided params`
190
- );
191
- }
192
- return inlinedSql;
193
- }
194
- function collectAliasQualifiedRefSegments(expression, segments = /* @__PURE__ */ new Set()) {
195
- if (expression.type === `ref`) {
196
- if (expression.path.length > 1) {
197
- const rootSegment = String(expression.path[0]);
198
- if (rootSegment.length > 0) {
199
- segments.add(rootSegment);
200
- }
201
- }
202
- return segments;
203
- }
204
- if (expression.type === `func`) {
205
- for (const arg of expression.args) {
206
- collectAliasQualifiedRefSegments(arg, segments);
207
- }
277
+ function toSqliteExpressionLiteral(value) {
278
+ if (typeof value === `bigint`) {
279
+ return sqliteValue.assertSQLiteBigIntInRange(value).toString();
208
280
  }
209
- return segments;
210
- }
211
- function createAliasAwareRowProxy(row, aliasSegments) {
212
- return new Proxy(row, {
213
- get(target, prop, receiver) {
214
- if (typeof prop !== `string`) {
215
- return Reflect.get(target, prop, receiver);
216
- }
217
- if (Object.prototype.hasOwnProperty.call(target, prop)) {
218
- const value = Reflect.get(target, prop, receiver);
219
- if (value !== void 0 || !aliasSegments.has(prop)) {
220
- return value;
221
- }
222
- return target;
223
- }
224
- if (aliasSegments.has(prop)) {
225
- return target;
226
- }
227
- return void 0;
228
- }
229
- });
281
+ return toSqliteLiteral(toSqliteParameterValue(value));
230
282
  }
231
283
  function compileRowExpressionEvaluator(expression) {
232
284
  let baseEvaluator;
@@ -237,19 +289,7 @@ function compileRowExpressionEvaluator(expression) {
237
289
  `Unsupported expression for SQLite adapter fallback evaluator: ${error.message}`
238
290
  );
239
291
  }
240
- const aliasSegments = collectAliasQualifiedRefSegments(expression);
241
- if (aliasSegments.size === 0) {
242
- return (row) => baseEvaluator(row);
243
- }
244
- const proxyCache = /* @__PURE__ */ new WeakMap();
245
- return (row) => {
246
- let proxy = proxyCache.get(row);
247
- if (!proxy) {
248
- proxy = createAliasAwareRowProxy(row, aliasSegments);
249
- proxyCache.set(row, proxy);
250
- }
251
- return baseEvaluator(proxy);
252
- };
292
+ return baseEvaluator;
253
293
  }
254
294
  function getOrderByObjectId(value) {
255
295
  const existing = orderByObjectIds.get(value);
@@ -272,6 +312,9 @@ function compareOrderByValues(left, right, compareOptions) {
272
312
  return compareOptions.nulls === `first` ? 1 : -1;
273
313
  }
274
314
  if (typeof left === `string` && typeof right === `string`) {
315
+ if (compareOptions.stringSort === `custom`) {
316
+ return compareOptions.compare(left, right);
317
+ }
275
318
  if (compareOptions.stringSort === `locale`) {
276
319
  return left.localeCompare(
277
320
  right,
@@ -366,32 +409,34 @@ function resolveComparisonValueKind(leftExpression, rightExpression, leftCompile
366
409
  }
367
410
  return `unknown`;
368
411
  }
369
- function compileComparisonSql(operator, leftSql, rightSql, valueKind) {
370
- if (valueKind === `bigint`) {
371
- return `(CAST(${leftSql} AS NUMERIC) ${operator} CAST(${rightSql} AS NUMERIC))`;
372
- }
373
- if (valueKind === `date`) {
374
- return `(date(${leftSql}) ${operator} date(${rightSql}))`;
375
- }
376
- if (valueKind === `datetime`) {
377
- return `(datetime(${leftSql}) ${operator} datetime(${rightSql}))`;
378
- }
379
- return `(${leftSql} ${operator} ${rightSql})`;
412
+ function compileComparisonSql(operator, leftExpression, rightExpression, leftSql, rightSql, valueKind, leftKind, rightKind) {
413
+ const compileOperand = (expression, sql, otherKind) => {
414
+ if (expression.type !== `val`) return sql;
415
+ if (valueKind === `date` && otherKind === `date`) return `date(${sql})`;
416
+ if (valueKind === `datetime` && otherKind === `datetime`) {
417
+ return `datetime(${sql})`;
418
+ }
419
+ return sql;
420
+ };
421
+ return `(${compileOperand(leftExpression, leftSql, rightKind)} ${operator} ${compileOperand(rightExpression, rightSql, leftKind)})`;
380
422
  }
381
423
  function compileRefExpressionSql(jsonPath) {
382
- const typePath = `${jsonPath}.${PERSISTED_TYPE_TAG}`;
383
- const taggedValuePath = `${jsonPath}.${PERSISTED_VALUE_TAG}`;
424
+ const typePath = `${jsonPath}.${sqliteValue.PERSISTED_TYPE_TAG}`;
425
+ const taggedValuePath = `${jsonPath}.${sqliteValue.PERSISTED_VALUE_TAG}`;
426
+ const typePathSql = toSqliteLiteral(typePath);
427
+ const taggedValuePathSql = toSqliteLiteral(taggedValuePath);
428
+ const jsonPathSql = toSqliteLiteral(jsonPath);
384
429
  return {
385
430
  supported: true,
386
- sql: `(CASE json_extract(value, ?)
387
- WHEN 'bigint' THEN CAST(json_extract(value, ?) AS NUMERIC)
388
- WHEN 'date' THEN json_extract(value, ?)
431
+ sql: `(CASE json_extract(value, ${typePathSql})
432
+ WHEN 'bigint' THEN CAST(json_extract(value, ${taggedValuePathSql}) AS NUMERIC)
433
+ WHEN 'date' THEN json_extract(value, ${taggedValuePathSql})
389
434
  WHEN 'nan' THEN NULL
390
435
  WHEN 'infinity' THEN NULL
391
436
  WHEN '-infinity' THEN NULL
392
- ELSE json_extract(value, ?)
437
+ ELSE json_extract(value, ${jsonPathSql})
393
438
  END)`,
394
- params: [typePath, taggedValuePath, taggedValuePath, jsonPath],
439
+ params: [],
395
440
  valueKind: `unknown`
396
441
  };
397
442
  }
@@ -418,18 +463,45 @@ function decodeStoredSqliteRows(storedRows) {
418
463
  function stableStringify(value) {
419
464
  return serializePersistedRowValue(value);
420
465
  }
421
- function compileSqlExpression(expression) {
466
+ function argumentCompilationContext(parentName, argumentIndex, argument, parentContext) {
467
+ if (parentContext === `index-expression`) return `index-expression`;
468
+ switch (parentName) {
469
+ case `and`:
470
+ case `or`:
471
+ case `not`:
472
+ return `predicate`;
473
+ case `eq`:
474
+ case `gt`:
475
+ case `gte`:
476
+ case `lt`:
477
+ case `lte`:
478
+ case `like`:
479
+ case `ilike`:
480
+ if (argument.type !== `val`) return `index-expression`;
481
+ return typeof argument.value === `bigint` ? `index-expression` : `predicate`;
482
+ case `in`:
483
+ return argumentIndex === 0 ? `index-expression` : `predicate`;
484
+ case `isNull`:
485
+ case `isUndefined`:
486
+ return `index-expression`;
487
+ default:
488
+ return `index-expression`;
489
+ }
490
+ }
491
+ function compileSqlExpression(expression, context = `predicate`) {
422
492
  if (expression.type === `val`) {
423
493
  const valueKind = getLiteralValueKind(expression.value);
424
494
  return {
425
495
  supported: true,
426
- sql: `?`,
427
- params: [toSqliteParameterValue(expression.value)],
496
+ sql: context === `index-expression` ? toSqliteExpressionLiteral(expression.value) : `?`,
497
+ params: context === `predicate` ? [toSqliteParameterValue(expression.value)] : [],
428
498
  valueKind
429
499
  };
430
500
  }
431
501
  if (expression.type === `ref`) {
432
- const jsonPath = createJsonPath(expression.path.map(String));
502
+ const jsonPath = createJsonPath(
503
+ db.IR.getPropRefPropertyPath(expression).map(String)
504
+ );
433
505
  if (!jsonPath) {
434
506
  return {
435
507
  supported: false,
@@ -439,7 +511,12 @@ function compileSqlExpression(expression) {
439
511
  }
440
512
  return compileRefExpressionSql(jsonPath);
441
513
  }
442
- const compiledArgs = expression.args.map((arg) => compileSqlExpression(arg));
514
+ const compiledArgs = expression.args.map(
515
+ (arg, index) => compileSqlExpression(
516
+ arg,
517
+ argumentCompilationContext(expression.name, index, arg, context)
518
+ )
519
+ );
443
520
  if (compiledArgs.some((arg) => !arg.supported)) {
444
521
  return {
445
522
  supported: false,
@@ -475,9 +552,13 @@ function compileSqlExpression(expression) {
475
552
  supported: true,
476
553
  sql: compileComparisonSql(
477
554
  operatorByName[expression.name],
555
+ expression.args[0],
556
+ expression.args[1],
478
557
  argSql[0],
479
558
  argSql[1],
480
- valueKind
559
+ valueKind,
560
+ getCompiledValueKind(compiledArgs[0]),
561
+ getCompiledValueKind(compiledArgs[1])
481
562
  ),
482
563
  params
483
564
  };
@@ -528,11 +609,14 @@ function compileSqlExpression(expression) {
528
609
  if (!leftSql) {
529
610
  return { supported: false, sql: ``, params: [] };
530
611
  }
612
+ if (context === `index-expression`) {
613
+ return {
614
+ supported: true,
615
+ sql: `(${leftSql} IN (${listValue.map((value) => toSqliteExpressionLiteral(value)).join(`, `)}))`,
616
+ params: leftParams
617
+ };
618
+ }
531
619
  if (listValue.length > SQLITE_MAX_IN_BATCH_SIZE) {
532
- const hasBigIntValues2 = listValue.some(
533
- (value) => typeof value === `bigint`
534
- );
535
- const inLeftSql2 = hasBigIntValues2 ? `CAST(${leftSql} AS NUMERIC)` : leftSql;
536
620
  const chunkClauses = [];
537
621
  const batchedParams = [];
538
622
  for (let startIndex = 0; startIndex < listValue.length; startIndex += SQLITE_MAX_IN_BATCH_SIZE) {
@@ -540,13 +624,13 @@ function compileSqlExpression(expression) {
540
624
  startIndex,
541
625
  startIndex + SQLITE_MAX_IN_BATCH_SIZE
542
626
  );
543
- chunkClauses.push(
544
- `(${inLeftSql2} IN (${chunkValues.map(() => `?`).join(`, `)}))`
545
- );
546
- batchedParams.push(...leftParams);
547
- batchedParams.push(
548
- ...chunkValues.map((value) => toSqliteParameterValue(value))
549
- );
627
+ const chunkParams = [];
628
+ const chunkValueSql = chunkValues.map((value) => {
629
+ chunkParams.push(toSqliteParameterValue(value));
630
+ return typeof value === `bigint` ? `CAST(? AS NUMERIC)` : `?`;
631
+ });
632
+ chunkClauses.push(`(${leftSql} IN (${chunkValueSql.join(`, `)}))`);
633
+ batchedParams.push(...leftParams, ...chunkParams);
550
634
  }
551
635
  return {
552
636
  supported: true,
@@ -554,18 +638,15 @@ function compileSqlExpression(expression) {
554
638
  params: batchedParams
555
639
  };
556
640
  }
557
- const hasBigIntValues = listValue.some(
558
- (value) => typeof value === `bigint`
559
- );
560
- const inLeftSql = hasBigIntValues ? `CAST(${leftSql} AS NUMERIC)` : leftSql;
561
- const listPlaceholders = listValue.map(() => `?`).join(`, `);
641
+ const listParams = [];
642
+ const listValueSql = listValue.map((value) => {
643
+ listParams.push(toSqliteParameterValue(value));
644
+ return typeof value === `bigint` ? `CAST(? AS NUMERIC)` : `?`;
645
+ });
562
646
  return {
563
647
  supported: true,
564
- sql: `(${inLeftSql} IN (${listPlaceholders}))`,
565
- params: [
566
- ...leftParams,
567
- ...listValue.map((value) => toSqliteParameterValue(value))
568
- ]
648
+ sql: `(${leftSql} IN (${listValueSql.join(`, `)}))`,
649
+ params: [...leftParams, ...listParams]
569
650
  };
570
651
  }
571
652
  case `like`:
@@ -640,7 +721,10 @@ function compileOrderByClauses(orderBy) {
640
721
  const parts = [];
641
722
  const params = [];
642
723
  for (const clause of orderBy) {
643
- const compiledExpression = compileSqlExpression(clause.expression);
724
+ const compiledExpression = compileSqlExpression(
725
+ clause.expression,
726
+ `index-expression`
727
+ );
644
728
  if (!compiledExpression.supported) {
645
729
  return {
646
730
  supported: false,
@@ -668,7 +752,7 @@ function isExpressionLikeShape(value) {
668
752
  return Object.prototype.hasOwnProperty.call(candidate, `value`);
669
753
  }
670
754
  if (candidate.type === `ref`) {
671
- return Array.isArray(candidate.path);
755
+ return Array.isArray(candidate.path) && (candidate.sourceAlias === void 0 || typeof candidate.sourceAlias === `string` && candidate.path[0] === candidate.sourceAlias);
672
756
  }
673
757
  if (candidate.type === `func`) {
674
758
  if (typeof candidate.name !== `string` || !Array.isArray(candidate.args)) {
@@ -692,14 +776,20 @@ function normalizeIndexSqlFragment(fragment) {
692
776
  hasParsedJson = true;
693
777
  } catch {
694
778
  }
695
- if (hasParsedJson && isExpressionLikeShape(parsedJson)) {
696
- const compiled = compileSqlExpression(parsedJson);
779
+ const decodedJson = hasParsedJson ? decodePersistedJsonValue(parsedJson) : void 0;
780
+ if (hasParsedJson && isExpressionLikeShape(decodedJson)) {
781
+ const compiled = compileSqlExpression(decodedJson, `index-expression`);
697
782
  if (!compiled.supported) {
698
783
  throw new errors.InvalidPersistedCollectionConfigError(
699
784
  `Persisted index expression is not supported by the SQLite compiler`
700
785
  );
701
786
  }
702
- return inlineSqlParams(compiled.sql, compiled.params);
787
+ if (compiled.params.length !== 0) {
788
+ throw new errors.InvalidPersistedCollectionConfigError(
789
+ `Persisted index expression cannot contain bound parameters`
790
+ );
791
+ }
792
+ return compiled.sql;
703
793
  }
704
794
  return sanitizeExpressionSqlFragment(fragment);
705
795
  }
@@ -721,6 +811,13 @@ class SQLiteCorePersistenceAdapter {
721
811
  this.initialized = false;
722
812
  this.collectionTableCache = /* @__PURE__ */ new Map();
723
813
  this.collectionTableLoads = /* @__PURE__ */ new Map();
814
+ const maxBoundParameters = options.driver.maxBoundParameters;
815
+ if (maxBoundParameters !== void 0 && (!Number.isInteger(maxBoundParameters) || maxBoundParameters < 4)) {
816
+ throw new errors.InvalidPersistedCollectionConfigError(
817
+ `SQLite driver maxBoundParameters must be an integer of at least 4`
818
+ );
819
+ }
820
+ this.replacementBatchSize = maxBoundParameters === void 0 ? REPLACEMENT_BATCH_SIZE : Math.min(REPLACEMENT_BATCH_SIZE, Math.floor(maxBoundParameters / 4));
724
821
  const schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
725
822
  if (!Number.isInteger(schemaVersion) || schemaVersion < 0) {
726
823
  throw new errors.InvalidPersistedCollectionConfigError(
@@ -743,12 +840,67 @@ class SQLiteCorePersistenceAdapter {
743
840
  `SQLite adapter pullSinceReloadThreshold must be a non-negative integer`
744
841
  );
745
842
  }
746
- this.driver = options.driver;
843
+ this.schedulingIdentitySource = options.driver;
844
+ const schedulingKey = getSharedLogicalSchedulingKey(options.driver) ?? observedDriverSchedulingKeys.get(options.driver);
845
+ this.scheduler = schedulingKey ? getSharedPersistenceScheduler(schedulingKey) : void 0;
846
+ this.driver = schedulingKey ? options.driver : observeSharedLogicalSchedulingSupport(options.driver, (key) => {
847
+ observedDriverSchedulingKeys.set(options.driver, key);
848
+ const scheduler = getSharedPersistenceScheduler(key);
849
+ this.scheduler ??= scheduler;
850
+ if (this.activeUnscheduledHydration) {
851
+ scheduler.adoptRunningHydrate(this.activeUnscheduledHydration);
852
+ }
853
+ });
747
854
  this.schemaVersion = schemaVersion;
748
855
  this.schemaMismatchPolicy = options.schemaMismatchPolicy ?? `sync-present-reset`;
749
856
  this.appliedTxPruneMaxRows = options.appliedTxPruneMaxRows;
750
857
  this.appliedTxPruneMaxAgeSeconds = options.appliedTxPruneMaxAgeSeconds;
751
858
  this.pullSinceReloadThreshold = pullSinceReloadThreshold;
859
+ this.hydrationAdapter = {
860
+ loadSubset: (collectionId, loadOptions, context) => this.loadSubsetUnscheduled(collectionId, loadOptions, context),
861
+ loadResumeSnapshot: (collectionId, context) => this.loadResumeSnapshotUnscheduled(collectionId, context),
862
+ applyCommittedTx: (collectionId, tx) => this.applyCommittedTxUnscheduled(collectionId, tx),
863
+ loadCollectionMetadata: (collectionId) => this.loadCollectionMetadataUnscheduled(collectionId),
864
+ scanRows: (collectionId, scanOptions) => this.scanRowsUnscheduled(collectionId, scanOptions),
865
+ ensureIndex: (collectionId, signature, spec) => this.ensureIndexUnscheduled(collectionId, signature, spec),
866
+ markIndexRemoved: (collectionId, signature) => this.markIndexRemovedUnscheduled(collectionId, signature),
867
+ getStreamPosition: (collectionId) => this.getStreamPositionUnscheduled(collectionId),
868
+ pullSince: (collectionId, fromRowVersion) => this.pullSinceUnscheduled(collectionId, fromRowVersion),
869
+ runInHydrationScope: async (task) => task(this.hydrationAdapter)
870
+ };
871
+ }
872
+ runInHydrationScope(task) {
873
+ const scheduler = this.resolveScheduler();
874
+ if (scheduler) {
875
+ return scheduler.runHydrate(() => task(this.hydrationAdapter));
876
+ }
877
+ const hydration = Promise.resolve().then(() => task(this.hydrationAdapter));
878
+ this.activeUnscheduledHydration = hydration;
879
+ const clear = () => {
880
+ if (this.activeUnscheduledHydration === hydration) {
881
+ this.activeUnscheduledHydration = void 0;
882
+ }
883
+ };
884
+ void hydration.then(clear, clear);
885
+ return hydration;
886
+ }
887
+ runInRegularScope(task) {
888
+ return this.runRegular(() => task(this.hydrationAdapter));
889
+ }
890
+ isHydrationScopeScheduled() {
891
+ return this.resolveScheduler() !== void 0;
892
+ }
893
+ runRegular(task) {
894
+ const scheduler = this.resolveScheduler();
895
+ return scheduler ? scheduler.runRegular(task) : task();
896
+ }
897
+ resolveScheduler() {
898
+ if (this.scheduler) return this.scheduler;
899
+ const key = observedDriverSchedulingKeys.get(this.schedulingIdentitySource);
900
+ if (key) {
901
+ this.scheduler = getSharedPersistenceScheduler(key);
902
+ }
903
+ return this.scheduler;
752
904
  }
753
905
  runInTransaction(fn) {
754
906
  if (typeof this.driver.transactionWithDriver === `function`) {
@@ -756,69 +908,197 @@ class SQLiteCorePersistenceAdapter {
756
908
  }
757
909
  return this.driver.transaction(fn);
758
910
  }
759
- async loadSubset(collectionId, options, ctx) {
911
+ async assertCurrentSchemaVersion(collectionId, driver, operation) {
912
+ const schemaRows = await driver.query(
913
+ `SELECT schema_version
914
+ FROM collection_registry
915
+ WHERE collection_id = ?
916
+ LIMIT 1`,
917
+ [collectionId]
918
+ );
919
+ const persistedSchemaVersion = schemaRows[0]?.schema_version;
920
+ if (persistedSchemaVersion !== this.schemaVersion) {
921
+ throw new errors.InvalidPersistedCollectionConfigError(
922
+ `Schema version mismatch for collection "${collectionId}": found ${persistedSchemaVersion ?? `missing`}, expected ${this.schemaVersion}. Refusing to ${operation} through a stale cached adapter.`
923
+ );
924
+ }
925
+ }
926
+ loadSubset(collectionId, options, ctx) {
927
+ return this.runRegular(
928
+ () => this.loadSubsetUnscheduled(collectionId, options, ctx)
929
+ );
930
+ }
931
+ async loadSubsetUnscheduled(collectionId, options, ctx) {
760
932
  const tableMapping = await this.ensureCollectionReady(collectionId);
761
- await this.touchRequiredIndexes(collectionId, ctx?.requiredIndexSignatures);
762
- if (options.cursor) {
763
- const whereCurrentOptions = {
764
- where: options.where ? new db.IR.Func(`and`, [options.where, options.cursor.whereCurrent]) : options.cursor.whereCurrent,
765
- orderBy: options.orderBy
766
- };
767
- const whereFromOptions = {
768
- where: options.where ? new db.IR.Func(`and`, [options.where, options.cursor.whereFrom]) : options.cursor.whereFrom,
769
- orderBy: options.orderBy,
770
- limit: options.limit
771
- };
772
- const [whereCurrentRows, whereFromRows] = await Promise.all([
773
- this.loadSubsetInternal(tableMapping, whereCurrentOptions),
774
- this.loadSubsetInternal(tableMapping, whereFromOptions)
775
- ]);
776
- const mergedRows = /* @__PURE__ */ new Map();
777
- for (const row of [...whereCurrentRows, ...whereFromRows]) {
778
- mergedRows.set(persisted.encodePersistedStorageKey(row.key), row);
933
+ return this.runInTransaction(async (transactionDriver) => {
934
+ await this.assertCurrentSchemaVersion(
935
+ collectionId,
936
+ transactionDriver,
937
+ `load persisted rows`
938
+ );
939
+ await this.touchRequiredIndexes(
940
+ collectionId,
941
+ ctx?.requiredIndexSignatures,
942
+ transactionDriver
943
+ );
944
+ if (options.cursor) {
945
+ const whereCurrentOptions = {
946
+ where: options.where ? new db.IR.Func(`and`, [options.where, options.cursor.whereCurrent]) : options.cursor.whereCurrent,
947
+ orderBy: options.orderBy
948
+ };
949
+ const whereFromOptions = {
950
+ where: options.where ? new db.IR.Func(`and`, [options.where, options.cursor.whereFrom]) : options.cursor.whereFrom,
951
+ orderBy: options.orderBy,
952
+ limit: options.limit
953
+ };
954
+ const [whereCurrentRows, whereFromRows] = await Promise.all([
955
+ this.loadSubsetInternal(
956
+ tableMapping,
957
+ whereCurrentOptions,
958
+ transactionDriver
959
+ ),
960
+ this.loadSubsetInternal(
961
+ tableMapping,
962
+ whereFromOptions,
963
+ transactionDriver
964
+ )
965
+ ]);
966
+ const mergedRows = /* @__PURE__ */ new Map();
967
+ for (const row of [...whereCurrentRows, ...whereFromRows]) {
968
+ mergedRows.set(persisted.encodePersistedStorageKey(row.key), row);
969
+ }
970
+ const orderedRows = this.applyInMemoryOrderBy(
971
+ Array.from(mergedRows.values()),
972
+ options.orderBy
973
+ );
974
+ return orderedRows.map((row) => ({
975
+ key: row.key,
976
+ value: row.value,
977
+ metadata: row.metadata
978
+ }));
779
979
  }
780
- const orderedRows = this.applyInMemoryOrderBy(
781
- Array.from(mergedRows.values()),
782
- options.orderBy
980
+ const rows = await this.loadSubsetInternal(
981
+ tableMapping,
982
+ options,
983
+ transactionDriver
783
984
  );
784
- return orderedRows.map((row) => ({
985
+ return rows.map((row) => ({
785
986
  key: row.key,
786
987
  value: row.value,
787
988
  metadata: row.metadata
788
989
  }));
789
- }
790
- const rows = await this.loadSubsetInternal(tableMapping, options);
791
- return rows.map((row) => ({
792
- key: row.key,
793
- value: row.value,
794
- metadata: row.metadata
795
- }));
990
+ });
991
+ }
992
+ loadResumeSnapshot(collectionId, ctx) {
993
+ return this.runRegular(
994
+ () => this.loadResumeSnapshotUnscheduled(collectionId, ctx)
995
+ );
996
+ }
997
+ async loadResumeSnapshotUnscheduled(collectionId, ctx) {
998
+ const tableMapping = await this.ensureCollectionReady(collectionId);
999
+ const includeRows = ctx?.includeRows !== false;
1000
+ return this.runInTransaction(async (transactionDriver) => {
1001
+ await this.assertCurrentSchemaVersion(
1002
+ collectionId,
1003
+ transactionDriver,
1004
+ `load a resume snapshot`
1005
+ );
1006
+ if (includeRows) {
1007
+ await this.touchRequiredIndexes(
1008
+ collectionId,
1009
+ ctx?.requiredIndexSignatures,
1010
+ transactionDriver
1011
+ );
1012
+ }
1013
+ const rows = includeRows ? await this.loadSubsetInternal(tableMapping, {}, transactionDriver) : [];
1014
+ const { latestRowVersion, keySet } = await this.readKeySetEvidence(
1015
+ collectionId,
1016
+ transactionDriver
1017
+ );
1018
+ const collectionMetadataRows = await transactionDriver.query(
1019
+ `SELECT key, value
1020
+ FROM collection_metadata
1021
+ WHERE collection_id = ?`,
1022
+ [collectionId]
1023
+ );
1024
+ const { latestTerm, latestSeq } = await this.readStreamPosition(
1025
+ collectionId,
1026
+ transactionDriver
1027
+ );
1028
+ const resetRows = await transactionDriver.query(
1029
+ `SELECT reset_epoch
1030
+ FROM collection_reset_epoch
1031
+ WHERE collection_id = ?
1032
+ LIMIT 1`,
1033
+ [collectionId]
1034
+ );
1035
+ return {
1036
+ rows: rows.map((row) => ({
1037
+ key: row.key,
1038
+ value: row.value,
1039
+ metadata: row.metadata
1040
+ })),
1041
+ keySet,
1042
+ collectionMetadata: collectionMetadataRows.map((row) => ({
1043
+ key: row.key,
1044
+ value: deserializePersistedRowValue(row.value)
1045
+ })),
1046
+ latestTerm,
1047
+ latestSeq,
1048
+ latestRowVersion,
1049
+ resetEpoch: resetRows[0]?.reset_epoch ?? 0
1050
+ };
1051
+ });
1052
+ }
1053
+ applyCommittedTx(collectionId, tx) {
1054
+ return this.runRegular(
1055
+ () => this.applyCommittedTxUnscheduled(collectionId, tx)
1056
+ );
796
1057
  }
797
- async applyCommittedTx(collectionId, tx) {
1058
+ async applyCommittedTxUnscheduled(collectionId, tx) {
798
1059
  const tableMapping = await this.ensureCollectionReady(collectionId);
799
1060
  const collectionTableSql = quoteIdentifier(tableMapping.tableName);
800
1061
  const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName);
801
1062
  await this.runInTransaction(async (transactionDriver) => {
802
- const alreadyApplied = await transactionDriver.query(
803
- `SELECT 1 AS applied
804
- FROM applied_tx
805
- WHERE collection_id = ? AND term = ? AND seq = ?
806
- LIMIT 1`,
807
- [collectionId, tx.term, tx.seq]
808
- );
809
- if (alreadyApplied.length > 0) {
810
- return;
811
- }
812
1063
  const versionRows = await transactionDriver.query(
813
- `SELECT latest_row_version
1064
+ `SELECT
1065
+ latest_row_version,
1066
+ key_set_evidence_available,
1067
+ (
1068
+ SELECT schema_version
1069
+ FROM collection_registry
1070
+ WHERE collection_id = ?
1071
+ LIMIT 1
1072
+ ) AS schema_version,
1073
+ EXISTS (
1074
+ SELECT 1
1075
+ FROM applied_tx
1076
+ WHERE collection_id = ? AND term = ? AND seq = ?
1077
+ ) AS already_applied
814
1078
  FROM collection_version
815
1079
  WHERE collection_id = ?
816
1080
  LIMIT 1`,
817
- [collectionId]
1081
+ [collectionId, collectionId, tx.term, tx.seq, collectionId]
818
1082
  );
819
- const currentRowVersion = versionRows[0]?.latest_row_version ?? 0;
1083
+ const version = versionRows[0];
1084
+ if (!version) {
1085
+ throw new errors.InvalidPersistedCollectionConfigError(
1086
+ `Missing persisted version state for collection "${collectionId}"`
1087
+ );
1088
+ }
1089
+ if (version.schema_version !== this.schemaVersion) {
1090
+ throw new errors.InvalidPersistedCollectionConfigError(
1091
+ `Schema version mismatch for collection "${collectionId}": found ${version.schema_version}, expected ${this.schemaVersion}. Refusing to apply a committed transaction through a stale cached adapter.`
1092
+ );
1093
+ }
1094
+ if (version.already_applied === 1) {
1095
+ return;
1096
+ }
1097
+ const currentRowVersion = version.latest_row_version;
820
1098
  const nextRowVersion = Math.max(currentRowVersion + 1, tx.rowVersion);
821
- const replayDelta = tx.truncate ? null : {
1099
+ const replacesPersistedBaseline = tx.truncate === true;
1100
+ const tracksPersistedKeySet = version.key_set_evidence_available === 1 || replacesPersistedBaseline;
1101
+ const replayDelta = replacesPersistedBaseline ? null : {
822
1102
  txId: tx.txId,
823
1103
  latestRowVersion: nextRowVersion,
824
1104
  changedRows: tx.mutations.filter((mutation) => mutation.type !== `delete`).map((mutation) => ({
@@ -829,13 +1109,67 @@ class SQLiteCorePersistenceAdapter {
829
1109
  rowMetadataMutations: tx.rowMetadataMutations ?? [],
830
1110
  collectionMetadataMutations: tx.collectionMetadataMutations ?? []
831
1111
  };
832
- if (tx.truncate) {
1112
+ if (replacesPersistedBaseline) {
1113
+ await transactionDriver.run(
1114
+ `DELETE FROM collection_expected_keys
1115
+ WHERE collection_id = ?`,
1116
+ [collectionId]
1117
+ );
833
1118
  await transactionDriver.run(`DELETE FROM ${collectionTableSql}`);
834
1119
  await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`);
835
1120
  }
836
- for (const mutation of tx.mutations) {
1121
+ const replacementKeys = replacesPersistedBaseline ? tx.mutations.map(
1122
+ (mutation) => persisted.encodePersistedStorageKey(mutation.key)
1123
+ ) : [];
1124
+ const batchReplacement = replacesPersistedBaseline && tx.mutations.length > 0 && tx.mutations.every((mutation) => mutation.type !== `delete`) && new Set(replacementKeys).size === replacementKeys.length;
1125
+ if (batchReplacement) {
1126
+ const finalRowMetadata = /* @__PURE__ */ new Map();
1127
+ for (const mutation of tx.rowMetadataMutations ?? []) {
1128
+ finalRowMetadata.set(
1129
+ persisted.encodePersistedStorageKey(mutation.key),
1130
+ mutation.type === `delete` ? void 0 : mutation.value
1131
+ );
1132
+ }
1133
+ for (let start = 0; start < tx.mutations.length; start += this.replacementBatchSize) {
1134
+ const mutations = tx.mutations.slice(
1135
+ start,
1136
+ start + this.replacementBatchSize
1137
+ );
1138
+ const keys = replacementKeys.slice(
1139
+ start,
1140
+ start + this.replacementBatchSize
1141
+ );
1142
+ await transactionDriver.run(
1143
+ `INSERT INTO collection_expected_keys (collection_id, key)
1144
+ VALUES ${keys.map(() => `(?, ?)`).join(`, `)}`,
1145
+ keys.flatMap((key) => [collectionId, key])
1146
+ );
1147
+ await transactionDriver.run(
1148
+ `INSERT INTO ${collectionTableSql} (key, value, metadata, row_version)
1149
+ VALUES ${keys.map(() => `(?, ?, ?, ?)`).join(`, `)}`,
1150
+ mutations.flatMap((mutation, index) => {
1151
+ const key = keys[index];
1152
+ const metadata = finalRowMetadata.has(key) ? finalRowMetadata.get(key) : mutation.type !== `delete` && mutation.metadataChanged === true ? mutation.metadata : void 0;
1153
+ return [
1154
+ key,
1155
+ serializePersistedRowValue(mutation.value),
1156
+ metadata === void 0 ? null : serializePersistedRowValue(metadata),
1157
+ nextRowVersion
1158
+ ];
1159
+ })
1160
+ );
1161
+ }
1162
+ }
1163
+ for (const mutation of batchReplacement ? [] : tx.mutations) {
837
1164
  const encodedKey = persisted.encodePersistedStorageKey(mutation.key);
838
1165
  if (mutation.type === `delete`) {
1166
+ if (tracksPersistedKeySet) {
1167
+ await transactionDriver.run(
1168
+ `DELETE FROM collection_expected_keys
1169
+ WHERE collection_id = ? AND key = ?`,
1170
+ [collectionId, encodedKey]
1171
+ );
1172
+ }
839
1173
  await transactionDriver.run(
840
1174
  `DELETE FROM ${collectionTableSql}
841
1175
  WHERE key = ?`,
@@ -868,6 +1202,14 @@ class SQLiteCorePersistenceAdapter {
868
1202
  const existingMetadata = existingRows[0]?.metadata != null ? deserializePersistedRowValue(existingRows[0].metadata) : void 0;
869
1203
  const mergedValue = mutation.type === `update` ? mergeObjectRows(existingValue, mutation.value) : mutation.value;
870
1204
  const nextMetadata = mutation.metadataChanged === true ? mutation.metadata : existingMetadata;
1205
+ if (tracksPersistedKeySet) {
1206
+ await transactionDriver.run(
1207
+ `INSERT INTO collection_expected_keys (collection_id, key)
1208
+ VALUES (?, ?)
1209
+ ON CONFLICT(collection_id, key) DO NOTHING`,
1210
+ [collectionId, encodedKey]
1211
+ );
1212
+ }
871
1213
  await transactionDriver.run(
872
1214
  `INSERT INTO ${collectionTableSql} (key, value, metadata, row_version)
873
1215
  VALUES (?, ?, ?, ?)
@@ -888,7 +1230,7 @@ class SQLiteCorePersistenceAdapter {
888
1230
  [encodedKey]
889
1231
  );
890
1232
  }
891
- for (const rowMetadataMutation of tx.rowMetadataMutations ?? []) {
1233
+ for (const rowMetadataMutation of batchReplacement ? [] : tx.rowMetadataMutations ?? []) {
892
1234
  const encodedKey = persisted.encodePersistedStorageKey(rowMetadataMutation.key);
893
1235
  if (rowMetadataMutation.type === `delete`) {
894
1236
  await transactionDriver.run(
@@ -932,11 +1274,23 @@ class SQLiteCorePersistenceAdapter {
932
1274
  }
933
1275
  }
934
1276
  await transactionDriver.run(
935
- `INSERT INTO collection_version (collection_id, latest_row_version)
936
- VALUES (?, ?)
937
- ON CONFLICT(collection_id) DO UPDATE SET
938
- latest_row_version = excluded.latest_row_version`,
939
- [collectionId, nextRowVersion]
1277
+ `UPDATE collection_version
1278
+ SET latest_row_version = ?,
1279
+ key_set_evidence_available = CASE
1280
+ WHEN ? = 1 THEN 1
1281
+ ELSE key_set_evidence_available
1282
+ END,
1283
+ key_set_evidence_incompatible = CASE
1284
+ WHEN ? = 1 THEN 0
1285
+ ELSE key_set_evidence_incompatible
1286
+ END
1287
+ WHERE collection_id = ?`,
1288
+ [
1289
+ nextRowVersion,
1290
+ replacesPersistedBaseline ? 1 : 0,
1291
+ replacesPersistedBaseline ? 1 : 0,
1292
+ collectionId
1293
+ ]
940
1294
  );
941
1295
  await transactionDriver.run(
942
1296
  `INSERT INTO leader_term (collection_id, latest_term)
@@ -968,40 +1322,70 @@ class SQLiteCorePersistenceAdapter {
968
1322
  tx.txId,
969
1323
  nextRowVersion,
970
1324
  replayDelta ? stableStringify(replayDelta) : null,
971
- tx.truncate ? 1 : 0
1325
+ replacesPersistedBaseline ? 1 : 0
972
1326
  ]
973
1327
  );
974
1328
  await this.pruneAppliedTxRows(collectionId, transactionDriver);
975
1329
  });
976
1330
  }
977
- async loadCollectionMetadata(collectionId) {
978
- const rows = await this.driver.query(
979
- `SELECT key, value
980
- FROM collection_metadata
981
- WHERE collection_id = ?`,
982
- [collectionId]
1331
+ loadCollectionMetadata(collectionId) {
1332
+ return this.runRegular(
1333
+ () => this.loadCollectionMetadataUnscheduled(collectionId)
983
1334
  );
984
- return rows.map((row) => ({
985
- key: row.key,
986
- value: deserializePersistedRowValue(row.value)
987
- }));
988
1335
  }
989
- async scanRows(collectionId, options) {
1336
+ async loadCollectionMetadataUnscheduled(collectionId) {
1337
+ await this.ensureCollectionReady(collectionId);
1338
+ return this.runInTransaction(async (transactionDriver) => {
1339
+ await this.assertCurrentSchemaVersion(
1340
+ collectionId,
1341
+ transactionDriver,
1342
+ `load collection metadata`
1343
+ );
1344
+ const rows = await transactionDriver.query(
1345
+ `SELECT key, value
1346
+ FROM collection_metadata
1347
+ WHERE collection_id = ?`,
1348
+ [collectionId]
1349
+ );
1350
+ return rows.map((row) => ({
1351
+ key: row.key,
1352
+ value: deserializePersistedRowValue(row.value)
1353
+ }));
1354
+ });
1355
+ }
1356
+ scanRows(collectionId, options) {
1357
+ return this.runRegular(
1358
+ () => this.scanRowsUnscheduled(collectionId, options)
1359
+ );
1360
+ }
1361
+ async scanRowsUnscheduled(collectionId, options) {
990
1362
  const tableMapping = await this.ensureCollectionReady(collectionId);
991
1363
  const collectionTableSql = quoteIdentifier(tableMapping.tableName);
992
- const storedRows = await this.driver.query(
993
- options?.metadataOnly ? `SELECT key, value, metadata, row_version
994
- FROM ${collectionTableSql}
995
- WHERE metadata IS NOT NULL` : `SELECT key, value, metadata, row_version
996
- FROM ${collectionTableSql}`
1364
+ return this.runInTransaction(async (transactionDriver) => {
1365
+ await this.assertCurrentSchemaVersion(
1366
+ collectionId,
1367
+ transactionDriver,
1368
+ `scan persisted rows`
1369
+ );
1370
+ const storedRows = await transactionDriver.query(
1371
+ options?.metadataOnly ? `SELECT key, value, metadata, row_version
1372
+ FROM ${collectionTableSql}
1373
+ WHERE metadata IS NOT NULL` : `SELECT key, value, metadata, row_version
1374
+ FROM ${collectionTableSql}`
1375
+ );
1376
+ return decodeStoredSqliteRows(storedRows).map((row) => ({
1377
+ key: row.key,
1378
+ value: row.value,
1379
+ metadata: row.metadata
1380
+ }));
1381
+ });
1382
+ }
1383
+ ensureIndex(collectionId, signature, spec) {
1384
+ return this.runRegular(
1385
+ () => this.ensureIndexUnscheduled(collectionId, signature, spec)
997
1386
  );
998
- return decodeStoredSqliteRows(storedRows).map((row) => ({
999
- key: row.key,
1000
- value: row.value,
1001
- metadata: row.metadata
1002
- }));
1003
1387
  }
1004
- async ensureIndex(collectionId, signature, spec) {
1388
+ async ensureIndexUnscheduled(collectionId, signature, spec) {
1005
1389
  const tableMapping = await this.ensureCollectionReady(collectionId);
1006
1390
  const collectionTableSql = quoteIdentifier(tableMapping.tableName);
1007
1391
  const indexName = buildIndexName(collectionId, signature);
@@ -1010,8 +1394,28 @@ class SQLiteCorePersistenceAdapter {
1010
1394
  (fragment) => normalizeIndexSqlFragment(fragment)
1011
1395
  );
1012
1396
  const expressionSql = normalizedExpressionSql.join(`, `);
1397
+ const persistedExpressionSql = JSON.stringify(normalizedExpressionSql);
1013
1398
  const whereSql = spec.whereSql ? normalizeIndexSqlFragment(spec.whereSql) : void 0;
1399
+ const persistedWhereSql = whereSql ?? null;
1014
1400
  await this.runInTransaction(async (transactionDriver) => {
1401
+ await this.assertCurrentSchemaVersion(
1402
+ collectionId,
1403
+ transactionDriver,
1404
+ `create a persisted index`
1405
+ );
1406
+ const existingRows = await transactionDriver.query(
1407
+ `SELECT index_name, expression_sql, where_sql
1408
+ FROM persisted_index_registry
1409
+ WHERE collection_id = ? AND signature = ?
1410
+ LIMIT 1`,
1411
+ [collectionId, signature]
1412
+ );
1413
+ const existing = existingRows[0];
1414
+ if (existing && (existing.index_name !== indexName || existing.expression_sql !== persistedExpressionSql || existing.where_sql !== persistedWhereSql)) {
1415
+ await transactionDriver.exec(
1416
+ `DROP INDEX IF EXISTS ${quoteIdentifier(existing.index_name)}`
1417
+ );
1418
+ }
1015
1419
  await transactionDriver.run(
1016
1420
  `INSERT INTO persisted_index_registry (
1017
1421
  collection_id,
@@ -1039,8 +1443,8 @@ class SQLiteCorePersistenceAdapter {
1039
1443
  collectionId,
1040
1444
  signature,
1041
1445
  indexName,
1042
- JSON.stringify(normalizedExpressionSql),
1043
- whereSql ?? null
1446
+ persistedExpressionSql,
1447
+ persistedWhereSql
1044
1448
  ]
1045
1449
  );
1046
1450
  const createIndexSql = whereSql ? `CREATE INDEX IF NOT EXISTS ${indexNameSql}
@@ -1050,48 +1454,83 @@ class SQLiteCorePersistenceAdapter {
1050
1454
  await transactionDriver.exec(createIndexSql);
1051
1455
  });
1052
1456
  }
1053
- async markIndexRemoved(collectionId, signature) {
1054
- await this.ensureCollectionReady(collectionId);
1055
- const rows = await this.driver.query(
1056
- `SELECT index_name
1057
- FROM persisted_index_registry
1058
- WHERE collection_id = ? AND signature = ?
1059
- LIMIT 1`,
1060
- [collectionId, signature]
1061
- );
1062
- const indexName = rows[0]?.index_name;
1063
- await this.driver.run(
1064
- `UPDATE persisted_index_registry
1065
- SET removed = 1,
1066
- updated_at = CAST(strftime('%s', 'now') AS INTEGER),
1067
- last_used_at = CAST(strftime('%s', 'now') AS INTEGER)
1068
- WHERE collection_id = ? AND signature = ?`,
1069
- [collectionId, signature]
1457
+ markIndexRemoved(collectionId, signature) {
1458
+ return this.runRegular(
1459
+ () => this.markIndexRemovedUnscheduled(collectionId, signature)
1070
1460
  );
1071
- if (indexName) {
1072
- await this.driver.exec(
1073
- `DROP INDEX IF EXISTS ${quoteIdentifier(indexName)}`
1461
+ }
1462
+ async markIndexRemovedUnscheduled(collectionId, signature) {
1463
+ await this.ensureCollectionReady(collectionId);
1464
+ await this.runInTransaction(async (transactionDriver) => {
1465
+ await this.assertCurrentSchemaVersion(
1466
+ collectionId,
1467
+ transactionDriver,
1468
+ `remove a persisted index`
1074
1469
  );
1075
- }
1470
+ const rows = await transactionDriver.query(
1471
+ `SELECT index_name
1472
+ FROM persisted_index_registry
1473
+ WHERE collection_id = ? AND signature = ?
1474
+ LIMIT 1`,
1475
+ [collectionId, signature]
1476
+ );
1477
+ const indexName = rows[0]?.index_name;
1478
+ await transactionDriver.run(
1479
+ `UPDATE persisted_index_registry
1480
+ SET removed = 1,
1481
+ updated_at = CAST(strftime('%s', 'now') AS INTEGER),
1482
+ last_used_at = CAST(strftime('%s', 'now') AS INTEGER)
1483
+ WHERE collection_id = ? AND signature = ?`,
1484
+ [collectionId, signature]
1485
+ );
1486
+ if (indexName) {
1487
+ await transactionDriver.exec(
1488
+ `DROP INDEX IF EXISTS ${quoteIdentifier(indexName)}`
1489
+ );
1490
+ }
1491
+ });
1076
1492
  }
1077
- async getStreamPosition(collectionId) {
1493
+ getStreamPosition(collectionId) {
1494
+ return this.getStreamPositionUnscheduled(collectionId);
1495
+ }
1496
+ async getStreamPositionUnscheduled(collectionId) {
1078
1497
  await this.ensureCollectionReady(collectionId);
1079
- const [termRows, versionRows, seqRows] = await Promise.all([
1080
- this.driver.query(
1498
+ return this.runInTransaction(async (transactionDriver) => {
1499
+ await this.assertCurrentSchemaVersion(
1500
+ collectionId,
1501
+ transactionDriver,
1502
+ `read the stream position`
1503
+ );
1504
+ const [position, latestRowVersion] = await Promise.all([
1505
+ this.readStreamPosition(collectionId, transactionDriver),
1506
+ this.readLatestRowVersion(collectionId, transactionDriver)
1507
+ ]);
1508
+ return {
1509
+ ...position,
1510
+ latestRowVersion
1511
+ };
1512
+ });
1513
+ }
1514
+ async readLatestRowVersion(collectionId, driver) {
1515
+ const versionRows = await driver.query(
1516
+ `SELECT latest_row_version
1517
+ FROM collection_version
1518
+ WHERE collection_id = ?
1519
+ LIMIT 1`,
1520
+ [collectionId]
1521
+ );
1522
+ return versionRows[0]?.latest_row_version ?? 0;
1523
+ }
1524
+ async readStreamPosition(collectionId, driver) {
1525
+ const [termRows, seqRows] = await Promise.all([
1526
+ driver.query(
1081
1527
  `SELECT latest_term
1082
1528
  FROM leader_term
1083
1529
  WHERE collection_id = ?
1084
1530
  LIMIT 1`,
1085
1531
  [collectionId]
1086
1532
  ),
1087
- this.driver.query(
1088
- `SELECT latest_row_version
1089
- FROM collection_version
1090
- WHERE collection_id = ?
1091
- LIMIT 1`,
1092
- [collectionId]
1093
- ),
1094
- this.driver.query(
1533
+ driver.query(
1095
1534
  `SELECT MAX(seq) AS max_seq
1096
1535
  FROM applied_tx
1097
1536
  WHERE collection_id = ? AND term = (
@@ -1102,116 +1541,146 @@ class SQLiteCorePersistenceAdapter {
1102
1541
  ]);
1103
1542
  return {
1104
1543
  latestTerm: termRows[0]?.latest_term ?? 0,
1105
- latestSeq: seqRows[0]?.max_seq ?? 0,
1106
- latestRowVersion: versionRows[0]?.latest_row_version ?? 0
1544
+ latestSeq: seqRows[0]?.max_seq ?? 0
1545
+ };
1546
+ }
1547
+ async readKeySetEvidence(collectionId, driver) {
1548
+ const versionRows = await driver.query(
1549
+ `SELECT
1550
+ latest_row_version,
1551
+ key_set_evidence_available,
1552
+ key_set_evidence_incompatible AS key_set_incompatible
1553
+ FROM collection_version
1554
+ WHERE collection_id = ?
1555
+ LIMIT 1`,
1556
+ [collectionId]
1557
+ );
1558
+ const version = versionRows[0];
1559
+ return {
1560
+ latestRowVersion: version?.latest_row_version ?? 0,
1561
+ keySet: {
1562
+ status: version?.key_set_evidence_available !== 1 ? `unknown` : version.key_set_incompatible === 1 ? `incompatible` : `consistent`
1563
+ }
1107
1564
  };
1108
1565
  }
1109
- async pullSince(collectionId, fromRowVersion) {
1566
+ pullSince(collectionId, fromRowVersion) {
1567
+ return this.runRegular(
1568
+ () => this.pullSinceUnscheduled(collectionId, fromRowVersion)
1569
+ );
1570
+ }
1571
+ async pullSinceUnscheduled(collectionId, fromRowVersion) {
1110
1572
  const tableMapping = await this.ensureCollectionReady(collectionId);
1111
1573
  const collectionTableSql = quoteIdentifier(tableMapping.tableName);
1112
1574
  const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName);
1113
- const [
1114
- changedRows,
1115
- deletedRows,
1116
- latestVersionRows,
1117
- replayRows,
1118
- replayAvailabilityRows
1119
- ] = await Promise.all([
1120
- this.driver.query(
1121
- `SELECT key
1575
+ return this.runInTransaction(async (transactionDriver) => {
1576
+ await this.assertCurrentSchemaVersion(
1577
+ collectionId,
1578
+ transactionDriver,
1579
+ `read persisted transaction deltas`
1580
+ );
1581
+ const [
1582
+ changedRows,
1583
+ deletedRows,
1584
+ latestVersionRows,
1585
+ replayRows,
1586
+ replayAvailabilityRows
1587
+ ] = await Promise.all([
1588
+ transactionDriver.query(
1589
+ `SELECT key
1122
1590
  FROM ${collectionTableSql}
1123
1591
  WHERE row_version > ?`,
1124
- [fromRowVersion]
1125
- ),
1126
- this.driver.query(
1127
- `SELECT key
1592
+ [fromRowVersion]
1593
+ ),
1594
+ transactionDriver.query(
1595
+ `SELECT key
1128
1596
  FROM ${tombstoneTableSql}
1129
1597
  WHERE row_version > ?`,
1130
- [fromRowVersion]
1131
- ),
1132
- this.driver.query(
1133
- `SELECT latest_row_version
1598
+ [fromRowVersion]
1599
+ ),
1600
+ transactionDriver.query(
1601
+ `SELECT latest_row_version
1134
1602
  FROM collection_version
1135
1603
  WHERE collection_id = ?
1136
1604
  LIMIT 1`,
1137
- [collectionId]
1138
- ),
1139
- this.driver.query(
1140
- `SELECT tx_id, row_version, replay_json, replay_requires_full_reload
1605
+ [collectionId]
1606
+ ),
1607
+ transactionDriver.query(
1608
+ `SELECT tx_id, row_version, replay_json, replay_requires_full_reload
1141
1609
  FROM applied_tx
1142
1610
  WHERE collection_id = ? AND row_version > ?
1143
1611
  ORDER BY term ASC, seq ASC`,
1144
- [collectionId, fromRowVersion]
1145
- ),
1146
- this.driver.query(
1147
- `SELECT MIN(row_version) AS min_row_version
1612
+ [collectionId, fromRowVersion]
1613
+ ),
1614
+ transactionDriver.query(
1615
+ `SELECT MIN(row_version) AS min_row_version
1148
1616
  FROM applied_tx
1149
1617
  WHERE collection_id = ?`,
1150
- [collectionId]
1151
- )
1152
- ]);
1153
- const latestRowVersion = latestVersionRows[0]?.latest_row_version ?? 0;
1154
- const replayFloor = replayAvailabilityRows[0]?.min_row_version;
1155
- if (latestRowVersion > fromRowVersion && (replayFloor == null || replayFloor > fromRowVersion + 1)) {
1156
- return {
1157
- latestRowVersion,
1158
- requiresFullReload: true
1159
- };
1160
- }
1161
- const changedKeyCount = changedRows.length + deletedRows.length;
1162
- if (changedKeyCount > this.pullSinceReloadThreshold) {
1163
- return {
1164
- latestRowVersion,
1165
- requiresFullReload: true
1166
- };
1167
- }
1168
- if (replayRows.some(
1169
- (row) => row.replay_requires_full_reload !== 0 || row.replay_json == null
1170
- )) {
1171
- return {
1172
- latestRowVersion,
1173
- requiresFullReload: true
1618
+ [collectionId]
1619
+ )
1620
+ ]);
1621
+ const latestRowVersion = latestVersionRows[0]?.latest_row_version ?? 0;
1622
+ const replayFloor = replayAvailabilityRows[0]?.min_row_version;
1623
+ if (latestRowVersion > fromRowVersion && (replayFloor == null || replayFloor > fromRowVersion + 1)) {
1624
+ return {
1625
+ latestRowVersion,
1626
+ requiresFullReload: true
1627
+ };
1628
+ }
1629
+ const changedKeyCount = changedRows.length + deletedRows.length;
1630
+ if (changedKeyCount > this.pullSinceReloadThreshold) {
1631
+ return {
1632
+ latestRowVersion,
1633
+ requiresFullReload: true
1634
+ };
1635
+ }
1636
+ if (replayRows.some(
1637
+ (row) => row.replay_requires_full_reload !== 0 || row.replay_json == null
1638
+ )) {
1639
+ return {
1640
+ latestRowVersion,
1641
+ requiresFullReload: true
1642
+ };
1643
+ }
1644
+ const decodeKey = (encodedKey) => {
1645
+ try {
1646
+ return persisted.decodePersistedStorageKey(encodedKey);
1647
+ } catch (error) {
1648
+ throw new errors.InvalidPersistedStorageKeyEncodingError(
1649
+ `${encodedKey}: ${error.message}`
1650
+ );
1651
+ }
1174
1652
  };
1175
- }
1176
- const decodeKey = (encodedKey) => {
1177
- try {
1178
- return persisted.decodePersistedStorageKey(encodedKey);
1179
- } catch (error) {
1180
- throw new errors.InvalidPersistedStorageKeyEncodingError(
1181
- `${encodedKey}: ${error.message}`
1653
+ const deltas = replayRows.map((row) => {
1654
+ const parsed = deserializePersistedRowValue(
1655
+ row.replay_json ?? `null`
1182
1656
  );
1183
- }
1184
- };
1185
- const deltas = replayRows.map((row) => {
1186
- const parsed = deserializePersistedRowValue(
1187
- row.replay_json ?? `null`
1657
+ if (!parsed) {
1658
+ throw new errors.InvalidPersistedCollectionConfigError(
1659
+ `missing replay payload for applied_tx row`
1660
+ );
1661
+ }
1662
+ return parsed;
1663
+ });
1664
+ const replayChangeCount = deltas.reduce(
1665
+ (count, delta) => count + delta.changedRows.length + delta.deletedKeys.length + delta.rowMetadataMutations.length + delta.collectionMetadataMutations.length,
1666
+ 0
1188
1667
  );
1189
- if (!parsed) {
1190
- throw new errors.InvalidPersistedCollectionConfigError(
1191
- `missing replay payload for applied_tx row`
1192
- );
1668
+ if (replayChangeCount > this.pullSinceReloadThreshold) {
1669
+ return {
1670
+ latestRowVersion,
1671
+ requiresFullReload: true
1672
+ };
1193
1673
  }
1194
- return parsed;
1195
- });
1196
- const replayChangeCount = deltas.reduce(
1197
- (count, delta) => count + delta.changedRows.length + delta.deletedKeys.length + delta.rowMetadataMutations.length + delta.collectionMetadataMutations.length,
1198
- 0
1199
- );
1200
- if (replayChangeCount > this.pullSinceReloadThreshold) {
1201
1674
  return {
1202
1675
  latestRowVersion,
1203
- requiresFullReload: true
1676
+ requiresFullReload: false,
1677
+ changedKeys: changedRows.map((row) => decodeKey(row.key)),
1678
+ deletedKeys: deletedRows.map((row) => decodeKey(row.key)),
1679
+ deltas
1204
1680
  };
1205
- }
1206
- return {
1207
- latestRowVersion,
1208
- requiresFullReload: false,
1209
- changedKeys: changedRows.map((row) => decodeKey(row.key)),
1210
- deletedKeys: deletedRows.map((row) => decodeKey(row.key)),
1211
- deltas
1212
- };
1681
+ });
1213
1682
  }
1214
- async loadSubsetInternal(tableMapping, options) {
1683
+ async loadSubsetInternal(tableMapping, options, driver = this.driver) {
1215
1684
  const collectionTableSql = quoteIdentifier(tableMapping.tableName);
1216
1685
  const whereCompiled = options.where ? compileSqlExpression(options.where) : { supported: true, sql: ``, params: [] };
1217
1686
  const orderByCompiled = compileOrderByClauses(options.orderBy);
@@ -1225,10 +1694,7 @@ class SQLiteCorePersistenceAdapter {
1225
1694
  sql = `${sql} ORDER BY ${orderByCompiled.sql}, key ASC`;
1226
1695
  queryParams.push(...orderByCompiled.params);
1227
1696
  }
1228
- const storedRows = await this.driver.query(
1229
- sql,
1230
- queryParams
1231
- );
1697
+ const storedRows = await driver.query(sql, queryParams);
1232
1698
  const parsedRows = decodeStoredSqliteRows(storedRows);
1233
1699
  const filteredRows = this.applyInMemoryWhere(parsedRows, options.where);
1234
1700
  const orderedRows = this.applyInMemoryOrderBy(filteredRows, options.orderBy);
@@ -1288,12 +1754,12 @@ class SQLiteCorePersistenceAdapter {
1288
1754
  }
1289
1755
  return rows.slice(start, start + limit);
1290
1756
  }
1291
- async touchRequiredIndexes(collectionId, requiredIndexSignatures) {
1757
+ async touchRequiredIndexes(collectionId, requiredIndexSignatures, driver = this.driver) {
1292
1758
  if (!requiredIndexSignatures || requiredIndexSignatures.length === 0) {
1293
1759
  return;
1294
1760
  }
1295
1761
  for (const signature of requiredIndexSignatures) {
1296
- await this.driver.run(
1762
+ await driver.run(
1297
1763
  `UPDATE persisted_index_registry
1298
1764
  SET last_used_at = CAST(strftime('%s', 'now') AS INTEGER),
1299
1765
  updated_at = CAST(strftime('%s', 'now') AS INTEGER)
@@ -1355,31 +1821,21 @@ class SQLiteCorePersistenceAdapter {
1355
1821
  this.collectionTableLoads.delete(collectionId);
1356
1822
  }
1357
1823
  }
1358
- async ensureCollectionReadyInternal(collectionId) {
1359
- const existingRows = await this.driver.query(
1824
+ async loadCollectionRegistration(collectionId) {
1825
+ const rows = await this.driver.query(
1360
1826
  `SELECT table_name, tombstone_table_name, schema_version
1361
1827
  FROM collection_registry
1362
1828
  WHERE collection_id = ?
1363
1829
  LIMIT 1`,
1364
1830
  [collectionId]
1365
1831
  );
1366
- let tableName;
1367
- let tombstoneTableName;
1368
- if (existingRows.length > 0) {
1369
- tableName = existingRows[0].table_name;
1370
- tombstoneTableName = existingRows[0].tombstone_table_name;
1371
- if (existingRows[0].schema_version !== this.schemaVersion) {
1372
- await this.handleSchemaMismatch(
1373
- collectionId,
1374
- existingRows[0].schema_version,
1375
- this.schemaVersion,
1376
- tableName,
1377
- tombstoneTableName
1378
- );
1379
- }
1380
- } else {
1381
- tableName = persisted.createPersistedTableName(collectionId, `c`);
1382
- tombstoneTableName = persisted.createPersistedTableName(collectionId, `t`);
1832
+ return rows[0];
1833
+ }
1834
+ async ensureCollectionReadyInternal(collectionId) {
1835
+ let registration = await this.loadCollectionRegistration(collectionId);
1836
+ if (!registration) {
1837
+ const tableName2 = persisted.createPersistedTableName(collectionId, `c`);
1838
+ const tombstoneTableName2 = persisted.createPersistedTableName(collectionId, `t`);
1383
1839
  await this.driver.run(
1384
1840
  `INSERT INTO collection_registry (
1385
1841
  collection_id,
@@ -1388,8 +1844,26 @@ class SQLiteCorePersistenceAdapter {
1388
1844
  schema_version,
1389
1845
  updated_at
1390
1846
  )
1391
- VALUES (?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))`,
1392
- [collectionId, tableName, tombstoneTableName, this.schemaVersion]
1847
+ VALUES (?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
1848
+ ON CONFLICT DO NOTHING`,
1849
+ [collectionId, tableName2, tombstoneTableName2, this.schemaVersion]
1850
+ );
1851
+ registration = await this.loadCollectionRegistration(collectionId);
1852
+ if (!registration) {
1853
+ throw new errors.InvalidPersistedCollectionConfigError(
1854
+ `Unable to register persistence tables for collection "${collectionId}"`
1855
+ );
1856
+ }
1857
+ }
1858
+ const tableName = registration.table_name;
1859
+ const tombstoneTableName = registration.tombstone_table_name;
1860
+ if (registration.schema_version !== this.schemaVersion) {
1861
+ await this.handleSchemaMismatch(
1862
+ collectionId,
1863
+ registration.schema_version,
1864
+ this.schemaVersion,
1865
+ tableName,
1866
+ tombstoneTableName
1393
1867
  );
1394
1868
  }
1395
1869
  const collectionTableSql = quoteIdentifier(tableName);
@@ -1419,8 +1893,13 @@ class SQLiteCorePersistenceAdapter {
1419
1893
  ON ${tombstoneTableSql} (row_version)`
1420
1894
  );
1421
1895
  await this.driver.run(
1422
- `INSERT INTO collection_version (collection_id, latest_row_version)
1423
- VALUES (?, 0)
1896
+ `INSERT INTO collection_version (
1897
+ collection_id,
1898
+ latest_row_version,
1899
+ key_set_evidence_available,
1900
+ key_set_evidence_incompatible
1901
+ )
1902
+ VALUES (?, 0, 1, 0)
1424
1903
  ON CONFLICT(collection_id) DO NOTHING`,
1425
1904
  [collectionId]
1426
1905
  );
@@ -1430,6 +1909,7 @@ class SQLiteCorePersistenceAdapter {
1430
1909
  ON CONFLICT(collection_id) DO NOTHING`,
1431
1910
  [collectionId]
1432
1911
  );
1912
+ await this.ensureCollectionKeyEvidenceTriggers(collectionId, tableName);
1433
1913
  const mapping = {
1434
1914
  tableName,
1435
1915
  tombstoneTableName
@@ -1437,6 +1917,68 @@ class SQLiteCorePersistenceAdapter {
1437
1917
  this.collectionTableCache.set(collectionId, mapping);
1438
1918
  return mapping;
1439
1919
  }
1920
+ async ensureCollectionKeyEvidenceTriggers(collectionId, tableName) {
1921
+ const collectionTableSql = quoteIdentifier(tableName);
1922
+ const collectionIdLiteral = toSqliteLiteral(collectionId);
1923
+ const insertTriggerSql = quoteIdentifier(`${tableName}_key_evidence_insert`);
1924
+ const deleteTriggerSql = quoteIdentifier(`${tableName}_key_evidence_delete`);
1925
+ const updateTriggerSql = quoteIdentifier(`${tableName}_key_evidence_update`);
1926
+ await this.driver.exec(
1927
+ `CREATE TRIGGER IF NOT EXISTS ${insertTriggerSql}
1928
+ AFTER INSERT ON ${collectionTableSql}
1929
+ WHEN EXISTS (
1930
+ SELECT 1
1931
+ FROM collection_version
1932
+ WHERE collection_id = ${collectionIdLiteral}
1933
+ AND key_set_evidence_available = 1
1934
+ ) AND NOT EXISTS (
1935
+ SELECT 1
1936
+ FROM collection_expected_keys
1937
+ WHERE collection_id = ${collectionIdLiteral}
1938
+ AND key = NEW.key
1939
+ )
1940
+ BEGIN
1941
+ UPDATE collection_version
1942
+ SET key_set_evidence_incompatible = 1
1943
+ WHERE collection_id = ${collectionIdLiteral};
1944
+ END`
1945
+ );
1946
+ await this.driver.exec(
1947
+ `CREATE TRIGGER IF NOT EXISTS ${deleteTriggerSql}
1948
+ AFTER DELETE ON ${collectionTableSql}
1949
+ WHEN EXISTS (
1950
+ SELECT 1
1951
+ FROM collection_version
1952
+ WHERE collection_id = ${collectionIdLiteral}
1953
+ AND key_set_evidence_available = 1
1954
+ ) AND EXISTS (
1955
+ SELECT 1
1956
+ FROM collection_expected_keys
1957
+ WHERE collection_id = ${collectionIdLiteral}
1958
+ AND key = OLD.key
1959
+ )
1960
+ BEGIN
1961
+ UPDATE collection_version
1962
+ SET key_set_evidence_incompatible = 1
1963
+ WHERE collection_id = ${collectionIdLiteral};
1964
+ END`
1965
+ );
1966
+ await this.driver.exec(
1967
+ `CREATE TRIGGER IF NOT EXISTS ${updateTriggerSql}
1968
+ AFTER UPDATE OF key ON ${collectionTableSql}
1969
+ WHEN OLD.key <> NEW.key AND EXISTS (
1970
+ SELECT 1
1971
+ FROM collection_version
1972
+ WHERE collection_id = ${collectionIdLiteral}
1973
+ AND key_set_evidence_available = 1
1974
+ )
1975
+ BEGIN
1976
+ UPDATE collection_version
1977
+ SET key_set_evidence_incompatible = 1
1978
+ WHERE collection_id = ${collectionIdLiteral};
1979
+ END`
1980
+ );
1981
+ }
1440
1982
  async handleSchemaMismatch(collectionId, previousSchemaVersion, nextSchemaVersion, tableName, tombstoneTableName) {
1441
1983
  if (this.schemaMismatchPolicy === `sync-absent-error`) {
1442
1984
  throw new errors.InvalidPersistedCollectionConfigError(
@@ -1446,6 +1988,22 @@ class SQLiteCorePersistenceAdapter {
1446
1988
  const collectionTableSql = quoteIdentifier(tableName);
1447
1989
  const tombstoneTableSql = quoteIdentifier(tombstoneTableName);
1448
1990
  await this.runInTransaction(async (transactionDriver) => {
1991
+ const currentSchemaRows = await transactionDriver.query(
1992
+ `SELECT schema_version
1993
+ FROM collection_registry
1994
+ WHERE collection_id = ?
1995
+ LIMIT 1`,
1996
+ [collectionId]
1997
+ );
1998
+ const currentSchemaVersion = currentSchemaRows[0]?.schema_version;
1999
+ if (currentSchemaVersion === nextSchemaVersion) {
2000
+ return;
2001
+ }
2002
+ if (currentSchemaVersion !== previousSchemaVersion) {
2003
+ throw new errors.InvalidPersistedCollectionConfigError(
2004
+ `Schema version changed concurrently for collection "${collectionId}": found ${currentSchemaVersion ?? `no registry entry`} after observing ${previousSchemaVersion}; refusing to reset it to ${nextSchemaVersion}.`
2005
+ );
2006
+ }
1449
2007
  const persistedIndexes = await transactionDriver.query(
1450
2008
  `SELECT index_name
1451
2009
  FROM persisted_index_registry
@@ -1457,6 +2015,11 @@ class SQLiteCorePersistenceAdapter {
1457
2015
  `DROP INDEX IF EXISTS ${quoteIdentifier(row.index_name)}`
1458
2016
  );
1459
2017
  }
2018
+ await transactionDriver.run(
2019
+ `DELETE FROM collection_expected_keys
2020
+ WHERE collection_id = ?`,
2021
+ [collectionId]
2022
+ );
1460
2023
  await transactionDriver.run(`DELETE FROM ${collectionTableSql}`);
1461
2024
  await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`);
1462
2025
  await transactionDriver.run(
@@ -1469,6 +2032,11 @@ class SQLiteCorePersistenceAdapter {
1469
2032
  WHERE collection_id = ?`,
1470
2033
  [collectionId]
1471
2034
  );
2035
+ await transactionDriver.run(
2036
+ `DELETE FROM collection_metadata
2037
+ WHERE collection_id = ?`,
2038
+ [collectionId]
2039
+ );
1472
2040
  await transactionDriver.run(
1473
2041
  `UPDATE collection_registry
1474
2042
  SET schema_version = ?,
@@ -1477,10 +2045,17 @@ class SQLiteCorePersistenceAdapter {
1477
2045
  [nextSchemaVersion, collectionId]
1478
2046
  );
1479
2047
  await transactionDriver.run(
1480
- `INSERT INTO collection_version (collection_id, latest_row_version)
1481
- VALUES (?, 0)
2048
+ `INSERT INTO collection_version (
2049
+ collection_id,
2050
+ latest_row_version,
2051
+ key_set_evidence_available,
2052
+ key_set_evidence_incompatible
2053
+ )
2054
+ VALUES (?, 0, 1, 0)
1482
2055
  ON CONFLICT(collection_id) DO UPDATE SET
1483
- latest_row_version = 0`,
2056
+ latest_row_version = 0,
2057
+ key_set_evidence_available = 1,
2058
+ key_set_evidence_incompatible = 0`,
1484
2059
  [collectionId]
1485
2060
  );
1486
2061
  await transactionDriver.run(
@@ -1554,7 +2129,37 @@ class SQLiteCorePersistenceAdapter {
1554
2129
  await this.driver.exec(
1555
2130
  `CREATE TABLE IF NOT EXISTS collection_version (
1556
2131
  collection_id TEXT PRIMARY KEY,
1557
- latest_row_version INTEGER NOT NULL
2132
+ latest_row_version INTEGER NOT NULL,
2133
+ key_set_evidence_available INTEGER NOT NULL DEFAULT 0,
2134
+ key_set_evidence_incompatible INTEGER NOT NULL DEFAULT 0
2135
+ )`
2136
+ );
2137
+ const collectionVersionColumns = await this.driver.query(
2138
+ `PRAGMA table_info(collection_version)`
2139
+ );
2140
+ const keyEvidenceColumns = [
2141
+ `key_set_evidence_available`,
2142
+ `key_set_evidence_incompatible`
2143
+ ];
2144
+ for (const columnName of keyEvidenceColumns) {
2145
+ if (collectionVersionColumns.some(({ name }) => name === columnName)) {
2146
+ continue;
2147
+ }
2148
+ try {
2149
+ await this.driver.exec(
2150
+ `ALTER TABLE collection_version ADD COLUMN ${columnName} INTEGER NOT NULL DEFAULT 0`
2151
+ );
2152
+ } catch (error) {
2153
+ if (!isDuplicateColumnAddError(error, columnName)) {
2154
+ throw error;
2155
+ }
2156
+ }
2157
+ }
2158
+ await this.driver.exec(
2159
+ `CREATE TABLE IF NOT EXISTS collection_expected_keys (
2160
+ collection_id TEXT NOT NULL,
2161
+ key TEXT NOT NULL,
2162
+ PRIMARY KEY (collection_id, key)
1558
2163
  )`
1559
2164
  );
1560
2165
  await this.driver.exec(