@evolu/common 8.12.0 → 8.14.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 (59) hide show
  1. package/dist/src/Callbacks.d.ts +12 -1
  2. package/dist/src/Callbacks.d.ts.map +1 -1
  3. package/dist/src/Callbacks.js +3 -0
  4. package/dist/src/Error.d.ts +10 -4
  5. package/dist/src/Error.d.ts.map +1 -1
  6. package/dist/src/Error.js +10 -4
  7. package/dist/src/Object.d.ts +65 -0
  8. package/dist/src/Object.d.ts.map +1 -1
  9. package/dist/src/Object.js +142 -0
  10. package/dist/src/Resource.d.ts +0 -5
  11. package/dist/src/Resource.d.ts.map +1 -1
  12. package/dist/src/Resource.js +6 -13
  13. package/dist/src/Sqlite.d.ts.map +1 -1
  14. package/dist/src/Sqlite.js +7 -0
  15. package/dist/src/Task.d.ts +10 -8
  16. package/dist/src/Task.d.ts.map +1 -1
  17. package/dist/src/Task.js +41 -5
  18. package/dist/src/Worker.d.ts +3 -3
  19. package/dist/src/local-first/Db.d.ts +8 -3
  20. package/dist/src/local-first/Db.d.ts.map +1 -1
  21. package/dist/src/local-first/Db.js +56 -19
  22. package/dist/src/local-first/Evolu.d.ts +147 -39
  23. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  24. package/dist/src/local-first/Evolu.js +53 -9
  25. package/dist/src/local-first/Owner.d.ts +9 -0
  26. package/dist/src/local-first/Owner.d.ts.map +1 -1
  27. package/dist/src/local-first/Owner.js +9 -0
  28. package/dist/src/local-first/Protocol.d.ts +6 -4
  29. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  30. package/dist/src/local-first/Protocol.js +1 -6
  31. package/dist/src/local-first/Schema.d.ts +7 -1
  32. package/dist/src/local-first/Schema.d.ts.map +1 -1
  33. package/dist/src/local-first/Shared.d.ts +550 -153
  34. package/dist/src/local-first/Shared.d.ts.map +1 -1
  35. package/dist/src/local-first/Shared.js +724 -273
  36. package/dist/src/local-first/Storage.d.ts +16 -12
  37. package/dist/src/local-first/Storage.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/Callbacks.test.ts +20 -0
  40. package/src/Callbacks.ts +17 -1
  41. package/src/Error.ts +10 -4
  42. package/src/Object.test.ts +296 -0
  43. package/src/Object.ts +163 -0
  44. package/src/Resource.test.ts +20 -16
  45. package/src/Resource.ts +6 -20
  46. package/src/Sqlite.ts +7 -0
  47. package/src/Task.test.ts +233 -62
  48. package/src/Task.ts +47 -13
  49. package/src/Worker.ts +3 -3
  50. package/src/local-first/Db.ts +88 -18
  51. package/src/local-first/Evolu.test.ts +381 -11
  52. package/src/local-first/Evolu.ts +219 -51
  53. package/src/local-first/Owner.ts +9 -0
  54. package/src/local-first/Protocol.test.ts +42 -60
  55. package/src/local-first/Protocol.ts +7 -9
  56. package/src/local-first/Schema.ts +8 -2
  57. package/src/local-first/Shared.test.ts +2714 -644
  58. package/src/local-first/Shared.ts +1245 -401
  59. package/src/local-first/Storage.ts +18 -19
@@ -61,6 +61,7 @@ import {
61
61
  type SharedWorkerInput,
62
62
  type SharedWorkerOutput,
63
63
  type SyncState,
64
+ syncStateToOwnerSyncStatus,
64
65
  } from "./Shared.ts";
65
66
  import {
66
67
  acquireLeaderLock,
@@ -72,6 +73,7 @@ import { err, ok } from "../Result.ts";
72
73
  import { SqliteBoolean } from "../Sqlite.ts";
73
74
  import { explicitAbortReason, testCreateDeps, testCreateRun } from "../Task.ts";
74
75
  import { testCreateId } from "../Test.ts";
76
+ import { Millis } from "../Time.ts";
75
77
  import {
76
78
  assertType,
77
79
  createIdFromString,
@@ -370,16 +372,6 @@ describe("Evolu", () => {
370
372
  assertSame(setup.deps.evoluError.get(), null);
371
373
  });
372
374
 
373
- it("says nothing to a worker that reports it keeps databases in memory", async () => {
374
- using setup = await setupCreateEvoluDeps();
375
-
376
- setup.sharedWorkerPort.postMessage({ type: "StorageUnavailable" });
377
- await testWaitForWorkerMessage();
378
-
379
- assertEqual(setup.messages, []);
380
- assertSame(setup.deps.evoluError.get(), null);
381
- });
382
-
383
375
  it("announces itself as tab leader with its console level once its worker connects", async () => {
384
376
  const testConsole = testCreateConsole();
385
377
  using setup = await setupCreateEvoluDeps(testConsole);
@@ -428,6 +420,7 @@ describe("Evolu", () => {
428
420
  tables: { todo: new Set(["title"]) },
429
421
  indexes: [],
430
422
  };
423
+ const sharedWorkerId = testCreateId()<"SharedWorker">();
431
424
  const sharedWorkerPort: {
432
425
  value: MessagePort<SharedWorkerOutput, SharedWorkerInput> | null;
433
426
  } = { value: null };
@@ -468,6 +461,7 @@ describe("Evolu", () => {
468
461
  sqliteSchema,
469
462
  encryptionKey: testAppOwner.encryptionKey,
470
463
  memoryOnly: true,
464
+ sharedWorkerId,
471
465
  port: dbWorkerChannel.port1.native,
472
466
  },
473
467
  [dbWorkerChannel.port1.native],
@@ -483,6 +477,7 @@ describe("Evolu", () => {
483
477
  sqliteSchema,
484
478
  encryptionKey: testAppOwner.encryptionKey,
485
479
  memoryOnly: true,
480
+ sharedWorkerId,
486
481
  port: dbWorkerChannel.port1.native,
487
482
  });
488
483
 
@@ -592,7 +587,7 @@ describe("Evolu", () => {
592
587
  );
593
588
  const createState = (label: string): SyncState => ({
594
589
  transports: [],
595
- tenants: [{ name: Name.orThrow(label), refused: false, owners: [] }],
590
+ tenants: [{ type: "Active", name: Name.orThrow(label), owners: [] }],
596
591
  });
597
592
 
598
593
  // Nothing reaches the tab before its worker names the channel.
@@ -616,6 +611,214 @@ describe("Evolu", () => {
616
611
  assertEqual(deps.syncState.get(), createState("changed"));
617
612
  });
618
613
 
614
+ it("keeps the unchanged parts of each sync state snapshot", async () => {
615
+ using setup = await setupCreateEvoluDeps();
616
+ const { deps, connect } = setup;
617
+ const workerId = testCreateId()<"SharedWorker">();
618
+ using channel = testCreateBroadcastChannel<SyncState>(
619
+ `evolu:sync-state:${workerId}`,
620
+ );
621
+ await connect(workerId);
622
+ let notificationCount = 0;
623
+ deps.syncState.subscribe(() => {
624
+ notificationCount++;
625
+ });
626
+ const transportId = testCreateId()<"SyncTransport">();
627
+ // Every snapshot is a new structured clone. The worker wraps a caught
628
+ // Error inside an error in an UnknownError, whose cause can hold the
629
+ // bytes of a received change.
630
+ const createState = (
631
+ lastSentAt: number,
632
+ message = "wrong key",
633
+ ): SyncState => ({
634
+ transports: [
635
+ {
636
+ type: "WebSocket",
637
+ id: transportId,
638
+ label: "wss://relay.example",
639
+ connection: {
640
+ type: "Open",
641
+ openedAt: Millis.orThrow(1000),
642
+ error: null,
643
+ },
644
+ },
645
+ ],
646
+ tenants: [
647
+ {
648
+ type: "Active",
649
+ name: testName,
650
+ owners: [
651
+ {
652
+ type: "Writable",
653
+ ownerId: testAppOwner.id,
654
+ routes: [
655
+ {
656
+ type: "Settled",
657
+ transportId,
658
+ skippedError: {
659
+ type: "DecryptWithXChaCha20Poly1305Error",
660
+ error: {
661
+ type: "UnknownError",
662
+ error: {
663
+ message,
664
+ cause: { values: { photo: Uint8Array.of(1, 2, 3) } },
665
+ },
666
+ },
667
+ at: Millis.orThrow(1500),
668
+ },
669
+ completeAt: null,
670
+ lastSentAt: Millis.orThrow(lastSentAt),
671
+ lastReceivedAt: Millis.orThrow(1500),
672
+ },
673
+ ],
674
+ },
675
+ ],
676
+ },
677
+ ],
678
+ });
679
+ const routeOf = (state: SyncState | null) => {
680
+ const tenant = state?.tenants[0];
681
+ assert(tenant?.type === "Active", "The tenant should be active.");
682
+ const owner = tenant.owners[0];
683
+ assert(owner?.type === "Writable", "The owner should be writable.");
684
+ const route = owner.routes[0];
685
+ assert(route?.type === "Settled", "The route should be settled.");
686
+ return route;
687
+ };
688
+ const statusOf = (state: SyncState | null) =>
689
+ syncStateToOwnerSyncStatus(state, testName, testAppOwner.id);
690
+
691
+ channel.postMessage(createState(1000));
692
+ await testWaitForWorkerMessage();
693
+ const first = deps.syncState.get();
694
+ assertNotNull(first);
695
+ assertSame(notificationCount, 1);
696
+
697
+ // An equal snapshot leaves the store unchanged.
698
+ channel.postMessage(createState(1000));
699
+ await testWaitForWorkerMessage();
700
+ assertSame(deps.syncState.get(), first);
701
+ assertSame(notificationCount, 1);
702
+
703
+ // A changed route gets a new object, and every unchanged part keeps the
704
+ // previous one, so the owner's status is the same object.
705
+ channel.postMessage(createState(2000));
706
+ await testWaitForWorkerMessage();
707
+ const second = deps.syncState.get();
708
+ assertNotNull(second);
709
+ assertNotSame(second, first);
710
+ assertSame(notificationCount, 2);
711
+ assertSame(second.transports, first.transports);
712
+ assertNotSame(routeOf(second), routeOf(first));
713
+ assertSame(routeOf(second).skippedError, routeOf(first).skippedError);
714
+ assertSame(statusOf(second), statusOf(first));
715
+
716
+ // An error with another message is a new error and a new status.
717
+ channel.postMessage(createState(2000, "tampered"));
718
+ await testWaitForWorkerMessage();
719
+ const third = deps.syncState.get();
720
+ assertNotSame(routeOf(third).skippedError, routeOf(second).skippedError);
721
+ assertNotSame(statusOf(third), statusOf(second));
722
+ });
723
+
724
+ it("keeps each part of a sync state snapshot when one before it goes away", async () => {
725
+ using setup = await setupCreateEvoluDeps();
726
+ const { deps, connect } = setup;
727
+ const createId = testCreateId();
728
+ const workerId = createId<"SharedWorker">();
729
+ using channel = testCreateBroadcastChannel<SyncState>(
730
+ `evolu:sync-state:${workerId}`,
731
+ );
732
+ await connect(workerId);
733
+ const removedTransportId = createId<"SyncTransport">();
734
+ const transportId = createId<"SyncTransport">();
735
+ const at = Millis.orThrow(1000);
736
+ // The full snapshot lists a transport, tenant, owner, and route before
737
+ // each part the other snapshot keeps. Only the kept route has an error.
738
+ const createState = (isFull: boolean): SyncState => ({
739
+ transports: [removedTransportId, transportId]
740
+ .slice(isFull ? 0 : 1)
741
+ .map((id) => ({
742
+ type: "WebSocket",
743
+ id,
744
+ label: `wss://${id}.example`,
745
+ connection: { type: "Open", openedAt: at, error: null },
746
+ })),
747
+ tenants: [
748
+ ...(isFull
749
+ ? [
750
+ {
751
+ type: "Active" as const,
752
+ name: Name.orThrow("removed"),
753
+ owners: [],
754
+ },
755
+ ]
756
+ : []),
757
+ {
758
+ type: "Active",
759
+ name: testName,
760
+ owners: [
761
+ ...(isFull
762
+ ? [
763
+ {
764
+ type: "Writable" as const,
765
+ ownerId: createId<"OwnerId">(),
766
+ routes: [],
767
+ },
768
+ ]
769
+ : []),
770
+ {
771
+ type: "Writable",
772
+ ownerId: testAppOwner.id,
773
+ routes: [
774
+ ...(isFull
775
+ ? [
776
+ {
777
+ type: "Complete" as const,
778
+ transportId: removedTransportId,
779
+ completeAt: at,
780
+ lastSentAt: at,
781
+ lastReceivedAt: at,
782
+ },
783
+ ]
784
+ : []),
785
+ {
786
+ type: "Pending",
787
+ transportId,
788
+ failure: {
789
+ type: "ProtocolQuotaError",
790
+ ownerId: testAppOwner.id,
791
+ at,
792
+ },
793
+ skippedError: null,
794
+ completeAt: null,
795
+ lastSentAt: at,
796
+ lastReceivedAt: at,
797
+ },
798
+ ],
799
+ },
800
+ ],
801
+ },
802
+ ],
803
+ });
804
+ const statusOf = (state: SyncState | null) =>
805
+ syncStateToOwnerSyncStatus(state, testName, testAppOwner.id);
806
+
807
+ channel.postMessage(createState(true));
808
+ await testWaitForWorkerMessage();
809
+ const full = deps.syncState.get();
810
+ assertNotNull(full);
811
+ assertSame(statusOf(full).type, "Error");
812
+
813
+ channel.postMessage(createState(false));
814
+ await testWaitForWorkerMessage();
815
+ const kept = deps.syncState.get();
816
+ assertNotNull(kept);
817
+ assertEqual(kept, createState(false));
818
+ assertSame(kept.transports[0], full.transports[1]);
819
+ assertSame(statusOf(kept), statusOf(full));
820
+ });
821
+
619
822
  it("listens on its worker's channel before asking for a snapshot", async () => {
620
823
  const name = "evolu:sync-state:worker";
621
824
  using worker = testCreateSharedWorker<
@@ -727,6 +930,29 @@ describe("Evolu", () => {
727
930
  assertEqual(deps.evoluError.get(), error);
728
931
  });
729
932
 
933
+ it("only logs a sync error that an older build broadcasts", async () => {
934
+ const testConsole = testCreateConsole();
935
+ using setup = await setupCreateEvoluDeps(testConsole);
936
+ const { deps, consoleEntryOrErrorBroadcastChannel } = setup;
937
+ const error = {
938
+ type: "ProtocolQuotaError",
939
+ ownerId: testAppOwner.id,
940
+ } as const;
941
+
942
+ consoleEntryOrErrorBroadcastChannel.postMessage({
943
+ type: "Error",
944
+ // @ts-expect-error A ConsoleEntryOrError Error carries only an UnknownError, but a still-running worker of an older build posts a ProtocolQuotaError.
945
+ error,
946
+ });
947
+
948
+ await testWaitForWorkerMessage();
949
+
950
+ assertSame(deps.evoluError.get(), null);
951
+ assertEqual(testConsole.getEntriesSnapshot(), [
952
+ { method: "error", path: [], args: [error] },
953
+ ]);
954
+ });
955
+
730
956
  for (const source of ["SharedWorker", "Error", "ConsoleEntry"] as const) {
731
957
  it(`preserves the first startup refusal while logging a later ${source} message`, async () => {
732
958
  const testConsole = testCreateConsole();
@@ -1266,6 +1492,106 @@ describe("Evolu", () => {
1266
1492
  });
1267
1493
  });
1268
1494
 
1495
+ describe("device persistence", () => {
1496
+ const setupPersistenceRequests = async () => {
1497
+ let requestCount = 0;
1498
+ const setup = await setupRunWithEvoluDeps({
1499
+ requestPersistentStorage: () => {
1500
+ requestCount += 1;
1501
+ },
1502
+ });
1503
+ return Object.assign(setup, { getRequestCount: () => requestCount });
1504
+ };
1505
+
1506
+ it("resolves devicePersistence to what its tenant sends", async () => {
1507
+ await using setup = await setupRunWithEvoluDeps();
1508
+ const evolu = await setup.run.ok(testCreateEvolu);
1509
+
1510
+ setup.postEvoluOutput({
1511
+ type: "OnDevicePersistence",
1512
+ devicePersistence: "NotPersisted",
1513
+ });
1514
+
1515
+ assertSame(await evolu.devicePersistence, "NotPersisted");
1516
+ });
1517
+
1518
+ it("resolves devicePersistence to Unknown when disposed before its tenant sends it", async () => {
1519
+ await using setup = await setupRunWithEvoluDeps();
1520
+ const evolu = await setup.run.ok(testCreateEvolu);
1521
+
1522
+ await evolu[Symbol.asyncDispose]();
1523
+
1524
+ assertSame(await evolu.devicePersistence, "Unknown");
1525
+ });
1526
+
1527
+ it("requests persistent storage once, after the first mutation, when devicePersistence is Unknown", async () => {
1528
+ await using setup = await setupPersistenceRequests();
1529
+ const evolu = await setup.run.ok(testCreateEvolu);
1530
+ setup.postEvoluOutput({
1531
+ type: "OnDevicePersistence",
1532
+ devicePersistence: "Unknown",
1533
+ });
1534
+ await evolu.devicePersistence;
1535
+ await testWaitForWorkerMessage();
1536
+ assertSame(setup.getRequestCount(), 0);
1537
+
1538
+ evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("A") });
1539
+ evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("B") });
1540
+ await testWaitForWorkerMessage();
1541
+ evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("C") });
1542
+ await testWaitForWorkerMessage();
1543
+
1544
+ assertSame(setup.getRequestCount(), 1);
1545
+ });
1546
+
1547
+ it("requests persistent storage once devicePersistence resolves after the first mutation", async () => {
1548
+ await using setup = await setupPersistenceRequests();
1549
+ const evolu = await setup.run.ok(testCreateEvolu);
1550
+
1551
+ evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("A") });
1552
+ await testWaitForWorkerMessage();
1553
+ assertSame(setup.getRequestCount(), 0);
1554
+
1555
+ setup.postEvoluOutput({
1556
+ type: "OnDevicePersistence",
1557
+ devicePersistence: "Unknown",
1558
+ });
1559
+ await evolu.devicePersistence;
1560
+ await testWaitForWorkerMessage();
1561
+
1562
+ assertSame(setup.getRequestCount(), 1);
1563
+ });
1564
+
1565
+ for (const devicePersistence of ["Persisted", "NotPersisted"] as const) {
1566
+ it(`does not request persistent storage when devicePersistence is ${devicePersistence}`, async () => {
1567
+ await using setup = await setupPersistenceRequests();
1568
+ const evolu = await setup.run.ok(testCreateEvolu);
1569
+ setup.postEvoluOutput({
1570
+ type: "OnDevicePersistence",
1571
+ devicePersistence,
1572
+ });
1573
+
1574
+ evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("A") });
1575
+ await evolu.devicePersistence;
1576
+ await testWaitForWorkerMessage();
1577
+
1578
+ assertSame(setup.getRequestCount(), 0);
1579
+ });
1580
+ }
1581
+
1582
+ it("does not request persistent storage after disposal", async () => {
1583
+ await using setup = await setupPersistenceRequests();
1584
+ const evolu = await setup.run.ok(testCreateEvolu);
1585
+
1586
+ evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("A") });
1587
+ await evolu[Symbol.asyncDispose]();
1588
+ await evolu.devicePersistence;
1589
+ await testWaitForWorkerMessage();
1590
+
1591
+ assertSame(setup.getRequestCount(), 0);
1592
+ });
1593
+ });
1594
+
1269
1595
  describe("dispose evolu", () => {
1270
1596
  it("logs disposeEvolu once", async () => {
1271
1597
  const testConsole = testCreateConsole();
@@ -1634,6 +1960,50 @@ describe("Evolu", () => {
1634
1960
  assertEqual(called, 0);
1635
1961
  });
1636
1962
 
1963
+ it("releases the onComplete callbacks of a failed mutation without running them", async () => {
1964
+ await using setup = await setupRunWithEvoluDeps();
1965
+ const { run, evoluInputs, postEvoluOutput } = setup;
1966
+ const evolu = await run.ok(testCreateEvolu);
1967
+
1968
+ let called = 0;
1969
+ evolu.insert(
1970
+ "todo",
1971
+ { title: NonEmptyTrimmedString100.orThrow("First failed") },
1972
+ {
1973
+ onComplete: () => {
1974
+ called += 1;
1975
+ },
1976
+ },
1977
+ );
1978
+ evolu.insert(
1979
+ "todo",
1980
+ { title: NonEmptyTrimmedString100.orThrow("Second failed") },
1981
+ {
1982
+ onComplete: () => {
1983
+ called += 10;
1984
+ },
1985
+ },
1986
+ );
1987
+
1988
+ await testWaitForWorkerMessage();
1989
+
1990
+ const mutate = evoluInputs[0] as ExtractTyped<EvoluInput, "Mutate">;
1991
+ const { onCompleteIds } = mutate;
1992
+ assertLength(onCompleteIds, 2);
1993
+
1994
+ postEvoluOutput({ type: "OnMutateFailed", onCompleteIds });
1995
+ // Patches naming released callbacks find nothing to run.
1996
+ postEvoluOutput({
1997
+ type: "OnPatchesByQuery",
1998
+ patchesByQuery: new Map(),
1999
+ onCompleteIds,
2000
+ });
2001
+
2002
+ await testWaitForWorkerMessage();
2003
+
2004
+ assertEqual(called, 0);
2005
+ });
2006
+
1637
2007
  it("executes mutate onComplete callback when query patches are received", async () => {
1638
2008
  await using setup = await setupRunWithEvoluDeps();
1639
2009
  const { run, evoluInputs, postEvoluOutput } = setup;