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