@evolu/common 8.16.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 (51) 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/Crypto.d.ts +21 -3
  5. package/dist/src/Crypto.d.ts.map +1 -1
  6. package/dist/src/Crypto.js +18 -2
  7. package/dist/src/Error.d.ts.map +1 -1
  8. package/dist/src/Error.js +5 -1
  9. package/dist/src/Polyfills.d.ts.map +1 -1
  10. package/dist/src/Polyfills.js +3 -2
  11. package/dist/src/Task.d.ts +4 -3
  12. package/dist/src/Task.d.ts.map +1 -1
  13. package/dist/src/Task.js +4 -3
  14. package/dist/src/local-first/Db.d.ts.map +1 -1
  15. package/dist/src/local-first/Db.js +18 -7
  16. package/dist/src/local-first/Evolu.d.ts +10 -1
  17. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  18. package/dist/src/local-first/Evolu.js +5 -1
  19. package/dist/src/local-first/Owner.d.ts +20 -8
  20. package/dist/src/local-first/Owner.d.ts.map +1 -1
  21. package/dist/src/local-first/Owner.js +23 -10
  22. package/dist/src/local-first/Protocol.d.ts +215 -93
  23. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  24. package/dist/src/local-first/Protocol.js +799 -489
  25. package/dist/src/local-first/Relay.d.ts +9 -1
  26. package/dist/src/local-first/Relay.d.ts.map +1 -1
  27. package/dist/src/local-first/Relay.js +41 -36
  28. package/dist/src/local-first/Schema.d.ts +1 -1
  29. package/dist/src/local-first/Shared.d.ts +75 -49
  30. package/dist/src/local-first/Shared.d.ts.map +1 -1
  31. package/dist/src/local-first/Shared.js +62 -42
  32. package/dist/src/local-first/Storage.d.ts +80 -18
  33. package/dist/src/local-first/Storage.d.ts.map +1 -1
  34. package/dist/src/local-first/Storage.js +20 -4
  35. package/package.json +1 -1
  36. package/src/Bytes.test.ts +212 -0
  37. package/src/Bytes.ts +67 -17
  38. package/src/Crypto.ts +23 -3
  39. package/src/Error.ts +5 -1
  40. package/src/Polyfills.ts +3 -2
  41. package/src/Task.ts +4 -3
  42. package/src/local-first/Db.ts +29 -14
  43. package/src/local-first/Evolu.test.ts +43 -12
  44. package/src/local-first/Evolu.ts +16 -2
  45. package/src/local-first/Owner.test.ts +52 -1
  46. package/src/local-first/Owner.ts +26 -7
  47. package/src/local-first/Protocol.test.ts +1541 -44
  48. package/src/local-first/Protocol.ts +1070 -605
  49. package/src/local-first/Relay.ts +80 -69
  50. package/src/local-first/Shared.ts +106 -67
  51. package/src/local-first/Storage.ts +100 -27
@@ -13,7 +13,8 @@ import {
13
13
  } from "../Array.ts";
14
14
  import { assert } from "../Assert.ts";
15
15
  import type { TimingSafeEqualDep } from "../Crypto.ts";
16
- import { err, ok } from "../Result.ts";
16
+ import { createUnknownError } from "../Error.ts";
17
+ import { err, ok, trySync } from "../Result.ts";
17
18
  import type { SqliteDep } from "../Sqlite.ts";
18
19
  import { sql } from "../Sqlite.ts";
19
20
  import { createMutexByKey } from "../Task.ts";
@@ -25,12 +26,13 @@ import {
25
26
  // OwnerTransport,
26
27
  OwnerWriteKey,
27
28
  } from "./Owner.ts";
29
+ import type { ProtocolWriteKeyError } from "./Protocol.ts";
28
30
  import type {
29
31
  EncryptedDbChange,
30
32
  SqliteStorageDeps,
31
33
  Storage,
32
34
  StorageConfig,
33
- StorageQuotaError,
35
+ StorageWriteMessagesError,
34
36
  } from "./Storage.ts";
35
37
  import {
36
38
  createBaseSqliteStorage,
@@ -66,7 +68,15 @@ export interface RelayConfig extends StorageConfig {
66
68
  *
67
69
  * OwnerId is used rather than short-lived tokens because this only controls
68
70
  * relay access, not write permissions. Since all data is encrypted on the
69
- * relay, OwnerId exposure is safe.
71
+ * relay, OwnerId exposure reveals no data.
72
+ *
73
+ * It does allow write-key squatting. The relay stores the first write key
74
+ * presented for an owner, so anyone who learns an OwnerId before the owner's
75
+ * own write key reaches the relay can claim it. The owner then gets
76
+ * {@link ProtocolWriteKeyError} on this relay until the operator deletes the
77
+ * owner's row in `evolu_writeKey`. This callback does not prevent that: it
78
+ * receives only the OwnerId of a connection, which a squatter can name as
79
+ * well, and every message on the connection can name another owner.
70
80
  *
71
81
  * Owners specify which relays to connect to via `OwnerTransport`. In
72
82
  * WebSocket-based implementations, this check occurs before accepting the
@@ -186,6 +196,9 @@ export const createRelaySqliteStorage =
186
196
  return deps.timingSafeEqual(rows[0].writeKey, writeKey);
187
197
  }
188
198
 
199
+ // When SQLite fails this insert, for example on a full disk, the throw
200
+ // reaches the protocol, which logs it and answers with WriteError,
201
+ // because a boolean has no room for the error.
189
202
  deps.sqlite.exec(sql`
190
203
  insert into evolu_writeKey (ownerId, writeKey)
191
204
  values (${ownerId}, ${writeKey});
@@ -194,15 +207,6 @@ export const createRelaySqliteStorage =
194
207
  return true;
195
208
  },
196
209
 
197
- setWriteKey: (ownerId, writeKey) => {
198
- deps.sqlite.exec(sql`
199
- insert into evolu_writeKey (ownerId, writeKey)
200
- values (${ownerId}, ${writeKey})
201
- on conflict (ownerId) do update
202
- set writeKey = excluded.writeKey;
203
- `);
204
- },
205
-
206
210
  writeMessages: (ownerIdBytes, messages) => async (run) => {
207
211
  const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
208
212
  const uniqueMessagesWithTimestampBytes = dedupeArray(
@@ -215,32 +219,42 @@ export const createRelaySqliteStorage =
215
219
 
216
220
  return run(
217
221
  mutexByOwnerId.withLock(ownerId, async () => {
218
- const existingTimestampsResult =
219
- sqliteStorageBase.getExistingTimestamps(
220
- ownerIdBytes,
221
- mapArray(uniqueMessagesWithTimestampBytes, (m) => m.timestamp),
222
+ // SQLite can fail these reads, for example on a corrupt page, and
223
+ // a throw would panic the relay's shared Run.
224
+ const readResult = trySync(() => {
225
+ const existingTimestampsResult =
226
+ sqliteStorageBase.getExistingTimestamps(
227
+ ownerIdBytes,
228
+ mapArray(
229
+ uniqueMessagesWithTimestampBytes,
230
+ (m) => m.timestamp,
231
+ ),
232
+ );
233
+
234
+ const existingTimestampKeys = new Set(
235
+ mapArray(existingTimestampsResult, uint8ArrayToBase64Url),
236
+ );
237
+ const newMessages = filterArray(
238
+ uniqueMessagesWithTimestampBytes,
239
+ (message) =>
240
+ !existingTimestampKeys.has(
241
+ uint8ArrayToBase64Url(message.timestamp),
242
+ ),
222
243
  );
223
244
 
224
- const existingTimestampKeys = new Set(
225
- mapArray(existingTimestampsResult, uint8ArrayToBase64Url),
226
- );
227
- const newMessages = filterArray(
228
- uniqueMessagesWithTimestampBytes,
229
- (message) =>
230
- !existingTimestampKeys.has(
231
- uint8ArrayToBase64Url(message.timestamp),
232
- ),
233
- );
245
+ // Nothing to write
246
+ if (!isNonEmptyArray(newMessages)) return null;
234
247
 
235
- // Nothing to write
236
- if (!isNonEmptyArray(newMessages)) {
237
- return ok();
238
- }
248
+ const usage = readOwnerUsageOrDefault(deps)(
249
+ ownerIdBytes,
250
+ firstInArray(newMessages).timestamp,
251
+ );
239
252
 
240
- const usage = readOwnerUsageOrDefault(deps)(
241
- ownerIdBytes,
242
- firstInArray(newMessages).timestamp,
243
- );
253
+ return { newMessages, usage };
254
+ }, createUnknownError);
255
+ if (!readResult.ok) return readResult;
256
+ if (readResult.value === null) return ok();
257
+ const { newMessages, usage } = readResult.value;
244
258
 
245
259
  const incomingBytes = newMessages.reduce(
246
260
  (sum, m) => sum + m.change.length,
@@ -260,7 +274,7 @@ export const createRelaySqliteStorage =
260
274
  ? await quotaResult
261
275
  : quotaResult;
262
276
  if (!isWithinQuota) {
263
- return err<StorageQuotaError>({
277
+ return err<StorageWriteMessagesError>({
264
278
  type: "StorageQuotaError",
265
279
  ownerId,
266
280
  });
@@ -268,39 +282,42 @@ export const createRelaySqliteStorage =
268
282
 
269
283
  let { firstTimestamp, lastTimestamp } = usage;
270
284
 
271
- return deps.sqlite.transaction(() => {
272
- for (const { timestamp, change } of newMessages) {
273
- let strategy;
274
- [strategy, firstTimestamp, lastTimestamp] =
275
- getTimestampInsertStrategy(
285
+ // SQLite can fail the write, for example on a full disk. The
286
+ // transaction has rolled back, and a throw would panic the relay's
287
+ // shared Run.
288
+ return trySync(() => {
289
+ deps.sqlite.transaction(() => {
290
+ for (const { timestamp, change } of newMessages) {
291
+ let strategy;
292
+ [strategy, firstTimestamp, lastTimestamp] =
293
+ getTimestampInsertStrategy(
294
+ timestamp,
295
+ firstTimestamp,
296
+ lastTimestamp,
297
+ );
298
+
299
+ sqliteStorageBase.insertTimestamp(
300
+ ownerIdBytes,
276
301
  timestamp,
277
- firstTimestamp,
278
- lastTimestamp,
302
+ strategy,
279
303
  );
280
304
 
281
- sqliteStorageBase.insertTimestamp(
305
+ deps.sqlite.exec(sql`
306
+ insert into evolu_message
307
+ ("ownerId", "timestamp", "change")
308
+ values (${ownerIdBytes}, ${timestamp}, ${change})
309
+ on conflict do nothing;
310
+ `);
311
+ }
312
+
313
+ updateOwnerUsage(deps)(
282
314
  ownerIdBytes,
283
- timestamp,
284
- strategy,
315
+ newStoredBytes,
316
+ firstTimestamp,
317
+ lastTimestamp,
285
318
  );
286
-
287
- deps.sqlite.exec(sql`
288
- insert into evolu_message
289
- ("ownerId", "timestamp", "change")
290
- values (${ownerIdBytes}, ${timestamp}, ${change})
291
- on conflict do nothing;
292
- `);
293
- }
294
-
295
- updateOwnerUsage(deps)(
296
- ownerIdBytes,
297
- newStoredBytes,
298
- firstTimestamp,
299
- lastTimestamp,
300
- );
301
-
302
- return ok();
303
- });
319
+ });
320
+ }, createUnknownError);
304
321
  }),
305
322
  );
306
323
  },
@@ -329,13 +346,7 @@ export const createRelaySqliteStorage =
329
346
  delete from evolu_message where ownerId = ${ownerId};
330
347
  `);
331
348
 
332
- deps.sqlite.exec(sql`
333
- delete from evolu_usage where ownerId = ${ownerId};
334
- `);
335
-
336
349
  sqliteStorageBase.deleteOwner(ownerId);
337
-
338
- return ok();
339
350
  });
340
351
  },
341
352
  };
@@ -111,10 +111,10 @@
111
111
  * whichever instance made it, even one disposed before the database worker
112
112
  * answered. When a relay frame stores new messages, the tenant requests a round
113
113
  * through each other transport claimed for the owner, so data learned from one
114
- * relay reaches the others, except through a route that skipped a message, as
115
- * described below. A closed transport reconciles when it opens, and a
116
- * replacement leader reconciles every transport again, because a response
117
- * reporting stored messages may have been lost.
114
+ * relay reaches the others, except through a route that skipped a message it
115
+ * received or could not send, as described below. A closed transport reconciles
116
+ * when it opens, and a replacement leader reconciles every transport again,
117
+ * because a response reporting stored messages may have been lost.
118
118
  *
119
119
  * Relays omit the sending socket when broadcasting an upload, so the uploader
120
120
  * also delivers it as local Broadcast frames to every other tenant with
@@ -178,17 +178,17 @@
178
178
  * request, a replacement leader, or storing messages from another transport,
179
179
  * unless the route skipped a message. No failed or aborted result has arrived
180
180
  * on the route since.
181
- * - No result that skipped a received message has arrived on the route since a
182
- * round was last requested through it, and since that request, no messages
183
- * have been stored from another transport, directly or through a sibling copy
184
- * uploaded outside this transport, and no sibling copy has failed to apply or
185
- * skipped a message.
181
+ * - No result that skipped a received message or a stored change too large to
182
+ * send has arrived on the route since a round was last requested through it,
183
+ * and since that request, no messages have been stored from another
184
+ * transport, directly or through a sibling copy uploaded outside this
185
+ * transport, and no sibling copy has failed to apply or skipped a message.
186
186
  *
187
187
  * A route is settled when every condition except the last holds, so a complete
188
188
  * route is settled too. A settled route that is incomplete has ended its
189
- * reconciliation, but its relay may offer a message the database skipped, or
190
- * messages stored elsewhere may not have reached it; it is published as
191
- * {@link SettledSyncRoute}.
189
+ * reconciliation, but its relay may offer a message the database skipped, the
190
+ * database may store a change too large to send, or messages stored elsewhere
191
+ * may not have reached it; it is published as {@link SettledSyncRoute}.
192
192
  *
193
193
  * A reconciliation chain ends only with a converged result, a failure, an
194
194
  * abort, a dropped frame, or a continuation that finds the socket closed, and
@@ -207,20 +207,34 @@
207
207
  * while the database worker creates a round is logged there and fails the
208
208
  * round's routes with `SyncFailed` without a retry. A mutation that throws is
209
209
  * rolled back and reported as an {@link UnknownError} to the tab that made it,
210
- * or to every tab when its Evolu instance was disposed first. Other unexpected
211
- * SQLite exceptions remain unsupported and can panic the database worker. A
212
- * frame the relay silently drops, such as invalid data, leaves the count above
213
- * zero until the liveness rule below replaces the socket.
214
- *
215
- * A result that skipped a received message the database could not decrypt,
216
- * verify, or decode records the error on the route as its `skippedError`,
217
- * without ending its chain, so it requests no round, and the relay offers the
218
- * message again in every round through the route until the database stores a
219
- * message with that timestamp. The messages received elsewhere that the last
220
- * condition names request no round through such a route, even while a requested
221
- * round checks it again, because each round would download every skipped
222
- * message again. The next round that an explicit request, a reopen, a
223
- * replacement leader, or a failure sends through the route reconciles them.
210
+ * or to every tab when its Evolu instance was disposed first. A received batch
211
+ * SQLite fails to store, for example on a full disk, is rolled back and fails
212
+ * its route with an `UnknownError`. Other unexpected SQLite exceptions remain
213
+ * unsupported and can panic the database worker. A frame the relay silently
214
+ * drops, such as invalid data, leaves the count above zero until the liveness
215
+ * rule below replaces the socket.
216
+ *
217
+ * A result that skipped a message records the error on the route as its
218
+ * `skippedError`, without ending its chain, so it requests no round. A message
219
+ * is skipped in two cases:
220
+ *
221
+ * - The relay offers a message the database could not decrypt, verify, or decode.
222
+ * The relay offers it again in every round through the route until the
223
+ * database stores a message with that timestamp.
224
+ * - The database stores a change it cannot send, a
225
+ * {@link ProtocolChangeTooLargeError}: one saved before `maxMutationSize`
226
+ * existed, or a crafted one received from a relay. Every route through a
227
+ * relay that lacks it skips it on every sync, so each such route stays
228
+ * settled.
229
+ *
230
+ * The messages received elsewhere that the last condition names request no
231
+ * round through such a route, even while a requested round checks it again,
232
+ * because each round would download every skipped message again, or skip again
233
+ * a change too large to send. The next round that an explicit request, a
234
+ * reopen, a replacement leader, or a failure sends through the route reconciles
235
+ * them. So while the database stores a change too large to send, messages from
236
+ * other relays reach a route only through such a round, for example after a
237
+ * reconnect or {@link Evolu.requestSync}.
224
238
  *
225
239
  * ### Liveness
226
240
  *
@@ -353,6 +367,7 @@ import {
353
367
  MessageType,
354
368
  parseProtocolHeader,
355
369
  type ApplyProtocolMessageAsClientResult,
370
+ type ProtocolChangeTooLargeError,
356
371
  type ProtocolError,
357
372
  type ProtocolInvalidDataError,
358
373
  type ProtocolMessage,
@@ -659,8 +674,9 @@ export interface ReadonlySyncTenantOwner extends Typed<"Readonly"> {
659
674
 
660
675
  /**
661
676
  * One database's use of one owner through one transport: `Pending` until its
662
- * reconciliation ends, then `Complete`, or `Settled` while its relay offers a
663
- * change the database skipped. See Synchronization completion in this module's
677
+ * reconciliation ends, then `Complete`, or `Settled` while it skips a change:
678
+ * one its relay offers that the database could not store, or one the database
679
+ * stores but cannot send. See Synchronization completion in this module's
664
680
  * documentation.
665
681
  */
666
682
  export type SyncRoute = PendingSyncRoute | SettledSyncRoute | CompleteSyncRoute;
@@ -680,9 +696,12 @@ export interface PendingSyncRoute extends Typed<"Pending"> {
680
696
  */
681
697
  readonly failure: SyncRouteError | null;
682
698
  /**
683
- * The first change skipped in the latest reply that skipped one, or null. The
684
- * relay offers it again in every round through the route until this database
685
- * stores a change with that timestamp.
699
+ * The first change skipped in the latest reply that skipped one, or null. A
700
+ * change the relay offers is offered again in every round through the route
701
+ * until this database stores a change with that timestamp. A
702
+ * {@link ProtocolChangeTooLargeError} is a change this database stores but
703
+ * cannot send, which every route through a relay that lacks it skips on every
704
+ * sync.
686
705
  */
687
706
  readonly skippedError: SyncRouteError | null;
688
707
  /** When the route last became complete, or null. */
@@ -697,10 +716,15 @@ export interface PendingSyncRoute extends Typed<"Pending"> {
697
716
  }
698
717
 
699
718
  /**
700
- * A route whose reconciliation ended while its relay offers a change the
701
- * database skipped, so it is incomplete. Changes stored from other relays
702
- * request no round through it; the next round requested through it, such as by
703
- * {@link Evolu.requestSync} or a reopen, checks it again.
719
+ * A route whose reconciliation ended with a skipped change, so it is
720
+ * incomplete: its relay offers a change the database could not store, or the
721
+ * database stores a change it cannot send, a
722
+ * {@link ProtocolChangeTooLargeError}. Changes stored from other relays request
723
+ * no round through it; the next round requested through it, such as by
724
+ * {@link Evolu.requestSync} or a reopen, checks it again. Every route through a
725
+ * relay that lacks a change too large to send skips it on every sync, so those
726
+ * routes stay settled, and messages from other relays reach them only through
727
+ * such rounds.
704
728
  */
705
729
  export interface SettledSyncRoute extends Typed<"Settled"> {
706
730
  readonly transportId: SyncTransportId;
@@ -734,11 +758,13 @@ export interface CompleteSyncRoute extends Typed<"Complete"> {
734
758
  *
735
759
  * It is the error itself, so it carries its details, such as the expected and
736
760
  * actual timestamps of a {@link ProtocolTimestampMismatchError}. A skipped
737
- * message adds {@link DecryptWithXChaCha20Poly1305Error}. A
738
- * {@link ProtocolInvalidDataError} leaves out its data, which can be a whole
739
- * frame. `WriteFailed` means a `writeMessages` call that threw, logged by the
740
- * protocol, and `SyncFailed` means a logged failure while creating a round or
741
- * reconciling ranges.
761
+ * message adds {@link DecryptWithXChaCha20Poly1305Error} and
762
+ * {@link ProtocolChangeTooLargeError}. A {@link ProtocolInvalidDataError} leaves
763
+ * out its data, which can be a whole frame. An {@link UnknownError} means SQLite
764
+ * failed to store received messages, for example on a full disk. `WriteFailed`
765
+ * means a `writeMessages` call that threw, logged by the protocol, and
766
+ * `SyncFailed` means a logged failure while creating a round or reconciling
767
+ * ranges.
742
768
  */
743
769
  export type SyncRouteError = (
744
770
  | Exclude<ProtocolError, ProtocolInvalidDataError>
@@ -749,6 +775,7 @@ export type SyncRouteError = (
749
775
  | (Omit<DecryptWithXChaCha20Poly1305Error, "error"> & {
750
776
  readonly error: UnknownError;
751
777
  })
778
+ | ProtocolChangeTooLargeError
752
779
  | Typed<"WriteFailed">
753
780
  | Typed<"SyncFailed">
754
781
  ) & { readonly at: Millis };
@@ -779,10 +806,10 @@ export interface RelaySyncState {
779
806
  * - `Synced`: a relay is up to date, and none syncs.
780
807
  * - `Offline`: every relay is disconnected. Evolu keeps reconnecting, up to 30
781
808
  * seconds apart, so `Offline` can briefly outlast the outage.
782
- * - `Error`: a relay failed or offers a change this database skipped. `error` is
783
- * the newest failure, or without one, the newest skipped change: a failure
784
- * stops syncing through its relay, while a skipped change leaves out only
785
- * that change.
809
+ * - `Error`: a relay failed, a relay offers a change this database skipped, or
810
+ * this database stores a change too large to send. `error` is the newest
811
+ * failure, or without one, the newest skipped change: a failure stops syncing
812
+ * through its relay, while a skipped change leaves out only that change.
786
813
  *
787
814
  * Evolu stores changes in the local database before they sync, so sync needs no
788
815
  * UI while it works: show nothing for `NoRelays`, `Syncing`, and `Synced`. An
@@ -1365,13 +1392,15 @@ export type DbWorkerQueuedResponse =
1365
1392
  readonly ownerId: OwnerId;
1366
1393
  readonly didWriteMessages: boolean;
1367
1394
  /**
1368
- * The first error of a received message the DbWorker skipped while
1369
- * storing the rest, or null. It does not end the round.
1395
+ * The first error of a message the DbWorker skipped, or null: a
1396
+ * received one it did not store while storing the rest, or a stored
1397
+ * change too large to send. It does not end the round.
1370
1398
  */
1371
1399
  readonly skippedError:
1372
1400
  | DecryptWithXChaCha20Poly1305Error
1373
1401
  | ProtocolInvalidDataError
1374
1402
  | ProtocolTimestampMismatchError
1403
+ | ProtocolChangeTooLargeError
1375
1404
  | null;
1376
1405
  readonly result: Result<
1377
1406
  ApplyProtocolMessageAsClientResult,
@@ -1529,8 +1558,10 @@ interface PendingRoute extends Typed<"Pending"> {
1529
1558
  }
1530
1559
 
1531
1560
  /**
1532
- * A message a route skipped. Its relay offers the message again in every round,
1533
- * so messages received elsewhere request no round through the route.
1561
+ * A message a route skipped: one its relay offers that the database could not
1562
+ * store, which the relay offers again in every round, or one the database
1563
+ * stores but cannot send, which every round skips again. So messages received
1564
+ * elsewhere request no round through the route.
1534
1565
  */
1535
1566
  interface RouteSkip {
1536
1567
  readonly error: SyncRouteError;
@@ -1543,8 +1574,9 @@ interface RouteSkip {
1543
1574
  }
1544
1575
 
1545
1576
  /**
1546
- * A route whose reconciliation has ended, but whose relay may offer a skipped
1547
- * message or lack messages stored elsewhere, so it is incomplete.
1577
+ * A route whose reconciliation has ended, but which is incomplete: its relay
1578
+ * may offer a skipped message, the database may store a change too large to
1579
+ * send, or the relay may lack messages stored elsewhere.
1548
1580
  */
1549
1581
  interface SettledRoute extends Typed<"Settled"> {
1550
1582
  readonly skippedError: SyncRouteError;
@@ -1584,6 +1616,7 @@ const errorToSyncRouteError = (
1584
1616
  | ProtocolError
1585
1617
  | StorageWriteMessagesError
1586
1618
  | DecryptWithXChaCha20Poly1305Error
1619
+ | ProtocolChangeTooLargeError
1587
1620
  | Typed<"WriteFailed">
1588
1621
  | Typed<"SyncFailed">,
1589
1622
  at: Millis,
@@ -1773,8 +1806,9 @@ export const initSharedWorker =
1773
1806
 
1774
1807
  // Held while this worker runs. Its DbWorkers stop once they can take it,
1775
1808
  // because a Dispose posted right before this worker closes can be lost, as
1776
- // in Firefox. Taken before the build lock, so nothing delays the end of
1777
- // starting once that lock is held.
1809
+ // in Firefox (https://bugzilla.mozilla.org/show_bug.cgi?id=2077609). Taken
1810
+ // before the build lock, so nothing delays the end of starting once that
1811
+ // lock is held.
1778
1812
  disposer.use(await run.ok(acquireLeaderLock(workerId)));
1779
1813
 
1780
1814
  // Released after every tenant is disposed and has told its DbWorker to
@@ -2780,7 +2814,8 @@ const createEvoluTenant =
2780
2814
  !hasQueuedWrite;
2781
2815
  // Settling drops the failure, so the next one requests a round
2782
2816
  // again. A route with a skip it is not rechecking settles without
2783
- // completing, because its relay may offer the skipped message or
2817
+ // completing, because its relay may offer the skipped message, the
2818
+ // database may store a change too large to send, or the relay may
2784
2819
  // lack messages stored elsewhere.
2785
2820
  const pending = routeToPending(route.progress);
2786
2821
  route.progress =
@@ -2887,11 +2922,12 @@ const createEvoluTenant =
2887
2922
  if (!isAborted) route.lastReceivedAt = now;
2888
2923
  // A skipped message is recorded as the route's skip, not a
2889
2924
  // failure, and does not end the round, whose response below is
2890
- // still sent, so it requests no round. The relay offers the
2891
- // message again in every later round, so the route stays
2892
- // incomplete until a round requested through it settles without
2893
- // skipping a message and without messages stored elsewhere since
2894
- // that request.
2925
+ // still sent, so it requests no round. The relay offers a message
2926
+ // the database could not store again in every later round, and
2927
+ // every round skips a stored change too large to send again, so
2928
+ // the route stays incomplete until a round requested through it
2929
+ // settles without skipping a message and without messages stored
2930
+ // elsewhere since that request.
2895
2931
  if (skippedError !== null)
2896
2932
  route.progress = {
2897
2933
  ...routeToPending(route.progress),
@@ -2918,12 +2954,14 @@ const createEvoluTenant =
2918
2954
  }
2919
2955
  } else if (failure !== null || skippedError !== null) {
2920
2956
  // A sibling's copy comes from this worker, not from a relay, so no
2921
- // route shows its failure. It is a Broadcast, which carries no
2922
- // relay error, so an error or a skip means a bug, such as
2923
- // databases holding different keys for the owner, and is reported
2924
- // as an unexpected failure. A Failed result was logged, which
2925
- // reports it already. An error is the failure, which is never an
2926
- // abort, and like a route, the report leaves out its frame.
2957
+ // route shows its failure. SQLite can fail to store it, for example
2958
+ // on a full disk, which returns an UnknownError. It is a Broadcast,
2959
+ // which carries no relay error, so any other error or a skip means
2960
+ // a bug, such as databases holding different keys for the owner.
2961
+ // Each is reported as an unexpected failure. A Failed result was
2962
+ // logged, which reports it already. An error is the failure, which
2963
+ // is never an abort, and like a route, the report leaves out its
2964
+ // frame.
2927
2965
  const unexpected = error !== null ? failure : skippedError;
2928
2966
  if (unexpected !== null)
2929
2967
  deps.postConsoleEntryOrError({
@@ -3043,9 +3081,10 @@ const createEvoluTenant =
3043
3081
  * `except`, after messages from elsewhere were stored or a sibling copy was
3044
3082
  * not fully stored. Rounds toward the same transport coalesce whatever
3045
3083
  * their source. A route that skipped a message gets none, because its relay
3046
- * would offer that message again, and a route rechecking one stops
3047
- * rechecking, because its round may have read the database before this
3048
- * event. A later requested round through such a route reconciles it.
3084
+ * would offer that message again or the round would skip a stored change
3085
+ * too large to send again. A route rechecking one stops rechecking, because
3086
+ * its round may have read the database before this event. A later requested
3087
+ * round through such a route reconciles it.
3049
3088
  */
3050
3089
  const requestRoundsForReceivedMessages = (
3051
3090
  ownerId: OwnerId,