@evolu/common 8.10.0 → 8.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/src/Bytes.d.ts +39 -2
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +50 -2
  4. package/dist/src/Config.d.ts +22 -22
  5. package/dist/src/Config.d.ts.map +1 -1
  6. package/dist/src/Console.d.ts +62 -7
  7. package/dist/src/Console.d.ts.map +1 -1
  8. package/dist/src/Console.js +20 -4
  9. package/dist/src/Crypto.d.ts +76 -4
  10. package/dist/src/Crypto.d.ts.map +1 -1
  11. package/dist/src/Crypto.js +55 -4
  12. package/dist/src/Error.d.ts +45 -0
  13. package/dist/src/Error.d.ts.map +1 -1
  14. package/dist/src/Error.js +69 -0
  15. package/dist/src/Fs.d.ts +92 -18
  16. package/dist/src/Fs.d.ts.map +1 -1
  17. package/dist/src/Fs.js +2 -0
  18. package/dist/src/Identicon.d.ts +2 -2
  19. package/dist/src/Identicon.js +2 -2
  20. package/dist/src/LeakDetector.d.ts +22 -3
  21. package/dist/src/LeakDetector.d.ts.map +1 -1
  22. package/dist/src/LeakDetector.js +12 -2
  23. package/dist/src/LockManager.d.ts +8 -0
  24. package/dist/src/LockManager.d.ts.map +1 -1
  25. package/dist/src/LockManager.js +6 -0
  26. package/dist/src/Object.d.ts.map +1 -1
  27. package/dist/src/Object.js +5 -0
  28. package/dist/src/Platform.d.ts +47 -7
  29. package/dist/src/Platform.d.ts.map +1 -1
  30. package/dist/src/Platform.js +24 -5
  31. package/dist/src/Random.d.ts +25 -2
  32. package/dist/src/Random.d.ts.map +1 -1
  33. package/dist/src/Random.js +14 -2
  34. package/dist/src/Resource.d.ts +156 -1
  35. package/dist/src/Resource.d.ts.map +1 -1
  36. package/dist/src/Resource.js +201 -72
  37. package/dist/src/Schedule.d.ts +11 -10
  38. package/dist/src/Schedule.d.ts.map +1 -1
  39. package/dist/src/Schedule.js +1 -1
  40. package/dist/src/Sqlite.d.ts +132 -16
  41. package/dist/src/Sqlite.d.ts.map +1 -1
  42. package/dist/src/Sqlite.js +63 -9
  43. package/dist/src/Task.d.ts +15 -4
  44. package/dist/src/Task.d.ts.map +1 -1
  45. package/dist/src/Task.js +41 -15
  46. package/dist/src/Test.d.ts +9 -0
  47. package/dist/src/Test.d.ts.map +1 -1
  48. package/dist/src/Test.js +4 -0
  49. package/dist/src/Time.d.ts +106 -9
  50. package/dist/src/Time.d.ts.map +1 -1
  51. package/dist/src/Time.js +55 -4
  52. package/dist/src/Type.d.ts +1455 -1310
  53. package/dist/src/Type.d.ts.map +1 -1
  54. package/dist/src/Type.js +1274 -517
  55. package/dist/src/WebSocket.d.ts +164 -13
  56. package/dist/src/WebSocket.d.ts.map +1 -1
  57. package/dist/src/WebSocket.js +133 -24
  58. package/dist/src/Worker.d.ts +90 -8
  59. package/dist/src/Worker.d.ts.map +1 -1
  60. package/dist/src/Worker.js +28 -2
  61. package/dist/src/index.d.ts +6 -7
  62. package/dist/src/index.d.ts.map +1 -1
  63. package/dist/src/index.js +2 -3
  64. package/dist/src/local-first/Db.d.ts +52 -3
  65. package/dist/src/local-first/Db.d.ts.map +1 -1
  66. package/dist/src/local-first/Db.js +412 -137
  67. package/dist/src/local-first/Evolu.d.ts +412 -213
  68. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  69. package/dist/src/local-first/Evolu.js +181 -18
  70. package/dist/src/local-first/Owner.d.ts +13 -30
  71. package/dist/src/local-first/Owner.d.ts.map +1 -1
  72. package/dist/src/local-first/Owner.js +13 -30
  73. package/dist/src/local-first/Protocol.d.ts +106 -19
  74. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  75. package/dist/src/local-first/Protocol.js +162 -60
  76. package/dist/src/local-first/Query.d.ts +8 -15
  77. package/dist/src/local-first/Query.d.ts.map +1 -1
  78. package/dist/src/local-first/Relay.d.ts.map +1 -1
  79. package/dist/src/local-first/Relay.js +4 -2
  80. package/dist/src/local-first/Schema.d.ts +346 -23
  81. package/dist/src/local-first/Schema.d.ts.map +1 -1
  82. package/dist/src/local-first/Schema.js +214 -17
  83. package/dist/src/local-first/Shared.d.ts +537 -22
  84. package/dist/src/local-first/Shared.d.ts.map +1 -1
  85. package/dist/src/local-first/Shared.js +1437 -234
  86. package/dist/src/local-first/Storage.d.ts +195 -17
  87. package/dist/src/local-first/Storage.d.ts.map +1 -1
  88. package/dist/src/local-first/Storage.js +85 -22
  89. package/dist/src/local-first/Timestamp.d.ts +392 -41
  90. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  91. package/dist/src/local-first/Timestamp.js +403 -81
  92. package/dist/src/local-first/index.d.ts +0 -1
  93. package/dist/src/local-first/index.d.ts.map +1 -1
  94. package/dist/src/local-first/index.js +0 -1
  95. package/package.json +1 -1
  96. package/src/Assert.test.ts +2 -5
  97. package/src/Bytes.test.ts +27 -0
  98. package/src/Bytes.ts +58 -2
  99. package/src/Config.test.ts +2 -6
  100. package/src/Config.ts +133 -133
  101. package/src/Console.ts +62 -7
  102. package/src/Crypto.ts +76 -4
  103. package/src/Eq.test.ts +2 -3
  104. package/src/Error.test.ts +76 -3
  105. package/src/Error.ts +71 -0
  106. package/src/Fs.ts +92 -18
  107. package/src/Identicon.ts +2 -2
  108. package/src/LeakDetector.ts +22 -3
  109. package/src/LockManager.ts +8 -0
  110. package/src/Object.test.ts +27 -12
  111. package/src/Object.ts +5 -0
  112. package/src/Platform.ts +50 -8
  113. package/src/Random.ts +25 -2
  114. package/src/Resource.test.ts +837 -0
  115. package/src/Resource.ts +235 -15
  116. package/src/Schedule.test.ts +50 -12
  117. package/src/Schedule.ts +24 -14
  118. package/src/Sqlite.ts +137 -17
  119. package/src/Task.test.ts +189 -8
  120. package/src/Task.ts +56 -17
  121. package/src/Test.ts +9 -0
  122. package/src/Time.ts +106 -9
  123. package/src/Type.test.ts +946 -1028
  124. package/src/Type.ts +4195 -3136
  125. package/src/Types.test.ts +4 -14
  126. package/src/WebSocket.ts +313 -40
  127. package/src/Worker.ts +90 -8
  128. package/src/index.ts +20 -6
  129. package/src/local-first/Db.ts +644 -339
  130. package/src/local-first/Evolu.test.ts +994 -22
  131. package/src/local-first/Evolu.ts +625 -232
  132. package/src/local-first/Owner.ts +13 -30
  133. package/src/local-first/Protocol.test.ts +634 -10
  134. package/src/local-first/Protocol.ts +255 -109
  135. package/src/local-first/Query.ts +8 -15
  136. package/src/local-first/Relay.ts +4 -2
  137. package/src/local-first/Schema.test.ts +143 -0
  138. package/src/local-first/Schema.ts +376 -26
  139. package/src/local-first/Shared.test.ts +7731 -559
  140. package/src/local-first/Shared.ts +2036 -267
  141. package/src/local-first/Storage.ts +224 -36
  142. package/src/local-first/Timestamp.test.ts +344 -70
  143. package/src/local-first/Timestamp.ts +434 -118
  144. package/src/local-first/index.ts +0 -1
  145. package/dist/src/local-first/Error.d.ts +0 -12
  146. package/dist/src/local-first/Error.d.ts.map +0 -1
  147. package/dist/src/local-first/Error.js +0 -6
  148. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  149. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  150. package/dist/src/local-first/LocalAuth.js +0 -179
  151. package/src/local-first/Error.ts +0 -17
  152. package/src/local-first/LocalAuth.ts +0 -457
@@ -103,9 +103,9 @@
103
103
  * fit within the limit, the protocol automatically continues synchronization in
104
104
  * subsequent rounds using range-based reconciliation.
105
105
  *
106
- * Database mutations are limited to 640KB, which is smaller than the protocol
107
- * message limit to ensure efficient sync with
108
- * {@link defaultProtocolMessageRangesMaxSize}.
106
+ * Each mutation is limited to {@link maxMutationSize}, so every change fits one
107
+ * message of {@link defaultProtocolMessageMaxSize} next to the largest ranges
108
+ * section.
109
109
  *
110
110
  * ## Why Binary?
111
111
  *
@@ -182,6 +182,7 @@ import {
182
182
  } from "../Array.ts";
183
183
  import {
184
184
  assert,
185
+ assertNonEmptyArray,
185
186
  assertNonNullable,
186
187
  assertNotUndefined,
187
188
  assertSame,
@@ -189,6 +190,7 @@ import {
189
190
  import type { Brand } from "../Brand.ts";
190
191
  import {
191
192
  type Buffer,
193
+ BufferError,
192
194
  createBuffer,
193
195
  createRunLengthEncoder,
194
196
  decodeFlags,
@@ -247,6 +249,7 @@ import {
247
249
  zeroNonNegativeInt,
248
250
  } from "../Type.ts";
249
251
  import type { Predicate } from "../Types.ts";
252
+ import type { Evolu, maxMutationSize } from "./Evolu.ts";
250
253
  import {
251
254
  type Owner,
252
255
  type OwnerError,
@@ -271,6 +274,7 @@ import {
271
274
  type RangeUpperBound,
272
275
  type SkipRange,
273
276
  type StorageDep,
277
+ type StorageWriteMessagesError,
274
278
  type TimestampsRange,
275
279
  } from "./Storage.ts";
276
280
  import {
@@ -367,18 +371,27 @@ export const MessageType = {
367
371
 
368
372
  export type MessageType = (typeof MessageType)[keyof typeof MessageType];
369
373
 
370
- /** Parsed protocol header for supported protocol messages. */
374
+ /**
375
+ * The routing prefix of a {@link ProtocolMessage}.
376
+ *
377
+ * The version and the {@link OwnerId} begin every message of every protocol
378
+ * version. The message type follows them only in messages of this peer's
379
+ * version; the layout after the prefix is unknown for any other version.
380
+ */
371
381
  export interface ProtocolHeader extends Typed<"ProtocolHeader"> {
372
- readonly version: 1;
382
+ readonly version: NonNegativeInt;
373
383
  readonly ownerId: OwnerId;
374
- readonly messageType: MessageType;
384
+ /** Absent when `version` differs from {@link protocolVersion}. */
385
+ readonly messageType?: MessageType;
375
386
  }
376
387
 
377
388
  /**
378
- * Parses the protocol header needed for routing.
389
+ * Parses the {@link ProtocolHeader} a transport needs to route a message.
379
390
  *
380
- * Every protocol message begins with `protocolVersion`, `ownerId`, and
381
- * `messageType`.
391
+ * Parsing succeeds for versions this peer cannot process, because a
392
+ * non-initiator answers a version mismatch with only its version and the owner
393
+ * ID. Rejecting that reply here would drop it before
394
+ * {@link applyProtocolMessageAsClient} can report {@link ProtocolVersionError}.
382
395
  */
383
396
  export const parseProtocolHeader = (
384
397
  inputMessage: Uint8Array,
@@ -468,8 +481,9 @@ export interface ProtocolWriteError
468
481
  * still sync normally.
469
482
  *
470
483
  * Clients should prompt the user to contact the relay provider or upgrade their
471
- * plan. Quota monitoring and management is the relay provider's
472
- * responsibility.
484
+ * plan. Quota monitoring and management is the relay provider's responsibility.
485
+ * After additional quota is available, call {@link Evolu.requestSync} with the
486
+ * affected owner's ID to retry locally stored changes.
473
487
  */
474
488
  export interface ProtocolQuotaError
475
489
  extends OwnerError, Typed<"ProtocolQuotaError"> {}
@@ -558,9 +572,87 @@ export const createProtocolMessageFromCrdtMessages =
558
572
  return buffer.unwrap();
559
573
  };
560
574
 
575
+ /**
576
+ * Creates size-limited broadcast {@link ProtocolMessage}s containing every
577
+ * supplied {@link CrdtMessage}.
578
+ *
579
+ * Broadcasts contain no synchronization ranges or write key. Unlike
580
+ * {@link createProtocolMessageFromCrdtMessages}, this function splits all
581
+ * messages into complete frames rather than relying on later synchronization
582
+ * rounds to deliver messages that do not fit. Each individual message must fit
583
+ * the configured size limit.
584
+ *
585
+ * ### Example
586
+ *
587
+ * ```ts
588
+ * import {
589
+ * assertSame,
590
+ * createId,
591
+ * getOrThrow,
592
+ * testCreateDeps,
593
+ * } from "@evolu/common";
594
+ * import {
595
+ * createProtocolBroadcastMessagesFromCrdtMessages,
596
+ * MessageType,
597
+ * parseProtocolHeader,
598
+ * testAppOwner,
599
+ * testCreateCrdtMessage,
600
+ * } from "@evolu/common/local-first";
601
+ *
602
+ * const deps = testCreateDeps();
603
+ * const broadcasts = createProtocolBroadcastMessagesFromCrdtMessages(deps)(
604
+ * testAppOwner,
605
+ * [testCreateCrdtMessage(createId(deps), 1, "Ada")],
606
+ * );
607
+ * assertSame(broadcasts.length, 1);
608
+ * assertSame(
609
+ * getOrThrow(parseProtocolHeader(broadcasts[0])).messageType,
610
+ * MessageType.Broadcast,
611
+ * );
612
+ * ```
613
+ */
614
+ export const createProtocolBroadcastMessagesFromCrdtMessages =
615
+ (deps: RandomBytesDep) =>
616
+ (
617
+ owner: Owner,
618
+ messages: NonEmptyReadonlyArray<CrdtMessage>,
619
+ maxSize: ProtocolMessageMaxSize = defaultProtocolMessageMaxSize,
620
+ ): NonEmptyReadonlyArray<ProtocolMessage> => {
621
+ const broadcasts: Array<ProtocolMessage> = [];
622
+ let buffer = createProtocolMessageBuffer(owner.id, {
623
+ messageType: MessageType.Broadcast,
624
+ totalMaxSize: maxSize,
625
+ });
626
+ for (const message of messages) {
627
+ const encryptedMessage = {
628
+ timestamp: message.timestamp,
629
+ change: encodeAndEncryptDbChange(deps)(message, owner.encryptionKey),
630
+ };
631
+
632
+ if (!buffer.canAddMessage(encryptedMessage)) {
633
+ const nextBuffer = createProtocolMessageBuffer(owner.id, {
634
+ messageType: MessageType.Broadcast,
635
+ totalMaxSize: maxSize,
636
+ });
637
+ assert(
638
+ nextBuffer.canAddMessage(encryptedMessage),
639
+ "the message is too big",
640
+ );
641
+ broadcasts.push(buffer.unwrap());
642
+ buffer = nextBuffer;
643
+ }
644
+
645
+ buffer.addMessage(encryptedMessage);
646
+ }
647
+
648
+ broadcasts.push(buffer.unwrap());
649
+ assertNonEmptyArray(broadcasts);
650
+ return broadcasts;
651
+ };
652
+
561
653
  /** Creates a {@link ProtocolMessage} for sync. */
562
654
  export const createProtocolMessageForSync =
563
- (deps: StorageDep & ConsoleDep) =>
655
+ (deps: StorageDep) =>
564
656
  (ownerId: OwnerId, subscriptionFlag?: SubscriptionFlag): ProtocolMessage => {
565
657
  const buffer = createProtocolMessageBuffer(ownerId, {
566
658
  messageType: MessageType.Request,
@@ -902,21 +994,53 @@ export interface ApplyProtocolMessageAsClientOptions {
902
994
  }
903
995
 
904
996
  /**
905
- * Result type for {@link applyProtocolMessageAsClient} that distinguishes
906
- * between responses to client requests and broadcast messages.
997
+ * Result of {@link applyProtocolMessageAsClient}: a continuation for the
998
+ * non-initiator.
907
999
  */
908
1000
  export interface ApplyProtocolMessageAsClientResponse extends Typed<"Response"> {
909
1001
  readonly message: ProtocolMessage;
1002
+ /** The uploaded messages, if any, for delivery to other local databases. */
1003
+ readonly broadcast?: ProtocolMessage;
910
1004
  }
911
1005
 
912
- export interface ApplyProtocolMessageAsClientNoResponse extends Typed<"NoResponse"> {}
1006
+ /**
1007
+ * Result of {@link applyProtocolMessageAsClient}: the response needed nothing
1008
+ * more, because its ranges all matched or it carried none.
1009
+ */
1010
+ export interface ApplyProtocolMessageAsClientConverged extends Typed<"Converged"> {}
913
1011
 
1012
+ /**
1013
+ * Result of {@link applyProtocolMessageAsClient}: a Broadcast message whose
1014
+ * messages were written.
1015
+ */
914
1016
  export interface ApplyProtocolMessageAsClientBroadcast extends Typed<"Broadcast"> {}
915
1017
 
1018
+ /**
1019
+ * Result of {@link applyProtocolMessageAsClient}: the messages were written, but
1020
+ * without a write key no ranges are reconciled.
1021
+ */
1022
+ export interface ApplyProtocolMessageAsClientReadonly extends Typed<"Readonly"> {}
1023
+
1024
+ /**
1025
+ * Result of {@link applyProtocolMessageAsClient}: the protocol logged an
1026
+ * exception thrown by calling the storage's `writeMessages` (`Write`) or a
1027
+ * failed range reconciliation (`Sync`) to the console. An exception while the
1028
+ * returned Task runs, as the built-in storages throw, is a defect that aborts
1029
+ * the Run instead. Expected write rejections return the original
1030
+ * {@link StorageWriteMessagesError} through {@link Result}. Reconciliation can
1031
+ * fail after messages have been committed; that failure does not roll back the
1032
+ * write.
1033
+ */
1034
+ export interface ApplyProtocolMessageAsClientFailed extends Typed<"Failed"> {
1035
+ readonly cause: "Write" | "Sync";
1036
+ }
1037
+
916
1038
  export type ApplyProtocolMessageAsClientResult =
917
1039
  | ApplyProtocolMessageAsClientResponse
918
- | ApplyProtocolMessageAsClientNoResponse
919
- | ApplyProtocolMessageAsClientBroadcast;
1040
+ | ApplyProtocolMessageAsClientConverged
1041
+ | ApplyProtocolMessageAsClientBroadcast
1042
+ | ApplyProtocolMessageAsClientReadonly
1043
+ | ApplyProtocolMessageAsClientFailed;
920
1044
 
921
1045
  export const applyProtocolMessageAsClient =
922
1046
  (
@@ -924,12 +1048,7 @@ export const applyProtocolMessageAsClient =
924
1048
  options: ApplyProtocolMessageAsClientOptions = {},
925
1049
  ): Task<
926
1050
  ApplyProtocolMessageAsClientResult,
927
- | ProtocolInvalidDataError
928
- | ProtocolSyncError
929
- | ProtocolVersionError
930
- | ProtocolWriteError
931
- | ProtocolWriteKeyError
932
- | ProtocolQuotaError,
1051
+ ProtocolError | StorageWriteMessagesError,
933
1052
  StorageDep
934
1053
  > =>
935
1054
  async (run) => {
@@ -995,15 +1114,18 @@ export const applyProtocolMessageAsClient =
995
1114
  const result = await run(
996
1115
  storage.writeMessages(ownerIdBytes, messages),
997
1116
  );
998
- // Quota errors are handled by Storage; protocol just stops syncing.
999
- if (!result.ok) return ok({ type: "NoResponse" });
1117
+ if (!result.ok) return result;
1000
1118
  } catch (error) {
1001
1119
  if (AbortError.is(error)) throw error;
1002
1120
  run.deps.console.error(error);
1003
- return ok({ type: "NoResponse" });
1121
+ return ok({ type: "Failed", cause: "Write" });
1004
1122
  }
1005
1123
  }
1006
1124
 
1125
+ if (messageType === MessageType.Broadcast) {
1126
+ return ok({ type: "Broadcast" });
1127
+ }
1128
+
1007
1129
  // Now: No writeKey, no sync.
1008
1130
  // TODO: Allow to sync SharedReadonlyOwner
1009
1131
  // Without local changes, writeKey will not be required.
@@ -1011,17 +1133,13 @@ export const applyProtocolMessageAsClient =
1011
1133
  // the sync will stop.
1012
1134
  const writeKey = options.writeKey;
1013
1135
  if (writeKey == null) {
1014
- return ok({ type: "NoResponse" });
1015
- }
1016
-
1017
- if (messageType === MessageType.Broadcast) {
1018
- return ok({ type: "Broadcast" });
1136
+ return ok({ type: "Readonly" });
1019
1137
  }
1020
1138
 
1021
1139
  const ranges = decodeRanges(input);
1022
1140
 
1023
1141
  if (!isNonEmptyArray(ranges)) {
1024
- return ok({ type: "NoResponse" });
1142
+ return ok({ type: "Converged" });
1025
1143
  }
1026
1144
 
1027
1145
  const output = createProtocolMessageBuffer(ownerId, {
@@ -1030,14 +1148,23 @@ export const applyProtocolMessageAsClient =
1030
1148
  rangesMaxSize: options.rangesMaxSize,
1031
1149
  });
1032
1150
 
1033
- const result = sync(run.deps)(ranges, output, ownerIdBytes);
1151
+ let broadcast: ProtocolMessageBuffer | undefined;
1152
+ const result = sync(run.deps)(ranges, output, ownerIdBytes, (message) => {
1153
+ broadcast ??= createProtocolMessageBuffer(ownerId, {
1154
+ messageType: MessageType.Broadcast,
1155
+ });
1156
+ broadcast.addMessage(message);
1157
+ });
1034
1158
 
1035
- // Client sync error (handled via Storage) or no changes.
1036
- if (!result.ok || !result.value) {
1037
- return ok({ type: "NoResponse" });
1038
- }
1159
+ // A failure was logged by sync.
1160
+ if (!result.ok) return ok({ type: "Failed", cause: "Sync" });
1161
+ if (!result.value) return ok({ type: "Converged" });
1039
1162
 
1040
- return ok({ type: "Response", message: output.unwrap() });
1163
+ return ok({
1164
+ type: "Response",
1165
+ message: output.unwrap(),
1166
+ ...(broadcast && { broadcast: broadcast.unwrap() }),
1167
+ });
1041
1168
  } catch (error) {
1042
1169
  if (AbortError.is(error)) throw error;
1043
1170
  return err<ProtocolInvalidDataError>({
@@ -1159,9 +1286,13 @@ export const applyProtocolMessageAsRelay =
1159
1286
  );
1160
1287
 
1161
1288
  if (!result.ok) {
1289
+ const isQuotaError = result.error.type === "StorageQuotaError";
1290
+ if (!isQuotaError) run.deps.console.error(result.error);
1162
1291
  const message = createProtocolMessageBuffer(ownerId, {
1163
1292
  messageType: MessageType.Response,
1164
- errorCode: ProtocolErrorCode.QuotaError,
1293
+ errorCode: isQuotaError
1294
+ ? ProtocolErrorCode.QuotaError
1295
+ : ProtocolErrorCode.WriteError,
1165
1296
  }).unwrap();
1166
1297
  return ok({ type: "Response", message });
1167
1298
  }
@@ -1256,7 +1387,7 @@ const parseProtocolHeaderFromBuffer = (input: Buffer): ProtocolHeader => {
1256
1387
  const [version, ownerId] = decodeVersionAndOwner(input);
1257
1388
 
1258
1389
  if (version !== protocolVersion) {
1259
- throw new ProtocolDecodeError(`Unsupported protocol version: ${version}`);
1390
+ return { type: "ProtocolHeader", version, ownerId };
1260
1391
  }
1261
1392
 
1262
1393
  const messageTypeValue = input.shift();
@@ -1276,12 +1407,7 @@ const parseProtocolHeaderFromBuffer = (input: Buffer): ProtocolHeader => {
1276
1407
  throw new ProtocolDecodeError("Invalid MessageType");
1277
1408
  }
1278
1409
 
1279
- return {
1280
- type: "ProtocolHeader",
1281
- version: 1,
1282
- ownerId,
1283
- messageType,
1284
- };
1410
+ return { type: "ProtocolHeader", version, ownerId, messageType };
1285
1411
  };
1286
1412
 
1287
1413
  /**
@@ -1319,6 +1445,7 @@ const sync =
1319
1445
  ranges: NonEmptyReadonlyArray<Range>,
1320
1446
  output: ProtocolMessageBuffer,
1321
1447
  ownerIdBytes: OwnerIdBytes,
1448
+ onMessage?: (message: EncryptedCrdtMessage) => void,
1322
1449
  ): Result<boolean, typeof ProtocolErrorCode.SyncError> => {
1323
1450
  const outputInitialSize = output.getSize();
1324
1451
  let storageSize: NonNegativeInt;
@@ -1427,13 +1554,19 @@ const sync =
1427
1554
  skipRange(range);
1428
1555
  } else if (output.canSplitRange()) {
1429
1556
  coalesceSkipsBeforeAdd();
1430
- splitRange(deps)(
1431
- ownerIdBytes,
1432
- lower,
1433
- upper,
1434
- currentUpperBound,
1435
- output,
1436
- );
1557
+ try {
1558
+ splitRange(deps)(
1559
+ ownerIdBytes,
1560
+ lower,
1561
+ upper,
1562
+ currentUpperBound,
1563
+ output,
1564
+ );
1565
+ } catch (error) {
1566
+ if (AbortError.is(error)) throw error;
1567
+ deps.console.error(error);
1568
+ return err(ProtocolErrorCode.SyncError);
1569
+ }
1437
1570
  } else {
1438
1571
  return addFingerprintForRemainingRange(upper)
1439
1572
  ? ok(true)
@@ -1495,7 +1628,10 @@ const sync =
1495
1628
  }
1496
1629
 
1497
1630
  ourTimestamps.add(timestampBinary);
1498
- if (message) output.addMessage(message);
1631
+ if (message) {
1632
+ output.addMessage(message);
1633
+ onMessage?.(message);
1634
+ }
1499
1635
  return true;
1500
1636
  },
1501
1637
  );
@@ -1547,7 +1683,7 @@ const sync =
1547
1683
  };
1548
1684
 
1549
1685
  const splitRange =
1550
- (deps: StorageDep & ConsoleDep) =>
1686
+ (deps: StorageDep) =>
1551
1687
  (
1552
1688
  ownerId: OwnerIdBytes,
1553
1689
  lower: NonNegativeInt,
@@ -1565,15 +1701,10 @@ const splitRange =
1565
1701
  timestamps: createTimestampsBuffer(),
1566
1702
  };
1567
1703
 
1568
- deps.storage.iterate(
1569
- ownerId,
1570
- zeroNonNegativeInt,
1571
- itemCount,
1572
- (timestamp) => {
1573
- range.timestamps.add(timestampBytesToTimestamp(timestamp));
1574
- return true;
1575
- },
1576
- );
1704
+ deps.storage.iterate(ownerId, lower, upper, (timestamp) => {
1705
+ range.timestamps.add(timestampBytesToTimestamp(timestamp));
1706
+ return true;
1707
+ });
1577
1708
 
1578
1709
  buffer.addRange(range);
1579
1710
  return;
@@ -1588,17 +1719,11 @@ const splitRange =
1588
1719
  ...buckets.value.map((b) => NonNegativeInt.orThrow(b + lower)),
1589
1720
  ];
1590
1721
 
1591
- let fingerprintRanges: ReadonlyArray<FingerprintRange>;
1592
- try {
1593
- fingerprintRanges = deps.storage.fingerprintRanges(
1594
- ownerId,
1595
- fingerprintRangesBuckets,
1596
- upperBound,
1597
- );
1598
- } catch (error) {
1599
- deps.console.error(error);
1600
- return;
1601
- }
1722
+ const fingerprintRanges = deps.storage.fingerprintRanges(
1723
+ ownerId,
1724
+ fingerprintRangesBuckets,
1725
+ upperBound,
1726
+ );
1602
1727
 
1603
1728
  const rangesToUse =
1604
1729
  lower > 0 ? fingerprintRanges.slice(1) : fingerprintRanges;
@@ -1731,30 +1856,7 @@ export const encodeAndEncryptDbChange =
1731
1856
  (message: CrdtMessage, key: EncryptionKey): EncryptedDbChange => {
1732
1857
  const buffer = createBuffer();
1733
1858
 
1734
- encodeNonNegativeInt(buffer, protocolVersion);
1735
-
1736
- // Encode the timestamp to prevent tampering (e.g., a malicious relay
1737
- // assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1738
- buffer.extend(timestampToTimestampBytes(message.timestamp));
1739
-
1740
- encodeFlags(buffer, [
1741
- message.change.isInsert,
1742
- // Encode nullable boolean as two flags: presence + value.
1743
- message.change.isDelete != null,
1744
- message.change.isDelete ?? false,
1745
- ]);
1746
-
1747
- encodeString(buffer, message.change.table);
1748
- buffer.extend(idToIdBytes(message.change.id));
1749
-
1750
- const entries = objectToEntries(message.change.values);
1751
-
1752
- encodeLength(buffer, entries);
1753
- for (const [column, value] of entries) {
1754
- assertNotUndefined(value);
1755
- encodeString(buffer, column);
1756
- encodeSqliteValue(buffer, value);
1757
- }
1859
+ encodeDbChange(buffer, message);
1758
1860
 
1759
1861
  // Add PADMÉ padding (ignored during decoding)
1760
1862
  buffer.extend(createPadmePadding(buffer.getLength()));
@@ -1772,6 +1874,41 @@ export const encodeAndEncryptDbChange =
1772
1874
  return buffer.unwrap() as EncryptedDbChange;
1773
1875
  };
1774
1876
 
1877
+ /**
1878
+ * Encodes a {@link CrdtMessage} as {@link encodeAndEncryptDbChange} does before
1879
+ * padding and encryption.
1880
+ *
1881
+ * {@link Evolu.getMutationSize} measures mutations with this encoding, so
1882
+ * {@link maxMutationSize} limits exactly what is encoded, and every change
1883
+ * within it fits one protocol message.
1884
+ */
1885
+ export const encodeDbChange = (buffer: Buffer, message: CrdtMessage): void => {
1886
+ encodeNonNegativeInt(buffer, protocolVersion);
1887
+
1888
+ // Encode the timestamp to prevent tampering (e.g., a malicious relay
1889
+ // assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1890
+ buffer.extend(timestampToTimestampBytes(message.timestamp));
1891
+
1892
+ encodeFlags(buffer, [
1893
+ message.change.isInsert,
1894
+ // Encode nullable boolean as two flags: presence + value.
1895
+ message.change.isDelete != null,
1896
+ message.change.isDelete ?? false,
1897
+ ]);
1898
+
1899
+ encodeString(buffer, message.change.table);
1900
+ buffer.extend(idToIdBytes(message.change.id));
1901
+
1902
+ const entries = objectToEntries(message.change.values);
1903
+
1904
+ encodeLength(buffer, entries);
1905
+ for (const [column, value] of entries) {
1906
+ assertNotUndefined(value);
1907
+ encodeString(buffer, column);
1908
+ encodeSqliteValue(buffer, value);
1909
+ }
1910
+ };
1911
+
1775
1912
  /**
1776
1913
  * Decrypts and decodes an {@link EncryptedCrdtMessage} using the provided
1777
1914
  * owner's encryption key. Verifies that the embedded timestamp matches the
@@ -1945,21 +2082,30 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
1945
2082
  }
1946
2083
 
1947
2084
  const json = Json.from.parent(value);
1948
- // Only encode as Json if it survives JSON.parse/JSON.stringify round-trip.
1949
- // Some valid JSON strings like "-0E0" get normalized to "0" during parsing,
1950
- // which would cause data corruption if we don't verify round-trip safety.
1951
- if (json.ok && JSON.stringify(jsonToJsonValue(json.value)) === value) {
2085
+ if (json.ok) {
2086
+ const jsonValue = jsonToJsonValue(json.value);
1952
2087
  jsonBuffer.reset();
1953
2088
  try {
1954
- encodeJsonValue(jsonBuffer, jsonToJsonValue(json.value));
1955
- const jsonBytes = jsonBuffer.unwrap();
1956
- encodeNonNegativeInt(buffer, ProtocolValueType.Json);
1957
- encodeLength(buffer, jsonBytes);
1958
- buffer.extend(jsonBytes);
2089
+ // Encoding first rejects nesting deeper than decoding allows, before
2090
+ // the recursive JSON.stringify below could overflow the stack. Such
2091
+ // a value is encoded as a plain string.
2092
+ encodeJsonValue(jsonBuffer, jsonValue);
2093
+ // Only encode as Json if it survives JSON.parse/JSON.stringify
2094
+ // round-trip. Some valid JSON strings like "-0E0" get normalized to
2095
+ // "0" during parsing, which would cause data corruption if we don't
2096
+ // verify round-trip safety.
2097
+ if (JSON.stringify(jsonValue) === value) {
2098
+ const jsonBytes = jsonBuffer.unwrap();
2099
+ encodeNonNegativeInt(buffer, ProtocolValueType.Json);
2100
+ encodeLength(buffer, jsonBytes);
2101
+ buffer.extend(jsonBytes);
2102
+ return;
2103
+ }
2104
+ } catch (error) {
2105
+ if (!(error instanceof BufferError)) throw error;
1959
2106
  } finally {
1960
2107
  jsonBuffer.reset();
1961
2108
  }
1962
- return;
1963
2109
  }
1964
2110
 
1965
2111
  const base64Url = Base64Url.from.parent(value);
@@ -43,25 +43,22 @@ export type { NotNull as KyselyNotNull } from "kysely";
43
43
  * import {
44
44
  * assertType,
45
45
  * createQueryBuilder,
46
- * id,
46
+ * testEvoluSchema,
47
+ * type TestEvoluSchema,
47
48
  * NonEmptyTrimmedString100,
48
49
  * type Query,
50
+ * type TestTodoId,
49
51
  * } from "@evolu/common";
50
52
  *
51
- * const TodoId = id("Todo");
52
- * type TodoId = typeof TodoId.Output;
53
- * const Schema = {
54
- * todo: { id: TodoId, title: NonEmptyTrimmedString100 },
55
- * };
56
- * const createQuery = createQueryBuilder(Schema);
53
+ * const createQuery = createQueryBuilder(testEvoluSchema);
57
54
  * const allTodos = createQuery((db) => db.selectFrom("todo").selectAll());
58
55
  *
59
56
  * type AllTodosRow = typeof allTodos.Row;
60
57
  * assertType<
61
- * typeof allTodos extends Query<typeof Schema> ? true : false,
58
+ * typeof allTodos extends Query<TestEvoluSchema> ? true : false,
62
59
  * true
63
60
  * >();
64
- * assertType<AllTodosRow["id"], TodoId>();
61
+ * assertType<AllTodosRow["id"], TestTodoId>();
65
62
  * assertType<AllTodosRow["title"], NonEmptyTrimmedString100 | null>();
66
63
  * ```
67
64
  */
@@ -82,15 +79,11 @@ export type Query<
82
79
  * import {
83
80
  * assertType,
84
81
  * createQueryBuilder,
85
- * id,
82
+ * testEvoluSchema,
86
83
  * type InferRow,
87
- * NonEmptyTrimmedString100,
88
84
  * } from "@evolu/common";
89
85
  *
90
- * const Schema = {
91
- * todo: { id: id("Todo"), title: NonEmptyTrimmedString100 },
92
- * };
93
- * const createQuery = createQueryBuilder(Schema);
86
+ * const createQuery = createQueryBuilder(testEvoluSchema);
94
87
  * const allTodos = createQuery((db) =>
95
88
  * db.selectFrom("todo").selectAll(),
96
89
  * );
@@ -17,7 +17,7 @@ import { err, ok } from "../Result.ts";
17
17
  import type { SqliteDep } from "../Sqlite.ts";
18
18
  import { sql } from "../Sqlite.ts";
19
19
  import { createMutexByKey } from "../Task.ts";
20
- import { Name, PositiveInt, uint8ArrayToBase64Url } from "../Type.ts";
20
+ import { Name, NonNegativeInt, uint8ArrayToBase64Url } from "../Type.ts";
21
21
  import { isPromiseLike, type Awaitable } from "../Types.ts";
22
22
  import {
23
23
  OwnerId,
@@ -246,7 +246,9 @@ export const createRelaySqliteStorage =
246
246
  (sum, m) => sum + m.change.length,
247
247
  0,
248
248
  );
249
- const newStoredBytes = PositiveInt.orThrow(
249
+ // A sum of lengths can be zero. Throwing here would panic the
250
+ // relay's shared Run.
251
+ const newStoredBytes = NonNegativeInt.orThrow(
250
252
  (usage.storedBytes ?? 0) + incomingBytes,
251
253
  );
252
254