@evolu/common 7.2.1 → 7.2.3

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 (43) hide show
  1. package/dist/src/Object.d.ts +16 -1
  2. package/dist/src/Object.d.ts.map +1 -1
  3. package/dist/src/Object.js +16 -1
  4. package/dist/src/Order.d.ts +2 -1
  5. package/dist/src/Order.d.ts.map +1 -1
  6. package/dist/src/Order.js +1 -0
  7. package/dist/src/WebSocket.d.ts.map +1 -1
  8. package/dist/src/WebSocket.js +4 -1
  9. package/dist/src/local-first/Db.d.ts.map +1 -1
  10. package/dist/src/local-first/Db.js +5 -11
  11. package/dist/src/local-first/Diff.js +2 -2
  12. package/dist/src/local-first/Evolu.d.ts +14 -11
  13. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  14. package/dist/src/local-first/Evolu.js +8 -5
  15. package/dist/src/local-first/Owner.d.ts +39 -42
  16. package/dist/src/local-first/Owner.d.ts.map +1 -1
  17. package/dist/src/local-first/Owner.js +22 -29
  18. package/dist/src/local-first/Protocol.d.ts +6 -6
  19. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  20. package/dist/src/local-first/Protocol.js +2 -2
  21. package/dist/src/local-first/Public.d.ts +1 -0
  22. package/dist/src/local-first/Public.d.ts.map +1 -1
  23. package/dist/src/local-first/Storage.d.ts +3 -3
  24. package/dist/src/local-first/Storage.d.ts.map +1 -1
  25. package/dist/src/local-first/Sync.d.ts +10 -17
  26. package/dist/src/local-first/Sync.d.ts.map +1 -1
  27. package/dist/src/local-first/Sync.js +108 -130
  28. package/dist/src/local-first/Timestamp.d.ts +13 -0
  29. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  30. package/dist/src/local-first/Timestamp.js +14 -1
  31. package/package.json +1 -1
  32. package/src/Object.ts +20 -1
  33. package/src/Order.ts +2 -1
  34. package/src/WebSocket.ts +6 -1
  35. package/src/local-first/Db.ts +5 -12
  36. package/src/local-first/Diff.ts +2 -2
  37. package/src/local-first/Evolu.ts +27 -15
  38. package/src/local-first/Owner.ts +47 -57
  39. package/src/local-first/Protocol.ts +8 -8
  40. package/src/local-first/Public.ts +1 -0
  41. package/src/local-first/Storage.ts +3 -3
  42. package/src/local-first/Sync.ts +123 -175
  43. package/src/local-first/Timestamp.ts +14 -1
@@ -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, 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,12 +27,12 @@ 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,
36
37
  IdBytes,
37
38
  idBytesToId,
@@ -42,16 +43,12 @@ import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
42
43
  import type { AppOwnerDep, PostMessageDep } from "./Db.js";
43
44
  import {
44
45
  AppOwner,
45
- OwnerEncryptionKey,
46
+ Owner,
46
47
  OwnerId,
47
- OwnerIdBytes,
48
48
  ownerIdBytesToOwnerId,
49
49
  ownerIdToOwnerIdBytes,
50
50
  OwnerTransport,
51
- OwnerWriteKey,
52
- ShardOwner,
53
- SharedOwner,
54
- SharedReadonlyOwner,
51
+ ReadonlyOwner,
55
52
  } from "./Owner.js";
56
53
  import {
57
54
  applyProtocolMessageAsClient,
@@ -74,7 +71,6 @@ import {
74
71
  getOwnerUsage,
75
72
  getTimestampInsertStrategy,
76
73
  Storage,
77
- StorageInsertTimestampStrategy,
78
74
  StorageWriteError,
79
75
  updateOwnerUsage,
80
76
  } from "./Storage.js";
@@ -97,9 +93,9 @@ export interface Sync extends Disposable {
97
93
  /**
98
94
  * Assigns or removes an owner to/from transports with reference counting.
99
95
  *
100
- * Owners are only "active" if assigned to at least one transport. Uses
101
- * `owner.transports` or falls back to config transports. Multiple calls
102
- * increment/decrement reference counts (useful for React Hooks).
96
+ * Owners are only synced if assigned to at least one transport. Uses
97
+ * `owner.transports` or falls back to {@link SyncConfig} transports. Multiple
98
+ * calls increment/decrement reference counts (useful for React Hooks).
103
99
  */
104
100
  readonly useOwner: (use: boolean, owner: SyncOwner) => void;
105
101
 
@@ -119,18 +115,13 @@ export interface SyncDep {
119
115
  }
120
116
 
121
117
  /**
122
- * Represents an owner for sync operations. This is a unified interface that
123
- * abstracts over the specific owner types ({@link ShardOwner},
124
- * {@link SharedOwner}, {@link SharedReadonlyOwner}) for the sync layer.
118
+ * Represents an owner for sync operations.
125
119
  *
126
- * The sync layer only needs the essential data for synchronization and doesn't
127
- * need to distinguish between different owner types.
120
+ * Includes readonly owner fields plus optional write key (for clients that
121
+ * write) and optional transports to override SyncConfig transports per owner.
128
122
  */
129
- export interface SyncOwner {
130
- readonly id: OwnerId;
131
- readonly encryptionKey: OwnerEncryptionKey;
132
- /** Optional for read-only owners like {@link SharedReadonlyOwner}. */
133
- readonly writeKey?: OwnerWriteKey;
123
+ export interface SyncOwner extends ReadonlyOwner {
124
+ readonly writeKey?: Owner["writeKey"];
134
125
  readonly transports?: ReadonlyArray<OwnerTransport>;
135
126
  }
136
127
 
@@ -180,7 +171,7 @@ export const createSync =
180
171
  /** Returns owner data only if actively assigned to at least one transport. */
181
172
  const getSyncOwner = (ownerId: OwnerId): SyncOwner | null => {
182
173
  if (isDisposed) return null;
183
- return transports.getConsumer(ownerId);
174
+ return resources.getConsumer(ownerId);
184
175
  };
185
176
 
186
177
  const storageResult = createClientStorage({
@@ -191,24 +182,24 @@ export const createSync =
191
182
  if (!storageResult.ok) return storageResult;
192
183
  const storage = storageResult.value;
193
184
 
194
- const createResource = (transportConfig: OwnerTransport): WebSocket => {
195
- const transportKey = createTransportKey(transportConfig);
185
+ const createResource = (transport: OwnerTransport): WebSocket => {
186
+ const transportKey = createTransportKey(transport);
196
187
 
197
188
  deps.console.log("[sync]", "createWebSocket", {
198
189
  transportKey,
199
- url: transportConfig.url,
190
+ url: transport.url,
200
191
  });
201
192
 
202
- return deps.createWebSocket(transportConfig.url, {
193
+ return deps.createWebSocket(transport.url, {
203
194
  binaryType: "arraybuffer",
204
195
 
205
196
  onOpen: () => {
206
197
  if (isDisposed) return;
207
198
 
208
- const webSocket = transports.getResource(transportKey);
199
+ const webSocket = resources.getResource(transportKey);
209
200
  if (!webSocket) return;
210
201
 
211
- const ownerIds = transports.getConsumersForResource(transportKey);
202
+ const ownerIds = resources.getConsumersForResource(transportKey);
212
203
  deps.console.log("[sync]", "onOpen", { transportKey, ownerIds });
213
204
 
214
205
  for (const ownerId of ownerIds) {
@@ -239,7 +230,7 @@ export const createSync =
239
230
  // Only handle ArrayBuffer data for sync messages
240
231
  if (isDisposed || !(data instanceof ArrayBuffer)) return;
241
232
 
242
- const webSocket = transports.getResource(transportKey);
233
+ const webSocket = resources.getResource(transportKey);
243
234
  if (!webSocket) return;
244
235
 
245
236
  const input = new Uint8Array(data);
@@ -277,7 +268,7 @@ export const createSync =
277
268
  });
278
269
  };
279
270
 
280
- const transports = createResources<
271
+ const resources = createResources<
281
272
  WebSocket,
282
273
  TransportKey,
283
274
  OwnerTransport,
@@ -327,16 +318,16 @@ export const createSync =
327
318
  }
328
319
 
329
320
  deps.console.log("[sync]", "useOwner", { use, owner });
330
- const transportsToUse = owner.transports ?? config.transports;
321
+ const transports = owner.transports ?? config.transports;
331
322
 
332
323
  if (use) {
333
- transports.addConsumer(owner, transportsToUse);
324
+ resources.addConsumer(owner, transports);
334
325
  } else {
335
- const result = transports.removeConsumer(owner, transportsToUse);
326
+ const result = resources.removeConsumer(owner, transports);
336
327
 
337
328
  if (!result.ok) {
338
329
  deps.console.warn("[sync]", "Failed to remove consumer", {
339
- transportsToUse,
330
+ transports,
340
331
  ownerId: owner.id,
341
332
  error: result.error,
342
333
  });
@@ -382,13 +373,13 @@ export const createSync =
382
373
  messages,
383
374
  );
384
375
 
385
- const transportsToUse = owner.transports ?? config.transports;
376
+ const transports = owner.transports ?? config.transports;
386
377
 
387
378
  // Send message to all transports for this owner
388
- for (const transportConfig of transportsToUse) {
389
- const transportKey = createTransportKey(transportConfig);
379
+ for (const transport of transports) {
380
+ const transportKey = createTransportKey(transport);
390
381
 
391
- const webSocket = transports.getResource(transportKey);
382
+ const webSocket = resources.getResource(transportKey);
392
383
  if (!webSocket) continue;
393
384
 
394
385
  if (webSocket.isOpen()) {
@@ -404,7 +395,7 @@ export const createSync =
404
395
  [Symbol.dispose]: () => {
405
396
  if (isDisposed) return;
406
397
  isDisposed = true;
407
- transports[Symbol.dispose]();
398
+ resources[Symbol.dispose]();
408
399
  },
409
400
  };
410
401
 
@@ -459,8 +450,7 @@ const createClientStorage =
459
450
  SqliteDep &
460
451
  SymmetricCryptoDep &
461
452
  TimeDep &
462
- TimestampConfigDep &
463
- PostMessageDep,
453
+ TimestampConfigDep,
464
454
  ) =>
465
455
  (config: {
466
456
  onError: (
@@ -553,12 +543,6 @@ const createClientStorage =
553
543
  if (!applyMessagesResult.ok) return applyMessagesResult;
554
544
  }
555
545
 
556
- // // Apply local mutations atomically with approved messages
557
- // for (const change of localMutations) {
558
- // const result = applyLocalOnlyChange(deps)(change);
559
- // if (!result.ok) return result;
560
- // }
561
-
562
546
  return deps.clock.save(clockTimestamp);
563
547
  });
564
548
 
@@ -600,11 +584,11 @@ const createClientStorage =
600
584
  }
601
585
 
602
586
  const { rows } = result.value;
603
- assert(rows.length > 0, "Rows must not be empty");
587
+ assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
604
588
 
605
- const { table, id } = rows[0];
606
- const values: Record<string, SqliteValue> = {};
607
- let isInsert = false;
589
+ const { table, id } = firstInArray(rows);
590
+ const values = createRecord<string, SqliteValue>();
591
+ let isInsert;
608
592
  let isDelete: boolean | null = null;
609
593
 
610
594
  for (const r of rows) {
@@ -614,10 +598,13 @@ const createClientStorage =
614
598
  case "createdAt":
615
599
  isInsert = true;
616
600
  break;
601
+ case "updatedAt":
602
+ isInsert = false;
603
+ break;
617
604
  case "isDeleted":
618
605
  assert(
619
606
  SqliteBoolean.is(r.value),
620
- "isDeleted column must contain a valid SqliteBoolean (0 or 1)",
607
+ "isDeleted column must contain a valid SqliteBoolean",
621
608
  );
622
609
  isDelete = sqliteBooleanToBoolean(r.value);
623
610
  break;
@@ -626,6 +613,8 @@ const createClientStorage =
626
613
  }
627
614
  }
628
615
 
616
+ assert(Boolean.is(isInsert), "isInsert must be in evolu_history");
617
+
629
618
  const message: CrdtMessage = {
630
619
  timestamp: timestampBytesToTimestamp(timestamp),
631
620
  change: DbChange.orThrow({
@@ -646,9 +635,27 @@ const createClientStorage =
646
635
 
647
636
  type TransportKey = string & Brand<"TransportKey">;
648
637
 
649
- /** Creates a unique identifier for a transport configuration. */
650
- const createTransportKey = (transportConfig: OwnerTransport): TransportKey => {
651
- return `${transportConfig.type}:${transportConfig.url}` as TransportKey;
638
+ /** Creates a unique identifier for a {@link OwnerTransport}. */
639
+ const createTransportKey = (transport: OwnerTransport): TransportKey => {
640
+ return `${transport.type}:${transport.url}` as TransportKey;
641
+ };
642
+
643
+ const dbChangeToColumns = (change: DbChange, now: DateIso) => {
644
+ let values = objectToEntries(change.values);
645
+
646
+ // SystemColumns are not encoded in change.values.
647
+ values = appendToArray(values, [
648
+ change.isInsert ? "createdAt" : "updatedAt",
649
+ now,
650
+ ]);
651
+ if (change.isDelete != null) {
652
+ values = appendToArray(values, [
653
+ "isDeleted",
654
+ booleanToSqliteBoolean(change.isDelete),
655
+ ]);
656
+ }
657
+
658
+ return values;
652
659
  };
653
660
 
654
661
  export const applyLocalOnlyChange =
@@ -661,23 +668,16 @@ export const applyLocalOnlyChange =
661
668
  `);
662
669
  if (!result.ok) return result;
663
670
  } else {
664
- const now = deps.time.nowIso();
665
671
  const ownerId = deps.appOwner.id;
672
+ const columns = dbChangeToColumns(change, deps.time.nowIso());
666
673
 
667
- let entries = objectToEntries(change.values);
668
- if (change.isDelete !== null) {
669
- entries = [...entries, ["isDeleted", sqliteFalse]];
670
- }
671
-
672
- for (const [column, value] of entries) {
674
+ for (const [column, value] of columns) {
673
675
  const result = deps.sqlite.exec(sql.prepared`
674
676
  insert into ${sql.identifier(change.table)}
675
- ("ownerId", "id", ${sql.identifier(column)}, createdAt, updatedAt)
676
- values (${ownerId}, ${change.id}, ${value}, ${now}, ${now})
677
+ ("ownerId", "id", ${sql.identifier(column)})
678
+ values (${ownerId}, ${change.id}, ${value})
677
679
  on conflict ("ownerId", "id") do update
678
- set
679
- ${sql.identifier(column)} = ${value},
680
- updatedAt = ${now};
680
+ set ${sql.identifier(column)} = ${value};
681
681
  `);
682
682
  if (!result.ok) return result;
683
683
  }
@@ -694,35 +694,75 @@ const applyMessages =
694
694
  ): Result<void, SqliteError> => {
695
695
  const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
696
696
 
697
- const usageResult = getOwnerUsage(deps)(
697
+ const usage = getOwnerUsage(deps)(
698
698
  ownerIdBytes,
699
699
  timestampToTimestampBytes(firstInArray(messages).timestamp),
700
700
  );
701
- if (!usageResult.ok) return usageResult;
702
-
703
- let { firstTimestamp, lastTimestamp } = usageResult.value;
701
+ if (!usage.ok) return usage;
702
+
703
+ let { firstTimestamp, lastTimestamp } = usage.value;
704
+
705
+ for (const { timestamp, change } of messages) {
706
+ const timestampBytes = timestampToTimestampBytes(timestamp);
707
+ const idBytes = idToIdBytes(change.id);
708
+ const columns = dbChangeToColumns(change, timestampToDateIso(timestamp));
709
+
710
+ for (const [column, value] of columns) {
711
+ const updateAppTable = deps.sqlite.exec(sql.prepared`
712
+ with
713
+ existingTimestamp as (
714
+ select 1
715
+ from evolu_history
716
+ where
717
+ "ownerId" = ${ownerIdBytes}
718
+ and "table" = ${change.table}
719
+ and "id" = ${idBytes}
720
+ and "column" = ${column}
721
+ and "timestamp" >= ${timestampBytes}
722
+ limit 1
723
+ )
724
+ insert into ${sql.identifier(change.table)}
725
+ ("ownerId", "id", ${sql.identifier(column)})
726
+ select ${ownerId}, ${change.id}, ${value}
727
+ where not exists (select 1 from existingTimestamp)
728
+ on conflict ("ownerId", "id") do update
729
+ set ${sql.identifier(column)} = ${value}
730
+ where not exists (select 1 from existingTimestamp);
731
+ `);
704
732
 
705
- for (const message of messages) {
706
- const date = timestampToDateIso(message.timestamp);
707
- const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message, date);
708
- if (!result1.ok) return result1;
733
+ if (!updateAppTable.ok) return updateAppTable;
734
+
735
+ const insertHistory = deps.sqlite.exec(sql.prepared`
736
+ insert into evolu_history
737
+ ("ownerId", "table", "id", "column", "value", "timestamp")
738
+ values
739
+ (
740
+ ${ownerIdBytes},
741
+ ${change.table},
742
+ ${idBytes},
743
+ ${column},
744
+ ${value},
745
+ ${timestampBytes}
746
+ )
747
+ on conflict do nothing;
748
+ `);
709
749
 
710
- const timestamp = timestampToTimestampBytes(message.timestamp);
750
+ if (!insertHistory.ok) return insertHistory;
751
+ }
711
752
 
712
753
  let strategy;
713
754
  [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
714
- timestamp,
755
+ timestampBytes,
715
756
  firstTimestamp,
716
757
  lastTimestamp,
717
758
  );
718
759
 
719
- const result2 = applyMessageToTimestampAndHistoryTables(deps)(
760
+ const insertTimestamp = deps.storage.insertTimestamp(
720
761
  ownerIdBytes,
721
- message,
762
+ timestampBytes,
722
763
  strategy,
723
- date,
724
764
  );
725
- if (!result2.ok) return result2;
765
+ if (!insertTimestamp.ok) return insertTimestamp;
726
766
  }
727
767
 
728
768
  /**
@@ -741,98 +781,6 @@ const applyMessages =
741
781
  return ok();
742
782
  };
743
783
 
744
- const applyMessageToAppTable =
745
- (deps: SqliteDep) =>
746
- (
747
- ownerIdBytes: OwnerIdBytes,
748
- message: CrdtMessage,
749
- date: DateIso,
750
- ): Result<void, SqliteError> => {
751
- const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
752
- const columns = dbChangeToColumns(message.change, date);
753
-
754
- for (const [column, value] of columns) {
755
- const result = deps.sqlite.exec(sql.prepared`
756
- with
757
- existingTimestamp as (
758
- select 1
759
- from evolu_history
760
- where
761
- "ownerId" = ${ownerIdBytes}
762
- and "table" = ${message.change.table}
763
- and "id" = ${idToIdBytes(message.change.id)}
764
- and "column" = ${column}
765
- and "timestamp" >= ${timestampToTimestampBytes(message.timestamp)}
766
- limit 1
767
- )
768
- insert into ${sql.identifier(message.change.table)}
769
- ("ownerId", "id", ${sql.identifier(column)}, updatedAt)
770
- select ${ownerId}, ${message.change.id}, ${value}, ${date}
771
- where not exists (select 1 from existingTimestamp)
772
- on conflict ("ownerId", "id") do update
773
- set
774
- ${sql.identifier(column)} = ${value},
775
- updatedAt = ${date}
776
- where not exists (select 1 from existingTimestamp);
777
- `);
778
-
779
- if (!result.ok) return result;
780
- }
781
-
782
- return ok();
783
- };
784
-
785
- export const applyMessageToTimestampAndHistoryTables =
786
- (deps: ClientStorageDep & SqliteDep) =>
787
- (
788
- ownerId: OwnerIdBytes,
789
- message: CrdtMessage,
790
- strategy: StorageInsertTimestampStrategy,
791
- date: DateIso,
792
- ): Result<void, SqliteError> => {
793
- const timestamp = timestampToTimestampBytes(message.timestamp);
794
- const id = idToIdBytes(message.change.id);
795
-
796
- const result = deps.storage.insertTimestamp(ownerId, timestamp, strategy);
797
- if (!result.ok) return result;
798
-
799
- const columns = dbChangeToColumns(message.change, date);
800
-
801
- for (const [column, value] of columns) {
802
- const result = deps.sqlite.exec(sql.prepared`
803
- insert into evolu_history
804
- ("ownerId", "table", "id", "column", "value", "timestamp")
805
- values
806
- (
807
- ${ownerId},
808
- ${message.change.table},
809
- ${id},
810
- ${column},
811
- ${value},
812
- ${timestamp}
813
- )
814
- on conflict do nothing;
815
- `);
816
- if (!result.ok) return result;
817
- }
818
-
819
- return ok();
820
- };
821
-
822
- const dbChangeToColumns = (
823
- change: DbChange,
824
- date: DateIso,
825
- ): Array<[string, SqliteValue | DateIso]> => {
826
- const entries = [...objectToEntries(change.values)];
827
- if (change.isInsert) {
828
- entries.push(["createdAt", date]);
829
- }
830
- if (change.isDelete !== null) {
831
- entries.push(["isDeleted", booleanToSqliteBoolean(change.isDelete)]);
832
- }
833
- return entries;
834
- };
835
-
836
784
  /**
837
785
  * TODO: Rework for the new owners API.
838
786
  *
@@ -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;