@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.
- package/dist/src/Bytes.d.ts +39 -2
- package/dist/src/Bytes.d.ts.map +1 -1
- package/dist/src/Bytes.js +50 -2
- package/dist/src/Config.d.ts +22 -22
- package/dist/src/Config.d.ts.map +1 -1
- package/dist/src/Console.d.ts +62 -7
- package/dist/src/Console.d.ts.map +1 -1
- package/dist/src/Console.js +20 -4
- package/dist/src/Crypto.d.ts +76 -4
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +55 -4
- package/dist/src/Error.d.ts +45 -0
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +69 -0
- package/dist/src/Fs.d.ts +92 -18
- package/dist/src/Fs.d.ts.map +1 -1
- package/dist/src/Fs.js +2 -0
- package/dist/src/Identicon.d.ts +2 -2
- package/dist/src/Identicon.js +2 -2
- package/dist/src/LeakDetector.d.ts +22 -3
- package/dist/src/LeakDetector.d.ts.map +1 -1
- package/dist/src/LeakDetector.js +12 -2
- package/dist/src/LockManager.d.ts +8 -0
- package/dist/src/LockManager.d.ts.map +1 -1
- package/dist/src/LockManager.js +6 -0
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +5 -0
- package/dist/src/Platform.d.ts +47 -7
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +24 -5
- package/dist/src/Random.d.ts +25 -2
- package/dist/src/Random.d.ts.map +1 -1
- package/dist/src/Random.js +14 -2
- package/dist/src/Resource.d.ts +156 -1
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +201 -72
- package/dist/src/Schedule.d.ts +11 -10
- package/dist/src/Schedule.d.ts.map +1 -1
- package/dist/src/Schedule.js +1 -1
- package/dist/src/Sqlite.d.ts +132 -16
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +63 -9
- package/dist/src/Task.d.ts +15 -4
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +41 -15
- package/dist/src/Test.d.ts +9 -0
- package/dist/src/Test.d.ts.map +1 -1
- package/dist/src/Test.js +4 -0
- package/dist/src/Time.d.ts +106 -9
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +55 -4
- package/dist/src/Type.d.ts +1455 -1310
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +1274 -517
- package/dist/src/WebSocket.d.ts +164 -13
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/WebSocket.js +133 -24
- package/dist/src/Worker.d.ts +90 -8
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +28 -2
- package/dist/src/index.d.ts +6 -7
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -3
- package/dist/src/local-first/Db.d.ts +52 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +412 -137
- package/dist/src/local-first/Evolu.d.ts +412 -213
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +181 -18
- package/dist/src/local-first/Owner.d.ts +13 -30
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +13 -30
- package/dist/src/local-first/Protocol.d.ts +106 -19
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +162 -60
- package/dist/src/local-first/Query.d.ts +8 -15
- package/dist/src/local-first/Query.d.ts.map +1 -1
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +4 -2
- package/dist/src/local-first/Schema.d.ts +346 -23
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Schema.js +214 -17
- package/dist/src/local-first/Shared.d.ts +537 -22
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +1437 -234
- package/dist/src/local-first/Storage.d.ts +195 -17
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +85 -22
- package/dist/src/local-first/Timestamp.d.ts +392 -41
- package/dist/src/local-first/Timestamp.d.ts.map +1 -1
- package/dist/src/local-first/Timestamp.js +403 -81
- package/dist/src/local-first/index.d.ts +0 -1
- package/dist/src/local-first/index.d.ts.map +1 -1
- package/dist/src/local-first/index.js +0 -1
- package/package.json +1 -1
- package/src/Assert.test.ts +2 -5
- package/src/Bytes.test.ts +27 -0
- package/src/Bytes.ts +58 -2
- package/src/Config.test.ts +2 -6
- package/src/Config.ts +133 -133
- package/src/Console.ts +62 -7
- package/src/Crypto.ts +76 -4
- package/src/Eq.test.ts +2 -3
- package/src/Error.test.ts +76 -3
- package/src/Error.ts +71 -0
- package/src/Fs.ts +92 -18
- package/src/Identicon.ts +2 -2
- package/src/LeakDetector.ts +22 -3
- package/src/LockManager.ts +8 -0
- package/src/Object.test.ts +27 -12
- package/src/Object.ts +5 -0
- package/src/Platform.ts +50 -8
- package/src/Random.ts +25 -2
- package/src/Resource.test.ts +837 -0
- package/src/Resource.ts +235 -15
- package/src/Schedule.test.ts +50 -12
- package/src/Schedule.ts +24 -14
- package/src/Sqlite.ts +137 -17
- package/src/Task.test.ts +189 -8
- package/src/Task.ts +56 -17
- package/src/Test.ts +9 -0
- package/src/Time.ts +106 -9
- package/src/Type.test.ts +946 -1028
- package/src/Type.ts +4195 -3136
- package/src/Types.test.ts +4 -14
- package/src/WebSocket.ts +313 -40
- package/src/Worker.ts +90 -8
- package/src/index.ts +20 -6
- package/src/local-first/Db.ts +644 -339
- package/src/local-first/Evolu.test.ts +994 -22
- package/src/local-first/Evolu.ts +625 -232
- package/src/local-first/Owner.ts +13 -30
- package/src/local-first/Protocol.test.ts +634 -10
- package/src/local-first/Protocol.ts +255 -109
- package/src/local-first/Query.ts +8 -15
- package/src/local-first/Relay.ts +4 -2
- package/src/local-first/Schema.test.ts +143 -0
- package/src/local-first/Schema.ts +376 -26
- package/src/local-first/Shared.test.ts +7731 -559
- package/src/local-first/Shared.ts +2036 -267
- package/src/local-first/Storage.ts +224 -36
- package/src/local-first/Timestamp.test.ts +344 -70
- package/src/local-first/Timestamp.ts +434 -118
- package/src/local-first/index.ts +0 -1
- package/dist/src/local-first/Error.d.ts +0 -12
- package/dist/src/local-first/Error.d.ts.map +0 -1
- package/dist/src/local-first/Error.js +0 -6
- package/dist/src/local-first/LocalAuth.d.ts +0 -150
- package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
- package/dist/src/local-first/LocalAuth.js +0 -179
- package/src/local-first/Error.ts +0 -17
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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: "
|
|
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
|
+
});
|