@evolu/common 8.17.0 → 8.18.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 +31 -14
- package/dist/src/Bytes.d.ts.map +1 -1
- package/dist/src/Bytes.js +48 -8
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +5 -1
- package/dist/src/Polyfills.d.ts.map +1 -1
- package/dist/src/Polyfills.js +3 -2
- package/dist/src/Task.d.ts +4 -3
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +4 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +18 -7
- package/dist/src/local-first/Evolu.d.ts +10 -1
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +5 -1
- package/dist/src/local-first/Protocol.d.ts +215 -93
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +799 -489
- package/dist/src/local-first/Relay.d.ts +9 -1
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +41 -36
- package/dist/src/local-first/Shared.d.ts +75 -49
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +62 -42
- package/dist/src/local-first/Storage.d.ts +80 -18
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +20 -4
- package/package.json +1 -1
- package/src/Bytes.test.ts +212 -0
- package/src/Bytes.ts +67 -17
- package/src/Error.ts +5 -1
- package/src/Polyfills.ts +3 -2
- package/src/Task.ts +4 -3
- package/src/local-first/Db.ts +29 -14
- package/src/local-first/Evolu.test.ts +43 -12
- package/src/local-first/Evolu.ts +16 -2
- package/src/local-first/Protocol.test.ts +1541 -44
- package/src/local-first/Protocol.ts +1070 -605
- package/src/local-first/Relay.ts +80 -69
- package/src/local-first/Shared.ts +106 -67
- package/src/local-first/Storage.ts +100 -27
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as fc from "fast-check";
|
|
2
2
|
import { describe, it, test } from "node:test";
|
|
3
|
+
import { setFlagsFromString } from "node:v8";
|
|
4
|
+
import { runInNewContext } from "node:vm";
|
|
3
5
|
import type { NonEmptyReadonlyArray } from "../Array.ts";
|
|
4
6
|
import {
|
|
7
|
+
assert,
|
|
5
8
|
assertEqual,
|
|
6
9
|
assertEqualBytes,
|
|
7
10
|
assertErr,
|
|
@@ -14,8 +17,22 @@ import {
|
|
|
14
17
|
assertThrowsInstanceOf,
|
|
15
18
|
assertTrue,
|
|
16
19
|
} from "../Assert.ts";
|
|
17
|
-
import {
|
|
18
|
-
|
|
20
|
+
import {
|
|
21
|
+
createBuffer,
|
|
22
|
+
decodeLength,
|
|
23
|
+
decodeNonNegativeInt,
|
|
24
|
+
encodeLength,
|
|
25
|
+
encodeNonNegativeInt,
|
|
26
|
+
} from "../Bytes.ts";
|
|
27
|
+
import {
|
|
28
|
+
decryptWithXChaCha20Poly1305,
|
|
29
|
+
EncryptionKey,
|
|
30
|
+
encryptWithXChaCha20Poly1305,
|
|
31
|
+
Entropy24,
|
|
32
|
+
XChaCha20Poly1305Ciphertext,
|
|
33
|
+
xChaCha20Poly1305NonceLength,
|
|
34
|
+
} from "../Crypto.ts";
|
|
35
|
+
import { createUnknownError } from "../Error.ts";
|
|
19
36
|
import { constFalse, constTrue } from "../Function.ts";
|
|
20
37
|
import { SqliteValue } from "../Sqlite.ts";
|
|
21
38
|
import {
|
|
@@ -29,7 +46,9 @@ import {
|
|
|
29
46
|
createProtocolMessageBuffer,
|
|
30
47
|
createProtocolBroadcastMessagesFromCrdtMessages,
|
|
31
48
|
defaultProtocolMessageMaxSize,
|
|
49
|
+
defaultProtocolMessageRangesMaxSize,
|
|
32
50
|
ProtocolMessageMaxSize,
|
|
51
|
+
ProtocolMessageRangesMaxSize,
|
|
33
52
|
ProtocolErrorCode,
|
|
34
53
|
createProtocolMessageFromCrdtMessages,
|
|
35
54
|
createProtocolMessageForSync,
|
|
@@ -43,12 +62,19 @@ import {
|
|
|
43
62
|
ProtocolValueType,
|
|
44
63
|
protocolVersion,
|
|
45
64
|
SubscriptionFlags,
|
|
65
|
+
type ApplyProtocolMessageAsClientOptions,
|
|
66
|
+
type ApplyProtocolMessageAsRelayOptions,
|
|
67
|
+
type ProtocolChangeTooLargeError,
|
|
46
68
|
type ProtocolError,
|
|
69
|
+
type ProtocolMessageBuffer,
|
|
70
|
+
type TimestampsBuffer,
|
|
71
|
+
type TimestampsRangeWithTimestampsBuffer,
|
|
47
72
|
} from "./Protocol.ts";
|
|
48
73
|
import type {
|
|
49
74
|
CrdtMessage,
|
|
50
75
|
EncryptedCrdtMessage,
|
|
51
76
|
EncryptedDbChange,
|
|
77
|
+
FingerprintRange,
|
|
52
78
|
StorageDep,
|
|
53
79
|
} from "./Storage.ts";
|
|
54
80
|
import {
|
|
@@ -61,6 +87,7 @@ import {
|
|
|
61
87
|
} from "./Storage.ts";
|
|
62
88
|
import { err, getOrThrow, ok } from "../Result.ts";
|
|
63
89
|
import {
|
|
90
|
+
AbortError,
|
|
64
91
|
createAbortError,
|
|
65
92
|
testCreateDeps,
|
|
66
93
|
testCreateRun,
|
|
@@ -78,10 +105,12 @@ import {
|
|
|
78
105
|
PositiveInt,
|
|
79
106
|
} from "../Type.ts";
|
|
80
107
|
import {
|
|
108
|
+
Counter,
|
|
81
109
|
createInitialTimestamp,
|
|
82
110
|
createTimestamp,
|
|
83
111
|
maxCounter,
|
|
84
112
|
maxNodeId,
|
|
113
|
+
NodeId,
|
|
85
114
|
timestampBytesToTimestamp,
|
|
86
115
|
timestampToTimestampBytes,
|
|
87
116
|
} from "./Timestamp.ts";
|
|
@@ -103,6 +132,25 @@ test("protocolVersion", () => {
|
|
|
103
132
|
assertEqual(protocolVersion, 1);
|
|
104
133
|
});
|
|
105
134
|
|
|
135
|
+
test("ProtocolErrorCode", () => {
|
|
136
|
+
assertType<ProtocolErrorCode, 0 | 1 | 2 | 3 | 4>();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("apply options are readonly", () => {
|
|
140
|
+
assertType<
|
|
141
|
+
ApplyProtocolMessageAsClientOptions,
|
|
142
|
+
Readonly<ApplyProtocolMessageAsClientOptions>
|
|
143
|
+
>();
|
|
144
|
+
assertType<
|
|
145
|
+
ApplyProtocolMessageAsRelayOptions,
|
|
146
|
+
Readonly<ApplyProtocolMessageAsRelayOptions>
|
|
147
|
+
>();
|
|
148
|
+
|
|
149
|
+
const options: ApplyProtocolMessageAsClientOptions = {};
|
|
150
|
+
// @ts-expect-error Cannot assign to 'rangesMaxSize' because it is a read-only property.
|
|
151
|
+
options.rangesMaxSize = defaultProtocolMessageRangesMaxSize;
|
|
152
|
+
});
|
|
153
|
+
|
|
106
154
|
test("ProtocolValueType", () => {
|
|
107
155
|
assertEqual(ProtocolValueType, {
|
|
108
156
|
Base64Url: 32,
|
|
@@ -207,6 +255,23 @@ test("encodeSqliteValue/decodeSqliteValue preserves an own __proto__ JSON object
|
|
|
207
255
|
assertEqual(decodeSqliteValue(buffer), value);
|
|
208
256
|
});
|
|
209
257
|
|
|
258
|
+
test("encodeSqliteValue/decodeSqliteValue replaces a lone surrogate in a string with U+FFFD", () => {
|
|
259
|
+
const buffer = createBuffer();
|
|
260
|
+
|
|
261
|
+
encodeSqliteValue(buffer, "a\uD800b");
|
|
262
|
+
|
|
263
|
+
assertEqualBytes(buffer.unwrap(), [20, 5, 97, 0xef, 0xbf, 0xbd, 98]);
|
|
264
|
+
assertSame(decodeSqliteValue(buffer), "a�b");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("encodeSqliteValue/decodeSqliteValue keeps a byte order mark in a string", () => {
|
|
268
|
+
for (const value of ["\uFEFFabc", "\uFEFF", "a\uFEFFb"]) {
|
|
269
|
+
const buffer = createBuffer();
|
|
270
|
+
encodeSqliteValue(buffer, value);
|
|
271
|
+
assertSame(decodeSqliteValue(buffer), value);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
210
275
|
test("encodeSqliteValue encodes JSON nested deeper than decoding allows as a string", () => {
|
|
211
276
|
for (const [depth, type] of [
|
|
212
277
|
[1_000, ProtocolValueType.Json],
|
|
@@ -224,6 +289,54 @@ test("encodeSqliteValue encodes JSON nested deeper than decoding allows as a str
|
|
|
224
289
|
}
|
|
225
290
|
});
|
|
226
291
|
|
|
292
|
+
test("encodeSqliteValue does not retain memory for a large JSON value", () => {
|
|
293
|
+
setFlagsFromString("--expose-gc");
|
|
294
|
+
const gc = runInNewContext("gc") as () => void;
|
|
295
|
+
// Earlier tests in this process may still hold memory, so this measures
|
|
296
|
+
// only what encoding adds.
|
|
297
|
+
gc();
|
|
298
|
+
gc();
|
|
299
|
+
const before = process.memoryUsage().arrayBuffers;
|
|
300
|
+
const buffer = createBuffer();
|
|
301
|
+
|
|
302
|
+
encodeSqliteValue(buffer, JSON.stringify(["x".repeat(3_000_000)]));
|
|
303
|
+
gc();
|
|
304
|
+
gc();
|
|
305
|
+
|
|
306
|
+
const retained =
|
|
307
|
+
process.memoryUsage().arrayBuffers - before - buffer.getCapacity();
|
|
308
|
+
assert(retained < 1_000_000, "Expected JSON encoding memory released.", {
|
|
309
|
+
actual: retained,
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("encoding and decoding errors do not need Error.captureStackTrace", () => {
|
|
314
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
315
|
+
Error,
|
|
316
|
+
"captureStackTrace",
|
|
317
|
+
);
|
|
318
|
+
assertNonNullable(descriptor);
|
|
319
|
+
Reflect.deleteProperty(Error, "captureStackTrace");
|
|
320
|
+
try {
|
|
321
|
+
const value = "[".repeat(1_001) + "]".repeat(1_001);
|
|
322
|
+
const buffer = createBuffer();
|
|
323
|
+
encodeSqliteValue(buffer, value);
|
|
324
|
+
assertEqual(buffer.unwrap()[0], ProtocolValueType.String);
|
|
325
|
+
assertEqual(decodeSqliteValue(buffer), value);
|
|
326
|
+
|
|
327
|
+
const invalidTypeMessage = createBuffer();
|
|
328
|
+
encodeNonNegativeInt(invalidTypeMessage, protocolVersion);
|
|
329
|
+
invalidTypeMessage.extend(ownerIdToOwnerIdBytes(testAppOwner.id));
|
|
330
|
+
invalidTypeMessage.extend([9]);
|
|
331
|
+
const invalidType = parseProtocolHeader(invalidTypeMessage.unwrap());
|
|
332
|
+
assertErr(invalidType);
|
|
333
|
+
assertInstanceOf(invalidType.error.error, Error);
|
|
334
|
+
assertEqual(invalidType.error.error.message, "Invalid MessageType");
|
|
335
|
+
} finally {
|
|
336
|
+
Object.defineProperty(Error, "captureStackTrace", descriptor);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
227
340
|
test("encodeSqliteValue/decodeSqliteValue property tests", () => {
|
|
228
341
|
const deps = testCreateDeps();
|
|
229
342
|
// Property test: round-trip encoding/decoding should preserve the value
|
|
@@ -547,6 +660,69 @@ test("decryptAndDecodeDbChange timestamp tamper-proofing", () => {
|
|
|
547
660
|
);
|
|
548
661
|
});
|
|
549
662
|
|
|
663
|
+
test("decryptAndDecodeDbChange rejects a newer EncryptedDbChange version", () => {
|
|
664
|
+
const deps = testCreateDeps();
|
|
665
|
+
const crdtMessage = createTestCrdtMessage(deps);
|
|
666
|
+
const encryptedMessage = createEncryptedCrdtMessage(deps, crdtMessage);
|
|
667
|
+
|
|
668
|
+
const envelope = createBuffer(encryptedMessage.change);
|
|
669
|
+
const nonce = envelope.shiftN(
|
|
670
|
+
NonNegativeInt.orThrow(xChaCha20Poly1305NonceLength),
|
|
671
|
+
);
|
|
672
|
+
const plaintext = getOrThrow(
|
|
673
|
+
decryptWithXChaCha20Poly1305(
|
|
674
|
+
XChaCha20Poly1305Ciphertext.orThrow(
|
|
675
|
+
envelope.shiftN(decodeLength(envelope)),
|
|
676
|
+
),
|
|
677
|
+
Entropy24.orThrow(nonce),
|
|
678
|
+
testAppOwner.encryptionKey,
|
|
679
|
+
),
|
|
680
|
+
);
|
|
681
|
+
assertEqual(plaintext[0], 1);
|
|
682
|
+
|
|
683
|
+
const reencryptWithVersion = (version: number): EncryptedCrdtMessage => {
|
|
684
|
+
const forged = new Uint8Array(plaintext);
|
|
685
|
+
forged[0] = version;
|
|
686
|
+
const [ciphertext, forgedNonce] = encryptWithXChaCha20Poly1305(deps)(
|
|
687
|
+
forged,
|
|
688
|
+
testAppOwner.encryptionKey,
|
|
689
|
+
);
|
|
690
|
+
const buffer = createBuffer();
|
|
691
|
+
buffer.extend(forgedNonce);
|
|
692
|
+
encodeLength(buffer, ciphertext);
|
|
693
|
+
buffer.extend(ciphertext);
|
|
694
|
+
return {
|
|
695
|
+
timestamp: crdtMessage.timestamp,
|
|
696
|
+
change: buffer.unwrap() as EncryptedDbChange,
|
|
697
|
+
};
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// 6.0.1-preview.35 wrote version 0 with the same layout.
|
|
701
|
+
assertOk(
|
|
702
|
+
decryptAndDecodeDbChange(
|
|
703
|
+
reencryptWithVersion(0),
|
|
704
|
+
testAppOwner.encryptionKey,
|
|
705
|
+
),
|
|
706
|
+
crdtMessage.change,
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const newerVersion = reencryptWithVersion(2);
|
|
710
|
+
const newer = decryptAndDecodeDbChange(
|
|
711
|
+
newerVersion,
|
|
712
|
+
testAppOwner.encryptionKey,
|
|
713
|
+
);
|
|
714
|
+
assertErr(newer);
|
|
715
|
+
assertEqual(newer.error.type, "ProtocolInvalidDataError");
|
|
716
|
+
if (newer.error.type === "ProtocolInvalidDataError") {
|
|
717
|
+
assertSame(newer.error.data, newerVersion.change);
|
|
718
|
+
assertInstanceOf(newer.error.error, Error);
|
|
719
|
+
assertEqual(
|
|
720
|
+
newer.error.error.message,
|
|
721
|
+
"Unsupported EncryptedDbChange version",
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
550
726
|
const shouldNotBeCalled = () => {
|
|
551
727
|
throw new Error("should not be called");
|
|
552
728
|
};
|
|
@@ -559,7 +735,6 @@ const shouldNotBeCalledStorageDep: StorageDep = {
|
|
|
559
735
|
findLowerBound: shouldNotBeCalled,
|
|
560
736
|
iterate: shouldNotBeCalled,
|
|
561
737
|
validateWriteKey: shouldNotBeCalled,
|
|
562
|
-
setWriteKey: shouldNotBeCalled,
|
|
563
738
|
writeMessages: shouldNotBeCalled,
|
|
564
739
|
readDbChange: shouldNotBeCalled,
|
|
565
740
|
deleteOwner: shouldNotBeCalled,
|
|
@@ -594,7 +769,464 @@ test("createTimestampsBuffer maxTimestamp", () => {
|
|
|
594
769
|
assertEqual(buffer.getLength(), 21);
|
|
595
770
|
});
|
|
596
771
|
|
|
772
|
+
const createTestTimestamp = (millis: number) =>
|
|
773
|
+
createTimestamp({ millis: Millis.orThrow(millis) });
|
|
774
|
+
|
|
775
|
+
const setupTimestampsBuffer = (millises: ReadonlyArray<number>) => {
|
|
776
|
+
const timestamps = createTimestampsBuffer();
|
|
777
|
+
for (const millis of millises) timestamps.add(createTestTimestamp(millis));
|
|
778
|
+
return timestamps;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const timestampsBufferToBytes = (timestamps: TimestampsBuffer) => {
|
|
782
|
+
const buffer = createBuffer();
|
|
783
|
+
timestamps.append(buffer);
|
|
784
|
+
return buffer.unwrap();
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
describe("TimestampsBuffer checkpoint", () => {
|
|
788
|
+
it("restores the count across the 127/128 varint boundary", () => {
|
|
789
|
+
const millises = Array.from({ length: 128 }, (_, index) => index);
|
|
790
|
+
const timestamps = setupTimestampsBuffer(millises.slice(0, 127));
|
|
791
|
+
const restore = timestamps.checkpoint();
|
|
792
|
+
timestamps.add(createTestTimestamp(127));
|
|
793
|
+
assertEqualBytes(timestampsBufferToBytes(timestamps).slice(0, 2), [128, 1]);
|
|
794
|
+
|
|
795
|
+
restore();
|
|
796
|
+
assertSame(timestamps.getCount(), 127);
|
|
797
|
+
assertSame(timestampsBufferToBytes(timestamps)[0], 127);
|
|
798
|
+
assertEqualBytes(
|
|
799
|
+
timestampsBufferToBytes(timestamps),
|
|
800
|
+
timestampsBufferToBytes(setupTimestampsBuffer(millises.slice(0, 127))),
|
|
801
|
+
);
|
|
802
|
+
assertSame(
|
|
803
|
+
timestamps.getLength(),
|
|
804
|
+
setupTimestampsBuffer(millises.slice(0, 127)).getLength(),
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
timestamps.add(createTestTimestamp(127));
|
|
808
|
+
assertEqualBytes(
|
|
809
|
+
timestampsBufferToBytes(timestamps),
|
|
810
|
+
timestampsBufferToBytes(setupTimestampsBuffer(millises)),
|
|
811
|
+
);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("restores the previous millis", () => {
|
|
815
|
+
const timestamps = setupTimestampsBuffer([100]);
|
|
816
|
+
const restore = timestamps.checkpoint();
|
|
817
|
+
timestamps.add(createTestTimestamp(500));
|
|
818
|
+
restore();
|
|
819
|
+
timestamps.add(createTestTimestamp(700));
|
|
820
|
+
|
|
821
|
+
// Millis are encoded as deltas after the count.
|
|
822
|
+
const buffer = createBuffer(timestampsBufferToBytes(timestamps));
|
|
823
|
+
assertSame(decodeNonNegativeInt(buffer), 2);
|
|
824
|
+
const first = decodeNonNegativeInt(buffer);
|
|
825
|
+
const second = first + decodeNonNegativeInt(buffer);
|
|
826
|
+
assertEqual([first, second], [100, 700]);
|
|
827
|
+
assertEqualBytes(
|
|
828
|
+
timestampsBufferToBytes(timestamps),
|
|
829
|
+
timestampsBufferToBytes(setupTimestampsBuffer([100, 700])),
|
|
830
|
+
);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("restores infinite entries", () => {
|
|
834
|
+
const timestamps = setupTimestampsBuffer([1]);
|
|
835
|
+
const restoreFinite = timestamps.checkpoint();
|
|
836
|
+
timestamps.addInfinite();
|
|
837
|
+
const restoreInfinite = timestamps.checkpoint();
|
|
838
|
+
timestamps.addInfinite();
|
|
839
|
+
assertSame(timestamps.getCount(), 3);
|
|
840
|
+
|
|
841
|
+
restoreInfinite();
|
|
842
|
+
assertSame(timestamps.getCount(), 2);
|
|
843
|
+
|
|
844
|
+
restoreFinite();
|
|
845
|
+
assertSame(timestamps.getCount(), 1);
|
|
846
|
+
assertEqualBytes(
|
|
847
|
+
timestampsBufferToBytes(timestamps),
|
|
848
|
+
timestampsBufferToBytes(setupTimestampsBuffer([1])),
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it("restores an empty buffer", () => {
|
|
853
|
+
const timestamps = createTimestampsBuffer();
|
|
854
|
+
const restore = timestamps.checkpoint();
|
|
855
|
+
timestamps.add(
|
|
856
|
+
createTimestamp({
|
|
857
|
+
millis: Millis.orThrow(5),
|
|
858
|
+
counter: maxCounter,
|
|
859
|
+
nodeId: maxNodeId,
|
|
860
|
+
}),
|
|
861
|
+
);
|
|
862
|
+
timestamps.addInfinite();
|
|
863
|
+
|
|
864
|
+
restore();
|
|
865
|
+
assertSame(timestamps.getCount(), 0);
|
|
866
|
+
assertEqualBytes(timestampsBufferToBytes(timestamps), [0]);
|
|
867
|
+
|
|
868
|
+
timestamps.add(createTestTimestamp(7));
|
|
869
|
+
assertEqualBytes(
|
|
870
|
+
timestampsBufferToBytes(timestamps),
|
|
871
|
+
timestampsBufferToBytes(setupTimestampsBuffer([7])),
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
describe("ProtocolMessageBuffer tryWrite", () => {
|
|
877
|
+
const setupRequestBuffer = ({
|
|
878
|
+
rangesMaxSize,
|
|
879
|
+
}: {
|
|
880
|
+
rangesMaxSize?: ProtocolMessageRangesMaxSize;
|
|
881
|
+
} = {}) =>
|
|
882
|
+
createProtocolMessageBuffer(testAppOwner.id, {
|
|
883
|
+
messageType: MessageType.Request,
|
|
884
|
+
rangesMaxSize,
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
const createTestMessage = (
|
|
888
|
+
millis: number,
|
|
889
|
+
changeLength: number,
|
|
890
|
+
): EncryptedCrdtMessage => ({
|
|
891
|
+
timestamp: createTimestamp({
|
|
892
|
+
millis: Millis.orThrow(millis),
|
|
893
|
+
counter: maxCounter,
|
|
894
|
+
nodeId: maxNodeId,
|
|
895
|
+
}),
|
|
896
|
+
change: new Uint8Array(changeLength).fill(millis) as EncryptedDbChange,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
const createTestBound = (millis: number) =>
|
|
900
|
+
timestampToTimestampBytes(createTestTimestamp(millis));
|
|
901
|
+
|
|
902
|
+
const closingRange: FingerprintRange = {
|
|
903
|
+
type: RangeType.Fingerprint,
|
|
904
|
+
upperBound: InfiniteUpperBound,
|
|
905
|
+
fingerprint: zeroFingerprint,
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
// The first range adds the ranges count, and Fingerprint(InfiniteUpperBound)
|
|
909
|
+
// adds its type and fingerprint without an upper bound.
|
|
910
|
+
const firstClosingRangeSize = 1 + 1 + 12;
|
|
911
|
+
|
|
912
|
+
const emptyRequestLength = setupRequestBuffer().unwrap().length;
|
|
913
|
+
|
|
914
|
+
it("keeps a write that fits the total budget exactly", () => {
|
|
915
|
+
// Changes near the limit and this probe have a 3-byte length varint.
|
|
916
|
+
const probe = setupRequestBuffer();
|
|
917
|
+
probe.addMessage(createTestMessage(1, 100_000));
|
|
918
|
+
const messageOverhead = probe.unwrap().length - 100_000;
|
|
919
|
+
|
|
920
|
+
for (const reserve of [0, 5]) {
|
|
921
|
+
const fittingLength =
|
|
922
|
+
defaultProtocolMessageMaxSize -
|
|
923
|
+
messageOverhead -
|
|
924
|
+
firstClosingRangeSize -
|
|
925
|
+
reserve;
|
|
926
|
+
|
|
927
|
+
const fits = setupRequestBuffer();
|
|
928
|
+
assertTrue(
|
|
929
|
+
fits.tryWrite(() => {
|
|
930
|
+
fits.addMessage(createTestMessage(1, fittingLength));
|
|
931
|
+
}, NonNegativeInt.orThrow(reserve)),
|
|
932
|
+
);
|
|
933
|
+
fits.addRange(closingRange);
|
|
934
|
+
assertSame(fits.getSize(), defaultProtocolMessageMaxSize - reserve);
|
|
935
|
+
assertSame(fits.unwrap().length, defaultProtocolMessageMaxSize - reserve);
|
|
936
|
+
|
|
937
|
+
const over = setupRequestBuffer();
|
|
938
|
+
assertFalse(
|
|
939
|
+
over.tryWrite(() => {
|
|
940
|
+
over.addMessage(createTestMessage(1, fittingLength + 1));
|
|
941
|
+
}, NonNegativeInt.orThrow(reserve)),
|
|
942
|
+
);
|
|
943
|
+
assertEqualBytes(over.unwrap(), setupRequestBuffer().unwrap());
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it("keeps a write that fits the ranges budget exactly", () => {
|
|
948
|
+
const addLargeRange = (buffer: ProtocolMessageBuffer) => () => {
|
|
949
|
+
// 3-byte millis deltas make the range a little over 3,000 bytes.
|
|
950
|
+
const timestamps = createTimestampsBuffer();
|
|
951
|
+
for (let index = 1; index <= 1_000; index++) {
|
|
952
|
+
timestamps.add(createTestTimestamp(index * 100_000));
|
|
953
|
+
}
|
|
954
|
+
buffer.addRange({
|
|
955
|
+
type: RangeType.Timestamps,
|
|
956
|
+
upperBound: createTestBound(200_000_000),
|
|
957
|
+
timestamps,
|
|
958
|
+
});
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
const probe = setupRequestBuffer();
|
|
962
|
+
addLargeRange(probe)();
|
|
963
|
+
probe.addRange(closingRange);
|
|
964
|
+
const closedRangesSize = probe.unwrap().length - emptyRequestLength;
|
|
965
|
+
|
|
966
|
+
for (const reserve of [0, 5]) {
|
|
967
|
+
const fits = setupRequestBuffer({
|
|
968
|
+
rangesMaxSize: ProtocolMessageRangesMaxSize.orThrow(
|
|
969
|
+
closedRangesSize + reserve,
|
|
970
|
+
),
|
|
971
|
+
});
|
|
972
|
+
assertTrue(
|
|
973
|
+
fits.tryWrite(addLargeRange(fits), NonNegativeInt.orThrow(reserve)),
|
|
974
|
+
);
|
|
975
|
+
fits.addRange(closingRange);
|
|
976
|
+
assertSame(fits.unwrap().length - emptyRequestLength, closedRangesSize);
|
|
977
|
+
|
|
978
|
+
const over = setupRequestBuffer({
|
|
979
|
+
rangesMaxSize: ProtocolMessageRangesMaxSize.orThrow(
|
|
980
|
+
closedRangesSize + reserve - 1,
|
|
981
|
+
),
|
|
982
|
+
});
|
|
983
|
+
assertFalse(
|
|
984
|
+
over.tryWrite(addLargeRange(over), NonNegativeInt.orThrow(reserve)),
|
|
985
|
+
);
|
|
986
|
+
assertEqualBytes(over.unwrap(), setupRequestBuffer().unwrap());
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it("reserves exactly the bytes of the closing range", () => {
|
|
991
|
+
// Closing adds a range type and a fingerprint, and a byte to the ranges
|
|
992
|
+
// count varint when the count becomes 1 or 128.
|
|
993
|
+
for (const [rangesCount, closingRangeSize] of [
|
|
994
|
+
[0, 14],
|
|
995
|
+
[1, 13],
|
|
996
|
+
[127, 14],
|
|
997
|
+
[128, 13],
|
|
998
|
+
] as const) {
|
|
999
|
+
const setupBuffer = () => {
|
|
1000
|
+
const buffer = setupRequestBuffer();
|
|
1001
|
+
for (let index = 1; index <= rangesCount; index++) {
|
|
1002
|
+
buffer.addRange({
|
|
1003
|
+
type: RangeType.Skip,
|
|
1004
|
+
upperBound: createTestBound(index),
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
return buffer;
|
|
1008
|
+
};
|
|
1009
|
+
const probe = setupBuffer();
|
|
1010
|
+
probe.addMessage(createTestMessage(1, 100_000));
|
|
1011
|
+
const fittingLength =
|
|
1012
|
+
defaultProtocolMessageMaxSize -
|
|
1013
|
+
(probe.getSize() - 100_000) -
|
|
1014
|
+
closingRangeSize;
|
|
1015
|
+
|
|
1016
|
+
const fits = setupBuffer();
|
|
1017
|
+
assertTrue(
|
|
1018
|
+
fits.tryWrite(() => {
|
|
1019
|
+
fits.addMessage(createTestMessage(1, fittingLength));
|
|
1020
|
+
}),
|
|
1021
|
+
);
|
|
1022
|
+
fits.addRange(closingRange);
|
|
1023
|
+
assertSame(fits.unwrap().length, defaultProtocolMessageMaxSize);
|
|
1024
|
+
|
|
1025
|
+
const over = setupBuffer();
|
|
1026
|
+
assertFalse(
|
|
1027
|
+
over.tryWrite(() => {
|
|
1028
|
+
over.addMessage(createTestMessage(1, fittingLength + 1));
|
|
1029
|
+
}),
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("reserves nothing once an infinite range closed the frame", () => {
|
|
1035
|
+
const probe = setupRequestBuffer();
|
|
1036
|
+
probe.addRange(closingRange);
|
|
1037
|
+
probe.addMessage(createTestMessage(1, 100_000));
|
|
1038
|
+
const fittingLength =
|
|
1039
|
+
defaultProtocolMessageMaxSize - (probe.getSize() - 100_000);
|
|
1040
|
+
|
|
1041
|
+
const fits = setupRequestBuffer();
|
|
1042
|
+
fits.addRange(closingRange);
|
|
1043
|
+
assertTrue(
|
|
1044
|
+
fits.tryWrite(() => {
|
|
1045
|
+
fits.addMessage(createTestMessage(1, fittingLength));
|
|
1046
|
+
}),
|
|
1047
|
+
);
|
|
1048
|
+
assertSame(fits.unwrap().length, defaultProtocolMessageMaxSize);
|
|
1049
|
+
|
|
1050
|
+
const over = setupRequestBuffer();
|
|
1051
|
+
over.addRange(closingRange);
|
|
1052
|
+
assertFalse(
|
|
1053
|
+
over.tryWrite(() => {
|
|
1054
|
+
over.addMessage(createTestMessage(1, fittingLength + 1));
|
|
1055
|
+
}),
|
|
1056
|
+
);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it("leaves no trace of a rejected write", () => {
|
|
1060
|
+
const setupBuffer = () => {
|
|
1061
|
+
const buffer = setupRequestBuffer();
|
|
1062
|
+
buffer.addMessage(createTestMessage(1, 10));
|
|
1063
|
+
buffer.addMessage(createTestMessage(2, 10));
|
|
1064
|
+
buffer.addRange({
|
|
1065
|
+
type: RangeType.Skip,
|
|
1066
|
+
upperBound: createTestBound(10),
|
|
1067
|
+
});
|
|
1068
|
+
buffer.addRange({
|
|
1069
|
+
type: RangeType.Fingerprint,
|
|
1070
|
+
upperBound: createTestBound(20),
|
|
1071
|
+
fingerprint: zeroFingerprint,
|
|
1072
|
+
});
|
|
1073
|
+
return buffer;
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
const untouched = setupBuffer();
|
|
1077
|
+
const rejected = setupBuffer();
|
|
1078
|
+
assertFalse(
|
|
1079
|
+
rejected.tryWrite(() => {
|
|
1080
|
+
// The same counter and NodeId rewrite the runs in place.
|
|
1081
|
+
rejected.addMessage(createTestMessage(3, 10));
|
|
1082
|
+
rejected.addRange({
|
|
1083
|
+
type: RangeType.Fingerprint,
|
|
1084
|
+
upperBound: createTestBound(30),
|
|
1085
|
+
fingerprint: zeroFingerprint,
|
|
1086
|
+
});
|
|
1087
|
+
rejected.addMessage(
|
|
1088
|
+
createTestMessage(4, defaultProtocolMessageMaxSize),
|
|
1089
|
+
);
|
|
1090
|
+
}),
|
|
1091
|
+
);
|
|
1092
|
+
assertSame(rejected.getSize(), untouched.getSize());
|
|
1093
|
+
|
|
1094
|
+
for (const buffer of [untouched, rejected]) {
|
|
1095
|
+
buffer.addMessage(createTestMessage(5, 10));
|
|
1096
|
+
buffer.addRange({
|
|
1097
|
+
type: RangeType.Timestamps,
|
|
1098
|
+
upperBound: createTestBound(40),
|
|
1099
|
+
timestamps: setupTimestampsBuffer([25, 35]),
|
|
1100
|
+
});
|
|
1101
|
+
buffer.addRange(closingRange);
|
|
1102
|
+
}
|
|
1103
|
+
assertEqualBytes(rejected.unwrap(), untouched.unwrap());
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("asserts the size limit only outside a trial", () => {
|
|
1107
|
+
const buffer = setupRequestBuffer();
|
|
1108
|
+
const tooLarge = createTestMessage(2, defaultProtocolMessageMaxSize);
|
|
1109
|
+
assertFalse(
|
|
1110
|
+
buffer.tryWrite(() => {
|
|
1111
|
+
assertFalse(
|
|
1112
|
+
buffer.tryWrite(() => {
|
|
1113
|
+
buffer.addMessage(tooLarge);
|
|
1114
|
+
}),
|
|
1115
|
+
);
|
|
1116
|
+
// The outer trial is still running.
|
|
1117
|
+
buffer.addMessage(tooLarge);
|
|
1118
|
+
}),
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
const error = assertThrowsInstanceOf(() => {
|
|
1122
|
+
buffer.addMessage(tooLarge);
|
|
1123
|
+
}, Error);
|
|
1124
|
+
assertSame(error.message, "the message is too big");
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it("undoes a write that throws", () => {
|
|
1128
|
+
const failure = new Error("storage unavailable");
|
|
1129
|
+
const buffer = setupRequestBuffer();
|
|
1130
|
+
assertSame(
|
|
1131
|
+
assertThrowsInstanceOf(
|
|
1132
|
+
() =>
|
|
1133
|
+
buffer.tryWrite(() => {
|
|
1134
|
+
buffer.addMessage(createTestMessage(1, 10));
|
|
1135
|
+
buffer.addRange(closingRange);
|
|
1136
|
+
throw failure;
|
|
1137
|
+
}),
|
|
1138
|
+
Error,
|
|
1139
|
+
),
|
|
1140
|
+
failure,
|
|
1141
|
+
);
|
|
1142
|
+
|
|
1143
|
+
buffer.addRange(closingRange);
|
|
1144
|
+
const expected = setupRequestBuffer();
|
|
1145
|
+
expected.addRange(closingRange);
|
|
1146
|
+
assertEqualBytes(buffer.unwrap(), expected.unwrap());
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("keeps or undoes a compound write as a whole", () => {
|
|
1150
|
+
const addRanges = (buffer: ProtocolMessageBuffer) => () => {
|
|
1151
|
+
buffer.addMessage(createTestMessage(1, 10));
|
|
1152
|
+
// A coalesced skip followed by the ranges of a split.
|
|
1153
|
+
buffer.addRange({
|
|
1154
|
+
type: RangeType.Skip,
|
|
1155
|
+
upperBound: createTestBound(10),
|
|
1156
|
+
});
|
|
1157
|
+
buffer.addRange({
|
|
1158
|
+
type: RangeType.Fingerprint,
|
|
1159
|
+
upperBound: createTestBound(20),
|
|
1160
|
+
fingerprint: zeroFingerprint,
|
|
1161
|
+
});
|
|
1162
|
+
const timestamps = createTimestampsBuffer();
|
|
1163
|
+
for (let index = 1; index <= 1_000; index++) {
|
|
1164
|
+
timestamps.add(createTestTimestamp(20 + index * 100_000));
|
|
1165
|
+
}
|
|
1166
|
+
buffer.addRange({
|
|
1167
|
+
type: RangeType.Timestamps,
|
|
1168
|
+
upperBound: createTestBound(200_000_000),
|
|
1169
|
+
timestamps,
|
|
1170
|
+
});
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
const expectedKept = setupRequestBuffer();
|
|
1174
|
+
addRanges(expectedKept)();
|
|
1175
|
+
expectedKept.addRange(closingRange);
|
|
1176
|
+
|
|
1177
|
+
const kept = setupRequestBuffer({
|
|
1178
|
+
rangesMaxSize: ProtocolMessageRangesMaxSize.orThrow(100_000),
|
|
1179
|
+
});
|
|
1180
|
+
assertTrue(kept.tryWrite(addRanges(kept)));
|
|
1181
|
+
kept.addRange(closingRange);
|
|
1182
|
+
assertEqualBytes(kept.unwrap(), expectedKept.unwrap());
|
|
1183
|
+
|
|
1184
|
+
const expectedUndone = setupRequestBuffer();
|
|
1185
|
+
expectedUndone.addRange(closingRange);
|
|
1186
|
+
|
|
1187
|
+
const undone = setupRequestBuffer({
|
|
1188
|
+
rangesMaxSize: ProtocolMessageRangesMaxSize.orThrow(3_000),
|
|
1189
|
+
});
|
|
1190
|
+
assertFalse(undone.tryWrite(addRanges(undone)));
|
|
1191
|
+
undone.addRange(closingRange);
|
|
1192
|
+
// Neither the ranges nor the message remain.
|
|
1193
|
+
assertEqualBytes(undone.unwrap(), expectedUndone.unwrap());
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
|
|
597
1197
|
describe("createProtocolMessageBuffer", () => {
|
|
1198
|
+
it("unwraps repeatedly without changing the builder", () => {
|
|
1199
|
+
const deps = testCreateDeps();
|
|
1200
|
+
const setupBuffer = () =>
|
|
1201
|
+
createProtocolMessageBuffer(testAppOwner.id, {
|
|
1202
|
+
messageType: MessageType.Request,
|
|
1203
|
+
writeKey: testAppOwner.writeKey,
|
|
1204
|
+
});
|
|
1205
|
+
const first = createEncryptedCrdtMessage(deps, createTestCrdtMessage(deps));
|
|
1206
|
+
const second = createEncryptedCrdtMessage(
|
|
1207
|
+
deps,
|
|
1208
|
+
createTestCrdtMessage(deps),
|
|
1209
|
+
);
|
|
1210
|
+
const range: FingerprintRange = {
|
|
1211
|
+
type: RangeType.Fingerprint,
|
|
1212
|
+
upperBound: InfiniteUpperBound,
|
|
1213
|
+
fingerprint: zeroFingerprint,
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
const buffer = setupBuffer();
|
|
1217
|
+
buffer.addMessage(first);
|
|
1218
|
+
const message = buffer.unwrap();
|
|
1219
|
+
assertEqualBytes(buffer.unwrap(), message);
|
|
1220
|
+
|
|
1221
|
+
buffer.addMessage(second);
|
|
1222
|
+
buffer.addRange(range);
|
|
1223
|
+
const expected = setupBuffer();
|
|
1224
|
+
expected.addMessage(first);
|
|
1225
|
+
expected.addMessage(second);
|
|
1226
|
+
expected.addRange(range);
|
|
1227
|
+
assertEqualBytes(buffer.unwrap(), expected.unwrap());
|
|
1228
|
+
});
|
|
1229
|
+
|
|
598
1230
|
it("should allow no ranges", () => {
|
|
599
1231
|
const buffer = createProtocolMessageBuffer(testAppOwner.id, {
|
|
600
1232
|
messageType: MessageType.Request,
|
|
@@ -888,56 +1520,266 @@ describe("E2E versioning", () => {
|
|
|
888
1520
|
});
|
|
889
1521
|
});
|
|
890
1522
|
|
|
1523
|
+
/**
|
|
1524
|
+
* Applies `message` as a relay and returns the message of the error that
|
|
1525
|
+
* rejected it as `ProtocolInvalidDataError`.
|
|
1526
|
+
*/
|
|
1527
|
+
const getRelayDecodeErrorMessage = async (
|
|
1528
|
+
message: Uint8Array,
|
|
1529
|
+
storage: StorageDep["storage"] = shouldNotBeCalledStorageDep.storage,
|
|
1530
|
+
): Promise<string> => {
|
|
1531
|
+
await using run = testCreateRun({ storage });
|
|
1532
|
+
const result = await run(applyProtocolMessageAsRelay(message));
|
|
1533
|
+
assertErr(result);
|
|
1534
|
+
assertSame(result.error.type, "ProtocolInvalidDataError");
|
|
1535
|
+
assertInstanceOf(result.error.error, Error);
|
|
1536
|
+
return result.error.error.message;
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
/** Returns a Buffer holding a Request header with the given flag bytes. */
|
|
1540
|
+
const setupRequestHeader = (hasWriteKey = 0, subscriptionFlag = 0) => {
|
|
1541
|
+
const buffer = createBuffer();
|
|
1542
|
+
encodeNonNegativeInt(buffer, protocolVersion);
|
|
1543
|
+
buffer.extend(ownerIdToOwnerIdBytes(testAppOwner.id));
|
|
1544
|
+
buffer.extend([MessageType.Request, hasWriteKey, subscriptionFlag]);
|
|
1545
|
+
return buffer;
|
|
1546
|
+
};
|
|
1547
|
+
|
|
891
1548
|
describe("E2E errors", () => {
|
|
892
|
-
it("
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1549
|
+
it("rejects a ranges section larger than any conforming sender produces", async () => {
|
|
1550
|
+
const request = createProtocolMessageBuffer(testAppOwner.id, {
|
|
1551
|
+
messageType: MessageType.Request,
|
|
1552
|
+
});
|
|
1553
|
+
const upperBound = timestampToTimestampBytes(createTestTimestamp(1));
|
|
1554
|
+
// Equal bounds take about 2 bytes per range.
|
|
1555
|
+
for (let i = 0; i < 100_500; i++)
|
|
1556
|
+
request.addRange({ type: RangeType.Skip, upperBound });
|
|
1557
|
+
request.addRange({
|
|
1558
|
+
type: RangeType.Fingerprint,
|
|
1559
|
+
upperBound: InfiniteUpperBound,
|
|
1560
|
+
fingerprint: zeroFingerprint,
|
|
1561
|
+
});
|
|
897
1562
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}),
|
|
1563
|
+
assertSame(
|
|
1564
|
+
await getRelayDecodeErrorMessage(request.unwrap()),
|
|
1565
|
+
"Ranges section exceeds 200000 bytes",
|
|
902
1566
|
);
|
|
1567
|
+
});
|
|
903
1568
|
|
|
904
|
-
|
|
905
|
-
|
|
1569
|
+
it("rejects a ranges count larger than the remaining bytes", async () => {
|
|
1570
|
+
const request = setupRequestHeader();
|
|
1571
|
+
request.extend([0]);
|
|
1572
|
+
encodeNonNegativeInt(request, NonNegativeInt.orThrow(1e9));
|
|
1573
|
+
request.extend(new Uint8Array(1000));
|
|
1574
|
+
|
|
1575
|
+
// Every range but the last has an upper bound timestamp.
|
|
1576
|
+
assertSame(
|
|
1577
|
+
await getRelayDecodeErrorMessage(request.unwrap()),
|
|
1578
|
+
"Invalid timestamps count",
|
|
1579
|
+
);
|
|
906
1580
|
});
|
|
907
1581
|
|
|
908
|
-
it("
|
|
909
|
-
const
|
|
910
|
-
const timestamp = timestampBytesToTimestamp(testTimestampsAsc[0]);
|
|
911
|
-
const dbChange = createDbChange(deps);
|
|
1582
|
+
it("rejects a timestamps count larger than the remaining bytes", async () => {
|
|
1583
|
+
const count = NonNegativeInt.orThrow(1e9);
|
|
912
1584
|
|
|
913
|
-
const messages
|
|
914
|
-
|
|
915
|
-
|
|
1585
|
+
const messages = setupRequestHeader();
|
|
1586
|
+
encodeNonNegativeInt(messages, count);
|
|
1587
|
+
messages.extend(new Uint8Array(1000));
|
|
916
1588
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
);
|
|
1589
|
+
// One range with InfiniteUpperBound listing its timestamps.
|
|
1590
|
+
const nested = setupRequestHeader();
|
|
1591
|
+
nested.extend([0, 1, RangeType.Timestamps]);
|
|
1592
|
+
encodeNonNegativeInt(nested, count);
|
|
1593
|
+
nested.extend(new Uint8Array(1000));
|
|
921
1594
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
...shouldNotBeCalledStorageDep.storage,
|
|
927
|
-
validateWriteKey: constFalse,
|
|
928
|
-
},
|
|
929
|
-
});
|
|
930
|
-
const response = await run.orThrow(
|
|
931
|
-
applyProtocolMessageAsRelay(initiatorMessage),
|
|
932
|
-
);
|
|
933
|
-
assertEqualBytes(
|
|
934
|
-
response.message,
|
|
935
|
-
[
|
|
936
|
-
1, 5, 39, 254, 242, 108, 77, 142, 9, 59, 219, 32, 254, 15, 186, 235,
|
|
937
|
-
212, 1, 1, 0,
|
|
938
|
-
],
|
|
1595
|
+
for (const request of [messages, nested])
|
|
1596
|
+
assertSame(
|
|
1597
|
+
await getRelayDecodeErrorMessage(request.unwrap()),
|
|
1598
|
+
"Invalid timestamps count",
|
|
939
1599
|
);
|
|
940
|
-
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
it("rejects descending range upper bounds before touching storage", async () => {
|
|
1603
|
+
const createBound = (counter: number, nodeId: string) =>
|
|
1604
|
+
timestampToTimestampBytes(
|
|
1605
|
+
createTimestamp({
|
|
1606
|
+
millis: Millis.orThrow(1500),
|
|
1607
|
+
counter: Counter.orThrow(counter),
|
|
1608
|
+
nodeId: NodeId.orThrow(nodeId),
|
|
1609
|
+
}),
|
|
1610
|
+
);
|
|
1611
|
+
|
|
1612
|
+
const descendingCounters = createProtocolMessageBuffer(testAppOwner.id, {
|
|
1613
|
+
messageType: MessageType.Request,
|
|
1614
|
+
});
|
|
1615
|
+
for (const counter of [2, 1, 0])
|
|
1616
|
+
descendingCounters.addRange({
|
|
1617
|
+
type: RangeType.Fingerprint,
|
|
1618
|
+
upperBound: createBound(counter, "0000000000000000"),
|
|
1619
|
+
fingerprint: zeroFingerprint,
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
const descendingNodeIds = createProtocolMessageBuffer(testAppOwner.id, {
|
|
1623
|
+
messageType: MessageType.Request,
|
|
1624
|
+
});
|
|
1625
|
+
for (const nodeId of ["0000000000000002", "0000000000000001"])
|
|
1626
|
+
descendingNodeIds.addRange({
|
|
1627
|
+
type: RangeType.Skip,
|
|
1628
|
+
upperBound: createBound(0, nodeId),
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
for (const request of [descendingCounters, descendingNodeIds]) {
|
|
1632
|
+
request.addRange({
|
|
1633
|
+
type: RangeType.Fingerprint,
|
|
1634
|
+
upperBound: InfiniteUpperBound,
|
|
1635
|
+
fingerprint: zeroFingerprint,
|
|
1636
|
+
});
|
|
1637
|
+
assertSame(
|
|
1638
|
+
await getRelayDecodeErrorMessage(request.unwrap()),
|
|
1639
|
+
"Range upper bounds must be non-decreasing",
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it("relay rejects a change shorter than the encryption envelope", async () => {
|
|
1645
|
+
const createRequestWithChange = (length: number) => {
|
|
1646
|
+
const request = createProtocolMessageBuffer(testAppOwner.id, {
|
|
1647
|
+
messageType: MessageType.Request,
|
|
1648
|
+
writeKey: testAppOwner.writeKey,
|
|
1649
|
+
});
|
|
1650
|
+
request.addMessage({
|
|
1651
|
+
timestamp: createTestTimestamp(1),
|
|
1652
|
+
change: new Uint8Array(length) as EncryptedDbChange,
|
|
1653
|
+
});
|
|
1654
|
+
return request.unwrap();
|
|
1655
|
+
};
|
|
1656
|
+
const storage: StorageDep["storage"] = {
|
|
1657
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
1658
|
+
validateWriteKey: constTrue,
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1661
|
+
for (const length of [0, 40])
|
|
1662
|
+
assertSame(
|
|
1663
|
+
await getRelayDecodeErrorMessage(
|
|
1664
|
+
createRequestWithChange(length),
|
|
1665
|
+
storage,
|
|
1666
|
+
),
|
|
1667
|
+
"EncryptedDbChange is too short",
|
|
1668
|
+
);
|
|
1669
|
+
|
|
1670
|
+
// A 24-byte nonce, a 1-byte length, and a 16-byte tag.
|
|
1671
|
+
const writtenLengths: Array<number> = [];
|
|
1672
|
+
await using run = testCreateRun({
|
|
1673
|
+
storage: {
|
|
1674
|
+
...storage,
|
|
1675
|
+
writeMessages: (_ownerId, messages) => () => {
|
|
1676
|
+
for (const { change } of messages) writtenLengths.push(change.length);
|
|
1677
|
+
return ok();
|
|
1678
|
+
},
|
|
1679
|
+
},
|
|
1680
|
+
} satisfies StorageDep);
|
|
1681
|
+
assertOk(
|
|
1682
|
+
await run(applyProtocolMessageAsRelay(createRequestWithChange(41))),
|
|
1683
|
+
);
|
|
1684
|
+
assertEqual(writtenLengths, [41]);
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
it("client passes a change shorter than the encryption envelope to storage", async () => {
|
|
1688
|
+
// Deployed relays store such changes, and client storage skips a change
|
|
1689
|
+
// it cannot read instead of rejecting the response.
|
|
1690
|
+
const response = createProtocolMessageBuffer(testAppOwner.id, {
|
|
1691
|
+
messageType: MessageType.Response,
|
|
1692
|
+
errorCode: ProtocolErrorCode.NoError,
|
|
1693
|
+
});
|
|
1694
|
+
response.addMessage({
|
|
1695
|
+
timestamp: createTestTimestamp(1),
|
|
1696
|
+
change: new Uint8Array(0) as EncryptedDbChange,
|
|
1697
|
+
});
|
|
1698
|
+
const writtenLengths: Array<number> = [];
|
|
1699
|
+
await using run = testCreateRun({
|
|
1700
|
+
storage: {
|
|
1701
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
1702
|
+
writeMessages: (_ownerId, messages) => () => {
|
|
1703
|
+
for (const { change } of messages) writtenLengths.push(change.length);
|
|
1704
|
+
return ok();
|
|
1705
|
+
},
|
|
1706
|
+
},
|
|
1707
|
+
} satisfies StorageDep);
|
|
1708
|
+
|
|
1709
|
+
assertOk(await run(applyProtocolMessageAsClient(response.unwrap())), {
|
|
1710
|
+
type: "Readonly",
|
|
1711
|
+
});
|
|
1712
|
+
assertEqual(writtenLengths, [0]);
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
it("ProtocolInvalidDataError", async () => {
|
|
1716
|
+
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
1717
|
+
const malformedMessage = createBuffer();
|
|
1718
|
+
// Only version, no ownerId
|
|
1719
|
+
encodeNonNegativeInt(malformedMessage, 1 as NonNegativeInt);
|
|
1720
|
+
|
|
1721
|
+
const clientResult = await run(
|
|
1722
|
+
applyProtocolMessageAsClient(malformedMessage.unwrap(), {
|
|
1723
|
+
version: 0 as NonNegativeInt,
|
|
1724
|
+
}),
|
|
1725
|
+
);
|
|
1726
|
+
|
|
1727
|
+
assertFalse(clientResult.ok);
|
|
1728
|
+
assertEqual(clientResult.error.type, "ProtocolInvalidDataError");
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
// Every released client does this, so it is what a relay adding a code under
|
|
1732
|
+
// protocol version 1 gets.
|
|
1733
|
+
it("reports an unknown ProtocolErrorCode as ProtocolInvalidDataError", async () => {
|
|
1734
|
+
const response = createBuffer();
|
|
1735
|
+
encodeNonNegativeInt(response, protocolVersion);
|
|
1736
|
+
response.extend(ownerIdToOwnerIdBytes(testAppOwner.id));
|
|
1737
|
+
response.extend([MessageType.Response, 5]);
|
|
1738
|
+
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
1739
|
+
|
|
1740
|
+
const result = await run(applyProtocolMessageAsClient(response.unwrap()));
|
|
1741
|
+
|
|
1742
|
+
assertErr(result);
|
|
1743
|
+
const { error } = result;
|
|
1744
|
+
assert(error.type === "ProtocolInvalidDataError", "Expected invalid data");
|
|
1745
|
+
assertFalse("ownerId" in error);
|
|
1746
|
+
assertInstanceOf(error.error, Error);
|
|
1747
|
+
assertSame(error.error.message, "Invalid ProtocolErrorCode: 5");
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
it("ProtocolWriteKeyError", async () => {
|
|
1751
|
+
const deps = testCreateDeps();
|
|
1752
|
+
const timestamp = timestampBytesToTimestamp(testTimestampsAsc[0]);
|
|
1753
|
+
const dbChange = createDbChange(deps);
|
|
1754
|
+
|
|
1755
|
+
const messages: NonEmptyReadonlyArray<CrdtMessage> = [
|
|
1756
|
+
{ timestamp, change: dbChange },
|
|
1757
|
+
];
|
|
1758
|
+
|
|
1759
|
+
const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)(
|
|
1760
|
+
testAppOwner,
|
|
1761
|
+
messages,
|
|
1762
|
+
);
|
|
1763
|
+
|
|
1764
|
+
let responseMessage: Uint8Array;
|
|
1765
|
+
{
|
|
1766
|
+
await using run = testCreateRun({
|
|
1767
|
+
storage: {
|
|
1768
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
1769
|
+
validateWriteKey: constFalse,
|
|
1770
|
+
},
|
|
1771
|
+
});
|
|
1772
|
+
const response = await run.orThrow(
|
|
1773
|
+
applyProtocolMessageAsRelay(initiatorMessage),
|
|
1774
|
+
);
|
|
1775
|
+
assertEqualBytes(
|
|
1776
|
+
response.message,
|
|
1777
|
+
[
|
|
1778
|
+
1, 5, 39, 254, 242, 108, 77, 142, 9, 59, 219, 32, 254, 15, 186, 235,
|
|
1779
|
+
212, 1, 1, 0,
|
|
1780
|
+
],
|
|
1781
|
+
);
|
|
1782
|
+
responseMessage = response.message;
|
|
941
1783
|
}
|
|
942
1784
|
|
|
943
1785
|
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
@@ -950,7 +1792,7 @@ describe("E2E errors", () => {
|
|
|
950
1792
|
);
|
|
951
1793
|
});
|
|
952
1794
|
|
|
953
|
-
it("reports a rejected relay write as a quota and a
|
|
1795
|
+
it("reports a rejected relay write as a quota and a failed one as a write failure", async () => {
|
|
954
1796
|
const deps = testCreateDeps();
|
|
955
1797
|
const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)(
|
|
956
1798
|
testAppOwner,
|
|
@@ -1003,10 +1845,51 @@ describe("E2E errors", () => {
|
|
|
1003
1845
|
["error"],
|
|
1004
1846
|
);
|
|
1005
1847
|
assertSame(thrown.logged[0]?.args[0], failure);
|
|
1848
|
+
// So is a write SQLite fails, such as on a full disk, which the storage
|
|
1849
|
+
// returns.
|
|
1850
|
+
const unknownError = createUnknownError(new Error("disk full"));
|
|
1851
|
+
const failed = await relayResponseFor(() => () => err(unknownError));
|
|
1852
|
+
assertEqual(
|
|
1853
|
+
await run(applyProtocolMessageAsClient(failed.message)),
|
|
1854
|
+
err({ type: "ProtocolWriteError", ownerId: testAppOwner.id }),
|
|
1855
|
+
);
|
|
1856
|
+
assertEqual(
|
|
1857
|
+
failed.logged.map(({ method }) => method),
|
|
1858
|
+
["error"],
|
|
1859
|
+
);
|
|
1860
|
+
assertSame(failed.logged[0]?.args[0], unknownError);
|
|
1006
1861
|
});
|
|
1007
1862
|
});
|
|
1008
1863
|
|
|
1009
1864
|
describe("E2E relay options", () => {
|
|
1865
|
+
it("rejects unknown request flags before any side effect", async () => {
|
|
1866
|
+
for (const [hasWriteKey, subscriptionFlag, errorMessage] of [
|
|
1867
|
+
[2, SubscriptionFlags.Subscribe, "Invalid hasWriteKey: 2"],
|
|
1868
|
+
[0, 7, "Invalid SubscriptionFlag: 7"],
|
|
1869
|
+
] as const) {
|
|
1870
|
+
const request = setupRequestHeader(hasWriteKey, subscriptionFlag);
|
|
1871
|
+
request.extend([0]);
|
|
1872
|
+
const subscriptions: Array<string> = [];
|
|
1873
|
+
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
1874
|
+
|
|
1875
|
+
const result = await run(
|
|
1876
|
+
applyProtocolMessageAsRelay(request.unwrap(), {
|
|
1877
|
+
subscribe: () => {
|
|
1878
|
+
subscriptions.push("subscribe");
|
|
1879
|
+
},
|
|
1880
|
+
unsubscribe: () => {
|
|
1881
|
+
subscriptions.push("unsubscribe");
|
|
1882
|
+
},
|
|
1883
|
+
}),
|
|
1884
|
+
);
|
|
1885
|
+
|
|
1886
|
+
assertErr(result);
|
|
1887
|
+
assertInstanceOf(result.error.error, Error);
|
|
1888
|
+
assertSame(result.error.error.message, errorMessage);
|
|
1889
|
+
assertEqual(subscriptions, []);
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1010
1893
|
it("subscribe", async () => {
|
|
1011
1894
|
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
1012
1895
|
const message = createProtocolMessageBuffer(testAppOwner.id, {
|
|
@@ -1026,6 +1909,39 @@ describe("E2E relay options", () => {
|
|
|
1026
1909
|
assertEqual(subscribeCalledWithOwnerId, testAppOwner.id);
|
|
1027
1910
|
});
|
|
1028
1911
|
|
|
1912
|
+
it("subscribes before rejecting the write key", async () => {
|
|
1913
|
+
const message = createProtocolMessageBuffer(testAppOwner.id, {
|
|
1914
|
+
messageType: MessageType.Request,
|
|
1915
|
+
writeKey: testAppOwner.writeKey,
|
|
1916
|
+
subscriptionFlag: SubscriptionFlags.Subscribe,
|
|
1917
|
+
}).unwrap();
|
|
1918
|
+
const subscribedOwnerIds: Array<string> = [];
|
|
1919
|
+
|
|
1920
|
+
let response: Uint8Array;
|
|
1921
|
+
{
|
|
1922
|
+
await using run = testCreateRun({
|
|
1923
|
+
storage: {
|
|
1924
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
1925
|
+
validateWriteKey: constFalse,
|
|
1926
|
+
},
|
|
1927
|
+
});
|
|
1928
|
+
({ message: response } = await run.orThrow(
|
|
1929
|
+
applyProtocolMessageAsRelay(message, {
|
|
1930
|
+
subscribe: (ownerId) => {
|
|
1931
|
+
subscribedOwnerIds.push(ownerId);
|
|
1932
|
+
},
|
|
1933
|
+
}),
|
|
1934
|
+
));
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
assertEqual(subscribedOwnerIds, [testAppOwner.id]);
|
|
1938
|
+
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
1939
|
+
assertEqual(
|
|
1940
|
+
await run(applyProtocolMessageAsClient(response)),
|
|
1941
|
+
err({ type: "ProtocolWriteKeyError", ownerId: testAppOwner.id }),
|
|
1942
|
+
);
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1029
1945
|
it("unsubscribe", async () => {
|
|
1030
1946
|
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
1031
1947
|
const message = createProtocolMessageBuffer(testAppOwner.id, {
|
|
@@ -1332,6 +2248,367 @@ describe("applyProtocolMessageAsClient results", () => {
|
|
|
1332
2248
|
});
|
|
1333
2249
|
});
|
|
1334
2250
|
|
|
2251
|
+
describe("decoding before applying", () => {
|
|
2252
|
+
it("relay rejects a request with descending range bounds before any side effect", async () => {
|
|
2253
|
+
const deps = testCreateDeps();
|
|
2254
|
+
const request = createProtocolMessageBuffer(testAppOwner.id, {
|
|
2255
|
+
messageType: MessageType.Request,
|
|
2256
|
+
writeKey: testAppOwner.writeKey,
|
|
2257
|
+
subscriptionFlag: SubscriptionFlags.Subscribe,
|
|
2258
|
+
});
|
|
2259
|
+
request.addMessage(
|
|
2260
|
+
createEncryptedCrdtMessage(deps, createTestCrdtMessage(deps)),
|
|
2261
|
+
);
|
|
2262
|
+
for (const counter of [2, 1])
|
|
2263
|
+
request.addRange({
|
|
2264
|
+
type: RangeType.Skip,
|
|
2265
|
+
upperBound: timestampToTimestampBytes(
|
|
2266
|
+
createTimestamp({
|
|
2267
|
+
millis: Millis.orThrow(1500),
|
|
2268
|
+
counter: Counter.orThrow(counter),
|
|
2269
|
+
}),
|
|
2270
|
+
),
|
|
2271
|
+
});
|
|
2272
|
+
request.addRange({
|
|
2273
|
+
type: RangeType.Fingerprint,
|
|
2274
|
+
upperBound: InfiniteUpperBound,
|
|
2275
|
+
fingerprint: zeroFingerprint,
|
|
2276
|
+
});
|
|
2277
|
+
const sideEffects: Array<string> = [];
|
|
2278
|
+
await using run = testCreateRun({
|
|
2279
|
+
storage: {
|
|
2280
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2281
|
+
// A relay storage stores the write key of a new owner.
|
|
2282
|
+
validateWriteKey: () => {
|
|
2283
|
+
sideEffects.push("validateWriteKey");
|
|
2284
|
+
return true;
|
|
2285
|
+
},
|
|
2286
|
+
writeMessages: () => () => {
|
|
2287
|
+
sideEffects.push("writeMessages");
|
|
2288
|
+
return ok();
|
|
2289
|
+
},
|
|
2290
|
+
},
|
|
2291
|
+
} satisfies StorageDep);
|
|
2292
|
+
|
|
2293
|
+
const result = await run(
|
|
2294
|
+
applyProtocolMessageAsRelay(request.unwrap(), {
|
|
2295
|
+
subscribe: () => {
|
|
2296
|
+
sideEffects.push("subscribe");
|
|
2297
|
+
},
|
|
2298
|
+
broadcast: () => {
|
|
2299
|
+
sideEffects.push("broadcast");
|
|
2300
|
+
},
|
|
2301
|
+
}),
|
|
2302
|
+
);
|
|
2303
|
+
|
|
2304
|
+
assertErr(result);
|
|
2305
|
+
assertInstanceOf(result.error.error, Error);
|
|
2306
|
+
assertSame(
|
|
2307
|
+
result.error.error.message,
|
|
2308
|
+
"Range upper bounds must be non-decreasing",
|
|
2309
|
+
);
|
|
2310
|
+
assertEqual(sideEffects, []);
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
it("relay rejects a request over its totalMaxSize before any side effect", async () => {
|
|
2314
|
+
const setupRequest = (changeLength: number) => {
|
|
2315
|
+
const request = createProtocolMessageBuffer(testAppOwner.id, {
|
|
2316
|
+
messageType: MessageType.Request,
|
|
2317
|
+
writeKey: testAppOwner.writeKey,
|
|
2318
|
+
subscriptionFlag: SubscriptionFlags.Subscribe,
|
|
2319
|
+
totalMaxSize: ProtocolMessageMaxSize.orThrow(2_000_000),
|
|
2320
|
+
});
|
|
2321
|
+
request.addMessage({
|
|
2322
|
+
timestamp: createTimestamp(),
|
|
2323
|
+
change: new Uint8Array(changeLength) as EncryptedDbChange,
|
|
2324
|
+
});
|
|
2325
|
+
return request.unwrap();
|
|
2326
|
+
};
|
|
2327
|
+
const overhead = setupRequest(999_000).length - 999_000;
|
|
2328
|
+
const sideEffects: Array<string> = [];
|
|
2329
|
+
await using run = testCreateRun({
|
|
2330
|
+
storage: {
|
|
2331
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2332
|
+
validateWriteKey: () => {
|
|
2333
|
+
sideEffects.push("validateWriteKey");
|
|
2334
|
+
return true;
|
|
2335
|
+
},
|
|
2336
|
+
writeMessages: () => () => {
|
|
2337
|
+
sideEffects.push("writeMessages");
|
|
2338
|
+
return ok();
|
|
2339
|
+
},
|
|
2340
|
+
},
|
|
2341
|
+
} satisfies StorageDep);
|
|
2342
|
+
const apply = (request: Uint8Array) =>
|
|
2343
|
+
run(
|
|
2344
|
+
applyProtocolMessageAsRelay(request, {
|
|
2345
|
+
subscribe: () => {
|
|
2346
|
+
sideEffects.push("subscribe");
|
|
2347
|
+
},
|
|
2348
|
+
broadcast: () => {
|
|
2349
|
+
sideEffects.push("broadcast");
|
|
2350
|
+
},
|
|
2351
|
+
}),
|
|
2352
|
+
);
|
|
2353
|
+
|
|
2354
|
+
const largest = setupRequest(defaultProtocolMessageMaxSize - overhead);
|
|
2355
|
+
assertSame(largest.length, defaultProtocolMessageMaxSize);
|
|
2356
|
+
assertOk(await apply(largest));
|
|
2357
|
+
assertEqual(sideEffects, [
|
|
2358
|
+
"subscribe",
|
|
2359
|
+
"validateWriteKey",
|
|
2360
|
+
"writeMessages",
|
|
2361
|
+
"broadcast",
|
|
2362
|
+
]);
|
|
2363
|
+
|
|
2364
|
+
sideEffects.length = 0;
|
|
2365
|
+
const tooLarge = setupRequest(defaultProtocolMessageMaxSize - overhead + 1);
|
|
2366
|
+
const result = await apply(tooLarge);
|
|
2367
|
+
|
|
2368
|
+
assertErr(result);
|
|
2369
|
+
assertSame(result.error.data, tooLarge);
|
|
2370
|
+
assertInstanceOf(result.error.error, Error);
|
|
2371
|
+
assertSame(result.error.error.message, "Request is too large");
|
|
2372
|
+
assertEqual(sideEffects, []);
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
for (const hasWriteKey of [true, false]) {
|
|
2376
|
+
it(`client rejects a response with malformed ranges before storing its messages ${hasWriteKey ? "with" : "without"} a write key`, async () => {
|
|
2377
|
+
const deps = testCreateDeps();
|
|
2378
|
+
const response = createProtocolMessageBuffer(testAppOwner.id, {
|
|
2379
|
+
messageType: MessageType.Response,
|
|
2380
|
+
errorCode: ProtocolErrorCode.NoError,
|
|
2381
|
+
});
|
|
2382
|
+
response.addMessage(
|
|
2383
|
+
createEncryptedCrdtMessage(deps, createTestCrdtMessage(deps)),
|
|
2384
|
+
);
|
|
2385
|
+
// One range of an unknown type.
|
|
2386
|
+
const message = new Uint8Array([...response.unwrap(), 1, 127]);
|
|
2387
|
+
let writeCount = 0;
|
|
2388
|
+
await using run = testCreateRun({
|
|
2389
|
+
storage: {
|
|
2390
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2391
|
+
writeMessages: () => () => {
|
|
2392
|
+
writeCount++;
|
|
2393
|
+
return ok();
|
|
2394
|
+
},
|
|
2395
|
+
},
|
|
2396
|
+
} satisfies StorageDep);
|
|
2397
|
+
|
|
2398
|
+
const result = await run(
|
|
2399
|
+
applyProtocolMessageAsClient(
|
|
2400
|
+
message,
|
|
2401
|
+
hasWriteKey ? { writeKey: testAppOwner.writeKey } : {},
|
|
2402
|
+
),
|
|
2403
|
+
);
|
|
2404
|
+
|
|
2405
|
+
assertErr(result);
|
|
2406
|
+
const { error } = result;
|
|
2407
|
+
assert(
|
|
2408
|
+
error.type === "ProtocolInvalidDataError",
|
|
2409
|
+
"Expected invalid data",
|
|
2410
|
+
);
|
|
2411
|
+
assertSame(error.data, message);
|
|
2412
|
+
assertInstanceOf(error.error, Error);
|
|
2413
|
+
assertSame(error.error.message, "Invalid RangeType: 127");
|
|
2414
|
+
assertSame(writeCount, 0);
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
it("relay answers a write key its storage fails to validate with WriteError", async () => {
|
|
2419
|
+
const deps = testCreateDeps();
|
|
2420
|
+
const request = createProtocolMessageFromCrdtMessages(deps)(testAppOwner, [
|
|
2421
|
+
createTestCrdtMessage(deps),
|
|
2422
|
+
]);
|
|
2423
|
+
// SQLite can fail to store the write key of a new owner, such as on a full
|
|
2424
|
+
// disk.
|
|
2425
|
+
const failure = new Error("database or disk is full");
|
|
2426
|
+
let response: Uint8Array;
|
|
2427
|
+
{
|
|
2428
|
+
await using run = testCreateRun({
|
|
2429
|
+
storage: {
|
|
2430
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2431
|
+
validateWriteKey: () => {
|
|
2432
|
+
throw failure;
|
|
2433
|
+
},
|
|
2434
|
+
},
|
|
2435
|
+
} satisfies StorageDep);
|
|
2436
|
+
|
|
2437
|
+
({ message: response } = await run.orThrow(
|
|
2438
|
+
applyProtocolMessageAsRelay(request),
|
|
2439
|
+
));
|
|
2440
|
+
|
|
2441
|
+
const entries = run.deps.console.getEntriesSnapshot();
|
|
2442
|
+
assertEqual(
|
|
2443
|
+
entries.map(({ method }) => method),
|
|
2444
|
+
["error"],
|
|
2445
|
+
);
|
|
2446
|
+
assertSame(entries[0].args[0], failure);
|
|
2447
|
+
}
|
|
2448
|
+
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
2449
|
+
assertEqual(
|
|
2450
|
+
await run(applyProtocolMessageAsClient(response)),
|
|
2451
|
+
err({ type: "ProtocolWriteError", ownerId: testAppOwner.id }),
|
|
2452
|
+
);
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2455
|
+
it("relay propagates a throw from its broadcast callback as a defect", async () => {
|
|
2456
|
+
const deps = testCreateDeps();
|
|
2457
|
+
const request = createProtocolMessageFromCrdtMessages(deps)(testAppOwner, [
|
|
2458
|
+
createTestCrdtMessage(deps),
|
|
2459
|
+
]);
|
|
2460
|
+
const defect = new Error("broadcast failed");
|
|
2461
|
+
await using run = testCreateRun({
|
|
2462
|
+
storage: {
|
|
2463
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2464
|
+
validateWriteKey: constTrue,
|
|
2465
|
+
writeMessages: () => () => ok(),
|
|
2466
|
+
},
|
|
2467
|
+
} satisfies StorageDep);
|
|
2468
|
+
|
|
2469
|
+
const result = await run.abortable(
|
|
2470
|
+
applyProtocolMessageAsRelay(request, {
|
|
2471
|
+
broadcast: () => {
|
|
2472
|
+
throw defect;
|
|
2473
|
+
},
|
|
2474
|
+
}),
|
|
2475
|
+
);
|
|
2476
|
+
|
|
2477
|
+
assertErr(result);
|
|
2478
|
+
assert(AbortError.is(result.error), "A defect panics the Run.");
|
|
2479
|
+
assertEqual(result.error.reason.type, "PanicAbortReason");
|
|
2480
|
+
assertSame(result.error.reason.defect, defect);
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2483
|
+
/**
|
|
2484
|
+
* Returns a response that asks for every change and a storage holding one
|
|
2485
|
+
* change that no message can hold, whose iterate throws `iterateFailure`
|
|
2486
|
+
* after visiting it.
|
|
2487
|
+
*/
|
|
2488
|
+
const setupTooLargeChange = (iterateFailure?: Error) => {
|
|
2489
|
+
const response = createProtocolMessageBuffer(testAppOwner.id, {
|
|
2490
|
+
messageType: MessageType.Response,
|
|
2491
|
+
errorCode: ProtocolErrorCode.NoError,
|
|
2492
|
+
});
|
|
2493
|
+
response.addRange({
|
|
2494
|
+
type: RangeType.Timestamps,
|
|
2495
|
+
upperBound: InfiniteUpperBound,
|
|
2496
|
+
timestamps: createTimestampsBuffer(),
|
|
2497
|
+
});
|
|
2498
|
+
const storage: StorageDep["storage"] = {
|
|
2499
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2500
|
+
getSize: () => NonNegativeInt.orThrow(1),
|
|
2501
|
+
findLowerBound: (_ownerId, _begin, end) => end,
|
|
2502
|
+
iterate: (_ownerId, _begin, _end, callback) => {
|
|
2503
|
+
callback(testTimestampsAsc[0], NonNegativeInt.orThrow(0));
|
|
2504
|
+
if (iterateFailure) throw iterateFailure;
|
|
2505
|
+
},
|
|
2506
|
+
readDbChange: () =>
|
|
2507
|
+
new Uint8Array(defaultProtocolMessageMaxSize) as EncryptedDbChange,
|
|
2508
|
+
};
|
|
2509
|
+
return { message: response.unwrap(), storage };
|
|
2510
|
+
};
|
|
2511
|
+
|
|
2512
|
+
it("client propagates a throw from onChangeTooLarge as a defect", async () => {
|
|
2513
|
+
const { message, storage } = setupTooLargeChange();
|
|
2514
|
+
const defect = new Error("onChangeTooLarge failed");
|
|
2515
|
+
await using run = testCreateRun({ storage } satisfies StorageDep);
|
|
2516
|
+
|
|
2517
|
+
const result = await run.abortable(
|
|
2518
|
+
applyProtocolMessageAsClient(message, {
|
|
2519
|
+
writeKey: testAppOwner.writeKey,
|
|
2520
|
+
onChangeTooLarge: () => {
|
|
2521
|
+
throw defect;
|
|
2522
|
+
},
|
|
2523
|
+
}),
|
|
2524
|
+
);
|
|
2525
|
+
|
|
2526
|
+
assertErr(result);
|
|
2527
|
+
assert(AbortError.is(result.error), "A defect panics the Run.");
|
|
2528
|
+
assertEqual(result.error.reason.type, "PanicAbortReason");
|
|
2529
|
+
assertSame(result.error.reason.defect, defect);
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
it("client reports a change too large before an iteration that fails", async () => {
|
|
2533
|
+
const failure = new Error("iterate failed");
|
|
2534
|
+
const { message, storage } = setupTooLargeChange(failure);
|
|
2535
|
+
const changesTooLarge: Array<ProtocolChangeTooLargeError> = [];
|
|
2536
|
+
await using run = testCreateRun({ storage } satisfies StorageDep);
|
|
2537
|
+
|
|
2538
|
+
const result = await run(
|
|
2539
|
+
applyProtocolMessageAsClient(message, {
|
|
2540
|
+
writeKey: testAppOwner.writeKey,
|
|
2541
|
+
onChangeTooLarge: (error) => {
|
|
2542
|
+
changesTooLarge.push(error);
|
|
2543
|
+
},
|
|
2544
|
+
}),
|
|
2545
|
+
);
|
|
2546
|
+
|
|
2547
|
+
assertOk(result, { type: "Failed", cause: "Sync" });
|
|
2548
|
+
assertEqual(changesTooLarge, [
|
|
2549
|
+
{
|
|
2550
|
+
type: "ProtocolChangeTooLargeError",
|
|
2551
|
+
timestamp: timestampBytesToTimestamp(testTimestampsAsc[0]),
|
|
2552
|
+
size: defaultProtocolMessageMaxSize,
|
|
2553
|
+
},
|
|
2554
|
+
]);
|
|
2555
|
+
const entries = run.deps.console.getEntriesSnapshot();
|
|
2556
|
+
assertEqual(
|
|
2557
|
+
entries.map(({ method }) => method),
|
|
2558
|
+
["error"],
|
|
2559
|
+
);
|
|
2560
|
+
assertSame(entries[0].args[0], failure);
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
it("client rejects a Request before storing its changes", async () => {
|
|
2564
|
+
const deps = testCreateDeps();
|
|
2565
|
+
const request = createProtocolMessageFromCrdtMessages(deps)(testAppOwner, [
|
|
2566
|
+
createTestCrdtMessage(deps),
|
|
2567
|
+
]);
|
|
2568
|
+
await using run = testCreateRun(shouldNotBeCalledStorageDep);
|
|
2569
|
+
|
|
2570
|
+
const result = await run(
|
|
2571
|
+
applyProtocolMessageAsClient(request, {
|
|
2572
|
+
writeKey: testAppOwner.writeKey,
|
|
2573
|
+
}),
|
|
2574
|
+
);
|
|
2575
|
+
|
|
2576
|
+
assertErr(result);
|
|
2577
|
+
const { error } = result;
|
|
2578
|
+
assert(error.type === "ProtocolInvalidDataError", "Expected invalid data");
|
|
2579
|
+
assertSame(error.data, request);
|
|
2580
|
+
assertInstanceOf(error.error, Error);
|
|
2581
|
+
assertSame(error.error.message, "Expected a Response or a Broadcast");
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
it("relay rejects a Response or a Broadcast before any side effect", async () => {
|
|
2585
|
+
const deps = testCreateDeps();
|
|
2586
|
+
const message = createEncryptedCrdtMessage(
|
|
2587
|
+
deps,
|
|
2588
|
+
createTestCrdtMessage(deps),
|
|
2589
|
+
);
|
|
2590
|
+
const response = createProtocolMessageBuffer(testAppOwner.id, {
|
|
2591
|
+
messageType: MessageType.Response,
|
|
2592
|
+
errorCode: ProtocolErrorCode.NoError,
|
|
2593
|
+
});
|
|
2594
|
+
response.addMessage(message);
|
|
2595
|
+
const errorResponse = createProtocolMessageBuffer(testAppOwner.id, {
|
|
2596
|
+
messageType: MessageType.Response,
|
|
2597
|
+
errorCode: ProtocolErrorCode.WriteError,
|
|
2598
|
+
});
|
|
2599
|
+
const broadcast = createProtocolMessageBuffer(testAppOwner.id, {
|
|
2600
|
+
messageType: MessageType.Broadcast,
|
|
2601
|
+
});
|
|
2602
|
+
broadcast.addMessage(message);
|
|
2603
|
+
|
|
2604
|
+
for (const input of [response, errorResponse, broadcast])
|
|
2605
|
+
assertSame(
|
|
2606
|
+
await getRelayDecodeErrorMessage(input.unwrap()),
|
|
2607
|
+
"Expected a Request",
|
|
2608
|
+
);
|
|
2609
|
+
});
|
|
2610
|
+
});
|
|
2611
|
+
|
|
1335
2612
|
describe("split-range failures", () => {
|
|
1336
2613
|
for (const role of ["client", "relay"] as const) {
|
|
1337
2614
|
for (const method of ["fingerprintRanges", "iterate"] as const) {
|
|
@@ -1409,6 +2686,111 @@ describe("split-range failures", () => {
|
|
|
1409
2686
|
}
|
|
1410
2687
|
});
|
|
1411
2688
|
|
|
2689
|
+
describe("sync defects", () => {
|
|
2690
|
+
const setupInput = (
|
|
2691
|
+
role: "client" | "relay",
|
|
2692
|
+
range: FingerprintRange | TimestampsRangeWithTimestampsBuffer,
|
|
2693
|
+
) => {
|
|
2694
|
+
const input = createProtocolMessageBuffer(
|
|
2695
|
+
testAppOwner.id,
|
|
2696
|
+
role === "client"
|
|
2697
|
+
? {
|
|
2698
|
+
messageType: MessageType.Response,
|
|
2699
|
+
errorCode: ProtocolErrorCode.NoError,
|
|
2700
|
+
}
|
|
2701
|
+
: {
|
|
2702
|
+
messageType: MessageType.Request,
|
|
2703
|
+
writeKey: testAppOwner.writeKey,
|
|
2704
|
+
},
|
|
2705
|
+
);
|
|
2706
|
+
input.addRange(range);
|
|
2707
|
+
return input.unwrap();
|
|
2708
|
+
};
|
|
2709
|
+
|
|
2710
|
+
for (const role of ["client", "relay"] as const) {
|
|
2711
|
+
it(`${role} propagates a throw while writing a split as a defect`, async () => {
|
|
2712
|
+
const message = setupInput(role, {
|
|
2713
|
+
type: RangeType.Fingerprint,
|
|
2714
|
+
upperBound: InfiniteUpperBound,
|
|
2715
|
+
fingerprint: zeroFingerprint,
|
|
2716
|
+
});
|
|
2717
|
+
const range: FingerprintRange = {
|
|
2718
|
+
type: RangeType.Fingerprint,
|
|
2719
|
+
upperBound: InfiniteUpperBound,
|
|
2720
|
+
fingerprint: timestampBytesToFingerprint(testTimestampsAsc[0]),
|
|
2721
|
+
};
|
|
2722
|
+
await using run = testCreateRun({
|
|
2723
|
+
storage: {
|
|
2724
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2725
|
+
getSize: () => NonNegativeInt.orThrow(32),
|
|
2726
|
+
findLowerBound: (_ownerId, _begin, end) => end,
|
|
2727
|
+
fingerprint: () => range.fingerprint,
|
|
2728
|
+
validateWriteKey: constTrue,
|
|
2729
|
+
// A second range after an InfiniteUpperBound one fails the builder's
|
|
2730
|
+
// assertion inside the split's trial.
|
|
2731
|
+
fingerprintRanges: () => [range, range],
|
|
2732
|
+
},
|
|
2733
|
+
} satisfies StorageDep);
|
|
2734
|
+
|
|
2735
|
+
const result =
|
|
2736
|
+
role === "client"
|
|
2737
|
+
? await run.abortable(
|
|
2738
|
+
applyProtocolMessageAsClient(message, {
|
|
2739
|
+
writeKey: testAppOwner.writeKey,
|
|
2740
|
+
}),
|
|
2741
|
+
)
|
|
2742
|
+
: await run.abortable(applyProtocolMessageAsRelay(message));
|
|
2743
|
+
|
|
2744
|
+
assertErr(result);
|
|
2745
|
+
assert(AbortError.is(result.error), "A defect panics the Run.");
|
|
2746
|
+
assertEqual(result.error.reason.type, "PanicAbortReason");
|
|
2747
|
+
assertInstanceOf(result.error.reason.defect, Error);
|
|
2748
|
+
assertSame(
|
|
2749
|
+
result.error.reason.defect.message,
|
|
2750
|
+
"Cannot add a range after an InfiniteUpperBound range",
|
|
2751
|
+
);
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
it(`${role} propagates a throw while answering a Timestamps range as a defect`, async () => {
|
|
2755
|
+
const message = setupInput(role, {
|
|
2756
|
+
type: RangeType.Timestamps,
|
|
2757
|
+
upperBound: InfiniteUpperBound,
|
|
2758
|
+
timestamps: createTimestampsBuffer(),
|
|
2759
|
+
});
|
|
2760
|
+
await using run = testCreateRun({
|
|
2761
|
+
storage: {
|
|
2762
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2763
|
+
getSize: () => NonNegativeInt.orThrow(2),
|
|
2764
|
+
findLowerBound: (_ownerId, _begin, end) => end,
|
|
2765
|
+
validateWriteKey: constTrue,
|
|
2766
|
+
// Descending millis fail the assertion of the answer's timestamps.
|
|
2767
|
+
iterate: (_ownerId, _begin, _end, callback) => {
|
|
2768
|
+
if (callback(testTimestampsAsc[1], NonNegativeInt.orThrow(0)))
|
|
2769
|
+
callback(testTimestampsAsc[0], NonNegativeInt.orThrow(1));
|
|
2770
|
+
},
|
|
2771
|
+
readDbChange: () => new Uint8Array(41) as EncryptedDbChange,
|
|
2772
|
+
},
|
|
2773
|
+
} satisfies StorageDep);
|
|
2774
|
+
|
|
2775
|
+
const result =
|
|
2776
|
+
role === "client"
|
|
2777
|
+
? await run.abortable(
|
|
2778
|
+
applyProtocolMessageAsClient(message, {
|
|
2779
|
+
writeKey: testAppOwner.writeKey,
|
|
2780
|
+
}),
|
|
2781
|
+
)
|
|
2782
|
+
: await run.abortable(applyProtocolMessageAsRelay(message));
|
|
2783
|
+
|
|
2784
|
+
assertErr(result);
|
|
2785
|
+
assert(AbortError.is(result.error), "A defect panics the Run.");
|
|
2786
|
+
assertEqual(result.error.reason.type, "PanicAbortReason");
|
|
2787
|
+
assertInstanceOf(result.error.reason.defect, Error);
|
|
2788
|
+
assertSame(result.error.reason.defect.message, "Expected NonNegative.");
|
|
2789
|
+
assertEqual(run.deps.console.getEntriesSnapshot(), []);
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
});
|
|
2793
|
+
|
|
1412
2794
|
test("local mutation broadcasts contain every message in nonempty size-limited frames", async () => {
|
|
1413
2795
|
const deps = testCreateDeps();
|
|
1414
2796
|
const messages: NonEmptyReadonlyArray<CrdtMessage> = [
|
|
@@ -1516,6 +2898,121 @@ test("local mutation broadcasts reject a message that cannot fit in an empty fra
|
|
|
1516
2898
|
}
|
|
1517
2899
|
});
|
|
1518
2900
|
|
|
2901
|
+
const setupFrameFillingMessages = (deps: TestDeps) => {
|
|
2902
|
+
const large: CrdtMessage = {
|
|
2903
|
+
timestamp: createTimestamp({ millis: Millis.orThrow(1) }),
|
|
2904
|
+
change: DbChange.orThrow({
|
|
2905
|
+
...createDbChange(deps),
|
|
2906
|
+
values: { data: new Uint8Array(1_000_000).fill(1) },
|
|
2907
|
+
}),
|
|
2908
|
+
};
|
|
2909
|
+
const small: CrdtMessage = {
|
|
2910
|
+
timestamp: createTimestamp({ millis: Millis.orThrow(2) }),
|
|
2911
|
+
change: createDbChange(deps),
|
|
2912
|
+
};
|
|
2913
|
+
// Padding makes the length the same for every encryption.
|
|
2914
|
+
const largeChangeLength = createEncryptedDbChange(deps, large).length;
|
|
2915
|
+
|
|
2916
|
+
const measureFrame = (buffer: ProtocolMessageBuffer) => {
|
|
2917
|
+
buffer.addMessage({
|
|
2918
|
+
timestamp: large.timestamp,
|
|
2919
|
+
change: new Uint8Array(largeChangeLength) as EncryptedDbChange,
|
|
2920
|
+
});
|
|
2921
|
+
return buffer.unwrap().length;
|
|
2922
|
+
};
|
|
2923
|
+
|
|
2924
|
+
return { large, small, measureFrame };
|
|
2925
|
+
};
|
|
2926
|
+
|
|
2927
|
+
test("upload requests fill a frame exactly before their continuation range", async () => {
|
|
2928
|
+
const deps = testCreateDeps();
|
|
2929
|
+
const { large, small, measureFrame } = setupFrameFillingMessages(deps);
|
|
2930
|
+
// The continuation range is the first range, which takes 14 bytes.
|
|
2931
|
+
const exactSize =
|
|
2932
|
+
measureFrame(
|
|
2933
|
+
createProtocolMessageBuffer(testAppOwner.id, {
|
|
2934
|
+
messageType: MessageType.Request,
|
|
2935
|
+
writeKey: testAppOwner.writeKey,
|
|
2936
|
+
totalMaxSize: ProtocolMessageMaxSize.orThrow(2_000_000),
|
|
2937
|
+
}),
|
|
2938
|
+
) + 14;
|
|
2939
|
+
|
|
2940
|
+
for (const [maxSize, expectedUploaded] of [
|
|
2941
|
+
[exactSize, [large]],
|
|
2942
|
+
[exactSize - 1, []],
|
|
2943
|
+
] as const) {
|
|
2944
|
+
const request = createProtocolMessageFromCrdtMessages(deps)(
|
|
2945
|
+
testAppOwner,
|
|
2946
|
+
[large, small],
|
|
2947
|
+
ProtocolMessageMaxSize.orThrow(maxSize),
|
|
2948
|
+
);
|
|
2949
|
+
assertTrue(request.length <= maxSize);
|
|
2950
|
+
if (expectedUploaded.length > 0) assertSame(request.length, maxSize);
|
|
2951
|
+
// The frame ends with one Fingerprint range and its fingerprint.
|
|
2952
|
+
assertEqualBytes(request.subarray(-14, -12), [1, RangeType.Fingerprint]);
|
|
2953
|
+
|
|
2954
|
+
const uploaded: Array<CrdtMessage> = [];
|
|
2955
|
+
await using run = testCreateRun({
|
|
2956
|
+
storage: {
|
|
2957
|
+
...shouldNotBeCalledStorageDep.storage,
|
|
2958
|
+
validateWriteKey: constTrue,
|
|
2959
|
+
writeMessages: (_ownerId, messages) => () => {
|
|
2960
|
+
for (const message of messages) {
|
|
2961
|
+
uploaded.push({
|
|
2962
|
+
timestamp: message.timestamp,
|
|
2963
|
+
change: getOrThrow(
|
|
2964
|
+
decryptAndDecodeDbChange(message, testAppOwner.encryptionKey),
|
|
2965
|
+
),
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
return ok();
|
|
2969
|
+
},
|
|
2970
|
+
getSize: () => NonNegativeInt.orThrow(0),
|
|
2971
|
+
findLowerBound: () => NonNegativeInt.orThrow(0),
|
|
2972
|
+
fingerprint: () => zeroFingerprint,
|
|
2973
|
+
iterate: () => {},
|
|
2974
|
+
},
|
|
2975
|
+
} satisfies StorageDep);
|
|
2976
|
+
await run.orThrow(
|
|
2977
|
+
applyProtocolMessageAsRelay(request, {
|
|
2978
|
+
totalMaxSize: ProtocolMessageMaxSize.orThrow(maxSize),
|
|
2979
|
+
}),
|
|
2980
|
+
);
|
|
2981
|
+
assertEqual(uploaded, expectedUploaded);
|
|
2982
|
+
}
|
|
2983
|
+
});
|
|
2984
|
+
|
|
2985
|
+
test("local mutation broadcasts fill a frame exactly", () => {
|
|
2986
|
+
const deps = testCreateDeps();
|
|
2987
|
+
const { large, small, measureFrame } = setupFrameFillingMessages(deps);
|
|
2988
|
+
// Broadcasts hold no ranges, so nothing is reserved to close them.
|
|
2989
|
+
const exactSize = measureFrame(
|
|
2990
|
+
createProtocolMessageBuffer(testAppOwner.id, {
|
|
2991
|
+
messageType: MessageType.Broadcast,
|
|
2992
|
+
totalMaxSize: ProtocolMessageMaxSize.orThrow(2_000_000),
|
|
2993
|
+
}),
|
|
2994
|
+
);
|
|
2995
|
+
|
|
2996
|
+
const broadcasts = createProtocolBroadcastMessagesFromCrdtMessages(deps)(
|
|
2997
|
+
testAppOwner,
|
|
2998
|
+
[large, small],
|
|
2999
|
+
ProtocolMessageMaxSize.orThrow(exactSize),
|
|
3000
|
+
);
|
|
3001
|
+
assertSame(broadcasts.length, 2);
|
|
3002
|
+
assertSame(broadcasts[0].length, exactSize);
|
|
3003
|
+
|
|
3004
|
+
const error = assertThrowsInstanceOf(
|
|
3005
|
+
() =>
|
|
3006
|
+
createProtocolBroadcastMessagesFromCrdtMessages(deps)(
|
|
3007
|
+
testAppOwner,
|
|
3008
|
+
[large],
|
|
3009
|
+
ProtocolMessageMaxSize.orThrow(exactSize - 1),
|
|
3010
|
+
),
|
|
3011
|
+
Error,
|
|
3012
|
+
);
|
|
3013
|
+
assertSame(error.message, "the message is too big");
|
|
3014
|
+
});
|
|
3015
|
+
|
|
1519
3016
|
test("client continuation broadcasts exactly the uploaded messages within the frame limit", async () => {
|
|
1520
3017
|
const deps = testCreateDeps();
|
|
1521
3018
|
const messages = [1, 2].map((millis) => {
|