@evolu/common 8.12.0 → 8.13.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.
- package/dist/src/Callbacks.d.ts +12 -1
- package/dist/src/Callbacks.d.ts.map +1 -1
- package/dist/src/Callbacks.js +3 -0
- package/dist/src/Error.d.ts +10 -4
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +10 -4
- package/dist/src/Object.d.ts +65 -0
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +142 -0
- package/dist/src/Resource.d.ts +0 -5
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +6 -13
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +7 -0
- package/dist/src/Task.d.ts +10 -8
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +41 -5
- package/dist/src/Worker.d.ts +3 -3
- package/dist/src/local-first/Db.d.ts +8 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +56 -19
- package/dist/src/local-first/Evolu.d.ts +81 -37
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +31 -6
- package/dist/src/local-first/Owner.d.ts +9 -0
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +9 -0
- package/dist/src/local-first/Protocol.d.ts +6 -4
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +1 -6
- package/dist/src/local-first/Schema.d.ts +7 -1
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Shared.d.ts +523 -133
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +696 -256
- package/dist/src/local-first/Storage.d.ts +16 -12
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Callbacks.test.ts +20 -0
- package/src/Callbacks.ts +17 -1
- package/src/Error.ts +10 -4
- package/src/Object.test.ts +296 -0
- package/src/Object.ts +163 -0
- package/src/Resource.test.ts +20 -16
- package/src/Resource.ts +6 -20
- package/src/Sqlite.ts +7 -0
- package/src/Task.test.ts +233 -62
- package/src/Task.ts +47 -13
- package/src/Worker.ts +3 -3
- package/src/local-first/Db.ts +88 -18
- package/src/local-first/Evolu.test.ts +281 -1
- package/src/local-first/Evolu.ts +124 -46
- package/src/local-first/Owner.ts +9 -0
- package/src/local-first/Protocol.test.ts +42 -60
- package/src/local-first/Protocol.ts +7 -9
- package/src/local-first/Schema.ts +8 -2
- package/src/local-first/Shared.test.ts +2633 -646
- package/src/local-first/Shared.ts +1192 -364
- 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,
|
|
@@ -428,6 +430,7 @@ describe("Evolu", () => {
|
|
|
428
430
|
tables: { todo: new Set(["title"]) },
|
|
429
431
|
indexes: [],
|
|
430
432
|
};
|
|
433
|
+
const sharedWorkerId = testCreateId()<"SharedWorker">();
|
|
431
434
|
const sharedWorkerPort: {
|
|
432
435
|
value: MessagePort<SharedWorkerOutput, SharedWorkerInput> | null;
|
|
433
436
|
} = { value: null };
|
|
@@ -468,6 +471,7 @@ describe("Evolu", () => {
|
|
|
468
471
|
sqliteSchema,
|
|
469
472
|
encryptionKey: testAppOwner.encryptionKey,
|
|
470
473
|
memoryOnly: true,
|
|
474
|
+
sharedWorkerId,
|
|
471
475
|
port: dbWorkerChannel.port1.native,
|
|
472
476
|
},
|
|
473
477
|
[dbWorkerChannel.port1.native],
|
|
@@ -483,6 +487,7 @@ describe("Evolu", () => {
|
|
|
483
487
|
sqliteSchema,
|
|
484
488
|
encryptionKey: testAppOwner.encryptionKey,
|
|
485
489
|
memoryOnly: true,
|
|
490
|
+
sharedWorkerId,
|
|
486
491
|
port: dbWorkerChannel.port1.native,
|
|
487
492
|
});
|
|
488
493
|
|
|
@@ -592,7 +597,7 @@ describe("Evolu", () => {
|
|
|
592
597
|
);
|
|
593
598
|
const createState = (label: string): SyncState => ({
|
|
594
599
|
transports: [],
|
|
595
|
-
tenants: [{ name: Name.orThrow(label),
|
|
600
|
+
tenants: [{ type: "Active", name: Name.orThrow(label), owners: [] }],
|
|
596
601
|
});
|
|
597
602
|
|
|
598
603
|
// Nothing reaches the tab before its worker names the channel.
|
|
@@ -616,6 +621,214 @@ describe("Evolu", () => {
|
|
|
616
621
|
assertEqual(deps.syncState.get(), createState("changed"));
|
|
617
622
|
});
|
|
618
623
|
|
|
624
|
+
it("keeps the unchanged parts of each sync state snapshot", async () => {
|
|
625
|
+
using setup = await setupCreateEvoluDeps();
|
|
626
|
+
const { deps, connect } = setup;
|
|
627
|
+
const workerId = testCreateId()<"SharedWorker">();
|
|
628
|
+
using channel = testCreateBroadcastChannel<SyncState>(
|
|
629
|
+
`evolu:sync-state:${workerId}`,
|
|
630
|
+
);
|
|
631
|
+
await connect(workerId);
|
|
632
|
+
let notificationCount = 0;
|
|
633
|
+
deps.syncState.subscribe(() => {
|
|
634
|
+
notificationCount++;
|
|
635
|
+
});
|
|
636
|
+
const transportId = testCreateId()<"SyncTransport">();
|
|
637
|
+
// Every snapshot is a new structured clone. The worker wraps a caught
|
|
638
|
+
// Error inside an error in an UnknownError, whose cause can hold the
|
|
639
|
+
// bytes of a received change.
|
|
640
|
+
const createState = (
|
|
641
|
+
lastSentAt: number,
|
|
642
|
+
message = "wrong key",
|
|
643
|
+
): SyncState => ({
|
|
644
|
+
transports: [
|
|
645
|
+
{
|
|
646
|
+
type: "WebSocket",
|
|
647
|
+
id: transportId,
|
|
648
|
+
label: "wss://relay.example",
|
|
649
|
+
connection: {
|
|
650
|
+
type: "Open",
|
|
651
|
+
openedAt: Millis.orThrow(1000),
|
|
652
|
+
error: null,
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
tenants: [
|
|
657
|
+
{
|
|
658
|
+
type: "Active",
|
|
659
|
+
name: testName,
|
|
660
|
+
owners: [
|
|
661
|
+
{
|
|
662
|
+
type: "Writable",
|
|
663
|
+
ownerId: testAppOwner.id,
|
|
664
|
+
routes: [
|
|
665
|
+
{
|
|
666
|
+
type: "Settled",
|
|
667
|
+
transportId,
|
|
668
|
+
skippedError: {
|
|
669
|
+
type: "DecryptWithXChaCha20Poly1305Error",
|
|
670
|
+
error: {
|
|
671
|
+
type: "UnknownError",
|
|
672
|
+
error: {
|
|
673
|
+
message,
|
|
674
|
+
cause: { values: { photo: Uint8Array.of(1, 2, 3) } },
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
at: Millis.orThrow(1500),
|
|
678
|
+
},
|
|
679
|
+
completeAt: null,
|
|
680
|
+
lastSentAt: Millis.orThrow(lastSentAt),
|
|
681
|
+
lastReceivedAt: Millis.orThrow(1500),
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
},
|
|
687
|
+
],
|
|
688
|
+
});
|
|
689
|
+
const routeOf = (state: SyncState | null) => {
|
|
690
|
+
const tenant = state?.tenants[0];
|
|
691
|
+
assert(tenant?.type === "Active", "The tenant should be active.");
|
|
692
|
+
const owner = tenant.owners[0];
|
|
693
|
+
assert(owner?.type === "Writable", "The owner should be writable.");
|
|
694
|
+
const route = owner.routes[0];
|
|
695
|
+
assert(route?.type === "Settled", "The route should be settled.");
|
|
696
|
+
return route;
|
|
697
|
+
};
|
|
698
|
+
const statusOf = (state: SyncState | null) =>
|
|
699
|
+
syncStateToOwnerSyncStatus(state, testName, testAppOwner.id);
|
|
700
|
+
|
|
701
|
+
channel.postMessage(createState(1000));
|
|
702
|
+
await testWaitForWorkerMessage();
|
|
703
|
+
const first = deps.syncState.get();
|
|
704
|
+
assertNotNull(first);
|
|
705
|
+
assertSame(notificationCount, 1);
|
|
706
|
+
|
|
707
|
+
// An equal snapshot leaves the store unchanged.
|
|
708
|
+
channel.postMessage(createState(1000));
|
|
709
|
+
await testWaitForWorkerMessage();
|
|
710
|
+
assertSame(deps.syncState.get(), first);
|
|
711
|
+
assertSame(notificationCount, 1);
|
|
712
|
+
|
|
713
|
+
// A changed route gets a new object, and every unchanged part keeps the
|
|
714
|
+
// previous one, so the owner's status is the same object.
|
|
715
|
+
channel.postMessage(createState(2000));
|
|
716
|
+
await testWaitForWorkerMessage();
|
|
717
|
+
const second = deps.syncState.get();
|
|
718
|
+
assertNotNull(second);
|
|
719
|
+
assertNotSame(second, first);
|
|
720
|
+
assertSame(notificationCount, 2);
|
|
721
|
+
assertSame(second.transports, first.transports);
|
|
722
|
+
assertNotSame(routeOf(second), routeOf(first));
|
|
723
|
+
assertSame(routeOf(second).skippedError, routeOf(first).skippedError);
|
|
724
|
+
assertSame(statusOf(second), statusOf(first));
|
|
725
|
+
|
|
726
|
+
// An error with another message is a new error and a new status.
|
|
727
|
+
channel.postMessage(createState(2000, "tampered"));
|
|
728
|
+
await testWaitForWorkerMessage();
|
|
729
|
+
const third = deps.syncState.get();
|
|
730
|
+
assertNotSame(routeOf(third).skippedError, routeOf(second).skippedError);
|
|
731
|
+
assertNotSame(statusOf(third), statusOf(second));
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("keeps each part of a sync state snapshot when one before it goes away", async () => {
|
|
735
|
+
using setup = await setupCreateEvoluDeps();
|
|
736
|
+
const { deps, connect } = setup;
|
|
737
|
+
const createId = testCreateId();
|
|
738
|
+
const workerId = createId<"SharedWorker">();
|
|
739
|
+
using channel = testCreateBroadcastChannel<SyncState>(
|
|
740
|
+
`evolu:sync-state:${workerId}`,
|
|
741
|
+
);
|
|
742
|
+
await connect(workerId);
|
|
743
|
+
const removedTransportId = createId<"SyncTransport">();
|
|
744
|
+
const transportId = createId<"SyncTransport">();
|
|
745
|
+
const at = Millis.orThrow(1000);
|
|
746
|
+
// The full snapshot lists a transport, tenant, owner, and route before
|
|
747
|
+
// each part the other snapshot keeps. Only the kept route has an error.
|
|
748
|
+
const createState = (isFull: boolean): SyncState => ({
|
|
749
|
+
transports: [removedTransportId, transportId]
|
|
750
|
+
.slice(isFull ? 0 : 1)
|
|
751
|
+
.map((id) => ({
|
|
752
|
+
type: "WebSocket",
|
|
753
|
+
id,
|
|
754
|
+
label: `wss://${id}.example`,
|
|
755
|
+
connection: { type: "Open", openedAt: at, error: null },
|
|
756
|
+
})),
|
|
757
|
+
tenants: [
|
|
758
|
+
...(isFull
|
|
759
|
+
? [
|
|
760
|
+
{
|
|
761
|
+
type: "Active" as const,
|
|
762
|
+
name: Name.orThrow("removed"),
|
|
763
|
+
owners: [],
|
|
764
|
+
},
|
|
765
|
+
]
|
|
766
|
+
: []),
|
|
767
|
+
{
|
|
768
|
+
type: "Active",
|
|
769
|
+
name: testName,
|
|
770
|
+
owners: [
|
|
771
|
+
...(isFull
|
|
772
|
+
? [
|
|
773
|
+
{
|
|
774
|
+
type: "Writable" as const,
|
|
775
|
+
ownerId: createId<"OwnerId">(),
|
|
776
|
+
routes: [],
|
|
777
|
+
},
|
|
778
|
+
]
|
|
779
|
+
: []),
|
|
780
|
+
{
|
|
781
|
+
type: "Writable",
|
|
782
|
+
ownerId: testAppOwner.id,
|
|
783
|
+
routes: [
|
|
784
|
+
...(isFull
|
|
785
|
+
? [
|
|
786
|
+
{
|
|
787
|
+
type: "Complete" as const,
|
|
788
|
+
transportId: removedTransportId,
|
|
789
|
+
completeAt: at,
|
|
790
|
+
lastSentAt: at,
|
|
791
|
+
lastReceivedAt: at,
|
|
792
|
+
},
|
|
793
|
+
]
|
|
794
|
+
: []),
|
|
795
|
+
{
|
|
796
|
+
type: "Pending",
|
|
797
|
+
transportId,
|
|
798
|
+
failure: {
|
|
799
|
+
type: "ProtocolQuotaError",
|
|
800
|
+
ownerId: testAppOwner.id,
|
|
801
|
+
at,
|
|
802
|
+
},
|
|
803
|
+
skippedError: null,
|
|
804
|
+
completeAt: null,
|
|
805
|
+
lastSentAt: at,
|
|
806
|
+
lastReceivedAt: at,
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
},
|
|
812
|
+
],
|
|
813
|
+
});
|
|
814
|
+
const statusOf = (state: SyncState | null) =>
|
|
815
|
+
syncStateToOwnerSyncStatus(state, testName, testAppOwner.id);
|
|
816
|
+
|
|
817
|
+
channel.postMessage(createState(true));
|
|
818
|
+
await testWaitForWorkerMessage();
|
|
819
|
+
const full = deps.syncState.get();
|
|
820
|
+
assertNotNull(full);
|
|
821
|
+
assertSame(statusOf(full).type, "Error");
|
|
822
|
+
|
|
823
|
+
channel.postMessage(createState(false));
|
|
824
|
+
await testWaitForWorkerMessage();
|
|
825
|
+
const kept = deps.syncState.get();
|
|
826
|
+
assertNotNull(kept);
|
|
827
|
+
assertEqual(kept, createState(false));
|
|
828
|
+
assertSame(kept.transports[0], full.transports[1]);
|
|
829
|
+
assertSame(statusOf(kept), statusOf(full));
|
|
830
|
+
});
|
|
831
|
+
|
|
619
832
|
it("listens on its worker's channel before asking for a snapshot", async () => {
|
|
620
833
|
const name = "evolu:sync-state:worker";
|
|
621
834
|
using worker = testCreateSharedWorker<
|
|
@@ -727,6 +940,29 @@ describe("Evolu", () => {
|
|
|
727
940
|
assertEqual(deps.evoluError.get(), error);
|
|
728
941
|
});
|
|
729
942
|
|
|
943
|
+
it("only logs a sync error that an older build broadcasts", async () => {
|
|
944
|
+
const testConsole = testCreateConsole();
|
|
945
|
+
using setup = await setupCreateEvoluDeps(testConsole);
|
|
946
|
+
const { deps, consoleEntryOrErrorBroadcastChannel } = setup;
|
|
947
|
+
const error = {
|
|
948
|
+
type: "ProtocolQuotaError",
|
|
949
|
+
ownerId: testAppOwner.id,
|
|
950
|
+
} as const;
|
|
951
|
+
|
|
952
|
+
consoleEntryOrErrorBroadcastChannel.postMessage({
|
|
953
|
+
type: "Error",
|
|
954
|
+
// @ts-expect-error A ConsoleEntryOrError Error carries only an UnknownError, but a still-running worker of an older build posts a ProtocolQuotaError.
|
|
955
|
+
error,
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
await testWaitForWorkerMessage();
|
|
959
|
+
|
|
960
|
+
assertSame(deps.evoluError.get(), null);
|
|
961
|
+
assertEqual(testConsole.getEntriesSnapshot(), [
|
|
962
|
+
{ method: "error", path: [], args: [error] },
|
|
963
|
+
]);
|
|
964
|
+
});
|
|
965
|
+
|
|
730
966
|
for (const source of ["SharedWorker", "Error", "ConsoleEntry"] as const) {
|
|
731
967
|
it(`preserves the first startup refusal while logging a later ${source} message`, async () => {
|
|
732
968
|
const testConsole = testCreateConsole();
|
|
@@ -1634,6 +1870,50 @@ describe("Evolu", () => {
|
|
|
1634
1870
|
assertEqual(called, 0);
|
|
1635
1871
|
});
|
|
1636
1872
|
|
|
1873
|
+
it("releases the onComplete callbacks of a failed mutation without running them", async () => {
|
|
1874
|
+
await using setup = await setupRunWithEvoluDeps();
|
|
1875
|
+
const { run, evoluInputs, postEvoluOutput } = setup;
|
|
1876
|
+
const evolu = await run.ok(testCreateEvolu);
|
|
1877
|
+
|
|
1878
|
+
let called = 0;
|
|
1879
|
+
evolu.insert(
|
|
1880
|
+
"todo",
|
|
1881
|
+
{ title: NonEmptyTrimmedString100.orThrow("First failed") },
|
|
1882
|
+
{
|
|
1883
|
+
onComplete: () => {
|
|
1884
|
+
called += 1;
|
|
1885
|
+
},
|
|
1886
|
+
},
|
|
1887
|
+
);
|
|
1888
|
+
evolu.insert(
|
|
1889
|
+
"todo",
|
|
1890
|
+
{ title: NonEmptyTrimmedString100.orThrow("Second failed") },
|
|
1891
|
+
{
|
|
1892
|
+
onComplete: () => {
|
|
1893
|
+
called += 10;
|
|
1894
|
+
},
|
|
1895
|
+
},
|
|
1896
|
+
);
|
|
1897
|
+
|
|
1898
|
+
await testWaitForWorkerMessage();
|
|
1899
|
+
|
|
1900
|
+
const mutate = evoluInputs[0] as ExtractTyped<EvoluInput, "Mutate">;
|
|
1901
|
+
const { onCompleteIds } = mutate;
|
|
1902
|
+
assertLength(onCompleteIds, 2);
|
|
1903
|
+
|
|
1904
|
+
postEvoluOutput({ type: "OnMutateFailed", onCompleteIds });
|
|
1905
|
+
// Patches naming released callbacks find nothing to run.
|
|
1906
|
+
postEvoluOutput({
|
|
1907
|
+
type: "OnPatchesByQuery",
|
|
1908
|
+
patchesByQuery: new Map(),
|
|
1909
|
+
onCompleteIds,
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
await testWaitForWorkerMessage();
|
|
1913
|
+
|
|
1914
|
+
assertEqual(called, 0);
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1637
1917
|
it("executes mutate onComplete callback when query patches are received", async () => {
|
|
1638
1918
|
await using setup = await setupRunWithEvoluDeps();
|
|
1639
1919
|
const { run, evoluInputs, postEvoluOutput } = setup;
|
package/src/local-first/Evolu.ts
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
import { createMicrotaskBatch } from "../Microtask.ts";
|
|
32
32
|
import {
|
|
33
33
|
createMutableRecord,
|
|
34
|
+
isPlainObject,
|
|
34
35
|
objectToEntries,
|
|
36
|
+
shareStructure,
|
|
35
37
|
type ReadonlyRecord,
|
|
36
38
|
} from "../Object.ts";
|
|
37
39
|
import type { FlushSyncDep, ReloadAppDep } from "../Platform.ts";
|
|
@@ -75,7 +77,9 @@ import { createOwnerWebSocketTransport } from "./Owner.ts";
|
|
|
75
77
|
import {
|
|
76
78
|
encodeDbChange,
|
|
77
79
|
type ProtocolError,
|
|
80
|
+
type ProtocolInvalidDataError,
|
|
78
81
|
type ProtocolQuotaError,
|
|
82
|
+
type ProtocolTimestampMismatchError,
|
|
79
83
|
type ProtocolVersionError,
|
|
80
84
|
} from "./Protocol.ts";
|
|
81
85
|
import type {
|
|
@@ -102,12 +106,15 @@ import type {
|
|
|
102
106
|
EvoluInput,
|
|
103
107
|
EvoluOutput,
|
|
104
108
|
OtherBuildRunningError,
|
|
109
|
+
OwnerSyncStatus,
|
|
110
|
+
PendingSyncRoute,
|
|
105
111
|
SharedWorkerDep,
|
|
106
112
|
SyncState,
|
|
107
|
-
|
|
113
|
+
syncStateToOwnerSyncStatus,
|
|
114
|
+
syncStateToRelaySyncStates,
|
|
108
115
|
} from "./Shared.ts";
|
|
109
116
|
import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.ts";
|
|
110
|
-
import { DbChange
|
|
117
|
+
import { DbChange } from "./Storage.ts";
|
|
111
118
|
import { createTimestamp, type Timestamp } from "./Timestamp.ts";
|
|
112
119
|
|
|
113
120
|
/**
|
|
@@ -388,8 +395,10 @@ export interface Evolu<
|
|
|
388
395
|
*
|
|
389
396
|
* Pass `onComplete` when follow-up work must wait until the mutation is
|
|
390
397
|
* stored and subscribed queries reflect it. It never runs when the database
|
|
391
|
-
* is unavailable; see {@link Evolu.loadQuery}.
|
|
392
|
-
*
|
|
398
|
+
* is unavailable; see {@link Evolu.loadQuery}. It also never runs when the
|
|
399
|
+
* mutation could not be stored, which {@link EvoluErrorDep.evoluError}
|
|
400
|
+
* reports. A stored change is not always visible; see
|
|
401
|
+
* {@link MutationOptions.onComplete}.
|
|
393
402
|
*
|
|
394
403
|
* ### Example
|
|
395
404
|
*
|
|
@@ -707,6 +716,9 @@ export interface Evolu<
|
|
|
707
716
|
/**
|
|
708
717
|
* Exports the SQLite database file.
|
|
709
718
|
*
|
|
719
|
+
* The exported file is not encrypted, even when the local database is. Any
|
|
720
|
+
* SQLite tool can read all local data from it, so treat it as plaintext.
|
|
721
|
+
*
|
|
710
722
|
* Exports are sequential: concurrent calls share one pending export instead
|
|
711
723
|
* of starting parallel exports.
|
|
712
724
|
*
|
|
@@ -818,9 +830,12 @@ export interface Evolu<
|
|
|
818
830
|
*
|
|
819
831
|
* Reconciles locally stored changes, including writes previously rejected
|
|
820
832
|
* with {@link ProtocolQuotaError}, through the owner's active transports. Call
|
|
821
|
-
* this after the relay provider confirms additional quota is available.
|
|
822
|
-
*
|
|
823
|
-
*
|
|
833
|
+
* this after the relay provider confirms additional quota is available. It
|
|
834
|
+
* also checks again the changes this database skipped from the owner's
|
|
835
|
+
* relays, and sends such a relay the changes received from other relays,
|
|
836
|
+
* which otherwise wait for its next sync, such as after a reconnect. Existing
|
|
837
|
+
* connections and {@link Evolu.useOwner} registrations are retained, including
|
|
838
|
+
* registrations shared by multiple instances or tabs.
|
|
824
839
|
*
|
|
825
840
|
* The owner must have an active writable registration in this database.
|
|
826
841
|
* Unregistered or read-only owners are ignored. Requests are skipped while
|
|
@@ -828,8 +843,9 @@ export interface Evolu<
|
|
|
828
843
|
* reopen. Disposal drops locally buffered requests. Calling a disposed
|
|
829
844
|
* instance throws, like other Evolu operations.
|
|
830
845
|
*
|
|
831
|
-
* Returns immediately, without waiting for synchronization to complete.
|
|
832
|
-
*
|
|
846
|
+
* Returns immediately, without waiting for synchronization to complete. The
|
|
847
|
+
* owner's routes in sync state show a failure, such as a quota rejection, as
|
|
848
|
+
* `failure`, and a skipped change as `skippedError`.
|
|
833
849
|
*
|
|
834
850
|
* ### Example
|
|
835
851
|
*
|
|
@@ -858,9 +874,11 @@ export interface Evolu<
|
|
|
858
874
|
export type UnuseOwner = () => void;
|
|
859
875
|
|
|
860
876
|
/**
|
|
861
|
-
* Represents errors that can occur in {@link Evolu}.
|
|
877
|
+
* Represents app-level errors that can occur in {@link Evolu}.
|
|
862
878
|
*
|
|
863
|
-
* Apps show them from {@link EvoluErrorDep.evoluError}.
|
|
879
|
+
* Apps show them from {@link EvoluErrorDep.evoluError}. Problems a relay causes
|
|
880
|
+
* while syncing an owner are not EvoluErrors; see below. An unexpected failure
|
|
881
|
+
* while syncing is an {@link UnknownError}.
|
|
864
882
|
*
|
|
865
883
|
* An error that leaves the app unusable deserves a modal dialog, which moves
|
|
866
884
|
* focus into itself and restores it when closed. Any other error is a status
|
|
@@ -874,31 +892,46 @@ export type UnuseOwner = () => void;
|
|
|
874
892
|
* - {@link OtherBuildRunningError} blocks the app while it lasts: another version
|
|
875
893
|
* of the app holds the local data. Ask the user to close the app's other
|
|
876
894
|
* tabs. It clears when the wait ends.
|
|
877
|
-
* - {@link ProtocolError} does not block the app: sync with a relay failed for an
|
|
878
|
-
* owner, because the relay rejected or failed a request, or sent data that
|
|
879
|
-
* could not be decoded or verified; sync state shows the affected routes. A
|
|
880
|
-
* {@link ProtocolQuotaError} needs more relay quota, then
|
|
881
|
-
* {@link Evolu.requestSync}; a {@link ProtocolVersionError} needs an app or
|
|
882
|
-
* relay update.
|
|
883
|
-
* - {@link StorageQuotaError} does not block the app: a storage or billing quota
|
|
884
|
-
* was exceeded, so a batch of an owner's changes was not stored. The built-in
|
|
885
|
-
* client storage does not report it yet; a relay's quota arrives as
|
|
886
|
-
* {@link ProtocolQuotaError}.
|
|
887
|
-
* - {@link DecryptWithXChaCha20Poly1305Error} does not block the app: changes
|
|
888
|
-
* received for an owner could not be decrypted, so none of their batch was
|
|
889
|
-
* stored.
|
|
890
895
|
* - {@link UnknownError} does not block the app: Evolu logged an unexpected
|
|
891
|
-
* failure. Show a generic
|
|
896
|
+
* failure, such as a mutation that could not be stored. Show a generic
|
|
897
|
+
* message.
|
|
898
|
+
*
|
|
899
|
+
* A problem syncing an owner through a relay belongs to that relay and often
|
|
900
|
+
* repeats in every round, so sync state shows it on the relay's route, as its
|
|
901
|
+
* {@link PendingSyncRoute.failure} with its details, and apps show it as the
|
|
902
|
+
* owner's `Error` {@link OwnerSyncStatus}. When the relay rejected or failed a
|
|
903
|
+
* request, or sent a frame that could not be decoded, the route shows a
|
|
904
|
+
* {@link ProtocolError}. A {@link ProtocolQuotaError} needs more relay quota,
|
|
905
|
+
* then {@link Evolu.requestSync}, and a {@link ProtocolVersionError} needs an app
|
|
906
|
+
* or relay update.
|
|
907
|
+
*
|
|
908
|
+
* A received change that was not created with the owner's encryption key, was
|
|
909
|
+
* altered afterwards, or cannot be decoded by this app version is skipped,
|
|
910
|
+
* while everything else still syncs. The relay offers it again in every round,
|
|
911
|
+
* so the route shows it as its `skippedError`: the
|
|
912
|
+
* {@link DecryptWithXChaCha20Poly1305Error},
|
|
913
|
+
* {@link ProtocolTimestampMismatchError}, or {@link ProtocolInvalidDataError} of
|
|
914
|
+
* the first change skipped in a reply, with its details. The owner's status is
|
|
915
|
+
* `Error` with it too, unless a relay has a failure. Once this database stores
|
|
916
|
+
* a valid change with that timestamp, for example from another relay, the relay
|
|
917
|
+
* no longer offers its copy, and the route completes with its next sync, such
|
|
918
|
+
* as after a reconnect or {@link Evolu.requestSync}, during which no changes
|
|
919
|
+
* arrive from other relays. If you don't trust that relay, stop using it for
|
|
920
|
+
* the owner. For owners that use the default transports, such as
|
|
921
|
+
* {@link Evolu.appOwner} when {@link EvoluConfig.transports} is not empty,
|
|
922
|
+
* replace the relay there; an empty list stops syncing the app owner and makes
|
|
923
|
+
* {@link Evolu.useOwner} require explicit transports. For an owner you passed
|
|
924
|
+
* explicit transports to {@link Evolu.useOwner}, call its {@link UnuseOwner} and
|
|
925
|
+
* use it again without that relay. Do this wherever the owner is used, such as
|
|
926
|
+
* in every tab, because the owner syncs through every transport any of its uses
|
|
927
|
+
* claims. If every route of the owner shows
|
|
928
|
+
* {@link DecryptWithXChaCha20Poly1305Error} and your code creates or shares the
|
|
929
|
+
* owner, check the owner's keys.
|
|
892
930
|
*
|
|
893
931
|
* @group Core
|
|
894
932
|
*/
|
|
895
933
|
export type EvoluError =
|
|
896
|
-
|
|
|
897
|
-
| OtherBuildRunningError
|
|
898
|
-
| ProtocolError
|
|
899
|
-
| StorageQuotaError
|
|
900
|
-
| UnknownError
|
|
901
|
-
| UnsupportedDbVersionError;
|
|
934
|
+
OtherBuildRunningError | UnknownError | UnsupportedDbVersionError;
|
|
902
935
|
|
|
903
936
|
/**
|
|
904
937
|
* The largest {@link Mutation}, in bytes.
|
|
@@ -975,6 +1008,13 @@ export interface EvoluErrorDep {
|
|
|
975
1008
|
* instead; see {@link UnsupportedDbVersionError}. Show that blocking message
|
|
976
1009
|
* outside any query-loading boundary, so pending queries do not hide it.
|
|
977
1010
|
*
|
|
1011
|
+
* An {@link UnknownError} leaves Evolu in an unknown state, so the app can
|
|
1012
|
+
* only ask the user to close the tab. A failed shared worker closes itself,
|
|
1013
|
+
* so the app opened again starts a new one. Some errors reach every tab, such
|
|
1014
|
+
* as an unexpected failure of the shared worker. An app that forwards this
|
|
1015
|
+
* store to an error tracker from each tab then reports such an error once per
|
|
1016
|
+
* tab.
|
|
1017
|
+
*
|
|
978
1018
|
* ### Example
|
|
979
1019
|
*
|
|
980
1020
|
* ```ts
|
|
@@ -988,14 +1028,13 @@ export interface EvoluErrorDep {
|
|
|
988
1028
|
* // The message for the current error, or null for none.
|
|
989
1029
|
* const errorMessage = (error: EvoluError | null): string | null => {
|
|
990
1030
|
* if (!error) return null;
|
|
991
|
-
* // oxlint-disable-next-line typescript/switch-exhaustiveness-check -- The default handles every other EvoluError.
|
|
992
1031
|
* switch (error.type) {
|
|
993
1032
|
* case "UnsupportedDbVersionError":
|
|
994
1033
|
* return "Your data requires a newer version of this app. Please update it.";
|
|
995
1034
|
* case "OtherBuildRunningError":
|
|
996
1035
|
* return "This app is open in another tab with a different version. Close that tab to continue.";
|
|
997
|
-
*
|
|
998
|
-
* return "Something went wrong. Please
|
|
1036
|
+
* case "UnknownError":
|
|
1037
|
+
* return "Something went wrong. Please close this tab and open the app again.";
|
|
999
1038
|
* }
|
|
1000
1039
|
* };
|
|
1001
1040
|
*
|
|
@@ -1031,8 +1070,15 @@ export interface SyncStateDep {
|
|
|
1031
1070
|
/**
|
|
1032
1071
|
* {@link ReadonlyStore} of the latest {@link SyncState} shared by all
|
|
1033
1072
|
* {@link Evolu} instances, or null before the shared worker sends its first
|
|
1034
|
-
* snapshot.
|
|
1035
|
-
*
|
|
1073
|
+
* snapshot. It lists every database the shared worker serves, including other
|
|
1074
|
+
* tabs', so an app finds its own by {@link Evolu.name}. Apps show users an
|
|
1075
|
+
* owner's {@link OwnerSyncStatus}, from {@link syncStateToOwnerSyncStatus} or a
|
|
1076
|
+
* framework binding's `useOwnerSyncStatus`; views of each relay use
|
|
1077
|
+
* {@link syncStateToRelaySyncStates}.
|
|
1078
|
+
*
|
|
1079
|
+
* Each snapshot keeps the previous snapshot's object for every part that did
|
|
1080
|
+
* not change, and a snapshot equal to the previous one leaves the store
|
|
1081
|
+
* unchanged, so parts and derived statuses can be compared with `===`.
|
|
1036
1082
|
*
|
|
1037
1083
|
* ### Example
|
|
1038
1084
|
*
|
|
@@ -1041,6 +1087,7 @@ export interface SyncStateDep {
|
|
|
1041
1087
|
* assertEqual,
|
|
1042
1088
|
* createId,
|
|
1043
1089
|
* createStore,
|
|
1090
|
+
* Millis,
|
|
1044
1091
|
* testCreateDeps,
|
|
1045
1092
|
* } from "@evolu/common";
|
|
1046
1093
|
* import type {
|
|
@@ -1050,7 +1097,7 @@ export interface SyncStateDep {
|
|
|
1050
1097
|
*
|
|
1051
1098
|
* const openRelayLabels = (deps: SyncStateDep): ReadonlyArray<string> =>
|
|
1052
1099
|
* (deps.syncState.get()?.transports ?? [])
|
|
1053
|
-
* .filter(({
|
|
1100
|
+
* .filter(({ connection }) => connection.type === "Open")
|
|
1054
1101
|
* .map(({ label }) => label);
|
|
1055
1102
|
*
|
|
1056
1103
|
* using syncState = createStore<SyncState | null>(null);
|
|
@@ -1060,12 +1107,14 @@ export interface SyncStateDep {
|
|
|
1060
1107
|
* syncState.set({
|
|
1061
1108
|
* transports: [
|
|
1062
1109
|
* {
|
|
1110
|
+
* type: "WebSocket",
|
|
1063
1111
|
* id: createId<"SyncTransport">(deps),
|
|
1064
1112
|
* label: "wss://relay.example",
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1113
|
+
* connection: {
|
|
1114
|
+
* type: "Open",
|
|
1115
|
+
* openedAt: Millis.orThrow(1000),
|
|
1116
|
+
* error: null,
|
|
1117
|
+
* },
|
|
1069
1118
|
* },
|
|
1070
1119
|
* ],
|
|
1071
1120
|
* tenants: [],
|
|
@@ -1155,11 +1204,15 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
|
|
|
1155
1204
|
}
|
|
1156
1205
|
break;
|
|
1157
1206
|
|
|
1158
|
-
case "Error":
|
|
1159
|
-
|
|
1207
|
+
case "Error": {
|
|
1208
|
+
// Every build shares this channel, so another build's worker can post
|
|
1209
|
+
// an error type this build does not post, such as an older build's
|
|
1210
|
+
// sync error. That build's own tabs show it, so this tab only logs it.
|
|
1211
|
+
if (message.error.type === "UnknownError") setEvoluError(message.error);
|
|
1160
1212
|
// Keep typed errors visible in logs as operational failures.
|
|
1161
1213
|
console.error(message.error);
|
|
1162
1214
|
break;
|
|
1215
|
+
}
|
|
1163
1216
|
|
|
1164
1217
|
default:
|
|
1165
1218
|
exhaustiveCheck(message);
|
|
@@ -1173,8 +1226,9 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
|
|
|
1173
1226
|
break;
|
|
1174
1227
|
|
|
1175
1228
|
case "Error":
|
|
1176
|
-
// Sent to this tab only: its database refused startup,
|
|
1177
|
-
// still waits for another
|
|
1229
|
+
// Sent to this tab only: its database refused startup, a mutation it
|
|
1230
|
+
// made could not be stored, or its worker still waits for another
|
|
1231
|
+
// build.
|
|
1178
1232
|
setEvoluError(message.error);
|
|
1179
1233
|
console.error(message.error);
|
|
1180
1234
|
break;
|
|
@@ -1197,7 +1251,15 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
|
|
|
1197
1251
|
message.syncStateChannelName,
|
|
1198
1252
|
);
|
|
1199
1253
|
syncStateBroadcastChannel.onMessage = (state) => {
|
|
1200
|
-
|
|
1254
|
+
// Every snapshot arrives as a new structured clone, so its unchanged
|
|
1255
|
+
// parts get the previous snapshot's objects back, and an unchanged
|
|
1256
|
+
// snapshot leaves the store as it is.
|
|
1257
|
+
const previous = syncState.get();
|
|
1258
|
+
syncState.set(
|
|
1259
|
+
previous
|
|
1260
|
+
? shareStructure(previous, state, syncStateItemToKey)
|
|
1261
|
+
: state,
|
|
1262
|
+
);
|
|
1201
1263
|
};
|
|
1202
1264
|
// Asking only after listening misses no snapshot.
|
|
1203
1265
|
sharedWorker.port.postMessage({ type: "RequestSyncState" });
|
|
@@ -1235,6 +1297,14 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
|
|
|
1235
1297
|
);
|
|
1236
1298
|
};
|
|
1237
1299
|
|
|
1300
|
+
// Transports, tenants, owners, and routes come and go, so each is compared
|
|
1301
|
+
// with its previous self by its ID: a transport's id, a tenant's name, an
|
|
1302
|
+
// owner's ownerId, or a route's transportId. A wrong key only loses sharing.
|
|
1303
|
+
const syncStateItemToKey = (item: unknown): unknown =>
|
|
1304
|
+
isPlainObject(item)
|
|
1305
|
+
? (item.id ?? item.name ?? item.ownerId ?? item.transportId)
|
|
1306
|
+
: undefined;
|
|
1307
|
+
|
|
1238
1308
|
/**
|
|
1239
1309
|
* Creates an {@link Evolu} instance from {@link EvoluSchema} and
|
|
1240
1310
|
* {@link EvoluConfig}.
|
|
@@ -1434,6 +1504,14 @@ export const createEvolu =
|
|
|
1434
1504
|
break;
|
|
1435
1505
|
}
|
|
1436
1506
|
|
|
1507
|
+
case "OnMutateFailed": {
|
|
1508
|
+
// The tab reports the failure, and these callbacks never run.
|
|
1509
|
+
for (const onCompleteId of message.onCompleteIds) {
|
|
1510
|
+
onMutateCompleteCallbacks.unregister(onCompleteId);
|
|
1511
|
+
}
|
|
1512
|
+
break;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1437
1515
|
default:
|
|
1438
1516
|
exhaustiveCheck(message);
|
|
1439
1517
|
}
|
package/src/local-first/Owner.ts
CHANGED
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
* - {@link OwnerEncryptionKey}: the symmetric key that protects the data.
|
|
29
29
|
* - {@link OwnerWriteKey}: the rotatable token that authorizes writes.
|
|
30
30
|
*
|
|
31
|
+
* Only holders of the encryption key can create an owner's changes. Each change
|
|
32
|
+
* is encrypted and authenticated together with its timestamp, so a relay, or
|
|
33
|
+
* anyone who can write to a relay for the owner, can store bytes for the owner
|
|
34
|
+
* but cannot forge or move a change. A client stores only the changes it can
|
|
35
|
+
* decrypt, verify, and decode, and syncs only what it stored. It skips any
|
|
36
|
+
* other change, which sync state shows on the route of the relay that holds it,
|
|
37
|
+
* and the relay offers it again on each sync until the client stores a valid
|
|
38
|
+
* change with that timestamp.
|
|
39
|
+
*
|
|
31
40
|
* @module
|
|
32
41
|
*/
|
|
33
42
|
|