@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.
Files changed (72) hide show
  1. package/dist/src/Bytes.d.ts +39 -2
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +50 -2
  4. package/dist/src/Callbacks.d.ts +12 -1
  5. package/dist/src/Callbacks.d.ts.map +1 -1
  6. package/dist/src/Callbacks.js +3 -0
  7. package/dist/src/Error.d.ts +10 -4
  8. package/dist/src/Error.d.ts.map +1 -1
  9. package/dist/src/Error.js +10 -4
  10. package/dist/src/Object.d.ts +65 -0
  11. package/dist/src/Object.d.ts.map +1 -1
  12. package/dist/src/Object.js +142 -0
  13. package/dist/src/Resource.d.ts +0 -5
  14. package/dist/src/Resource.d.ts.map +1 -1
  15. package/dist/src/Resource.js +6 -13
  16. package/dist/src/Sqlite.d.ts.map +1 -1
  17. package/dist/src/Sqlite.js +7 -0
  18. package/dist/src/Task.d.ts +10 -8
  19. package/dist/src/Task.d.ts.map +1 -1
  20. package/dist/src/Task.js +41 -5
  21. package/dist/src/Worker.d.ts +3 -3
  22. package/dist/src/index.d.ts +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +1 -1
  25. package/dist/src/local-first/Db.d.ts +8 -3
  26. package/dist/src/local-first/Db.d.ts.map +1 -1
  27. package/dist/src/local-first/Db.js +56 -19
  28. package/dist/src/local-first/Evolu.d.ts +142 -24
  29. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  30. package/dist/src/local-first/Evolu.js +109 -8
  31. package/dist/src/local-first/Owner.d.ts +9 -0
  32. package/dist/src/local-first/Owner.d.ts.map +1 -1
  33. package/dist/src/local-first/Owner.js +9 -0
  34. package/dist/src/local-first/Protocol.d.ts +18 -7
  35. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  36. package/dist/src/local-first/Protocol.js +45 -28
  37. package/dist/src/local-first/Relay.d.ts.map +1 -1
  38. package/dist/src/local-first/Relay.js +4 -2
  39. package/dist/src/local-first/Schema.d.ts +18 -3
  40. package/dist/src/local-first/Schema.d.ts.map +1 -1
  41. package/dist/src/local-first/Shared.d.ts +523 -133
  42. package/dist/src/local-first/Shared.d.ts.map +1 -1
  43. package/dist/src/local-first/Shared.js +696 -256
  44. package/dist/src/local-first/Storage.d.ts +19 -15
  45. package/dist/src/local-first/Storage.d.ts.map +1 -1
  46. package/dist/src/local-first/Storage.js +4 -2
  47. package/package.json +1 -1
  48. package/src/Bytes.test.ts +27 -0
  49. package/src/Bytes.ts +58 -2
  50. package/src/Callbacks.test.ts +20 -0
  51. package/src/Callbacks.ts +17 -1
  52. package/src/Error.ts +10 -4
  53. package/src/Object.test.ts +296 -0
  54. package/src/Object.ts +163 -0
  55. package/src/Resource.test.ts +20 -16
  56. package/src/Resource.ts +6 -20
  57. package/src/Sqlite.ts +7 -0
  58. package/src/Task.test.ts +233 -62
  59. package/src/Task.ts +47 -13
  60. package/src/Worker.ts +3 -3
  61. package/src/index.ts +6 -1
  62. package/src/local-first/Db.ts +88 -18
  63. package/src/local-first/Evolu.test.ts +589 -2
  64. package/src/local-first/Evolu.ts +285 -36
  65. package/src/local-first/Owner.ts +9 -0
  66. package/src/local-first/Protocol.test.ts +59 -60
  67. package/src/local-first/Protocol.ts +67 -47
  68. package/src/local-first/Relay.ts +4 -2
  69. package/src/local-first/Schema.ts +20 -3
  70. package/src/local-first/Shared.test.ts +2633 -646
  71. package/src/local-first/Shared.ts +1192 -364
  72. 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), refused: false, owners: [] }],
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<{