@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,8 +1,8 @@
1
- import { IR, toBooleanPredicate, compileSingleRowExpression } from "@tanstack/db";
1
+ import { IR, toBooleanPredicate, compileSingleRowExpression, compareTemporalValues } from "@tanstack/db";
2
2
  import { InvalidPersistedCollectionConfigError, InvalidPersistedStorageKeyEncodingError } from "./errors.js";
3
3
  import { encodePersistedStorageKey, createPersistedTableName, decodePersistedStorageKey, SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY } from "./persisted.js";
4
- import { PERSISTED_TYPE_TAG, PERSISTED_VALUE_TAG, serializeSQLiteBigInt, assertSQLiteBigIntInRange } from "./sqlite-value.js";
5
- const REPLACEMENT_BATCH_SIZE = 100;
4
+ import { serializeSQLiteTemporal, sqliteTemporalIdentity, assertSQLiteBigIntInRange, sqliteTemporalKind, PERSISTED_TYPE_TAG, PERSISTED_VALUE_TAG, reviveSQLiteTemporal, serializeSQLiteBigInt } from "./sqlite-value.js";
5
+ const REPLACEMENT_BATCH_SIZE = 125;
6
6
  class SharedPersistenceScheduler {
7
7
  constructor() {
8
8
  this.regularQueue = [];
@@ -94,6 +94,7 @@ function observeSharedLogicalSchedulingSupport(driver, onSupport) {
94
94
  return promise;
95
95
  };
96
96
  return {
97
+ maxBoundParameters: driver.maxBoundParameters,
97
98
  exec: (sql) => observe(driver.exec(sql)),
98
99
  query: (sql, params = []) => observe(driver.query(sql, params)),
99
100
  run: (sql, params = []) => observe(driver.run(sql, params)),
@@ -107,7 +108,6 @@ const DEFAULT_SCHEMA_VERSION = 1;
107
108
  const DEFAULT_PULL_SINCE_RELOAD_THRESHOLD = 128;
108
109
  const DEFAULT_APPLIED_TX_PRUNE_MAX_ROWS = 1e3;
109
110
  const DEFAULT_APPLIED_TX_PRUNE_MAX_AGE_SECONDS = 24 * 60 * 60;
110
- const SQLITE_MAX_IN_BATCH_SIZE = 900;
111
111
  const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
112
112
  const FORBIDDEN_SQL_FRAGMENT_PATTERN = /(;|--|\/\*)/;
113
113
  const persistedTaggedValueTypes = /* @__PURE__ */ new Set([
@@ -115,7 +115,10 @@ const persistedTaggedValueTypes = /* @__PURE__ */ new Set([
115
115
  `date`,
116
116
  `nan`,
117
117
  `infinity`,
118
- `-infinity`
118
+ `-infinity`,
119
+ `string`,
120
+ `Temporal.Instant`,
121
+ `Temporal.PlainDate`
119
122
  ]);
120
123
  const orderByObjectIds = /* @__PURE__ */ new WeakMap();
121
124
  let nextOrderByObjectId = 1;
@@ -153,6 +156,8 @@ function encodePersistedJsonValue(value) {
153
156
  if (typeof value === `bigint`) {
154
157
  return serializeSQLiteBigInt(value);
155
158
  }
159
+ const temporal = serializeSQLiteTemporal(value);
160
+ if (temporal) return temporal;
156
161
  if (value instanceof Date) {
157
162
  return {
158
163
  [PERSISTED_TYPE_TAG]: `date`,
@@ -192,6 +197,12 @@ function encodePersistedJsonValue(value) {
192
197
  encodedRecord[key] = encodedValue;
193
198
  }
194
199
  }
200
+ if (typeof recordValue[PERSISTED_TYPE_TAG] === `string`) {
201
+ encodedRecord[PERSISTED_TYPE_TAG] = {
202
+ [PERSISTED_TYPE_TAG]: `string`,
203
+ [PERSISTED_VALUE_TAG]: recordValue[PERSISTED_TYPE_TAG]
204
+ };
205
+ }
195
206
  return encodedRecord;
196
207
  }
197
208
  return value;
@@ -202,6 +213,14 @@ function decodePersistedJsonValue(value) {
202
213
  }
203
214
  if (isPersistedTaggedValue(value)) {
204
215
  switch (value[PERSISTED_TYPE_TAG]) {
216
+ case `string`:
217
+ return value[PERSISTED_VALUE_TAG];
218
+ case `Temporal.Instant`:
219
+ case `Temporal.PlainDate`:
220
+ return reviveSQLiteTemporal(
221
+ value[PERSISTED_TYPE_TAG],
222
+ value[PERSISTED_VALUE_TAG]
223
+ );
205
224
  case `bigint`:
206
225
  return BigInt(value[PERSISTED_VALUE_TAG]);
207
226
  case `date`: {
@@ -261,7 +280,8 @@ function toSqliteParameterValue(value) {
261
280
  if (value instanceof Date) {
262
281
  return value.toISOString();
263
282
  }
264
- return serializePersistedRowValue(value);
283
+ const temporal = serializeSQLiteTemporal(value);
284
+ return temporal?.order ?? serializePersistedRowValue(value);
265
285
  }
266
286
  function toSqliteLiteral(value) {
267
287
  if (value === null) {
@@ -270,6 +290,9 @@ function toSqliteLiteral(value) {
270
290
  if (typeof value === `number`) {
271
291
  return Number.isFinite(value) ? String(value) : `NULL`;
272
292
  }
293
+ if (value.includes(`\0`)) {
294
+ return `json_extract(${toSqliteLiteral(JSON.stringify(value))}, '$')`;
295
+ }
273
296
  return `'${value.replace(/'/g, `''`)}'`;
274
297
  }
275
298
  function toSqliteExpressionLiteral(value) {
@@ -345,6 +368,9 @@ function compareOrderByValues(left, right, compareOptions) {
345
368
  if (left instanceof Date && right instanceof Date) {
346
369
  return left.getTime() - right.getTime();
347
370
  }
371
+ if (sqliteTemporalKind(left) && sqliteTemporalKind(right)) {
372
+ return compareTemporalValues(left, right);
373
+ }
348
374
  const leftIsObject = typeof left === `object`;
349
375
  const rightIsObject = typeof right === `object`;
350
376
  if (leftIsObject || rightIsObject) {
@@ -379,6 +405,33 @@ function createJsonPath(path) {
379
405
  }
380
406
  return jsonPath;
381
407
  }
408
+ const MAX_INDEXED_NUMERIC_PATH_SEGMENTS = 3;
409
+ function hasDeepNumericPath(expression) {
410
+ if (expression.type === `ref`) {
411
+ return IR.getPropRefPropertyPath(expression).filter(
412
+ (segment) => /^[0-9]+$/.test(String(segment))
413
+ ).length > MAX_INDEXED_NUMERIC_PATH_SEGMENTS;
414
+ }
415
+ return expression.type === `func` && expression.args.some(hasDeepNumericPath);
416
+ }
417
+ function createJsonPathVariants(path) {
418
+ const canonical = createJsonPath(path);
419
+ if (!canonical) return null;
420
+ let paths = [`$`];
421
+ for (const segment of path) {
422
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment)) {
423
+ paths = paths.map((prefix) => `${prefix}.${segment}`);
424
+ } else if (/^[0-9]+$/.test(segment)) {
425
+ if (paths.length >= 2 ** MAX_INDEXED_NUMERIC_PATH_SEGMENTS)
426
+ return [canonical];
427
+ paths = paths.flatMap((prefix) => [
428
+ `${prefix}[${segment}]`,
429
+ `${prefix}."${segment}"`
430
+ ]);
431
+ }
432
+ }
433
+ return paths;
434
+ }
382
435
  function getLiteralValueKind(value) {
383
436
  if (typeof value === `bigint`) {
384
437
  return `bigint`;
@@ -424,16 +477,23 @@ function compileRefExpressionSql(jsonPath) {
424
477
  const typePathSql = toSqliteLiteral(typePath);
425
478
  const taggedValuePathSql = toSqliteLiteral(taggedValuePath);
426
479
  const jsonPathSql = toSqliteLiteral(jsonPath);
427
- return {
428
- supported: true,
429
- sql: `(CASE json_extract(value, ${typePathSql})
480
+ const typeSql = `json_extract(value, ${typePathSql})`;
481
+ const orderPathSql = toSqliteLiteral(`${jsonPath}.order`);
482
+ const sql = `(CASE ${typeSql}
430
483
  WHEN 'bigint' THEN CAST(json_extract(value, ${taggedValuePathSql}) AS NUMERIC)
431
484
  WHEN 'date' THEN json_extract(value, ${taggedValuePathSql})
485
+ WHEN 'string' THEN json_extract(value, ${taggedValuePathSql})
486
+ WHEN 'Temporal.Instant' THEN json_extract(value, ${orderPathSql})
487
+ WHEN 'Temporal.PlainDate' THEN json_extract(value, ${orderPathSql})
432
488
  WHEN 'nan' THEN NULL
433
489
  WHEN 'infinity' THEN NULL
434
490
  WHEN '-infinity' THEN NULL
435
491
  ELSE json_extract(value, ${jsonPathSql})
436
- END)`,
492
+ END)`;
493
+ return {
494
+ supported: true,
495
+ sql,
496
+ identitySql: `(CASE WHEN ${typeSql} IN ('Temporal.Instant', 'Temporal.PlainDate') THEN ${typeSql} || ':' || json_extract(value, ${taggedValuePathSql}) ELSE ${sql} END)`,
437
497
  params: [],
438
498
  valueKind: `unknown`
439
499
  };
@@ -461,6 +521,11 @@ function decodeStoredSqliteRows(storedRows) {
461
521
  function stableStringify(value) {
462
522
  return serializePersistedRowValue(value);
463
523
  }
524
+ function joinSqlDisjunction(parts) {
525
+ if (parts.length === 1) return parts[0];
526
+ const middle = Math.floor(parts.length / 2);
527
+ return `(${joinSqlDisjunction(parts.slice(0, middle))} OR ${joinSqlDisjunction(parts.slice(middle))})`;
528
+ }
464
529
  function argumentCompilationContext(parentName, argumentIndex, argument, parentContext) {
465
530
  if (parentContext === `index-expression`) return `index-expression`;
466
531
  switch (parentName) {
@@ -486,30 +551,59 @@ function argumentCompilationContext(parentName, argumentIndex, argument, parentC
486
551
  return `index-expression`;
487
552
  }
488
553
  }
554
+ function hasUnpairedSurrogate(value) {
555
+ return /[\uD800-\uDFFF]/u.test(value);
556
+ }
557
+ function isSafeEqualityLiteral(value) {
558
+ return typeof value === `string` || typeof value === `boolean` || typeof value === `bigint` || sqliteTemporalKind(value) !== void 0;
559
+ }
560
+ function isSafeCoalesceOperand(expression) {
561
+ return expression?.type === `func` && expression.name === `coalesce` && expression.args.length === 2 && expression.args[0]?.type === `ref` && expression.args[1]?.type === `val` && isSafeEqualityLiteral(expression.args[1].value) && !(typeof expression.args[1].value === `string` && hasUnpairedSurrogate(expression.args[1].value));
562
+ }
489
563
  function compileSqlExpression(expression, context = `predicate`) {
490
564
  if (expression.type === `val`) {
491
- const valueKind = getLiteralValueKind(expression.value);
565
+ if (context === `predicate` && typeof expression.value === `string` && hasUnpairedSurrogate(expression.value)) {
566
+ return {
567
+ supported: true,
568
+ sql: `json_extract(?, '$')`,
569
+ params: [JSON.stringify(expression.value)]
570
+ };
571
+ }
572
+ const temporal = serializeSQLiteTemporal(expression.value);
573
+ const value = temporal?.order ?? toSqliteParameterValue(expression.value);
492
574
  return {
575
+ identitySql: temporal ? toSqliteLiteral(sqliteTemporalIdentity(temporal)) : void 0,
493
576
  supported: true,
494
- sql: context === `index-expression` ? toSqliteExpressionLiteral(expression.value) : `?`,
495
- params: context === `predicate` ? [toSqliteParameterValue(expression.value)] : [],
496
- valueKind
577
+ sql: context === `index-expression` ? toSqliteExpressionLiteral(
578
+ typeof expression.value === `bigint` ? expression.value : value
579
+ ) : `?`,
580
+ params: context === `predicate` ? [value] : [],
581
+ valueKind: getLiteralValueKind(expression.value)
497
582
  };
498
583
  }
499
584
  if (expression.type === `ref`) {
500
- const jsonPath = createJsonPath(
585
+ const jsonPaths = createJsonPathVariants(
501
586
  IR.getPropRefPropertyPath(expression).map(String)
502
587
  );
503
- if (!jsonPath) {
588
+ if (!jsonPaths) {
504
589
  return {
505
590
  supported: false,
506
591
  sql: ``,
507
592
  params: []
508
593
  };
509
594
  }
510
- return compileRefExpressionSql(jsonPath);
595
+ if (jsonPaths.length === 1) return compileRefExpressionSql(jsonPaths[0]);
596
+ const variants = jsonPaths.map(compileRefExpressionSql);
597
+ return {
598
+ supported: true,
599
+ identitySql: `COALESCE(${variants.map((variant) => variant.identitySql).join(`, `)})`,
600
+ sql: `COALESCE(${variants.map((variant) => variant.sql).join(`, `)})`,
601
+ params: [],
602
+ valueKind: `unknown`
603
+ };
511
604
  }
512
- const compiledArgs = expression.args.map(
605
+ const args = expression.name === `in` ? expression.args.slice(0, 1) : expression.args;
606
+ const compiledArgs = args.map(
513
607
  (arg, index) => compileSqlExpression(
514
608
  arg,
515
609
  argumentCompilationContext(expression.name, index, arg, context)
@@ -546,38 +640,37 @@ function compileSqlExpression(expression, context = `predicate`) {
546
640
  lt: `<`,
547
641
  lte: `<=`
548
642
  };
643
+ const comparison = compileComparisonSql(
644
+ operatorByName[expression.name],
645
+ expression.args[0],
646
+ expression.args[1],
647
+ argSql[0],
648
+ argSql[1],
649
+ valueKind,
650
+ getCompiledValueKind(compiledArgs[0]),
651
+ getCompiledValueKind(compiledArgs[1])
652
+ );
653
+ const needsIdentity = expression.name === `eq` && expression.args.every(
654
+ (arg) => arg.type !== `val` || sqliteTemporalKind(arg.value) || context === `index-expression` && typeof arg.value === `string`
655
+ );
549
656
  return {
550
657
  supported: true,
551
- sql: compileComparisonSql(
552
- operatorByName[expression.name],
553
- expression.args[0],
554
- expression.args[1],
555
- argSql[0],
556
- argSql[1],
557
- valueKind,
558
- getCompiledValueKind(compiledArgs[0]),
559
- getCompiledValueKind(compiledArgs[1])
560
- ),
561
- params
562
- };
563
- }
564
- case `and`: {
565
- if (argSql.length < 2) {
566
- return { supported: false, sql: ``, params: [] };
567
- }
568
- return {
569
- supported: true,
570
- sql: argSql.map((sql) => `(${sql})`).join(` AND `),
658
+ sql: needsIdentity ? `(${comparison} AND (${compiledArgs[0].identitySql ?? argSql[0]} = ${compiledArgs[1].identitySql ?? argSql[1]}))` : comparison,
571
659
  params
572
660
  };
573
661
  }
662
+ case `and`:
574
663
  case `or`: {
575
- if (argSql.length < 2) {
576
- return { supported: false, sql: ``, params: [] };
664
+ if (argSql.length === 0) {
665
+ return {
666
+ supported: true,
667
+ sql: expression.name === `and` ? `(1 = 1)` : `(0 = 1)`,
668
+ params: []
669
+ };
577
670
  }
578
671
  return {
579
672
  supported: true,
580
- sql: argSql.map((sql) => `(${sql})`).join(` OR `),
673
+ sql: argSql.map((sql) => `(${sql})`).join(expression.name === `and` ? ` AND ` : ` OR `),
581
674
  params
582
675
  };
583
676
  }
@@ -607,44 +700,34 @@ function compileSqlExpression(expression, context = `predicate`) {
607
700
  if (!leftSql) {
608
701
  return { supported: false, sql: ``, params: [] };
609
702
  }
610
- if (context === `index-expression`) {
703
+ const values = listValue.map((value) => {
704
+ const temporal = serializeSQLiteTemporal(value);
611
705
  return {
612
- supported: true,
613
- sql: `(${leftSql} IN (${listValue.map((value) => toSqliteExpressionLiteral(value)).join(`, `)}))`,
614
- params: leftParams
706
+ value: typeof value === `bigint` ? assertSQLiteBigIntInRange(value) : temporal?.order ?? toSqliteParameterValue(value),
707
+ identity: temporal ? sqliteTemporalIdentity(temporal) : void 0
615
708
  };
709
+ });
710
+ let identity = ``;
711
+ if (listValue.some(
712
+ (value) => sqliteTemporalKind(value) || context === `index-expression` && typeof value === `string`
713
+ )) {
714
+ const leftIdentity = compiledArgs[0]?.identitySql ?? leftSql;
715
+ identity = context === `index-expression` ? ` AND (${joinSqlDisjunction(values.map(({ value, identity: nativeIdentity }) => `(${leftSql} = ${toSqliteExpressionLiteral(value)} AND ${leftIdentity} = ${toSqliteExpressionLiteral(nativeIdentity ?? value)})`))})` : ` AND ((${leftSql}, ${leftIdentity}) IN (VALUES ${[...new Set(values.map(({ value, identity: nativeIdentity }) => `(${toSqliteExpressionLiteral(value)}, ${toSqliteExpressionLiteral(nativeIdentity ?? value)})`))].join(`, `)}))`;
616
716
  }
617
- if (listValue.length > SQLITE_MAX_IN_BATCH_SIZE) {
618
- const chunkClauses = [];
619
- const batchedParams = [];
620
- for (let startIndex = 0; startIndex < listValue.length; startIndex += SQLITE_MAX_IN_BATCH_SIZE) {
621
- const chunkValues = listValue.slice(
622
- startIndex,
623
- startIndex + SQLITE_MAX_IN_BATCH_SIZE
624
- );
625
- const chunkParams = [];
626
- const chunkValueSql = chunkValues.map((value) => {
627
- chunkParams.push(toSqliteParameterValue(value));
628
- return typeof value === `bigint` ? `CAST(? AS NUMERIC)` : `?`;
629
- });
630
- chunkClauses.push(`(${leftSql} IN (${chunkValueSql.join(`, `)}))`);
631
- batchedParams.push(...leftParams, ...chunkParams);
632
- }
717
+ if (context === `index-expression`) {
633
718
  return {
634
719
  supported: true,
635
- sql: `(${chunkClauses.join(` OR `)})`,
636
- params: batchedParams
720
+ sql: `(${leftSql} IN (${values.map(({ value }) => toSqliteExpressionLiteral(value)).join(`, `)})${identity})`,
721
+ params: leftParams
637
722
  };
638
723
  }
639
- const listParams = [];
640
- const listValueSql = listValue.map((value) => {
641
- listParams.push(toSqliteParameterValue(value));
642
- return typeof value === `bigint` ? `CAST(? AS NUMERIC)` : `?`;
643
- });
724
+ const jsonList = `[${values.map(
725
+ ({ value }) => typeof value === `bigint` ? assertSQLiteBigIntInRange(value).toString() : JSON.stringify(value)
726
+ ).join(`,`)}]`;
644
727
  return {
645
728
  supported: true,
646
- sql: `(${leftSql} IN (${listValueSql.join(`, `)}))`,
647
- params: [...leftParams, ...listParams]
729
+ sql: `(${leftSql} IN (SELECT value FROM json_each(?))${identity})`,
730
+ params: [...leftParams, jsonList]
648
731
  };
649
732
  }
650
733
  case `like`:
@@ -671,7 +754,12 @@ function compileSqlExpression(expression, context = `predicate`) {
671
754
  case `concat`:
672
755
  return { supported: true, sql: `(${argSql.join(` || `)})`, params };
673
756
  case `coalesce`:
674
- return { supported: true, sql: `COALESCE(${argSql.join(`, `)})`, params };
757
+ return {
758
+ supported: true,
759
+ sql: `COALESCE(${argSql.join(`, `)})`,
760
+ identitySql: `COALESCE(${compiledArgs.map((arg) => arg.identitySql ?? arg.sql).join(`, `)})`,
761
+ params
762
+ };
675
763
  case `add`:
676
764
  return { supported: true, sql: `(${argSql[0]} + ${argSql[1]})`, params };
677
765
  case `subtract`:
@@ -708,6 +796,131 @@ function compileSqlExpression(expression, context = `predicate`) {
708
796
  };
709
797
  }
710
798
  }
799
+ function nativeComparisonRef(expression) {
800
+ if (expression.type !== `func` || expression.args.length !== 2)
801
+ return void 0;
802
+ const [left, right] = expression.args;
803
+ if (expression.name === `in`) {
804
+ return left?.type === `ref` && right?.type === `val` && Array.isArray(right.value) && right.value.length > 0 && right.value.every((value) => sqliteTemporalKind(value)) ? left : void 0;
805
+ }
806
+ if (![`eq`, `gt`, `gte`, `lt`, `lte`].includes(expression.name))
807
+ return void 0;
808
+ if (left?.type === `ref` && right?.type === `val` && sqliteTemporalKind(right.value))
809
+ return left;
810
+ if (right?.type === `ref` && left?.type === `val` && sqliteTemporalKind(left.value))
811
+ return right;
812
+ return void 0;
813
+ }
814
+ function compileNativeRefGuard(ref) {
815
+ const paths = createJsonPathVariants(
816
+ IR.getPropRefPropertyPath(ref).map(String)
817
+ );
818
+ const kinds = paths.map(
819
+ (path) => `json_extract(value, ${toSqliteLiteral(`${path}.${PERSISTED_TYPE_TAG}`)})`
820
+ );
821
+ const kind = kinds.length === 1 ? kinds[0] : `COALESCE(${kinds.join(`, `)})`;
822
+ return `${kind} IN ('Temporal.Instant', 'Temporal.PlainDate')`;
823
+ }
824
+ function compileSafeSqlPrefilter(expression, compiled) {
825
+ const direct = () => {
826
+ const candidate = compiled ?? compileSqlExpression(expression);
827
+ return candidate.supported ? candidate : void 0;
828
+ };
829
+ if (expression.type === `val`) {
830
+ return typeof expression.value === `boolean` || expression.value == null ? direct() : void 0;
831
+ }
832
+ if (expression.type === `ref`) return void 0;
833
+ if (expression.name === `and` || expression.name === `or`) {
834
+ if (expression.args.length === 0) return direct();
835
+ const candidates = expression.args.map(
836
+ (argument) => compileSafeSqlPrefilter(argument)
837
+ );
838
+ if (expression.name === `or` && candidates.some((candidate) => !candidate))
839
+ return void 0;
840
+ const selected = candidates.filter(
841
+ (candidate) => candidate !== void 0
842
+ );
843
+ if (selected.length === 0) return void 0;
844
+ return {
845
+ supported: true,
846
+ sql: selected.map(({ sql }) => `(${sql})`).join(expression.name === `and` ? ` AND ` : ` OR `),
847
+ params: selected.flatMap(({ params }) => params)
848
+ };
849
+ }
850
+ if (hasDeepNumericPath(expression)) return void 0;
851
+ compiled ??= compileSqlExpression(expression);
852
+ if (!compiled.supported) return void 0;
853
+ const [left, right] = expression.args;
854
+ if (expression.name === `not` && expression.args.length === 1 && left && nativeComparisonRef(left)) {
855
+ return { ...compiled, sql: `COALESCE(${compiled.sql}, 1)` };
856
+ }
857
+ if (expression.args.length === 2) {
858
+ const nativeRef = nativeComparisonRef(expression);
859
+ if (nativeRef) {
860
+ if (expression.name === `eq` || expression.name === `in`) return compiled;
861
+ const fieldSql = compileSqlExpression(nativeRef, `index-expression`).sql;
862
+ return { ...compiled, sql: `(${compiled.sql} OR ${fieldSql} IS NULL)` };
863
+ }
864
+ if (expression.name === `eq` && left?.type === `ref` && right?.type === `ref`) {
865
+ return {
866
+ ...compiled,
867
+ sql: `(CASE WHEN ${compileNativeRefGuard(left)} AND ${compileNativeRefGuard(right)} THEN ${compiled.sql} ELSE 1 END)`
868
+ };
869
+ }
870
+ if ([`gt`, `gte`, `lt`, `lte`].includes(expression.name) && (left?.type === `ref` || right?.type === `ref`)) {
871
+ const field = left?.type === `ref` ? left : right?.type === `ref` ? right : void 0;
872
+ const literal = field === left ? right : left;
873
+ if (field && literal?.type === `val` && (typeof literal.value === `number` && Number.isSafeInteger(literal.value) || typeof literal.value === `bigint`)) {
874
+ const fieldSql = compileSqlExpression(field, `index-expression`).sql;
875
+ const literalSql = compileSqlExpression(
876
+ literal,
877
+ typeof literal.value === `bigint` ? `index-expression` : `predicate`
878
+ );
879
+ const greaterSide = field === left && [`gt`, `gte`].includes(expression.name) || field === right && [`lt`, `lte`].includes(expression.name);
880
+ const unsafeBigInt = typeof literal.value === `bigint` && !Number.isSafeInteger(Number(literal.value));
881
+ const roundedNumberCandidates = unsafeBigInt ? literal.value > 0n ? ` OR ${fieldSql} >= ${Number.MAX_SAFE_INTEGER}` : ` OR ${fieldSql} <= -${Number.MAX_SAFE_INTEGER}` : ``;
882
+ return {
883
+ supported: true,
884
+ sql: greaterSide ? `(${fieldSql} >= ${literalSql.sql} OR ${fieldSql} IS NULL${roundedNumberCandidates})` : `(${fieldSql} <= ${literalSql.sql} OR ${fieldSql} IS NULL OR ${fieldSql} >= ''${roundedNumberCandidates})`,
885
+ params: literalSql.params
886
+ };
887
+ }
888
+ }
889
+ if (expression.name === `eq`) {
890
+ if ((left?.type === `ref` || isSafeCoalesceOperand(left)) && right?.type === `val` && isSafeEqualityLiteral(right.value) || (right?.type === `ref` || isSafeCoalesceOperand(right)) && left?.type === `val` && isSafeEqualityLiteral(left.value)) {
891
+ return compiled;
892
+ }
893
+ const lower = left?.type === `func` && left.name === `lower` ? left : right?.type === `func` && right.name === `lower` ? right : void 0;
894
+ const literal = lower === left ? right : left;
895
+ if (lower?.args.length === 1 && lower.args[0]?.type === `ref` && literal?.type === `val` && typeof literal.value === `string` && [...literal.value].every((char) => char.charCodeAt(0) <= 127) && !literal.value.includes(`\0`)) {
896
+ const lowerSql = compileSqlExpression(lower, `index-expression`).sql;
897
+ return {
898
+ supported: true,
899
+ sql: `(${compiled.sql} OR (${lowerSql} >= ? AND ${lowerSql} GLOB '*[^ -~]*'))`,
900
+ params: [...compiled.params, literal.value]
901
+ };
902
+ }
903
+ }
904
+ }
905
+ if (expression.name === `in` && expression.args.length === 2 && right?.type === `val` && Array.isArray(right.value)) {
906
+ if (right.value.length === 0) return compiled;
907
+ if (left?.type === `ref`) {
908
+ if (right.value.every((value) => typeof value === `bigint`))
909
+ return compiled;
910
+ if (right.value.every((value) => typeof value === `string`)) {
911
+ return {
912
+ supported: true,
913
+ sql: `(${compileSqlExpression(left, `index-expression`).sql} IN (SELECT value FROM json_each(?)))`,
914
+ params: [JSON.stringify(right.value)]
915
+ };
916
+ }
917
+ }
918
+ }
919
+ if (expression.name === `like` && expression.args.length === 2 && left?.type === `ref` && right?.type === `val` && typeof right.value === `string` && /^[\x20-\x7e]*%$/.test(right.value) && !right.value.slice(0, -1).includes(`%`) && !right.value.includes(`_`)) {
920
+ return compiled;
921
+ }
922
+ return void 0;
923
+ }
711
924
  function compileOrderByClauses(orderBy) {
712
925
  if (!orderBy || orderBy.length === 0) {
713
926
  return {
@@ -797,6 +1010,23 @@ function mergeObjectRows(existing, incoming) {
797
1010
  }
798
1011
  return incoming;
799
1012
  }
1013
+ function* batches(changes, maxSize) {
1014
+ for (let offset = 0; offset < changes.length; offset += maxSize) {
1015
+ yield changes.slice(offset, offset + maxSize);
1016
+ }
1017
+ }
1018
+ function lastMetadataByKey(changes, keyOf, undefinedSetIsNull = false) {
1019
+ const latest = /* @__PURE__ */ new Map();
1020
+ for (const change of changes) {
1021
+ const key = keyOf(change);
1022
+ const previous = latest.get(key);
1023
+ if (previous?.type === `set` && (previous.value !== void 0 || !undefinedSetIsNull) && serializePersistedRowValue(previous.value) === void 0) {
1024
+ throw new TypeError(`Metadata value cannot be bound to SQLite`);
1025
+ }
1026
+ latest.set(key, change);
1027
+ }
1028
+ return [...latest.values()];
1029
+ }
800
1030
  function buildIndexName(collectionId, signature) {
801
1031
  const sanitizedSignature = signature.toLowerCase().replace(/[^a-z0-9]+/g, `_`).replace(/^_+|_+$/g, ``).slice(0, 24);
802
1032
  const hashSource = `${collectionId}:${signature}`;
@@ -1116,158 +1346,172 @@ class SQLiteCorePersistenceAdapter {
1116
1346
  await transactionDriver.run(`DELETE FROM ${collectionTableSql}`);
1117
1347
  await transactionDriver.run(`DELETE FROM ${tombstoneTableSql}`);
1118
1348
  }
1119
- const replacementKeys = replacesPersistedBaseline ? tx.mutations.map(
1120
- (mutation) => encodePersistedStorageKey(mutation.key)
1121
- ) : [];
1122
- const batchReplacement = replacesPersistedBaseline && tx.mutations.length > 0 && tx.mutations.every((mutation) => mutation.type !== `delete`) && new Set(replacementKeys).size === replacementKeys.length;
1123
- if (batchReplacement) {
1124
- const finalRowMetadata = /* @__PURE__ */ new Map();
1125
- for (const mutation of tx.rowMetadataMutations ?? []) {
1126
- finalRowMetadata.set(
1127
- encodePersistedStorageKey(mutation.key),
1128
- mutation.type === `delete` ? void 0 : mutation.value
1129
- );
1349
+ const rowMutations = /* @__PURE__ */ new Map();
1350
+ for (const mutation of tx.mutations) {
1351
+ const key = encodePersistedStorageKey(mutation.key);
1352
+ const previous = rowMutations.get(key);
1353
+ if (!previous) {
1354
+ rowMutations.set(key, mutation);
1355
+ continue;
1356
+ }
1357
+ serializePersistedRowValue(previous.value);
1358
+ if (previous.type !== `delete` && previous.metadataChanged === true && previous.metadata !== void 0) {
1359
+ serializePersistedRowValue(previous.metadata);
1360
+ }
1361
+ if (mutation.type === `delete`) {
1362
+ rowMutations.set(key, mutation);
1363
+ continue;
1130
1364
  }
1131
- for (let start = 0; start < tx.mutations.length; start += this.replacementBatchSize) {
1132
- const mutations = tx.mutations.slice(
1133
- start,
1134
- start + this.replacementBatchSize
1365
+ const value = mutation.type === `insert` || previous.type === `delete` ? mutation.value : mergeObjectRows(previous.value, mutation.value);
1366
+ const metadataChanged = mutation.metadataChanged === true || previous.type === `delete` || previous.metadataChanged === true;
1367
+ const metadata = mutation.metadataChanged === true ? mutation.metadata : previous.type === `delete` ? void 0 : previous.metadata;
1368
+ const next = { key: mutation.key, value, metadataChanged, metadata };
1369
+ rowMutations.set(
1370
+ key,
1371
+ mutation.type === `insert` || previous.type !== `update` ? { type: `insert`, ...next } : { type: `update`, ...next }
1372
+ );
1373
+ }
1374
+ for (const batch of batches(
1375
+ [...rowMutations.values()],
1376
+ this.replacementBatchSize
1377
+ )) {
1378
+ const writes = batch.filter((mutation) => mutation.type !== `delete`);
1379
+ const deletes = batch.filter((mutation) => mutation.type === `delete`);
1380
+ const readKeys = writes.filter(
1381
+ (mutation) => mutation.type === `update` || mutation.metadataChanged !== true
1382
+ ).map((mutation) => encodePersistedStorageKey(mutation.key));
1383
+ const existingRows = readKeys.length > 0 ? await transactionDriver.query(
1384
+ `SELECT key, value, metadata
1385
+ FROM ${collectionTableSql}
1386
+ WHERE key IN (${readKeys.map(() => `?`).join(`, `)})`,
1387
+ readKeys
1388
+ ) : [];
1389
+ const existing = new Map(existingRows.map((row) => [row.key, row]));
1390
+ const writeKeys = writes.map(
1391
+ (mutation) => encodePersistedStorageKey(mutation.key)
1392
+ );
1393
+ const deleteKeys = deletes.map(
1394
+ (mutation) => encodePersistedStorageKey(mutation.key)
1395
+ );
1396
+ if (tracksPersistedKeySet && writeKeys.length > 0) {
1397
+ await transactionDriver.run(
1398
+ `INSERT INTO collection_expected_keys (collection_id, key)
1399
+ VALUES ${writeKeys.map(() => `(?, ?)`).join(`, `)}
1400
+ ON CONFLICT(collection_id, key) DO NOTHING`,
1401
+ writeKeys.flatMap((key) => [collectionId, key])
1135
1402
  );
1136
- const keys = replacementKeys.slice(
1137
- start,
1138
- start + this.replacementBatchSize
1403
+ }
1404
+ if (tracksPersistedKeySet && deleteKeys.length > 0) {
1405
+ await transactionDriver.run(
1406
+ `DELETE FROM collection_expected_keys
1407
+ WHERE collection_id = ? AND key IN (${deleteKeys.map(() => `?`).join(`, `)})`,
1408
+ [collectionId, ...deleteKeys]
1139
1409
  );
1410
+ }
1411
+ if (deleteKeys.length > 0) {
1140
1412
  await transactionDriver.run(
1141
- `INSERT INTO collection_expected_keys (collection_id, key)
1142
- VALUES ${keys.map(() => `(?, ?)`).join(`, `)}`,
1143
- keys.flatMap((key) => [collectionId, key])
1413
+ `DELETE FROM ${collectionTableSql}
1414
+ WHERE key IN (${deleteKeys.map(() => `?`).join(`, `)})`,
1415
+ deleteKeys
1144
1416
  );
1417
+ }
1418
+ if (writeKeys.length > 0) {
1145
1419
  await transactionDriver.run(
1146
1420
  `INSERT INTO ${collectionTableSql} (key, value, metadata, row_version)
1147
- VALUES ${keys.map(() => `(?, ?, ?, ?)`).join(`, `)}`,
1148
- mutations.flatMap((mutation, index) => {
1149
- const key = keys[index];
1150
- const metadata = finalRowMetadata.has(key) ? finalRowMetadata.get(key) : mutation.type !== `delete` && mutation.metadataChanged === true ? mutation.metadata : void 0;
1421
+ VALUES ${writeKeys.map(() => `(?, ?, ?, ?)`).join(`, `)}
1422
+ ON CONFLICT(key) DO UPDATE SET
1423
+ value = excluded.value,
1424
+ metadata = excluded.metadata,
1425
+ row_version = excluded.row_version`,
1426
+ writes.flatMap((mutation, index) => {
1427
+ const previous = existing.get(writeKeys[index]);
1428
+ const previousValue = previous?.value ? deserializePersistedRowValue(previous.value) : void 0;
1429
+ const previousMetadata = previous?.metadata != null ? deserializePersistedRowValue(previous.metadata) : void 0;
1430
+ const value = mutation.type === `update` ? mergeObjectRows(previousValue, mutation.value) : mutation.value;
1431
+ const metadata = mutation.metadataChanged === true ? mutation.metadata : previousMetadata;
1151
1432
  return [
1152
- key,
1153
- serializePersistedRowValue(mutation.value),
1433
+ writeKeys[index],
1434
+ serializePersistedRowValue(value),
1154
1435
  metadata === void 0 ? null : serializePersistedRowValue(metadata),
1155
1436
  nextRowVersion
1156
1437
  ];
1157
1438
  })
1158
1439
  );
1159
- }
1160
- }
1161
- for (const mutation of batchReplacement ? [] : tx.mutations) {
1162
- const encodedKey = encodePersistedStorageKey(mutation.key);
1163
- if (mutation.type === `delete`) {
1164
- if (tracksPersistedKeySet) {
1165
- await transactionDriver.run(
1166
- `DELETE FROM collection_expected_keys
1167
- WHERE collection_id = ? AND key = ?`,
1168
- [collectionId, encodedKey]
1169
- );
1170
- }
1171
1440
  await transactionDriver.run(
1172
- `DELETE FROM ${collectionTableSql}
1173
- WHERE key = ?`,
1174
- [encodedKey]
1441
+ `DELETE FROM ${tombstoneTableSql}
1442
+ WHERE key IN (${writeKeys.map(() => `?`).join(`, `)})`,
1443
+ writeKeys
1175
1444
  );
1445
+ }
1446
+ if (deletes.length > 0) {
1176
1447
  await transactionDriver.run(
1177
1448
  `INSERT INTO ${tombstoneTableSql} (key, value, row_version, deleted_at)
1178
- VALUES (?, ?, ?, ?)
1449
+ VALUES ${deleteKeys.map(() => `(?, ?, ?, ?)`).join(`, `)}
1179
1450
  ON CONFLICT(key) DO UPDATE SET
1180
1451
  value = excluded.value,
1181
1452
  row_version = excluded.row_version,
1182
1453
  deleted_at = excluded.deleted_at`,
1183
- [
1184
- encodedKey,
1454
+ deletes.flatMap((mutation, index) => [
1455
+ deleteKeys[index],
1185
1456
  serializePersistedRowValue(mutation.value),
1186
1457
  nextRowVersion,
1187
1458
  (/* @__PURE__ */ new Date()).toISOString()
1188
- ]
1459
+ ])
1189
1460
  );
1190
- continue;
1191
1461
  }
1192
- const existingRows = await transactionDriver.query(
1193
- `SELECT value, metadata
1194
- FROM ${collectionTableSql}
1195
- WHERE key = ?
1196
- LIMIT 1`,
1197
- [encodedKey]
1462
+ }
1463
+ for (const batch of batches(
1464
+ lastMetadataByKey(
1465
+ tx.rowMetadataMutations ?? [],
1466
+ (mutation) => encodePersistedStorageKey(mutation.key),
1467
+ true
1468
+ ),
1469
+ this.replacementBatchSize
1470
+ )) {
1471
+ const keys = batch.map(
1472
+ (mutation) => encodePersistedStorageKey(mutation.key)
1198
1473
  );
1199
- const existingValue = existingRows[0]?.value ? deserializePersistedRowValue(existingRows[0].value) : void 0;
1200
- const existingMetadata = existingRows[0]?.metadata != null ? deserializePersistedRowValue(existingRows[0].metadata) : void 0;
1201
- const mergedValue = mutation.type === `update` ? mergeObjectRows(existingValue, mutation.value) : mutation.value;
1202
- const nextMetadata = mutation.metadataChanged === true ? mutation.metadata : existingMetadata;
1203
- if (tracksPersistedKeySet) {
1204
- await transactionDriver.run(
1205
- `INSERT INTO collection_expected_keys (collection_id, key)
1206
- VALUES (?, ?)
1207
- ON CONFLICT(collection_id, key) DO NOTHING`,
1208
- [collectionId, encodedKey]
1209
- );
1210
- }
1211
1474
  await transactionDriver.run(
1212
- `INSERT INTO ${collectionTableSql} (key, value, metadata, row_version)
1213
- VALUES (?, ?, ?, ?)
1214
- ON CONFLICT(key) DO UPDATE SET
1215
- value = excluded.value,
1216
- metadata = excluded.metadata,
1217
- row_version = excluded.row_version`,
1475
+ `UPDATE ${collectionTableSql}
1476
+ SET metadata = CASE key ${keys.map(() => `WHEN ? THEN ?`).join(` `)} END
1477
+ WHERE key IN (${keys.map(() => `?`).join(`, `)})`,
1218
1478
  [
1219
- encodedKey,
1220
- serializePersistedRowValue(mergedValue),
1221
- nextMetadata === void 0 ? null : serializePersistedRowValue(nextMetadata),
1222
- nextRowVersion
1479
+ ...batch.flatMap((mutation, index) => [
1480
+ keys[index],
1481
+ mutation.type === `delete` || mutation.value === void 0 ? null : serializePersistedRowValue(mutation.value)
1482
+ ]),
1483
+ ...keys
1223
1484
  ]
1224
1485
  );
1225
- await transactionDriver.run(
1226
- `DELETE FROM ${tombstoneTableSql}
1227
- WHERE key = ?`,
1228
- [encodedKey]
1229
- );
1230
1486
  }
1231
- for (const rowMetadataMutation of batchReplacement ? [] : tx.rowMetadataMutations ?? []) {
1232
- const encodedKey = encodePersistedStorageKey(rowMetadataMutation.key);
1233
- if (rowMetadataMutation.type === `delete`) {
1234
- await transactionDriver.run(
1235
- `UPDATE ${collectionTableSql}
1236
- SET metadata = NULL
1237
- WHERE key = ?`,
1238
- [encodedKey]
1239
- );
1240
- } else {
1241
- await transactionDriver.run(
1242
- `UPDATE ${collectionTableSql}
1243
- SET metadata = ?
1244
- WHERE key = ?`,
1245
- [
1246
- rowMetadataMutation.value === void 0 ? null : serializePersistedRowValue(rowMetadataMutation.value),
1247
- encodedKey
1248
- ]
1249
- );
1250
- }
1251
- }
1252
- for (const metadataMutation of tx.collectionMetadataMutations ?? []) {
1253
- if (metadataMutation.type === `delete`) {
1487
+ for (const batch of batches(
1488
+ lastMetadataByKey(
1489
+ tx.collectionMetadataMutations ?? [],
1490
+ (mutation) => mutation.key
1491
+ ),
1492
+ this.replacementBatchSize
1493
+ )) {
1494
+ const deletes = batch.filter((mutation) => mutation.type === `delete`);
1495
+ const sets = batch.filter((mutation) => mutation.type === `set`);
1496
+ if (deletes.length > 0) {
1254
1497
  await transactionDriver.run(
1255
1498
  `DELETE FROM collection_metadata
1256
- WHERE collection_id = ? AND key = ?`,
1257
- [collectionId, metadataMutation.key]
1499
+ WHERE collection_id = ? AND key IN (${deletes.map(() => `?`).join(`, `)})`,
1500
+ [collectionId, ...deletes.map((mutation) => mutation.key)]
1258
1501
  );
1259
- } else {
1502
+ }
1503
+ if (sets.length > 0) {
1260
1504
  await transactionDriver.run(
1261
1505
  `INSERT INTO collection_metadata (collection_id, key, value, updated_at)
1262
- VALUES (?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
1506
+ VALUES ${sets.map(() => `(?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))`).join(`, `)}
1263
1507
  ON CONFLICT(collection_id, key) DO UPDATE SET
1264
1508
  value = excluded.value,
1265
1509
  updated_at = excluded.updated_at`,
1266
- [
1510
+ sets.flatMap((mutation) => [
1267
1511
  collectionId,
1268
- metadataMutation.key,
1269
- serializePersistedRowValue(metadataMutation.value)
1270
- ]
1512
+ mutation.key,
1513
+ serializePersistedRowValue(mutation.value)
1514
+ ])
1271
1515
  );
1272
1516
  }
1273
1517
  }
@@ -1680,18 +1924,23 @@ class SQLiteCorePersistenceAdapter {
1680
1924
  }
1681
1925
  async loadSubsetInternal(tableMapping, options, driver = this.driver) {
1682
1926
  const collectionTableSql = quoteIdentifier(tableMapping.tableName);
1683
- const whereCompiled = options.where ? compileSqlExpression(options.where) : { supported: true, sql: ``, params: [] };
1927
+ const whereCompiled = options.where ? compileSqlExpression(options.where) : { supported: false, sql: ``, params: [] };
1928
+ const safePrefilter = options.where ? compileSafeSqlPrefilter(options.where, whereCompiled) : void 0;
1684
1929
  const orderByCompiled = compileOrderByClauses(options.orderBy);
1685
1930
  const queryParams = [];
1686
1931
  let sql = `SELECT key, value, metadata, row_version FROM ${collectionTableSql}`;
1687
- if (options.where && whereCompiled.supported) {
1688
- sql = `${sql} WHERE ${whereCompiled.sql}`;
1689
- queryParams.push(...whereCompiled.params);
1932
+ if (safePrefilter) {
1933
+ sql = `${sql} WHERE ${safePrefilter.sql}`;
1934
+ queryParams.push(...safePrefilter.params);
1690
1935
  }
1691
1936
  if (options.orderBy && orderByCompiled.supported) {
1692
1937
  sql = `${sql} ORDER BY ${orderByCompiled.sql}, key ASC`;
1693
1938
  queryParams.push(...orderByCompiled.params);
1694
1939
  }
1940
+ if (queryParams.length > (driver.maxBoundParameters ?? this.driver.maxBoundParameters ?? 999)) {
1941
+ sql = `SELECT key, value, metadata, row_version FROM ${collectionTableSql}`;
1942
+ queryParams.length = 0;
1943
+ }
1695
1944
  const storedRows = await driver.query(sql, queryParams);
1696
1945
  const parsedRows = decodeStoredSqliteRows(storedRows);
1697
1946
  const filteredRows = this.applyInMemoryWhere(parsedRows, options.where);