@evolu/common 8.10.0 → 8.11.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 (144) hide show
  1. package/dist/src/Config.d.ts +22 -22
  2. package/dist/src/Config.d.ts.map +1 -1
  3. package/dist/src/Console.d.ts +62 -7
  4. package/dist/src/Console.d.ts.map +1 -1
  5. package/dist/src/Console.js +20 -4
  6. package/dist/src/Crypto.d.ts +76 -4
  7. package/dist/src/Crypto.d.ts.map +1 -1
  8. package/dist/src/Crypto.js +55 -4
  9. package/dist/src/Error.d.ts +45 -0
  10. package/dist/src/Error.d.ts.map +1 -1
  11. package/dist/src/Error.js +69 -0
  12. package/dist/src/Fs.d.ts +92 -18
  13. package/dist/src/Fs.d.ts.map +1 -1
  14. package/dist/src/Fs.js +2 -0
  15. package/dist/src/Identicon.d.ts +2 -2
  16. package/dist/src/Identicon.js +2 -2
  17. package/dist/src/LeakDetector.d.ts +22 -3
  18. package/dist/src/LeakDetector.d.ts.map +1 -1
  19. package/dist/src/LeakDetector.js +12 -2
  20. package/dist/src/LockManager.d.ts +8 -0
  21. package/dist/src/LockManager.d.ts.map +1 -1
  22. package/dist/src/LockManager.js +6 -0
  23. package/dist/src/Object.d.ts.map +1 -1
  24. package/dist/src/Object.js +5 -0
  25. package/dist/src/Platform.d.ts +47 -7
  26. package/dist/src/Platform.d.ts.map +1 -1
  27. package/dist/src/Platform.js +24 -5
  28. package/dist/src/Random.d.ts +25 -2
  29. package/dist/src/Random.d.ts.map +1 -1
  30. package/dist/src/Random.js +14 -2
  31. package/dist/src/Resource.d.ts +156 -1
  32. package/dist/src/Resource.d.ts.map +1 -1
  33. package/dist/src/Resource.js +201 -72
  34. package/dist/src/Schedule.d.ts +11 -10
  35. package/dist/src/Schedule.d.ts.map +1 -1
  36. package/dist/src/Schedule.js +1 -1
  37. package/dist/src/Sqlite.d.ts +132 -16
  38. package/dist/src/Sqlite.d.ts.map +1 -1
  39. package/dist/src/Sqlite.js +63 -9
  40. package/dist/src/Task.d.ts +15 -4
  41. package/dist/src/Task.d.ts.map +1 -1
  42. package/dist/src/Task.js +41 -15
  43. package/dist/src/Test.d.ts +9 -0
  44. package/dist/src/Test.d.ts.map +1 -1
  45. package/dist/src/Test.js +4 -0
  46. package/dist/src/Time.d.ts +106 -9
  47. package/dist/src/Time.d.ts.map +1 -1
  48. package/dist/src/Time.js +55 -4
  49. package/dist/src/Type.d.ts +1455 -1310
  50. package/dist/src/Type.d.ts.map +1 -1
  51. package/dist/src/Type.js +1274 -517
  52. package/dist/src/WebSocket.d.ts +164 -13
  53. package/dist/src/WebSocket.d.ts.map +1 -1
  54. package/dist/src/WebSocket.js +133 -24
  55. package/dist/src/Worker.d.ts +90 -8
  56. package/dist/src/Worker.d.ts.map +1 -1
  57. package/dist/src/Worker.js +28 -2
  58. package/dist/src/index.d.ts +6 -7
  59. package/dist/src/index.d.ts.map +1 -1
  60. package/dist/src/index.js +2 -3
  61. package/dist/src/local-first/Db.d.ts +52 -3
  62. package/dist/src/local-first/Db.d.ts.map +1 -1
  63. package/dist/src/local-first/Db.js +412 -137
  64. package/dist/src/local-first/Evolu.d.ts +336 -211
  65. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  66. package/dist/src/local-first/Evolu.js +102 -15
  67. package/dist/src/local-first/Owner.d.ts +13 -30
  68. package/dist/src/local-first/Owner.d.ts.map +1 -1
  69. package/dist/src/local-first/Owner.js +13 -30
  70. package/dist/src/local-first/Protocol.d.ts +94 -16
  71. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  72. package/dist/src/local-first/Protocol.js +118 -38
  73. package/dist/src/local-first/Query.d.ts +8 -15
  74. package/dist/src/local-first/Query.d.ts.map +1 -1
  75. package/dist/src/local-first/Schema.d.ts +335 -21
  76. package/dist/src/local-first/Schema.d.ts.map +1 -1
  77. package/dist/src/local-first/Schema.js +214 -17
  78. package/dist/src/local-first/Shared.d.ts +537 -22
  79. package/dist/src/local-first/Shared.d.ts.map +1 -1
  80. package/dist/src/local-first/Shared.js +1437 -234
  81. package/dist/src/local-first/Storage.d.ts +192 -14
  82. package/dist/src/local-first/Storage.d.ts.map +1 -1
  83. package/dist/src/local-first/Storage.js +81 -20
  84. package/dist/src/local-first/Timestamp.d.ts +392 -41
  85. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  86. package/dist/src/local-first/Timestamp.js +403 -81
  87. package/dist/src/local-first/index.d.ts +0 -1
  88. package/dist/src/local-first/index.d.ts.map +1 -1
  89. package/dist/src/local-first/index.js +0 -1
  90. package/package.json +1 -1
  91. package/src/Assert.test.ts +2 -5
  92. package/src/Config.test.ts +2 -6
  93. package/src/Config.ts +133 -133
  94. package/src/Console.ts +62 -7
  95. package/src/Crypto.ts +76 -4
  96. package/src/Eq.test.ts +2 -3
  97. package/src/Error.test.ts +76 -3
  98. package/src/Error.ts +71 -0
  99. package/src/Fs.ts +92 -18
  100. package/src/Identicon.ts +2 -2
  101. package/src/LeakDetector.ts +22 -3
  102. package/src/LockManager.ts +8 -0
  103. package/src/Object.test.ts +27 -12
  104. package/src/Object.ts +5 -0
  105. package/src/Platform.ts +50 -8
  106. package/src/Random.ts +25 -2
  107. package/src/Resource.test.ts +837 -0
  108. package/src/Resource.ts +235 -15
  109. package/src/Schedule.test.ts +50 -12
  110. package/src/Schedule.ts +24 -14
  111. package/src/Sqlite.ts +137 -17
  112. package/src/Task.test.ts +189 -8
  113. package/src/Task.ts +56 -17
  114. package/src/Test.ts +9 -0
  115. package/src/Time.ts +106 -9
  116. package/src/Type.test.ts +946 -1028
  117. package/src/Type.ts +4195 -3136
  118. package/src/Types.test.ts +4 -14
  119. package/src/WebSocket.ts +313 -40
  120. package/src/Worker.ts +90 -8
  121. package/src/index.ts +15 -6
  122. package/src/local-first/Db.ts +644 -339
  123. package/src/local-first/Evolu.test.ts +686 -21
  124. package/src/local-first/Evolu.ts +450 -228
  125. package/src/local-first/Owner.ts +13 -30
  126. package/src/local-first/Protocol.test.ts +617 -10
  127. package/src/local-first/Protocol.ts +196 -72
  128. package/src/local-first/Query.ts +8 -15
  129. package/src/local-first/Schema.test.ts +143 -0
  130. package/src/local-first/Schema.ts +363 -24
  131. package/src/local-first/Shared.test.ts +7731 -559
  132. package/src/local-first/Shared.ts +2036 -267
  133. package/src/local-first/Storage.ts +218 -32
  134. package/src/local-first/Timestamp.test.ts +344 -70
  135. package/src/local-first/Timestamp.ts +434 -118
  136. package/src/local-first/index.ts +0 -1
  137. package/dist/src/local-first/Error.d.ts +0 -12
  138. package/dist/src/local-first/Error.d.ts.map +0 -1
  139. package/dist/src/local-first/Error.js +0 -6
  140. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  141. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  142. package/dist/src/local-first/LocalAuth.js +0 -179
  143. package/src/local-first/Error.ts +0 -17
  144. package/src/local-first/LocalAuth.ts +0 -457
@@ -4,8 +4,10 @@ import type { NonEmptyReadonlyArray } from "../Array.ts";
4
4
  import {
5
5
  assertEqual,
6
6
  assertEqualBytes,
7
+ assertErr,
7
8
  assertFalse,
8
9
  assertInstanceOf,
10
+ assertLength,
9
11
  assertNonNullable,
10
12
  assertOk,
11
13
  assertSame,
@@ -25,7 +27,12 @@ import {
25
27
  applyProtocolMessageAsClient,
26
28
  applyProtocolMessageAsRelay,
27
29
  createProtocolMessageBuffer,
30
+ createProtocolBroadcastMessagesFromCrdtMessages,
31
+ defaultProtocolMessageMaxSize,
32
+ ProtocolMessageMaxSize,
33
+ ProtocolErrorCode,
28
34
  createProtocolMessageFromCrdtMessages,
35
+ createProtocolMessageForSync,
29
36
  createTimestampsBuffer,
30
37
  decodeSqliteValue,
31
38
  decryptAndDecodeDbChange,
@@ -36,6 +43,7 @@ import {
36
43
  ProtocolValueType,
37
44
  protocolVersion,
38
45
  SubscriptionFlags,
46
+ type ProtocolError,
39
47
  } from "./Protocol.ts";
40
48
  import type {
41
49
  CrdtMessage,
@@ -43,16 +51,26 @@ import type {
43
51
  EncryptedDbChange,
44
52
  StorageDep,
45
53
  } from "./Storage.ts";
46
- import { DbChange, InfiniteUpperBound, RangeType } from "./Storage.ts";
54
+ import {
55
+ DbChange,
56
+ InfiniteUpperBound,
57
+ RangeType,
58
+ timestampBytesToFingerprint,
59
+ zeroFingerprint,
60
+ type StorageWriteMessagesError,
61
+ } from "./Storage.ts";
47
62
  import { err, getOrThrow, ok } from "../Result.ts";
48
63
  import {
64
+ createAbortError,
49
65
  testCreateDeps,
50
66
  testCreateRun,
67
+ type InferTaskErr,
51
68
  type RunDefaultDeps,
52
69
  type TestRunDefaultDeps as TestDeps,
53
70
  } from "../Task.ts";
54
71
  import { maxMillis, Millis } from "../Time.ts";
55
72
  import {
73
+ assertType,
56
74
  createId,
57
75
  DateIsoFromDate,
58
76
  FiniteNumber,
@@ -531,6 +549,28 @@ const shouldNotBeCalledStorageDep: StorageDep = {
531
549
  },
532
550
  };
533
551
 
552
+ test("createProtocolMessageForSync does not swallow unexpected fingerprint exceptions", () => {
553
+ const failure = new Error("fingerprint ranges unavailable");
554
+ const deps = {
555
+ ...testCreateDeps(),
556
+ storage: {
557
+ ...shouldNotBeCalledStorageDep.storage,
558
+ getSize: () => NonNegativeInt.orThrow(32),
559
+ fingerprintRanges: () => {
560
+ throw failure;
561
+ },
562
+ },
563
+ } satisfies StorageDep;
564
+
565
+ assertSame(
566
+ assertThrowsInstanceOf(
567
+ () => createProtocolMessageForSync(deps)(testAppOwner.id),
568
+ Error,
569
+ ),
570
+ failure,
571
+ );
572
+ });
573
+
534
574
  test("createTimestampsBuffer maxTimestamp", () => {
535
575
  const buffer = createTimestampsBuffer();
536
576
  buffer.add(timestampBytesToTimestamp(maxTimestamp));
@@ -641,7 +681,7 @@ describe("createProtocolMessageBuffer", () => {
641
681
  });
642
682
  });
643
683
 
644
- test("parseProtocolHeader parses supported headers and rejects malformed ones", () => {
684
+ test("parseProtocolHeader parses any version and rejects malformed ones", () => {
645
685
  const requestHeader = parseProtocolHeader(
646
686
  createProtocolMessageBuffer(testAppOwner.id, {
647
687
  messageType: MessageType.Request,
@@ -689,16 +729,20 @@ test("parseProtocolHeader parses supported headers and rejects malformed ones",
689
729
  }),
690
730
  );
691
731
 
692
- const invalidVersionMessage = createProtocolMessageBuffer(testAppOwner.id, {
732
+ // Only the version and the owner ID are stable across versions, so another
733
+ // version parses to that prefix without a message type.
734
+ const otherVersionMessage = createProtocolMessageBuffer(testAppOwner.id, {
693
735
  version: PositiveInt.orThrow(2),
694
736
  messageType: MessageType.Request,
695
737
  }).unwrap();
696
- const invalidVersion = parseProtocolHeader(invalidVersionMessage);
697
- assertFalse(invalidVersion.ok);
698
- if (!invalidVersion.ok) {
699
- assertEqual(invalidVersion.error.type, "ProtocolInvalidDataError");
700
- assertInstanceOf(invalidVersion.error.error, Error);
701
- }
738
+ assertEqual(
739
+ parseProtocolHeader(otherVersionMessage),
740
+ ok({
741
+ type: "ProtocolHeader",
742
+ version: 2,
743
+ ownerId: testAppOwner.id,
744
+ }),
745
+ );
702
746
 
703
747
  const invalidTypeMessage = createBuffer();
704
748
  encodeNonNegativeInt(invalidTypeMessage, protocolVersion);
@@ -788,6 +832,43 @@ describe("E2E versioning", () => {
788
832
  }),
789
833
  );
790
834
  });
835
+
836
+ it("transport routes the non-initiator's mismatch reply", async () => {
837
+ await using run = testCreateRun(shouldNotBeCalledStorageDep);
838
+ const v2 = 2 as NonNegativeInt;
839
+
840
+ const clientMessage = createProtocolMessageBuffer(testAppOwner.id, {
841
+ messageType: MessageType.Request,
842
+ }).unwrap();
843
+
844
+ const relayResponse = await run.orThrow(
845
+ applyProtocolMessageAsRelay(clientMessage, {}, v2),
846
+ );
847
+
848
+ // The reply carries only the version and the owner ID, and the transport
849
+ // must route it rather than reject it as invalid data.
850
+ assertEqual(
851
+ parseProtocolHeader(relayResponse.message),
852
+ ok({
853
+ type: "ProtocolHeader",
854
+ version: 2,
855
+ ownerId: testAppOwner.id,
856
+ }),
857
+ );
858
+
859
+ const clientResult = await run(
860
+ applyProtocolMessageAsClient(relayResponse.message),
861
+ );
862
+ assertEqual(
863
+ clientResult,
864
+ err({
865
+ type: "ProtocolVersionError",
866
+ version: 2,
867
+ isInitiator: true,
868
+ ownerId: testAppOwner.id,
869
+ }),
870
+ );
871
+ });
791
872
  });
792
873
 
793
874
  describe("E2E errors", () => {
@@ -851,6 +932,63 @@ describe("E2E errors", () => {
851
932
  err({ type: "ProtocolWriteKeyError", ownerId: testAppOwner.id }),
852
933
  );
853
934
  });
935
+
936
+ it("rejected relay writes report quota and other causes distinctly", async () => {
937
+ const deps = testCreateDeps();
938
+ const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)(
939
+ testAppOwner,
940
+ [
941
+ {
942
+ timestamp: timestampBytesToTimestamp(testTimestampsAsc[0]),
943
+ change: createDbChange(deps),
944
+ },
945
+ ],
946
+ );
947
+ /** Returns the relay's response and what it logged for the rejection. */
948
+ const relayResponseFor = async (error: StorageWriteMessagesError) => {
949
+ await using run = testCreateRun({
950
+ storage: {
951
+ ...shouldNotBeCalledStorageDep.storage,
952
+ validateWriteKey: () => true,
953
+ writeMessages: () => () => err(error),
954
+ },
955
+ } satisfies StorageDep);
956
+ const { message } = await run.orThrow(
957
+ applyProtocolMessageAsRelay(initiatorMessage),
958
+ );
959
+ return {
960
+ message,
961
+ logged: run.deps.console
962
+ .getEntriesSnapshot()
963
+ .map(({ method, args }) => ({ method, args })),
964
+ };
965
+ };
966
+
967
+ await using run = testCreateRun(shouldNotBeCalledStorageDep);
968
+ // A quota rejection is expected, so the relay does not log it.
969
+ const quota = await relayResponseFor({
970
+ type: "StorageQuotaError",
971
+ ownerId: testAppOwner.id,
972
+ });
973
+ assertEqual(
974
+ await run(applyProtocolMessageAsClient(quota.message)),
975
+ err({ type: "ProtocolQuotaError", ownerId: testAppOwner.id }),
976
+ );
977
+ assertEqual(quota.logged, []);
978
+ // Any other storage rejection is a relay write failure, not a quota, and
979
+ // the relay logs its cause.
980
+ const mismatch: StorageWriteMessagesError = {
981
+ type: "ProtocolTimestampMismatchError",
982
+ expected: timestampBytesToTimestamp(testTimestampsAsc[0]),
983
+ timestamp: timestampBytesToTimestamp(testTimestampsAsc[1]),
984
+ };
985
+ const other = await relayResponseFor(mismatch);
986
+ assertEqual(
987
+ await run(applyProtocolMessageAsClient(other.message)),
988
+ err({ type: "ProtocolWriteError", ownerId: testAppOwner.id }),
989
+ );
990
+ assertEqual(other.logged, [{ method: "error", args: [mismatch] }]);
991
+ });
854
992
  });
855
993
 
856
994
  describe("E2E relay options", () => {
@@ -1030,8 +1168,477 @@ describe("E2E relay options", () => {
1030
1168
  const result = await run(
1031
1169
  applyProtocolMessageAsClient(broadcastedMessage),
1032
1170
  );
1033
- assertOk(result, { type: "NoResponse" });
1171
+ assertOk(result, { type: "Broadcast" });
1034
1172
  }
1035
1173
  assertTrue(writeMessagesCalled);
1036
1174
  });
1037
1175
  });
1176
+
1177
+ describe("applyProtocolMessageAsClient results", () => {
1178
+ const createResponse = () =>
1179
+ createProtocolMessageBuffer(testAppOwner.id, {
1180
+ messageType: MessageType.Response,
1181
+ errorCode: ProtocolErrorCode.NoError,
1182
+ });
1183
+
1184
+ it("reports a response without ranges as converged", async () => {
1185
+ await using run = testCreateRun(shouldNotBeCalledStorageDep);
1186
+ const result = await run(
1187
+ applyProtocolMessageAsClient(createResponse().unwrap(), {
1188
+ writeKey: testAppOwner.writeKey,
1189
+ }),
1190
+ );
1191
+ assertOk(result, { type: "Converged" });
1192
+ });
1193
+
1194
+ it("reports matching ranges as converged", async () => {
1195
+ const input = createResponse();
1196
+ input.addRange({ type: RangeType.Skip, upperBound: InfiniteUpperBound });
1197
+ let findLowerBoundCalled = false;
1198
+ await using run = testCreateRun({
1199
+ storage: {
1200
+ ...shouldNotBeCalledStorageDep.storage,
1201
+ getSize: () => NonNegativeInt.orThrow(3),
1202
+ findLowerBound: (_ownerId, _begin, end) => {
1203
+ findLowerBoundCalled = true;
1204
+ return end;
1205
+ },
1206
+ },
1207
+ } satisfies StorageDep);
1208
+ const result = await run(
1209
+ applyProtocolMessageAsClient(input.unwrap(), {
1210
+ writeKey: testAppOwner.writeKey,
1211
+ }),
1212
+ );
1213
+ assertOk(result, { type: "Converged" });
1214
+ assertTrue(findLowerBoundCalled);
1215
+ });
1216
+
1217
+ it("reports a response without a write key as readonly", async () => {
1218
+ await using run = testCreateRun(shouldNotBeCalledStorageDep);
1219
+ const result = await run(
1220
+ applyProtocolMessageAsClient(createResponse().unwrap()),
1221
+ );
1222
+ assertOk(result, { type: "Readonly" });
1223
+ });
1224
+
1225
+ it("preserves expected storage write rejection causes", async () => {
1226
+ const deps = testCreateDeps();
1227
+ const input = createResponse();
1228
+ input.addMessage(
1229
+ createEncryptedCrdtMessage(deps, {
1230
+ timestamp: timestampBytesToTimestamp(testTimestampsAsc[0]),
1231
+ change: createDbChange(deps),
1232
+ }),
1233
+ );
1234
+ const message = input.unwrap();
1235
+
1236
+ const errors: ReadonlyArray<StorageWriteMessagesError> = [
1237
+ {
1238
+ type: "DecryptWithXChaCha20Poly1305Error",
1239
+ error: new Error("decryption failed"),
1240
+ },
1241
+ {
1242
+ type: "ProtocolInvalidDataError",
1243
+ data: Uint8Array.of(255),
1244
+ error: new Error("decoding failed"),
1245
+ },
1246
+ {
1247
+ type: "ProtocolTimestampMismatchError",
1248
+ expected: timestampBytesToTimestamp(testTimestampsAsc[0]),
1249
+ timestamp: timestampBytesToTimestamp(testTimestampsAsc[1]),
1250
+ },
1251
+ { type: "StorageQuotaError", ownerId: testAppOwner.id },
1252
+ ];
1253
+
1254
+ for (const error of errors) {
1255
+ await using run = testCreateRun({
1256
+ storage: {
1257
+ ...shouldNotBeCalledStorageDep.storage,
1258
+ writeMessages: () => () => err(error),
1259
+ },
1260
+ } satisfies StorageDep);
1261
+ const task = applyProtocolMessageAsClient(message, {
1262
+ writeKey: testAppOwner.writeKey,
1263
+ });
1264
+ assertType<
1265
+ InferTaskErr<typeof task>,
1266
+ ProtocolError | StorageWriteMessagesError
1267
+ >();
1268
+ const result = await run(task);
1269
+ assertErr(result);
1270
+ assertSame(result.error, error);
1271
+ }
1272
+ });
1273
+
1274
+ it("reports a thrown write as failed", async () => {
1275
+ const deps = testCreateDeps();
1276
+ const input = createResponse();
1277
+ input.addMessage(
1278
+ createEncryptedCrdtMessage(deps, {
1279
+ timestamp: timestampBytesToTimestamp(testTimestampsAsc[0]),
1280
+ change: createDbChange(deps),
1281
+ }),
1282
+ );
1283
+ const message = input.unwrap();
1284
+ const failure = new Error("write failed");
1285
+
1286
+ await using throwingRun = testCreateRun({
1287
+ storage: {
1288
+ ...shouldNotBeCalledStorageDep.storage,
1289
+ // A throw from the call itself, before a Task runs. A throw inside the
1290
+ // Task is a defect of the Run, not a write failure.
1291
+ writeMessages: () => {
1292
+ throw failure;
1293
+ },
1294
+ },
1295
+ } satisfies StorageDep);
1296
+ assertOk(
1297
+ await throwingRun(
1298
+ applyProtocolMessageAsClient(message, {
1299
+ writeKey: testAppOwner.writeKey,
1300
+ }),
1301
+ ),
1302
+ { type: "Failed", cause: "Write" },
1303
+ );
1304
+ // The result carries no cause, so the log is the only trace of it.
1305
+ const entries = throwingRun.deps.console.getEntriesSnapshot();
1306
+ assertLength(entries, 1);
1307
+ assertSame(entries[0].method, "error");
1308
+ assertLength(entries[0].args, 1);
1309
+ assertSame(entries[0].args[0], failure);
1310
+ });
1311
+
1312
+ it("reports a failed range reconciliation as failed", async () => {
1313
+ const input = createResponse();
1314
+ input.addRange({
1315
+ type: RangeType.Fingerprint,
1316
+ upperBound: InfiniteUpperBound,
1317
+ fingerprint: zeroFingerprint,
1318
+ });
1319
+ await using run = testCreateRun({
1320
+ storage: {
1321
+ ...shouldNotBeCalledStorageDep.storage,
1322
+ getSize: () => {
1323
+ throw new Error("storage unavailable");
1324
+ },
1325
+ },
1326
+ } satisfies StorageDep);
1327
+ const result = await run(
1328
+ applyProtocolMessageAsClient(input.unwrap(), {
1329
+ writeKey: testAppOwner.writeKey,
1330
+ }),
1331
+ );
1332
+ assertOk(result, { type: "Failed", cause: "Sync" });
1333
+ });
1334
+ });
1335
+
1336
+ describe("split-range failures", () => {
1337
+ for (const role of ["client", "relay"] as const) {
1338
+ for (const method of ["fingerprintRanges", "iterate"] as const) {
1339
+ for (const isAbort of [false, true]) {
1340
+ it(`${role} ${isAbort ? "preserves aborts" : "reports sync failure"} from ${method}`, async () => {
1341
+ const failure = isAbort
1342
+ ? createAbortError({ type: "Stop" })
1343
+ : new Error(`${method} unavailable`);
1344
+ const input = createProtocolMessageBuffer(
1345
+ testAppOwner.id,
1346
+ role === "client"
1347
+ ? {
1348
+ messageType: MessageType.Response,
1349
+ errorCode: ProtocolErrorCode.NoError,
1350
+ }
1351
+ : {
1352
+ messageType: MessageType.Request,
1353
+ writeKey: testAppOwner.writeKey,
1354
+ },
1355
+ );
1356
+ input.addRange({
1357
+ type: RangeType.Fingerprint,
1358
+ upperBound: InfiniteUpperBound,
1359
+ fingerprint: zeroFingerprint,
1360
+ });
1361
+ await using run = testCreateRun({
1362
+ storage: {
1363
+ ...shouldNotBeCalledStorageDep.storage,
1364
+ getSize: () =>
1365
+ NonNegativeInt.orThrow(method === "fingerprintRanges" ? 32 : 1),
1366
+ findLowerBound: (_ownerId, _begin, end) => end,
1367
+ fingerprint: () =>
1368
+ timestampBytesToFingerprint(testTimestampsAsc[0]),
1369
+ validateWriteKey: constTrue,
1370
+ [method]: () => {
1371
+ // oxlint-disable-next-line typescript/only-throw-error -- AbortError is Task abort control flow.
1372
+ throw failure;
1373
+ },
1374
+ },
1375
+ } satisfies StorageDep);
1376
+ const message = input.unwrap();
1377
+ const result =
1378
+ role === "client"
1379
+ ? await run.abortable(
1380
+ applyProtocolMessageAsClient(message, {
1381
+ writeKey: testAppOwner.writeKey,
1382
+ }),
1383
+ )
1384
+ : await run.abortable(applyProtocolMessageAsRelay(message));
1385
+
1386
+ if (isAbort) {
1387
+ assertErr(result);
1388
+ assertSame(result.error, failure);
1389
+ assertEqual(run.deps.console.getEntriesSnapshot(), []);
1390
+ return;
1391
+ }
1392
+ assertOk(result);
1393
+ if (role === "client") {
1394
+ assertEqual(result.value, { type: "Failed", cause: "Sync" });
1395
+ } else {
1396
+ assertSame(result.value.type, "Response");
1397
+ assertErr(
1398
+ await run(applyProtocolMessageAsClient(result.value.message)),
1399
+ { type: "ProtocolSyncError", ownerId: testAppOwner.id },
1400
+ );
1401
+ }
1402
+ const entries = run.deps.console.getEntriesSnapshot();
1403
+ assertLength(entries, 1);
1404
+ assertSame(entries[0].method, "error");
1405
+ assertLength(entries[0].args, 1);
1406
+ assertSame(entries[0].args[0], failure);
1407
+ });
1408
+ }
1409
+ }
1410
+ }
1411
+ });
1412
+
1413
+ test("local mutation broadcasts contain every message in nonempty size-limited frames", async () => {
1414
+ const deps = testCreateDeps();
1415
+ const messages: NonEmptyReadonlyArray<CrdtMessage> = [
1416
+ {
1417
+ timestamp: createTimestamp({ millis: Millis.orThrow(1) }),
1418
+ change: DbChange.orThrow({
1419
+ ...createDbChange(deps),
1420
+ values: { data: new Uint8Array(700_000).fill(1) },
1421
+ }),
1422
+ },
1423
+ {
1424
+ timestamp: createTimestamp({ millis: Millis.orThrow(2) }),
1425
+ change: DbChange.orThrow({
1426
+ ...createDbChange(deps),
1427
+ values: { data: new Uint8Array(700_000).fill(2) },
1428
+ }),
1429
+ },
1430
+ {
1431
+ timestamp: createTimestamp({ millis: Millis.orThrow(3) }),
1432
+ change: createDbChange(deps),
1433
+ },
1434
+ ];
1435
+
1436
+ for (const [maxSize, expectedFrameCount] of [
1437
+ [defaultProtocolMessageMaxSize, 2],
1438
+ [ProtocolMessageMaxSize.orThrow(2_000_000), 1],
1439
+ ] as const) {
1440
+ const broadcasts = createProtocolBroadcastMessagesFromCrdtMessages(deps)(
1441
+ testAppOwner,
1442
+ messages,
1443
+ maxSize,
1444
+ );
1445
+ assertSame(broadcasts.length, expectedFrameCount);
1446
+
1447
+ const received: Array<CrdtMessage> = [];
1448
+ let writes = 0;
1449
+ await using run = testCreateRun({
1450
+ storage: {
1451
+ ...shouldNotBeCalledStorageDep.storage,
1452
+ writeMessages: (ownerId, encryptedMessages) => () => {
1453
+ assertEqualBytes(ownerId, ownerIdToOwnerIdBytes(testAppOwner.id));
1454
+ assertTrue(encryptedMessages.length > 0);
1455
+ writes++;
1456
+ for (const message of encryptedMessages) {
1457
+ received.push({
1458
+ timestamp: message.timestamp,
1459
+ change: getOrThrow(
1460
+ decryptAndDecodeDbChange(message, testAppOwner.encryptionKey),
1461
+ ),
1462
+ });
1463
+ }
1464
+ return ok();
1465
+ },
1466
+ },
1467
+ } satisfies StorageDep);
1468
+
1469
+ for (const broadcast of broadcasts) {
1470
+ assertTrue(broadcast.length <= maxSize);
1471
+ assertSame(
1472
+ getOrThrow(parseProtocolHeader(broadcast)).messageType,
1473
+ MessageType.Broadcast,
1474
+ );
1475
+ assertOk(
1476
+ await run(
1477
+ applyProtocolMessageAsClient(broadcast, {
1478
+ writeKey: testAppOwner.writeKey,
1479
+ }),
1480
+ ),
1481
+ { type: "Broadcast" },
1482
+ );
1483
+ }
1484
+
1485
+ assertSame(writes, broadcasts.length);
1486
+ assertEqual(received, messages);
1487
+ }
1488
+ });
1489
+
1490
+ test("local mutation broadcasts reject a message that cannot fit in an empty frame", () => {
1491
+ const deps = testCreateDeps();
1492
+ const smallMessage: CrdtMessage = {
1493
+ timestamp: createTimestamp({ millis: Millis.orThrow(1) }),
1494
+ change: createDbChange(deps),
1495
+ };
1496
+ const oversizedMessage: CrdtMessage = {
1497
+ timestamp: createTimestamp({ millis: Millis.orThrow(2) }),
1498
+ change: DbChange.orThrow({
1499
+ ...createDbChange(deps),
1500
+ values: { data: new Uint8Array(1_100_000) },
1501
+ }),
1502
+ };
1503
+
1504
+ for (const messages of [
1505
+ [oversizedMessage],
1506
+ [smallMessage, oversizedMessage],
1507
+ ] as const) {
1508
+ const error = assertThrowsInstanceOf(
1509
+ () =>
1510
+ createProtocolBroadcastMessagesFromCrdtMessages(deps)(
1511
+ testAppOwner,
1512
+ messages,
1513
+ ),
1514
+ Error,
1515
+ );
1516
+ assertSame(error.message, "the message is too big");
1517
+ }
1518
+ });
1519
+
1520
+ test("client continuation broadcasts exactly the uploaded messages within the frame limit", async () => {
1521
+ const deps = testCreateDeps();
1522
+ const messages = [1, 2].map((millis) => {
1523
+ const message: CrdtMessage = {
1524
+ timestamp: createTimestamp({ millis: Millis.orThrow(millis) }),
1525
+ change: DbChange.orThrow({
1526
+ ...createDbChange(deps),
1527
+ values: { data: new Uint8Array(700_000).fill(millis) },
1528
+ }),
1529
+ };
1530
+ return createEncryptedCrdtMessage(deps, message);
1531
+ });
1532
+ const input = createProtocolMessageBuffer(testAppOwner.id, {
1533
+ messageType: MessageType.Response,
1534
+ errorCode: ProtocolErrorCode.NoError,
1535
+ });
1536
+ input.addRange({
1537
+ type: RangeType.Timestamps,
1538
+ upperBound: InfiniteUpperBound,
1539
+ timestamps: createTimestampsBuffer(),
1540
+ });
1541
+
1542
+ await using senderRun = testCreateRun({
1543
+ storage: {
1544
+ ...shouldNotBeCalledStorageDep.storage,
1545
+ getSize: () => NonNegativeInt.orThrow(messages.length),
1546
+ findLowerBound: (_ownerId, _begin, end) => end,
1547
+ fingerprint: () => zeroFingerprint,
1548
+ iterate: (_ownerId, begin, end, callback) => {
1549
+ for (let index = begin; index < end; index++) {
1550
+ const message = messages[index];
1551
+ assertNonNullable(message);
1552
+ if (
1553
+ !callback(
1554
+ timestampToTimestampBytes(message.timestamp),
1555
+ NonNegativeInt.orThrow(index),
1556
+ )
1557
+ )
1558
+ break;
1559
+ }
1560
+ },
1561
+ readDbChange: (_ownerId, timestamp) => {
1562
+ const message = messages.find(
1563
+ (message) =>
1564
+ message.timestamp.millis ===
1565
+ timestampBytesToTimestamp(timestamp).millis,
1566
+ );
1567
+ assertNonNullable(message);
1568
+ return message.change;
1569
+ },
1570
+ },
1571
+ } satisfies StorageDep);
1572
+ const result = await senderRun.orThrow(
1573
+ applyProtocolMessageAsClient(input.unwrap(), {
1574
+ writeKey: testAppOwner.writeKey,
1575
+ }),
1576
+ );
1577
+ assertSame(result.type, "Response");
1578
+ const broadcast = result.broadcast;
1579
+ assertNonNullable(broadcast);
1580
+ assertTrue(broadcast.length <= defaultProtocolMessageMaxSize);
1581
+
1582
+ let receivedBroadcast = false;
1583
+ await using receiverRun = testCreateRun({
1584
+ storage: {
1585
+ ...shouldNotBeCalledStorageDep.storage,
1586
+ getSize: () => NonNegativeInt.orThrow(0),
1587
+ findLowerBound: () => NonNegativeInt.orThrow(0),
1588
+ fingerprint: () => zeroFingerprint,
1589
+ iterate: () => {},
1590
+ validateWriteKey: constTrue,
1591
+ writeMessages: (_ownerId, uploaded) => () => {
1592
+ assertEqual(uploaded, [messages[0]]);
1593
+ return ok();
1594
+ },
1595
+ },
1596
+ } satisfies StorageDep);
1597
+ assertOk(
1598
+ await receiverRun(
1599
+ applyProtocolMessageAsClient(broadcast, {
1600
+ writeKey: testAppOwner.writeKey,
1601
+ }),
1602
+ ),
1603
+ { type: "Broadcast" },
1604
+ );
1605
+ await receiverRun.orThrow(
1606
+ applyProtocolMessageAsRelay(result.message, {
1607
+ broadcast: (_ownerId, relayBroadcast) => {
1608
+ receivedBroadcast = true;
1609
+ assertEqualBytes(relayBroadcast, broadcast);
1610
+ },
1611
+ }),
1612
+ );
1613
+ assertTrue(receivedBroadcast);
1614
+ });
1615
+
1616
+ test("client range-only continuation has no local broadcast", async () => {
1617
+ const timestamps = createTimestampsBuffer();
1618
+ timestamps.add(timestampBytesToTimestamp(testTimestampsAsc[0]));
1619
+ const input = createProtocolMessageBuffer(testAppOwner.id, {
1620
+ messageType: MessageType.Response,
1621
+ errorCode: ProtocolErrorCode.NoError,
1622
+ });
1623
+ input.addRange({
1624
+ type: RangeType.Timestamps,
1625
+ upperBound: InfiniteUpperBound,
1626
+ timestamps,
1627
+ });
1628
+
1629
+ await using run = testCreateRun({
1630
+ storage: {
1631
+ ...shouldNotBeCalledStorageDep.storage,
1632
+ getSize: () => NonNegativeInt.orThrow(0),
1633
+ findLowerBound: () => NonNegativeInt.orThrow(0),
1634
+ iterate: () => {},
1635
+ },
1636
+ } satisfies StorageDep);
1637
+ const result = await run.orThrow(
1638
+ applyProtocolMessageAsClient(input.unwrap(), {
1639
+ writeKey: testAppOwner.writeKey,
1640
+ }),
1641
+ );
1642
+ assertSame(result.type, "Response");
1643
+ assertFalse("broadcast" in result);
1644
+ });