@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.
Files changed (41) hide show
  1. package/dist/src/Bytes.d.ts +31 -14
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +48 -8
  4. package/dist/src/Error.d.ts.map +1 -1
  5. package/dist/src/Error.js +5 -1
  6. package/dist/src/Polyfills.d.ts.map +1 -1
  7. package/dist/src/Polyfills.js +3 -2
  8. package/dist/src/Task.d.ts +4 -3
  9. package/dist/src/Task.d.ts.map +1 -1
  10. package/dist/src/Task.js +4 -3
  11. package/dist/src/local-first/Db.d.ts.map +1 -1
  12. package/dist/src/local-first/Db.js +18 -7
  13. package/dist/src/local-first/Evolu.d.ts +10 -1
  14. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  15. package/dist/src/local-first/Evolu.js +5 -1
  16. package/dist/src/local-first/Protocol.d.ts +215 -93
  17. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  18. package/dist/src/local-first/Protocol.js +799 -489
  19. package/dist/src/local-first/Relay.d.ts +9 -1
  20. package/dist/src/local-first/Relay.d.ts.map +1 -1
  21. package/dist/src/local-first/Relay.js +41 -36
  22. package/dist/src/local-first/Shared.d.ts +75 -49
  23. package/dist/src/local-first/Shared.d.ts.map +1 -1
  24. package/dist/src/local-first/Shared.js +62 -42
  25. package/dist/src/local-first/Storage.d.ts +80 -18
  26. package/dist/src/local-first/Storage.d.ts.map +1 -1
  27. package/dist/src/local-first/Storage.js +20 -4
  28. package/package.json +1 -1
  29. package/src/Bytes.test.ts +212 -0
  30. package/src/Bytes.ts +67 -17
  31. package/src/Error.ts +5 -1
  32. package/src/Polyfills.ts +3 -2
  33. package/src/Task.ts +4 -3
  34. package/src/local-first/Db.ts +29 -14
  35. package/src/local-first/Evolu.test.ts +43 -12
  36. package/src/local-first/Evolu.ts +16 -2
  37. package/src/local-first/Protocol.test.ts +1541 -44
  38. package/src/local-first/Protocol.ts +1070 -605
  39. package/src/local-first/Relay.ts +80 -69
  40. package/src/local-first/Shared.ts +106 -67
  41. package/src/local-first/Storage.ts +100 -27
package/src/Bytes.ts CHANGED
@@ -103,16 +103,11 @@ export const concatByteArrays = (
103
103
  return result;
104
104
  };
105
105
 
106
- /**
107
- * Custom error for {@link Buffer}-related failures like premature end of data.
108
- * Provides better stack traces for debugging binary protocol issues.
109
- */
106
+ /** Custom error for {@link Buffer}-related failures like premature end of data. */
110
107
  export class BufferError extends Error {
111
108
  constructor(message: string) {
112
109
  super(message);
113
110
  this.name = this.constructor.name;
114
-
115
- Error.captureStackTrace(this, this.constructor);
116
111
  }
117
112
  }
118
113
 
@@ -173,34 +168,34 @@ export class BufferError extends Error {
173
168
  */
174
169
  export interface Buffer {
175
170
  /** Returns the current capacity of the buffer. */
176
- getCapacity: () => NonNegativeInt;
171
+ readonly getCapacity: () => NonNegativeInt;
177
172
 
178
173
  /** Returns the current number of bytes stored in the buffer. */
179
- getLength: () => NonNegativeInt;
174
+ readonly getLength: () => NonNegativeInt;
180
175
 
181
176
  /**
182
177
  * Appends binary data to the buffer, resizing if necessary. Throws if
183
178
  * `arg.length` is not a non-negative safe integer.
184
179
  */
185
- extend: (arg: Uint8Array | ArrayLike<number>) => void;
180
+ readonly extend: (arg: Uint8Array | ArrayLike<number>) => void;
186
181
 
187
182
  /**
188
183
  * Removes and returns the first byte. Throws an `Error` with message "Buffer
189
184
  * parse ended prematurely" if the buffer is empty.
190
185
  */
191
- shift: () => NonNegativeInt;
186
+ readonly shift: () => NonNegativeInt;
192
187
 
193
188
  /**
194
189
  * Removes and returns the first `n` bytes. Throws an `Error` with message
195
190
  * "Buffer parse ended prematurely" if fewer than `n` bytes remain.
196
191
  */
197
- shiftN: (n: NonNegativeInt) => Uint8Array;
192
+ readonly shiftN: (n: NonNegativeInt) => Uint8Array;
198
193
 
199
194
  /**
200
195
  * Truncates the buffer to the specified length, discarding data from the end.
201
196
  * Throws if the new length is greater than the current length.
202
197
  */
203
- truncate: (length: NonNegativeInt) => void;
198
+ readonly truncate: (length: NonNegativeInt) => void;
204
199
 
205
200
  /**
206
201
  * Resets the buffer to its initial empty state, preserving its capacity.
@@ -209,14 +204,14 @@ export interface Buffer {
209
204
  * when you want to clear the buffer and write new data, avoiding unnecessary
210
205
  * allocations.
211
206
  */
212
- reset: () => void;
207
+ readonly reset: () => void;
213
208
 
214
209
  /**
215
210
  * Returns a view of the buffer’s current data. Do not modify this array, as
216
211
  * it directly alters the buffer’s internal state, potentially breaking
217
212
  * subsequent operations.
218
213
  */
219
- unwrap: () => Uint8Array;
214
+ readonly unwrap: () => Uint8Array;
220
215
  }
221
216
 
222
217
  /** Creates a {@link Buffer} for efficient byte operations. */
@@ -438,18 +433,37 @@ export const encodeLength = (
438
433
  /** Decodes an array-like value length. */
439
434
  export const decodeLength = decodeNonNegativeInt;
440
435
 
441
- /** Encodes a length-prefixed UTF-8 string. */
436
+ /**
437
+ * Encodes a length-prefixed UTF-8 string.
438
+ *
439
+ * A lone surrogate becomes U+FFFD, so the bytes are always valid UTF-8. Unlike
440
+ * {@link encodeJsonValue}, which keeps lone surrogates, this does not round-trip
441
+ * every JavaScript string.
442
+ */
442
443
  export const encodeString = (buffer: Buffer, value: string): void => {
443
444
  const bytes = utf8ToBytes(value);
444
445
  encodeLength(buffer, bytes);
445
446
  buffer.extend(bytes);
446
447
  };
447
448
 
448
- /** Decodes a length-prefixed UTF-8 string. */
449
+ /**
450
+ * Decodes a length-prefixed UTF-8 string.
451
+ *
452
+ * Invalid UTF-8 decodes as U+FFFD instead of throwing. A leading U+FEFF is
453
+ * kept. Decoders in `@evolu/common` 8.17 and earlier drop it, so such peers
454
+ * still store a string that starts with it without the mark.
455
+ */
449
456
  export const decodeString = (buffer: Buffer): string => {
450
457
  const length = decodeLength(buffer);
451
458
  const bytes = buffer.shiftN(length);
452
- return bytesToUtf8(bytes);
459
+ const string = bytesToUtf8(bytes);
460
+ // bytesToUtf8 uses the default TextDecoder, which drops a leading U+FEFF
461
+ // (EF BB BF in UTF-8), so it is restored here. Passing { ignoreBOM: true }
462
+ // to a TextDecoder would also keep it, but costs more compiler types in the
463
+ // type benchmark.
464
+ return bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
465
+ ? `\uFEFF${string}`
466
+ : string;
453
467
  };
454
468
 
455
469
  /** Incrementally encodes consecutive equal values using run-length encoding. */
@@ -457,6 +471,15 @@ export interface RunLengthEncoder<T> {
457
471
  readonly add: (value: T) => void;
458
472
  readonly getLength: () => NonNegativeInt;
459
473
  readonly unwrap: () => Uint8Array;
474
+
475
+ /**
476
+ * Returns a function that restores the encoder, in constant time, to the
477
+ * state it had when this was called, discarding every value added since.
478
+ *
479
+ * A restore function can be called repeatedly. It is valid until the encoder
480
+ * is restored to an earlier checkpoint.
481
+ */
482
+ readonly checkpoint: () => () => void;
460
483
  }
461
484
 
462
485
  /** Creates an incremental run-length encoder. */
@@ -485,6 +508,26 @@ export const createRunLengthEncoder = <T>(
485
508
  getLength: () => buffer.getLength(),
486
509
 
487
510
  unwrap: () => buffer.unwrap(),
511
+
512
+ checkpoint: () => {
513
+ const checkpointLength = previousLength;
514
+ const checkpointValue = previousValue;
515
+ const checkpointRunLength = runLength;
516
+
517
+ return () => {
518
+ // `add` rewrites the last run in place, so the bytes after its start
519
+ // may differ from the checkpoint's even at the same length. Bytes
520
+ // before it never change, so the last run is encoded again.
521
+ buffer.truncate(checkpointLength);
522
+ previousLength = checkpointLength;
523
+ previousValue = checkpointValue;
524
+ runLength = checkpointRunLength;
525
+ if (runLength > 0) {
526
+ encodeValue(buffer, previousValue as T);
527
+ encodeNonNegativeInt(buffer, runLength);
528
+ }
529
+ };
530
+ },
488
531
  };
489
532
  };
490
533
 
@@ -557,6 +600,9 @@ interface JsonKeyCacheEntry {
557
600
  const jsonKeyCacheSize = 4096;
558
601
  const maxCachedJsonKeyByteLength = 32;
559
602
  const maxJsonNestingDepth = 1_000;
603
+ // A string reserves 3 bytes per UTF-16 code unit, so one large value would
604
+ // otherwise keep several times its size allocated after encoding.
605
+ const maxRetainedJsonEncoderLength = 1024 * 1024;
560
606
  let jsonEncoderTarget = new Uint8Array(8192);
561
607
  let jsonEncoderTargetView = new DataView(jsonEncoderTarget.buffer);
562
608
  let jsonEncoderPosition = 0;
@@ -622,6 +668,10 @@ export const encodeJsonValue = (buffer: Buffer, value: JsonValue): void => {
622
668
  jsonEncoderPosition = 0;
623
669
  jsonEncoderDepth = 0;
624
670
  jsonEncoderIsActive = false;
671
+ if (jsonEncoderTarget.length > maxRetainedJsonEncoderLength) {
672
+ jsonEncoderTarget = new Uint8Array(8192);
673
+ jsonEncoderTargetView = new DataView(jsonEncoderTarget.buffer);
674
+ }
625
675
  }
626
676
  };
627
677
 
package/src/Error.ts CHANGED
@@ -172,7 +172,11 @@ export const defectToError = (reported: unknown): Error => {
172
172
  // instanceof fails.
173
173
  const tag = Object.prototype.toString.call(defect);
174
174
  // Chromium reports a DOMException from a worker without its name or message,
175
- // so it is described in an Error.
175
+ // so it is described in an Error. Its fix covered only exceptions thrown in
176
+ // classic workers (https://issues.chromium.org/issues/41241359); Chrome 154
177
+ // still drops both for reportError
178
+ // (https://issues.chromium.org/issues/568850673) and for a module worker's
179
+ // evaluation.
176
180
  if (tag === "[object DOMException]") {
177
181
  const { name, message } = defect as Error;
178
182
  return new Error(`${name}: ${message}`, { cause: defect });
package/src/Polyfills.ts CHANGED
@@ -37,8 +37,9 @@ export const installPolyfills = (): void => {
37
37
  * This module intentionally owns `DisposableStack` and `AsyncDisposableStack`
38
38
  * polyfills instead of depending on `es-shims/DisposableStack` at runtime.
39
39
  *
40
- * Evolu originally used the upstream package, but WebKit hit a known async
41
- * disposal completion bug (`completion["?"]` crash, see issue #9). The local
40
+ * Evolu originally used the upstream package, but WebKit, which needs the
41
+ * polyfill, hit its async disposal completion bug (`completion["?"]` crash,
42
+ * https://github.com/es-shims/DisposableStack/issues/9). The local
42
43
  * implementation applies the fix and keeps behavior deterministic across
43
44
  * runtimes used by Evolu.
44
45
  *
package/src/Task.ts CHANGED
@@ -1798,9 +1798,10 @@ export interface AbortReason extends InferType<typeof AbortReason> {}
1798
1798
  * Helpers that abort their own child Tasks should catch or normalize AbortError
1799
1799
  * before it escapes the helper boundary. The reason carries typed domain data.
1800
1800
  *
1801
- * WebKit fetch rejects with its own abort error instead of `signal.reason`.
1802
- * Native wrappers should treat `signal.reason` as the source of truth and
1803
- * normalize aborts to AbortError.
1801
+ * WebKit fetch rejects with its own abort error instead of `signal.reason`
1802
+ * ([WebKit bug 246069](https://bugs.webkit.org/show_bug.cgi?id=246069)). Native
1803
+ * wrappers should treat `signal.reason` as the source of truth and normalize
1804
+ * aborts to AbortError.
1804
1805
  *
1805
1806
  * @group Core
1806
1807
  */
@@ -57,7 +57,7 @@ import {
57
57
  type RandomBytesDep,
58
58
  } from "../Crypto.ts";
59
59
  import { createUnknownError } from "../Error.ts";
60
- import { constFalse, constVoid } from "../Function.ts";
60
+ import { constFalse } from "../Function.ts";
61
61
  import type { LockManagerDep } from "../LockManager.ts";
62
62
  import {
63
63
  acquireLeaderLock,
@@ -116,6 +116,7 @@ import {
116
116
  decryptAndDecodeDbChange,
117
117
  encodeAndEncryptDbChange,
118
118
  SubscriptionFlags,
119
+ type ProtocolChangeTooLargeError,
119
120
  type ProtocolInvalidDataError,
120
121
  type ProtocolMessage,
121
122
  type ProtocolTimestampMismatchError,
@@ -374,6 +375,7 @@ export const startDbWorker =
374
375
  const result = await run.abortable(
375
376
  applyProtocolMessageAsClient(inputMessage, {
376
377
  writeKey: owner.writeKey,
378
+ onChangeTooLarge: storage.onChangeTooLarge,
377
379
  }),
378
380
  );
379
381
  postQueuedResponse({
@@ -475,7 +477,8 @@ export const startDbWorker =
475
477
 
476
478
  // Stops once the SharedWorker no longer leads for its ID, which the
477
479
  // platform releases when it closes, because Firefox can lose a Dispose
478
- // posted right before that.
480
+ // posted right before that
481
+ // (https://bugzilla.mozilla.org/show_bug.cgi?id=2077609).
479
482
  const sharedWorkerEnded = acquireLeaderLockCallback(deps)(
480
483
  initMessage.sharedWorkerId,
481
484
  () => resolve(ok()),
@@ -917,14 +920,18 @@ interface ClientStorage extends Storage, BaseSqliteStorage {
917
920
  ) => void;
918
921
  readonly didWriteMessages: () => boolean;
919
922
  /**
920
- * The first error of a message that {@link Storage.writeMessages} skipped in
921
- * this request, or null.
923
+ * The first error of a message skipped in this request, or null: one that
924
+ * {@link Storage.writeMessages} could not store, or a stored change that sync
925
+ * could not send.
922
926
  */
923
927
  readonly skippedError: () =>
924
928
  | DecryptWithXChaCha20Poly1305Error
925
929
  | ProtocolInvalidDataError
926
930
  | ProtocolTimestampMismatchError
931
+ | ProtocolChangeTooLargeError
927
932
  | null;
933
+ /** Records a stored change that sync skipped, unless an error came first. */
934
+ readonly onChangeTooLarge: (error: ProtocolChangeTooLargeError) => void;
928
935
  }
929
936
 
930
937
  const createClientStorage = (
@@ -961,10 +968,12 @@ const createClientStorage = (
961
968
 
962
969
  didWriteMessages: () => didWriteMessages,
963
970
  skippedError: () => skippedError,
971
+ onChangeTooLarge: (error) => {
972
+ skippedError ??= error;
973
+ },
964
974
 
965
975
  // Not implemented yet.
966
976
  validateWriteKey: constFalse,
967
- setWriteKey: constVoid,
968
977
 
969
978
  writeMessages: (ownerIdBytes, encryptedMessages) => () => {
970
979
  // TODO: Add quota checking for collaborative scenarios.
@@ -1020,15 +1029,21 @@ const createClientStorage = (
1020
1029
  }
1021
1030
 
1022
1031
  let wroteNewMessages = false;
1023
- deps.sqlite.transaction(() => {
1024
- wroteNewMessages = applyMessages(deps)(
1025
- ownerIdBytesToOwnerId(ownerIdBytes),
1026
- messages,
1027
- QuarantineOrigin.ReceivedMessage,
1028
- now,
1029
- );
1030
- saveClock(deps)(clockTimestamp);
1031
- });
1032
+ // SQLite can fail the write, for example on a full disk. The transaction
1033
+ // has rolled back, so the route reports the error instead of a throw
1034
+ // panicking the DbWorker.
1035
+ const written = trySync(() => {
1036
+ deps.sqlite.transaction(() => {
1037
+ wroteNewMessages = applyMessages(deps)(
1038
+ ownerIdBytesToOwnerId(ownerIdBytes),
1039
+ messages,
1040
+ QuarantineOrigin.ReceivedMessage,
1041
+ now,
1042
+ );
1043
+ saveClock(deps)(clockTimestamp);
1044
+ });
1045
+ }, createUnknownError);
1046
+ if (!written.ok) return written;
1032
1047
  clock.set(clockTimestamp);
1033
1048
  // A batch of duplicates changes no table, so queries need no refresh.
1034
1049
  if (wroteNewMessages) didWriteMessages = true;
@@ -75,7 +75,7 @@ import { err, ok } from "../Result.ts";
75
75
  import { SqliteBoolean } from "../Sqlite.ts";
76
76
  import { explicitAbortReason, testCreateDeps, testCreateRun } from "../Task.ts";
77
77
  import { testCreateId } from "../Test.ts";
78
- import { Millis } from "../Time.ts";
78
+ import { maxMillis, Millis } from "../Time.ts";
79
79
  import {
80
80
  assertType,
81
81
  createIdFromString,
@@ -90,8 +90,14 @@ import {
90
90
  Uint8Array as Uint8ArrayType,
91
91
  } from "../Type.ts";
92
92
  import type { ExtractTyped } from "../Type.ts";
93
- import { DbChange } from "./Storage.ts";
94
- import { createTimestamp, type NodeId } from "./Timestamp.ts";
93
+ import { DbChange, RangeType } from "./Storage.ts";
94
+ import {
95
+ createTimestamp,
96
+ maxCounter,
97
+ maxNodeId,
98
+ type NodeId,
99
+ timestampToTimestampBytes,
100
+ } from "./Timestamp.ts";
95
101
  import {
96
102
  testCreateBroadcastChannel,
97
103
  testCreateMessageChannel,
@@ -3144,13 +3150,25 @@ describe("Evolu", () => {
3144
3150
  }),
3145
3151
  };
3146
3152
 
3147
- // A request and a broadcast assert that the change fits.
3148
- createProtocolMessageFromCrdtMessages(deps)(testAppOwner, [message]);
3153
+ const change = encodeAndEncryptDbChange(deps)(
3154
+ message,
3155
+ testAppOwner.encryptionKey,
3156
+ );
3157
+
3158
+ // A request leaves out a change that does not fit and asks for another
3159
+ // round instead, so it must be longer than the change. A broadcast
3160
+ // throws when the change does not fit.
3161
+ assertTrue(
3162
+ createProtocolMessageFromCrdtMessages(deps)(testAppOwner, [message])
3163
+ .length > change.length,
3164
+ );
3149
3165
  createProtocolBroadcastMessagesFromCrdtMessages(deps)(testAppOwner, [
3150
3166
  message,
3151
3167
  ]);
3152
3168
 
3153
- // A relay's response can pair it with the largest ranges section.
3169
+ // A relay's response can pair it with the largest ranges section: a
3170
+ // Skip range and a Timestamps range listing nearly 100,000 bytes, with
3171
+ // room left to close the frame.
3154
3172
  const rangesMaxSize = ProtocolMessageRangesMaxSize.orThrow(100_000);
3155
3173
  const timestamps = createTimestampsBuffer();
3156
3174
  for (let index = 1; timestamps.getLength() < 99_900; index++) {
@@ -3167,12 +3185,25 @@ describe("Evolu", () => {
3167
3185
  rangesMaxSize,
3168
3186
  });
3169
3187
  assertTrue(
3170
- response.canAddTimestampsRangeAndMessage(timestamps, {
3171
- timestamp: message.timestamp,
3172
- change: encodeAndEncryptDbChange(deps)(
3173
- message,
3174
- testAppOwner.encryptionKey,
3175
- ),
3188
+ response.tryWrite(() => {
3189
+ response.addMessage({ timestamp: message.timestamp, change });
3190
+ response.addRange({
3191
+ type: RangeType.Skip,
3192
+ upperBound: timestampToTimestampBytes(
3193
+ createTimestamp({ millis: Millis.orThrow(2 ** 47) }),
3194
+ ),
3195
+ });
3196
+ response.addRange({
3197
+ type: RangeType.Timestamps,
3198
+ upperBound: timestampToTimestampBytes(
3199
+ createTimestamp({
3200
+ millis: maxMillis,
3201
+ counter: maxCounter,
3202
+ nodeId: maxNodeId,
3203
+ }),
3204
+ ),
3205
+ timestamps,
3206
+ });
3176
3207
  }),
3177
3208
  );
3178
3209
  });
@@ -76,6 +76,7 @@ import type {
76
76
  import { createOwnerWebSocketTransport } from "./Owner.ts";
77
77
  import {
78
78
  encodeDbChange,
79
+ type ProtocolChangeTooLargeError,
79
80
  type ProtocolError,
80
81
  type ProtocolInvalidDataError,
81
82
  type ProtocolQuotaError,
@@ -960,7 +961,8 @@ export type DevicePersistence = "Persisted" | "NotPersisted" | "Unknown";
960
961
  * request, or sent a frame that could not be decoded, the route shows a
961
962
  * {@link ProtocolError}. A {@link ProtocolQuotaError} needs more relay quota,
962
963
  * then {@link Evolu.requestSync}, and a {@link ProtocolVersionError} needs an app
963
- * or relay update.
964
+ * or relay update. A route shows an {@link UnknownError} failure when this
965
+ * device could not store received changes, for example on a full disk.
964
966
  *
965
967
  * A received change that was not created with the owner's encryption key, was
966
968
  * altered afterwards, or cannot be decoded by this app version is skipped,
@@ -985,6 +987,14 @@ export type DevicePersistence = "Persisted" | "NotPersisted" | "Unknown";
985
987
  * {@link DecryptWithXChaCha20Poly1305Error} and your code creates or shares the
986
988
  * owner, check the owner's keys.
987
989
  *
990
+ * A {@link ProtocolChangeTooLargeError} skip is a change no protocol message can
991
+ * hold: one this device saved before {@link maxMutationSize} existed, or a
992
+ * crafted one received from a relay. Every route through a relay that lacks it
993
+ * shows it after each sync, and everything else still syncs, but changes
994
+ * received from one relay reach the others only after a reconnect or
995
+ * {@link Evolu.requestSync}. The change is not recovered, and replacing relays
996
+ * does not help.
997
+ *
988
998
  * @group Core
989
999
  */
990
1000
  export type EvoluError =
@@ -1708,7 +1718,11 @@ export const createEvolu =
1708
1718
  // gets it already batched and cannot reach the call site; quarantine,
1709
1719
  // which holds changes stored for sync; and skipping it in sync, which
1710
1720
  // keeps range fingerprints disagreeing. Received changes are not
1711
- // checked, and oversized changes stored before this limit are not
1721
+ // checked. Changes saved before this limit can be too large for any
1722
+ // protocol message, as can crafted changes on a relay. Their
1723
+ // fingerprints disagree either way, so sync skips them, which ends
1724
+ // every round instead of retrying forever. A client reports such a
1725
+ // change as its route's skippedError, and a relay logs it. They are not
1712
1726
  // recovered, which needs a history-aware design.
1713
1727
  //
1714
1728
  // Local-only tables never sync, so they have no limit.