@evolu/common 8.11.0 → 8.13.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 (72) 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/Callbacks.d.ts +12 -1
  5. package/dist/src/Callbacks.d.ts.map +1 -1
  6. package/dist/src/Callbacks.js +3 -0
  7. package/dist/src/Error.d.ts +10 -4
  8. package/dist/src/Error.d.ts.map +1 -1
  9. package/dist/src/Error.js +10 -4
  10. package/dist/src/Object.d.ts +65 -0
  11. package/dist/src/Object.d.ts.map +1 -1
  12. package/dist/src/Object.js +142 -0
  13. package/dist/src/Resource.d.ts +0 -5
  14. package/dist/src/Resource.d.ts.map +1 -1
  15. package/dist/src/Resource.js +6 -13
  16. package/dist/src/Sqlite.d.ts.map +1 -1
  17. package/dist/src/Sqlite.js +7 -0
  18. package/dist/src/Task.d.ts +10 -8
  19. package/dist/src/Task.d.ts.map +1 -1
  20. package/dist/src/Task.js +41 -5
  21. package/dist/src/Worker.d.ts +3 -3
  22. package/dist/src/index.d.ts +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +1 -1
  25. package/dist/src/local-first/Db.d.ts +8 -3
  26. package/dist/src/local-first/Db.d.ts.map +1 -1
  27. package/dist/src/local-first/Db.js +56 -19
  28. package/dist/src/local-first/Evolu.d.ts +142 -24
  29. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  30. package/dist/src/local-first/Evolu.js +109 -8
  31. package/dist/src/local-first/Owner.d.ts +9 -0
  32. package/dist/src/local-first/Owner.d.ts.map +1 -1
  33. package/dist/src/local-first/Owner.js +9 -0
  34. package/dist/src/local-first/Protocol.d.ts +18 -7
  35. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  36. package/dist/src/local-first/Protocol.js +45 -28
  37. package/dist/src/local-first/Relay.d.ts.map +1 -1
  38. package/dist/src/local-first/Relay.js +4 -2
  39. package/dist/src/local-first/Schema.d.ts +18 -3
  40. package/dist/src/local-first/Schema.d.ts.map +1 -1
  41. package/dist/src/local-first/Shared.d.ts +523 -133
  42. package/dist/src/local-first/Shared.d.ts.map +1 -1
  43. package/dist/src/local-first/Shared.js +696 -256
  44. package/dist/src/local-first/Storage.d.ts +19 -15
  45. package/dist/src/local-first/Storage.d.ts.map +1 -1
  46. package/dist/src/local-first/Storage.js +4 -2
  47. package/package.json +1 -1
  48. package/src/Bytes.test.ts +27 -0
  49. package/src/Bytes.ts +58 -2
  50. package/src/Callbacks.test.ts +20 -0
  51. package/src/Callbacks.ts +17 -1
  52. package/src/Error.ts +10 -4
  53. package/src/Object.test.ts +296 -0
  54. package/src/Object.ts +163 -0
  55. package/src/Resource.test.ts +20 -16
  56. package/src/Resource.ts +6 -20
  57. package/src/Sqlite.ts +7 -0
  58. package/src/Task.test.ts +233 -62
  59. package/src/Task.ts +47 -13
  60. package/src/Worker.ts +3 -3
  61. package/src/index.ts +6 -1
  62. package/src/local-first/Db.ts +88 -18
  63. package/src/local-first/Evolu.test.ts +589 -2
  64. package/src/local-first/Evolu.ts +285 -36
  65. package/src/local-first/Owner.ts +9 -0
  66. package/src/local-first/Protocol.test.ts +59 -60
  67. package/src/local-first/Protocol.ts +67 -47
  68. package/src/local-first/Relay.ts +4 -2
  69. package/src/local-first/Schema.ts +20 -3
  70. package/src/local-first/Shared.test.ts +2633 -646
  71. package/src/local-first/Shared.ts +1192 -364
  72. package/src/local-first/Storage.ts +24 -23
@@ -53,7 +53,9 @@
53
53
  * still waiting after three seconds reports {@link OtherBuildRunningError} to
54
54
  * its tabs. Every build broadcasts console entries and errors on
55
55
  * {@link consoleEntryOrErrorBroadcastChannelName}, so a waiting tab also prints
56
- * the running build's output and reports its errors as its own `evoluError`.
56
+ * the running build's output, reports each {@link UnknownError} it sends as its
57
+ * own `evoluError`, and only logs any other error, such as an older build's
58
+ * sync error.
57
59
  *
58
60
  * The tabs of one worker elect the host of its DbWorkers among themselves, with
59
61
  * a lock scoped to the worker, so a tab of another worker never hosts them.
@@ -100,7 +102,8 @@
100
102
  * writable registrations for its owner, whichever instance made it, even one
101
103
  * disposed before the database worker answered. When a relay frame stores new
102
104
  * messages, the tenant requests a round through each other transport claimed
103
- * for the owner, so data learned from one relay reaches the others. A closed
105
+ * for the owner, so data learned from one relay reaches the others, except
106
+ * through a route that skipped a message, as described below. A closed
104
107
  * transport reconciles when it opens, and a replacement leader reconciles every
105
108
  * transport again, because a response reporting stored messages may have been
106
109
  * lost.
@@ -109,26 +112,34 @@
109
112
  * also delivers it as local Broadcast frames to every other tenant with
110
113
  * writable access to the owner, even while sockets are closed. A copy keeps the
111
114
  * uploader's target, and a recipient that stores new continuation messages
112
- * reconciles them through the transports outside that target. Local delivery
113
- * forwards uploads, including historical messages sent during reconciliation,
114
- * but does not reconcile local database histories with each other. That waits
115
- * until replication scopes and retention semantics are defined.
115
+ * reconciles them through the transports outside that target, except through a
116
+ * route that skipped a message. Local delivery forwards uploads, including
117
+ * historical messages sent during reconciliation, but does not reconcile local
118
+ * database histories with each other. That waits until replication scopes and
119
+ * retention semantics are defined.
116
120
  *
117
121
  * ## Sync state
118
122
  *
119
123
  * The shared worker publishes one plain snapshot, {@link SyncState}, of every
120
124
  * transport it manages and every database and owner registration it holds, with
121
- * one route per writable registration and transport, as specified below.
122
- * {@link syncStateToOwnerSyncStates} derives one state per database and owner.
125
+ * one route per writable registration and transport, as specified below. Each
126
+ * part is a union of the states the worker keeps for it: a transport's
127
+ * connection, a database that is active or refused startup, a writable or
128
+ * readonly owner, and a pending, settled, or complete route. A transport's
129
+ * events drive its connection, so publishing never reads a socket.
130
+ *
131
+ * Apps show users an owner's {@link OwnerSyncStatus}, which
132
+ * {@link syncStateToOwnerSyncStatus} derives. A view of each relay pairs each
133
+ * route with its transport by {@link syncStateToRelaySyncStates} and tells the
134
+ * relay's status by {@link relaySyncStateToStatus}. Snapshots store neither, so
135
+ * a status never disagrees with the routes it comes from.
123
136
  *
124
137
  * Each worker broadcasts snapshots on its own channel, whose name a connecting
125
138
  * tab receives through its port, so a tab never hears another worker, such as
126
139
  * one of a different app version, and
127
140
  * {@link SyncStateDep.syncState | deps.syncState} keeps the last snapshot. The
128
- * worker publishes after every change it observes; a transition without an
129
- * event, such as a closed socket starting to reconnect, appears with the next
130
- * snapshot. The snapshot lives in worker memory only, so a new worker starts
131
- * empty.
141
+ * worker publishes after every change it observes. The snapshot lives in worker
142
+ * memory only, so a new worker starts empty.
132
143
  *
133
144
  * ## Synchronization completion
134
145
  *
@@ -156,27 +167,51 @@
156
167
  * - The tenant has sent a round through the transport since the last event that
157
168
  * requires one: its first use of the transport for the owner, the socket
158
169
  * opening, an explicit request, a replacement leader, or storing messages
159
- * from another transport. No failed or aborted result has arrived on the
160
- * route since.
170
+ * from another transport, unless the route skipped a message. No failed or
171
+ * aborted result has arrived on the route since.
172
+ * - No result that skipped a received message has arrived on the route since a
173
+ * round was last requested through it, and since that request, no messages
174
+ * have been stored from another transport, directly or through a sibling copy
175
+ * uploaded outside this transport, and no sibling copy has failed to apply or
176
+ * skipped a message.
177
+ *
178
+ * A route is settled when every condition except the last holds, so a complete
179
+ * route is settled too. A settled route that is incomplete has ended its
180
+ * reconciliation, but its relay may offer a message the database skipped, or
181
+ * messages stored elsewhere may not have reached it; it is published as
182
+ * {@link SettledSyncRoute}.
161
183
  *
162
184
  * A reconciliation chain ends only with a converged result, a failure, an
163
185
  * abort, a dropped frame, or a continuation that finds the socket closed, and
164
186
  * relay errors reach every applying tenant, so these conditions mean every
165
187
  * chain, including this tenant's, converged. A local Broadcast from a sibling
166
188
  * tenant holds every route of its owner until it is applied, because it arrives
167
- * without a request of its own; one that fails to apply requires a round
168
- * through every transport.
189
+ * without a request of its own; one that fails to apply or skips a message
190
+ * requires a round through every transport whose route has not skipped one.
169
191
  *
170
192
  * A failed result on a route requests one round through it. Any further failure
171
- * before the route completes waits for an explicit request or a reopen, so a
193
+ * before the route settles waits for an explicit request or a reopen, so a
172
194
  * persistent failure cannot loop; a converged reply in between does not end the
173
195
  * wait, because it may answer another tenant's round on the shared socket. An
174
196
  * aborted apply leaves its routes incomplete without a retry. An exception
175
197
  * while the database worker creates a round is logged there and fails the
176
- * round's routes with `SyncFailed` without a retry; other unexpected SQLite
177
- * exceptions remain unsupported and can panic the database worker. A frame the
178
- * relay silently drops, such as invalid data, leaves the count above zero until
179
- * the liveness rule below replaces the socket.
198
+ * round's routes with `SyncFailed` without a retry. A mutation that throws is
199
+ * rolled back and reported as an {@link UnknownError} to the tab that made it,
200
+ * or to every tab when its Evolu instance was disposed first. Other unexpected
201
+ * SQLite exceptions remain unsupported and can panic the database worker. A
202
+ * frame the relay silently drops, such as invalid data, leaves the count above
203
+ * zero until the liveness rule below replaces the socket.
204
+ *
205
+ * A result that skipped a received message the database could not decrypt,
206
+ * verify, or decode records the error on the route as its `skippedError`,
207
+ * without ending its chain, so it requests no round, and the relay offers the
208
+ * message again in every round through the route until the database stores a
209
+ * message with that timestamp. The messages received elsewhere that the last
210
+ * condition names request no round through such a route, even while a requested
211
+ * round checks it again, because each round would download every skipped
212
+ * message again. The next round that a first use of the transport for the
213
+ * owner, an explicit request, a reopen, a replacement leader, or a failure
214
+ * sends through the route reconciles them.
180
215
  *
181
216
  * ### Liveness
182
217
  *
@@ -196,11 +231,13 @@
196
231
  * The timeout belongs to the transport, not to an owner, because a small reply
197
232
  * for one owner can wait behind another owner's large frame on the socket. A
198
233
  * grown timeout lasts while a request is outstanding on the socket or a
199
- * database that has not refused startup has an incomplete route through it,
200
- * including one waiting after a failure, and ends with the transport. A reply's
201
- * speed proves nothing, because a recovery on a slow link starts with small
202
- * replies that arrive quickly. The reopen resets the counts and starts the open
203
- * rounds, so a dropped frame delays a route instead of stranding it.
234
+ * database that has not refused startup has an unsettled route through it,
235
+ * including one waiting after a failure, and ends with the transport. A settled
236
+ * route that is incomplete does not count, because its reconciliation has
237
+ * ended. A reply's speed proves nothing, because a recovery on a slow link
238
+ * starts with small replies that arrive quickly. The reopen resets the counts
239
+ * and starts the open rounds, so a dropped frame delays a route instead of
240
+ * stranding it.
204
241
  *
205
242
  * The shared worker sends nothing while idle, so a path that dies then may go
206
243
  * unnoticed until its next request; until then, changes from other devices stop
@@ -213,7 +250,8 @@
213
250
  import { type NonEmptyReadonlyArray } from "../Array.ts";
214
251
  import type { Brand } from "../Brand.ts";
215
252
  import type { ConsoleEntry, ConsoleLevel } from "../Console.ts";
216
- import type { EncryptionKey } from "../Crypto.ts";
253
+ import type { DecryptWithXChaCha20Poly1305Error, EncryptionKey } from "../Crypto.ts";
254
+ import { type UnknownError } from "../Error.ts";
217
255
  import { type LockManagerDep } from "../LockManager.ts";
218
256
  import { type Result } from "../Result.ts";
219
257
  import type { NonEmptyReadonlySet } from "../Set.ts";
@@ -221,12 +259,11 @@ import type { SqliteSchema } from "../Sqlite.ts";
221
259
  import { AbortError, type Task } from "../Task.ts";
222
260
  import { type Millis } from "../Time.ts";
223
261
  import { type ExtractTyped, type Id, type InferType, type LiteralType, type Name, type ObjectType, type Typed } from "../Type.ts";
224
- import type { CreateWebSocketDep, WebSocketError, WebSocketReadyState } from "../WebSocket.ts";
262
+ import type { CreateWebSocketDep, WebSocketError } from "../WebSocket.ts";
225
263
  import type { SharedWorker as CommonSharedWorker, CreateBroadcastChannelDep, CreateMessageChannelDep, NativeMessagePort, SharedWorkerSelf, WorkerDeps } from "../Worker.ts";
226
264
  import type { DbWorkerInit, UnsupportedDbVersionError } from "./Db.ts";
227
- import type { EvoluError } from "./Evolu.ts";
228
265
  import type { Owner, OwnerId, SyncOwner } from "./Owner.ts";
229
- import { type ApplyProtocolMessageAsClientResult, type ProtocolError, type ProtocolMessage } from "./Protocol.ts";
266
+ import { type ApplyProtocolMessageAsClientResult, type ProtocolError, type ProtocolInvalidDataError, type ProtocolMessage, type ProtocolTimestampMismatchError } from "./Protocol.ts";
230
267
  import { type Patch, type Query, type RowsByQueryMap } from "./Query.ts";
231
268
  import { type MutationChange } from "./Schema.ts";
232
269
  import type { CrdtMessage, StorageWriteMessagesError } from "./Storage.ts";
@@ -256,11 +293,11 @@ export type SharedWorkerInput = {
256
293
  };
257
294
  export type SharedWorkerOutput = DbWorkerInit | {
258
295
  /**
259
- * Sent to one tab only: its database refused startup, or another build
260
- * keeps this worker waiting.
296
+ * Sent to one tab only: its database refused startup, a mutation it made
297
+ * could not be stored, or another build keeps this worker waiting.
261
298
  */
262
299
  readonly type: "Error";
263
- readonly error: UnsupportedDbVersionError | OtherBuildRunningError;
300
+ readonly error: OtherBuildRunningError | UnknownError | UnsupportedDbVersionError;
264
301
  } | {
265
302
  /**
266
303
  * Sent to a tab that connects while the worker waits for the build lock;
@@ -291,7 +328,7 @@ export type ConsoleEntryOrError = {
291
328
  readonly entry: ConsoleEntry;
292
329
  } | {
293
330
  readonly type: "Error";
294
- readonly error: EvoluError;
331
+ readonly error: UnknownError;
295
332
  };
296
333
  export declare const consoleEntryOrErrorBroadcastChannelName = "evolu:console-entry-or-error";
297
334
  /** Identifies one running SharedWorker instance. */
@@ -337,126 +374,466 @@ export interface BuildWaitingRequest extends InferType<typeof BuildWaitingReques
337
374
  export interface OtherBuildRunningError extends Typed<"OtherBuildRunningError"> {
338
375
  }
339
376
  /**
340
- * A snapshot of the transports and databases the shared worker manages.
341
- *
342
- * See the Sync state section of this module's documentation.
377
+ * A snapshot of the transports and databases the shared worker manages. Each
378
+ * part is a union of the states the worker keeps for it, so each part holds
379
+ * only the fields valid for its state. Apps derive what to show with
380
+ * {@link syncStateToOwnerSyncStatus}; see Sync state in this module's
381
+ * documentation.
343
382
  */
344
383
  export interface SyncState {
345
384
  readonly transports: ReadonlyArray<SyncTransport>;
346
385
  readonly tenants: ReadonlyArray<SyncTenant>;
347
386
  }
348
- /** One WebSocket, shared by every owner and database claiming it. */
349
- export interface SyncTransport {
350
- /** Opaque and stable for the transport's lifetime, across socket replacements. */
387
+ /**
388
+ * One transport, shared by every owner and database claiming it. Its `type` is
389
+ * the kind of transport, and its {@link SyncConnection} tells whether it is
390
+ * connected.
391
+ */
392
+ export type SyncTransport = WebSocketSyncTransport;
393
+ /** A WebSocket {@link SyncTransport}. */
394
+ export interface WebSocketSyncTransport extends Typed<"WebSocket"> {
395
+ /** Opaque and stable for the transport's lifetime, across reconnects. */
351
396
  readonly id: SyncTransportId;
352
- /** The URL without its query, which carries the owner ID. */
397
+ /** The relay URL without the owner-specific query. */
353
398
  readonly label: string;
354
- readonly readyState: WebSocketReadyState;
355
- /** When the connection last opened, or null before its first open. */
356
- readonly openedAt: Millis | null;
357
- /** When the connection last closed, or null before its first close. */
358
- readonly closedAt: Millis | null;
359
- /**
360
- * The last error, retained after a successful reconnect; null if none. Errors
361
- * while reconnecting are routine.
362
- */
363
- readonly error: SyncTransportError | null;
399
+ readonly connection: SyncConnection;
364
400
  }
365
401
  export type SyncTransportId = Id & Brand<"SyncTransport">;
366
402
  export interface SyncTransportError {
367
403
  readonly type: WebSocketError["type"];
368
404
  readonly at: Millis;
369
405
  }
370
- /** One named local database and the owners it registered. */
371
- export interface SyncTenant {
406
+ /**
407
+ * The connection of a {@link SyncTransport}: `Connecting` until its first
408
+ * connection opens or fails, `Open` while a connection is open, and
409
+ * `Disconnected` from a close, a failure, or an unanswered request that
410
+ * replaced the connection, until a connection opens again. It never returns to
411
+ * `Connecting`. The transport's events drive these states.
412
+ */
413
+ export type SyncConnection = ConnectingSyncConnection | OpenSyncConnection | DisconnectedSyncConnection;
414
+ /**
415
+ * A first connection, which has neither opened nor failed. A connection to a
416
+ * host that drops packets stays here until the operating system or browser
417
+ * gives up on it, which can take a minute or more.
418
+ */
419
+ export interface ConnectingSyncConnection extends Typed<"Connecting"> {
420
+ }
421
+ /** An open connection. */
422
+ export interface OpenSyncConnection extends Typed<"Open"> {
423
+ /** When this connection opened. */
424
+ readonly openedAt: Millis;
425
+ /** The last error of an earlier connection or attempt, or null. */
426
+ readonly error: SyncTransportError | null;
427
+ }
428
+ /**
429
+ * No connection: a connection closed or failed, or the relay did not answer a
430
+ * request in time. The transport reconnects by itself.
431
+ */
432
+ export interface DisconnectedSyncConnection extends Typed<"Disconnected"> {
433
+ /** When it disconnected. Failed reconnect attempts leave it unchanged. */
434
+ readonly disconnectedAt: Millis;
435
+ /** When the last connection opened, or null when none has. */
436
+ readonly openedAt: Millis | null;
437
+ /**
438
+ * The last error, or null. A close or an unanswered request records none, so
439
+ * it can come from an earlier connection. Errors while reconnecting are
440
+ * routine.
441
+ */
442
+ readonly error: SyncTransportError | null;
443
+ }
444
+ /** One named local database: `Active`, or `Refused` when it refused startup. */
445
+ export type SyncTenant = ActiveSyncTenant | RefusedSyncTenant;
446
+ /**
447
+ * A database that has not refused startup, including one still starting, with
448
+ * the owners its instances registered.
449
+ */
450
+ export interface ActiveSyncTenant extends Typed<"Active"> {
372
451
  readonly name: Name;
373
- /** The database refused startup, so nothing it holds synchronizes. */
374
- readonly refused: boolean;
375
452
  readonly owners: ReadonlyArray<SyncTenantOwner>;
376
453
  }
377
- export interface SyncTenantOwner {
454
+ /**
455
+ * A database that refused startup, so nothing it holds syncs. Its tabs also
456
+ * receive the error through `evoluError`.
457
+ */
458
+ export interface RefusedSyncTenant extends Typed<"Refused"> {
459
+ readonly name: Name;
460
+ readonly error: UnsupportedDbVersionError;
461
+ }
462
+ /**
463
+ * An owner registered by any instance of an {@link ActiveSyncTenant}: `Writable`
464
+ * when any registration holds its write key, otherwise `Readonly`.
465
+ */
466
+ export type SyncTenantOwner = WritableSyncTenantOwner | ReadonlySyncTenantOwner;
467
+ /**
468
+ * An owner the database syncs, with one route per transport claimed for it by
469
+ * this or any other database. Routes are briefly empty while the owner's
470
+ * transports are being claimed.
471
+ */
472
+ export interface WritableSyncTenantOwner extends Typed<"Writable"> {
473
+ readonly ownerId: OwnerId;
474
+ readonly routes: ReadonlyArray<SyncRoute>;
475
+ }
476
+ /**
477
+ * An owner registered only without its write key. Its registrations hold
478
+ * transports, which other databases' routes for the owner use, but this
479
+ * database does not sync it.
480
+ */
481
+ export interface ReadonlySyncTenantOwner extends Typed<"Readonly"> {
378
482
  readonly ownerId: OwnerId;
379
- /** A readonly registration holds transports but never synchronizes. */
380
- readonly writable: boolean;
381
483
  /** Every transport claimed for the owner, by any database. */
382
484
  readonly transportIds: ReadonlyArray<SyncTransportId>;
383
- /** One route per transport for a writable owner; none for a readonly one. */
384
- readonly routes: ReadonlyArray<SyncRoute>;
385
485
  }
386
486
  /**
387
- * One database's use of one owner through one transport. See the
388
- * Synchronization completion section of this module's documentation.
487
+ * One database's use of one owner through one transport: `Pending` until its
488
+ * reconciliation ends, then `Complete`, or `Settled` while its relay offers a
489
+ * change the database skipped. See Synchronization completion in this module's
490
+ * documentation.
389
491
  */
390
- export interface SyncRoute {
492
+ export type SyncRoute = PendingSyncRoute | SettledSyncRoute | CompleteSyncRoute;
493
+ /**
494
+ * A route whose reconciliation has not ended: its transport is not open, a
495
+ * request or a received frame is outstanding, a replicated write is queued, or
496
+ * a round must still be sent through it.
497
+ */
498
+ export interface PendingSyncRoute extends Typed<"Pending"> {
391
499
  readonly transportId: SyncTransportId;
392
- /** Whether the database is reconciled with the relay for the owner. */
393
- readonly complete: boolean;
500
+ /**
501
+ * The failure since the route last settled, or null. The first one requests a
502
+ * round; a further one waits for {@link Evolu.requestSync} or a reopen.
503
+ */
504
+ readonly failure: SyncRouteError | null;
505
+ /**
506
+ * The first change skipped in the latest reply that skipped one, or null. The
507
+ * relay offers it again in every round through the route until this database
508
+ * stores a change with that timestamp.
509
+ */
510
+ readonly skippedError: SyncRouteError | null;
394
511
  /** When the route last became complete, or null. */
395
512
  readonly completeAt: Millis | null;
396
513
  /** When this database last sent a request through the route, or null. */
397
514
  readonly lastSentAt: Millis | null;
398
515
  /**
399
516
  * When processing a frame from the route last finished, successfully or with
400
- * a failure, or null. Aborted processing does not update this timestamp.
517
+ * a failure, or null. Aborted processing does not update it.
401
518
  */
402
519
  readonly lastReceivedAt: Millis | null;
403
- /** The last failed result on the route; cleared when the route completes. */
404
- readonly error: SyncRouteError | null;
405
- }
406
- export interface SyncRouteError {
407
- readonly type: SyncRouteErrorType;
408
- readonly at: Millis;
409
520
  }
410
521
  /**
411
- * A {@link ProtocolError} or the original {@link StorageWriteMessagesError} type
412
- * for a rejected write. `WriteFailed` means a `writeMessages` call that threw,
413
- * logged by the protocol, and `SyncFailed` means a logged failure while
414
- * creating a round or reconciling ranges.
522
+ * A route whose reconciliation ended while its relay offers a change the
523
+ * database skipped, so it is incomplete. Changes stored from other relays
524
+ * request no round through it; the next round requested through it, such as by
525
+ * {@link Evolu.requestSync} or a reopen, checks it again.
415
526
  */
416
- export type SyncRouteErrorType = ProtocolError["type"] | StorageWriteMessagesError["type"] | "WriteFailed" | "SyncFailed";
417
- /**
418
- * One owner's standing with its relays in one database, derived from
419
- * {@link SyncState} by {@link syncStateToOwnerSyncStates}.
420
- */
421
- export interface OwnerSyncState {
422
- readonly name: Name;
423
- readonly ownerId: OwnerId;
424
- readonly status: OwnerSyncStatus;
425
- /** When a route of the owner last became complete, or null. */
426
- readonly syncedAt: Millis | null;
427
- /** The newest route error of the owner, or null. */
428
- readonly error: SyncRouteError | null;
429
- /** Each relay of the owner, in the order of its routes. */
430
- readonly relays: ReadonlyArray<RelaySyncState>;
527
+ export interface SettledSyncRoute extends Typed<"Settled"> {
528
+ readonly transportId: SyncTransportId;
529
+ /** The first change skipped in the latest reply that skipped one. */
530
+ readonly skippedError: SyncRouteError;
531
+ /** When the route last became complete, or null. */
532
+ readonly completeAt: Millis | null;
533
+ /** When this database last sent a request through the route. */
534
+ readonly lastSentAt: Millis;
535
+ /** When processing a frame from the route last finished, or null. */
536
+ readonly lastReceivedAt: Millis | null;
537
+ }
538
+ /** A route on which the database is reconciled with the relay for the owner. */
539
+ export interface CompleteSyncRoute extends Typed<"Complete"> {
540
+ readonly transportId: SyncTransportId;
541
+ /** When the route became complete. */
542
+ readonly completeAt: Millis;
543
+ /** When this database last sent a request through the route. */
544
+ readonly lastSentAt: Millis;
545
+ /** When processing a frame from the route last finished, or null. */
546
+ readonly lastReceivedAt: Millis | null;
431
547
  }
432
548
  /**
433
- * The status of an {@link OwnerSyncState}: the first of `error`, `syncing`,
434
- * `synced`, and `offline` that one of its relays has, or `initial` before the
435
- * owner has a relay. Work in progress on an open transport shows before a relay
436
- * that is already up to date.
549
+ * A failure or a skipped change of a {@link SyncRoute}, with the time it
550
+ * arrived.
551
+ *
552
+ * It is the error itself, so it carries its details, such as the expected and
553
+ * actual timestamps of a {@link ProtocolTimestampMismatchError}. A skipped
554
+ * message adds {@link DecryptWithXChaCha20Poly1305Error}. A
555
+ * {@link ProtocolInvalidDataError} leaves out its data, which can be a whole
556
+ * frame. The caught value in the `error` of either is an {@link UnknownError}.
557
+ * `WriteFailed` means a `writeMessages` call that threw, logged by the
558
+ * protocol, and `SyncFailed` means a logged failure while creating a round or
559
+ * reconciling ranges.
437
560
  */
438
- export type OwnerSyncStatus = "initial" | RelaySyncStatus;
561
+ export type SyncRouteError = (Exclude<ProtocolError, ProtocolInvalidDataError> | Omit<ProtocolInvalidDataError, "data"> | StorageWriteMessagesError | DecryptWithXChaCha20Poly1305Error | Typed<"WriteFailed"> | Typed<"SyncFailed">) & {
562
+ readonly at: Millis;
563
+ };
564
+ /** The type of a {@link SyncRouteError}. */
565
+ export type SyncRouteErrorType = SyncRouteError["type"];
439
566
  /**
440
- * One relay of an {@link OwnerSyncState}: a transport and the database's route
441
- * through it.
567
+ * One relay of an owner in one database: a transport and the database's route
568
+ * through it, from {@link syncStateToRelaySyncStates}. Its status comes from
569
+ * {@link relaySyncStateToStatus} and is not stored beside them.
442
570
  */
443
571
  export interface RelaySyncState {
444
572
  readonly transport: SyncTransport;
445
573
  readonly route: SyncRoute;
446
- readonly status: RelaySyncStatus;
447
574
  }
448
575
  /**
449
- * The status of a {@link RelaySyncState}: `error` when its route failed and has
450
- * not completed since, `synced` when the route is complete, `syncing` while the
451
- * transport is open, and `offline` otherwise.
576
+ * What an app shows users about syncing one owner of one database, from
577
+ * {@link syncStateToOwnerSyncStatus} or the React and Vue `useOwnerSyncStatus`.
578
+ *
579
+ * Its variants:
580
+ *
581
+ * - `NoRelays`: nothing syncs the owner here. There is no snapshot yet, the
582
+ * owner's transports are still being set up, the app uses no relays for it,
583
+ * it is registered only as readonly, or the database refused startup, which
584
+ * `evoluError` reports.
585
+ * - `Syncing`: a relay connects for the first time or reconciles. A first
586
+ * connection to a host that drops packets stays `Syncing` until the platform
587
+ * gives up on it, which can take a minute.
588
+ * - `Synced`: a relay is up to date, and none syncs.
589
+ * - `Offline`: every relay is disconnected. Evolu keeps reconnecting, up to 30
590
+ * seconds apart, so `Offline` can briefly outlast the outage.
591
+ * - `Error`: a relay failed or offers a change this database skipped. `error` is
592
+ * the newest failure, or without one, the newest skipped change: a failure
593
+ * stops syncing through its relay, while a skipped change leaves out only
594
+ * that change.
595
+ *
596
+ * Evolu saves changes on the device before they sync, so sync needs no UI while
597
+ * it works: show nothing for `NoRelays`, `Syncing`, and `Synced`. An indicator
598
+ * that changes with every edit distracts, and screen readers announce each
599
+ * change.
600
+ *
601
+ * For `Offline` and `Error`, show one quiet line that lasts as long as the
602
+ * status, not a dialog, which interrupts, or a toast, which disappears while
603
+ * the problem lasts. Say that changes are saved on this device. Evolu reports
604
+ * `Offline` at once; an app may wait a few seconds before showing it, because
605
+ * brief disconnections, such as waking from sleep, reconnect quickly. For
606
+ * `Error`, write actionable text for the error types the app can act on, such
607
+ * as a {@link ProtocolQuotaError}: the relay stores no more data for the owner,
608
+ * so offer more quota, such as a plan upgrade, then call
609
+ * {@link Evolu.requestSync} with the owner's ID. For any other error, show
610
+ * generic text that names the error type, which helps when the user reports
611
+ * it.
612
+ *
613
+ * Render the line inside one element with `role="status"` that stays mounted:
614
+ * screen readers announce changes only in a live region that already exists,
615
+ * and a polite announcement fits a status that loses nothing. Do not use
616
+ * `role="alert"`.
617
+ *
618
+ * Show the app owner's status once for the whole app, such as below the header,
619
+ * and another owner's status where the app shows that owner's data. Each Evolu
620
+ * instance finds its own status by {@link Evolu.name}, so an app with several
621
+ * databases shows the status of the one the user works in.
622
+ *
623
+ * ### Example
624
+ *
625
+ * ```ts
626
+ * import { assertEqual, Millis } from "@evolu/common";
627
+ * import {
628
+ * testAppOwner,
629
+ * type OwnerSyncStatus,
630
+ * } from "@evolu/common/local-first";
631
+ *
632
+ * // What to tell the user, or null while sync works or is not used.
633
+ * const syncStatusToMessage = (status: OwnerSyncStatus): string | null => {
634
+ * switch (status.type) {
635
+ * case "NoRelays":
636
+ * case "Syncing":
637
+ * case "Synced":
638
+ * return null;
639
+ * case "Offline":
640
+ * return "Offline. Your changes are saved on this device.";
641
+ * case "Error":
642
+ * return status.error.type === "ProtocolQuotaError"
643
+ * ? "Sync is paused because the sync server is full. Your changes are saved on this device."
644
+ * : `Sync error: ${status.error.type}. Your changes are saved on this device.`;
645
+ * }
646
+ * };
647
+ *
648
+ * assertEqual(syncStatusToMessage({ type: "Synced" }), null);
649
+ * assertEqual(
650
+ * syncStatusToMessage({ type: "Offline" }),
651
+ * "Offline. Your changes are saved on this device.",
652
+ * );
653
+ * assertEqual(
654
+ * syncStatusToMessage({
655
+ * type: "Error",
656
+ * error: {
657
+ * type: "ProtocolQuotaError",
658
+ * ownerId: testAppOwner.id,
659
+ * at: Millis.orThrow(1000),
660
+ * },
661
+ * }),
662
+ * "Sync is paused because the sync server is full. Your changes are saved on this device.",
663
+ * );
664
+ * assertEqual(
665
+ * syncStatusToMessage({
666
+ * type: "Error",
667
+ * error: { type: "SyncFailed", at: Millis.orThrow(1000) },
668
+ * }),
669
+ * "Sync error: SyncFailed. Your changes are saved on this device.",
670
+ * );
671
+ * ```
672
+ */
673
+ export type OwnerSyncStatus = NoRelaysSyncStatus | RelaySyncStatus;
674
+ /**
675
+ * The status of a {@link RelaySyncState}, from {@link relaySyncStateToStatus}:
676
+ * `Error` when its route has a failure or a skipped change, the failure first;
677
+ * otherwise `Synced` when the route is complete, `Syncing` while the
678
+ * transport's connection is `Connecting` or `Open`, and `Offline` while it is
679
+ * `Disconnected`.
680
+ */
681
+ export type RelaySyncStatus = SyncingSyncStatus | SyncedSyncStatus | OfflineSyncStatus | ErrorSyncStatus;
682
+ /** Evolu reports no relay syncing the owner in the database. */
683
+ export interface NoRelaysSyncStatus extends Typed<"NoRelays"> {
684
+ }
685
+ /** A relay makes its first connection or reconciles. */
686
+ export interface SyncingSyncStatus extends Typed<"Syncing"> {
687
+ }
688
+ /** A relay is reconciled with the database for the owner. */
689
+ export interface SyncedSyncStatus extends Typed<"Synced"> {
690
+ }
691
+ /** Relays are disconnected and reconnecting, so changes wait on this device. */
692
+ export interface OfflineSyncStatus extends Typed<"Offline"> {
693
+ }
694
+ /** A route failed or holds a change the database skipped. */
695
+ export interface ErrorSyncStatus extends Typed<"Error"> {
696
+ /**
697
+ * The failure, or without one, the skipped change. For an owner, the newest
698
+ * failure of any relay, or without one, the newest skipped change.
699
+ */
700
+ readonly error: SyncRouteError;
701
+ }
702
+ /**
703
+ * Tells what an app shows about syncing an owner in a database: `Error` when a
704
+ * relay has one, holding the newest failure of any relay or, without one, the
705
+ * newest skipped change; otherwise the first of `Syncing`, `Synced`, and
706
+ * `Offline` that a relay has, or `NoRelays`. Accepts null, the store's value
707
+ * before the first snapshot. See {@link OwnerSyncStatus} for what to show.
708
+ *
709
+ * The same status is the same object: statuses other than `Error` are shared
710
+ * constants, and an `Error` status is the same object for the same error
711
+ * object, which {@link SyncStateDep.syncState} keeps between snapshots while it
712
+ * is unchanged. So compare statuses with `===`.
713
+ *
714
+ * ### Example
715
+ *
716
+ * ```ts
717
+ * import {
718
+ * assertEqual,
719
+ * assertSame,
720
+ * createId,
721
+ * createUnknownError,
722
+ * Millis,
723
+ * testCreateDeps,
724
+ * testName,
725
+ * } from "@evolu/common";
726
+ * import {
727
+ * syncStateToOwnerSyncStatus,
728
+ * testAppOwner,
729
+ * type PendingSyncRoute,
730
+ * type SyncConnection,
731
+ * type SyncRoute,
732
+ * type SyncState,
733
+ * type SyncTransportId,
734
+ * } from "@evolu/common/local-first";
735
+ *
736
+ * const deps = testCreateDeps();
737
+ * const primaryId = createId<"SyncTransport">(deps);
738
+ * const backupId = createId<"SyncTransport">(deps);
739
+ *
740
+ * const stateOf = (
741
+ * connections: ReadonlyArray<SyncConnection>,
742
+ * routes: ReadonlyArray<SyncRoute>,
743
+ * ): SyncState => ({
744
+ * transports: connections.map((connection, index) => ({
745
+ * type: "WebSocket",
746
+ * id: index === 0 ? primaryId : backupId,
747
+ * label: "wss://relay.example",
748
+ * connection,
749
+ * })),
750
+ * tenants: [
751
+ * {
752
+ * type: "Active",
753
+ * name: testName,
754
+ * owners: [{ type: "Writable", ownerId: testAppOwner.id, routes }],
755
+ * },
756
+ * ],
757
+ * });
758
+ * const pending = (transportId: SyncTransportId): PendingSyncRoute => ({
759
+ * type: "Pending",
760
+ * transportId,
761
+ * failure: null,
762
+ * skippedError: null,
763
+ * completeAt: null,
764
+ * lastSentAt: null,
765
+ * lastReceivedAt: null,
766
+ * });
767
+ * const statusOf = (state: SyncState | null) =>
768
+ * syncStateToOwnerSyncStatus(state, testName, testAppOwner.id);
769
+ *
770
+ * // Before the first snapshot, nothing syncs the owner.
771
+ * assertEqual(statusOf(null), { type: "NoRelays" });
772
+ *
773
+ * // A first connection is syncing; a lost one is offline.
774
+ * const connecting: SyncConnection = { type: "Connecting" };
775
+ * assertEqual(statusOf(stateOf([connecting], [pending(primaryId)])), {
776
+ * type: "Syncing",
777
+ * });
778
+ * const disconnected: SyncConnection = {
779
+ * type: "Disconnected",
780
+ * disconnectedAt: Millis.orThrow(2000),
781
+ * openedAt: Millis.orThrow(1000),
782
+ * error: null,
783
+ * };
784
+ * assertEqual(statusOf(stateOf([disconnected], [pending(primaryId)])), {
785
+ * type: "Offline",
786
+ * });
787
+ *
788
+ * // A quota failure on one relay shows before a newer skipped change on
789
+ * // another, because the app can act on it.
790
+ * const quotaError = {
791
+ * type: "ProtocolQuotaError",
792
+ * ownerId: testAppOwner.id,
793
+ * at: Millis.orThrow(3000),
794
+ * } as const;
795
+ * const open: SyncConnection = {
796
+ * type: "Open",
797
+ * openedAt: Millis.orThrow(3400),
798
+ * error: null,
799
+ * };
800
+ * assertEqual(
801
+ * statusOf(
802
+ * stateOf(
803
+ * [disconnected, open],
804
+ * [
805
+ * { ...pending(primaryId), failure: quotaError },
806
+ * {
807
+ * type: "Settled",
808
+ * transportId: backupId,
809
+ * skippedError: {
810
+ * type: "DecryptWithXChaCha20Poly1305Error",
811
+ * error: createUnknownError(new Error("invalid tag")),
812
+ * at: Millis.orThrow(4000),
813
+ * },
814
+ * completeAt: null,
815
+ * lastSentAt: Millis.orThrow(3500),
816
+ * lastReceivedAt: Millis.orThrow(4000),
817
+ * },
818
+ * ],
819
+ * ),
820
+ * ),
821
+ * { type: "Error", error: quotaError },
822
+ * );
823
+ *
824
+ * // The same error gives the same status object.
825
+ * const quotaState = stateOf(
826
+ * [disconnected],
827
+ * [{ ...pending(primaryId), failure: quotaError }],
828
+ * );
829
+ * assertSame(statusOf(quotaState), statusOf(quotaState));
830
+ * ```
452
831
  */
453
- export type RelaySyncStatus = "syncing" | "synced" | "offline" | "error";
832
+ export declare const syncStateToOwnerSyncStatus: (state: SyncState | null, name: Name, ownerId: OwnerId) => OwnerSyncStatus;
454
833
  /**
455
- * Folds the routes of every writable owner registration of every running
456
- * database in a {@link SyncState} into one {@link OwnerSyncState} per database
457
- * and owner, which pairs each route with its transport as a
458
- * {@link RelaySyncState}. A database that refused startup and a readonly
459
- * registration synchronize nothing, so they are left out.
834
+ * Pairs each route of an owner in a database with its transport, in route
835
+ * order. Returns none for a null snapshot, a missing or refused database, and a
836
+ * missing or readonly owner.
460
837
  *
461
838
  * ### Example
462
839
  *
@@ -469,7 +846,8 @@ export type RelaySyncStatus = "syncing" | "synced" | "offline" | "error";
469
846
  * testName,
470
847
  * } from "@evolu/common";
471
848
  * import {
472
- * syncStateToOwnerSyncStates,
849
+ * relaySyncStateToStatus,
850
+ * syncStateToRelaySyncStates,
473
851
  * testAppOwner,
474
852
  * type SyncRoute,
475
853
  * type SyncState,
@@ -478,52 +856,51 @@ export type RelaySyncStatus = "syncing" | "synced" | "offline" | "error";
478
856
  *
479
857
  * const deps = testCreateDeps();
480
858
  * const transport: SyncTransport = {
859
+ * type: "WebSocket",
481
860
  * id: createId<"SyncTransport">(deps),
482
861
  * label: "wss://relay.example",
483
- * readyState: "open",
484
- * openedAt: null,
485
- * closedAt: null,
486
- * error: null,
862
+ * connection: {
863
+ * type: "Open",
864
+ * openedAt: Millis.orThrow(800),
865
+ * error: null,
866
+ * },
487
867
  * };
488
868
  * const route: SyncRoute = {
869
+ * type: "Complete",
489
870
  * transportId: transport.id,
490
- * complete: true,
491
871
  * completeAt: Millis.orThrow(1000),
492
872
  * lastSentAt: Millis.orThrow(900),
493
873
  * lastReceivedAt: Millis.orThrow(1000),
494
- * error: null,
495
874
  * };
496
875
  * const state: SyncState = {
497
876
  * transports: [transport],
498
877
  * tenants: [
499
878
  * {
879
+ * type: "Active",
500
880
  * name: testName,
501
- * refused: false,
502
881
  * owners: [
503
- * {
504
- * ownerId: testAppOwner.id,
505
- * writable: true,
506
- * transportIds: [transport.id],
507
- * routes: [route],
508
- * },
882
+ * { type: "Writable", ownerId: testAppOwner.id, routes: [route] },
509
883
  * ],
510
884
  * },
511
885
  * ],
512
886
  * };
513
887
  *
514
- * assertEqual(syncStateToOwnerSyncStates(state), [
515
- * {
516
- * name: testName,
517
- * ownerId: testAppOwner.id,
518
- * status: "synced",
519
- * syncedAt: Millis.orThrow(1000),
520
- * error: null,
521
- * relays: [{ transport, route, status: "synced" }],
522
- * },
523
- * ]);
888
+ * const relays = syncStateToRelaySyncStates(
889
+ * state,
890
+ * testName,
891
+ * testAppOwner.id,
892
+ * );
893
+ * assertEqual(relays, [{ transport, route }]);
894
+ * assertEqual(relays.map(relaySyncStateToStatus), [{ type: "Synced" }]);
895
+ * assertEqual(
896
+ * syncStateToRelaySyncStates(null, testName, testAppOwner.id),
897
+ * [],
898
+ * );
524
899
  * ```
525
900
  */
526
- export declare const syncStateToOwnerSyncStates: (state: SyncState) => ReadonlyArray<OwnerSyncState>;
901
+ export declare const syncStateToRelaySyncStates: (state: SyncState | null, name: Name, ownerId: OwnerId) => ReadonlyArray<RelaySyncState>;
902
+ /** Tells the status of one relay; a failure shows before a skipped change. */
903
+ export declare const relaySyncStateToStatus: ({ transport, route, }: RelaySyncState) => RelaySyncStatus;
527
904
  export type EvoluInput = {
528
905
  readonly type: "Mutate";
529
906
  readonly changes: NonEmptyReadonlyArray<MutationChange>;
@@ -553,6 +930,10 @@ export type EvoluOutput = {
553
930
  } | {
554
931
  readonly type: "OnExport";
555
932
  readonly file: Uint8Array<ArrayBuffer>;
933
+ } | {
934
+ /** The mutation with these onComplete callbacks could not be stored. */
935
+ readonly type: "OnMutateFailed";
936
+ readonly onCompleteIds: ReadonlyArray<Id>;
556
937
  };
557
938
  export type DbWorkerInput = (Typed<"Request"> & {
558
939
  readonly attemptId: Id;
@@ -609,6 +990,10 @@ export type DbWorkerQueuedResponse = {
609
990
  readonly clock: Timestamp;
610
991
  readonly messagesByOwnerId: ReadonlyMap<OwnerId, NonEmptyReadonlyArray<CrdtMessage>>;
611
992
  readonly rowsByQuery: RowsByQueryMap;
993
+ } | {
994
+ /** The mutation threw, so it rolled back and nothing was stored. */
995
+ readonly type: "MutateFailed";
996
+ readonly error: UnknownError;
612
997
  } | {
613
998
  readonly type: "Query";
614
999
  readonly rowsByQuery: RowsByQueryMap;
@@ -628,6 +1013,11 @@ export type DbWorkerQueuedResponse = {
628
1013
  readonly clock: Timestamp;
629
1014
  readonly ownerId: OwnerId;
630
1015
  readonly didWriteMessages: boolean;
1016
+ /**
1017
+ * The first error of a received message the DbWorker skipped while
1018
+ * storing the rest, or null. It does not end the round.
1019
+ */
1020
+ readonly skippedError: DecryptWithXChaCha20Poly1305Error | ProtocolInvalidDataError | ProtocolTimestampMismatchError | null;
631
1021
  readonly result: Result<ApplyProtocolMessageAsClientResult, ProtocolError | StorageWriteMessagesError | AbortError>;
632
1022
  };
633
1023
  };