@tanstack/db-sqlite-persistence-core 0.1.2

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.
@@ -0,0 +1,2129 @@
1
+ import {
2
+ IR,
3
+ compileSingleRowExpression,
4
+ toBooleanPredicate,
5
+ } from '@tanstack/db'
6
+ import {
7
+ InvalidPersistedCollectionConfigError,
8
+ InvalidPersistedStorageKeyEncodingError,
9
+ } from './errors'
10
+ import {
11
+ createPersistedTableName,
12
+ decodePersistedStorageKey,
13
+ encodePersistedStorageKey,
14
+ } from './persisted'
15
+ import type { LoadSubsetOptions } from '@tanstack/db'
16
+ import type {
17
+ PersistedIndexSpec,
18
+ PersistedRowScanOptions,
19
+ PersistedScannedRow,
20
+ PersistedTx,
21
+ PersistenceAdapter,
22
+ ReplayableTxDelta,
23
+ SQLiteDriver,
24
+ } from './persisted'
25
+
26
+ type SqliteSupportedValue = null | number | string
27
+
28
+ type CollectionTableMapping = {
29
+ tableName: string
30
+ tombstoneTableName: string
31
+ }
32
+
33
+ type CompiledSqlFragment = {
34
+ supported: boolean
35
+ sql: string
36
+ params: Array<SqliteSupportedValue>
37
+ valueKind?: CompiledValueKind
38
+ }
39
+
40
+ type StoredSqliteRow = {
41
+ key: string
42
+ value: string
43
+ metadata: string | null
44
+ row_version: number
45
+ }
46
+
47
+ type SQLiteCoreAdapterSchemaMismatchPolicy =
48
+ | `sync-present-reset`
49
+ | `sync-absent-error`
50
+ | `reset`
51
+
52
+ export type SQLiteCoreAdapterOptions = {
53
+ driver: SQLiteDriver
54
+ schemaVersion?: number
55
+ schemaMismatchPolicy?: SQLiteCoreAdapterSchemaMismatchPolicy
56
+ appliedTxPruneMaxRows?: number
57
+ appliedTxPruneMaxAgeSeconds?: number
58
+ pullSinceReloadThreshold?: number
59
+ }
60
+
61
+ export type SQLitePullSinceResult<TKey extends string | number> =
62
+ | {
63
+ latestRowVersion: number
64
+ requiresFullReload: true
65
+ }
66
+ | {
67
+ latestRowVersion: number
68
+ requiresFullReload: false
69
+ changedKeys: Array<TKey>
70
+ deletedKeys: Array<TKey>
71
+ deltas: Array<ReplayableTxDelta<Record<string, unknown>, TKey>>
72
+ }
73
+
74
+ const DEFAULT_SCHEMA_VERSION = 1
75
+ const DEFAULT_PULL_SINCE_RELOAD_THRESHOLD = 128
76
+ const SQLITE_MAX_IN_BATCH_SIZE = 900
77
+ const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
78
+ const FORBIDDEN_SQL_FRAGMENT_PATTERN = /(;|--|\/\*)/
79
+ const PERSISTED_TYPE_TAG = `__tanstack_db_persisted_type__`
80
+ const PERSISTED_VALUE_TAG = `value`
81
+
82
+ type CompiledValueKind = `unknown` | `bigint` | `date` | `datetime`
83
+ type PersistedTaggedValueType =
84
+ | `bigint`
85
+ | `date`
86
+ | `nan`
87
+ | `infinity`
88
+ | `-infinity`
89
+ type PersistedTaggedValue = {
90
+ [PERSISTED_TYPE_TAG]: PersistedTaggedValueType
91
+ [PERSISTED_VALUE_TAG]: string
92
+ }
93
+
94
+ const persistedTaggedValueTypes = new Set<PersistedTaggedValueType>([
95
+ `bigint`,
96
+ `date`,
97
+ `nan`,
98
+ `infinity`,
99
+ `-infinity`,
100
+ ])
101
+
102
+ const orderByObjectIds = new WeakMap<object, number>()
103
+ let nextOrderByObjectId = 1
104
+
105
+ function isDuplicateColumnAddError(
106
+ error: unknown,
107
+ columnName: string,
108
+ ): boolean {
109
+ if (!(error instanceof Error)) {
110
+ return false
111
+ }
112
+
113
+ const message = error.message.toLowerCase()
114
+ const normalizedColumnName = columnName.toLowerCase()
115
+ return (
116
+ (message.includes(`duplicate column name`) &&
117
+ message.includes(normalizedColumnName)) ||
118
+ (message.includes(`already exists`) &&
119
+ message.includes(normalizedColumnName))
120
+ )
121
+ }
122
+
123
+ function quoteIdentifier(identifier: string): string {
124
+ if (!SAFE_IDENTIFIER_PATTERN.test(identifier)) {
125
+ throw new InvalidPersistedCollectionConfigError(
126
+ `Invalid SQLite identifier "${identifier}"`,
127
+ )
128
+ }
129
+ return `"${identifier}"`
130
+ }
131
+
132
+ function isPersistedTaggedValue(value: unknown): value is PersistedTaggedValue {
133
+ if (typeof value !== `object` || value === null) {
134
+ return false
135
+ }
136
+
137
+ const taggedValue = value as {
138
+ [PERSISTED_TYPE_TAG]?: unknown
139
+ [PERSISTED_VALUE_TAG]?: unknown
140
+ }
141
+ if (
142
+ typeof taggedValue[PERSISTED_TYPE_TAG] !== `string` ||
143
+ typeof taggedValue[PERSISTED_VALUE_TAG] !== `string`
144
+ ) {
145
+ return false
146
+ }
147
+
148
+ return persistedTaggedValueTypes.has(
149
+ taggedValue[PERSISTED_TYPE_TAG] as PersistedTaggedValueType,
150
+ )
151
+ }
152
+
153
+ function encodePersistedJsonValue(value: unknown): unknown {
154
+ if (value === undefined || value === null) {
155
+ return value
156
+ }
157
+
158
+ if (typeof value === `bigint`) {
159
+ return {
160
+ [PERSISTED_TYPE_TAG]: `bigint`,
161
+ [PERSISTED_VALUE_TAG]: value.toString(),
162
+ } satisfies PersistedTaggedValue
163
+ }
164
+
165
+ if (value instanceof Date) {
166
+ return {
167
+ [PERSISTED_TYPE_TAG]: `date`,
168
+ [PERSISTED_VALUE_TAG]: value.toISOString(),
169
+ } satisfies PersistedTaggedValue
170
+ }
171
+
172
+ if (typeof value === `number`) {
173
+ if (Number.isNaN(value)) {
174
+ return {
175
+ [PERSISTED_TYPE_TAG]: `nan`,
176
+ [PERSISTED_VALUE_TAG]: `NaN`,
177
+ } satisfies PersistedTaggedValue
178
+ }
179
+ if (value === Number.POSITIVE_INFINITY) {
180
+ return {
181
+ [PERSISTED_TYPE_TAG]: `infinity`,
182
+ [PERSISTED_VALUE_TAG]: `Infinity`,
183
+ } satisfies PersistedTaggedValue
184
+ }
185
+ if (value === Number.NEGATIVE_INFINITY) {
186
+ return {
187
+ [PERSISTED_TYPE_TAG]: `-infinity`,
188
+ [PERSISTED_VALUE_TAG]: `-Infinity`,
189
+ } satisfies PersistedTaggedValue
190
+ }
191
+ return value
192
+ }
193
+
194
+ if (Array.isArray(value)) {
195
+ return value.map((entry) => encodePersistedJsonValue(entry))
196
+ }
197
+
198
+ if (typeof value === `object`) {
199
+ const encodedRecord: Record<string, unknown> = {}
200
+ const recordValue = value as Record<string, unknown>
201
+ for (const [key, entryValue] of Object.entries(recordValue)) {
202
+ const encodedValue = encodePersistedJsonValue(entryValue)
203
+ if (encodedValue !== undefined) {
204
+ encodedRecord[key] = encodedValue
205
+ }
206
+ }
207
+ return encodedRecord
208
+ }
209
+
210
+ return value
211
+ }
212
+
213
+ function decodePersistedJsonValue(value: unknown): unknown {
214
+ if (value === undefined || value === null) {
215
+ return value
216
+ }
217
+
218
+ if (isPersistedTaggedValue(value)) {
219
+ switch (value[PERSISTED_TYPE_TAG]) {
220
+ case `bigint`:
221
+ return BigInt(value[PERSISTED_VALUE_TAG])
222
+ case `date`: {
223
+ const parsedDate = new Date(value[PERSISTED_VALUE_TAG])
224
+ return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
225
+ }
226
+ case `nan`:
227
+ return Number.NaN
228
+ case `infinity`:
229
+ return Number.POSITIVE_INFINITY
230
+ case `-infinity`:
231
+ return Number.NEGATIVE_INFINITY
232
+ default:
233
+ return value
234
+ }
235
+ }
236
+
237
+ if (Array.isArray(value)) {
238
+ return value.map((entry) => decodePersistedJsonValue(entry))
239
+ }
240
+
241
+ if (typeof value === `object`) {
242
+ const decodedRecord: Record<string, unknown> = {}
243
+ const recordValue = value as Record<string, unknown>
244
+ for (const [key, entryValue] of Object.entries(recordValue)) {
245
+ decodedRecord[key] = decodePersistedJsonValue(entryValue)
246
+ }
247
+ return decodedRecord
248
+ }
249
+
250
+ return value
251
+ }
252
+
253
+ function serializePersistedRowValue(value: unknown): string {
254
+ return JSON.stringify(encodePersistedJsonValue(value))
255
+ }
256
+
257
+ function deserializePersistedRowValue<T>(value: string): T {
258
+ const parsedJson = JSON.parse(value) as unknown
259
+ return decodePersistedJsonValue(parsedJson) as T
260
+ }
261
+
262
+ function toSqliteParameterValue(value: unknown): SqliteSupportedValue {
263
+ if (value == null) {
264
+ return null
265
+ }
266
+
267
+ if (typeof value === `number`) {
268
+ if (!Number.isFinite(value)) {
269
+ return null
270
+ }
271
+ return value
272
+ }
273
+
274
+ if (typeof value === `bigint`) {
275
+ return value.toString()
276
+ }
277
+
278
+ if (typeof value === `boolean`) {
279
+ return value ? 1 : 0
280
+ }
281
+
282
+ if (typeof value === `string`) {
283
+ return value
284
+ }
285
+
286
+ if (value instanceof Date) {
287
+ return value.toISOString()
288
+ }
289
+
290
+ return serializePersistedRowValue(value)
291
+ }
292
+
293
+ function toSqliteLiteral(value: SqliteSupportedValue): string {
294
+ if (value === null) {
295
+ return `NULL`
296
+ }
297
+
298
+ if (typeof value === `number`) {
299
+ return Number.isFinite(value) ? String(value) : `NULL`
300
+ }
301
+
302
+ return `'${value.replace(/'/g, `''`)}'`
303
+ }
304
+
305
+ function inlineSqlParams(
306
+ sql: string,
307
+ params: ReadonlyArray<SqliteSupportedValue>,
308
+ ): string {
309
+ let index = 0
310
+ const inlinedSql = sql.replace(/\?/g, () => {
311
+ const paramValue = params[index]
312
+ index++
313
+ return toSqliteLiteral(paramValue ?? null)
314
+ })
315
+
316
+ if (index !== params.length) {
317
+ throw new InvalidPersistedCollectionConfigError(
318
+ `Unable to inline SQL params; placeholder count did not match provided params`,
319
+ )
320
+ }
321
+
322
+ return inlinedSql
323
+ }
324
+
325
+ type CompiledRowExpressionEvaluator = (row: Record<string, unknown>) => unknown
326
+
327
+ function collectAliasQualifiedRefSegments(
328
+ expression: IR.BasicExpression,
329
+ segments: Set<string> = new Set<string>(),
330
+ ): Set<string> {
331
+ if (expression.type === `ref`) {
332
+ if (expression.path.length > 1) {
333
+ const rootSegment = String(expression.path[0])
334
+ if (rootSegment.length > 0) {
335
+ segments.add(rootSegment)
336
+ }
337
+ }
338
+ return segments
339
+ }
340
+
341
+ if (expression.type === `func`) {
342
+ for (const arg of expression.args) {
343
+ collectAliasQualifiedRefSegments(arg, segments)
344
+ }
345
+ }
346
+
347
+ return segments
348
+ }
349
+
350
+ function createAliasAwareRowProxy(
351
+ row: Record<string, unknown>,
352
+ aliasSegments: ReadonlySet<string>,
353
+ ): Record<string, unknown> {
354
+ return new Proxy(row, {
355
+ get(target, prop, receiver) {
356
+ if (typeof prop !== `string`) {
357
+ return Reflect.get(target, prop, receiver)
358
+ }
359
+
360
+ if (Object.prototype.hasOwnProperty.call(target, prop)) {
361
+ const value = Reflect.get(target, prop, receiver)
362
+ if (value !== undefined || !aliasSegments.has(prop)) {
363
+ return value
364
+ }
365
+
366
+ return target
367
+ }
368
+
369
+ if (aliasSegments.has(prop)) {
370
+ return target
371
+ }
372
+
373
+ return undefined
374
+ },
375
+ })
376
+ }
377
+
378
+ function compileRowExpressionEvaluator(
379
+ expression: IR.BasicExpression,
380
+ ): CompiledRowExpressionEvaluator {
381
+ let baseEvaluator: ReturnType<typeof compileSingleRowExpression>
382
+ try {
383
+ baseEvaluator = compileSingleRowExpression(expression)
384
+ } catch (error) {
385
+ throw new InvalidPersistedCollectionConfigError(
386
+ `Unsupported expression for SQLite adapter fallback evaluator: ${(error as Error).message}`,
387
+ )
388
+ }
389
+
390
+ const aliasSegments = collectAliasQualifiedRefSegments(expression)
391
+ if (aliasSegments.size === 0) {
392
+ return (row) => baseEvaluator(row)
393
+ }
394
+
395
+ const proxyCache = new WeakMap<
396
+ Record<string, unknown>,
397
+ Record<string, unknown>
398
+ >()
399
+ return (row) => {
400
+ let proxy = proxyCache.get(row)
401
+ if (!proxy) {
402
+ proxy = createAliasAwareRowProxy(row, aliasSegments)
403
+ proxyCache.set(row, proxy)
404
+ }
405
+ return baseEvaluator(proxy)
406
+ }
407
+ }
408
+
409
+ function getOrderByObjectId(value: object): number {
410
+ const existing = orderByObjectIds.get(value)
411
+ if (existing !== undefined) {
412
+ return existing
413
+ }
414
+
415
+ const nextId = nextOrderByObjectId
416
+ nextOrderByObjectId++
417
+ orderByObjectIds.set(value, nextId)
418
+ return nextId
419
+ }
420
+
421
+ function compareOrderByValues(
422
+ left: unknown,
423
+ right: unknown,
424
+ compareOptions: IR.OrderByClause[`compareOptions`],
425
+ ): number {
426
+ if (left == null && right == null) {
427
+ return 0
428
+ }
429
+ if (left == null) {
430
+ return compareOptions.nulls === `first` ? -1 : 1
431
+ }
432
+ if (right == null) {
433
+ return compareOptions.nulls === `first` ? 1 : -1
434
+ }
435
+
436
+ if (typeof left === `string` && typeof right === `string`) {
437
+ if (compareOptions.stringSort === `locale`) {
438
+ return left.localeCompare(
439
+ right,
440
+ compareOptions.locale,
441
+ compareOptions.localeOptions,
442
+ )
443
+ }
444
+ if (left < right) {
445
+ return -1
446
+ }
447
+ if (left > right) {
448
+ return 1
449
+ }
450
+ return 0
451
+ }
452
+
453
+ if (Array.isArray(left) && Array.isArray(right)) {
454
+ const maxIndex = Math.min(left.length, right.length)
455
+ for (let index = 0; index < maxIndex; index++) {
456
+ const comparison = compareOrderByValues(
457
+ left[index],
458
+ right[index],
459
+ compareOptions,
460
+ )
461
+ if (comparison !== 0) {
462
+ return comparison
463
+ }
464
+ }
465
+ return left.length - right.length
466
+ }
467
+
468
+ if (left instanceof Date && right instanceof Date) {
469
+ return left.getTime() - right.getTime()
470
+ }
471
+
472
+ const leftIsObject = typeof left === `object`
473
+ const rightIsObject = typeof right === `object`
474
+ if (leftIsObject || rightIsObject) {
475
+ if (leftIsObject && rightIsObject) {
476
+ return getOrderByObjectId(left) - getOrderByObjectId(right)
477
+ }
478
+ return leftIsObject ? 1 : -1
479
+ }
480
+
481
+ if (left < right) {
482
+ return -1
483
+ }
484
+ if (left > right) {
485
+ return 1
486
+ }
487
+ return 0
488
+ }
489
+
490
+ function createJsonPath(path: Array<string>): string | null {
491
+ if (path.length === 0) {
492
+ return null
493
+ }
494
+
495
+ let jsonPath = `$`
496
+ for (const segment of path) {
497
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment)) {
498
+ jsonPath = `${jsonPath}.${segment}`
499
+ continue
500
+ }
501
+
502
+ if (/^[0-9]+$/.test(segment)) {
503
+ jsonPath = `${jsonPath}[${segment}]`
504
+ continue
505
+ }
506
+
507
+ return null
508
+ }
509
+
510
+ return jsonPath
511
+ }
512
+
513
+ function getLiteralValueKind(value: unknown): CompiledValueKind {
514
+ if (typeof value === `bigint`) {
515
+ return `bigint`
516
+ }
517
+ if (value instanceof Date) {
518
+ return `datetime`
519
+ }
520
+ return `unknown`
521
+ }
522
+
523
+ function getCompiledValueKind(
524
+ fragment: CompiledSqlFragment,
525
+ ): CompiledValueKind {
526
+ return fragment.valueKind ?? `unknown`
527
+ }
528
+
529
+ function resolveComparisonValueKind(
530
+ leftExpression: IR.BasicExpression,
531
+ rightExpression: IR.BasicExpression,
532
+ leftCompiled: CompiledSqlFragment,
533
+ rightCompiled: CompiledSqlFragment,
534
+ ): CompiledValueKind {
535
+ const leftKind = getCompiledValueKind(leftCompiled)
536
+ const rightKind = getCompiledValueKind(rightCompiled)
537
+
538
+ const hasBigIntLiteral =
539
+ (leftExpression.type === `val` &&
540
+ typeof leftExpression.value === `bigint`) ||
541
+ (rightExpression.type === `val` &&
542
+ typeof rightExpression.value === `bigint`)
543
+ if (hasBigIntLiteral || leftKind === `bigint` || rightKind === `bigint`) {
544
+ return `bigint`
545
+ }
546
+
547
+ const hasDateLiteral =
548
+ (leftExpression.type === `val` && leftExpression.value instanceof Date) ||
549
+ (rightExpression.type === `val` && rightExpression.value instanceof Date)
550
+ if (hasDateLiteral || leftKind === `datetime` || rightKind === `datetime`) {
551
+ return `datetime`
552
+ }
553
+
554
+ if (leftKind === `date` || rightKind === `date`) {
555
+ return `date`
556
+ }
557
+
558
+ return `unknown`
559
+ }
560
+
561
+ function compileComparisonSql(
562
+ operator: `=` | `>` | `>=` | `<` | `<=`,
563
+ leftSql: string,
564
+ rightSql: string,
565
+ valueKind: CompiledValueKind,
566
+ ): string {
567
+ if (valueKind === `bigint`) {
568
+ return `(CAST(${leftSql} AS NUMERIC) ${operator} CAST(${rightSql} AS NUMERIC))`
569
+ }
570
+ if (valueKind === `date`) {
571
+ return `(date(${leftSql}) ${operator} date(${rightSql}))`
572
+ }
573
+ if (valueKind === `datetime`) {
574
+ return `(datetime(${leftSql}) ${operator} datetime(${rightSql}))`
575
+ }
576
+ return `(${leftSql} ${operator} ${rightSql})`
577
+ }
578
+
579
+ function compileRefExpressionSql(jsonPath: string): CompiledSqlFragment {
580
+ const typePath = `${jsonPath}.${PERSISTED_TYPE_TAG}`
581
+ const taggedValuePath = `${jsonPath}.${PERSISTED_VALUE_TAG}`
582
+
583
+ return {
584
+ supported: true,
585
+ sql: `(CASE json_extract(value, ?)
586
+ WHEN 'bigint' THEN CAST(json_extract(value, ?) AS NUMERIC)
587
+ WHEN 'date' THEN json_extract(value, ?)
588
+ WHEN 'nan' THEN NULL
589
+ WHEN 'infinity' THEN NULL
590
+ WHEN '-infinity' THEN NULL
591
+ ELSE json_extract(value, ?)
592
+ END)`,
593
+ params: [typePath, taggedValuePath, taggedValuePath, jsonPath],
594
+ valueKind: `unknown`,
595
+ }
596
+ }
597
+
598
+ function sanitizeExpressionSqlFragment(fragment: string): string {
599
+ if (
600
+ fragment.trim().length === 0 ||
601
+ FORBIDDEN_SQL_FRAGMENT_PATTERN.test(fragment)
602
+ ) {
603
+ throw new InvalidPersistedCollectionConfigError(
604
+ `Invalid persisted index SQL fragment: "${fragment}"`,
605
+ )
606
+ }
607
+
608
+ return fragment
609
+ }
610
+
611
+ type InMemoryRow<TKey extends string | number, T extends object> = {
612
+ key: TKey
613
+ value: T
614
+ metadata?: unknown
615
+ rowVersion: number
616
+ }
617
+
618
+ function decodeStoredSqliteRows<TKey extends string | number, T extends object>(
619
+ storedRows: ReadonlyArray<StoredSqliteRow>,
620
+ ): Array<InMemoryRow<TKey, T>> {
621
+ return storedRows.map((row) => {
622
+ const key = decodePersistedStorageKey(row.key) as TKey
623
+ const value = deserializePersistedRowValue<T>(row.value)
624
+ return {
625
+ key,
626
+ value,
627
+ metadata:
628
+ row.metadata != null
629
+ ? deserializePersistedRowValue(row.metadata)
630
+ : undefined,
631
+ rowVersion: row.row_version,
632
+ }
633
+ })
634
+ }
635
+
636
+ function stableStringify(value: unknown): string {
637
+ return serializePersistedRowValue(value)
638
+ }
639
+
640
+ function compileSqlExpression(
641
+ expression: IR.BasicExpression,
642
+ ): CompiledSqlFragment {
643
+ if (expression.type === `val`) {
644
+ const valueKind = getLiteralValueKind(expression.value)
645
+ return {
646
+ supported: true,
647
+ sql: `?`,
648
+ params: [toSqliteParameterValue(expression.value)],
649
+ valueKind,
650
+ }
651
+ }
652
+
653
+ if (expression.type === `ref`) {
654
+ const jsonPath = createJsonPath(expression.path.map(String))
655
+ if (!jsonPath) {
656
+ return {
657
+ supported: false,
658
+ sql: ``,
659
+ params: [],
660
+ }
661
+ }
662
+
663
+ return compileRefExpressionSql(jsonPath)
664
+ }
665
+
666
+ const compiledArgs = expression.args.map((arg) => compileSqlExpression(arg))
667
+ if (compiledArgs.some((arg) => !arg.supported)) {
668
+ return {
669
+ supported: false,
670
+ sql: ``,
671
+ params: [],
672
+ }
673
+ }
674
+
675
+ const params = compiledArgs.flatMap((arg) => arg.params)
676
+ const argSql = compiledArgs.map((arg) => arg.sql)
677
+
678
+ switch (expression.name) {
679
+ case `eq`:
680
+ case `gt`:
681
+ case `gte`:
682
+ case `lt`:
683
+ case `lte`: {
684
+ if (
685
+ expression.args.length !== 2 ||
686
+ !argSql[0] ||
687
+ !argSql[1] ||
688
+ !compiledArgs[0] ||
689
+ !compiledArgs[1]
690
+ ) {
691
+ return { supported: false, sql: ``, params: [] }
692
+ }
693
+
694
+ const valueKind = resolveComparisonValueKind(
695
+ expression.args[0]!,
696
+ expression.args[1]!,
697
+ compiledArgs[0],
698
+ compiledArgs[1],
699
+ )
700
+ const operatorByName: Record<
701
+ `eq` | `gt` | `gte` | `lt` | `lte`,
702
+ `=` | `>` | `>=` | `<` | `<=`
703
+ > = {
704
+ eq: `=`,
705
+ gt: `>`,
706
+ gte: `>=`,
707
+ lt: `<`,
708
+ lte: `<=`,
709
+ }
710
+
711
+ return {
712
+ supported: true,
713
+ sql: compileComparisonSql(
714
+ operatorByName[expression.name],
715
+ argSql[0],
716
+ argSql[1],
717
+ valueKind,
718
+ ),
719
+ params,
720
+ }
721
+ }
722
+ case `and`: {
723
+ if (argSql.length < 2) {
724
+ return { supported: false, sql: ``, params: [] }
725
+ }
726
+ return {
727
+ supported: true,
728
+ sql: argSql.map((sql) => `(${sql})`).join(` AND `),
729
+ params,
730
+ }
731
+ }
732
+ case `or`: {
733
+ if (argSql.length < 2) {
734
+ return { supported: false, sql: ``, params: [] }
735
+ }
736
+ return {
737
+ supported: true,
738
+ sql: argSql.map((sql) => `(${sql})`).join(` OR `),
739
+ params,
740
+ }
741
+ }
742
+ case `not`: {
743
+ if (argSql.length !== 1) {
744
+ return { supported: false, sql: ``, params: [] }
745
+ }
746
+ return {
747
+ supported: true,
748
+ sql: `(NOT (${argSql[0]}))`,
749
+ params,
750
+ }
751
+ }
752
+ case `in`: {
753
+ if (expression.args.length !== 2 || expression.args[1]?.type !== `val`) {
754
+ return { supported: false, sql: ``, params: [] }
755
+ }
756
+
757
+ const listValue = expression.args[1].value
758
+ if (!Array.isArray(listValue)) {
759
+ return { supported: false, sql: ``, params: [] }
760
+ }
761
+
762
+ if (listValue.length === 0) {
763
+ return { supported: true, sql: `(0 = 1)`, params: [] }
764
+ }
765
+
766
+ const leftSql = argSql[0]
767
+ const leftParams = compiledArgs[0]?.params ?? []
768
+ if (!leftSql) {
769
+ return { supported: false, sql: ``, params: [] }
770
+ }
771
+
772
+ if (listValue.length > SQLITE_MAX_IN_BATCH_SIZE) {
773
+ const hasBigIntValues = listValue.some(
774
+ (value) => typeof value === `bigint`,
775
+ )
776
+ const inLeftSql = hasBigIntValues
777
+ ? `CAST(${leftSql} AS NUMERIC)`
778
+ : leftSql
779
+ const chunkClauses: Array<string> = []
780
+ const batchedParams: Array<SqliteSupportedValue> = []
781
+
782
+ for (
783
+ let startIndex = 0;
784
+ startIndex < listValue.length;
785
+ startIndex += SQLITE_MAX_IN_BATCH_SIZE
786
+ ) {
787
+ const chunkValues = listValue.slice(
788
+ startIndex,
789
+ startIndex + SQLITE_MAX_IN_BATCH_SIZE,
790
+ )
791
+ chunkClauses.push(
792
+ `(${inLeftSql} IN (${chunkValues.map(() => `?`).join(`, `)}))`,
793
+ )
794
+ batchedParams.push(...leftParams)
795
+ batchedParams.push(
796
+ ...chunkValues.map((value) => toSqliteParameterValue(value)),
797
+ )
798
+ }
799
+
800
+ return {
801
+ supported: true,
802
+ sql: `(${chunkClauses.join(` OR `)})`,
803
+ params: batchedParams,
804
+ }
805
+ }
806
+
807
+ const hasBigIntValues = listValue.some(
808
+ (value) => typeof value === `bigint`,
809
+ )
810
+ const inLeftSql = hasBigIntValues
811
+ ? `CAST(${leftSql} AS NUMERIC)`
812
+ : leftSql
813
+ const listPlaceholders = listValue.map(() => `?`).join(`, `)
814
+ return {
815
+ supported: true,
816
+ sql: `(${inLeftSql} IN (${listPlaceholders}))`,
817
+ params: [
818
+ ...leftParams,
819
+ ...listValue.map((value) => toSqliteParameterValue(value)),
820
+ ],
821
+ }
822
+ }
823
+ case `like`:
824
+ return {
825
+ supported: true,
826
+ sql: `(${argSql[0]} LIKE ${argSql[1]})`,
827
+ params,
828
+ }
829
+ case `ilike`:
830
+ return {
831
+ supported: true,
832
+ sql: `(LOWER(${argSql[0]}) LIKE LOWER(${argSql[1]}))`,
833
+ params,
834
+ }
835
+ case `isNull`:
836
+ case `isUndefined`:
837
+ return { supported: true, sql: `(${argSql[0]} IS NULL)`, params }
838
+ case `upper`:
839
+ return { supported: true, sql: `UPPER(${argSql[0]})`, params }
840
+ case `lower`:
841
+ return { supported: true, sql: `LOWER(${argSql[0]})`, params }
842
+ case `length`:
843
+ return { supported: true, sql: `LENGTH(${argSql[0]})`, params }
844
+ case `concat`:
845
+ return { supported: true, sql: `(${argSql.join(` || `)})`, params }
846
+ case `coalesce`:
847
+ return { supported: true, sql: `COALESCE(${argSql.join(`, `)})`, params }
848
+ case `add`:
849
+ return { supported: true, sql: `(${argSql[0]} + ${argSql[1]})`, params }
850
+ case `subtract`:
851
+ return { supported: true, sql: `(${argSql[0]} - ${argSql[1]})`, params }
852
+ case `multiply`:
853
+ return { supported: true, sql: `(${argSql[0]} * ${argSql[1]})`, params }
854
+ case `divide`:
855
+ return { supported: true, sql: `(${argSql[0]} / ${argSql[1]})`, params }
856
+ case `date`:
857
+ return {
858
+ supported: true,
859
+ sql: `date(${argSql[0]})`,
860
+ params,
861
+ valueKind: `date`,
862
+ }
863
+ case `datetime`:
864
+ return {
865
+ supported: true,
866
+ sql: `datetime(${argSql[0]})`,
867
+ params,
868
+ valueKind: `datetime`,
869
+ }
870
+ case `strftime`:
871
+ return {
872
+ supported: true,
873
+ sql: `strftime(${argSql.join(`, `)})`,
874
+ params,
875
+ }
876
+ default:
877
+ return {
878
+ supported: false,
879
+ sql: ``,
880
+ params: [],
881
+ }
882
+ }
883
+ }
884
+
885
+ function compileOrderByClauses(
886
+ orderBy: IR.OrderBy | undefined,
887
+ ): CompiledSqlFragment {
888
+ if (!orderBy || orderBy.length === 0) {
889
+ return {
890
+ supported: true,
891
+ sql: ``,
892
+ params: [],
893
+ }
894
+ }
895
+
896
+ const parts: Array<string> = []
897
+ const params: Array<SqliteSupportedValue> = []
898
+
899
+ for (const clause of orderBy) {
900
+ const compiledExpression = compileSqlExpression(clause.expression)
901
+ if (!compiledExpression.supported) {
902
+ return {
903
+ supported: false,
904
+ sql: ``,
905
+ params: [],
906
+ }
907
+ }
908
+
909
+ params.push(...compiledExpression.params)
910
+
911
+ const direction =
912
+ clause.compareOptions.direction === `desc` ? `DESC` : `ASC`
913
+ const nulls =
914
+ clause.compareOptions.nulls === `first` ? `NULLS FIRST` : `NULLS LAST`
915
+ parts.push(`${compiledExpression.sql} ${direction} ${nulls}`)
916
+ }
917
+
918
+ return {
919
+ supported: true,
920
+ sql: parts.join(`, `),
921
+ params,
922
+ }
923
+ }
924
+
925
+ function isExpressionLikeShape(value: unknown): value is IR.BasicExpression {
926
+ if (typeof value !== `object` || value === null) {
927
+ return false
928
+ }
929
+
930
+ const candidate = value as {
931
+ type?: unknown
932
+ value?: unknown
933
+ path?: unknown
934
+ name?: unknown
935
+ args?: unknown
936
+ }
937
+
938
+ if (candidate.type === `val`) {
939
+ return Object.prototype.hasOwnProperty.call(candidate, `value`)
940
+ }
941
+
942
+ if (candidate.type === `ref`) {
943
+ return Array.isArray(candidate.path)
944
+ }
945
+
946
+ if (candidate.type === `func`) {
947
+ if (typeof candidate.name !== `string` || !Array.isArray(candidate.args)) {
948
+ return false
949
+ }
950
+ return candidate.args.every((arg) => isExpressionLikeShape(arg))
951
+ }
952
+
953
+ return false
954
+ }
955
+
956
+ function normalizeIndexSqlFragment(fragment: string): string {
957
+ const trimmed = fragment.trim()
958
+ if (trimmed.length === 0) {
959
+ throw new InvalidPersistedCollectionConfigError(
960
+ `Index SQL fragment cannot be empty`,
961
+ )
962
+ }
963
+
964
+ let parsedJson: unknown
965
+ let hasParsedJson = false
966
+ try {
967
+ parsedJson = JSON.parse(trimmed) as unknown
968
+ hasParsedJson = true
969
+ } catch {
970
+ // Non-JSON strings are treated as raw SQL fragments below.
971
+ }
972
+
973
+ if (hasParsedJson && isExpressionLikeShape(parsedJson)) {
974
+ const compiled = compileSqlExpression(parsedJson)
975
+ if (!compiled.supported) {
976
+ throw new InvalidPersistedCollectionConfigError(
977
+ `Persisted index expression is not supported by the SQLite compiler`,
978
+ )
979
+ }
980
+ return inlineSqlParams(compiled.sql, compiled.params)
981
+ }
982
+
983
+ return sanitizeExpressionSqlFragment(fragment)
984
+ }
985
+
986
+ function mergeObjectRows<T extends object>(existing: unknown, incoming: T): T {
987
+ if (typeof existing === `object` && existing !== null) {
988
+ return Object.assign({}, existing as Record<string, unknown>, incoming) as T
989
+ }
990
+ return incoming
991
+ }
992
+
993
+ function buildIndexName(collectionId: string, signature: string): string {
994
+ const sanitizedSignature = signature
995
+ .toLowerCase()
996
+ .replace(/[^a-z0-9]+/g, `_`)
997
+ .replace(/^_+|_+$/g, ``)
998
+ .slice(0, 24)
999
+ const hashSource = `${collectionId}:${signature}`
1000
+ const hashedPart = createPersistedTableName(hashSource, `c`)
1001
+ const suffix = sanitizedSignature.length > 0 ? sanitizedSignature : `sig`
1002
+ return `idx_${hashedPart}_${suffix}`
1003
+ }
1004
+
1005
+ export class SQLiteCorePersistenceAdapter<
1006
+ T extends object,
1007
+ TKey extends string | number = string | number,
1008
+ > implements PersistenceAdapter<T, TKey> {
1009
+ private readonly driver: SQLiteDriver
1010
+ private readonly schemaVersion: number
1011
+ private readonly schemaMismatchPolicy: SQLiteCoreAdapterSchemaMismatchPolicy
1012
+ private readonly appliedTxPruneMaxRows: number | undefined
1013
+ private readonly appliedTxPruneMaxAgeSeconds: number | undefined
1014
+ private readonly pullSinceReloadThreshold: number
1015
+
1016
+ private initialized = false
1017
+ private readonly collectionTableCache = new Map<
1018
+ string,
1019
+ CollectionTableMapping
1020
+ >()
1021
+ private readonly collectionTableLoads = new Map<
1022
+ string,
1023
+ Promise<CollectionTableMapping>
1024
+ >()
1025
+
1026
+ constructor(options: SQLiteCoreAdapterOptions) {
1027
+ const schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION
1028
+ if (!Number.isInteger(schemaVersion) || schemaVersion < 0) {
1029
+ throw new InvalidPersistedCollectionConfigError(
1030
+ `SQLite adapter schemaVersion must be a non-negative integer`,
1031
+ )
1032
+ }
1033
+
1034
+ if (
1035
+ options.appliedTxPruneMaxRows !== undefined &&
1036
+ (!Number.isInteger(options.appliedTxPruneMaxRows) ||
1037
+ options.appliedTxPruneMaxRows < 0)
1038
+ ) {
1039
+ throw new InvalidPersistedCollectionConfigError(
1040
+ `SQLite adapter appliedTxPruneMaxRows must be a non-negative integer when provided`,
1041
+ )
1042
+ }
1043
+
1044
+ if (
1045
+ options.appliedTxPruneMaxAgeSeconds !== undefined &&
1046
+ (!Number.isInteger(options.appliedTxPruneMaxAgeSeconds) ||
1047
+ options.appliedTxPruneMaxAgeSeconds < 0)
1048
+ ) {
1049
+ throw new InvalidPersistedCollectionConfigError(
1050
+ `SQLite adapter appliedTxPruneMaxAgeSeconds must be a non-negative integer when provided`,
1051
+ )
1052
+ }
1053
+
1054
+ const pullSinceReloadThreshold =
1055
+ options.pullSinceReloadThreshold ?? DEFAULT_PULL_SINCE_RELOAD_THRESHOLD
1056
+ if (
1057
+ !Number.isInteger(pullSinceReloadThreshold) ||
1058
+ pullSinceReloadThreshold < 0
1059
+ ) {
1060
+ throw new InvalidPersistedCollectionConfigError(
1061
+ `SQLite adapter pullSinceReloadThreshold must be a non-negative integer`,
1062
+ )
1063
+ }
1064
+
1065
+ this.driver = options.driver
1066
+ this.schemaVersion = schemaVersion
1067
+ this.schemaMismatchPolicy =
1068
+ options.schemaMismatchPolicy ?? `sync-present-reset`
1069
+ this.appliedTxPruneMaxRows = options.appliedTxPruneMaxRows
1070
+ this.appliedTxPruneMaxAgeSeconds = options.appliedTxPruneMaxAgeSeconds
1071
+ this.pullSinceReloadThreshold = pullSinceReloadThreshold
1072
+ }
1073
+
1074
+ private runInTransaction<TResult>(
1075
+ fn: (transactionDriver: SQLiteDriver) => Promise<TResult>,
1076
+ ): Promise<TResult> {
1077
+ if (typeof this.driver.transactionWithDriver === `function`) {
1078
+ return this.driver.transactionWithDriver(fn)
1079
+ }
1080
+
1081
+ return this.driver.transaction(fn)
1082
+ }
1083
+
1084
+ async loadSubset(
1085
+ collectionId: string,
1086
+ options: LoadSubsetOptions,
1087
+ ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
1088
+ ): Promise<Array<{ key: TKey; value: T; metadata?: unknown }>> {
1089
+ const tableMapping = await this.ensureCollectionReady(collectionId)
1090
+ await this.touchRequiredIndexes(collectionId, ctx?.requiredIndexSignatures)
1091
+
1092
+ if (options.cursor) {
1093
+ const whereCurrentOptions: LoadSubsetOptions = {
1094
+ where: options.where
1095
+ ? new IR.Func(`and`, [options.where, options.cursor.whereCurrent])
1096
+ : options.cursor.whereCurrent,
1097
+ orderBy: options.orderBy,
1098
+ }
1099
+ const whereFromOptions: LoadSubsetOptions = {
1100
+ where: options.where
1101
+ ? new IR.Func(`and`, [options.where, options.cursor.whereFrom])
1102
+ : options.cursor.whereFrom,
1103
+ orderBy: options.orderBy,
1104
+ limit: options.limit,
1105
+ }
1106
+
1107
+ const [whereCurrentRows, whereFromRows] = await Promise.all([
1108
+ this.loadSubsetInternal(tableMapping, whereCurrentOptions),
1109
+ this.loadSubsetInternal(tableMapping, whereFromOptions),
1110
+ ])
1111
+
1112
+ const mergedRows = new Map<string, InMemoryRow<TKey, T>>()
1113
+ for (const row of [...whereCurrentRows, ...whereFromRows]) {
1114
+ mergedRows.set(encodePersistedStorageKey(row.key), row)
1115
+ }
1116
+
1117
+ const orderedRows = this.applyInMemoryOrderBy(
1118
+ Array.from(mergedRows.values()),
1119
+ options.orderBy,
1120
+ )
1121
+
1122
+ return orderedRows.map((row) => ({
1123
+ key: row.key,
1124
+ value: row.value,
1125
+ metadata: row.metadata,
1126
+ }))
1127
+ }
1128
+
1129
+ const rows = await this.loadSubsetInternal(tableMapping, options)
1130
+ return rows.map((row) => ({
1131
+ key: row.key,
1132
+ value: row.value,
1133
+ metadata: row.metadata,
1134
+ }))
1135
+ }
1136
+
1137
+ async applyCommittedTx(
1138
+ collectionId: string,
1139
+ tx: PersistedTx<T, TKey>,
1140
+ ): Promise<void> {
1141
+ const tableMapping = await this.ensureCollectionReady(collectionId)
1142
+ const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1143
+ const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
1144
+
1145
+ await this.runInTransaction(async (transactionDriver) => {
1146
+ const alreadyApplied = await transactionDriver.query<{ applied: number }>(
1147
+ `SELECT 1 AS applied
1148
+ FROM applied_tx
1149
+ WHERE collection_id = ? AND term = ? AND seq = ?
1150
+ LIMIT 1`,
1151
+ [collectionId, tx.term, tx.seq],
1152
+ )
1153
+
1154
+ if (alreadyApplied.length > 0) {
1155
+ return
1156
+ }
1157
+
1158
+ const versionRows = await transactionDriver.query<{
1159
+ latest_row_version: number
1160
+ }>(
1161
+ `SELECT latest_row_version
1162
+ FROM collection_version
1163
+ WHERE collection_id = ?
1164
+ LIMIT 1`,
1165
+ [collectionId],
1166
+ )
1167
+ const currentRowVersion = versionRows[0]?.latest_row_version ?? 0
1168
+ const nextRowVersion = Math.max(currentRowVersion + 1, tx.rowVersion)
1169
+ const replayDelta: ReplayableTxDelta<
1170
+ Record<string, unknown>,
1171
+ TKey
1172
+ > | null = tx.truncate
1173
+ ? null
1174
+ : {
1175
+ txId: tx.txId,
1176
+ latestRowVersion: nextRowVersion,
1177
+ changedRows: tx.mutations
1178
+ .filter((mutation) => mutation.type !== `delete`)
1179
+ .map((mutation) => ({
1180
+ key: mutation.key,
1181
+ value: mutation.value as Record<string, unknown>,
1182
+ })),
1183
+ deletedKeys: tx.mutations
1184
+ .filter((mutation) => mutation.type === `delete`)
1185
+ .map((mutation) => mutation.key),
1186
+ rowMetadataMutations: tx.rowMetadataMutations ?? [],
1187
+ collectionMetadataMutations: tx.collectionMetadataMutations ?? [],
1188
+ }
1189
+
1190
+ if (tx.truncate) {
1191
+ await transactionDriver.run(`DELETE FROM ${collectionTableSql}`)
1192
+ await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`)
1193
+ }
1194
+
1195
+ for (const mutation of tx.mutations) {
1196
+ const encodedKey = encodePersistedStorageKey(mutation.key)
1197
+ if (mutation.type === `delete`) {
1198
+ await transactionDriver.run(
1199
+ `DELETE FROM ${collectionTableSql}
1200
+ WHERE key = ?`,
1201
+ [encodedKey],
1202
+ )
1203
+ await transactionDriver.run(
1204
+ `INSERT INTO ${tombstoneTableSql} (key, value, row_version, deleted_at)
1205
+ VALUES (?, ?, ?, ?)
1206
+ ON CONFLICT(key) DO UPDATE SET
1207
+ value = excluded.value,
1208
+ row_version = excluded.row_version,
1209
+ deleted_at = excluded.deleted_at`,
1210
+ [
1211
+ encodedKey,
1212
+ serializePersistedRowValue(mutation.value),
1213
+ nextRowVersion,
1214
+ new Date().toISOString(),
1215
+ ],
1216
+ )
1217
+ continue
1218
+ }
1219
+
1220
+ const existingRows = await transactionDriver.query<{
1221
+ value: string
1222
+ metadata: string | null
1223
+ }>(
1224
+ `SELECT value, metadata
1225
+ FROM ${collectionTableSql}
1226
+ WHERE key = ?
1227
+ LIMIT 1`,
1228
+ [encodedKey],
1229
+ )
1230
+ const existingValue = existingRows[0]?.value
1231
+ ? deserializePersistedRowValue(existingRows[0].value)
1232
+ : undefined
1233
+ const existingMetadata =
1234
+ existingRows[0]?.metadata != null
1235
+ ? deserializePersistedRowValue(existingRows[0].metadata)
1236
+ : undefined
1237
+ const mergedValue =
1238
+ mutation.type === `update`
1239
+ ? mergeObjectRows(existingValue, mutation.value)
1240
+ : mutation.value
1241
+ const nextMetadata =
1242
+ mutation.metadataChanged === true
1243
+ ? mutation.metadata
1244
+ : existingMetadata
1245
+
1246
+ await transactionDriver.run(
1247
+ `INSERT INTO ${collectionTableSql} (key, value, metadata, row_version)
1248
+ VALUES (?, ?, ?, ?)
1249
+ ON CONFLICT(key) DO UPDATE SET
1250
+ value = excluded.value,
1251
+ metadata = excluded.metadata,
1252
+ row_version = excluded.row_version`,
1253
+ [
1254
+ encodedKey,
1255
+ serializePersistedRowValue(mergedValue),
1256
+ nextMetadata === undefined
1257
+ ? null
1258
+ : serializePersistedRowValue(nextMetadata),
1259
+ nextRowVersion,
1260
+ ],
1261
+ )
1262
+ await transactionDriver.run(
1263
+ `DELETE FROM ${tombstoneTableSql}
1264
+ WHERE key = ?`,
1265
+ [encodedKey],
1266
+ )
1267
+ }
1268
+
1269
+ for (const rowMetadataMutation of tx.rowMetadataMutations ?? []) {
1270
+ const encodedKey = encodePersistedStorageKey(rowMetadataMutation.key)
1271
+ if (rowMetadataMutation.type === `delete`) {
1272
+ await transactionDriver.run(
1273
+ `UPDATE ${collectionTableSql}
1274
+ SET metadata = NULL
1275
+ WHERE key = ?`,
1276
+ [encodedKey],
1277
+ )
1278
+ } else {
1279
+ await transactionDriver.run(
1280
+ `UPDATE ${collectionTableSql}
1281
+ SET metadata = ?
1282
+ WHERE key = ?`,
1283
+ [
1284
+ rowMetadataMutation.value === undefined
1285
+ ? null
1286
+ : serializePersistedRowValue(rowMetadataMutation.value),
1287
+ encodedKey,
1288
+ ],
1289
+ )
1290
+ }
1291
+ }
1292
+
1293
+ for (const metadataMutation of tx.collectionMetadataMutations ?? []) {
1294
+ if (metadataMutation.type === `delete`) {
1295
+ await transactionDriver.run(
1296
+ `DELETE FROM collection_metadata
1297
+ WHERE collection_id = ? AND key = ?`,
1298
+ [collectionId, metadataMutation.key],
1299
+ )
1300
+ } else {
1301
+ await transactionDriver.run(
1302
+ `INSERT INTO collection_metadata (collection_id, key, value, updated_at)
1303
+ VALUES (?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
1304
+ ON CONFLICT(collection_id, key) DO UPDATE SET
1305
+ value = excluded.value,
1306
+ updated_at = excluded.updated_at`,
1307
+ [
1308
+ collectionId,
1309
+ metadataMutation.key,
1310
+ serializePersistedRowValue(metadataMutation.value),
1311
+ ],
1312
+ )
1313
+ }
1314
+ }
1315
+
1316
+ await transactionDriver.run(
1317
+ `INSERT INTO collection_version (collection_id, latest_row_version)
1318
+ VALUES (?, ?)
1319
+ ON CONFLICT(collection_id) DO UPDATE SET
1320
+ latest_row_version = excluded.latest_row_version`,
1321
+ [collectionId, nextRowVersion],
1322
+ )
1323
+
1324
+ await transactionDriver.run(
1325
+ `INSERT INTO leader_term (collection_id, latest_term)
1326
+ VALUES (?, ?)
1327
+ ON CONFLICT(collection_id) DO UPDATE SET
1328
+ latest_term = CASE
1329
+ WHEN leader_term.latest_term > excluded.latest_term
1330
+ THEN leader_term.latest_term
1331
+ ELSE excluded.latest_term
1332
+ END`,
1333
+ [collectionId, tx.term],
1334
+ )
1335
+
1336
+ await transactionDriver.run(
1337
+ `INSERT INTO applied_tx (
1338
+ collection_id,
1339
+ term,
1340
+ seq,
1341
+ tx_id,
1342
+ row_version,
1343
+ replay_json,
1344
+ replay_requires_full_reload,
1345
+ applied_at
1346
+ )
1347
+ VALUES (?, ?, ?, ?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))`,
1348
+ [
1349
+ collectionId,
1350
+ tx.term,
1351
+ tx.seq,
1352
+ tx.txId,
1353
+ nextRowVersion,
1354
+ replayDelta ? stableStringify(replayDelta) : null,
1355
+ tx.truncate ? 1 : 0,
1356
+ ],
1357
+ )
1358
+
1359
+ await this.pruneAppliedTxRows(collectionId, transactionDriver)
1360
+ })
1361
+ }
1362
+
1363
+ async loadCollectionMetadata(
1364
+ collectionId: string,
1365
+ ): Promise<Array<{ key: string; value: unknown }>> {
1366
+ const rows = await this.driver.query<{ key: string; value: string }>(
1367
+ `SELECT key, value
1368
+ FROM collection_metadata
1369
+ WHERE collection_id = ?`,
1370
+ [collectionId],
1371
+ )
1372
+
1373
+ return rows.map((row) => ({
1374
+ key: row.key,
1375
+ value: deserializePersistedRowValue(row.value),
1376
+ }))
1377
+ }
1378
+
1379
+ async scanRows(
1380
+ collectionId: string,
1381
+ options?: PersistedRowScanOptions,
1382
+ ): Promise<Array<PersistedScannedRow<T, TKey>>> {
1383
+ const tableMapping = await this.ensureCollectionReady(collectionId)
1384
+ const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1385
+
1386
+ const storedRows = await this.driver.query<StoredSqliteRow>(
1387
+ options?.metadataOnly
1388
+ ? `SELECT key, value, metadata, row_version
1389
+ FROM ${collectionTableSql}
1390
+ WHERE metadata IS NOT NULL`
1391
+ : `SELECT key, value, metadata, row_version
1392
+ FROM ${collectionTableSql}`,
1393
+ )
1394
+
1395
+ return decodeStoredSqliteRows<TKey, T>(storedRows).map((row) => ({
1396
+ key: row.key,
1397
+ value: row.value,
1398
+ metadata: row.metadata,
1399
+ }))
1400
+ }
1401
+
1402
+ async ensureIndex(
1403
+ collectionId: string,
1404
+ signature: string,
1405
+ spec: PersistedIndexSpec,
1406
+ ): Promise<void> {
1407
+ const tableMapping = await this.ensureCollectionReady(collectionId)
1408
+ const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1409
+ const indexName = buildIndexName(collectionId, signature)
1410
+ const indexNameSql = quoteIdentifier(indexName)
1411
+ const normalizedExpressionSql = spec.expressionSql.map((fragment) =>
1412
+ normalizeIndexSqlFragment(fragment),
1413
+ )
1414
+ const expressionSql = normalizedExpressionSql.join(`, `)
1415
+ const whereSql = spec.whereSql
1416
+ ? normalizeIndexSqlFragment(spec.whereSql)
1417
+ : undefined
1418
+
1419
+ await this.runInTransaction(async (transactionDriver) => {
1420
+ await transactionDriver.run(
1421
+ `INSERT INTO persisted_index_registry (
1422
+ collection_id,
1423
+ signature,
1424
+ index_name,
1425
+ expression_sql,
1426
+ where_sql,
1427
+ removed,
1428
+ created_at,
1429
+ updated_at,
1430
+ last_used_at
1431
+ )
1432
+ VALUES (?, ?, ?, ?, ?, 0,
1433
+ CAST(strftime('%s', 'now') AS INTEGER),
1434
+ CAST(strftime('%s', 'now') AS INTEGER),
1435
+ CAST(strftime('%s', 'now') AS INTEGER))
1436
+ ON CONFLICT(collection_id, signature) DO UPDATE SET
1437
+ index_name = excluded.index_name,
1438
+ expression_sql = excluded.expression_sql,
1439
+ where_sql = excluded.where_sql,
1440
+ removed = 0,
1441
+ updated_at = CAST(strftime('%s', 'now') AS INTEGER),
1442
+ last_used_at = CAST(strftime('%s', 'now') AS INTEGER)`,
1443
+ [
1444
+ collectionId,
1445
+ signature,
1446
+ indexName,
1447
+ JSON.stringify(normalizedExpressionSql),
1448
+ whereSql ?? null,
1449
+ ],
1450
+ )
1451
+
1452
+ const createIndexSql = whereSql
1453
+ ? `CREATE INDEX IF NOT EXISTS ${indexNameSql}
1454
+ ON ${collectionTableSql} (${expressionSql})
1455
+ WHERE ${whereSql}`
1456
+ : `CREATE INDEX IF NOT EXISTS ${indexNameSql}
1457
+ ON ${collectionTableSql} (${expressionSql})`
1458
+ await transactionDriver.exec(createIndexSql)
1459
+ })
1460
+ }
1461
+
1462
+ async markIndexRemoved(
1463
+ collectionId: string,
1464
+ signature: string,
1465
+ ): Promise<void> {
1466
+ await this.ensureCollectionReady(collectionId)
1467
+ const rows = await this.driver.query<{ index_name: string }>(
1468
+ `SELECT index_name
1469
+ FROM persisted_index_registry
1470
+ WHERE collection_id = ? AND signature = ?
1471
+ LIMIT 1`,
1472
+ [collectionId, signature],
1473
+ )
1474
+ const indexName = rows[0]?.index_name
1475
+
1476
+ await this.driver.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
+
1485
+ if (indexName) {
1486
+ await this.driver.exec(
1487
+ `DROP INDEX IF EXISTS ${quoteIdentifier(indexName)}`,
1488
+ )
1489
+ }
1490
+ }
1491
+
1492
+ async getStreamPosition(collectionId: string): Promise<{
1493
+ latestTerm: number
1494
+ latestSeq: number
1495
+ latestRowVersion: number
1496
+ }> {
1497
+ await this.ensureCollectionReady(collectionId)
1498
+
1499
+ const [termRows, versionRows, seqRows] = await Promise.all([
1500
+ this.driver.query<{ latest_term: number }>(
1501
+ `SELECT latest_term
1502
+ FROM leader_term
1503
+ WHERE collection_id = ?
1504
+ LIMIT 1`,
1505
+ [collectionId],
1506
+ ),
1507
+ this.driver.query<{ latest_row_version: number }>(
1508
+ `SELECT latest_row_version
1509
+ FROM collection_version
1510
+ WHERE collection_id = ?
1511
+ LIMIT 1`,
1512
+ [collectionId],
1513
+ ),
1514
+ this.driver.query<{ max_seq: number }>(
1515
+ `SELECT MAX(seq) AS max_seq
1516
+ FROM applied_tx
1517
+ WHERE collection_id = ? AND term = (
1518
+ SELECT latest_term FROM leader_term WHERE collection_id = ? LIMIT 1
1519
+ )`,
1520
+ [collectionId, collectionId],
1521
+ ),
1522
+ ])
1523
+
1524
+ return {
1525
+ latestTerm: termRows[0]?.latest_term ?? 0,
1526
+ latestSeq: seqRows[0]?.max_seq ?? 0,
1527
+ latestRowVersion: versionRows[0]?.latest_row_version ?? 0,
1528
+ }
1529
+ }
1530
+
1531
+ async pullSince(
1532
+ collectionId: string,
1533
+ fromRowVersion: number,
1534
+ ): Promise<SQLitePullSinceResult<TKey>> {
1535
+ const tableMapping = await this.ensureCollectionReady(collectionId)
1536
+ const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1537
+ const tombstoneTableSql = quoteIdentifier(tableMapping.tombstoneTableName)
1538
+
1539
+ const [changedRows, deletedRows, latestVersionRows, replayRows] =
1540
+ await Promise.all([
1541
+ this.driver.query<{ key: string }>(
1542
+ `SELECT key
1543
+ FROM ${collectionTableSql}
1544
+ WHERE row_version > ?`,
1545
+ [fromRowVersion],
1546
+ ),
1547
+ this.driver.query<{ key: string }>(
1548
+ `SELECT key
1549
+ FROM ${tombstoneTableSql}
1550
+ WHERE row_version > ?`,
1551
+ [fromRowVersion],
1552
+ ),
1553
+ this.driver.query<{ latest_row_version: number }>(
1554
+ `SELECT latest_row_version
1555
+ FROM collection_version
1556
+ WHERE collection_id = ?
1557
+ LIMIT 1`,
1558
+ [collectionId],
1559
+ ),
1560
+ this.driver.query<{
1561
+ tx_id: string
1562
+ row_version: number
1563
+ replay_json: string | null
1564
+ replay_requires_full_reload: number
1565
+ }>(
1566
+ `SELECT tx_id, row_version, replay_json, replay_requires_full_reload
1567
+ FROM applied_tx
1568
+ WHERE collection_id = ? AND row_version > ?
1569
+ ORDER BY term ASC, seq ASC`,
1570
+ [collectionId, fromRowVersion],
1571
+ ),
1572
+ ])
1573
+
1574
+ const latestRowVersion = latestVersionRows[0]?.latest_row_version ?? 0
1575
+ const changedKeyCount = changedRows.length + deletedRows.length
1576
+
1577
+ if (changedKeyCount > this.pullSinceReloadThreshold) {
1578
+ return {
1579
+ latestRowVersion,
1580
+ requiresFullReload: true,
1581
+ }
1582
+ }
1583
+
1584
+ if (
1585
+ replayRows.some(
1586
+ (row) =>
1587
+ row.replay_requires_full_reload !== 0 || row.replay_json == null,
1588
+ )
1589
+ ) {
1590
+ return {
1591
+ latestRowVersion,
1592
+ requiresFullReload: true,
1593
+ }
1594
+ }
1595
+
1596
+ const decodeKey = (encodedKey: string): TKey => {
1597
+ try {
1598
+ return decodePersistedStorageKey(encodedKey) as TKey
1599
+ } catch (error) {
1600
+ throw new InvalidPersistedStorageKeyEncodingError(
1601
+ `${encodedKey}: ${(error as Error).message}`,
1602
+ )
1603
+ }
1604
+ }
1605
+
1606
+ const deltas = replayRows.map((row) => {
1607
+ const parsed = deserializePersistedRowValue<ReplayableTxDelta<
1608
+ Record<string, unknown>,
1609
+ TKey
1610
+ > | null>(row.replay_json ?? `null`)
1611
+ if (!parsed) {
1612
+ throw new InvalidPersistedCollectionConfigError(
1613
+ `missing replay payload for applied_tx row`,
1614
+ )
1615
+ }
1616
+ return parsed
1617
+ })
1618
+
1619
+ const replayChangeCount = deltas.reduce(
1620
+ (count, delta) =>
1621
+ count +
1622
+ delta.changedRows.length +
1623
+ delta.deletedKeys.length +
1624
+ delta.rowMetadataMutations.length +
1625
+ delta.collectionMetadataMutations.length,
1626
+ 0,
1627
+ )
1628
+
1629
+ if (replayChangeCount > this.pullSinceReloadThreshold) {
1630
+ return {
1631
+ latestRowVersion,
1632
+ requiresFullReload: true,
1633
+ }
1634
+ }
1635
+
1636
+ return {
1637
+ latestRowVersion,
1638
+ requiresFullReload: false,
1639
+ changedKeys: changedRows.map((row) => decodeKey(row.key)),
1640
+ deletedKeys: deletedRows.map((row) => decodeKey(row.key)),
1641
+ deltas,
1642
+ }
1643
+ }
1644
+
1645
+ private async loadSubsetInternal(
1646
+ tableMapping: CollectionTableMapping,
1647
+ options: LoadSubsetOptions,
1648
+ ): Promise<Array<InMemoryRow<TKey, T>>> {
1649
+ const collectionTableSql = quoteIdentifier(tableMapping.tableName)
1650
+ const whereCompiled = options.where
1651
+ ? compileSqlExpression(options.where)
1652
+ : { supported: true, sql: ``, params: [] as Array<SqliteSupportedValue> }
1653
+ const orderByCompiled = compileOrderByClauses(options.orderBy)
1654
+
1655
+ const queryParams: Array<SqliteSupportedValue> = []
1656
+ let sql = `SELECT key, value, metadata, row_version FROM ${collectionTableSql}`
1657
+
1658
+ if (options.where && whereCompiled.supported) {
1659
+ sql = `${sql} WHERE ${whereCompiled.sql}`
1660
+ queryParams.push(...whereCompiled.params)
1661
+ }
1662
+
1663
+ if (options.orderBy && orderByCompiled.supported) {
1664
+ sql = `${sql} ORDER BY ${orderByCompiled.sql}, key ASC`
1665
+ queryParams.push(...orderByCompiled.params)
1666
+ }
1667
+
1668
+ const storedRows = await this.driver.query<StoredSqliteRow>(
1669
+ sql,
1670
+ queryParams,
1671
+ )
1672
+ const parsedRows = decodeStoredSqliteRows<TKey, T>(storedRows)
1673
+
1674
+ const filteredRows = this.applyInMemoryWhere(parsedRows, options.where)
1675
+ const orderedRows = this.applyInMemoryOrderBy(filteredRows, options.orderBy)
1676
+ return this.applyInMemoryPagination(
1677
+ orderedRows,
1678
+ options.limit,
1679
+ options.offset,
1680
+ )
1681
+ }
1682
+
1683
+ private applyInMemoryWhere(
1684
+ rows: Array<InMemoryRow<TKey, T>>,
1685
+ where: IR.BasicExpression<boolean> | undefined,
1686
+ ): Array<InMemoryRow<TKey, T>> {
1687
+ if (!where) {
1688
+ return rows
1689
+ }
1690
+
1691
+ const evaluator = compileRowExpressionEvaluator(where)
1692
+ return rows.filter((row) =>
1693
+ toBooleanPredicate(
1694
+ evaluator(row.value as Record<string, unknown>) as boolean | null,
1695
+ ),
1696
+ )
1697
+ }
1698
+
1699
+ private applyInMemoryOrderBy(
1700
+ rows: Array<InMemoryRow<TKey, T>>,
1701
+ orderBy: IR.OrderBy | undefined,
1702
+ ): Array<InMemoryRow<TKey, T>> {
1703
+ if (!orderBy || orderBy.length === 0) {
1704
+ return rows
1705
+ }
1706
+
1707
+ const compiledClauses = orderBy.map((clause) => ({
1708
+ evaluator: compileRowExpressionEvaluator(clause.expression),
1709
+ compareOptions: clause.compareOptions,
1710
+ }))
1711
+
1712
+ const ordered = [...rows]
1713
+ ordered.sort((left, right) => {
1714
+ for (const clause of compiledClauses) {
1715
+ const leftValue = clause.evaluator(
1716
+ left.value as Record<string, unknown>,
1717
+ )
1718
+ const rightValue = clause.evaluator(
1719
+ right.value as Record<string, unknown>,
1720
+ )
1721
+
1722
+ const comparison = compareOrderByValues(
1723
+ leftValue,
1724
+ rightValue,
1725
+ clause.compareOptions,
1726
+ )
1727
+
1728
+ if (comparison !== 0) {
1729
+ return clause.compareOptions.direction === `desc`
1730
+ ? comparison * -1
1731
+ : comparison
1732
+ }
1733
+ }
1734
+
1735
+ const leftKey = encodePersistedStorageKey(left.key)
1736
+ const rightKey = encodePersistedStorageKey(right.key)
1737
+ if (leftKey < rightKey) {
1738
+ return -1
1739
+ }
1740
+ if (leftKey > rightKey) {
1741
+ return 1
1742
+ }
1743
+ return 0
1744
+ })
1745
+
1746
+ return ordered
1747
+ }
1748
+
1749
+ private applyInMemoryPagination(
1750
+ rows: Array<InMemoryRow<TKey, T>>,
1751
+ limit: number | undefined,
1752
+ offset: number | undefined,
1753
+ ): Array<InMemoryRow<TKey, T>> {
1754
+ const start = offset ?? 0
1755
+ if (limit === undefined) {
1756
+ return rows.slice(start)
1757
+ }
1758
+ return rows.slice(start, start + limit)
1759
+ }
1760
+
1761
+ private async touchRequiredIndexes(
1762
+ collectionId: string,
1763
+ requiredIndexSignatures: ReadonlyArray<string> | undefined,
1764
+ ): Promise<void> {
1765
+ if (!requiredIndexSignatures || requiredIndexSignatures.length === 0) {
1766
+ return
1767
+ }
1768
+
1769
+ for (const signature of requiredIndexSignatures) {
1770
+ await this.driver.run(
1771
+ `UPDATE persisted_index_registry
1772
+ SET last_used_at = CAST(strftime('%s', 'now') AS INTEGER),
1773
+ updated_at = CAST(strftime('%s', 'now') AS INTEGER)
1774
+ WHERE collection_id = ? AND signature = ? AND removed = 0`,
1775
+ [collectionId, signature],
1776
+ )
1777
+ }
1778
+ }
1779
+
1780
+ private async pruneAppliedTxRows(
1781
+ collectionId: string,
1782
+ driver: SQLiteDriver = this.driver,
1783
+ ): Promise<void> {
1784
+ if (
1785
+ this.appliedTxPruneMaxAgeSeconds !== undefined &&
1786
+ this.appliedTxPruneMaxAgeSeconds > 0
1787
+ ) {
1788
+ await driver.run(
1789
+ `DELETE FROM applied_tx
1790
+ WHERE collection_id = ?
1791
+ AND applied_at < (CAST(strftime('%s', 'now') AS INTEGER) - ?)`,
1792
+ [collectionId, this.appliedTxPruneMaxAgeSeconds],
1793
+ )
1794
+ }
1795
+
1796
+ if (
1797
+ this.appliedTxPruneMaxRows === undefined ||
1798
+ this.appliedTxPruneMaxRows <= 0
1799
+ ) {
1800
+ return
1801
+ }
1802
+
1803
+ const countRows = await driver.query<{ count: number }>(
1804
+ `SELECT COUNT(*) AS count
1805
+ FROM applied_tx
1806
+ WHERE collection_id = ?`,
1807
+ [collectionId],
1808
+ )
1809
+ const count = countRows[0]?.count ?? 0
1810
+ const excessRows = count - this.appliedTxPruneMaxRows
1811
+ if (excessRows <= 0) {
1812
+ return
1813
+ }
1814
+
1815
+ await driver.run(
1816
+ `DELETE FROM applied_tx
1817
+ WHERE rowid IN (
1818
+ SELECT rowid
1819
+ FROM applied_tx
1820
+ WHERE collection_id = ?
1821
+ ORDER BY term ASC, seq ASC
1822
+ LIMIT ?
1823
+ )`,
1824
+ [collectionId, excessRows],
1825
+ )
1826
+ }
1827
+
1828
+ private async ensureCollectionReady(
1829
+ collectionId: string,
1830
+ ): Promise<CollectionTableMapping> {
1831
+ await this.ensureInitialized()
1832
+
1833
+ const cached = this.collectionTableCache.get(collectionId)
1834
+ if (cached) {
1835
+ return cached
1836
+ }
1837
+
1838
+ const inFlight = this.collectionTableLoads.get(collectionId)
1839
+ if (inFlight) {
1840
+ return inFlight
1841
+ }
1842
+
1843
+ const loadPromise = this.ensureCollectionReadyInternal(collectionId)
1844
+ this.collectionTableLoads.set(collectionId, loadPromise)
1845
+
1846
+ try {
1847
+ return await loadPromise
1848
+ } finally {
1849
+ this.collectionTableLoads.delete(collectionId)
1850
+ }
1851
+ }
1852
+
1853
+ private async ensureCollectionReadyInternal(
1854
+ collectionId: string,
1855
+ ): Promise<CollectionTableMapping> {
1856
+ const existingRows = await this.driver.query<{
1857
+ table_name: string
1858
+ tombstone_table_name: string
1859
+ schema_version: number
1860
+ }>(
1861
+ `SELECT table_name, tombstone_table_name, schema_version
1862
+ FROM collection_registry
1863
+ WHERE collection_id = ?
1864
+ LIMIT 1`,
1865
+ [collectionId],
1866
+ )
1867
+
1868
+ let tableName: string
1869
+ let tombstoneTableName: string
1870
+
1871
+ if (existingRows.length > 0) {
1872
+ tableName = existingRows[0]!.table_name
1873
+ tombstoneTableName = existingRows[0]!.tombstone_table_name
1874
+
1875
+ if (existingRows[0]!.schema_version !== this.schemaVersion) {
1876
+ await this.handleSchemaMismatch(
1877
+ collectionId,
1878
+ existingRows[0]!.schema_version,
1879
+ this.schemaVersion,
1880
+ tableName,
1881
+ tombstoneTableName,
1882
+ )
1883
+ }
1884
+ } else {
1885
+ tableName = createPersistedTableName(collectionId, `c`)
1886
+ tombstoneTableName = createPersistedTableName(collectionId, `t`)
1887
+ await this.driver.run(
1888
+ `INSERT INTO collection_registry (
1889
+ collection_id,
1890
+ table_name,
1891
+ tombstone_table_name,
1892
+ schema_version,
1893
+ updated_at
1894
+ )
1895
+ VALUES (?, ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))`,
1896
+ [collectionId, tableName, tombstoneTableName, this.schemaVersion],
1897
+ )
1898
+ }
1899
+
1900
+ const collectionTableSql = quoteIdentifier(tableName)
1901
+ const tombstoneTableSql = quoteIdentifier(tombstoneTableName)
1902
+
1903
+ await this.driver.exec(
1904
+ `CREATE TABLE IF NOT EXISTS ${collectionTableSql} (
1905
+ key TEXT PRIMARY KEY,
1906
+ value TEXT NOT NULL,
1907
+ metadata TEXT,
1908
+ row_version INTEGER NOT NULL
1909
+ )`,
1910
+ )
1911
+ await this.driver.exec(
1912
+ `CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${tableName}_row_version_idx`)}
1913
+ ON ${collectionTableSql} (row_version)`,
1914
+ )
1915
+ await this.driver.exec(
1916
+ `CREATE TABLE IF NOT EXISTS ${tombstoneTableSql} (
1917
+ key TEXT PRIMARY KEY,
1918
+ value TEXT,
1919
+ row_version INTEGER NOT NULL,
1920
+ deleted_at TEXT NOT NULL
1921
+ )`,
1922
+ )
1923
+ await this.driver.exec(
1924
+ `CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${tombstoneTableName}_row_version_idx`)}
1925
+ ON ${tombstoneTableSql} (row_version)`,
1926
+ )
1927
+ await this.driver.run(
1928
+ `INSERT INTO collection_version (collection_id, latest_row_version)
1929
+ VALUES (?, 0)
1930
+ ON CONFLICT(collection_id) DO NOTHING`,
1931
+ [collectionId],
1932
+ )
1933
+ await this.driver.run(
1934
+ `INSERT INTO collection_reset_epoch (collection_id, reset_epoch, updated_at)
1935
+ VALUES (?, 0, CAST(strftime('%s', 'now') AS INTEGER))
1936
+ ON CONFLICT(collection_id) DO NOTHING`,
1937
+ [collectionId],
1938
+ )
1939
+
1940
+ const mapping = {
1941
+ tableName,
1942
+ tombstoneTableName,
1943
+ }
1944
+ this.collectionTableCache.set(collectionId, mapping)
1945
+ return mapping
1946
+ }
1947
+
1948
+ private async handleSchemaMismatch(
1949
+ collectionId: string,
1950
+ previousSchemaVersion: number,
1951
+ nextSchemaVersion: number,
1952
+ tableName: string,
1953
+ tombstoneTableName: string,
1954
+ ): Promise<void> {
1955
+ if (this.schemaMismatchPolicy === `sync-absent-error`) {
1956
+ throw new InvalidPersistedCollectionConfigError(
1957
+ `Schema version mismatch for collection "${collectionId}": found ${previousSchemaVersion}, expected ${nextSchemaVersion}. ` +
1958
+ `Set schemaMismatchPolicy to "sync-present-reset" or "reset" to allow automatic reset.`,
1959
+ )
1960
+ }
1961
+
1962
+ const collectionTableSql = quoteIdentifier(tableName)
1963
+ const tombstoneTableSql = quoteIdentifier(tombstoneTableName)
1964
+
1965
+ await this.runInTransaction(async (transactionDriver) => {
1966
+ const persistedIndexes = await transactionDriver.query<{
1967
+ index_name: string
1968
+ }>(
1969
+ `SELECT index_name
1970
+ FROM persisted_index_registry
1971
+ WHERE collection_id = ?`,
1972
+ [collectionId],
1973
+ )
1974
+ for (const row of persistedIndexes) {
1975
+ await transactionDriver.exec(
1976
+ `DROP INDEX IF EXISTS ${quoteIdentifier(row.index_name)}`,
1977
+ )
1978
+ }
1979
+
1980
+ await transactionDriver.run(`DELETE FROM ${collectionTableSql}`)
1981
+ await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`)
1982
+ await transactionDriver.run(
1983
+ `DELETE FROM applied_tx
1984
+ WHERE collection_id = ?`,
1985
+ [collectionId],
1986
+ )
1987
+ await transactionDriver.run(
1988
+ `DELETE FROM persisted_index_registry
1989
+ WHERE collection_id = ?`,
1990
+ [collectionId],
1991
+ )
1992
+ await transactionDriver.run(
1993
+ `UPDATE collection_registry
1994
+ SET schema_version = ?,
1995
+ updated_at = CAST(strftime('%s', 'now') AS INTEGER)
1996
+ WHERE collection_id = ?`,
1997
+ [nextSchemaVersion, collectionId],
1998
+ )
1999
+ await transactionDriver.run(
2000
+ `INSERT INTO collection_version (collection_id, latest_row_version)
2001
+ VALUES (?, 0)
2002
+ ON CONFLICT(collection_id) DO UPDATE SET
2003
+ latest_row_version = 0`,
2004
+ [collectionId],
2005
+ )
2006
+ await transactionDriver.run(
2007
+ `INSERT INTO collection_reset_epoch (collection_id, reset_epoch, updated_at)
2008
+ VALUES (?, 1, CAST(strftime('%s', 'now') AS INTEGER))
2009
+ ON CONFLICT(collection_id) DO UPDATE SET
2010
+ reset_epoch = collection_reset_epoch.reset_epoch + 1,
2011
+ updated_at = CAST(strftime('%s', 'now') AS INTEGER)`,
2012
+ [collectionId],
2013
+ )
2014
+ })
2015
+ }
2016
+
2017
+ private async ensureInitialized(): Promise<void> {
2018
+ if (this.initialized) {
2019
+ return
2020
+ }
2021
+
2022
+ await this.driver.exec(
2023
+ `CREATE TABLE IF NOT EXISTS collection_registry (
2024
+ collection_id TEXT PRIMARY KEY,
2025
+ table_name TEXT NOT NULL UNIQUE,
2026
+ tombstone_table_name TEXT NOT NULL UNIQUE,
2027
+ schema_version INTEGER NOT NULL,
2028
+ updated_at INTEGER NOT NULL
2029
+ )`,
2030
+ )
2031
+ await this.driver.exec(
2032
+ `CREATE TABLE IF NOT EXISTS persisted_index_registry (
2033
+ collection_id TEXT NOT NULL,
2034
+ signature TEXT NOT NULL,
2035
+ index_name TEXT NOT NULL,
2036
+ expression_sql TEXT NOT NULL,
2037
+ where_sql TEXT,
2038
+ removed INTEGER NOT NULL DEFAULT 0,
2039
+ created_at INTEGER NOT NULL,
2040
+ updated_at INTEGER NOT NULL,
2041
+ last_used_at INTEGER NOT NULL,
2042
+ PRIMARY KEY (collection_id, signature)
2043
+ )`,
2044
+ )
2045
+ await this.driver.exec(
2046
+ `CREATE TABLE IF NOT EXISTS applied_tx (
2047
+ collection_id TEXT NOT NULL,
2048
+ term INTEGER NOT NULL,
2049
+ seq INTEGER NOT NULL,
2050
+ tx_id TEXT NOT NULL,
2051
+ row_version INTEGER NOT NULL,
2052
+ replay_json TEXT,
2053
+ replay_requires_full_reload INTEGER NOT NULL DEFAULT 0,
2054
+ applied_at INTEGER NOT NULL,
2055
+ PRIMARY KEY (collection_id, term, seq)
2056
+ )`,
2057
+ )
2058
+ try {
2059
+ await this.driver.exec(
2060
+ `ALTER TABLE applied_tx ADD COLUMN replay_json TEXT`,
2061
+ )
2062
+ } catch (error) {
2063
+ if (!isDuplicateColumnAddError(error, `replay_json`)) {
2064
+ throw error
2065
+ }
2066
+ }
2067
+ try {
2068
+ await this.driver.exec(
2069
+ `ALTER TABLE applied_tx ADD COLUMN replay_requires_full_reload INTEGER NOT NULL DEFAULT 0`,
2070
+ )
2071
+ } catch (error) {
2072
+ if (!isDuplicateColumnAddError(error, `replay_requires_full_reload`)) {
2073
+ throw error
2074
+ }
2075
+ }
2076
+ await this.driver.exec(
2077
+ `CREATE TABLE IF NOT EXISTS collection_version (
2078
+ collection_id TEXT PRIMARY KEY,
2079
+ latest_row_version INTEGER NOT NULL
2080
+ )`,
2081
+ )
2082
+ await this.driver.exec(
2083
+ `CREATE TABLE IF NOT EXISTS collection_metadata (
2084
+ collection_id TEXT NOT NULL,
2085
+ key TEXT NOT NULL,
2086
+ value TEXT NOT NULL,
2087
+ updated_at INTEGER NOT NULL,
2088
+ PRIMARY KEY (collection_id, key)
2089
+ )`,
2090
+ )
2091
+ await this.driver.exec(
2092
+ `CREATE TABLE IF NOT EXISTS leader_term (
2093
+ collection_id TEXT PRIMARY KEY,
2094
+ latest_term INTEGER NOT NULL
2095
+ )`,
2096
+ )
2097
+ await this.driver.exec(
2098
+ `CREATE TABLE IF NOT EXISTS schema_version (
2099
+ scope TEXT PRIMARY KEY,
2100
+ version INTEGER NOT NULL,
2101
+ updated_at INTEGER NOT NULL
2102
+ )`,
2103
+ )
2104
+ await this.driver.run(
2105
+ `INSERT INTO schema_version (scope, version, updated_at)
2106
+ VALUES ('global', ?, CAST(strftime('%s', 'now') AS INTEGER))
2107
+ ON CONFLICT(scope) DO UPDATE SET
2108
+ version = excluded.version,
2109
+ updated_at = excluded.updated_at`,
2110
+ [this.schemaVersion],
2111
+ )
2112
+ await this.driver.exec(
2113
+ `CREATE TABLE IF NOT EXISTS collection_reset_epoch (
2114
+ collection_id TEXT PRIMARY KEY,
2115
+ reset_epoch INTEGER NOT NULL,
2116
+ updated_at INTEGER NOT NULL
2117
+ )`,
2118
+ )
2119
+
2120
+ this.initialized = true
2121
+ }
2122
+ }
2123
+
2124
+ export function createSQLiteCorePersistenceAdapter<
2125
+ T extends object,
2126
+ TKey extends string | number = string | number,
2127
+ >(options: SQLiteCoreAdapterOptions): PersistenceAdapter<T, TKey> {
2128
+ return new SQLiteCorePersistenceAdapter<T, TKey>(options)
2129
+ }