@tanstack/db-sqlite-persistence-core 0.4.3 → 0.4.5

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.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  IR,
3
+ compareTemporalValues,
3
4
  compileSingleRowExpression,
4
5
  toBooleanPredicate,
5
6
  } from '@tanstack/db'
@@ -17,7 +18,11 @@ import {
17
18
  PERSISTED_TYPE_TAG,
18
19
  PERSISTED_VALUE_TAG,
19
20
  assertSQLiteBigIntInRange,
21
+ reviveSQLiteTemporal,
20
22
  serializeSQLiteBigInt,
23
+ serializeSQLiteTemporal,
24
+ sqliteTemporalIdentity,
25
+ sqliteTemporalKind,
21
26
  } from './sqlite-value'
22
27
  import type { LoadSubsetOptions } from '@tanstack/db'
23
28
  import type {
@@ -36,7 +41,7 @@ type SqliteSupportedValue = null | number | string
36
41
 
37
42
  // The default stays below SQLite's older 999-variable limit. Drivers with a
38
43
  // lower binding cap use smaller chunks; each replacement row binds four values.
39
- const REPLACEMENT_BATCH_SIZE = 100
44
+ const REPLACEMENT_BATCH_SIZE = 125
40
45
 
41
46
  type CollectionTableMapping = {
42
47
  tableName: string
@@ -47,6 +52,7 @@ type CompiledSqlFragment = {
47
52
  supported: boolean
48
53
  sql: string
49
54
  params: Array<SqliteSupportedValue>
55
+ identitySql?: string
50
56
  valueKind?: CompiledValueKind
51
57
  }
52
58
 
@@ -60,9 +66,7 @@ type StoredSqliteRow = {
60
66
  }
61
67
 
62
68
  type SQLiteCoreAdapterSchemaMismatchPolicy =
63
- | `sync-present-reset`
64
- | `sync-absent-error`
65
- | `reset`
69
+ `sync-present-reset` | `sync-absent-error` | `reset`
66
70
 
67
71
  export type SQLiteCoreAdapterOptions = {
68
72
  driver: SQLiteDriver
@@ -222,6 +226,7 @@ function observeSharedLogicalSchedulingSupport(
222
226
  }
223
227
 
224
228
  return {
229
+ maxBoundParameters: driver.maxBoundParameters,
225
230
  exec: (sql) => observe(driver.exec(sql)),
226
231
  query: <T>(sql: string, params: ReadonlyArray<unknown> = []) =>
227
232
  observe(driver.query<T>(sql, params)),
@@ -256,7 +261,6 @@ export const DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS = 1_000
256
261
  */
257
262
  export const DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS = 24 * 60 * 60
258
263
 
259
- const SQLITE_MAX_IN_BATCH_SIZE = 900
260
264
  const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
261
265
  const FORBIDDEN_SQL_FRAGMENT_PATTERN = /(;|--|\/\*)/
262
266
  type CompiledValueKind = `unknown` | `bigint` | `date` | `datetime`
@@ -266,6 +270,9 @@ type PersistedTaggedValueType =
266
270
  | `nan`
267
271
  | `infinity`
268
272
  | `-infinity`
273
+ | `string`
274
+ | `Temporal.Instant`
275
+ | `Temporal.PlainDate`
269
276
  type PersistedTaggedValue = {
270
277
  [PERSISTED_TYPE_TAG]: PersistedTaggedValueType
271
278
  [PERSISTED_VALUE_TAG]: string
@@ -277,6 +284,9 @@ const persistedTaggedValueTypes = new Set<PersistedTaggedValueType>([
277
284
  `nan`,
278
285
  `infinity`,
279
286
  `-infinity`,
287
+ `string`,
288
+ `Temporal.Instant`,
289
+ `Temporal.PlainDate`,
280
290
  ])
281
291
 
282
292
  const orderByObjectIds = new WeakMap<object, number>()
@@ -339,6 +349,9 @@ function encodePersistedJsonValue(value: unknown): unknown {
339
349
  return serializeSQLiteBigInt(value) satisfies PersistedTaggedValue
340
350
  }
341
351
 
352
+ const temporal = serializeSQLiteTemporal(value)
353
+ if (temporal) return temporal
354
+
342
355
  if (value instanceof Date) {
343
356
  return {
344
357
  [PERSISTED_TYPE_TAG]: `date`,
@@ -381,6 +394,13 @@ function encodePersistedJsonValue(value: unknown): unknown {
381
394
  encodedRecord[key] = encodedValue
382
395
  }
383
396
  }
397
+ // Escape the marker field itself, leaving ordinary nested JSON paths intact.
398
+ if (typeof recordValue[PERSISTED_TYPE_TAG] === `string`) {
399
+ encodedRecord[PERSISTED_TYPE_TAG] = {
400
+ [PERSISTED_TYPE_TAG]: `string`,
401
+ [PERSISTED_VALUE_TAG]: recordValue[PERSISTED_TYPE_TAG],
402
+ }
403
+ }
384
404
  return encodedRecord
385
405
  }
386
406
 
@@ -394,6 +414,14 @@ function decodePersistedJsonValue(value: unknown): unknown {
394
414
 
395
415
  if (isPersistedTaggedValue(value)) {
396
416
  switch (value[PERSISTED_TYPE_TAG]) {
417
+ case `string`:
418
+ return value[PERSISTED_VALUE_TAG]
419
+ case `Temporal.Instant`:
420
+ case `Temporal.PlainDate`:
421
+ return reviveSQLiteTemporal(
422
+ value[PERSISTED_TYPE_TAG],
423
+ value[PERSISTED_VALUE_TAG],
424
+ )
397
425
  case `bigint`:
398
426
  return BigInt(value[PERSISTED_VALUE_TAG])
399
427
  case `date`: {
@@ -465,7 +493,8 @@ function toSqliteParameterValue(value: unknown): SqliteSupportedValue {
465
493
  return value.toISOString()
466
494
  }
467
495
 
468
- return serializePersistedRowValue(value)
496
+ const temporal = serializeSQLiteTemporal(value)
497
+ return temporal?.order ?? serializePersistedRowValue(value)
469
498
  }
470
499
 
471
500
  function toSqliteLiteral(value: SqliteSupportedValue): string {
@@ -477,6 +506,10 @@ function toSqliteLiteral(value: SqliteSupportedValue): string {
477
506
  return Number.isFinite(value) ? String(value) : `NULL`
478
507
  }
479
508
 
509
+ if (value.includes(`\u0000`)) {
510
+ // JSON escapes NUL before SQL parsing and matches stored-string extraction.
511
+ return `json_extract(${toSqliteLiteral(JSON.stringify(value))}, '$')`
512
+ }
480
513
  return `'${value.replace(/'/g, `''`)}'`
481
514
  }
482
515
 
@@ -569,6 +602,10 @@ function compareOrderByValues(
569
602
  return left.getTime() - right.getTime()
570
603
  }
571
604
 
605
+ if (sqliteTemporalKind(left) && sqliteTemporalKind(right)) {
606
+ return compareTemporalValues(left, right)
607
+ }
608
+
572
609
  const leftIsObject = typeof left === `object`
573
610
  const rightIsObject = typeof right === `object`
574
611
  if (leftIsObject || rightIsObject) {
@@ -610,6 +647,39 @@ function createJsonPath(path: Array<string>): string | null {
610
647
  return jsonPath
611
648
  }
612
649
 
650
+ const MAX_INDEXED_NUMERIC_PATH_SEGMENTS = 3
651
+
652
+ function hasDeepNumericPath(expression: IR.BasicExpression): boolean {
653
+ if (expression.type === `ref`) {
654
+ return (
655
+ IR.getPropRefPropertyPath(expression).filter((segment) =>
656
+ /^[0-9]+$/.test(String(segment)),
657
+ ).length > MAX_INDEXED_NUMERIC_PATH_SEGMENTS
658
+ )
659
+ }
660
+ return expression.type === `func` && expression.args.some(hasDeepNumericPath)
661
+ }
662
+
663
+ function createJsonPathVariants(path: Array<string>): Array<string> | null {
664
+ const canonical = createJsonPath(path)
665
+ if (!canonical) return null
666
+
667
+ let paths = [`$`]
668
+ for (const segment of path) {
669
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment)) {
670
+ paths = paths.map((prefix) => `${prefix}.${segment}`)
671
+ } else if (/^[0-9]+$/.test(segment)) {
672
+ if (paths.length >= 2 ** MAX_INDEXED_NUMERIC_PATH_SEGMENTS)
673
+ return [canonical]
674
+ paths = paths.flatMap((prefix) => [
675
+ `${prefix}[${segment}]`,
676
+ `${prefix}."${segment}"`,
677
+ ])
678
+ }
679
+ }
680
+ return paths
681
+ }
682
+
613
683
  function getLiteralValueKind(value: unknown): CompiledValueKind {
614
684
  if (typeof value === `bigint`) {
615
685
  return `bigint`
@@ -693,16 +763,23 @@ function compileRefExpressionSql(jsonPath: string): CompiledSqlFragment {
693
763
  const taggedValuePathSql = toSqliteLiteral(taggedValuePath)
694
764
  const jsonPathSql = toSqliteLiteral(jsonPath)
695
765
 
696
- return {
697
- supported: true,
698
- sql: `(CASE json_extract(value, ${typePathSql})
766
+ const typeSql = `json_extract(value, ${typePathSql})`
767
+ const orderPathSql = toSqliteLiteral(`${jsonPath}.order`)
768
+ const sql = `(CASE ${typeSql}
699
769
  WHEN 'bigint' THEN CAST(json_extract(value, ${taggedValuePathSql}) AS NUMERIC)
700
770
  WHEN 'date' THEN json_extract(value, ${taggedValuePathSql})
771
+ WHEN 'string' THEN json_extract(value, ${taggedValuePathSql})
772
+ WHEN 'Temporal.Instant' THEN json_extract(value, ${orderPathSql})
773
+ WHEN 'Temporal.PlainDate' THEN json_extract(value, ${orderPathSql})
701
774
  WHEN 'nan' THEN NULL
702
775
  WHEN 'infinity' THEN NULL
703
776
  WHEN '-infinity' THEN NULL
704
777
  ELSE json_extract(value, ${jsonPathSql})
705
- END)`,
778
+ END)`
779
+ return {
780
+ supported: true,
781
+ sql,
782
+ identitySql: `(CASE WHEN ${typeSql} IN ('Temporal.Instant', 'Temporal.PlainDate') THEN ${typeSql} || ':' || json_extract(value, ${taggedValuePathSql}) ELSE ${sql} END)`,
706
783
  params: [],
707
784
  valueKind: `unknown`,
708
785
  }
@@ -754,6 +831,13 @@ function stableStringify(value: unknown): string {
754
831
  return serializePersistedRowValue(value)
755
832
  }
756
833
 
834
+ /** Balance disjunctions so a large membership index stays below SQL depth limits. */
835
+ function joinSqlDisjunction(parts: ReadonlyArray<string>): string {
836
+ if (parts.length === 1) return parts[0]!
837
+ const middle = Math.floor(parts.length / 2)
838
+ return `(${joinSqlDisjunction(parts.slice(0, middle))} OR ${joinSqlDisjunction(parts.slice(middle))})`
839
+ }
840
+
757
841
  function argumentCompilationContext(
758
842
  parentName: string,
759
843
  argumentIndex: number,
@@ -788,31 +872,78 @@ function argumentCompilationContext(
788
872
  }
789
873
  }
790
874
 
875
+ function hasUnpairedSurrogate(value: string): boolean {
876
+ // With /u, paired surrogates form one code point; lone surrogates still match.
877
+ return /[\uD800-\uDFFF]/u.test(value)
878
+ }
879
+
880
+ function isSafeEqualityLiteral(value: unknown): boolean {
881
+ return (
882
+ typeof value === `string` ||
883
+ typeof value === `boolean` ||
884
+ typeof value === `bigint` ||
885
+ sqliteTemporalKind(value) !== undefined
886
+ )
887
+ }
888
+
889
+ function isSafeCoalesceOperand(
890
+ expression: IR.BasicExpression | undefined,
891
+ ): boolean {
892
+ return (
893
+ expression?.type === `func` &&
894
+ expression.name === `coalesce` &&
895
+ expression.args.length === 2 &&
896
+ expression.args[0]?.type === `ref` &&
897
+ expression.args[1]?.type === `val` &&
898
+ isSafeEqualityLiteral(expression.args[1].value) &&
899
+ !(
900
+ typeof expression.args[1].value === `string` &&
901
+ hasUnpairedSurrogate(expression.args[1].value)
902
+ )
903
+ )
904
+ }
905
+
791
906
  function compileSqlExpression(
792
907
  expression: IR.BasicExpression,
793
908
  context: SqlExpressionCompilationContext = `predicate`,
794
909
  ): CompiledSqlFragment {
795
910
  if (expression.type === `val`) {
796
- const valueKind = getLiteralValueKind(expression.value)
911
+ if (
912
+ context === `predicate` &&
913
+ typeof expression.value === `string` &&
914
+ hasUnpairedSurrogate(expression.value)
915
+ ) {
916
+ // JSON extraction changes lone surrogates in stored strings. Apply the
917
+ // same extraction to the binding so an indexed equality keeps its row.
918
+ return {
919
+ supported: true,
920
+ sql: `json_extract(?, '$')`,
921
+ params: [JSON.stringify(expression.value)],
922
+ }
923
+ }
924
+ const temporal = serializeSQLiteTemporal(expression.value)
925
+ const value = temporal?.order ?? toSqliteParameterValue(expression.value)
797
926
  return {
927
+ identitySql: temporal
928
+ ? toSqliteLiteral(sqliteTemporalIdentity(temporal))
929
+ : undefined,
798
930
  supported: true,
799
931
  sql:
800
932
  context === `index-expression`
801
- ? toSqliteExpressionLiteral(expression.value)
933
+ ? toSqliteExpressionLiteral(
934
+ typeof expression.value === `bigint` ? expression.value : value,
935
+ )
802
936
  : `?`,
803
- params:
804
- context === `predicate`
805
- ? [toSqliteParameterValue(expression.value)]
806
- : [],
807
- valueKind,
937
+ params: context === `predicate` ? [value] : [],
938
+ valueKind: getLiteralValueKind(expression.value),
808
939
  }
809
940
  }
810
941
 
811
942
  if (expression.type === `ref`) {
812
- const jsonPath = createJsonPath(
943
+ const jsonPaths = createJsonPathVariants(
813
944
  IR.getPropRefPropertyPath(expression).map(String),
814
945
  )
815
- if (!jsonPath) {
946
+ if (!jsonPaths) {
816
947
  return {
817
948
  supported: false,
818
949
  sql: ``,
@@ -820,10 +951,21 @@ function compileSqlExpression(
820
951
  }
821
952
  }
822
953
 
823
- return compileRefExpressionSql(jsonPath)
954
+ if (jsonPaths.length === 1) return compileRefExpressionSql(jsonPaths[0]!)
955
+ const variants = jsonPaths.map(compileRefExpressionSql)
956
+ return {
957
+ supported: true,
958
+ identitySql: `COALESCE(${variants.map((variant) => variant.identitySql).join(`, `)})`,
959
+ sql: `COALESCE(${variants.map((variant) => variant.sql).join(`, `)})`,
960
+ params: [],
961
+ valueKind: `unknown`,
962
+ }
824
963
  }
825
964
 
826
- const compiledArgs = expression.args.map((arg, index) =>
965
+ // IN owns its list encoding; compiling that list as a scalar discards work.
966
+ const args =
967
+ expression.name === `in` ? expression.args.slice(0, 1) : expression.args
968
+ const compiledArgs = args.map((arg, index) =>
827
969
  compileSqlExpression(
828
970
  arg,
829
971
  argumentCompilationContext(expression.name, index, arg, context),
@@ -873,38 +1015,49 @@ function compileSqlExpression(
873
1015
  lte: `<=`,
874
1016
  }
875
1017
 
1018
+ const comparison = compileComparisonSql(
1019
+ operatorByName[expression.name],
1020
+ expression.args[0]!,
1021
+ expression.args[1]!,
1022
+ argSql[0],
1023
+ argSql[1],
1024
+ valueKind,
1025
+ getCompiledValueKind(compiledArgs[0]),
1026
+ getCompiledValueKind(compiledArgs[1]),
1027
+ )
1028
+ // Native order ties need canonical identity, including in persisted
1029
+ // expressions. Ordinary string predicates keep one binding; residual
1030
+ // filtering removes native order-key collisions from their candidate set.
1031
+ const needsIdentity =
1032
+ expression.name === `eq` &&
1033
+ expression.args.every(
1034
+ (arg) =>
1035
+ arg.type !== `val` ||
1036
+ sqliteTemporalKind(arg.value) ||
1037
+ (context === `index-expression` && typeof arg.value === `string`),
1038
+ )
876
1039
  return {
877
1040
  supported: true,
878
- sql: compileComparisonSql(
879
- operatorByName[expression.name],
880
- expression.args[0]!,
881
- expression.args[1]!,
882
- argSql[0],
883
- argSql[1],
884
- valueKind,
885
- getCompiledValueKind(compiledArgs[0]),
886
- getCompiledValueKind(compiledArgs[1]),
887
- ),
888
- params,
889
- }
890
- }
891
- case `and`: {
892
- if (argSql.length < 2) {
893
- return { supported: false, sql: ``, params: [] }
894
- }
895
- return {
896
- supported: true,
897
- sql: argSql.map((sql) => `(${sql})`).join(` AND `),
1041
+ sql: needsIdentity
1042
+ ? `(${comparison} AND (${compiledArgs[0].identitySql ?? argSql[0]} = ${compiledArgs[1].identitySql ?? argSql[1]}))`
1043
+ : comparison,
898
1044
  params,
899
1045
  }
900
1046
  }
1047
+ case `and`:
901
1048
  case `or`: {
902
- if (argSql.length < 2) {
903
- return { supported: false, sql: ``, params: [] }
1049
+ if (argSql.length === 0) {
1050
+ return {
1051
+ supported: true,
1052
+ sql: expression.name === `and` ? `(1 = 1)` : `(0 = 1)`,
1053
+ params: [],
1054
+ }
904
1055
  }
905
1056
  return {
906
1057
  supported: true,
907
- sql: argSql.map((sql) => `(${sql})`).join(` OR `),
1058
+ sql: argSql
1059
+ .map((sql) => `(${sql})`)
1060
+ .join(expression.name === `and` ? ` AND ` : ` OR `),
908
1061
  params,
909
1062
  }
910
1063
  }
@@ -938,54 +1091,54 @@ function compileSqlExpression(
938
1091
  return { supported: false, sql: ``, params: [] }
939
1092
  }
940
1093
 
941
- if (context === `index-expression`) {
1094
+ // Order and identity are two projections of one validated native encoding.
1095
+ const values = listValue.map((value) => {
1096
+ const temporal = serializeSQLiteTemporal(value)
942
1097
  return {
943
- supported: true,
944
- sql: `(${leftSql} IN (${listValue
945
- .map((value) => toSqliteExpressionLiteral(value))
946
- .join(`, `)}))`,
947
- params: leftParams,
1098
+ value:
1099
+ typeof value === `bigint`
1100
+ ? assertSQLiteBigIntInRange(value)
1101
+ : (temporal?.order ?? toSqliteParameterValue(value)),
1102
+ identity: temporal ? sqliteTemporalIdentity(temporal) : undefined,
948
1103
  }
1104
+ })
1105
+ let identity = ``
1106
+ if (
1107
+ listValue.some(
1108
+ (value) =>
1109
+ sqliteTemporalKind(value) ||
1110
+ (context === `index-expression` && typeof value === `string`),
1111
+ )
1112
+ ) {
1113
+ const leftIdentity = compiledArgs[0]?.identitySql ?? leftSql
1114
+ // Index expressions cannot contain subqueries. Both forms preserve
1115
+ // correlated membership and SQL's three-valued Boolean semantics.
1116
+ identity =
1117
+ context === `index-expression`
1118
+ ? ` AND (${joinSqlDisjunction(values.map(({ value, identity: nativeIdentity }) => `(${leftSql} = ${toSqliteExpressionLiteral(value)} AND ${leftIdentity} = ${toSqliteExpressionLiteral(nativeIdentity ?? value)})`))})`
1119
+ : ` AND ((${leftSql}, ${leftIdentity}) IN (VALUES ${[...new Set(values.map(({ value, identity: nativeIdentity }) => `(${toSqliteExpressionLiteral(value)}, ${toSqliteExpressionLiteral(nativeIdentity ?? value)})`))].join(`, `)}))`
949
1120
  }
950
-
951
- if (listValue.length > SQLITE_MAX_IN_BATCH_SIZE) {
952
- const chunkClauses: Array<string> = []
953
- const batchedParams: Array<SqliteSupportedValue> = []
954
-
955
- for (
956
- let startIndex = 0;
957
- startIndex < listValue.length;
958
- startIndex += SQLITE_MAX_IN_BATCH_SIZE
959
- ) {
960
- const chunkValues = listValue.slice(
961
- startIndex,
962
- startIndex + SQLITE_MAX_IN_BATCH_SIZE,
963
- )
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)
971
- }
972
-
1121
+ if (context === `index-expression`) {
973
1122
  return {
974
1123
  supported: true,
975
- sql: `(${chunkClauses.join(` OR `)})`,
976
- params: batchedParams,
1124
+ sql: `(${leftSql} IN (${values
1125
+ .map(({ value }) => toSqliteExpressionLiteral(value))
1126
+ .join(`, `)})${identity})`,
1127
+ params: leftParams,
977
1128
  }
978
1129
  }
979
1130
 
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
- })
1131
+ const jsonList = `[${values
1132
+ .map(({ value }) =>
1133
+ typeof value === `bigint`
1134
+ ? assertSQLiteBigIntInRange(value).toString()
1135
+ : JSON.stringify(value),
1136
+ )
1137
+ .join(`,`)}]`
985
1138
  return {
986
1139
  supported: true,
987
- sql: `(${leftSql} IN (${listValueSql.join(`, `)}))`,
988
- params: [...leftParams, ...listParams],
1140
+ sql: `(${leftSql} IN (SELECT value FROM json_each(?))${identity})`,
1141
+ params: [...leftParams, jsonList],
989
1142
  }
990
1143
  }
991
1144
  case `like`:
@@ -1012,7 +1165,12 @@ function compileSqlExpression(
1012
1165
  case `concat`:
1013
1166
  return { supported: true, sql: `(${argSql.join(` || `)})`, params }
1014
1167
  case `coalesce`:
1015
- return { supported: true, sql: `COALESCE(${argSql.join(`, `)})`, params }
1168
+ return {
1169
+ supported: true,
1170
+ sql: `COALESCE(${argSql.join(`, `)})`,
1171
+ identitySql: `COALESCE(${compiledArgs.map((arg) => arg.identitySql ?? arg.sql).join(`, `)})`,
1172
+ params,
1173
+ }
1016
1174
  case `add`:
1017
1175
  return { supported: true, sql: `(${argSql[0]} + ${argSql[1]})`, params }
1018
1176
  case `subtract`:
@@ -1050,6 +1208,235 @@ function compileSqlExpression(
1050
1208
  }
1051
1209
  }
1052
1210
 
1211
+ /** A raw ref/native-literal leaf has exact native truth before prefilter broadening. */
1212
+ function nativeComparisonRef(
1213
+ expression: IR.BasicExpression,
1214
+ ): IR.PropRef | undefined {
1215
+ if (expression.type !== `func` || expression.args.length !== 2)
1216
+ return undefined
1217
+ const [left, right] = expression.args
1218
+ if (expression.name === `in`) {
1219
+ return left?.type === `ref` &&
1220
+ right?.type === `val` &&
1221
+ Array.isArray(right.value) &&
1222
+ right.value.length > 0 &&
1223
+ right.value.every((value) => sqliteTemporalKind(value))
1224
+ ? left
1225
+ : undefined
1226
+ }
1227
+ if (![`eq`, `gt`, `gte`, `lt`, `lte`].includes(expression.name))
1228
+ return undefined
1229
+ if (
1230
+ left?.type === `ref` &&
1231
+ right?.type === `val` &&
1232
+ sqliteTemporalKind(right.value)
1233
+ )
1234
+ return left
1235
+ if (
1236
+ right?.type === `ref` &&
1237
+ left?.type === `val` &&
1238
+ sqliteTemporalKind(left.value)
1239
+ )
1240
+ return right
1241
+ return undefined
1242
+ }
1243
+
1244
+ function compileNativeRefGuard(ref: IR.PropRef): string {
1245
+ const paths = createJsonPathVariants(
1246
+ IR.getPropRefPropertyPath(ref).map(String),
1247
+ )!
1248
+ const kinds = paths.map(
1249
+ (path) =>
1250
+ `json_extract(value, ${toSqliteLiteral(`${path}.${PERSISTED_TYPE_TAG}`)})`,
1251
+ )
1252
+ const kind = kinds.length === 1 ? kinds[0] : `COALESCE(${kinds.join(`, `)})`
1253
+ return `${kind} IN ('Temporal.Instant', 'Temporal.PlainDate')`
1254
+ }
1255
+
1256
+ function compileSafeSqlPrefilter(
1257
+ expression: IR.BasicExpression,
1258
+ compiled?: CompiledSqlFragment,
1259
+ ): CompiledSqlFragment | undefined {
1260
+ // Every public match must pass this SQL candidate before JavaScript applies
1261
+ // the authoritative row predicate. Extra candidates are allowed.
1262
+ const direct = () => {
1263
+ const candidate = compiled ?? compileSqlExpression(expression)
1264
+ return candidate.supported ? candidate : undefined
1265
+ }
1266
+ if (expression.type === `val`) {
1267
+ return typeof expression.value === `boolean` || expression.value == null
1268
+ ? direct()
1269
+ : undefined
1270
+ }
1271
+ if (expression.type === `ref`) return undefined
1272
+
1273
+ if (expression.name === `and` || expression.name === `or`) {
1274
+ if (expression.args.length === 0) return direct()
1275
+ const candidates = expression.args.map((argument) =>
1276
+ compileSafeSqlPrefilter(argument),
1277
+ )
1278
+ if (expression.name === `or` && candidates.some((candidate) => !candidate))
1279
+ return undefined
1280
+ const selected = candidates.filter(
1281
+ (candidate): candidate is CompiledSqlFragment => candidate !== undefined,
1282
+ )
1283
+ if (selected.length === 0) return undefined
1284
+ return {
1285
+ supported: true,
1286
+ sql: selected
1287
+ .map(({ sql }) => `(${sql})`)
1288
+ .join(expression.name === `and` ? ` AND ` : ` OR `),
1289
+ params: selected.flatMap(({ params }) => params),
1290
+ }
1291
+ }
1292
+
1293
+ // More than three digit segments need too many alternative JSON paths.
1294
+ // Keep their prefilter unbounded rather than exclude an object-key match.
1295
+ if (hasDeepNumericPath(expression)) return undefined
1296
+ compiled ??= compileSqlExpression(expression)
1297
+ if (!compiled.supported) return undefined
1298
+ const [left, right] = expression.args
1299
+ if (
1300
+ expression.name === `not` &&
1301
+ expression.args.length === 1 &&
1302
+ left &&
1303
+ nativeComparisonRef(left)
1304
+ ) {
1305
+ // Negate the original leaf, never a broadened prefilter. Nonfinite stored
1306
+ // scalars project to NULL; retaining them also retains valid NOT matches.
1307
+ return { ...compiled, sql: `COALESCE(${compiled.sql}, 1)` }
1308
+ }
1309
+ if (expression.args.length === 2) {
1310
+ const nativeRef = nativeComparisonRef(expression)
1311
+ if (nativeRef) {
1312
+ if (expression.name === `eq` || expression.name === `in`) return compiled
1313
+ // NaN orders above native values in the core evaluator. NULL candidates
1314
+ // also preserve that law with either operand direction.
1315
+ const fieldSql = compileSqlExpression(nativeRef, `index-expression`).sql
1316
+ return { ...compiled, sql: `(${compiled.sql} OR ${fieldSql} IS NULL)` }
1317
+ }
1318
+ if (
1319
+ expression.name === `eq` &&
1320
+ left?.type === `ref` &&
1321
+ right?.type === `ref`
1322
+ ) {
1323
+ // Ref/ref scalar coercions are not generally SQL-exact. Restrict only
1324
+ // native pairs; leave every other pair to the authoritative evaluator.
1325
+ return {
1326
+ ...compiled,
1327
+ sql: `(CASE WHEN ${compileNativeRefGuard(left)} AND ${compileNativeRefGuard(right)} THEN ${compiled.sql} ELSE 1 END)`,
1328
+ }
1329
+ }
1330
+ if (
1331
+ [`gt`, `gte`, `lt`, `lte`].includes(expression.name) &&
1332
+ (left?.type === `ref` || right?.type === `ref`)
1333
+ ) {
1334
+ const field =
1335
+ left?.type === `ref` ? left : right?.type === `ref` ? right : undefined
1336
+ const literal = field === left ? right : left
1337
+ if (
1338
+ field &&
1339
+ literal?.type === `val` &&
1340
+ ((typeof literal.value === `number` &&
1341
+ Number.isSafeInteger(literal.value)) ||
1342
+ typeof literal.value === `bigint`)
1343
+ ) {
1344
+ const fieldSql = compileSqlExpression(field, `index-expression`).sql
1345
+ const literalSql = compileSqlExpression(
1346
+ literal,
1347
+ typeof literal.value === `bigint` ? `index-expression` : `predicate`,
1348
+ )
1349
+ const greaterSide =
1350
+ (field === left && [`gt`, `gte`].includes(expression.name)) ||
1351
+ (field === right && [`lt`, `lte`].includes(expression.name))
1352
+ const unsafeBigInt =
1353
+ typeof literal.value === `bigint` &&
1354
+ !Number.isSafeInteger(Number(literal.value))
1355
+ const roundedNumberCandidates = unsafeBigInt
1356
+ ? literal.value > 0n
1357
+ ? ` OR ${fieldSql} >= ${Number.MAX_SAFE_INTEGER}`
1358
+ : ` OR ${fieldSql} <= -${Number.MAX_SAFE_INTEGER}`
1359
+ : ``
1360
+ return {
1361
+ supported: true,
1362
+ sql: greaterSide
1363
+ ? `(${fieldSql} >= ${literalSql.sql} OR ${fieldSql} IS NULL${roundedNumberCandidates})`
1364
+ : `(${fieldSql} <= ${literalSql.sql} OR ${fieldSql} IS NULL OR ${fieldSql} >= ''${roundedNumberCandidates})`,
1365
+ params: literalSql.params,
1366
+ }
1367
+ }
1368
+ }
1369
+ if (expression.name === `eq`) {
1370
+ if (
1371
+ ((left?.type === `ref` || isSafeCoalesceOperand(left)) &&
1372
+ right?.type === `val` &&
1373
+ isSafeEqualityLiteral(right.value)) ||
1374
+ ((right?.type === `ref` || isSafeCoalesceOperand(right)) &&
1375
+ left?.type === `val` &&
1376
+ isSafeEqualityLiteral(left.value))
1377
+ ) {
1378
+ return compiled
1379
+ }
1380
+ const lower =
1381
+ left?.type === `func` && left.name === `lower`
1382
+ ? left
1383
+ : right?.type === `func` && right.name === `lower`
1384
+ ? right
1385
+ : undefined
1386
+ const literal = lower === left ? right : left
1387
+ if (
1388
+ lower?.args.length === 1 &&
1389
+ lower.args[0]?.type === `ref` &&
1390
+ literal?.type === `val` &&
1391
+ typeof literal.value === `string` &&
1392
+ [...literal.value].every((char) => char.charCodeAt(0) <= 0x7f) &&
1393
+ !literal.value.includes(`\u0000`)
1394
+ ) {
1395
+ const lowerSql = compileSqlExpression(lower, `index-expression`).sql
1396
+ return {
1397
+ supported: true,
1398
+ sql: `(${compiled.sql} OR (${lowerSql} >= ? AND ${lowerSql} GLOB '*[^ -~]*'))`,
1399
+ params: [...compiled.params, literal.value],
1400
+ }
1401
+ }
1402
+ }
1403
+ }
1404
+ if (
1405
+ expression.name === `in` &&
1406
+ expression.args.length === 2 &&
1407
+ right?.type === `val` &&
1408
+ Array.isArray(right.value)
1409
+ ) {
1410
+ if (right.value.length === 0) return compiled
1411
+ if (left?.type === `ref`) {
1412
+ if (right.value.every((value) => typeof value === `bigint`))
1413
+ return compiled
1414
+ if (right.value.every((value) => typeof value === `string`)) {
1415
+ // Both sides pass through SQLite's JSON decoder; one binding keeps
1416
+ // large string scopes within the host parameter limit, even in OR.
1417
+ return {
1418
+ supported: true,
1419
+ sql: `(${compileSqlExpression(left, `index-expression`).sql} IN (SELECT value FROM json_each(?)))`,
1420
+ params: [JSON.stringify(right.value)],
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ if (
1426
+ expression.name === `like` &&
1427
+ expression.args.length === 2 &&
1428
+ left?.type === `ref` &&
1429
+ right?.type === `val` &&
1430
+ typeof right.value === `string` &&
1431
+ /^[\x20-\x7e]*%$/.test(right.value) &&
1432
+ !right.value.slice(0, -1).includes(`%`) &&
1433
+ !right.value.includes(`_`)
1434
+ ) {
1435
+ return compiled
1436
+ }
1437
+ return undefined
1438
+ }
1439
+
1053
1440
  function compileOrderByClauses(
1054
1441
  orderBy: IR.OrderBy | undefined,
1055
1442
  ): CompiledSqlFragment {
@@ -1175,6 +1562,39 @@ function mergeObjectRows<T extends object>(existing: unknown, incoming: T): T {
1175
1562
  return incoming
1176
1563
  }
1177
1564
 
1565
+ function* batches<T>(
1566
+ changes: ReadonlyArray<T>,
1567
+ maxSize: number,
1568
+ ): Generator<Array<T>> {
1569
+ for (let offset = 0; offset < changes.length; offset += maxSize) {
1570
+ yield changes.slice(offset, offset + maxSize)
1571
+ }
1572
+ }
1573
+
1574
+ function lastMetadataByKey<
1575
+ T extends { type: `set` | `delete`; value?: unknown },
1576
+ >(
1577
+ changes: ReadonlyArray<T>,
1578
+ keyOf: (change: T) => string,
1579
+ undefinedSetIsNull = false,
1580
+ ): Array<T> {
1581
+ const latest = new Map<string, T>()
1582
+ for (const change of changes) {
1583
+ const key = keyOf(change)
1584
+ const previous = latest.get(key)
1585
+ if (
1586
+ previous?.type === `set` &&
1587
+ (previous.value !== undefined || !undefinedSetIsNull) &&
1588
+ (serializePersistedRowValue(previous.value) as string | undefined) ===
1589
+ undefined
1590
+ ) {
1591
+ throw new TypeError(`Metadata value cannot be bound to SQLite`)
1592
+ }
1593
+ latest.set(key, change)
1594
+ }
1595
+ return [...latest.values()]
1596
+ }
1597
+
1178
1598
  function buildIndexName(collectionId: string, signature: string): string {
1179
1599
  const sanitizedSignature = signature
1180
1600
  .toLowerCase()
@@ -1672,58 +2092,134 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1672
2092
  await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`)
1673
2093
  }
1674
2094
 
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
- )
2095
+ const rowMutations = new Map<string, PersistedTx[`mutations`][number]>()
2096
+ for (const mutation of tx.mutations) {
2097
+ const key = encodePersistedStorageKey(mutation.key)
2098
+ const previous = rowMutations.get(key)
2099
+ if (!previous) {
2100
+ rowMutations.set(key, mutation)
2101
+ continue
1693
2102
  }
1694
2103
 
1695
- for (
1696
- let start = 0;
1697
- start < tx.mutations.length;
1698
- start += this.replacementBatchSize
2104
+ // A superseded action still had to serialize successfully.
2105
+ serializePersistedRowValue(previous.value)
2106
+ if (
2107
+ previous.type !== `delete` &&
2108
+ previous.metadataChanged === true &&
2109
+ previous.metadata !== undefined
1699
2110
  ) {
1700
- const mutations = tx.mutations.slice(
1701
- start,
1702
- start + this.replacementBatchSize,
1703
- )
1704
- const keys = replacementKeys.slice(
1705
- start,
1706
- start + this.replacementBatchSize,
2111
+ serializePersistedRowValue(previous.metadata)
2112
+ }
2113
+ if (mutation.type === `delete`) {
2114
+ rowMutations.set(key, mutation)
2115
+ continue
2116
+ }
2117
+ const value =
2118
+ mutation.type === `insert` || previous.type === `delete`
2119
+ ? mutation.value
2120
+ : mergeObjectRows(previous.value, mutation.value)
2121
+ const metadataChanged =
2122
+ mutation.metadataChanged === true ||
2123
+ previous.type === `delete` ||
2124
+ previous.metadataChanged === true
2125
+ const metadata =
2126
+ mutation.metadataChanged === true
2127
+ ? mutation.metadata
2128
+ : previous.type === `delete`
2129
+ ? undefined
2130
+ : previous.metadata
2131
+ const next = { key: mutation.key, value, metadataChanged, metadata }
2132
+ rowMutations.set(
2133
+ key,
2134
+ mutation.type === `insert` || previous.type !== `update`
2135
+ ? { type: `insert`, ...next }
2136
+ : { type: `update`, ...next },
2137
+ )
2138
+ }
2139
+
2140
+ for (const batch of batches(
2141
+ [...rowMutations.values()],
2142
+ this.replacementBatchSize,
2143
+ )) {
2144
+ const writes = batch.filter((mutation) => mutation.type !== `delete`)
2145
+ const deletes = batch.filter((mutation) => mutation.type === `delete`)
2146
+ const readKeys = writes
2147
+ .filter(
2148
+ (mutation) =>
2149
+ mutation.type === `update` || mutation.metadataChanged !== true,
1707
2150
  )
2151
+ .map((mutation) => encodePersistedStorageKey(mutation.key))
2152
+ const existingRows =
2153
+ readKeys.length > 0
2154
+ ? await transactionDriver.query<{
2155
+ key: string
2156
+ value: string
2157
+ metadata: string | null
2158
+ }>(
2159
+ `SELECT key, value, metadata
2160
+ FROM ${collectionTableSql}
2161
+ WHERE key IN (${readKeys.map(() => `?`).join(`, `)})`,
2162
+ readKeys,
2163
+ )
2164
+ : []
2165
+ const existing = new Map(existingRows.map((row) => [row.key, row]))
2166
+ const writeKeys = writes.map((mutation) =>
2167
+ encodePersistedStorageKey(mutation.key),
2168
+ )
2169
+ const deleteKeys = deletes.map((mutation) =>
2170
+ encodePersistedStorageKey(mutation.key),
2171
+ )
2172
+
2173
+ if (tracksPersistedKeySet && writeKeys.length > 0) {
1708
2174
  await transactionDriver.run(
1709
2175
  `INSERT INTO collection_expected_keys (collection_id, key)
1710
- VALUES ${keys.map(() => `(?, ?)`).join(`, `)}`,
1711
- keys.flatMap((key) => [collectionId, key]),
2176
+ VALUES ${writeKeys.map(() => `(?, ?)`).join(`, `)}
2177
+ ON CONFLICT(collection_id, key) DO NOTHING`,
2178
+ writeKeys.flatMap((key) => [collectionId, key]),
1712
2179
  )
2180
+ }
2181
+ if (tracksPersistedKeySet && deleteKeys.length > 0) {
2182
+ await transactionDriver.run(
2183
+ `DELETE FROM collection_expected_keys
2184
+ WHERE collection_id = ? AND key IN (${deleteKeys.map(() => `?`).join(`, `)})`,
2185
+ [collectionId, ...deleteKeys],
2186
+ )
2187
+ }
2188
+ if (deleteKeys.length > 0) {
2189
+ await transactionDriver.run(
2190
+ `DELETE FROM ${collectionTableSql}
2191
+ WHERE key IN (${deleteKeys.map(() => `?`).join(`, `)})`,
2192
+ deleteKeys,
2193
+ )
2194
+ }
2195
+ if (writeKeys.length > 0) {
1713
2196
  await transactionDriver.run(
1714
2197
  `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
2198
+ VALUES ${writeKeys.map(() => `(?, ?, ?, ?)`).join(`, `)}
2199
+ ON CONFLICT(key) DO UPDATE SET
2200
+ value = excluded.value,
2201
+ metadata = excluded.metadata,
2202
+ row_version = excluded.row_version`,
2203
+ writes.flatMap((mutation, index) => {
2204
+ const previous = existing.get(writeKeys[index]!)
2205
+ const previousValue = previous?.value
2206
+ ? deserializePersistedRowValue(previous.value)
2207
+ : undefined
2208
+ const previousMetadata =
2209
+ previous?.metadata != null
2210
+ ? deserializePersistedRowValue(previous.metadata)
1723
2211
  : undefined
2212
+ const value =
2213
+ mutation.type === `update`
2214
+ ? mergeObjectRows(previousValue, mutation.value)
2215
+ : mutation.value
2216
+ const metadata =
2217
+ mutation.metadataChanged === true
2218
+ ? mutation.metadata
2219
+ : previousMetadata
1724
2220
  return [
1725
- key,
1726
- serializePersistedRowValue(mutation.value),
2221
+ writeKeys[index]!,
2222
+ serializePersistedRowValue(value),
1727
2223
  metadata === undefined
1728
2224
  ? null
1729
2225
  : serializePersistedRowValue(metadata),
@@ -1731,143 +2227,85 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1731
2227
  ]
1732
2228
  }),
1733
2229
  )
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
2230
  await transactionDriver.run(
1748
- `DELETE FROM ${collectionTableSql}
1749
- WHERE key = ?`,
1750
- [encodedKey],
2231
+ `DELETE FROM ${tombstoneTableSql}
2232
+ WHERE key IN (${writeKeys.map(() => `?`).join(`, `)})`,
2233
+ writeKeys,
1751
2234
  )
2235
+ }
2236
+ if (deletes.length > 0) {
1752
2237
  await transactionDriver.run(
1753
2238
  `INSERT INTO ${tombstoneTableSql} (key, value, row_version, deleted_at)
1754
- VALUES (?, ?, ?, ?)
2239
+ VALUES ${deleteKeys.map(() => `(?, ?, ?, ?)`).join(`, `)}
1755
2240
  ON CONFLICT(key) DO UPDATE SET
1756
2241
  value = excluded.value,
1757
2242
  row_version = excluded.row_version,
1758
2243
  deleted_at = excluded.deleted_at`,
1759
- [
1760
- encodedKey,
2244
+ deletes.flatMap((mutation, index) => [
2245
+ deleteKeys[index]!,
1761
2246
  serializePersistedRowValue(mutation.value),
1762
2247
  nextRowVersion,
1763
2248
  new Date().toISOString(),
1764
- ],
2249
+ ]),
1765
2250
  )
1766
- continue
1767
2251
  }
2252
+ }
1768
2253
 
1769
- const existingRows = await transactionDriver.query<{
1770
- value: string
1771
- metadata: string | null
1772
- }>(
1773
- `SELECT value, metadata
1774
- FROM ${collectionTableSql}
1775
- WHERE key = ?
1776
- LIMIT 1`,
1777
- [encodedKey],
2254
+ for (const batch of batches(
2255
+ lastMetadataByKey(
2256
+ tx.rowMetadataMutations ?? [],
2257
+ (mutation) => encodePersistedStorageKey(mutation.key),
2258
+ true,
2259
+ ),
2260
+ this.replacementBatchSize,
2261
+ )) {
2262
+ const keys = batch.map((mutation) =>
2263
+ encodePersistedStorageKey(mutation.key),
1778
2264
  )
1779
- const existingValue = existingRows[0]?.value
1780
- ? deserializePersistedRowValue(existingRows[0].value)
1781
- : undefined
1782
- const existingMetadata =
1783
- existingRows[0]?.metadata != null
1784
- ? deserializePersistedRowValue(existingRows[0].metadata)
1785
- : undefined
1786
- const mergedValue =
1787
- mutation.type === `update`
1788
- ? mergeObjectRows(existingValue, mutation.value)
1789
- : mutation.value
1790
- const nextMetadata =
1791
- mutation.metadataChanged === true
1792
- ? mutation.metadata
1793
- : existingMetadata
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
- }
1803
2265
  await transactionDriver.run(
1804
- `INSERT INTO ${collectionTableSql} (key, value, metadata, row_version)
1805
- VALUES (?, ?, ?, ?)
1806
- ON CONFLICT(key) DO UPDATE SET
1807
- value = excluded.value,
1808
- metadata = excluded.metadata,
1809
- row_version = excluded.row_version`,
2266
+ `UPDATE ${collectionTableSql}
2267
+ SET metadata = CASE key ${keys.map(() => `WHEN ? THEN ?`).join(` `)} END
2268
+ WHERE key IN (${keys.map(() => `?`).join(`, `)})`,
1810
2269
  [
1811
- encodedKey,
1812
- serializePersistedRowValue(mergedValue),
1813
- nextMetadata === undefined
1814
- ? null
1815
- : serializePersistedRowValue(nextMetadata),
1816
- nextRowVersion,
2270
+ ...batch.flatMap((mutation, index) => [
2271
+ keys[index]!,
2272
+ mutation.type === `delete` || mutation.value === undefined
2273
+ ? null
2274
+ : serializePersistedRowValue(mutation.value),
2275
+ ]),
2276
+ ...keys,
1817
2277
  ],
1818
2278
  )
1819
- await transactionDriver.run(
1820
- `DELETE FROM ${tombstoneTableSql}
1821
- WHERE key = ?`,
1822
- [encodedKey],
1823
- )
1824
2279
  }
1825
2280
 
1826
- for (const rowMetadataMutation of batchReplacement
1827
- ? []
1828
- : (tx.rowMetadataMutations ?? [])) {
1829
- const encodedKey = encodePersistedStorageKey(rowMetadataMutation.key)
1830
- if (rowMetadataMutation.type === `delete`) {
1831
- await transactionDriver.run(
1832
- `UPDATE ${collectionTableSql}
1833
- SET metadata = NULL
1834
- WHERE key = ?`,
1835
- [encodedKey],
1836
- )
1837
- } else {
1838
- await transactionDriver.run(
1839
- `UPDATE ${collectionTableSql}
1840
- SET metadata = ?
1841
- WHERE key = ?`,
1842
- [
1843
- rowMetadataMutation.value === undefined
1844
- ? null
1845
- : serializePersistedRowValue(rowMetadataMutation.value),
1846
- encodedKey,
1847
- ],
1848
- )
1849
- }
1850
- }
1851
-
1852
- for (const metadataMutation of tx.collectionMetadataMutations ?? []) {
1853
- if (metadataMutation.type === `delete`) {
2281
+ for (const batch of batches(
2282
+ lastMetadataByKey(
2283
+ tx.collectionMetadataMutations ?? [],
2284
+ (mutation) => mutation.key,
2285
+ ),
2286
+ this.replacementBatchSize,
2287
+ )) {
2288
+ const deletes = batch.filter((mutation) => mutation.type === `delete`)
2289
+ const sets = batch.filter((mutation) => mutation.type === `set`)
2290
+ if (deletes.length > 0) {
1854
2291
  await transactionDriver.run(
1855
2292
  `DELETE FROM collection_metadata
1856
- WHERE collection_id = ? AND key = ?`,
1857
- [collectionId, metadataMutation.key],
2293
+ WHERE collection_id = ? AND key IN (${deletes.map(() => `?`).join(`, `)})`,
2294
+ [collectionId, ...deletes.map((mutation) => mutation.key)],
1858
2295
  )
1859
- } else {
2296
+ }
2297
+ if (sets.length > 0) {
1860
2298
  await transactionDriver.run(
1861
2299
  `INSERT INTO collection_metadata (collection_id, key, value, updated_at)
1862
- VALUES (?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
2300
+ VALUES ${sets.map(() => `(?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))`).join(`, `)}
1863
2301
  ON CONFLICT(collection_id, key) DO UPDATE SET
1864
2302
  value = excluded.value,
1865
2303
  updated_at = excluded.updated_at`,
1866
- [
2304
+ sets.flatMap((mutation) => [
1867
2305
  collectionId,
1868
- metadataMutation.key,
1869
- serializePersistedRowValue(metadataMutation.value),
1870
- ],
2306
+ mutation.key,
2307
+ serializePersistedRowValue(mutation.value),
2308
+ ]),
1871
2309
  )
1872
2310
  }
1873
2311
  }
@@ -2419,17 +2857,22 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2419
2857
  driver: SQLiteDriver = this.driver,
2420
2858
  ): Promise<Array<InMemoryRow<string | number, Record<string, unknown>>>> {
2421
2859
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2860
+ // Compile even when SQL cannot safely prefilter: invalid bindings must
2861
+ // reject before the in-memory fallback reads rows.
2422
2862
  const whereCompiled = options.where
2423
2863
  ? compileSqlExpression(options.where)
2424
- : { supported: true, sql: ``, params: [] as Array<SqliteSupportedValue> }
2864
+ : { supported: false, sql: ``, params: [] as Array<SqliteSupportedValue> }
2865
+ const safePrefilter = options.where
2866
+ ? compileSafeSqlPrefilter(options.where, whereCompiled)
2867
+ : undefined
2425
2868
  const orderByCompiled = compileOrderByClauses(options.orderBy)
2426
2869
 
2427
2870
  const queryParams: Array<SqliteSupportedValue> = []
2428
2871
  let sql = `SELECT key, value, metadata, row_version FROM ${collectionTableSql}`
2429
2872
 
2430
- if (options.where && whereCompiled.supported) {
2431
- sql = `${sql} WHERE ${whereCompiled.sql}`
2432
- queryParams.push(...whereCompiled.params)
2873
+ if (safePrefilter) {
2874
+ sql = `${sql} WHERE ${safePrefilter.sql}`
2875
+ queryParams.push(...safePrefilter.params)
2433
2876
  }
2434
2877
 
2435
2878
  if (options.orderBy && orderByCompiled.supported) {
@@ -2437,6 +2880,14 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2437
2880
  queryParams.push(...orderByCompiled.params)
2438
2881
  }
2439
2882
 
2883
+ if (
2884
+ queryParams.length >
2885
+ (driver.maxBoundParameters ?? this.driver.maxBoundParameters ?? 999)
2886
+ ) {
2887
+ sql = `SELECT key, value, metadata, row_version FROM ${collectionTableSql}`
2888
+ queryParams.length = 0
2889
+ }
2890
+
2440
2891
  const storedRows = await driver.query<StoredSqliteRow>(sql, queryParams)
2441
2892
  const parsedRows = decodeStoredSqliteRows(storedRows)
2442
2893