@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
@@ -8,13 +8,22 @@ import {
8
8
  InvalidPersistedStorageKeyEncodingError,
9
9
  } from './errors'
10
10
  import {
11
+ SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY,
11
12
  createPersistedTableName,
12
13
  decodePersistedStorageKey,
13
14
  encodePersistedStorageKey,
14
15
  } from './persisted'
16
+ import {
17
+ PERSISTED_TYPE_TAG,
18
+ PERSISTED_VALUE_TAG,
19
+ assertSQLiteBigIntInRange,
20
+ serializeSQLiteBigInt,
21
+ } from './sqlite-value'
15
22
  import type { LoadSubsetOptions } from '@tanstack/db'
16
23
  import type {
24
+ HydrationPersistenceAdapter,
17
25
  PersistedIndexSpec,
26
+ PersistedKeySetEvidence,
18
27
  PersistedRowScanOptions,
19
28
  PersistedScannedRow,
20
29
  PersistedTx,
@@ -25,6 +34,10 @@ import type {
25
34
 
26
35
  type SqliteSupportedValue = null | number | string
27
36
 
37
+ // The default stays below SQLite's older 999-variable limit. Drivers with a
38
+ // lower binding cap use smaller chunks; each replacement row binds four values.
39
+ const REPLACEMENT_BATCH_SIZE = 100
40
+
28
41
  type CollectionTableMapping = {
29
42
  tableName: string
30
43
  tombstoneTableName: string
@@ -37,6 +50,8 @@ type CompiledSqlFragment = {
37
50
  valueKind?: CompiledValueKind
38
51
  }
39
52
 
53
+ type SqlExpressionCompilationContext = `predicate` | `index-expression`
54
+
40
55
  type StoredSqliteRow = {
41
56
  key: string
42
57
  value: string
@@ -71,6 +86,159 @@ export type SQLitePullSinceResult<TKey extends string | number> =
71
86
  deltas: Array<ReplayableTxDelta<Record<string, unknown>, TKey>>
72
87
  }
73
88
 
89
+ type ScheduledOperationKind = `regular` | `hydrate`
90
+
91
+ type ScheduledOperation<T> = {
92
+ kind: ScheduledOperationKind
93
+ task: () => Promise<T>
94
+ resolve: (value: T) => void
95
+ reject: (error: unknown) => void
96
+ }
97
+
98
+ class SharedPersistenceScheduler {
99
+ private readonly regularQueue: Array<ScheduledOperation<unknown>> = []
100
+ private readonly hydrateQueue: Array<ScheduledOperation<unknown>> = []
101
+ private running = false
102
+ private lastCompletedKind: ScheduledOperationKind | undefined
103
+
104
+ runRegular<T>(task: () => Promise<T>): Promise<T> {
105
+ return this.enqueue(`regular`, task)
106
+ }
107
+
108
+ runHydrate<T>(task: () => Promise<T>): Promise<T> {
109
+ return this.enqueue(`hydrate`, task)
110
+ }
111
+
112
+ adoptRunningHydrate(completion: Promise<unknown>): void {
113
+ if (this.running) return
114
+ this.running = true
115
+ const finish = () => {
116
+ this.lastCompletedKind = `hydrate`
117
+ this.running = false
118
+ this.drain()
119
+ }
120
+ void completion.then(finish, finish)
121
+ }
122
+
123
+ private enqueue<T>(
124
+ kind: ScheduledOperationKind,
125
+ task: () => Promise<T>,
126
+ ): Promise<T> {
127
+ const result = new Promise<T>((resolve, reject) => {
128
+ const operation: ScheduledOperation<T> = {
129
+ kind,
130
+ task,
131
+ resolve,
132
+ reject,
133
+ }
134
+ const queue = kind === `hydrate` ? this.hydrateQueue : this.regularQueue
135
+ queue.push(operation as ScheduledOperation<unknown>)
136
+ })
137
+ this.drain()
138
+ return result
139
+ }
140
+
141
+ private drain(): void {
142
+ if (this.running) return
143
+
144
+ const operation = this.takeNext()
145
+ if (!operation) return
146
+
147
+ this.running = true
148
+ void this.execute(operation)
149
+ }
150
+
151
+ private async execute(operation: ScheduledOperation<unknown>): Promise<void> {
152
+ try {
153
+ operation.resolve(await operation.task())
154
+ } catch (error) {
155
+ operation.reject(error)
156
+ } finally {
157
+ this.lastCompletedKind = operation.kind
158
+ this.running = false
159
+ this.drain()
160
+ }
161
+ }
162
+
163
+ private takeNext(): ScheduledOperation<unknown> | undefined {
164
+ // Hydrates get priority after the currently running non-preemptible unit.
165
+ // While both lanes remain queued, alternate one regular operation after
166
+ // each hydrate (K=1), preserving FIFO order within each lane.
167
+ if (this.hydrateQueue.length > 0) {
168
+ if (
169
+ this.regularQueue.length > 0 &&
170
+ this.lastCompletedKind === `hydrate`
171
+ ) {
172
+ return this.regularQueue.shift()
173
+ }
174
+ return this.hydrateQueue.shift()
175
+ }
176
+ return this.regularQueue.shift()
177
+ }
178
+ }
179
+
180
+ const sharedPersistenceSchedulers = new WeakMap<
181
+ object,
182
+ SharedPersistenceScheduler
183
+ >()
184
+ const observedDriverSchedulingKeys = new WeakMap<object, object>()
185
+
186
+ function getSharedPersistenceScheduler(
187
+ key: object,
188
+ ): SharedPersistenceScheduler {
189
+ let scheduler = sharedPersistenceSchedulers.get(key)
190
+ if (!scheduler) {
191
+ scheduler = new SharedPersistenceScheduler()
192
+ sharedPersistenceSchedulers.set(key, scheduler)
193
+ }
194
+ return scheduler
195
+ }
196
+
197
+ function getSharedLogicalSchedulingKey(value: unknown): object | undefined {
198
+ if ((typeof value !== `object` && typeof value !== `function`) || !value) {
199
+ return undefined
200
+ }
201
+ const key = (
202
+ value as {
203
+ [SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY]?: unknown
204
+ }
205
+ )[SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY]
206
+ return key !== null && (typeof key === `object` || typeof key === `function`)
207
+ ? key
208
+ : undefined
209
+ }
210
+
211
+ function observeSharedLogicalSchedulingSupport(
212
+ driver: SQLiteDriver,
213
+ onSupport: (key: object) => void,
214
+ ): SQLiteDriver {
215
+ let observationPending = true
216
+ const observe = <T>(promise: Promise<T>): Promise<T> => {
217
+ if (!observationPending) return promise
218
+ observationPending = false
219
+ const key = getSharedLogicalSchedulingKey(promise)
220
+ if (key) onSupport(key)
221
+ return promise
222
+ }
223
+
224
+ return {
225
+ exec: (sql) => observe(driver.exec(sql)),
226
+ query: <T>(sql: string, params: ReadonlyArray<unknown> = []) =>
227
+ observe(driver.query<T>(sql, params)),
228
+ run: (sql, params = []) => observe(driver.run(sql, params)),
229
+ transaction: <T>(fn: (transactionDriver: SQLiteDriver) => Promise<T>) =>
230
+ observe(driver.transaction(fn)),
231
+ transactionWithDriver: <T>(
232
+ fn: (transactionDriver: SQLiteDriver) => Promise<T>,
233
+ ) =>
234
+ observe(
235
+ driver.transactionWithDriver
236
+ ? driver.transactionWithDriver(fn)
237
+ : driver.transaction(fn),
238
+ ),
239
+ }
240
+ }
241
+
74
242
  const DEFAULT_SCHEMA_VERSION = 1
75
243
  const DEFAULT_PULL_SINCE_RELOAD_THRESHOLD = 128
76
244
 
@@ -91,9 +259,6 @@ export const DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS = 24 * 60 * 60
91
259
  const SQLITE_MAX_IN_BATCH_SIZE = 900
92
260
  const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
93
261
  const FORBIDDEN_SQL_FRAGMENT_PATTERN = /(;|--|\/\*)/
94
- const PERSISTED_TYPE_TAG = `__tanstack_db_persisted_type__`
95
- const PERSISTED_VALUE_TAG = `value`
96
-
97
262
  type CompiledValueKind = `unknown` | `bigint` | `date` | `datetime`
98
263
  type PersistedTaggedValueType =
99
264
  | `bigint`
@@ -121,17 +286,17 @@ function isDuplicateColumnAddError(
121
286
  error: unknown,
122
287
  columnName: string,
123
288
  ): boolean {
124
- if (!(error instanceof Error)) {
289
+ if (typeof error !== `string` && !(error instanceof Error)) {
125
290
  return false
126
291
  }
127
292
 
128
- const message = error.message.toLowerCase()
129
- const normalizedColumnName = columnName.toLowerCase()
293
+ const normalizedMessage = (
294
+ typeof error === `string` ? error : error.message
295
+ ).toLowerCase()
130
296
  return (
131
- (message.includes(`duplicate column name`) &&
132
- message.includes(normalizedColumnName)) ||
133
- (message.includes(`already exists`) &&
134
- message.includes(normalizedColumnName))
297
+ normalizedMessage.includes(columnName.toLowerCase()) &&
298
+ (normalizedMessage.includes(`duplicate column name`) ||
299
+ normalizedMessage.includes(`already exists`))
135
300
  )
136
301
  }
137
302
 
@@ -171,10 +336,7 @@ function encodePersistedJsonValue(value: unknown): unknown {
171
336
  }
172
337
 
173
338
  if (typeof value === `bigint`) {
174
- return {
175
- [PERSISTED_TYPE_TAG]: `bigint`,
176
- [PERSISTED_VALUE_TAG]: value.toString(),
177
- } satisfies PersistedTaggedValue
339
+ return serializeSQLiteBigInt(value) satisfies PersistedTaggedValue
178
340
  }
179
341
 
180
342
  if (value instanceof Date) {
@@ -287,6 +449,7 @@ function toSqliteParameterValue(value: unknown): SqliteSupportedValue {
287
449
  }
288
450
 
289
451
  if (typeof value === `bigint`) {
452
+ assertSQLiteBigIntInRange(value)
290
453
  return value.toString()
291
454
  }
292
455
 
@@ -317,79 +480,15 @@ function toSqliteLiteral(value: SqliteSupportedValue): string {
317
480
  return `'${value.replace(/'/g, `''`)}'`
318
481
  }
319
482
 
320
- function inlineSqlParams(
321
- sql: string,
322
- params: ReadonlyArray<SqliteSupportedValue>,
323
- ): string {
324
- let index = 0
325
- const inlinedSql = sql.replace(/\?/g, () => {
326
- const paramValue = params[index]
327
- index++
328
- return toSqliteLiteral(paramValue ?? null)
329
- })
330
-
331
- if (index !== params.length) {
332
- throw new InvalidPersistedCollectionConfigError(
333
- `Unable to inline SQL params; placeholder count did not match provided params`,
334
- )
483
+ function toSqliteExpressionLiteral(value: unknown): string {
484
+ if (typeof value === `bigint`) {
485
+ return assertSQLiteBigIntInRange(value).toString()
335
486
  }
336
-
337
- return inlinedSql
487
+ return toSqliteLiteral(toSqliteParameterValue(value))
338
488
  }
339
489
 
340
490
  type CompiledRowExpressionEvaluator = (row: Record<string, unknown>) => unknown
341
491
 
342
- function collectAliasQualifiedRefSegments(
343
- expression: IR.BasicExpression,
344
- segments: Set<string> = new Set<string>(),
345
- ): Set<string> {
346
- if (expression.type === `ref`) {
347
- if (expression.path.length > 1) {
348
- const rootSegment = String(expression.path[0])
349
- if (rootSegment.length > 0) {
350
- segments.add(rootSegment)
351
- }
352
- }
353
- return segments
354
- }
355
-
356
- if (expression.type === `func`) {
357
- for (const arg of expression.args) {
358
- collectAliasQualifiedRefSegments(arg, segments)
359
- }
360
- }
361
-
362
- return segments
363
- }
364
-
365
- function createAliasAwareRowProxy(
366
- row: Record<string, unknown>,
367
- aliasSegments: ReadonlySet<string>,
368
- ): Record<string, unknown> {
369
- return new Proxy(row, {
370
- get(target, prop, receiver) {
371
- if (typeof prop !== `string`) {
372
- return Reflect.get(target, prop, receiver)
373
- }
374
-
375
- if (Object.prototype.hasOwnProperty.call(target, prop)) {
376
- const value = Reflect.get(target, prop, receiver)
377
- if (value !== undefined || !aliasSegments.has(prop)) {
378
- return value
379
- }
380
-
381
- return target
382
- }
383
-
384
- if (aliasSegments.has(prop)) {
385
- return target
386
- }
387
-
388
- return undefined
389
- },
390
- })
391
- }
392
-
393
492
  function compileRowExpressionEvaluator(
394
493
  expression: IR.BasicExpression,
395
494
  ): CompiledRowExpressionEvaluator {
@@ -401,24 +500,7 @@ function compileRowExpressionEvaluator(
401
500
  `Unsupported expression for SQLite adapter fallback evaluator: ${(error as Error).message}`,
402
501
  )
403
502
  }
404
-
405
- const aliasSegments = collectAliasQualifiedRefSegments(expression)
406
- if (aliasSegments.size === 0) {
407
- return (row) => baseEvaluator(row)
408
- }
409
-
410
- const proxyCache = new WeakMap<
411
- Record<string, unknown>,
412
- Record<string, unknown>
413
- >()
414
- return (row) => {
415
- let proxy = proxyCache.get(row)
416
- if (!proxy) {
417
- proxy = createAliasAwareRowProxy(row, aliasSegments)
418
- proxyCache.set(row, proxy)
419
- }
420
- return baseEvaluator(proxy)
421
- }
503
+ return baseEvaluator
422
504
  }
423
505
 
424
506
  function getOrderByObjectId(value: object): number {
@@ -449,6 +531,9 @@ function compareOrderByValues(
449
531
  }
450
532
 
451
533
  if (typeof left === `string` && typeof right === `string`) {
534
+ if (compareOptions.stringSort === `custom`) {
535
+ return compareOptions.compare(left, right)
536
+ }
452
537
  if (compareOptions.stringSort === `locale`) {
453
538
  return left.localeCompare(
454
539
  right,
@@ -575,37 +660,50 @@ function resolveComparisonValueKind(
575
660
 
576
661
  function compileComparisonSql(
577
662
  operator: `=` | `>` | `>=` | `<` | `<=`,
663
+ leftExpression: IR.BasicExpression,
664
+ rightExpression: IR.BasicExpression,
578
665
  leftSql: string,
579
666
  rightSql: string,
580
667
  valueKind: CompiledValueKind,
668
+ leftKind: CompiledValueKind,
669
+ rightKind: CompiledValueKind,
581
670
  ): string {
582
- if (valueKind === `bigint`) {
583
- return `(CAST(${leftSql} AS NUMERIC) ${operator} CAST(${rightSql} AS NUMERIC))`
584
- }
585
- if (valueKind === `date`) {
586
- return `(date(${leftSql}) ${operator} date(${rightSql}))`
587
- }
588
- if (valueKind === `datetime`) {
589
- return `(datetime(${leftSql}) ${operator} datetime(${rightSql}))`
671
+ const compileOperand = (
672
+ expression: IR.BasicExpression,
673
+ sql: string,
674
+ otherKind: CompiledValueKind,
675
+ ): string => {
676
+ if (expression.type !== `val`) return sql
677
+ if (valueKind === `date` && otherKind === `date`) return `date(${sql})`
678
+ if (valueKind === `datetime` && otherKind === `datetime`) {
679
+ return `datetime(${sql})`
680
+ }
681
+ return sql
590
682
  }
591
- return `(${leftSql} ${operator} ${rightSql})`
683
+
684
+ return `(${compileOperand(leftExpression, leftSql, rightKind)} ${operator} ${compileOperand(rightExpression, rightSql, leftKind)})`
592
685
  }
593
686
 
594
687
  function compileRefExpressionSql(jsonPath: string): CompiledSqlFragment {
595
688
  const typePath = `${jsonPath}.${PERSISTED_TYPE_TAG}`
596
689
  const taggedValuePath = `${jsonPath}.${PERSISTED_VALUE_TAG}`
690
+ // createJsonPath has already validated every segment. Keep these paths as
691
+ // canonical SQL literals so runtime refs match persisted expression indexes.
692
+ const typePathSql = toSqliteLiteral(typePath)
693
+ const taggedValuePathSql = toSqliteLiteral(taggedValuePath)
694
+ const jsonPathSql = toSqliteLiteral(jsonPath)
597
695
 
598
696
  return {
599
697
  supported: true,
600
- sql: `(CASE json_extract(value, ?)
601
- WHEN 'bigint' THEN CAST(json_extract(value, ?) AS NUMERIC)
602
- WHEN 'date' THEN json_extract(value, ?)
698
+ sql: `(CASE json_extract(value, ${typePathSql})
699
+ WHEN 'bigint' THEN CAST(json_extract(value, ${taggedValuePathSql}) AS NUMERIC)
700
+ WHEN 'date' THEN json_extract(value, ${taggedValuePathSql})
603
701
  WHEN 'nan' THEN NULL
604
702
  WHEN 'infinity' THEN NULL
605
703
  WHEN '-infinity' THEN NULL
606
- ELSE json_extract(value, ?)
704
+ ELSE json_extract(value, ${jsonPathSql})
607
705
  END)`,
608
- params: [typePath, taggedValuePath, taggedValuePath, jsonPath],
706
+ params: [],
609
707
  valueKind: `unknown`,
610
708
  }
611
709
  }
@@ -656,21 +754,64 @@ function stableStringify(value: unknown): string {
656
754
  return serializePersistedRowValue(value)
657
755
  }
658
756
 
757
+ function argumentCompilationContext(
758
+ parentName: string,
759
+ argumentIndex: number,
760
+ argument: IR.BasicExpression,
761
+ parentContext: SqlExpressionCompilationContext,
762
+ ): SqlExpressionCompilationContext {
763
+ if (parentContext === `index-expression`) return `index-expression`
764
+
765
+ switch (parentName) {
766
+ case `and`:
767
+ case `or`:
768
+ case `not`:
769
+ return `predicate`
770
+ case `eq`:
771
+ case `gt`:
772
+ case `gte`:
773
+ case `lt`:
774
+ case `lte`:
775
+ case `like`:
776
+ case `ilike`:
777
+ if (argument.type !== `val`) return `index-expression`
778
+ return typeof argument.value === `bigint`
779
+ ? `index-expression`
780
+ : `predicate`
781
+ case `in`:
782
+ return argumentIndex === 0 ? `index-expression` : `predicate`
783
+ case `isNull`:
784
+ case `isUndefined`:
785
+ return `index-expression`
786
+ default:
787
+ return `index-expression`
788
+ }
789
+ }
790
+
659
791
  function compileSqlExpression(
660
792
  expression: IR.BasicExpression,
793
+ context: SqlExpressionCompilationContext = `predicate`,
661
794
  ): CompiledSqlFragment {
662
795
  if (expression.type === `val`) {
663
796
  const valueKind = getLiteralValueKind(expression.value)
664
797
  return {
665
798
  supported: true,
666
- sql: `?`,
667
- params: [toSqliteParameterValue(expression.value)],
799
+ sql:
800
+ context === `index-expression`
801
+ ? toSqliteExpressionLiteral(expression.value)
802
+ : `?`,
803
+ params:
804
+ context === `predicate`
805
+ ? [toSqliteParameterValue(expression.value)]
806
+ : [],
668
807
  valueKind,
669
808
  }
670
809
  }
671
810
 
672
811
  if (expression.type === `ref`) {
673
- const jsonPath = createJsonPath(expression.path.map(String))
812
+ const jsonPath = createJsonPath(
813
+ IR.getPropRefPropertyPath(expression).map(String),
814
+ )
674
815
  if (!jsonPath) {
675
816
  return {
676
817
  supported: false,
@@ -682,7 +823,12 @@ function compileSqlExpression(
682
823
  return compileRefExpressionSql(jsonPath)
683
824
  }
684
825
 
685
- const compiledArgs = expression.args.map((arg) => compileSqlExpression(arg))
826
+ const compiledArgs = expression.args.map((arg, index) =>
827
+ compileSqlExpression(
828
+ arg,
829
+ argumentCompilationContext(expression.name, index, arg, context),
830
+ ),
831
+ )
686
832
  if (compiledArgs.some((arg) => !arg.supported)) {
687
833
  return {
688
834
  supported: false,
@@ -731,9 +877,13 @@ function compileSqlExpression(
731
877
  supported: true,
732
878
  sql: compileComparisonSql(
733
879
  operatorByName[expression.name],
880
+ expression.args[0]!,
881
+ expression.args[1]!,
734
882
  argSql[0],
735
883
  argSql[1],
736
884
  valueKind,
885
+ getCompiledValueKind(compiledArgs[0]),
886
+ getCompiledValueKind(compiledArgs[1]),
737
887
  ),
738
888
  params,
739
889
  }
@@ -788,13 +938,17 @@ function compileSqlExpression(
788
938
  return { supported: false, sql: ``, params: [] }
789
939
  }
790
940
 
941
+ if (context === `index-expression`) {
942
+ return {
943
+ supported: true,
944
+ sql: `(${leftSql} IN (${listValue
945
+ .map((value) => toSqliteExpressionLiteral(value))
946
+ .join(`, `)}))`,
947
+ params: leftParams,
948
+ }
949
+ }
950
+
791
951
  if (listValue.length > SQLITE_MAX_IN_BATCH_SIZE) {
792
- const hasBigIntValues = listValue.some(
793
- (value) => typeof value === `bigint`,
794
- )
795
- const inLeftSql = hasBigIntValues
796
- ? `CAST(${leftSql} AS NUMERIC)`
797
- : leftSql
798
952
  const chunkClauses: Array<string> = []
799
953
  const batchedParams: Array<SqliteSupportedValue> = []
800
954
 
@@ -807,13 +961,13 @@ function compileSqlExpression(
807
961
  startIndex,
808
962
  startIndex + SQLITE_MAX_IN_BATCH_SIZE,
809
963
  )
810
- chunkClauses.push(
811
- `(${inLeftSql} IN (${chunkValues.map(() => `?`).join(`, `)}))`,
812
- )
813
- batchedParams.push(...leftParams)
814
- batchedParams.push(
815
- ...chunkValues.map((value) => toSqliteParameterValue(value)),
816
- )
964
+ const chunkParams: Array<SqliteSupportedValue> = []
965
+ const chunkValueSql = chunkValues.map((value) => {
966
+ chunkParams.push(toSqliteParameterValue(value))
967
+ return typeof value === `bigint` ? `CAST(? AS NUMERIC)` : `?`
968
+ })
969
+ chunkClauses.push(`(${leftSql} IN (${chunkValueSql.join(`, `)}))`)
970
+ batchedParams.push(...leftParams, ...chunkParams)
817
971
  }
818
972
 
819
973
  return {
@@ -823,20 +977,15 @@ function compileSqlExpression(
823
977
  }
824
978
  }
825
979
 
826
- const hasBigIntValues = listValue.some(
827
- (value) => typeof value === `bigint`,
828
- )
829
- const inLeftSql = hasBigIntValues
830
- ? `CAST(${leftSql} AS NUMERIC)`
831
- : leftSql
832
- const listPlaceholders = listValue.map(() => `?`).join(`, `)
980
+ const listParams: Array<SqliteSupportedValue> = []
981
+ const listValueSql = listValue.map((value) => {
982
+ listParams.push(toSqliteParameterValue(value))
983
+ return typeof value === `bigint` ? `CAST(? AS NUMERIC)` : `?`
984
+ })
833
985
  return {
834
986
  supported: true,
835
- sql: `(${inLeftSql} IN (${listPlaceholders}))`,
836
- params: [
837
- ...leftParams,
838
- ...listValue.map((value) => toSqliteParameterValue(value)),
839
- ],
987
+ sql: `(${leftSql} IN (${listValueSql.join(`, `)}))`,
988
+ params: [...leftParams, ...listParams],
840
989
  }
841
990
  }
842
991
  case `like`:
@@ -916,7 +1065,10 @@ function compileOrderByClauses(
916
1065
  const params: Array<SqliteSupportedValue> = []
917
1066
 
918
1067
  for (const clause of orderBy) {
919
- const compiledExpression = compileSqlExpression(clause.expression)
1068
+ const compiledExpression = compileSqlExpression(
1069
+ clause.expression,
1070
+ `index-expression`,
1071
+ )
920
1072
  if (!compiledExpression.supported) {
921
1073
  return {
922
1074
  supported: false,
@@ -952,6 +1104,7 @@ function isExpressionLikeShape(value: unknown): value is IR.BasicExpression {
952
1104
  path?: unknown
953
1105
  name?: unknown
954
1106
  args?: unknown
1107
+ sourceAlias?: unknown
955
1108
  }
956
1109
 
957
1110
  if (candidate.type === `val`) {
@@ -959,7 +1112,12 @@ function isExpressionLikeShape(value: unknown): value is IR.BasicExpression {
959
1112
  }
960
1113
 
961
1114
  if (candidate.type === `ref`) {
962
- return Array.isArray(candidate.path)
1115
+ return (
1116
+ Array.isArray(candidate.path) &&
1117
+ (candidate.sourceAlias === undefined ||
1118
+ (typeof candidate.sourceAlias === `string` &&
1119
+ candidate.path[0] === candidate.sourceAlias))
1120
+ )
963
1121
  }
964
1122
 
965
1123
  if (candidate.type === `func`) {
@@ -989,14 +1147,22 @@ function normalizeIndexSqlFragment(fragment: string): string {
989
1147
  // Non-JSON strings are treated as raw SQL fragments below.
990
1148
  }
991
1149
 
992
- if (hasParsedJson && isExpressionLikeShape(parsedJson)) {
993
- const compiled = compileSqlExpression(parsedJson)
1150
+ const decodedJson = hasParsedJson
1151
+ ? decodePersistedJsonValue(parsedJson)
1152
+ : undefined
1153
+ if (hasParsedJson && isExpressionLikeShape(decodedJson)) {
1154
+ const compiled = compileSqlExpression(decodedJson, `index-expression`)
994
1155
  if (!compiled.supported) {
995
1156
  throw new InvalidPersistedCollectionConfigError(
996
1157
  `Persisted index expression is not supported by the SQLite compiler`,
997
1158
  )
998
1159
  }
999
- return inlineSqlParams(compiled.sql, compiled.params)
1160
+ if (compiled.params.length !== 0) {
1161
+ throw new InvalidPersistedCollectionConfigError(
1162
+ `Persisted index expression cannot contain bound parameters`,
1163
+ )
1164
+ }
1165
+ return compiled.sql
1000
1166
  }
1001
1167
 
1002
1168
  return sanitizeExpressionSqlFragment(fragment)
@@ -1023,11 +1189,16 @@ function buildIndexName(collectionId: string, signature: string): string {
1023
1189
 
1024
1190
  export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1025
1191
  private readonly driver: SQLiteDriver
1192
+ private readonly schedulingIdentitySource: SQLiteDriver
1193
+ private scheduler: SharedPersistenceScheduler | undefined
1194
+ private activeUnscheduledHydration: Promise<unknown> | undefined
1195
+ private readonly hydrationAdapter: HydrationPersistenceAdapter
1026
1196
  private readonly schemaVersion: number
1027
1197
  private readonly schemaMismatchPolicy: SQLiteCoreAdapterSchemaMismatchPolicy
1028
1198
  private readonly appliedTxPruneMaxRows: number | undefined
1029
1199
  private readonly appliedTxPruneMaxAgeSeconds: number | undefined
1030
1200
  private readonly pullSinceReloadThreshold: number
1201
+ private readonly replacementBatchSize: number
1031
1202
 
1032
1203
  private initialized = false
1033
1204
  private readonly collectionTableCache = new Map<
@@ -1040,6 +1211,20 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1040
1211
  >()
1041
1212
 
1042
1213
  constructor(options: SQLiteCoreAdapterOptions) {
1214
+ const maxBoundParameters = options.driver.maxBoundParameters
1215
+ if (
1216
+ maxBoundParameters !== undefined &&
1217
+ (!Number.isInteger(maxBoundParameters) || maxBoundParameters < 4)
1218
+ ) {
1219
+ throw new InvalidPersistedCollectionConfigError(
1220
+ `SQLite driver maxBoundParameters must be an integer of at least 4`,
1221
+ )
1222
+ }
1223
+ this.replacementBatchSize =
1224
+ maxBoundParameters === undefined
1225
+ ? REPLACEMENT_BATCH_SIZE
1226
+ : Math.min(REPLACEMENT_BATCH_SIZE, Math.floor(maxBoundParameters / 4))
1227
+
1043
1228
  const schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION
1044
1229
  if (!Number.isInteger(schemaVersion) || schemaVersion < 0) {
1045
1230
  throw new InvalidPersistedCollectionConfigError(
@@ -1078,13 +1263,93 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1078
1263
  )
1079
1264
  }
1080
1265
 
1081
- this.driver = options.driver
1266
+ this.schedulingIdentitySource = options.driver
1267
+ const schedulingKey =
1268
+ getSharedLogicalSchedulingKey(options.driver) ??
1269
+ observedDriverSchedulingKeys.get(options.driver)
1270
+ this.scheduler = schedulingKey
1271
+ ? getSharedPersistenceScheduler(schedulingKey)
1272
+ : undefined
1273
+ this.driver = schedulingKey
1274
+ ? options.driver
1275
+ : observeSharedLogicalSchedulingSupport(options.driver, (key) => {
1276
+ observedDriverSchedulingKeys.set(options.driver, key)
1277
+ const scheduler = getSharedPersistenceScheduler(key)
1278
+ this.scheduler ??= scheduler
1279
+ if (this.activeUnscheduledHydration) {
1280
+ scheduler.adoptRunningHydrate(this.activeUnscheduledHydration)
1281
+ }
1282
+ })
1082
1283
  this.schemaVersion = schemaVersion
1083
1284
  this.schemaMismatchPolicy =
1084
1285
  options.schemaMismatchPolicy ?? `sync-present-reset`
1085
1286
  this.appliedTxPruneMaxRows = options.appliedTxPruneMaxRows
1086
1287
  this.appliedTxPruneMaxAgeSeconds = options.appliedTxPruneMaxAgeSeconds
1087
1288
  this.pullSinceReloadThreshold = pullSinceReloadThreshold
1289
+ this.hydrationAdapter = {
1290
+ loadSubset: (collectionId, loadOptions, context) =>
1291
+ this.loadSubsetUnscheduled(collectionId, loadOptions, context),
1292
+ loadResumeSnapshot: (collectionId, context) =>
1293
+ this.loadResumeSnapshotUnscheduled(collectionId, context),
1294
+ applyCommittedTx: (collectionId, tx) =>
1295
+ this.applyCommittedTxUnscheduled(collectionId, tx),
1296
+ loadCollectionMetadata: (collectionId) =>
1297
+ this.loadCollectionMetadataUnscheduled(collectionId),
1298
+ scanRows: (collectionId, scanOptions) =>
1299
+ this.scanRowsUnscheduled(collectionId, scanOptions),
1300
+ ensureIndex: (collectionId, signature, spec) =>
1301
+ this.ensureIndexUnscheduled(collectionId, signature, spec),
1302
+ markIndexRemoved: (collectionId, signature) =>
1303
+ this.markIndexRemovedUnscheduled(collectionId, signature),
1304
+ getStreamPosition: (collectionId) =>
1305
+ this.getStreamPositionUnscheduled(collectionId),
1306
+ pullSince: (collectionId, fromRowVersion) =>
1307
+ this.pullSinceUnscheduled(collectionId, fromRowVersion),
1308
+ runInHydrationScope: async (task) => task(this.hydrationAdapter),
1309
+ }
1310
+ }
1311
+
1312
+ runInHydrationScope<T>(
1313
+ task: (adapter: HydrationPersistenceAdapter) => Promise<T>,
1314
+ ): Promise<T> {
1315
+ const scheduler = this.resolveScheduler()
1316
+ if (scheduler) {
1317
+ return scheduler.runHydrate(() => task(this.hydrationAdapter))
1318
+ }
1319
+
1320
+ const hydration = Promise.resolve().then(() => task(this.hydrationAdapter))
1321
+ this.activeUnscheduledHydration = hydration
1322
+ const clear = () => {
1323
+ if (this.activeUnscheduledHydration === hydration) {
1324
+ this.activeUnscheduledHydration = undefined
1325
+ }
1326
+ }
1327
+ void hydration.then(clear, clear)
1328
+ return hydration
1329
+ }
1330
+
1331
+ runInRegularScope<T>(
1332
+ task: (adapter: HydrationPersistenceAdapter) => Promise<T>,
1333
+ ): Promise<T> {
1334
+ return this.runRegular(() => task(this.hydrationAdapter))
1335
+ }
1336
+
1337
+ isHydrationScopeScheduled(): boolean {
1338
+ return this.resolveScheduler() !== undefined
1339
+ }
1340
+
1341
+ private runRegular<T>(task: () => Promise<T>): Promise<T> {
1342
+ const scheduler = this.resolveScheduler()
1343
+ return scheduler ? scheduler.runRegular(task) : task()
1344
+ }
1345
+
1346
+ private resolveScheduler(): SharedPersistenceScheduler | undefined {
1347
+ if (this.scheduler) return this.scheduler
1348
+ const key = observedDriverSchedulingKeys.get(this.schedulingIdentitySource)
1349
+ if (key) {
1350
+ this.scheduler = getSharedPersistenceScheduler(key)
1351
+ }
1352
+ return this.scheduler
1088
1353
  }
1089
1354
 
1090
1355
  private runInTransaction<TResult>(
@@ -1097,7 +1362,45 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1097
1362
  return this.driver.transaction(fn)
1098
1363
  }
1099
1364
 
1100
- async loadSubset(
1365
+ private async assertCurrentSchemaVersion(
1366
+ collectionId: string,
1367
+ driver: SQLiteDriver,
1368
+ operation: string,
1369
+ ): Promise<void> {
1370
+ const schemaRows = await driver.query<{ schema_version: number }>(
1371
+ `SELECT schema_version
1372
+ FROM collection_registry
1373
+ WHERE collection_id = ?
1374
+ LIMIT 1`,
1375
+ [collectionId],
1376
+ )
1377
+ const persistedSchemaVersion = schemaRows[0]?.schema_version
1378
+ if (persistedSchemaVersion !== this.schemaVersion) {
1379
+ throw new InvalidPersistedCollectionConfigError(
1380
+ `Schema version mismatch for collection "${collectionId}": ` +
1381
+ `found ${persistedSchemaVersion ?? `missing`}, expected ${this.schemaVersion}. ` +
1382
+ `Refusing to ${operation} through a stale cached adapter.`,
1383
+ )
1384
+ }
1385
+ }
1386
+
1387
+ loadSubset(
1388
+ collectionId: string,
1389
+ options: LoadSubsetOptions,
1390
+ ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
1391
+ ): Promise<
1392
+ Array<{
1393
+ key: string | number
1394
+ value: Record<string, unknown>
1395
+ metadata?: unknown
1396
+ }>
1397
+ > {
1398
+ return this.runRegular(() =>
1399
+ this.loadSubsetUnscheduled(collectionId, options, ctx),
1400
+ )
1401
+ }
1402
+
1403
+ private async loadSubsetUnscheduled(
1101
1404
  collectionId: string,
1102
1405
  options: LoadSubsetOptions,
1103
1406
  ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
@@ -1109,86 +1412,239 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1109
1412
  }>
1110
1413
  > {
1111
1414
  const tableMapping = await this.ensureCollectionReady(collectionId)
1112
- await this.touchRequiredIndexes(collectionId, ctx?.requiredIndexSignatures)
1113
-
1114
- if (options.cursor) {
1115
- const whereCurrentOptions: LoadSubsetOptions = {
1116
- where: options.where
1117
- ? new IR.Func(`and`, [options.where, options.cursor.whereCurrent])
1118
- : options.cursor.whereCurrent,
1119
- orderBy: options.orderBy,
1120
- }
1121
- const whereFromOptions: LoadSubsetOptions = {
1122
- where: options.where
1123
- ? new IR.Func(`and`, [options.where, options.cursor.whereFrom])
1124
- : options.cursor.whereFrom,
1125
- orderBy: options.orderBy,
1126
- limit: options.limit,
1127
- }
1415
+ return this.runInTransaction(async (transactionDriver) => {
1416
+ await this.assertCurrentSchemaVersion(
1417
+ collectionId,
1418
+ transactionDriver,
1419
+ `load persisted rows`,
1420
+ )
1421
+ await this.touchRequiredIndexes(
1422
+ collectionId,
1423
+ ctx?.requiredIndexSignatures,
1424
+ transactionDriver,
1425
+ )
1128
1426
 
1129
- const [whereCurrentRows, whereFromRows] = await Promise.all([
1130
- this.loadSubsetInternal(tableMapping, whereCurrentOptions),
1131
- this.loadSubsetInternal(tableMapping, whereFromOptions),
1132
- ])
1427
+ if (options.cursor) {
1428
+ const whereCurrentOptions: LoadSubsetOptions = {
1429
+ where: options.where
1430
+ ? new IR.Func(`and`, [options.where, options.cursor.whereCurrent])
1431
+ : options.cursor.whereCurrent,
1432
+ orderBy: options.orderBy,
1433
+ }
1434
+ const whereFromOptions: LoadSubsetOptions = {
1435
+ where: options.where
1436
+ ? new IR.Func(`and`, [options.where, options.cursor.whereFrom])
1437
+ : options.cursor.whereFrom,
1438
+ orderBy: options.orderBy,
1439
+ limit: options.limit,
1440
+ }
1441
+
1442
+ const [whereCurrentRows, whereFromRows] = await Promise.all([
1443
+ this.loadSubsetInternal(
1444
+ tableMapping,
1445
+ whereCurrentOptions,
1446
+ transactionDriver,
1447
+ ),
1448
+ this.loadSubsetInternal(
1449
+ tableMapping,
1450
+ whereFromOptions,
1451
+ transactionDriver,
1452
+ ),
1453
+ ])
1454
+
1455
+ const mergedRows = new Map<
1456
+ string,
1457
+ InMemoryRow<string | number, Record<string, unknown>>
1458
+ >()
1459
+ for (const row of [...whereCurrentRows, ...whereFromRows]) {
1460
+ mergedRows.set(encodePersistedStorageKey(row.key), row)
1461
+ }
1133
1462
 
1134
- const mergedRows = new Map<
1135
- string,
1136
- InMemoryRow<string | number, Record<string, unknown>>
1137
- >()
1138
- for (const row of [...whereCurrentRows, ...whereFromRows]) {
1139
- mergedRows.set(encodePersistedStorageKey(row.key), row)
1463
+ const orderedRows = this.applyInMemoryOrderBy(
1464
+ Array.from(mergedRows.values()),
1465
+ options.orderBy,
1466
+ )
1467
+
1468
+ return orderedRows.map((row) => ({
1469
+ key: row.key,
1470
+ value: row.value,
1471
+ metadata: row.metadata,
1472
+ }))
1140
1473
  }
1141
1474
 
1142
- const orderedRows = this.applyInMemoryOrderBy(
1143
- Array.from(mergedRows.values()),
1144
- options.orderBy,
1475
+ const rows = await this.loadSubsetInternal(
1476
+ tableMapping,
1477
+ options,
1478
+ transactionDriver,
1145
1479
  )
1146
-
1147
- return orderedRows.map((row) => ({
1480
+ return rows.map((row) => ({
1148
1481
  key: row.key,
1149
1482
  value: row.value,
1150
1483
  metadata: row.metadata,
1151
1484
  }))
1152
- }
1485
+ })
1486
+ }
1153
1487
 
1154
- const rows = await this.loadSubsetInternal(tableMapping, options)
1155
- return rows.map((row) => ({
1156
- key: row.key,
1157
- value: row.value,
1158
- metadata: row.metadata,
1159
- }))
1488
+ loadResumeSnapshot(
1489
+ collectionId: string,
1490
+ ctx?: {
1491
+ requiredIndexSignatures?: ReadonlyArray<string>
1492
+ includeRows?: boolean
1493
+ },
1494
+ ) {
1495
+ return this.runRegular(() =>
1496
+ this.loadResumeSnapshotUnscheduled(collectionId, ctx),
1497
+ )
1160
1498
  }
1161
1499
 
1162
- async applyCommittedTx(collectionId: string, tx: PersistedTx): Promise<void> {
1500
+ private async loadResumeSnapshotUnscheduled(
1501
+ collectionId: string,
1502
+ ctx?: {
1503
+ requiredIndexSignatures?: ReadonlyArray<string>
1504
+ includeRows?: boolean
1505
+ },
1506
+ ): Promise<{
1507
+ rows: Array<{
1508
+ key: string | number
1509
+ value: Record<string, unknown>
1510
+ metadata?: unknown
1511
+ }>
1512
+ keySet: PersistedKeySetEvidence
1513
+ collectionMetadata: Array<{ key: string; value: unknown }>
1514
+ latestTerm: number
1515
+ latestSeq: number
1516
+ latestRowVersion: number
1517
+ resetEpoch: number
1518
+ }> {
1163
1519
  const tableMapping = await this.ensureCollectionReady(collectionId)
1164
- const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1165
- const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
1520
+ const includeRows = ctx?.includeRows !== false
1166
1521
 
1167
- await this.runInTransaction(async (transactionDriver) => {
1168
- const alreadyApplied = await transactionDriver.query<{ applied: number }>(
1169
- `SELECT 1 AS applied
1170
- FROM applied_tx
1171
- WHERE collection_id = ? AND term = ? AND seq = ?
1522
+ return this.runInTransaction(async (transactionDriver) => {
1523
+ await this.assertCurrentSchemaVersion(
1524
+ collectionId,
1525
+ transactionDriver,
1526
+ `load a resume snapshot`,
1527
+ )
1528
+ if (includeRows) {
1529
+ await this.touchRequiredIndexes(
1530
+ collectionId,
1531
+ ctx?.requiredIndexSignatures,
1532
+ transactionDriver,
1533
+ )
1534
+ }
1535
+
1536
+ const rows = includeRows
1537
+ ? await this.loadSubsetInternal(tableMapping, {}, transactionDriver)
1538
+ : []
1539
+ const { latestRowVersion, keySet } = await this.readKeySetEvidence(
1540
+ collectionId,
1541
+ transactionDriver,
1542
+ )
1543
+ const collectionMetadataRows = await transactionDriver.query<{
1544
+ key: string
1545
+ value: string
1546
+ }>(
1547
+ `SELECT key, value
1548
+ FROM collection_metadata
1549
+ WHERE collection_id = ?`,
1550
+ [collectionId],
1551
+ )
1552
+ const { latestTerm, latestSeq } = await this.readStreamPosition(
1553
+ collectionId,
1554
+ transactionDriver,
1555
+ )
1556
+ const resetRows = await transactionDriver.query<{ reset_epoch: number }>(
1557
+ `SELECT reset_epoch
1558
+ FROM collection_reset_epoch
1559
+ WHERE collection_id = ?
1172
1560
  LIMIT 1`,
1173
- [collectionId, tx.term, tx.seq],
1561
+ [collectionId],
1174
1562
  )
1175
1563
 
1176
- if (alreadyApplied.length > 0) {
1177
- return
1564
+ return {
1565
+ rows: rows.map((row) => ({
1566
+ key: row.key,
1567
+ value: row.value,
1568
+ metadata: row.metadata,
1569
+ })),
1570
+ keySet,
1571
+ collectionMetadata: collectionMetadataRows.map((row) => ({
1572
+ key: row.key,
1573
+ value: deserializePersistedRowValue(row.value),
1574
+ })),
1575
+ latestTerm,
1576
+ latestSeq,
1577
+ latestRowVersion,
1578
+ resetEpoch: resetRows[0]?.reset_epoch ?? 0,
1178
1579
  }
1580
+ })
1581
+ }
1582
+
1583
+ applyCommittedTx(collectionId: string, tx: PersistedTx): Promise<void> {
1584
+ return this.runRegular(() =>
1585
+ this.applyCommittedTxUnscheduled(collectionId, tx),
1586
+ )
1587
+ }
1588
+
1589
+ private async applyCommittedTxUnscheduled(
1590
+ collectionId: string,
1591
+ tx: PersistedTx,
1592
+ ): Promise<void> {
1593
+ const tableMapping = await this.ensureCollectionReady(collectionId)
1594
+ const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1595
+ const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
1179
1596
 
1597
+ await this.runInTransaction(async (transactionDriver) => {
1180
1598
  const versionRows = await transactionDriver.query<{
1181
1599
  latest_row_version: number
1600
+ key_set_evidence_available: number
1601
+ schema_version: number
1602
+ already_applied: number
1182
1603
  }>(
1183
- `SELECT latest_row_version
1604
+ `SELECT
1605
+ latest_row_version,
1606
+ key_set_evidence_available,
1607
+ (
1608
+ SELECT schema_version
1609
+ FROM collection_registry
1610
+ WHERE collection_id = ?
1611
+ LIMIT 1
1612
+ ) AS schema_version,
1613
+ EXISTS (
1614
+ SELECT 1
1615
+ FROM applied_tx
1616
+ WHERE collection_id = ? AND term = ? AND seq = ?
1617
+ ) AS already_applied
1184
1618
  FROM collection_version
1185
1619
  WHERE collection_id = ?
1186
1620
  LIMIT 1`,
1187
- [collectionId],
1621
+ [collectionId, collectionId, tx.term, tx.seq, collectionId],
1188
1622
  )
1189
- const currentRowVersion = versionRows[0]?.latest_row_version ?? 0
1623
+ const version = versionRows[0]
1624
+
1625
+ if (!version) {
1626
+ throw new InvalidPersistedCollectionConfigError(
1627
+ `Missing persisted version state for collection "${collectionId}"`,
1628
+ )
1629
+ }
1630
+ if (version.schema_version !== this.schemaVersion) {
1631
+ throw new InvalidPersistedCollectionConfigError(
1632
+ `Schema version mismatch for collection "${collectionId}": ` +
1633
+ `found ${version.schema_version}, expected ${this.schemaVersion}. ` +
1634
+ `Refusing to apply a committed transaction through a stale cached adapter.`,
1635
+ )
1636
+ }
1637
+
1638
+ if (version.already_applied === 1) {
1639
+ return
1640
+ }
1641
+
1642
+ const currentRowVersion = version.latest_row_version
1190
1643
  const nextRowVersion = Math.max(currentRowVersion + 1, tx.rowVersion)
1191
- const replayDelta: ReplayableTxDelta | null = tx.truncate
1644
+ const replacesPersistedBaseline = tx.truncate === true
1645
+ const tracksPersistedKeySet =
1646
+ version.key_set_evidence_available === 1 || replacesPersistedBaseline
1647
+ const replayDelta: ReplayableTxDelta | null = replacesPersistedBaseline
1192
1648
  ? null
1193
1649
  : {
1194
1650
  txId: tx.txId,
@@ -1206,18 +1662,92 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1206
1662
  collectionMetadataMutations: tx.collectionMetadataMutations ?? [],
1207
1663
  }
1208
1664
 
1209
- if (tx.truncate) {
1665
+ if (replacesPersistedBaseline) {
1666
+ await transactionDriver.run(
1667
+ `DELETE FROM collection_expected_keys
1668
+ WHERE collection_id = ?`,
1669
+ [collectionId],
1670
+ )
1210
1671
  await transactionDriver.run(`DELETE FROM ${collectionTableSql}`)
1211
1672
  await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`)
1212
1673
  }
1213
1674
 
1214
- for (const mutation of tx.mutations) {
1215
- const encodedKey = encodePersistedStorageKey(mutation.key)
1216
- if (mutation.type === `delete`) {
1675
+ const replacementKeys = replacesPersistedBaseline
1676
+ ? tx.mutations.map((mutation) =>
1677
+ encodePersistedStorageKey(mutation.key),
1678
+ )
1679
+ : []
1680
+ const batchReplacement =
1681
+ replacesPersistedBaseline &&
1682
+ tx.mutations.length > 0 &&
1683
+ tx.mutations.every((mutation) => mutation.type !== `delete`) &&
1684
+ new Set(replacementKeys).size === replacementKeys.length
1685
+
1686
+ if (batchReplacement) {
1687
+ const finalRowMetadata = new Map<string, unknown>()
1688
+ for (const mutation of tx.rowMetadataMutations ?? []) {
1689
+ finalRowMetadata.set(
1690
+ encodePersistedStorageKey(mutation.key),
1691
+ mutation.type === `delete` ? undefined : mutation.value,
1692
+ )
1693
+ }
1694
+
1695
+ for (
1696
+ let start = 0;
1697
+ start < tx.mutations.length;
1698
+ start += this.replacementBatchSize
1699
+ ) {
1700
+ const mutations = tx.mutations.slice(
1701
+ start,
1702
+ start + this.replacementBatchSize,
1703
+ )
1704
+ const keys = replacementKeys.slice(
1705
+ start,
1706
+ start + this.replacementBatchSize,
1707
+ )
1217
1708
  await transactionDriver.run(
1218
- `DELETE FROM ${collectionTableSql}
1219
- WHERE key = ?`,
1220
- [encodedKey],
1709
+ `INSERT INTO collection_expected_keys (collection_id, key)
1710
+ VALUES ${keys.map(() => `(?, ?)`).join(`, `)}`,
1711
+ keys.flatMap((key) => [collectionId, key]),
1712
+ )
1713
+ await transactionDriver.run(
1714
+ `INSERT INTO ${collectionTableSql} (key, value, metadata, row_version)
1715
+ VALUES ${keys.map(() => `(?, ?, ?, ?)`).join(`, `)}`,
1716
+ mutations.flatMap((mutation, index) => {
1717
+ const key = keys[index]!
1718
+ const metadata = finalRowMetadata.has(key)
1719
+ ? finalRowMetadata.get(key)
1720
+ : mutation.type !== `delete` &&
1721
+ mutation.metadataChanged === true
1722
+ ? mutation.metadata
1723
+ : undefined
1724
+ return [
1725
+ key,
1726
+ serializePersistedRowValue(mutation.value),
1727
+ metadata === undefined
1728
+ ? null
1729
+ : serializePersistedRowValue(metadata),
1730
+ nextRowVersion,
1731
+ ]
1732
+ }),
1733
+ )
1734
+ }
1735
+ }
1736
+
1737
+ for (const mutation of batchReplacement ? [] : tx.mutations) {
1738
+ const encodedKey = encodePersistedStorageKey(mutation.key)
1739
+ if (mutation.type === `delete`) {
1740
+ if (tracksPersistedKeySet) {
1741
+ await transactionDriver.run(
1742
+ `DELETE FROM collection_expected_keys
1743
+ WHERE collection_id = ? AND key = ?`,
1744
+ [collectionId, encodedKey],
1745
+ )
1746
+ }
1747
+ await transactionDriver.run(
1748
+ `DELETE FROM ${collectionTableSql}
1749
+ WHERE key = ?`,
1750
+ [encodedKey],
1221
1751
  )
1222
1752
  await transactionDriver.run(
1223
1753
  `INSERT INTO ${tombstoneTableSql} (key, value, row_version, deleted_at)
@@ -1262,6 +1792,14 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1262
1792
  ? mutation.metadata
1263
1793
  : existingMetadata
1264
1794
 
1795
+ if (tracksPersistedKeySet) {
1796
+ await transactionDriver.run(
1797
+ `INSERT INTO collection_expected_keys (collection_id, key)
1798
+ VALUES (?, ?)
1799
+ ON CONFLICT(collection_id, key) DO NOTHING`,
1800
+ [collectionId, encodedKey],
1801
+ )
1802
+ }
1265
1803
  await transactionDriver.run(
1266
1804
  `INSERT INTO ${collectionTableSql} (key, value, metadata, row_version)
1267
1805
  VALUES (?, ?, ?, ?)
@@ -1285,7 +1823,9 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1285
1823
  )
1286
1824
  }
1287
1825
 
1288
- for (const rowMetadataMutation of tx.rowMetadataMutations ?? []) {
1826
+ for (const rowMetadataMutation of batchReplacement
1827
+ ? []
1828
+ : (tx.rowMetadataMutations ?? [])) {
1289
1829
  const encodedKey = encodePersistedStorageKey(rowMetadataMutation.key)
1290
1830
  if (rowMetadataMutation.type === `delete`) {
1291
1831
  await transactionDriver.run(
@@ -1333,11 +1873,23 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1333
1873
  }
1334
1874
 
1335
1875
  await transactionDriver.run(
1336
- `INSERT INTO collection_version (collection_id, latest_row_version)
1337
- VALUES (?, ?)
1338
- ON CONFLICT(collection_id) DO UPDATE SET
1339
- latest_row_version = excluded.latest_row_version`,
1340
- [collectionId, nextRowVersion],
1876
+ `UPDATE collection_version
1877
+ SET latest_row_version = ?,
1878
+ key_set_evidence_available = CASE
1879
+ WHEN ? = 1 THEN 1
1880
+ ELSE key_set_evidence_available
1881
+ END,
1882
+ key_set_evidence_incompatible = CASE
1883
+ WHEN ? = 1 THEN 0
1884
+ ELSE key_set_evidence_incompatible
1885
+ END
1886
+ WHERE collection_id = ?`,
1887
+ [
1888
+ nextRowVersion,
1889
+ replacesPersistedBaseline ? 1 : 0,
1890
+ replacesPersistedBaseline ? 1 : 0,
1891
+ collectionId,
1892
+ ],
1341
1893
  )
1342
1894
 
1343
1895
  await transactionDriver.run(
@@ -1371,7 +1923,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1371
1923
  tx.txId,
1372
1924
  nextRowVersion,
1373
1925
  replayDelta ? stableStringify(replayDelta) : null,
1374
- tx.truncate ? 1 : 0,
1926
+ replacesPersistedBaseline ? 1 : 0,
1375
1927
  ],
1376
1928
  )
1377
1929
 
@@ -1379,46 +1931,90 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1379
1931
  })
1380
1932
  }
1381
1933
 
1382
- async loadCollectionMetadata(
1934
+ loadCollectionMetadata(
1383
1935
  collectionId: string,
1384
1936
  ): Promise<Array<{ key: string; value: unknown }>> {
1385
- const rows = await this.driver.query<{ key: string; value: string }>(
1386
- `SELECT key, value
1387
- FROM collection_metadata
1388
- WHERE collection_id = ?`,
1389
- [collectionId],
1937
+ return this.runRegular(() =>
1938
+ this.loadCollectionMetadataUnscheduled(collectionId),
1390
1939
  )
1940
+ }
1391
1941
 
1392
- return rows.map((row) => ({
1393
- key: row.key,
1394
- value: deserializePersistedRowValue(row.value),
1395
- }))
1942
+ private async loadCollectionMetadataUnscheduled(
1943
+ collectionId: string,
1944
+ ): Promise<Array<{ key: string; value: unknown }>> {
1945
+ await this.ensureCollectionReady(collectionId)
1946
+ return this.runInTransaction(async (transactionDriver) => {
1947
+ await this.assertCurrentSchemaVersion(
1948
+ collectionId,
1949
+ transactionDriver,
1950
+ `load collection metadata`,
1951
+ )
1952
+ const rows = await transactionDriver.query<{
1953
+ key: string
1954
+ value: string
1955
+ }>(
1956
+ `SELECT key, value
1957
+ FROM collection_metadata
1958
+ WHERE collection_id = ?`,
1959
+ [collectionId],
1960
+ )
1961
+
1962
+ return rows.map((row) => ({
1963
+ key: row.key,
1964
+ value: deserializePersistedRowValue(row.value),
1965
+ }))
1966
+ })
1967
+ }
1968
+
1969
+ scanRows(
1970
+ collectionId: string,
1971
+ options?: PersistedRowScanOptions,
1972
+ ): Promise<Array<PersistedScannedRow>> {
1973
+ return this.runRegular(() =>
1974
+ this.scanRowsUnscheduled(collectionId, options),
1975
+ )
1396
1976
  }
1397
1977
 
1398
- async scanRows(
1978
+ private async scanRowsUnscheduled(
1399
1979
  collectionId: string,
1400
1980
  options?: PersistedRowScanOptions,
1401
1981
  ): Promise<Array<PersistedScannedRow>> {
1402
1982
  const tableMapping = await this.ensureCollectionReady(collectionId)
1403
1983
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1984
+ return this.runInTransaction(async (transactionDriver) => {
1985
+ await this.assertCurrentSchemaVersion(
1986
+ collectionId,
1987
+ transactionDriver,
1988
+ `scan persisted rows`,
1989
+ )
1990
+ const storedRows = await transactionDriver.query<StoredSqliteRow>(
1991
+ options?.metadataOnly
1992
+ ? `SELECT key, value, metadata, row_version
1993
+ FROM ${collectionTableSql}
1994
+ WHERE metadata IS NOT NULL`
1995
+ : `SELECT key, value, metadata, row_version
1996
+ FROM ${collectionTableSql}`,
1997
+ )
1404
1998
 
1405
- const storedRows = await this.driver.query<StoredSqliteRow>(
1406
- options?.metadataOnly
1407
- ? `SELECT key, value, metadata, row_version
1408
- FROM ${collectionTableSql}
1409
- WHERE metadata IS NOT NULL`
1410
- : `SELECT key, value, metadata, row_version
1411
- FROM ${collectionTableSql}`,
1412
- )
1999
+ return decodeStoredSqliteRows(storedRows).map((row) => ({
2000
+ key: row.key,
2001
+ value: row.value,
2002
+ metadata: row.metadata,
2003
+ }))
2004
+ })
2005
+ }
1413
2006
 
1414
- return decodeStoredSqliteRows(storedRows).map((row) => ({
1415
- key: row.key,
1416
- value: row.value,
1417
- metadata: row.metadata,
1418
- }))
2007
+ ensureIndex(
2008
+ collectionId: string,
2009
+ signature: string,
2010
+ spec: PersistedIndexSpec,
2011
+ ): Promise<void> {
2012
+ return this.runRegular(() =>
2013
+ this.ensureIndexUnscheduled(collectionId, signature, spec),
2014
+ )
1419
2015
  }
1420
2016
 
1421
- async ensureIndex(
2017
+ private async ensureIndexUnscheduled(
1422
2018
  collectionId: string,
1423
2019
  signature: string,
1424
2020
  spec: PersistedIndexSpec,
@@ -1431,11 +2027,44 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1431
2027
  normalizeIndexSqlFragment(fragment),
1432
2028
  )
1433
2029
  const expressionSql = normalizedExpressionSql.join(`, `)
2030
+ const persistedExpressionSql = JSON.stringify(normalizedExpressionSql)
1434
2031
  const whereSql = spec.whereSql
1435
2032
  ? normalizeIndexSqlFragment(spec.whereSql)
1436
2033
  : undefined
2034
+ const persistedWhereSql = whereSql ?? null
1437
2035
 
1438
2036
  await this.runInTransaction(async (transactionDriver) => {
2037
+ await this.assertCurrentSchemaVersion(
2038
+ collectionId,
2039
+ transactionDriver,
2040
+ `create a persisted index`,
2041
+ )
2042
+
2043
+ const existingRows = await transactionDriver.query<{
2044
+ index_name: string
2045
+ expression_sql: string
2046
+ where_sql: string | null
2047
+ }>(
2048
+ `SELECT index_name, expression_sql, where_sql
2049
+ FROM persisted_index_registry
2050
+ WHERE collection_id = ? AND signature = ?
2051
+ LIMIT 1`,
2052
+ [collectionId, signature],
2053
+ )
2054
+ const existing = existingRows[0]
2055
+ if (
2056
+ existing &&
2057
+ (existing.index_name !== indexName ||
2058
+ existing.expression_sql !== persistedExpressionSql ||
2059
+ existing.where_sql !== persistedWhereSql)
2060
+ ) {
2061
+ // A compiler upgrade can change normalized SQL without changing the
2062
+ // logical index signature. Rebuild only that stale physical index so
2063
+ // the registry and SQLite planner describe the same expression.
2064
+ await transactionDriver.exec(
2065
+ `DROP INDEX IF EXISTS ${quoteIdentifier(existing.index_name)}`,
2066
+ )
2067
+ }
1439
2068
  await transactionDriver.run(
1440
2069
  `INSERT INTO persisted_index_registry (
1441
2070
  collection_id,
@@ -1463,8 +2092,8 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1463
2092
  collectionId,
1464
2093
  signature,
1465
2094
  indexName,
1466
- JSON.stringify(normalizedExpressionSql),
1467
- whereSql ?? null,
2095
+ persistedExpressionSql,
2096
+ persistedWhereSql,
1468
2097
  ],
1469
2098
  )
1470
2099
 
@@ -1478,59 +2107,110 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1478
2107
  })
1479
2108
  }
1480
2109
 
1481
- async markIndexRemoved(
2110
+ markIndexRemoved(collectionId: string, signature: string): Promise<void> {
2111
+ return this.runRegular(() =>
2112
+ this.markIndexRemovedUnscheduled(collectionId, signature),
2113
+ )
2114
+ }
2115
+
2116
+ private async markIndexRemovedUnscheduled(
1482
2117
  collectionId: string,
1483
2118
  signature: string,
1484
2119
  ): Promise<void> {
1485
2120
  await this.ensureCollectionReady(collectionId)
1486
- const rows = await this.driver.query<{ index_name: string }>(
1487
- `SELECT index_name
1488
- FROM persisted_index_registry
1489
- WHERE collection_id = ? AND signature = ?
1490
- LIMIT 1`,
1491
- [collectionId, signature],
1492
- )
1493
- const indexName = rows[0]?.index_name
1494
-
1495
- await this.driver.run(
1496
- `UPDATE persisted_index_registry
1497
- SET removed = 1,
1498
- updated_at = CAST(strftime('%s', 'now') AS INTEGER),
1499
- last_used_at = CAST(strftime('%s', 'now') AS INTEGER)
1500
- WHERE collection_id = ? AND signature = ?`,
1501
- [collectionId, signature],
1502
- )
2121
+ await this.runInTransaction(async (transactionDriver) => {
2122
+ await this.assertCurrentSchemaVersion(
2123
+ collectionId,
2124
+ transactionDriver,
2125
+ `remove a persisted index`,
2126
+ )
2127
+ const rows = await transactionDriver.query<{ index_name: string }>(
2128
+ `SELECT index_name
2129
+ FROM persisted_index_registry
2130
+ WHERE collection_id = ? AND signature = ?
2131
+ LIMIT 1`,
2132
+ [collectionId, signature],
2133
+ )
2134
+ const indexName = rows[0]?.index_name
1503
2135
 
1504
- if (indexName) {
1505
- await this.driver.exec(
1506
- `DROP INDEX IF EXISTS ${quoteIdentifier(indexName)}`,
2136
+ await transactionDriver.run(
2137
+ `UPDATE persisted_index_registry
2138
+ SET removed = 1,
2139
+ updated_at = CAST(strftime('%s', 'now') AS INTEGER),
2140
+ last_used_at = CAST(strftime('%s', 'now') AS INTEGER)
2141
+ WHERE collection_id = ? AND signature = ?`,
2142
+ [collectionId, signature],
1507
2143
  )
1508
- }
2144
+
2145
+ if (indexName) {
2146
+ await transactionDriver.exec(
2147
+ `DROP INDEX IF EXISTS ${quoteIdentifier(indexName)}`,
2148
+ )
2149
+ }
2150
+ })
1509
2151
  }
1510
2152
 
1511
- async getStreamPosition(collectionId: string): Promise<{
2153
+ getStreamPosition(collectionId: string): Promise<{
2154
+ latestTerm: number
2155
+ latestSeq: number
2156
+ latestRowVersion: number
2157
+ }> {
2158
+ // Election must not queue behind a hydrate awaiting its first writer route.
2159
+ // The stream-position snapshot still uses the driver's transaction admission.
2160
+ return this.getStreamPositionUnscheduled(collectionId)
2161
+ }
2162
+
2163
+ private async getStreamPositionUnscheduled(collectionId: string): Promise<{
1512
2164
  latestTerm: number
1513
2165
  latestSeq: number
1514
2166
  latestRowVersion: number
1515
2167
  }> {
1516
2168
  await this.ensureCollectionReady(collectionId)
2169
+ return this.runInTransaction(async (transactionDriver) => {
2170
+ await this.assertCurrentSchemaVersion(
2171
+ collectionId,
2172
+ transactionDriver,
2173
+ `read the stream position`,
2174
+ )
2175
+ const [position, latestRowVersion] = await Promise.all([
2176
+ this.readStreamPosition(collectionId, transactionDriver),
2177
+ this.readLatestRowVersion(collectionId, transactionDriver),
2178
+ ])
1517
2179
 
1518
- const [termRows, versionRows, seqRows] = await Promise.all([
1519
- this.driver.query<{ latest_term: number }>(
2180
+ return {
2181
+ ...position,
2182
+ latestRowVersion,
2183
+ }
2184
+ })
2185
+ }
2186
+
2187
+ private async readLatestRowVersion(
2188
+ collectionId: string,
2189
+ driver: SQLiteDriver,
2190
+ ): Promise<number> {
2191
+ const versionRows = await driver.query<{ latest_row_version: number }>(
2192
+ `SELECT latest_row_version
2193
+ FROM collection_version
2194
+ WHERE collection_id = ?
2195
+ LIMIT 1`,
2196
+ [collectionId],
2197
+ )
2198
+ return versionRows[0]?.latest_row_version ?? 0
2199
+ }
2200
+
2201
+ private async readStreamPosition(
2202
+ collectionId: string,
2203
+ driver: SQLiteDriver,
2204
+ ): Promise<{ latestTerm: number; latestSeq: number }> {
2205
+ const [termRows, seqRows] = await Promise.all([
2206
+ driver.query<{ latest_term: number }>(
1520
2207
  `SELECT latest_term
1521
2208
  FROM leader_term
1522
2209
  WHERE collection_id = ?
1523
2210
  LIMIT 1`,
1524
2211
  [collectionId],
1525
2212
  ),
1526
- this.driver.query<{ latest_row_version: number }>(
1527
- `SELECT latest_row_version
1528
- FROM collection_version
1529
- WHERE collection_id = ?
1530
- LIMIT 1`,
1531
- [collectionId],
1532
- ),
1533
- this.driver.query<{ max_seq: number }>(
2213
+ driver.query<{ max_seq: number }>(
1534
2214
  `SELECT MAX(seq) AS max_seq
1535
2215
  FROM applied_tx
1536
2216
  WHERE collection_id = ? AND term = (
@@ -1543,11 +2223,55 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1543
2223
  return {
1544
2224
  latestTerm: termRows[0]?.latest_term ?? 0,
1545
2225
  latestSeq: seqRows[0]?.max_seq ?? 0,
1546
- latestRowVersion: versionRows[0]?.latest_row_version ?? 0,
1547
2226
  }
1548
2227
  }
1549
2228
 
1550
- async pullSince(
2229
+ private async readKeySetEvidence(
2230
+ collectionId: string,
2231
+ driver: SQLiteDriver,
2232
+ ): Promise<{
2233
+ latestRowVersion: number
2234
+ keySet: PersistedKeySetEvidence
2235
+ }> {
2236
+ const versionRows = await driver.query<{
2237
+ latest_row_version: number
2238
+ key_set_evidence_available: number
2239
+ key_set_incompatible: number
2240
+ }>(
2241
+ `SELECT
2242
+ latest_row_version,
2243
+ key_set_evidence_available,
2244
+ key_set_evidence_incompatible AS key_set_incompatible
2245
+ FROM collection_version
2246
+ WHERE collection_id = ?
2247
+ LIMIT 1`,
2248
+ [collectionId],
2249
+ )
2250
+ const version = versionRows[0]
2251
+
2252
+ return {
2253
+ latestRowVersion: version?.latest_row_version ?? 0,
2254
+ keySet: {
2255
+ status:
2256
+ version?.key_set_evidence_available !== 1
2257
+ ? `unknown`
2258
+ : version.key_set_incompatible === 1
2259
+ ? `incompatible`
2260
+ : `consistent`,
2261
+ },
2262
+ }
2263
+ }
2264
+
2265
+ pullSince(
2266
+ collectionId: string,
2267
+ fromRowVersion: number,
2268
+ ): Promise<SQLitePullSinceResult<string | number>> {
2269
+ return this.runRegular(() =>
2270
+ this.pullSinceUnscheduled(collectionId, fromRowVersion),
2271
+ )
2272
+ }
2273
+
2274
+ private async pullSinceUnscheduled(
1551
2275
  collectionId: string,
1552
2276
  fromRowVersion: number,
1553
2277
  ): Promise<SQLitePullSinceResult<string | number>> {
@@ -1555,136 +2279,144 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1555
2279
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1556
2280
  const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
1557
2281
 
1558
- const [
1559
- changedRows,
1560
- deletedRows,
1561
- latestVersionRows,
1562
- replayRows,
1563
- replayAvailabilityRows,
1564
- ] = await Promise.all([
1565
- this.driver.query<{ key: string }>(
1566
- `SELECT key
2282
+ return this.runInTransaction(async (transactionDriver) => {
2283
+ await this.assertCurrentSchemaVersion(
2284
+ collectionId,
2285
+ transactionDriver,
2286
+ `read persisted transaction deltas`,
2287
+ )
2288
+ const [
2289
+ changedRows,
2290
+ deletedRows,
2291
+ latestVersionRows,
2292
+ replayRows,
2293
+ replayAvailabilityRows,
2294
+ ] = await Promise.all([
2295
+ transactionDriver.query<{ key: string }>(
2296
+ `SELECT key
1567
2297
  FROM ${collectionTableSql}
1568
2298
  WHERE row_version > ?`,
1569
- [fromRowVersion],
1570
- ),
1571
- this.driver.query<{ key: string }>(
1572
- `SELECT key
2299
+ [fromRowVersion],
2300
+ ),
2301
+ transactionDriver.query<{ key: string }>(
2302
+ `SELECT key
1573
2303
  FROM ${tombstoneTableSql}
1574
2304
  WHERE row_version > ?`,
1575
- [fromRowVersion],
1576
- ),
1577
- this.driver.query<{ latest_row_version: number }>(
1578
- `SELECT latest_row_version
2305
+ [fromRowVersion],
2306
+ ),
2307
+ transactionDriver.query<{ latest_row_version: number }>(
2308
+ `SELECT latest_row_version
1579
2309
  FROM collection_version
1580
2310
  WHERE collection_id = ?
1581
2311
  LIMIT 1`,
1582
- [collectionId],
1583
- ),
1584
- this.driver.query<{
1585
- tx_id: string
1586
- row_version: number
1587
- replay_json: string | null
1588
- replay_requires_full_reload: number
1589
- }>(
1590
- `SELECT tx_id, row_version, replay_json, replay_requires_full_reload
2312
+ [collectionId],
2313
+ ),
2314
+ transactionDriver.query<{
2315
+ tx_id: string
2316
+ row_version: number
2317
+ replay_json: string | null
2318
+ replay_requires_full_reload: number
2319
+ }>(
2320
+ `SELECT tx_id, row_version, replay_json, replay_requires_full_reload
1591
2321
  FROM applied_tx
1592
2322
  WHERE collection_id = ? AND row_version > ?
1593
2323
  ORDER BY term ASC, seq ASC`,
1594
- [collectionId, fromRowVersion],
1595
- ),
1596
- this.driver.query<{ min_row_version: number | null }>(
1597
- `SELECT MIN(row_version) AS min_row_version
2324
+ [collectionId, fromRowVersion],
2325
+ ),
2326
+ transactionDriver.query<{ min_row_version: number | null }>(
2327
+ `SELECT MIN(row_version) AS min_row_version
1598
2328
  FROM applied_tx
1599
2329
  WHERE collection_id = ?`,
1600
- [collectionId],
1601
- ),
1602
- ])
2330
+ [collectionId],
2331
+ ),
2332
+ ])
1603
2333
 
1604
- const latestRowVersion = latestVersionRows[0]?.latest_row_version ?? 0
1605
- const replayFloor = replayAvailabilityRows[0]?.min_row_version
1606
- if (
1607
- latestRowVersion > fromRowVersion &&
1608
- (replayFloor == null || replayFloor > fromRowVersion + 1)
1609
- ) {
1610
- return {
1611
- latestRowVersion,
1612
- requiresFullReload: true,
2334
+ const latestRowVersion = latestVersionRows[0]?.latest_row_version ?? 0
2335
+ const replayFloor = replayAvailabilityRows[0]?.min_row_version
2336
+ if (
2337
+ latestRowVersion > fromRowVersion &&
2338
+ (replayFloor == null || replayFloor > fromRowVersion + 1)
2339
+ ) {
2340
+ return {
2341
+ latestRowVersion,
2342
+ requiresFullReload: true,
2343
+ }
1613
2344
  }
1614
- }
1615
2345
 
1616
- const changedKeyCount = changedRows.length + deletedRows.length
2346
+ const changedKeyCount = changedRows.length + deletedRows.length
1617
2347
 
1618
- if (changedKeyCount > this.pullSinceReloadThreshold) {
1619
- return {
1620
- latestRowVersion,
1621
- requiresFullReload: true,
2348
+ if (changedKeyCount > this.pullSinceReloadThreshold) {
2349
+ return {
2350
+ latestRowVersion,
2351
+ requiresFullReload: true,
2352
+ }
1622
2353
  }
1623
- }
1624
2354
 
1625
- if (
1626
- replayRows.some(
1627
- (row) =>
1628
- row.replay_requires_full_reload !== 0 || row.replay_json == null,
1629
- )
1630
- ) {
1631
- return {
1632
- latestRowVersion,
1633
- requiresFullReload: true,
2355
+ if (
2356
+ replayRows.some(
2357
+ (row) =>
2358
+ row.replay_requires_full_reload !== 0 || row.replay_json == null,
2359
+ )
2360
+ ) {
2361
+ return {
2362
+ latestRowVersion,
2363
+ requiresFullReload: true,
2364
+ }
1634
2365
  }
1635
- }
1636
2366
 
1637
- const decodeKey = (encodedKey: string): string | number => {
1638
- try {
1639
- return decodePersistedStorageKey(encodedKey)
1640
- } catch (error) {
1641
- throw new InvalidPersistedStorageKeyEncodingError(
1642
- `${encodedKey}: ${(error as Error).message}`,
1643
- )
2367
+ const decodeKey = (encodedKey: string): string | number => {
2368
+ try {
2369
+ return decodePersistedStorageKey(encodedKey)
2370
+ } catch (error) {
2371
+ throw new InvalidPersistedStorageKeyEncodingError(
2372
+ `${encodedKey}: ${(error as Error).message}`,
2373
+ )
2374
+ }
1644
2375
  }
1645
- }
1646
2376
 
1647
- const deltas = replayRows.map((row) => {
1648
- const parsed = deserializePersistedRowValue<ReplayableTxDelta | null>(
1649
- row.replay_json ?? `null`,
1650
- )
1651
- if (!parsed) {
1652
- throw new InvalidPersistedCollectionConfigError(
1653
- `missing replay payload for applied_tx row`,
2377
+ const deltas = replayRows.map((row) => {
2378
+ const parsed = deserializePersistedRowValue<ReplayableTxDelta | null>(
2379
+ row.replay_json ?? `null`,
1654
2380
  )
1655
- }
1656
- return parsed
1657
- })
2381
+ if (!parsed) {
2382
+ throw new InvalidPersistedCollectionConfigError(
2383
+ `missing replay payload for applied_tx row`,
2384
+ )
2385
+ }
2386
+ return parsed
2387
+ })
2388
+
2389
+ const replayChangeCount = deltas.reduce(
2390
+ (count, delta) =>
2391
+ count +
2392
+ delta.changedRows.length +
2393
+ delta.deletedKeys.length +
2394
+ delta.rowMetadataMutations.length +
2395
+ delta.collectionMetadataMutations.length,
2396
+ 0,
2397
+ )
1658
2398
 
1659
- const replayChangeCount = deltas.reduce(
1660
- (count, delta) =>
1661
- count +
1662
- delta.changedRows.length +
1663
- delta.deletedKeys.length +
1664
- delta.rowMetadataMutations.length +
1665
- delta.collectionMetadataMutations.length,
1666
- 0,
1667
- )
2399
+ if (replayChangeCount > this.pullSinceReloadThreshold) {
2400
+ return {
2401
+ latestRowVersion,
2402
+ requiresFullReload: true,
2403
+ }
2404
+ }
1668
2405
 
1669
- if (replayChangeCount > this.pullSinceReloadThreshold) {
1670
2406
  return {
1671
2407
  latestRowVersion,
1672
- requiresFullReload: true,
2408
+ requiresFullReload: false,
2409
+ changedKeys: changedRows.map((row) => decodeKey(row.key)),
2410
+ deletedKeys: deletedRows.map((row) => decodeKey(row.key)),
2411
+ deltas,
1673
2412
  }
1674
- }
1675
-
1676
- return {
1677
- latestRowVersion,
1678
- requiresFullReload: false,
1679
- changedKeys: changedRows.map((row) => decodeKey(row.key)),
1680
- deletedKeys: deletedRows.map((row) => decodeKey(row.key)),
1681
- deltas,
1682
- }
2413
+ })
1683
2414
  }
1684
2415
 
1685
2416
  private async loadSubsetInternal(
1686
2417
  tableMapping: CollectionTableMapping,
1687
2418
  options: LoadSubsetOptions,
2419
+ driver: SQLiteDriver = this.driver,
1688
2420
  ): Promise<Array<InMemoryRow<string | number, Record<string, unknown>>>> {
1689
2421
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1690
2422
  const whereCompiled = options.where
@@ -1705,10 +2437,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1705
2437
  queryParams.push(...orderByCompiled.params)
1706
2438
  }
1707
2439
 
1708
- const storedRows = await this.driver.query<StoredSqliteRow>(
1709
- sql,
1710
- queryParams,
1711
- )
2440
+ const storedRows = await driver.query<StoredSqliteRow>(sql, queryParams)
1712
2441
  const parsedRows = decodeStoredSqliteRows(storedRows)
1713
2442
 
1714
2443
  const filteredRows = this.applyInMemoryWhere(parsedRows, options.where)
@@ -1795,13 +2524,14 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1795
2524
  private async touchRequiredIndexes(
1796
2525
  collectionId: string,
1797
2526
  requiredIndexSignatures: ReadonlyArray<string> | undefined,
2527
+ driver: SQLiteDriver = this.driver,
1798
2528
  ): Promise<void> {
1799
2529
  if (!requiredIndexSignatures || requiredIndexSignatures.length === 0) {
1800
2530
  return
1801
2531
  }
1802
2532
 
1803
2533
  for (const signature of requiredIndexSignatures) {
1804
- await this.driver.run(
2534
+ await driver.run(
1805
2535
  `UPDATE persisted_index_registry
1806
2536
  SET last_used_at = CAST(strftime('%s', 'now') AS INTEGER),
1807
2537
  updated_at = CAST(strftime('%s', 'now') AS INTEGER)
@@ -1884,10 +2614,15 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1884
2614
  }
1885
2615
  }
1886
2616
 
1887
- private async ensureCollectionReadyInternal(
1888
- collectionId: string,
1889
- ): Promise<CollectionTableMapping> {
1890
- const existingRows = await this.driver.query<{
2617
+ private async loadCollectionRegistration(collectionId: string): Promise<
2618
+ | {
2619
+ table_name: string
2620
+ tombstone_table_name: string
2621
+ schema_version: number
2622
+ }
2623
+ | undefined
2624
+ > {
2625
+ const rows = await this.driver.query<{
1891
2626
  table_name: string
1892
2627
  tombstone_table_name: string
1893
2628
  schema_version: number
@@ -1899,25 +2634,17 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1899
2634
  [collectionId],
1900
2635
  )
1901
2636
 
1902
- let tableName: string
1903
- let tombstoneTableName: string
2637
+ return rows[0]
2638
+ }
1904
2639
 
1905
- if (existingRows.length > 0) {
1906
- tableName = existingRows[0]!.table_name
1907
- tombstoneTableName = existingRows[0]!.tombstone_table_name
2640
+ private async ensureCollectionReadyInternal(
2641
+ collectionId: string,
2642
+ ): Promise<CollectionTableMapping> {
2643
+ let registration = await this.loadCollectionRegistration(collectionId)
1908
2644
 
1909
- if (existingRows[0]!.schema_version !== this.schemaVersion) {
1910
- await this.handleSchemaMismatch(
1911
- collectionId,
1912
- existingRows[0]!.schema_version,
1913
- this.schemaVersion,
1914
- tableName,
1915
- tombstoneTableName,
1916
- )
1917
- }
1918
- } else {
1919
- tableName = createPersistedTableName(collectionId, `c`)
1920
- tombstoneTableName = createPersistedTableName(collectionId, `t`)
2645
+ if (!registration) {
2646
+ const tableName = createPersistedTableName(collectionId, `c`)
2647
+ const tombstoneTableName = createPersistedTableName(collectionId, `t`)
1921
2648
  await this.driver.run(
1922
2649
  `INSERT INTO collection_registry (
1923
2650
  collection_id,
@@ -1926,9 +2653,29 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1926
2653
  schema_version,
1927
2654
  updated_at
1928
2655
  )
1929
- VALUES (?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))`,
2656
+ VALUES (?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
2657
+ ON CONFLICT DO NOTHING`,
1930
2658
  [collectionId, tableName, tombstoneTableName, this.schemaVersion],
1931
2659
  )
2660
+
2661
+ registration = await this.loadCollectionRegistration(collectionId)
2662
+ if (!registration) {
2663
+ throw new InvalidPersistedCollectionConfigError(
2664
+ `Unable to register persistence tables for collection "${collectionId}"`,
2665
+ )
2666
+ }
2667
+ }
2668
+
2669
+ const tableName = registration.table_name
2670
+ const tombstoneTableName = registration.tombstone_table_name
2671
+ if (registration.schema_version !== this.schemaVersion) {
2672
+ await this.handleSchemaMismatch(
2673
+ collectionId,
2674
+ registration.schema_version,
2675
+ this.schemaVersion,
2676
+ tableName,
2677
+ tombstoneTableName,
2678
+ )
1932
2679
  }
1933
2680
 
1934
2681
  const collectionTableSql = quoteIdentifier(tableName)
@@ -1959,8 +2706,13 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1959
2706
  ON ${tombstoneTableSql} (row_version)`,
1960
2707
  )
1961
2708
  await this.driver.run(
1962
- `INSERT INTO collection_version (collection_id, latest_row_version)
1963
- VALUES (?, 0)
2709
+ `INSERT INTO collection_version (
2710
+ collection_id,
2711
+ latest_row_version,
2712
+ key_set_evidence_available,
2713
+ key_set_evidence_incompatible
2714
+ )
2715
+ VALUES (?, 0, 1, 0)
1964
2716
  ON CONFLICT(collection_id) DO NOTHING`,
1965
2717
  [collectionId],
1966
2718
  )
@@ -1970,7 +2722,7 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1970
2722
  ON CONFLICT(collection_id) DO NOTHING`,
1971
2723
  [collectionId],
1972
2724
  )
1973
-
2725
+ await this.ensureCollectionKeyEvidenceTriggers(collectionId, tableName)
1974
2726
  const mapping = {
1975
2727
  tableName,
1976
2728
  tombstoneTableName,
@@ -1979,6 +2731,73 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1979
2731
  return mapping
1980
2732
  }
1981
2733
 
2734
+ private async ensureCollectionKeyEvidenceTriggers(
2735
+ collectionId: string,
2736
+ tableName: string,
2737
+ ): Promise<void> {
2738
+ const collectionTableSql = quoteIdentifier(tableName)
2739
+ const collectionIdLiteral = toSqliteLiteral(collectionId)
2740
+ const insertTriggerSql = quoteIdentifier(`${tableName}_key_evidence_insert`)
2741
+ const deleteTriggerSql = quoteIdentifier(`${tableName}_key_evidence_delete`)
2742
+ const updateTriggerSql = quoteIdentifier(`${tableName}_key_evidence_update`)
2743
+
2744
+ await this.driver.exec(
2745
+ `CREATE TRIGGER IF NOT EXISTS ${insertTriggerSql}
2746
+ AFTER INSERT ON ${collectionTableSql}
2747
+ WHEN EXISTS (
2748
+ SELECT 1
2749
+ FROM collection_version
2750
+ WHERE collection_id = ${collectionIdLiteral}
2751
+ AND key_set_evidence_available = 1
2752
+ ) AND NOT EXISTS (
2753
+ SELECT 1
2754
+ FROM collection_expected_keys
2755
+ WHERE collection_id = ${collectionIdLiteral}
2756
+ AND key = NEW.key
2757
+ )
2758
+ BEGIN
2759
+ UPDATE collection_version
2760
+ SET key_set_evidence_incompatible = 1
2761
+ WHERE collection_id = ${collectionIdLiteral};
2762
+ END`,
2763
+ )
2764
+ await this.driver.exec(
2765
+ `CREATE TRIGGER IF NOT EXISTS ${deleteTriggerSql}
2766
+ AFTER DELETE ON ${collectionTableSql}
2767
+ WHEN EXISTS (
2768
+ SELECT 1
2769
+ FROM collection_version
2770
+ WHERE collection_id = ${collectionIdLiteral}
2771
+ AND key_set_evidence_available = 1
2772
+ ) AND EXISTS (
2773
+ SELECT 1
2774
+ FROM collection_expected_keys
2775
+ WHERE collection_id = ${collectionIdLiteral}
2776
+ AND key = OLD.key
2777
+ )
2778
+ BEGIN
2779
+ UPDATE collection_version
2780
+ SET key_set_evidence_incompatible = 1
2781
+ WHERE collection_id = ${collectionIdLiteral};
2782
+ END`,
2783
+ )
2784
+ await this.driver.exec(
2785
+ `CREATE TRIGGER IF NOT EXISTS ${updateTriggerSql}
2786
+ AFTER UPDATE OF key ON ${collectionTableSql}
2787
+ WHEN OLD.key <> NEW.key AND EXISTS (
2788
+ SELECT 1
2789
+ FROM collection_version
2790
+ WHERE collection_id = ${collectionIdLiteral}
2791
+ AND key_set_evidence_available = 1
2792
+ )
2793
+ BEGIN
2794
+ UPDATE collection_version
2795
+ SET key_set_evidence_incompatible = 1
2796
+ WHERE collection_id = ${collectionIdLiteral};
2797
+ END`,
2798
+ )
2799
+ }
2800
+
1982
2801
  private async handleSchemaMismatch(
1983
2802
  collectionId: string,
1984
2803
  previousSchemaVersion: number,
@@ -1997,6 +2816,27 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1997
2816
  const tombstoneTableSql = quoteIdentifier(tombstoneTableName)
1998
2817
 
1999
2818
  await this.runInTransaction(async (transactionDriver) => {
2819
+ const currentSchemaRows = await transactionDriver.query<{
2820
+ schema_version: number
2821
+ }>(
2822
+ `SELECT schema_version
2823
+ FROM collection_registry
2824
+ WHERE collection_id = ?
2825
+ LIMIT 1`,
2826
+ [collectionId],
2827
+ )
2828
+ const currentSchemaVersion = currentSchemaRows[0]?.schema_version
2829
+ if (currentSchemaVersion === nextSchemaVersion) {
2830
+ return
2831
+ }
2832
+ if (currentSchemaVersion !== previousSchemaVersion) {
2833
+ throw new InvalidPersistedCollectionConfigError(
2834
+ `Schema version changed concurrently for collection "${collectionId}": ` +
2835
+ `found ${currentSchemaVersion ?? `no registry entry`} after observing ${previousSchemaVersion}; ` +
2836
+ `refusing to reset it to ${nextSchemaVersion}.`,
2837
+ )
2838
+ }
2839
+
2000
2840
  const persistedIndexes = await transactionDriver.query<{
2001
2841
  index_name: string
2002
2842
  }>(
@@ -2011,6 +2851,11 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2011
2851
  )
2012
2852
  }
2013
2853
 
2854
+ await transactionDriver.run(
2855
+ `DELETE FROM collection_expected_keys
2856
+ WHERE collection_id = ?`,
2857
+ [collectionId],
2858
+ )
2014
2859
  await transactionDriver.run(`DELETE FROM ${collectionTableSql}`)
2015
2860
  await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`)
2016
2861
  await transactionDriver.run(
@@ -2023,6 +2868,11 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2023
2868
  WHERE collection_id = ?`,
2024
2869
  [collectionId],
2025
2870
  )
2871
+ await transactionDriver.run(
2872
+ `DELETE FROM collection_metadata
2873
+ WHERE collection_id = ?`,
2874
+ [collectionId],
2875
+ )
2026
2876
  await transactionDriver.run(
2027
2877
  `UPDATE collection_registry
2028
2878
  SET schema_version = ?,
@@ -2031,10 +2881,17 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2031
2881
  [nextSchemaVersion, collectionId],
2032
2882
  )
2033
2883
  await transactionDriver.run(
2034
- `INSERT INTO collection_version (collection_id, latest_row_version)
2035
- VALUES (?, 0)
2884
+ `INSERT INTO collection_version (
2885
+ collection_id,
2886
+ latest_row_version,
2887
+ key_set_evidence_available,
2888
+ key_set_evidence_incompatible
2889
+ )
2890
+ VALUES (?, 0, 1, 0)
2036
2891
  ON CONFLICT(collection_id) DO UPDATE SET
2037
- latest_row_version = 0`,
2892
+ latest_row_version = 0,
2893
+ key_set_evidence_available = 1,
2894
+ key_set_evidence_incompatible = 0`,
2038
2895
  [collectionId],
2039
2896
  )
2040
2897
  await transactionDriver.run(
@@ -2110,7 +2967,37 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2110
2967
  await this.driver.exec(
2111
2968
  `CREATE TABLE IF NOT EXISTS collection_version (
2112
2969
  collection_id TEXT PRIMARY KEY,
2113
- latest_row_version INTEGER NOT NULL
2970
+ latest_row_version INTEGER NOT NULL,
2971
+ key_set_evidence_available INTEGER NOT NULL DEFAULT 0,
2972
+ key_set_evidence_incompatible INTEGER NOT NULL DEFAULT 0
2973
+ )`,
2974
+ )
2975
+ const collectionVersionColumns = await this.driver.query<{ name: string }>(
2976
+ `PRAGMA table_info(collection_version)`,
2977
+ )
2978
+ const keyEvidenceColumns = [
2979
+ `key_set_evidence_available`,
2980
+ `key_set_evidence_incompatible`,
2981
+ ] as const
2982
+ for (const columnName of keyEvidenceColumns) {
2983
+ if (collectionVersionColumns.some(({ name }) => name === columnName)) {
2984
+ continue
2985
+ }
2986
+ try {
2987
+ await this.driver.exec(
2988
+ `ALTER TABLE collection_version ADD COLUMN ${columnName} INTEGER NOT NULL DEFAULT 0`,
2989
+ )
2990
+ } catch (error) {
2991
+ if (!isDuplicateColumnAddError(error, columnName)) {
2992
+ throw error
2993
+ }
2994
+ }
2995
+ }
2996
+ await this.driver.exec(
2997
+ `CREATE TABLE IF NOT EXISTS collection_expected_keys (
2998
+ collection_id TEXT NOT NULL,
2999
+ key TEXT NOT NULL,
3000
+ PRIMARY KEY (collection_id, key)
2114
3001
  )`,
2115
3002
  )
2116
3003
  await this.driver.exec(