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

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.
@@ -36,7 +36,7 @@ type SqliteSupportedValue = null | number | string
36
36
 
37
37
  // The default stays below SQLite's older 999-variable limit. Drivers with a
38
38
  // lower binding cap use smaller chunks; each replacement row binds four values.
39
- const REPLACEMENT_BATCH_SIZE = 100
39
+ const REPLACEMENT_BATCH_SIZE = 125
40
40
 
41
41
  type CollectionTableMapping = {
42
42
  tableName: string
@@ -60,9 +60,7 @@ type StoredSqliteRow = {
60
60
  }
61
61
 
62
62
  type SQLiteCoreAdapterSchemaMismatchPolicy =
63
- | `sync-present-reset`
64
- | `sync-absent-error`
65
- | `reset`
63
+ `sync-present-reset` | `sync-absent-error` | `reset`
66
64
 
67
65
  export type SQLiteCoreAdapterOptions = {
68
66
  driver: SQLiteDriver
@@ -222,6 +220,7 @@ function observeSharedLogicalSchedulingSupport(
222
220
  }
223
221
 
224
222
  return {
223
+ maxBoundParameters: driver.maxBoundParameters,
225
224
  exec: (sql) => observe(driver.exec(sql)),
226
225
  query: <T>(sql: string, params: ReadonlyArray<unknown> = []) =>
227
226
  observe(driver.query<T>(sql, params)),
@@ -256,16 +255,11 @@ export const DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS = 1_000
256
255
  */
257
256
  export const DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS = 24 * 60 * 60
258
257
 
259
- const SQLITE_MAX_IN_BATCH_SIZE = 900
260
258
  const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
261
259
  const FORBIDDEN_SQL_FRAGMENT_PATTERN = /(;|--|\/\*)/
262
260
  type CompiledValueKind = `unknown` | `bigint` | `date` | `datetime`
263
261
  type PersistedTaggedValueType =
264
- | `bigint`
265
- | `date`
266
- | `nan`
267
- | `infinity`
268
- | `-infinity`
262
+ `bigint` | `date` | `nan` | `infinity` | `-infinity`
269
263
  type PersistedTaggedValue = {
270
264
  [PERSISTED_TYPE_TAG]: PersistedTaggedValueType
271
265
  [PERSISTED_VALUE_TAG]: string
@@ -610,6 +604,39 @@ function createJsonPath(path: Array<string>): string | null {
610
604
  return jsonPath
611
605
  }
612
606
 
607
+ const MAX_INDEXED_NUMERIC_PATH_SEGMENTS = 3
608
+
609
+ function hasDeepNumericPath(expression: IR.BasicExpression): boolean {
610
+ if (expression.type === `ref`) {
611
+ return (
612
+ IR.getPropRefPropertyPath(expression).filter((segment) =>
613
+ /^[0-9]+$/.test(String(segment)),
614
+ ).length > MAX_INDEXED_NUMERIC_PATH_SEGMENTS
615
+ )
616
+ }
617
+ return expression.type === `func` && expression.args.some(hasDeepNumericPath)
618
+ }
619
+
620
+ function createJsonPathVariants(path: Array<string>): Array<string> | null {
621
+ const canonical = createJsonPath(path)
622
+ if (!canonical) return null
623
+
624
+ let paths = [`$`]
625
+ for (const segment of path) {
626
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment)) {
627
+ paths = paths.map((prefix) => `${prefix}.${segment}`)
628
+ } else if (/^[0-9]+$/.test(segment)) {
629
+ if (paths.length >= 2 ** MAX_INDEXED_NUMERIC_PATH_SEGMENTS)
630
+ return [canonical]
631
+ paths = paths.flatMap((prefix) => [
632
+ `${prefix}[${segment}]`,
633
+ `${prefix}."${segment}"`,
634
+ ])
635
+ }
636
+ }
637
+ return paths
638
+ }
639
+
613
640
  function getLiteralValueKind(value: unknown): CompiledValueKind {
614
641
  if (typeof value === `bigint`) {
615
642
  return `bigint`
@@ -788,11 +815,54 @@ function argumentCompilationContext(
788
815
  }
789
816
  }
790
817
 
818
+ function hasUnpairedSurrogate(value: string): boolean {
819
+ // With /u, paired surrogates form one code point; lone surrogates still match.
820
+ return /[\uD800-\uDFFF]/u.test(value)
821
+ }
822
+
823
+ function isSafeEqualityLiteral(value: unknown): boolean {
824
+ return (
825
+ typeof value === `string` ||
826
+ typeof value === `boolean` ||
827
+ typeof value === `bigint`
828
+ )
829
+ }
830
+
831
+ function isSafeCoalesceOperand(
832
+ expression: IR.BasicExpression | undefined,
833
+ ): boolean {
834
+ return (
835
+ expression?.type === `func` &&
836
+ expression.name === `coalesce` &&
837
+ expression.args.length === 2 &&
838
+ expression.args[0]?.type === `ref` &&
839
+ expression.args[1]?.type === `val` &&
840
+ isSafeEqualityLiteral(expression.args[1].value) &&
841
+ !(
842
+ typeof expression.args[1].value === `string` &&
843
+ hasUnpairedSurrogate(expression.args[1].value)
844
+ )
845
+ )
846
+ }
847
+
791
848
  function compileSqlExpression(
792
849
  expression: IR.BasicExpression,
793
850
  context: SqlExpressionCompilationContext = `predicate`,
794
851
  ): CompiledSqlFragment {
795
852
  if (expression.type === `val`) {
853
+ if (
854
+ context === `predicate` &&
855
+ typeof expression.value === `string` &&
856
+ hasUnpairedSurrogate(expression.value)
857
+ ) {
858
+ // JSON extraction changes lone surrogates in stored strings. Apply the
859
+ // same extraction to the binding so an indexed equality keeps its row.
860
+ return {
861
+ supported: true,
862
+ sql: `json_extract(?, '$')`,
863
+ params: [JSON.stringify(expression.value)],
864
+ }
865
+ }
796
866
  const valueKind = getLiteralValueKind(expression.value)
797
867
  return {
798
868
  supported: true,
@@ -809,10 +879,10 @@ function compileSqlExpression(
809
879
  }
810
880
 
811
881
  if (expression.type === `ref`) {
812
- const jsonPath = createJsonPath(
882
+ const jsonPaths = createJsonPathVariants(
813
883
  IR.getPropRefPropertyPath(expression).map(String),
814
884
  )
815
- if (!jsonPath) {
885
+ if (!jsonPaths) {
816
886
  return {
817
887
  supported: false,
818
888
  sql: ``,
@@ -820,7 +890,13 @@ function compileSqlExpression(
820
890
  }
821
891
  }
822
892
 
823
- return compileRefExpressionSql(jsonPath)
893
+ if (jsonPaths.length === 1) return compileRefExpressionSql(jsonPaths[0]!)
894
+ return {
895
+ supported: true,
896
+ sql: `COALESCE(${jsonPaths.map((path) => compileRefExpressionSql(path).sql).join(`, `)})`,
897
+ params: [],
898
+ valueKind: `unknown`,
899
+ }
824
900
  }
825
901
 
826
902
  const compiledArgs = expression.args.map((arg, index) =>
@@ -888,23 +964,20 @@ function compileSqlExpression(
888
964
  params,
889
965
  }
890
966
  }
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 `),
898
- params,
899
- }
900
- }
967
+ case `and`:
901
968
  case `or`: {
902
- if (argSql.length < 2) {
903
- return { supported: false, sql: ``, params: [] }
969
+ if (argSql.length === 0) {
970
+ return {
971
+ supported: true,
972
+ sql: expression.name === `and` ? `(1 = 1)` : `(0 = 1)`,
973
+ params: [],
974
+ }
904
975
  }
905
976
  return {
906
977
  supported: true,
907
- sql: argSql.map((sql) => `(${sql})`).join(` OR `),
978
+ sql: argSql
979
+ .map((sql) => `(${sql})`)
980
+ .join(expression.name === `and` ? ` AND ` : ` OR `),
908
981
  params,
909
982
  }
910
983
  }
@@ -948,44 +1021,17 @@ function compileSqlExpression(
948
1021
  }
949
1022
  }
950
1023
 
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
-
973
- return {
974
- supported: true,
975
- sql: `(${chunkClauses.join(` OR `)})`,
976
- params: batchedParams,
977
- }
978
- }
979
-
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
- })
1024
+ const jsonList = `[${listValue
1025
+ .map((value) =>
1026
+ typeof value === `bigint`
1027
+ ? assertSQLiteBigIntInRange(value).toString()
1028
+ : JSON.stringify(toSqliteParameterValue(value)),
1029
+ )
1030
+ .join(`,`)}]`
985
1031
  return {
986
1032
  supported: true,
987
- sql: `(${leftSql} IN (${listValueSql.join(`, `)}))`,
988
- params: [...leftParams, ...listParams],
1033
+ sql: `(${leftSql} IN (SELECT value FROM json_each(?)))`,
1034
+ params: [...leftParams, jsonList],
989
1035
  }
990
1036
  }
991
1037
  case `like`:
@@ -1050,6 +1096,160 @@ function compileSqlExpression(
1050
1096
  }
1051
1097
  }
1052
1098
 
1099
+ function compileSafeSqlPrefilter(
1100
+ expression: IR.BasicExpression,
1101
+ compiled?: CompiledSqlFragment,
1102
+ ): CompiledSqlFragment | undefined {
1103
+ // Every public match must pass this SQL candidate before JavaScript applies
1104
+ // the authoritative row predicate. Extra candidates are allowed.
1105
+ const direct = () => {
1106
+ const candidate = compiled ?? compileSqlExpression(expression)
1107
+ return candidate.supported ? candidate : undefined
1108
+ }
1109
+ if (expression.type === `val`) {
1110
+ return typeof expression.value === `boolean` || expression.value == null
1111
+ ? direct()
1112
+ : undefined
1113
+ }
1114
+ if (expression.type === `ref`) return undefined
1115
+
1116
+ if (expression.name === `and` || expression.name === `or`) {
1117
+ if (expression.args.length === 0) return direct()
1118
+ const candidates = expression.args.map((argument) =>
1119
+ compileSafeSqlPrefilter(argument),
1120
+ )
1121
+ if (expression.name === `or` && candidates.some((candidate) => !candidate))
1122
+ return undefined
1123
+ const selected = candidates.filter(
1124
+ (candidate): candidate is CompiledSqlFragment => candidate !== undefined,
1125
+ )
1126
+ if (selected.length === 0) return undefined
1127
+ return {
1128
+ supported: true,
1129
+ sql: selected
1130
+ .map(({ sql }) => `(${sql})`)
1131
+ .join(expression.name === `and` ? ` AND ` : ` OR `),
1132
+ params: selected.flatMap(({ params }) => params),
1133
+ }
1134
+ }
1135
+
1136
+ // More than three digit segments need too many alternative JSON paths.
1137
+ // Keep their prefilter unbounded rather than exclude an object-key match.
1138
+ if (hasDeepNumericPath(expression)) return undefined
1139
+ compiled ??= compileSqlExpression(expression)
1140
+ if (!compiled.supported) return undefined
1141
+ const [left, right] = expression.args
1142
+ if (expression.args.length === 2) {
1143
+ if (
1144
+ [`gt`, `gte`, `lt`, `lte`].includes(expression.name) &&
1145
+ (left?.type === `ref` || right?.type === `ref`)
1146
+ ) {
1147
+ const field =
1148
+ left?.type === `ref` ? left : right?.type === `ref` ? right : undefined
1149
+ const literal = field === left ? right : left
1150
+ if (
1151
+ field &&
1152
+ literal?.type === `val` &&
1153
+ ((typeof literal.value === `number` &&
1154
+ Number.isSafeInteger(literal.value)) ||
1155
+ typeof literal.value === `bigint`)
1156
+ ) {
1157
+ const fieldSql = compileSqlExpression(field, `index-expression`).sql
1158
+ const literalSql = compileSqlExpression(
1159
+ literal,
1160
+ typeof literal.value === `bigint` ? `index-expression` : `predicate`,
1161
+ )
1162
+ const greaterSide =
1163
+ (field === left && [`gt`, `gte`].includes(expression.name)) ||
1164
+ (field === right && [`lt`, `lte`].includes(expression.name))
1165
+ const unsafeBigInt =
1166
+ typeof literal.value === `bigint` &&
1167
+ !Number.isSafeInteger(Number(literal.value))
1168
+ const roundedNumberCandidates = unsafeBigInt
1169
+ ? literal.value > 0n
1170
+ ? ` OR ${fieldSql} >= ${Number.MAX_SAFE_INTEGER}`
1171
+ : ` OR ${fieldSql} <= -${Number.MAX_SAFE_INTEGER}`
1172
+ : ``
1173
+ return {
1174
+ supported: true,
1175
+ sql: greaterSide
1176
+ ? `(${fieldSql} >= ${literalSql.sql} OR ${fieldSql} IS NULL${roundedNumberCandidates})`
1177
+ : `(${fieldSql} <= ${literalSql.sql} OR ${fieldSql} IS NULL OR ${fieldSql} >= ''${roundedNumberCandidates})`,
1178
+ params: literalSql.params,
1179
+ }
1180
+ }
1181
+ }
1182
+ if (expression.name === `eq`) {
1183
+ if (
1184
+ ((left?.type === `ref` || isSafeCoalesceOperand(left)) &&
1185
+ right?.type === `val` &&
1186
+ isSafeEqualityLiteral(right.value)) ||
1187
+ ((right?.type === `ref` || isSafeCoalesceOperand(right)) &&
1188
+ left?.type === `val` &&
1189
+ isSafeEqualityLiteral(left.value))
1190
+ ) {
1191
+ return compiled
1192
+ }
1193
+ const lower =
1194
+ left?.type === `func` && left.name === `lower`
1195
+ ? left
1196
+ : right?.type === `func` && right.name === `lower`
1197
+ ? right
1198
+ : undefined
1199
+ const literal = lower === left ? right : left
1200
+ if (
1201
+ lower?.args.length === 1 &&
1202
+ lower.args[0]?.type === `ref` &&
1203
+ literal?.type === `val` &&
1204
+ typeof literal.value === `string` &&
1205
+ [...literal.value].every((char) => char.charCodeAt(0) <= 0x7f) &&
1206
+ !literal.value.includes(`\u0000`)
1207
+ ) {
1208
+ const lowerSql = compileSqlExpression(lower, `index-expression`).sql
1209
+ return {
1210
+ supported: true,
1211
+ sql: `(${compiled.sql} OR (${lowerSql} >= ? AND ${lowerSql} GLOB '*[^ -~]*'))`,
1212
+ params: [...compiled.params, literal.value],
1213
+ }
1214
+ }
1215
+ }
1216
+ }
1217
+ if (
1218
+ expression.name === `in` &&
1219
+ expression.args.length === 2 &&
1220
+ right?.type === `val` &&
1221
+ Array.isArray(right.value)
1222
+ ) {
1223
+ if (right.value.length === 0) return compiled
1224
+ if (left?.type === `ref`) {
1225
+ if (right.value.every((value) => typeof value === `bigint`))
1226
+ return compiled
1227
+ if (right.value.every((value) => typeof value === `string`)) {
1228
+ // Both sides pass through SQLite's JSON decoder; one binding keeps
1229
+ // large string scopes within the host parameter limit, even in OR.
1230
+ return {
1231
+ supported: true,
1232
+ sql: `(${compileSqlExpression(left, `index-expression`).sql} IN (SELECT value FROM json_each(?)))`,
1233
+ params: [JSON.stringify(right.value)],
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ if (
1239
+ expression.name === `like` &&
1240
+ expression.args.length === 2 &&
1241
+ left?.type === `ref` &&
1242
+ right?.type === `val` &&
1243
+ typeof right.value === `string` &&
1244
+ /^[\x20-\x7e]*%$/.test(right.value) &&
1245
+ !right.value.slice(0, -1).includes(`%`) &&
1246
+ !right.value.includes(`_`)
1247
+ ) {
1248
+ return compiled
1249
+ }
1250
+ return undefined
1251
+ }
1252
+
1053
1253
  function compileOrderByClauses(
1054
1254
  orderBy: IR.OrderBy | undefined,
1055
1255
  ): CompiledSqlFragment {
@@ -1175,6 +1375,39 @@ function mergeObjectRows<T extends object>(existing: unknown, incoming: T): T {
1175
1375
  return incoming
1176
1376
  }
1177
1377
 
1378
+ function* batches<T>(
1379
+ changes: ReadonlyArray<T>,
1380
+ maxSize: number,
1381
+ ): Generator<Array<T>> {
1382
+ for (let offset = 0; offset < changes.length; offset += maxSize) {
1383
+ yield changes.slice(offset, offset + maxSize)
1384
+ }
1385
+ }
1386
+
1387
+ function lastMetadataByKey<
1388
+ T extends { type: `set` | `delete`; value?: unknown },
1389
+ >(
1390
+ changes: ReadonlyArray<T>,
1391
+ keyOf: (change: T) => string,
1392
+ undefinedSetIsNull = false,
1393
+ ): Array<T> {
1394
+ const latest = new Map<string, T>()
1395
+ for (const change of changes) {
1396
+ const key = keyOf(change)
1397
+ const previous = latest.get(key)
1398
+ if (
1399
+ previous?.type === `set` &&
1400
+ (previous.value !== undefined || !undefinedSetIsNull) &&
1401
+ (serializePersistedRowValue(previous.value) as string | undefined) ===
1402
+ undefined
1403
+ ) {
1404
+ throw new TypeError(`Metadata value cannot be bound to SQLite`)
1405
+ }
1406
+ latest.set(key, change)
1407
+ }
1408
+ return [...latest.values()]
1409
+ }
1410
+
1178
1411
  function buildIndexName(collectionId: string, signature: string): string {
1179
1412
  const sanitizedSignature = signature
1180
1413
  .toLowerCase()
@@ -1672,58 +1905,134 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1672
1905
  await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`)
1673
1906
  }
1674
1907
 
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
- )
1908
+ const rowMutations = new Map<string, PersistedTx[`mutations`][number]>()
1909
+ for (const mutation of tx.mutations) {
1910
+ const key = encodePersistedStorageKey(mutation.key)
1911
+ const previous = rowMutations.get(key)
1912
+ if (!previous) {
1913
+ rowMutations.set(key, mutation)
1914
+ continue
1693
1915
  }
1694
1916
 
1695
- for (
1696
- let start = 0;
1697
- start < tx.mutations.length;
1698
- start += this.replacementBatchSize
1917
+ // A superseded action still had to serialize successfully.
1918
+ serializePersistedRowValue(previous.value)
1919
+ if (
1920
+ previous.type !== `delete` &&
1921
+ previous.metadataChanged === true &&
1922
+ previous.metadata !== undefined
1699
1923
  ) {
1700
- const mutations = tx.mutations.slice(
1701
- start,
1702
- start + this.replacementBatchSize,
1703
- )
1704
- const keys = replacementKeys.slice(
1705
- start,
1706
- start + this.replacementBatchSize,
1924
+ serializePersistedRowValue(previous.metadata)
1925
+ }
1926
+ if (mutation.type === `delete`) {
1927
+ rowMutations.set(key, mutation)
1928
+ continue
1929
+ }
1930
+ const value =
1931
+ mutation.type === `insert` || previous.type === `delete`
1932
+ ? mutation.value
1933
+ : mergeObjectRows(previous.value, mutation.value)
1934
+ const metadataChanged =
1935
+ mutation.metadataChanged === true ||
1936
+ previous.type === `delete` ||
1937
+ previous.metadataChanged === true
1938
+ const metadata =
1939
+ mutation.metadataChanged === true
1940
+ ? mutation.metadata
1941
+ : previous.type === `delete`
1942
+ ? undefined
1943
+ : previous.metadata
1944
+ const next = { key: mutation.key, value, metadataChanged, metadata }
1945
+ rowMutations.set(
1946
+ key,
1947
+ mutation.type === `insert` || previous.type !== `update`
1948
+ ? { type: `insert`, ...next }
1949
+ : { type: `update`, ...next },
1950
+ )
1951
+ }
1952
+
1953
+ for (const batch of batches(
1954
+ [...rowMutations.values()],
1955
+ this.replacementBatchSize,
1956
+ )) {
1957
+ const writes = batch.filter((mutation) => mutation.type !== `delete`)
1958
+ const deletes = batch.filter((mutation) => mutation.type === `delete`)
1959
+ const readKeys = writes
1960
+ .filter(
1961
+ (mutation) =>
1962
+ mutation.type === `update` || mutation.metadataChanged !== true,
1707
1963
  )
1964
+ .map((mutation) => encodePersistedStorageKey(mutation.key))
1965
+ const existingRows =
1966
+ readKeys.length > 0
1967
+ ? await transactionDriver.query<{
1968
+ key: string
1969
+ value: string
1970
+ metadata: string | null
1971
+ }>(
1972
+ `SELECT key, value, metadata
1973
+ FROM ${collectionTableSql}
1974
+ WHERE key IN (${readKeys.map(() => `?`).join(`, `)})`,
1975
+ readKeys,
1976
+ )
1977
+ : []
1978
+ const existing = new Map(existingRows.map((row) => [row.key, row]))
1979
+ const writeKeys = writes.map((mutation) =>
1980
+ encodePersistedStorageKey(mutation.key),
1981
+ )
1982
+ const deleteKeys = deletes.map((mutation) =>
1983
+ encodePersistedStorageKey(mutation.key),
1984
+ )
1985
+
1986
+ if (tracksPersistedKeySet && writeKeys.length > 0) {
1708
1987
  await transactionDriver.run(
1709
1988
  `INSERT INTO collection_expected_keys (collection_id, key)
1710
- VALUES ${keys.map(() => `(?, ?)`).join(`, `)}`,
1711
- keys.flatMap((key) => [collectionId, key]),
1989
+ VALUES ${writeKeys.map(() => `(?, ?)`).join(`, `)}
1990
+ ON CONFLICT(collection_id, key) DO NOTHING`,
1991
+ writeKeys.flatMap((key) => [collectionId, key]),
1712
1992
  )
1993
+ }
1994
+ if (tracksPersistedKeySet && deleteKeys.length > 0) {
1995
+ await transactionDriver.run(
1996
+ `DELETE FROM collection_expected_keys
1997
+ WHERE collection_id = ? AND key IN (${deleteKeys.map(() => `?`).join(`, `)})`,
1998
+ [collectionId, ...deleteKeys],
1999
+ )
2000
+ }
2001
+ if (deleteKeys.length > 0) {
2002
+ await transactionDriver.run(
2003
+ `DELETE FROM ${collectionTableSql}
2004
+ WHERE key IN (${deleteKeys.map(() => `?`).join(`, `)})`,
2005
+ deleteKeys,
2006
+ )
2007
+ }
2008
+ if (writeKeys.length > 0) {
1713
2009
  await transactionDriver.run(
1714
2010
  `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
2011
+ VALUES ${writeKeys.map(() => `(?, ?, ?, ?)`).join(`, `)}
2012
+ ON CONFLICT(key) DO UPDATE SET
2013
+ value = excluded.value,
2014
+ metadata = excluded.metadata,
2015
+ row_version = excluded.row_version`,
2016
+ writes.flatMap((mutation, index) => {
2017
+ const previous = existing.get(writeKeys[index]!)
2018
+ const previousValue = previous?.value
2019
+ ? deserializePersistedRowValue(previous.value)
2020
+ : undefined
2021
+ const previousMetadata =
2022
+ previous?.metadata != null
2023
+ ? deserializePersistedRowValue(previous.metadata)
1723
2024
  : undefined
2025
+ const value =
2026
+ mutation.type === `update`
2027
+ ? mergeObjectRows(previousValue, mutation.value)
2028
+ : mutation.value
2029
+ const metadata =
2030
+ mutation.metadataChanged === true
2031
+ ? mutation.metadata
2032
+ : previousMetadata
1724
2033
  return [
1725
- key,
1726
- serializePersistedRowValue(mutation.value),
2034
+ writeKeys[index]!,
2035
+ serializePersistedRowValue(value),
1727
2036
  metadata === undefined
1728
2037
  ? null
1729
2038
  : serializePersistedRowValue(metadata),
@@ -1731,143 +2040,85 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
1731
2040
  ]
1732
2041
  }),
1733
2042
  )
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
2043
  await transactionDriver.run(
1748
- `DELETE FROM ${collectionTableSql}
1749
- WHERE key = ?`,
1750
- [encodedKey],
2044
+ `DELETE FROM ${tombstoneTableSql}
2045
+ WHERE key IN (${writeKeys.map(() => `?`).join(`, `)})`,
2046
+ writeKeys,
1751
2047
  )
2048
+ }
2049
+ if (deletes.length > 0) {
1752
2050
  await transactionDriver.run(
1753
2051
  `INSERT INTO ${tombstoneTableSql} (key, value, row_version, deleted_at)
1754
- VALUES (?, ?, ?, ?)
2052
+ VALUES ${deleteKeys.map(() => `(?, ?, ?, ?)`).join(`, `)}
1755
2053
  ON CONFLICT(key) DO UPDATE SET
1756
2054
  value = excluded.value,
1757
2055
  row_version = excluded.row_version,
1758
2056
  deleted_at = excluded.deleted_at`,
1759
- [
1760
- encodedKey,
2057
+ deletes.flatMap((mutation, index) => [
2058
+ deleteKeys[index]!,
1761
2059
  serializePersistedRowValue(mutation.value),
1762
2060
  nextRowVersion,
1763
2061
  new Date().toISOString(),
1764
- ],
2062
+ ]),
1765
2063
  )
1766
- continue
1767
2064
  }
2065
+ }
1768
2066
 
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],
2067
+ for (const batch of batches(
2068
+ lastMetadataByKey(
2069
+ tx.rowMetadataMutations ?? [],
2070
+ (mutation) => encodePersistedStorageKey(mutation.key),
2071
+ true,
2072
+ ),
2073
+ this.replacementBatchSize,
2074
+ )) {
2075
+ const keys = batch.map((mutation) =>
2076
+ encodePersistedStorageKey(mutation.key),
1778
2077
  )
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
2078
  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`,
2079
+ `UPDATE ${collectionTableSql}
2080
+ SET metadata = CASE key ${keys.map(() => `WHEN ? THEN ?`).join(` `)} END
2081
+ WHERE key IN (${keys.map(() => `?`).join(`, `)})`,
1810
2082
  [
1811
- encodedKey,
1812
- serializePersistedRowValue(mergedValue),
1813
- nextMetadata === undefined
1814
- ? null
1815
- : serializePersistedRowValue(nextMetadata),
1816
- nextRowVersion,
2083
+ ...batch.flatMap((mutation, index) => [
2084
+ keys[index]!,
2085
+ mutation.type === `delete` || mutation.value === undefined
2086
+ ? null
2087
+ : serializePersistedRowValue(mutation.value),
2088
+ ]),
2089
+ ...keys,
1817
2090
  ],
1818
2091
  )
1819
- await transactionDriver.run(
1820
- `DELETE FROM ${tombstoneTableSql}
1821
- WHERE key = ?`,
1822
- [encodedKey],
1823
- )
1824
2092
  }
1825
2093
 
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`) {
2094
+ for (const batch of batches(
2095
+ lastMetadataByKey(
2096
+ tx.collectionMetadataMutations ?? [],
2097
+ (mutation) => mutation.key,
2098
+ ),
2099
+ this.replacementBatchSize,
2100
+ )) {
2101
+ const deletes = batch.filter((mutation) => mutation.type === `delete`)
2102
+ const sets = batch.filter((mutation) => mutation.type === `set`)
2103
+ if (deletes.length > 0) {
1854
2104
  await transactionDriver.run(
1855
2105
  `DELETE FROM collection_metadata
1856
- WHERE collection_id = ? AND key = ?`,
1857
- [collectionId, metadataMutation.key],
2106
+ WHERE collection_id = ? AND key IN (${deletes.map(() => `?`).join(`, `)})`,
2107
+ [collectionId, ...deletes.map((mutation) => mutation.key)],
1858
2108
  )
1859
- } else {
2109
+ }
2110
+ if (sets.length > 0) {
1860
2111
  await transactionDriver.run(
1861
2112
  `INSERT INTO collection_metadata (collection_id, key, value, updated_at)
1862
- VALUES (?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
2113
+ VALUES ${sets.map(() => `(?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))`).join(`, `)}
1863
2114
  ON CONFLICT(collection_id, key) DO UPDATE SET
1864
2115
  value = excluded.value,
1865
2116
  updated_at = excluded.updated_at`,
1866
- [
2117
+ sets.flatMap((mutation) => [
1867
2118
  collectionId,
1868
- metadataMutation.key,
1869
- serializePersistedRowValue(metadataMutation.value),
1870
- ],
2119
+ mutation.key,
2120
+ serializePersistedRowValue(mutation.value),
2121
+ ]),
1871
2122
  )
1872
2123
  }
1873
2124
  }
@@ -2419,17 +2670,22 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2419
2670
  driver: SQLiteDriver = this.driver,
2420
2671
  ): Promise<Array<InMemoryRow<string | number, Record<string, unknown>>>> {
2421
2672
  const collectionTableSql = quoteIdentifier(tableMapping.tableName)
2673
+ // Compile even when SQL cannot safely prefilter: invalid bindings must
2674
+ // reject before the in-memory fallback reads rows.
2422
2675
  const whereCompiled = options.where
2423
2676
  ? compileSqlExpression(options.where)
2424
- : { supported: true, sql: ``, params: [] as Array<SqliteSupportedValue> }
2677
+ : { supported: false, sql: ``, params: [] as Array<SqliteSupportedValue> }
2678
+ const safePrefilter = options.where
2679
+ ? compileSafeSqlPrefilter(options.where, whereCompiled)
2680
+ : undefined
2425
2681
  const orderByCompiled = compileOrderByClauses(options.orderBy)
2426
2682
 
2427
2683
  const queryParams: Array<SqliteSupportedValue> = []
2428
2684
  let sql = `SELECT key, value, metadata, row_version FROM ${collectionTableSql}`
2429
2685
 
2430
- if (options.where && whereCompiled.supported) {
2431
- sql = `${sql} WHERE ${whereCompiled.sql}`
2432
- queryParams.push(...whereCompiled.params)
2686
+ if (safePrefilter) {
2687
+ sql = `${sql} WHERE ${safePrefilter.sql}`
2688
+ queryParams.push(...safePrefilter.params)
2433
2689
  }
2434
2690
 
2435
2691
  if (options.orderBy && orderByCompiled.supported) {
@@ -2437,6 +2693,14 @@ export class SQLiteCorePersistenceAdapter implements PersistenceAdapter {
2437
2693
  queryParams.push(...orderByCompiled.params)
2438
2694
  }
2439
2695
 
2696
+ if (
2697
+ queryParams.length >
2698
+ (driver.maxBoundParameters ?? this.driver.maxBoundParameters ?? 999)
2699
+ ) {
2700
+ sql = `SELECT key, value, metadata, row_version FROM ${collectionTableSql}`
2701
+ queryParams.length = 0
2702
+ }
2703
+
2440
2704
  const storedRows = await driver.query<StoredSqliteRow>(sql, queryParams)
2441
2705
  const parsedRows = decodeStoredSqliteRows(storedRows)
2442
2706