@evolu/common 7.2.2 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/src/Function.d.ts +61 -0
  2. package/dist/src/Function.d.ts.map +1 -1
  3. package/dist/src/Function.js +19 -0
  4. package/dist/src/Object.d.ts +32 -1
  5. package/dist/src/Object.d.ts.map +1 -1
  6. package/dist/src/Object.js +32 -1
  7. package/dist/src/Order.d.ts +2 -1
  8. package/dist/src/Order.d.ts.map +1 -1
  9. package/dist/src/Order.js +1 -0
  10. package/dist/src/Result.d.ts +36 -29
  11. package/dist/src/Result.d.ts.map +1 -1
  12. package/dist/src/Sqlite.d.ts +9 -7
  13. package/dist/src/Sqlite.d.ts.map +1 -1
  14. package/dist/src/Sqlite.js +8 -0
  15. package/dist/src/Task.d.ts.map +1 -1
  16. package/dist/src/Task.js +6 -4
  17. package/dist/src/Type.d.ts +30 -2
  18. package/dist/src/Type.d.ts.map +1 -1
  19. package/dist/src/Type.js +82 -0
  20. package/dist/src/local-first/Db.d.ts +1 -5
  21. package/dist/src/local-first/Db.d.ts.map +1 -1
  22. package/dist/src/local-first/Db.js +112 -117
  23. package/dist/src/local-first/Evolu.d.ts +3 -0
  24. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  25. package/dist/src/local-first/Evolu.js +14 -12
  26. package/dist/src/local-first/LocalAuth.d.ts +2 -2
  27. package/dist/src/local-first/Owner.d.ts +3 -0
  28. package/dist/src/local-first/Owner.d.ts.map +1 -1
  29. package/dist/src/local-first/Protocol.d.ts +1 -1
  30. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  31. package/dist/src/local-first/Protocol.js +12 -12
  32. package/dist/src/local-first/PublicKysely.js +1 -1
  33. package/dist/src/local-first/Query.d.ts +54 -6
  34. package/dist/src/local-first/Query.d.ts.map +1 -1
  35. package/dist/src/local-first/Query.js +130 -11
  36. package/dist/src/local-first/Relay.d.ts.map +1 -1
  37. package/dist/src/local-first/Relay.js +17 -18
  38. package/dist/src/local-first/Schema.d.ts +15 -14
  39. package/dist/src/local-first/Schema.d.ts.map +1 -1
  40. package/dist/src/local-first/Schema.js +43 -51
  41. package/dist/src/local-first/Storage.d.ts +2 -2
  42. package/dist/src/local-first/Storage.d.ts.map +1 -1
  43. package/dist/src/local-first/Storage.js +2 -6
  44. package/dist/src/local-first/Sync.d.ts +10 -7
  45. package/dist/src/local-first/Sync.d.ts.map +1 -1
  46. package/dist/src/local-first/Sync.js +154 -103
  47. package/dist/src/local-first/Timestamp.d.ts +13 -0
  48. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  49. package/dist/src/local-first/Timestamp.js +14 -1
  50. package/dist/src/local-first/index.d.ts +0 -1
  51. package/dist/src/local-first/index.d.ts.map +1 -1
  52. package/dist/src/local-first/index.js +0 -1
  53. package/package.json +1 -1
  54. package/src/Function.ts +74 -0
  55. package/src/Object.ts +40 -1
  56. package/src/Order.ts +2 -1
  57. package/src/Result.ts +38 -29
  58. package/src/Sqlite.ts +18 -7
  59. package/src/Task.ts +6 -4
  60. package/src/Type.ts +134 -1
  61. package/src/local-first/Db.ts +182 -217
  62. package/src/local-first/Evolu.ts +21 -10
  63. package/src/local-first/LocalAuth.ts +2 -2
  64. package/src/local-first/Owner.ts +4 -0
  65. package/src/local-first/Protocol.ts +13 -19
  66. package/src/local-first/PublicKysely.ts +1 -1
  67. package/src/local-first/Query.ts +216 -21
  68. package/src/local-first/Relay.ts +20 -24
  69. package/src/local-first/Schema.ts +83 -70
  70. package/src/local-first/Storage.ts +3 -8
  71. package/src/local-first/Sync.ts +215 -135
  72. package/src/local-first/Timestamp.ts +14 -1
  73. package/src/local-first/index.ts +0 -1
  74. package/dist/src/local-first/Diff.d.ts +0 -43
  75. package/dist/src/local-first/Diff.d.ts.map +0 -1
  76. package/dist/src/local-first/Diff.js +0 -97
  77. package/src/local-first/Diff.ts +0 -144
@@ -26,13 +26,13 @@ import {
26
26
  TypeError,
27
27
  } from "../Type.js";
28
28
  import {
29
- OwnerError,
30
29
  Owner,
30
+ OwnerError,
31
31
  OwnerId,
32
32
  OwnerIdBytes,
33
33
  OwnerWriteKey,
34
34
  } from "./Owner.js";
35
- import { systemColumns } from "./Schema.js";
35
+ import { systemColumnsWithId } from "./Schema.js";
36
36
  import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
37
37
 
38
38
  export interface StorageConfig {
@@ -256,16 +256,11 @@ export interface CrdtMessage {
256
256
  export const DbChangeValues = record(String, SqliteValue);
257
257
  export type DbChangeValues = typeof DbChangeValues.Type;
258
258
 
259
- // "createdAt" and "updatedAt" are derived from Timestamp.
260
- // "id" and "isDeleted" are encoded separately to save bytes
261
- // "ownerId" is part of the protocol message
262
- const forbiddenSystemColumns = systemColumns.concat("id");
263
-
264
259
  export const ValidDbChangeValues = brand(
265
260
  "ValidDbChangeValues",
266
261
  DbChangeValues,
267
262
  (value) => {
268
- const invalidColumns = forbiddenSystemColumns.filter((key) => key in value);
263
+ const invalidColumns = systemColumnsWithId.filter((key) => key in value);
269
264
  if (invalidColumns.length > 0)
270
265
  return err<ValidDbChangeValuesError>({
271
266
  type: "ValidDbChangeValues",
@@ -1,10 +1,11 @@
1
1
  import {
2
+ appendToArray,
2
3
  firstInArray,
3
4
  isNonEmptyReadonlyArray,
4
5
  NonEmptyArray,
5
6
  NonEmptyReadonlyArray,
6
7
  } from "../Array.js";
7
- import { assert } from "../Assert.js";
8
+ import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
8
9
  import { Brand } from "../Brand.js";
9
10
  import { ConsoleDep } from "../Console.js";
10
11
  import {
@@ -15,7 +16,7 @@ import {
15
16
  import { eqArrayNumber } from "../Eq.js";
16
17
  import { createTransferableError, TransferableError } from "../Error.js";
17
18
  import { constFalse, constTrue } from "../Function.js";
18
- import { objectToEntries } from "../Object.js";
19
+ import { createRecord, getProperty, objectToEntries } from "../Object.js";
19
20
  import { RandomDep } from "../Random.js";
20
21
  import { createResources } from "../Resources.js";
21
22
  import { err, ok, Result } from "../Result.js";
@@ -26,22 +27,23 @@ import {
26
27
  sqliteBooleanToBoolean,
27
28
  SqliteDep,
28
29
  SqliteError,
29
- sqliteFalse,
30
30
  SqliteValue,
31
31
  } from "../Sqlite.js";
32
32
  import { AbortError, createMutex } from "../Task.js";
33
33
  import { TimeDep } from "../Time.js";
34
34
  import {
35
+ Boolean,
35
36
  DateIso,
37
+ Id,
36
38
  IdBytes,
37
39
  idBytesToId,
38
40
  idToIdBytes,
39
41
  PositiveInt,
40
42
  } from "../Type.js";
41
43
  import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
42
- import type { AppOwnerDep, PostMessageDep } from "./Db.js";
43
44
  import {
44
45
  AppOwner,
46
+ AppOwnerDep,
45
47
  Owner,
46
48
  OwnerId,
47
49
  OwnerIdBytes,
@@ -62,7 +64,7 @@ import {
62
64
  ProtocolTimestampMismatchError,
63
65
  SubscriptionFlags,
64
66
  } from "./Protocol.js";
65
- import { MutationChange } from "./Schema.js";
67
+ import { DbSchemaDep, MutationChange, systemColumns } from "./Schema.js";
66
68
  import {
67
69
  BaseSqliteStorage,
68
70
  CrdtMessage,
@@ -71,7 +73,6 @@ import {
71
73
  getOwnerUsage,
72
74
  getTimestampInsertStrategy,
73
75
  Storage,
74
- StorageInsertTimestampStrategy,
75
76
  StorageWriteError,
76
77
  updateOwnerUsage,
77
78
  } from "./Storage.js";
@@ -81,6 +82,7 @@ import {
81
82
  receiveTimestamp,
82
83
  sendTimestamp,
83
84
  Timestamp,
85
+ TimestampBytes,
84
86
  timestampBytesToTimestamp,
85
87
  TimestampConfigDep,
86
88
  TimestampCounterOverflowError,
@@ -158,7 +160,8 @@ export const createSync =
158
160
  deps: ClockDep &
159
161
  ConsoleDep &
160
162
  CreateWebSocketDep &
161
- PostMessageDep &
163
+ DbSchemaDep &
164
+ // PostMessageDep &
162
165
  RandomBytesDep &
163
166
  RandomDep &
164
167
  SqliteDep &
@@ -325,7 +328,6 @@ export const createSync =
325
328
  resources.addConsumer(owner, transports);
326
329
  } else {
327
330
  const result = resources.removeConsumer(owner, transports);
328
-
329
331
  if (!result.ok) {
330
332
  deps.console.warn("[sync]", "Failed to remove consumer", {
331
333
  transports,
@@ -446,13 +448,13 @@ export interface ClientStorageDep {
446
448
  const createClientStorage =
447
449
  (
448
450
  deps: ClockDep &
451
+ DbSchemaDep &
449
452
  GetSyncOwnerDep &
450
453
  RandomDep &
451
454
  SqliteDep &
452
455
  SymmetricCryptoDep &
453
456
  TimeDep &
454
- TimestampConfigDep &
455
- PostMessageDep,
457
+ TimestampConfigDep,
456
458
  ) =>
457
459
  (config: {
458
460
  onError: (
@@ -486,7 +488,7 @@ const createClientStorage =
486
488
  const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
487
489
 
488
490
  // Everything is sync now, but we will need async crypto in the future.
489
- const writeResult = await mutex.withLock<
491
+ const result = await mutex.withLock<
490
492
  boolean,
491
493
  | AbortError
492
494
  | ProtocolInvalidDataError
@@ -538,19 +540,13 @@ const createClientStorage =
538
540
  }
539
541
 
540
542
  if (isNonEmptyReadonlyArray(messages)) {
541
- const applyMessagesResult = applyMessages({ ...deps, storage })(
543
+ const result = applyMessages({ ...deps, storage })(
542
544
  owner.id,
543
545
  messages,
544
546
  );
545
- if (!applyMessagesResult.ok) return applyMessagesResult;
547
+ if (!result.ok) return result;
546
548
  }
547
549
 
548
- // // Apply local mutations atomically with approved messages
549
- // for (const change of localMutations) {
550
- // const result = applyLocalOnlyChange(deps)(change);
551
- // if (!result.ok) return result;
552
- // }
553
-
554
550
  return deps.clock.save(clockTimestamp);
555
551
  });
556
552
 
@@ -559,9 +555,9 @@ const createClientStorage =
559
555
  return ok(true);
560
556
  })();
561
557
 
562
- if (!writeResult.ok) {
563
- if (writeResult.error.type !== "AbortError") {
564
- config.onError(writeResult.error);
558
+ if (!result.ok) {
559
+ if (result.error.type !== "AbortError") {
560
+ config.onError(result.error);
565
561
  }
566
562
  return err<StorageWriteError>({ type: "StorageWriteError", ownerId });
567
563
  }
@@ -577,26 +573,34 @@ const createClientStorage =
577
573
  if (!owner) return null;
578
574
 
579
575
  const result = deps.sqlite.exec<{
580
- table: string;
581
- id: IdBytes;
582
- column: string;
583
- value: SqliteValue;
576
+ readonly table: string;
577
+ readonly id: IdBytes;
578
+ readonly column: string;
579
+ readonly value: SqliteValue;
584
580
  }>(sql`
585
581
  select "table", "id", "column", "value"
586
582
  from evolu_history
583
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
584
+ union all
585
+ select "table", "id", "column", "value"
586
+ from evolu_message_quarantine
587
587
  where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
588
588
  `);
589
+
589
590
  if (!result.ok) {
590
591
  config.onError(result.error);
591
592
  return null;
592
593
  }
593
594
 
594
595
  const { rows } = result.value;
595
- assert(rows.length > 0, "Rows must not be empty");
596
+ assertNonEmptyReadonlyArray(
597
+ rows,
598
+ "Every timestamp must have rows in evolu_history or quarantine",
599
+ );
596
600
 
597
- const { table, id } = rows[0];
598
- const values: Record<string, SqliteValue> = {};
599
- let isInsert = false;
601
+ const { table, id } = firstInArray(rows);
602
+ const values = createRecord<string, SqliteValue>();
603
+ let isInsert;
600
604
  let isDelete: boolean | null = null;
601
605
 
602
606
  for (const r of rows) {
@@ -606,10 +610,13 @@ const createClientStorage =
606
610
  case "createdAt":
607
611
  isInsert = true;
608
612
  break;
613
+ case "updatedAt":
614
+ isInsert = false;
615
+ break;
609
616
  case "isDeleted":
610
617
  assert(
611
618
  SqliteBoolean.is(r.value),
612
- "isDeleted column must contain a valid SqliteBoolean (0 or 1)",
619
+ "isDeleted column must contain a valid SqliteBoolean",
613
620
  );
614
621
  isDelete = sqliteBooleanToBoolean(r.value);
615
622
  break;
@@ -618,6 +625,8 @@ const createClientStorage =
618
625
  }
619
626
  }
620
627
 
628
+ assert(Boolean.is(isInsert), "isInsert must be in evolu_history");
629
+
621
630
  const message: CrdtMessage = {
622
631
  timestamp: timestampBytesToTimestamp(timestamp),
623
632
  change: DbChange.orThrow({
@@ -643,6 +652,24 @@ const createTransportKey = (transport: OwnerTransport): TransportKey => {
643
652
  return `${transport.type}:${transport.url}` as TransportKey;
644
653
  };
645
654
 
655
+ const dbChangeToColumns = (change: DbChange, now: DateIso) => {
656
+ let values = objectToEntries(change.values);
657
+
658
+ // SystemColumns are not encoded in change.values.
659
+ values = appendToArray(values, [
660
+ change.isInsert ? "createdAt" : "updatedAt",
661
+ now,
662
+ ]);
663
+ if (change.isDelete != null) {
664
+ values = appendToArray(values, [
665
+ "isDeleted",
666
+ booleanToSqliteBoolean(change.isDelete),
667
+ ]);
668
+ }
669
+
670
+ return values;
671
+ };
672
+
646
673
  export const applyLocalOnlyChange =
647
674
  (deps: SqliteDep & TimeDep & AppOwnerDep) =>
648
675
  (change: MutationChange): Result<void, SqliteError> => {
@@ -653,23 +680,16 @@ export const applyLocalOnlyChange =
653
680
  `);
654
681
  if (!result.ok) return result;
655
682
  } else {
656
- const now = deps.time.nowIso();
657
683
  const ownerId = deps.appOwner.id;
684
+ const columns = dbChangeToColumns(change, deps.time.nowIso());
658
685
 
659
- let entries = objectToEntries(change.values);
660
- if (change.isDelete !== null) {
661
- entries = [...entries, ["isDeleted", sqliteFalse]];
662
- }
663
-
664
- for (const [column, value] of entries) {
686
+ for (const [column, value] of columns) {
665
687
  const result = deps.sqlite.exec(sql.prepared`
666
688
  insert into ${sql.identifier(change.table)}
667
- ("ownerId", "id", ${sql.identifier(column)}, createdAt, updatedAt)
668
- values (${ownerId}, ${change.id}, ${value}, ${now}, ${now})
689
+ ("ownerId", "id", ${sql.identifier(column)})
690
+ values (${ownerId}, ${change.id}, ${value})
669
691
  on conflict ("ownerId", "id") do update
670
- set
671
- ${sql.identifier(column)} = ${value},
672
- updatedAt = ${now};
692
+ set ${sql.identifier(column)} = ${value};
673
693
  `);
674
694
  if (!result.ok) return result;
675
695
  }
@@ -679,129 +699,150 @@ export const applyLocalOnlyChange =
679
699
  };
680
700
 
681
701
  const applyMessages =
682
- (deps: ClientStorageDep & ClockDep & RandomDep & SqliteDep) =>
702
+ (deps: ClientStorageDep & ClockDep & DbSchemaDep & RandomDep & SqliteDep) =>
683
703
  (
684
704
  ownerId: OwnerId,
685
705
  messages: NonEmptyReadonlyArray<CrdtMessage>,
686
706
  ): Result<void, SqliteError> => {
687
707
  const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
688
708
 
689
- const usageResult = getOwnerUsage(deps)(
709
+ const usage = getOwnerUsage(deps)(
690
710
  ownerIdBytes,
691
711
  timestampToTimestampBytes(firstInArray(messages).timestamp),
692
712
  );
693
- if (!usageResult.ok) return usageResult;
694
-
695
- let { firstTimestamp, lastTimestamp } = usageResult.value;
696
-
697
- for (const message of messages) {
698
- const date = timestampToDateIso(message.timestamp);
699
- const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message, date);
700
- if (!result1.ok) return result1;
701
-
702
- const timestamp = timestampToTimestampBytes(message.timestamp);
713
+ if (!usage.ok) return usage;
714
+
715
+ let { firstTimestamp, lastTimestamp } = usage.value;
716
+
717
+ for (const { timestamp, change } of messages) {
718
+ const columns = dbChangeToColumns(change, timestampToDateIso(timestamp));
719
+ const idBytes = idToIdBytes(change.id);
720
+ const timestampBytes = timestampToTimestampBytes(timestamp);
721
+
722
+ for (const [column, value] of columns) {
723
+ if (validateColumnValue(deps)(change.table, column, value)) {
724
+ const result = applyColumnChange(deps)(
725
+ ownerIdBytes,
726
+ ownerId,
727
+ change.table,
728
+ idBytes,
729
+ change.id,
730
+ column,
731
+ value,
732
+ timestampBytes,
733
+ );
734
+ if (!result.ok) return result;
735
+ } else {
736
+ const result = deps.sqlite.exec(sql.prepared`
737
+ insert into evolu_message_quarantine
738
+ ("ownerId", "timestamp", "table", "id", "column", "value")
739
+ values
740
+ (
741
+ ${ownerIdBytes},
742
+ ${timestampBytes},
743
+ ${change.table},
744
+ ${idBytes},
745
+ ${column},
746
+ ${value}
747
+ )
748
+ on conflict do nothing;
749
+ `);
750
+ if (!result.ok) return result;
751
+ }
752
+ }
703
753
 
704
754
  let strategy;
705
755
  [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
706
- timestamp,
756
+ timestampBytes,
707
757
  firstTimestamp,
708
758
  lastTimestamp,
709
759
  );
710
760
 
711
- const result2 = applyMessageToTimestampAndHistoryTables(deps)(
761
+ const result = deps.storage.insertTimestamp(
712
762
  ownerIdBytes,
713
- message,
763
+ timestampBytes,
714
764
  strategy,
715
- date,
716
765
  );
717
- if (!result2.ok) return result2;
766
+ if (!result.ok) return result;
718
767
  }
719
768
 
720
769
  /**
721
- * TODO: Implement proper storedBytes tracking for client using encrypted
722
- * message sizes (need to figure out how to reuse received or postpone
723
- * client...).
770
+ * TODO: Implement proper storedBytes tracking for client using received and
771
+ * sent encrypted message sizes.
724
772
  */
725
- const updateUsage = updateOwnerUsage(deps)(
773
+ return updateOwnerUsage(deps)(
726
774
  ownerIdBytes,
727
775
  1 as PositiveInt, // Placeholder until proper tracking implemented
728
776
  firstTimestamp,
729
777
  lastTimestamp,
730
778
  );
731
- if (!updateUsage.ok) return updateUsage;
779
+ };
732
780
 
733
- return ok();
781
+ /**
782
+ * System columns that can appear in sync messages. Excludes `ownerId` because
783
+ * it's handled separately (stored per-row, not per-column in messages).
784
+ */
785
+ const systemColumnsWithoutOwnerId = systemColumns.difference(
786
+ new Set(["ownerId"]),
787
+ );
788
+
789
+ const validateColumnValue =
790
+ (deps: DbSchemaDep) =>
791
+ (table: string, column: string, _value: SqliteValue): boolean => {
792
+ const schemaColumns = getProperty(deps.dbSchema.tables, table);
793
+ return (
794
+ schemaColumns != null &&
795
+ (systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column))
796
+ );
734
797
  };
735
798
 
736
- const applyMessageToAppTable =
799
+ const applyColumnChange =
737
800
  (deps: SqliteDep) =>
738
801
  (
739
802
  ownerIdBytes: OwnerIdBytes,
740
- message: CrdtMessage,
741
- date: DateIso,
742
- ): Result<void, SqliteError> => {
743
- const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
744
- const columns = dbChangeToColumns(message.change, date);
745
-
746
- for (const [column, value] of columns) {
747
- const result = deps.sqlite.exec(sql.prepared`
748
- with
749
- existingTimestamp as (
750
- select 1
751
- from evolu_history
752
- where
753
- "ownerId" = ${ownerIdBytes}
754
- and "table" = ${message.change.table}
755
- and "id" = ${idToIdBytes(message.change.id)}
756
- and "column" = ${column}
757
- and "timestamp" >= ${timestampToTimestampBytes(message.timestamp)}
758
- limit 1
759
- )
760
- insert into ${sql.identifier(message.change.table)}
761
- ("ownerId", "id", ${sql.identifier(column)}, updatedAt)
762
- select ${ownerId}, ${message.change.id}, ${value}, ${date}
763
- where not exists (select 1 from existingTimestamp)
764
- on conflict ("ownerId", "id") do update
765
- set
766
- ${sql.identifier(column)} = ${value},
767
- updatedAt = ${date}
768
- where not exists (select 1 from existingTimestamp);
769
- `);
770
-
771
- if (!result.ok) return result;
772
- }
773
-
774
- return ok();
775
- };
776
-
777
- export const applyMessageToTimestampAndHistoryTables =
778
- (deps: ClientStorageDep & SqliteDep) =>
779
- (
780
- ownerId: OwnerIdBytes,
781
- message: CrdtMessage,
782
- strategy: StorageInsertTimestampStrategy,
783
- date: DateIso,
803
+ ownerId: OwnerId,
804
+ table: string,
805
+ idBytes: IdBytes,
806
+ id: Id,
807
+ column: string,
808
+ value: SqliteValue,
809
+ timestampBytes: TimestampBytes,
784
810
  ): Result<void, SqliteError> => {
785
- const timestamp = timestampToTimestampBytes(message.timestamp);
786
- const id = idToIdBytes(message.change.id);
787
-
788
- const result = deps.storage.insertTimestamp(ownerId, timestamp, strategy);
811
+ const result = deps.sqlite.exec(sql.prepared`
812
+ with
813
+ existingTimestamp as (
814
+ select 1
815
+ from evolu_history
816
+ where
817
+ "ownerId" = ${ownerIdBytes}
818
+ and "table" = ${table}
819
+ and "id" = ${idBytes}
820
+ and "column" = ${column}
821
+ and "timestamp" >= ${timestampBytes}
822
+ limit 1
823
+ )
824
+ insert into ${sql.identifier(table)}
825
+ ("ownerId", "id", ${sql.identifier(column)})
826
+ select ${ownerId}, ${id}, ${value}
827
+ where not exists (select 1 from existingTimestamp)
828
+ on conflict ("ownerId", "id") do update
829
+ set ${sql.identifier(column)} = ${value}
830
+ where not exists (select 1 from existingTimestamp);
831
+ `);
789
832
  if (!result.ok) return result;
790
833
 
791
- const columns = dbChangeToColumns(message.change, date);
792
-
793
- for (const [column, value] of columns) {
834
+ {
794
835
  const result = deps.sqlite.exec(sql.prepared`
795
836
  insert into evolu_history
796
837
  ("ownerId", "table", "id", "column", "value", "timestamp")
797
838
  values
798
839
  (
799
- ${ownerId},
800
- ${message.change.table},
801
- ${id},
840
+ ${ownerIdBytes},
841
+ ${table},
842
+ ${idBytes},
802
843
  ${column},
803
844
  ${value},
804
- ${timestamp}
845
+ ${timestampBytes}
805
846
  )
806
847
  on conflict do nothing;
807
848
  `);
@@ -811,19 +852,58 @@ export const applyMessageToTimestampAndHistoryTables =
811
852
  return ok();
812
853
  };
813
854
 
814
- const dbChangeToColumns = (
815
- change: DbChange,
816
- date: DateIso,
817
- ): Array<[string, SqliteValue | DateIso]> => {
818
- const entries = [...objectToEntries(change.values)];
819
- if (change.isInsert) {
820
- entries.push(["createdAt", date]);
821
- }
822
- if (change.isDelete !== null) {
823
- entries.push(["isDeleted", booleanToSqliteBoolean(change.isDelete)]);
824
- }
825
- return entries;
826
- };
855
+ /**
856
+ * Attempts to apply quarantined messages that may now be valid after a schema
857
+ * update. Messages are quarantined when they reference tables or columns that
858
+ * don't exist in the current schema (e.g., from a newer app version).
859
+ */
860
+ export const tryApplyQuarantinedMessages =
861
+ (deps: DbSchemaDep & SqliteDep) => (): Result<void, SqliteError> => {
862
+ const rows = deps.sqlite.exec<{
863
+ readonly ownerId: OwnerIdBytes;
864
+ readonly timestamp: TimestampBytes;
865
+ readonly table: string;
866
+ readonly id: IdBytes;
867
+ readonly column: string;
868
+ readonly value: SqliteValue;
869
+ }>(sql`
870
+ select "ownerId", "timestamp", "table", "id", "column", "value"
871
+ from evolu_message_quarantine;
872
+ `);
873
+ if (!rows.ok) return rows;
874
+
875
+ for (const row of rows.value.rows) {
876
+ if (!validateColumnValue(deps)(row.table, row.column, row.value))
877
+ continue;
878
+
879
+ const result = applyColumnChange(deps)(
880
+ row.ownerId,
881
+ ownerIdBytesToOwnerId(row.ownerId),
882
+ row.table,
883
+ row.id,
884
+ idBytesToId(row.id),
885
+ row.column,
886
+ row.value,
887
+ row.timestamp,
888
+ );
889
+ if (!result.ok) return result;
890
+
891
+ {
892
+ const result = deps.sqlite.exec(sql`
893
+ delete from evolu_message_quarantine
894
+ where
895
+ "ownerId" = ${row.ownerId}
896
+ and "timestamp" = ${row.timestamp}
897
+ and "table" = ${row.table}
898
+ and "id" = ${row.id}
899
+ and "column" = ${row.column};
900
+ `);
901
+ if (!result.ok) return result;
902
+ }
903
+ }
904
+
905
+ return ok();
906
+ };
827
907
 
828
908
  /**
829
909
  * TODO: Rework for the new owners API.
@@ -336,8 +336,21 @@ export const timestampBytesToTimestamp = (
336
336
  return { millis: Number(millis), counter, nodeId } as Timestamp;
337
337
  };
338
338
 
339
+ /**
340
+ * An {@link Order} for {@link TimestampBytes}.
341
+ *
342
+ * This `Order` uses lexicographic byte order to compare serialized
343
+ * {@link TimestampBytes} produced by {@link timestampToTimestampBytes}. See
344
+ * {@link orderUint8Array} for the underlying implementation.
345
+ */
339
346
  export const orderTimestampBytes: Order<TimestampBytes> = orderUint8Array;
340
347
 
348
+ /**
349
+ * Convert a {@link Timestamp} to an ISO 8601 {@link DateIso} string.
350
+ *
351
+ * The conversion uses the timestamp's `millis` (a {@link Millis} value) and
352
+ * `Date.prototype.toISOString()` to produce a `DateIso`.
353
+ */
341
354
  export const timestampToDateIso = (timestamp: Timestamp): DateIso =>
342
- // `as DateIso` is safe because the timestamp is always valid
355
+ // `as DateIso` is safe because Timestamp guarantees a valid `millis`
343
356
  new Date(timestamp.millis).toISOString() as DateIso;
@@ -5,7 +5,6 @@
5
5
  */
6
6
 
7
7
  export * from "./Db.js";
8
- export * from "./Diff.js";
9
8
  export * from "./Evolu.js";
10
9
  export * from "./Owner.js";
11
10
  export * from "./Platform.js";
@@ -1,43 +0,0 @@
1
- import { Query, Row } from "./Query.js";
2
- export interface QueryPatches {
3
- readonly query: Query;
4
- readonly patches: ReadonlyArray<Patch>;
5
- }
6
- export type Patch = ReplaceAllPatch | ReplaceAtPatch;
7
- export interface ReplaceAllPatch {
8
- readonly op: "replaceAll";
9
- readonly value: ReadonlyArray<Row>;
10
- }
11
- export interface ReplaceAtPatch {
12
- readonly op: "replaceAt";
13
- readonly index: number;
14
- readonly value: Row;
15
- }
16
- export declare const applyPatches: (patches: ReadonlyArray<Patch>, current: ReadonlyArray<Row>) => ReadonlyArray<Row>;
17
- /**
18
- * We detect only changes in the whole result and in-place edits. In the future,
19
- * we will add more heuristics. We will probably not implement the Myers diff
20
- * algorithm because it's faster to rerender all than to compute many detailed
21
- * patches. We will only implement logic a developer would implement manually,
22
- * if necessary.
23
- */
24
- export declare const makePatches: (previousRows: ReadonlyArray<Row> | undefined, nextRows: ReadonlyArray<Row>) => ReadonlyArray<Patch>;
25
- /**
26
- * A unique identifier prepended to JSON-encoded strings. This allows safe
27
- * detection and parsing of only those columns that require JSON.parse.
28
- *
29
- * The identifier is a cryptographically random Evolu Id, ensuring uniqueness
30
- * and preventing malicious actors from inserting fake data that could be
31
- * misinterpreted as JSON by the application.
32
- *
33
- * Note: The same queries created by different browser tabs will have different
34
- * identifiers and thus be considered different and cached separately. This is
35
- * usually not a big deal, but if needed, the DB cache can be optimized by
36
- * passing the kyselyJsonIdentifier into the DB worker during initialization,
37
- * allowing queries to be grouped and recognized across tabs or sessions.
38
- *
39
- * See: https://github.com/kysely-org/kysely/issues/1372#issuecomment-2702773948
40
- */
41
- export declare const kyselyJsonIdentifier: string & import("../Brand.js").Brand<"Id">;
42
- export declare const parseSqliteJsonArray: <T>(arr: ReadonlyArray<T>) => ReadonlyArray<T>;
43
- //# sourceMappingURL=Diff.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Diff.d.ts","sourceRoot":"","sources":["../../../src/local-first/Diff.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAExC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,KAAK,CAAC,CAAC;CACxC;AAED,MAAM,MAAM,KAAK,GAAG,eAAe,GAAG,cAAc,CAAC;AAErD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,YAAY,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,GAAG,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC;CACrB;AAED,eAAO,MAAM,YAAY,GACvB,SAAS,aAAa,CAAC,KAAK,CAAC,EAC7B,SAAS,aAAa,CAAC,GAAG,CAAC,KAC1B,aAAa,CAAC,GAAG,CAUP,CAAC;AAEd;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GACtB,cAAc,aAAa,CAAC,GAAG,CAAC,GAAG,SAAS,EAC5C,UAAU,aAAa,CAAC,GAAG,CAAC,KAC3B,aAAa,CAAC,KAAK,CA6BrB,CAAC;AAUF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,oBAAoB,4CAE/B,CAAC;AAEH,eAAO,MAAM,oBAAoB,GAAI,CAAC,EACpC,KAAK,aAAa,CAAC,CAAC,CAAC,KACpB,aAAa,CAAC,CAAC,CAMjB,CAAC"}