@evolu/common 8.11.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/Bytes.d.ts +39 -2
- package/dist/src/Bytes.d.ts.map +1 -1
- package/dist/src/Bytes.js +50 -2
- 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/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- 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 +142 -24
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +109 -8
- 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 +18 -7
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +45 -28
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +4 -2
- package/dist/src/local-first/Schema.d.ts +18 -3
- 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 +19 -15
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +4 -2
- package/package.json +1 -1
- package/src/Bytes.test.ts +27 -0
- package/src/Bytes.ts +58 -2
- 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/index.ts +6 -1
- package/src/local-first/Db.ts +88 -18
- package/src/local-first/Evolu.test.ts +589 -2
- package/src/local-first/Evolu.ts +285 -36
- package/src/local-first/Owner.ts +9 -0
- package/src/local-first/Protocol.test.ts +59 -60
- package/src/local-first/Protocol.ts +67 -47
- package/src/local-first/Relay.ts +4 -2
- package/src/local-first/Schema.ts +20 -3
- package/src/local-first/Shared.test.ts +2633 -646
- package/src/local-first/Shared.ts +1192 -364
- package/src/local-first/Storage.ts +24 -23
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
assertThrowsInstanceOf,
|
|
14
14
|
assertTrue,
|
|
15
15
|
} from "../Assert.ts";
|
|
16
|
+
import * as fc from "fast-check";
|
|
16
17
|
import { describe, it } from "node:test";
|
|
17
18
|
import type { Brand } from "../Brand.ts";
|
|
19
|
+
import { createBuffer } from "../Bytes.ts";
|
|
18
20
|
import type { ConsoleEntry, TestConsole } from "../Console.ts";
|
|
19
21
|
import { testCreateConsole } from "../Console.ts";
|
|
20
22
|
import { constVoid } from "../Function.ts";
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
createEvoluDeps,
|
|
26
28
|
type Evolu,
|
|
27
29
|
type EvoluConfig,
|
|
30
|
+
maxMutationSize,
|
|
28
31
|
testAppName,
|
|
29
32
|
type EvoluPlatformDeps,
|
|
30
33
|
} from "./Evolu.ts";
|
|
@@ -34,6 +37,17 @@ import {
|
|
|
34
37
|
createSharedOwner,
|
|
35
38
|
testAppOwner,
|
|
36
39
|
} from "./Owner.ts";
|
|
40
|
+
import {
|
|
41
|
+
createProtocolBroadcastMessagesFromCrdtMessages,
|
|
42
|
+
createProtocolMessageBuffer,
|
|
43
|
+
createProtocolMessageFromCrdtMessages,
|
|
44
|
+
createTimestampsBuffer,
|
|
45
|
+
encodeAndEncryptDbChange,
|
|
46
|
+
encodeDbChange,
|
|
47
|
+
MessageType,
|
|
48
|
+
ProtocolErrorCode,
|
|
49
|
+
ProtocolMessageRangesMaxSize,
|
|
50
|
+
} from "./Protocol.ts";
|
|
37
51
|
import { createQueryBuilder } from "./Schema.ts";
|
|
38
52
|
import {
|
|
39
53
|
consoleEntryOrErrorBroadcastChannelName,
|
|
@@ -47,6 +61,7 @@ import {
|
|
|
47
61
|
type SharedWorkerInput,
|
|
48
62
|
type SharedWorkerOutput,
|
|
49
63
|
type SyncState,
|
|
64
|
+
syncStateToOwnerSyncStatus,
|
|
50
65
|
} from "./Shared.ts";
|
|
51
66
|
import {
|
|
52
67
|
acquireLeaderLock,
|
|
@@ -56,19 +71,25 @@ import {
|
|
|
56
71
|
import { installPolyfills } from "../Polyfills.ts";
|
|
57
72
|
import { err, ok } from "../Result.ts";
|
|
58
73
|
import { SqliteBoolean } from "../Sqlite.ts";
|
|
59
|
-
import { explicitAbortReason, testCreateRun } from "../Task.ts";
|
|
74
|
+
import { explicitAbortReason, testCreateDeps, testCreateRun } from "../Task.ts";
|
|
60
75
|
import { testCreateId } from "../Test.ts";
|
|
76
|
+
import { Millis } from "../Time.ts";
|
|
61
77
|
import {
|
|
62
78
|
assertType,
|
|
63
79
|
createIdFromString,
|
|
80
|
+
FiniteNumber,
|
|
64
81
|
id,
|
|
65
82
|
NonEmptyTrimmedString100,
|
|
66
83
|
nullOr,
|
|
67
84
|
PositiveInt,
|
|
85
|
+
String as StringType,
|
|
68
86
|
testName,
|
|
69
87
|
Name,
|
|
88
|
+
Uint8Array as Uint8ArrayType,
|
|
70
89
|
} from "../Type.ts";
|
|
71
90
|
import type { ExtractTyped } from "../Type.ts";
|
|
91
|
+
import { DbChange } from "./Storage.ts";
|
|
92
|
+
import { createTimestamp, type NodeId } from "./Timestamp.ts";
|
|
72
93
|
import {
|
|
73
94
|
testCreateBroadcastChannel,
|
|
74
95
|
testCreateMessageChannel,
|
|
@@ -409,6 +430,7 @@ describe("Evolu", () => {
|
|
|
409
430
|
tables: { todo: new Set(["title"]) },
|
|
410
431
|
indexes: [],
|
|
411
432
|
};
|
|
433
|
+
const sharedWorkerId = testCreateId()<"SharedWorker">();
|
|
412
434
|
const sharedWorkerPort: {
|
|
413
435
|
value: MessagePort<SharedWorkerOutput, SharedWorkerInput> | null;
|
|
414
436
|
} = { value: null };
|
|
@@ -449,6 +471,7 @@ describe("Evolu", () => {
|
|
|
449
471
|
sqliteSchema,
|
|
450
472
|
encryptionKey: testAppOwner.encryptionKey,
|
|
451
473
|
memoryOnly: true,
|
|
474
|
+
sharedWorkerId,
|
|
452
475
|
port: dbWorkerChannel.port1.native,
|
|
453
476
|
},
|
|
454
477
|
[dbWorkerChannel.port1.native],
|
|
@@ -464,6 +487,7 @@ describe("Evolu", () => {
|
|
|
464
487
|
sqliteSchema,
|
|
465
488
|
encryptionKey: testAppOwner.encryptionKey,
|
|
466
489
|
memoryOnly: true,
|
|
490
|
+
sharedWorkerId,
|
|
467
491
|
port: dbWorkerChannel.port1.native,
|
|
468
492
|
});
|
|
469
493
|
|
|
@@ -573,7 +597,7 @@ describe("Evolu", () => {
|
|
|
573
597
|
);
|
|
574
598
|
const createState = (label: string): SyncState => ({
|
|
575
599
|
transports: [],
|
|
576
|
-
tenants: [{ name: Name.orThrow(label),
|
|
600
|
+
tenants: [{ type: "Active", name: Name.orThrow(label), owners: [] }],
|
|
577
601
|
});
|
|
578
602
|
|
|
579
603
|
// Nothing reaches the tab before its worker names the channel.
|
|
@@ -597,6 +621,214 @@ describe("Evolu", () => {
|
|
|
597
621
|
assertEqual(deps.syncState.get(), createState("changed"));
|
|
598
622
|
});
|
|
599
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
|
+
|
|
600
832
|
it("listens on its worker's channel before asking for a snapshot", async () => {
|
|
601
833
|
const name = "evolu:sync-state:worker";
|
|
602
834
|
using worker = testCreateSharedWorker<
|
|
@@ -708,6 +940,29 @@ describe("Evolu", () => {
|
|
|
708
940
|
assertEqual(deps.evoluError.get(), error);
|
|
709
941
|
});
|
|
710
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
|
+
|
|
711
966
|
for (const source of ["SharedWorker", "Error", "ConsoleEntry"] as const) {
|
|
712
967
|
it(`preserves the first startup refusal while logging a later ${source} message`, async () => {
|
|
713
968
|
const testConsole = testCreateConsole();
|
|
@@ -1615,6 +1870,50 @@ describe("Evolu", () => {
|
|
|
1615
1870
|
assertEqual(called, 0);
|
|
1616
1871
|
});
|
|
1617
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
|
+
|
|
1618
1917
|
it("executes mutate onComplete callback when query patches are received", async () => {
|
|
1619
1918
|
await using setup = await setupRunWithEvoluDeps();
|
|
1620
1919
|
const { run, evoluInputs, postEvoluOutput } = setup;
|
|
@@ -2447,6 +2746,294 @@ describe("Evolu", () => {
|
|
|
2447
2746
|
});
|
|
2448
2747
|
});
|
|
2449
2748
|
|
|
2749
|
+
describe("mutation size", () => {
|
|
2750
|
+
const NoteId = id("Note");
|
|
2751
|
+
const note = {
|
|
2752
|
+
id: NoteId,
|
|
2753
|
+
body: nullOr(StringType),
|
|
2754
|
+
data: nullOr(Uint8ArrayType),
|
|
2755
|
+
count: nullOr(FiniteNumber),
|
|
2756
|
+
};
|
|
2757
|
+
const SizeSchema = { note, _note: note };
|
|
2758
|
+
|
|
2759
|
+
const createSizeEvolu = createEvolu(SizeSchema, {
|
|
2760
|
+
appName: testAppName,
|
|
2761
|
+
appOwner: testAppOwner,
|
|
2762
|
+
transports: [],
|
|
2763
|
+
});
|
|
2764
|
+
|
|
2765
|
+
const noteId = NoteId.orThrow(createIdFromString("note"));
|
|
2766
|
+
|
|
2767
|
+
const getChanges = (inputs: ReadonlyArray<EvoluInput>) =>
|
|
2768
|
+
inputs.flatMap((input) => (input.type === "Mutate" ? input.changes : []));
|
|
2769
|
+
|
|
2770
|
+
const tooLargeMessage = (size: number) =>
|
|
2771
|
+
`The mutation of table 'note' is ${size} bytes, over maxMutationSize (640000). Bound the Types of its columns or check it with evolu.getMutationSize.`;
|
|
2772
|
+
|
|
2773
|
+
// The length of a blob that makes a note exactly maxMutationSize. Its
|
|
2774
|
+
// length varint takes three bytes, two more than an empty blob's.
|
|
2775
|
+
const getDataAtLimitLength = (evolu: Evolu<typeof SizeSchema>) =>
|
|
2776
|
+
maxMutationSize -
|
|
2777
|
+
evolu.getMutationSize("note", { data: new Uint8Array() }) -
|
|
2778
|
+
2;
|
|
2779
|
+
|
|
2780
|
+
it("getMutationSize measures the change as it is encoded", async () => {
|
|
2781
|
+
await using setup = await setupRunWithEvoluDeps();
|
|
2782
|
+
const evolu = await setup.run.ok(createSizeEvolu);
|
|
2783
|
+
|
|
2784
|
+
const values = {
|
|
2785
|
+
body: "Milk",
|
|
2786
|
+
data: new Uint8Array(10),
|
|
2787
|
+
count: FiniteNumber.orThrow(0.1),
|
|
2788
|
+
};
|
|
2789
|
+
const buffer = createBuffer();
|
|
2790
|
+
encodeDbChange(buffer, {
|
|
2791
|
+
timestamp: createTimestamp(),
|
|
2792
|
+
change: DbChange.orThrow({
|
|
2793
|
+
table: "note",
|
|
2794
|
+
id: noteId,
|
|
2795
|
+
values,
|
|
2796
|
+
isInsert: false,
|
|
2797
|
+
isDelete: true,
|
|
2798
|
+
}),
|
|
2799
|
+
});
|
|
2800
|
+
assertEqual(evolu.getMutationSize("note", values), buffer.getLength());
|
|
2801
|
+
|
|
2802
|
+
// `id` and `isDeleted` are not columns.
|
|
2803
|
+
assertEqual(
|
|
2804
|
+
evolu.getMutationSize("note", { id: noteId, isDeleted: 1 }),
|
|
2805
|
+
evolu.getMutationSize("note", {}),
|
|
2806
|
+
);
|
|
2807
|
+
|
|
2808
|
+
// @ts-expect-error Only the schema's tables can be measured.
|
|
2809
|
+
evolu.getMutationSize("unknown", {});
|
|
2810
|
+
// @ts-expect-error Values must match their column's Type.
|
|
2811
|
+
evolu.getMutationSize("note", { body: 1 });
|
|
2812
|
+
});
|
|
2813
|
+
|
|
2814
|
+
it("encodes no string in more than three bytes per code unit", async () => {
|
|
2815
|
+
await using setup = await setupRunWithEvoluDeps();
|
|
2816
|
+
const evolu = await setup.run.ok(createSizeEvolu);
|
|
2817
|
+
|
|
2818
|
+
const assertAtMostThreeBytesPerCodeUnit = (value: string) => {
|
|
2819
|
+
// A string also takes a length varint of up to three bytes, besides
|
|
2820
|
+
// the type tag that null takes too.
|
|
2821
|
+
assertTrue(
|
|
2822
|
+
evolu.getMutationSize("note", { body: value }) -
|
|
2823
|
+
evolu.getMutationSize("note", { body: null }) <=
|
|
2824
|
+
3 * value.length + 3,
|
|
2825
|
+
);
|
|
2826
|
+
};
|
|
2827
|
+
|
|
2828
|
+
for (const value of [
|
|
2829
|
+
"",
|
|
2830
|
+
"€".repeat(200_000),
|
|
2831
|
+
"😀".repeat(1_000),
|
|
2832
|
+
"\uD800".repeat(1_000),
|
|
2833
|
+
// JSON takes exactly three bytes per code unit here.
|
|
2834
|
+
"0.1",
|
|
2835
|
+
`[${"0.1,".repeat(999)}0.1]`,
|
|
2836
|
+
"[".repeat(1_001) + "]".repeat(1_001),
|
|
2837
|
+
noteId,
|
|
2838
|
+
"2024-01-01T00:00:00.000Z",
|
|
2839
|
+
]) {
|
|
2840
|
+
assertAtMostThreeBytesPerCodeUnit(value);
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
fc.assert(
|
|
2844
|
+
fc.property(
|
|
2845
|
+
fc.oneof(fc.string(), fc.string({ unit: "binary" }), fc.json()),
|
|
2846
|
+
assertAtMostThreeBytesPerCodeUnit,
|
|
2847
|
+
),
|
|
2848
|
+
{ numRuns: 2_000 },
|
|
2849
|
+
);
|
|
2850
|
+
});
|
|
2851
|
+
|
|
2852
|
+
it("rejects a mutation over maxMutationSize before batching it", async () => {
|
|
2853
|
+
await using setup = await setupRunWithEvoluDeps();
|
|
2854
|
+
const { run, evoluInputs, postEvoluOutput } = setup;
|
|
2855
|
+
const evolu = await run.ok(createSizeEvolu);
|
|
2856
|
+
|
|
2857
|
+
const dataAtLimitLength = getDataAtLimitLength(evolu);
|
|
2858
|
+
const atLimit = { data: new Uint8Array(dataAtLimitLength) };
|
|
2859
|
+
assertEqual(evolu.getMutationSize("note", atLimit), maxMutationSize);
|
|
2860
|
+
const completed: Array<string> = [];
|
|
2861
|
+
|
|
2862
|
+
const { id } = evolu.insert("note", atLimit, {
|
|
2863
|
+
onComplete: () => {
|
|
2864
|
+
completed.push("at limit");
|
|
2865
|
+
},
|
|
2866
|
+
});
|
|
2867
|
+
const error = assertThrowsInstanceOf(
|
|
2868
|
+
() =>
|
|
2869
|
+
evolu.insert(
|
|
2870
|
+
"note",
|
|
2871
|
+
{ data: new Uint8Array(dataAtLimitLength + 1) },
|
|
2872
|
+
{
|
|
2873
|
+
onComplete: () => {
|
|
2874
|
+
completed.push("over limit");
|
|
2875
|
+
},
|
|
2876
|
+
},
|
|
2877
|
+
),
|
|
2878
|
+
Error,
|
|
2879
|
+
);
|
|
2880
|
+
assertEqual(error.message, tooLargeMessage(maxMutationSize + 1));
|
|
2881
|
+
|
|
2882
|
+
await testWaitForWorkerMessage();
|
|
2883
|
+
|
|
2884
|
+
// The mutation made before the rejected one is sent as usual.
|
|
2885
|
+
assertEqual(
|
|
2886
|
+
getChanges(evoluInputs).map((change) => change.id),
|
|
2887
|
+
[id],
|
|
2888
|
+
);
|
|
2889
|
+
const mutate = evoluInputs.find((input) => input.type === "Mutate");
|
|
2890
|
+
assertNotUndefined(mutate);
|
|
2891
|
+
postEvoluOutput({
|
|
2892
|
+
type: "OnPatchesByQuery",
|
|
2893
|
+
patchesByQuery: new Map(),
|
|
2894
|
+
onCompleteIds: mutate.onCompleteIds,
|
|
2895
|
+
});
|
|
2896
|
+
await testWaitForWorkerMessage();
|
|
2897
|
+
assertEqual(completed, ["at limit"]);
|
|
2898
|
+
});
|
|
2899
|
+
|
|
2900
|
+
it("lets plain text use the whole limit at a byte per character", async () => {
|
|
2901
|
+
await using setup = await setupRunWithEvoluDeps();
|
|
2902
|
+
const { run, evoluInputs } = setup;
|
|
2903
|
+
const evolu = await run.ok(createSizeEvolu);
|
|
2904
|
+
|
|
2905
|
+
// Spaces keep the text from being encoded as Base64Url, which is smaller.
|
|
2906
|
+
// A long text's length varint takes two more bytes than a short one's.
|
|
2907
|
+
const length =
|
|
2908
|
+
maxMutationSize - evolu.getMutationSize("note", { body: " " }) - 1;
|
|
2909
|
+
assertEqual(
|
|
2910
|
+
evolu.getMutationSize("note", { body: " ".repeat(length) }),
|
|
2911
|
+
maxMutationSize,
|
|
2912
|
+
);
|
|
2913
|
+
assertTrue(length > 639_900);
|
|
2914
|
+
|
|
2915
|
+
evolu.update("note", { id: noteId, body: " ".repeat(length) });
|
|
2916
|
+
const error = assertThrowsInstanceOf(
|
|
2917
|
+
() =>
|
|
2918
|
+
evolu.update("note", { id: noteId, body: " ".repeat(length + 1) }),
|
|
2919
|
+
Error,
|
|
2920
|
+
);
|
|
2921
|
+
assertEqual(error.message, tooLargeMessage(maxMutationSize + 1));
|
|
2922
|
+
|
|
2923
|
+
await testWaitForWorkerMessage();
|
|
2924
|
+
assertLength(getChanges(evoluInputs), 1);
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2927
|
+
it("saves a copy of each binary value", async () => {
|
|
2928
|
+
await using setup = await setupRunWithEvoluDeps();
|
|
2929
|
+
const { run, evoluInputs } = setup;
|
|
2930
|
+
const evolu = await run.ok(createSizeEvolu);
|
|
2931
|
+
|
|
2932
|
+
const resizable = new ArrayBuffer(3, { maxByteLength: 8 });
|
|
2933
|
+
const growing = new Uint8Array(resizable);
|
|
2934
|
+
growing.set([1, 2, 3]);
|
|
2935
|
+
const shared = new Uint8Array(new SharedArrayBuffer(3));
|
|
2936
|
+
shared.set([4, 5, 6]);
|
|
2937
|
+
const nodeBuffer = Buffer.from([7, 8, 9]);
|
|
2938
|
+
|
|
2939
|
+
evolu.insert("note", { data: growing });
|
|
2940
|
+
evolu.insert("note", { data: shared });
|
|
2941
|
+
evolu.insert("note", { data: nodeBuffer });
|
|
2942
|
+
|
|
2943
|
+
// Changes made before the batch is sent do not reach it.
|
|
2944
|
+
resizable.resize(8);
|
|
2945
|
+
growing.fill(0);
|
|
2946
|
+
shared.fill(0);
|
|
2947
|
+
nodeBuffer.fill(0);
|
|
2948
|
+
|
|
2949
|
+
await testWaitForWorkerMessage();
|
|
2950
|
+
|
|
2951
|
+
const saved = getChanges(evoluInputs).map((change) => change.values.data);
|
|
2952
|
+
assertEqual(saved, [
|
|
2953
|
+
new Uint8Array([1, 2, 3]),
|
|
2954
|
+
new Uint8Array([4, 5, 6]),
|
|
2955
|
+
new Uint8Array([7, 8, 9]),
|
|
2956
|
+
]);
|
|
2957
|
+
for (const data of saved) {
|
|
2958
|
+
assert(data instanceof Uint8Array, "Expected a Uint8Array.");
|
|
2959
|
+
assertSame(Object.getPrototypeOf(data), Uint8Array.prototype);
|
|
2960
|
+
assertFalse(data.buffer instanceof SharedArrayBuffer);
|
|
2961
|
+
assertFalse((data.buffer as ArrayBuffer).resizable);
|
|
2962
|
+
}
|
|
2963
|
+
});
|
|
2964
|
+
|
|
2965
|
+
it("does not limit local-only tables but copies their binary values", async () => {
|
|
2966
|
+
await using setup = await setupRunWithEvoluDeps();
|
|
2967
|
+
const { run, evoluInputs } = setup;
|
|
2968
|
+
const evolu = await run.ok(createSizeEvolu);
|
|
2969
|
+
|
|
2970
|
+
const data = new Uint8Array(maxMutationSize + 1);
|
|
2971
|
+
evolu.insert("_note", { data });
|
|
2972
|
+
data[0] = 1;
|
|
2973
|
+
|
|
2974
|
+
await testWaitForWorkerMessage();
|
|
2975
|
+
|
|
2976
|
+
const [change] = getChanges(evoluInputs);
|
|
2977
|
+
assertNotUndefined(change);
|
|
2978
|
+
const saved = change.values.data;
|
|
2979
|
+
assert(saved instanceof Uint8Array, "Expected a Uint8Array.");
|
|
2980
|
+
assertNotSame(saved, data);
|
|
2981
|
+
assertEqual(saved.byteLength, maxMutationSize + 1);
|
|
2982
|
+
assertEqual(saved[0], 0);
|
|
2983
|
+
});
|
|
2984
|
+
|
|
2985
|
+
it("fits a change at the limit into one message next to the largest ranges section", async () => {
|
|
2986
|
+
await using setup = await setupRunWithEvoluDeps();
|
|
2987
|
+
const evolu = await setup.run.ok(createSizeEvolu);
|
|
2988
|
+
const deps = testCreateDeps();
|
|
2989
|
+
const values = { data: new Uint8Array(getDataAtLimitLength(evolu)) };
|
|
2990
|
+
assertEqual(evolu.getMutationSize("note", values), maxMutationSize);
|
|
2991
|
+
|
|
2992
|
+
const message = {
|
|
2993
|
+
timestamp: createTimestamp(),
|
|
2994
|
+
change: DbChange.orThrow({
|
|
2995
|
+
table: "note",
|
|
2996
|
+
id: noteId,
|
|
2997
|
+
values,
|
|
2998
|
+
isInsert: true,
|
|
2999
|
+
isDelete: null,
|
|
3000
|
+
}),
|
|
3001
|
+
};
|
|
3002
|
+
|
|
3003
|
+
// A request and a broadcast assert that the change fits.
|
|
3004
|
+
createProtocolMessageFromCrdtMessages(deps)(testAppOwner, [message]);
|
|
3005
|
+
createProtocolBroadcastMessagesFromCrdtMessages(deps)(testAppOwner, [
|
|
3006
|
+
message,
|
|
3007
|
+
]);
|
|
3008
|
+
|
|
3009
|
+
// A relay's response can pair it with the largest ranges section.
|
|
3010
|
+
const rangesMaxSize = ProtocolMessageRangesMaxSize.orThrow(100_000);
|
|
3011
|
+
const timestamps = createTimestampsBuffer();
|
|
3012
|
+
for (let index = 1; timestamps.getLength() < 99_900; index++) {
|
|
3013
|
+
timestamps.add(
|
|
3014
|
+
createTimestamp({
|
|
3015
|
+
millis: createTimestamp().millis,
|
|
3016
|
+
nodeId: index.toString(16).padStart(16, "0") as NodeId,
|
|
3017
|
+
}),
|
|
3018
|
+
);
|
|
3019
|
+
}
|
|
3020
|
+
const response = createProtocolMessageBuffer(testAppOwner.id, {
|
|
3021
|
+
messageType: MessageType.Response,
|
|
3022
|
+
errorCode: ProtocolErrorCode.NoError,
|
|
3023
|
+
rangesMaxSize,
|
|
3024
|
+
});
|
|
3025
|
+
assertTrue(
|
|
3026
|
+
response.canAddTimestampsRangeAndMessage(timestamps, {
|
|
3027
|
+
timestamp: message.timestamp,
|
|
3028
|
+
change: encodeAndEncryptDbChange(deps)(
|
|
3029
|
+
message,
|
|
3030
|
+
testAppOwner.encryptionKey,
|
|
3031
|
+
),
|
|
3032
|
+
}),
|
|
3033
|
+
);
|
|
3034
|
+
});
|
|
3035
|
+
});
|
|
3036
|
+
|
|
2450
3037
|
describe("exportDatabase", () => {
|
|
2451
3038
|
it("throws when OnExport arrives without pending export", async () => {
|
|
2452
3039
|
const channels: Array<{
|