@tanstack/db-sqlite-persistence-core 0.2.23 → 0.4.1

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