@evolu/common 8.12.0 → 8.14.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 (59) hide show
  1. package/dist/src/Callbacks.d.ts +12 -1
  2. package/dist/src/Callbacks.d.ts.map +1 -1
  3. package/dist/src/Callbacks.js +3 -0
  4. package/dist/src/Error.d.ts +10 -4
  5. package/dist/src/Error.d.ts.map +1 -1
  6. package/dist/src/Error.js +10 -4
  7. package/dist/src/Object.d.ts +65 -0
  8. package/dist/src/Object.d.ts.map +1 -1
  9. package/dist/src/Object.js +142 -0
  10. package/dist/src/Resource.d.ts +0 -5
  11. package/dist/src/Resource.d.ts.map +1 -1
  12. package/dist/src/Resource.js +6 -13
  13. package/dist/src/Sqlite.d.ts.map +1 -1
  14. package/dist/src/Sqlite.js +7 -0
  15. package/dist/src/Task.d.ts +10 -8
  16. package/dist/src/Task.d.ts.map +1 -1
  17. package/dist/src/Task.js +41 -5
  18. package/dist/src/Worker.d.ts +3 -3
  19. package/dist/src/local-first/Db.d.ts +8 -3
  20. package/dist/src/local-first/Db.d.ts.map +1 -1
  21. package/dist/src/local-first/Db.js +56 -19
  22. package/dist/src/local-first/Evolu.d.ts +147 -39
  23. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  24. package/dist/src/local-first/Evolu.js +53 -9
  25. package/dist/src/local-first/Owner.d.ts +9 -0
  26. package/dist/src/local-first/Owner.d.ts.map +1 -1
  27. package/dist/src/local-first/Owner.js +9 -0
  28. package/dist/src/local-first/Protocol.d.ts +6 -4
  29. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  30. package/dist/src/local-first/Protocol.js +1 -6
  31. package/dist/src/local-first/Schema.d.ts +7 -1
  32. package/dist/src/local-first/Schema.d.ts.map +1 -1
  33. package/dist/src/local-first/Shared.d.ts +550 -153
  34. package/dist/src/local-first/Shared.d.ts.map +1 -1
  35. package/dist/src/local-first/Shared.js +724 -273
  36. package/dist/src/local-first/Storage.d.ts +16 -12
  37. package/dist/src/local-first/Storage.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/Callbacks.test.ts +20 -0
  40. package/src/Callbacks.ts +17 -1
  41. package/src/Error.ts +10 -4
  42. package/src/Object.test.ts +296 -0
  43. package/src/Object.ts +163 -0
  44. package/src/Resource.test.ts +20 -16
  45. package/src/Resource.ts +6 -20
  46. package/src/Sqlite.ts +7 -0
  47. package/src/Task.test.ts +233 -62
  48. package/src/Task.ts +47 -13
  49. package/src/Worker.ts +3 -3
  50. package/src/local-first/Db.ts +88 -18
  51. package/src/local-first/Evolu.test.ts +381 -11
  52. package/src/local-first/Evolu.ts +219 -51
  53. package/src/local-first/Owner.ts +9 -0
  54. package/src/local-first/Protocol.test.ts +42 -60
  55. package/src/local-first/Protocol.ts +7 -9
  56. package/src/local-first/Schema.ts +8 -2
  57. package/src/local-first/Shared.test.ts +2714 -644
  58. package/src/local-first/Shared.ts +1245 -401
  59. package/src/local-first/Storage.ts +18 -19
package/src/Task.ts CHANGED
@@ -485,7 +485,9 @@
485
485
  * ## Glossary
486
486
  *
487
487
  * - **Defect** — a thrown or rejected value other than {@link AbortError}, rather
488
- * than a declared {@link Result} error.
488
+ * than a declared {@link Result} error. A `SuppressedError` nesting only
489
+ * AbortErrors, thrown when `await using` cleanup observes the abort a Task is
490
+ * propagating, is an abort, like the same cleanup in `try`/`finally`.
489
491
  * - **Outcome** — a Fiber's settlement: resolution with the Task {@link Result},
490
492
  * or rejection with {@link AbortError}. The original defect is reported
491
493
  * through {@link ReportDefectDep} whether or not the Fiber is observed; the
@@ -1536,7 +1538,9 @@ export interface DisposableRun<D = unknown>
1536
1538
  * Sync disposal starts cleanup without waiting and does not throw finalizer
1537
1539
  * defects synchronously. Async disposal awaits cleanup. If a finalizer
1538
1540
  * defects, the defect is reported once, and every async disposal call rejects
1539
- * with the same already-reported {@link AbortError}.
1541
+ * with the same already-reported {@link AbortError}. A finalizer that rejects
1542
+ * with an AbortError, for example by awaiting another DisposableRun whose
1543
+ * finalizer defected, is not reported; async disposal rejects with it.
1540
1544
  *
1541
1545
  * Calling `defer` after disposal starts is a programmer error.
1542
1546
  *
@@ -2012,10 +2016,10 @@ export const testAbortError = /*#__PURE__*/ createAbortError(testAbortReason);
2012
2016
  /**
2013
2017
  * Abort reason recorded when a defect panics the root {@link Run}.
2014
2018
  *
2015
- * A defect is a thrown or rejected value other than {@link AbortError}.
2016
- * Recoverable domain errors belong in {@link Result}. Bugs and unrecoverable
2017
- * failures, such as storage engine errors the {@link Task} cannot usefully
2018
- * handle, may throw or reject.
2019
+ * A defect is a thrown or rejected value other than {@link AbortError} or a
2020
+ * `SuppressedError` nesting only AbortErrors. Recoverable domain errors belong
2021
+ * in {@link Result}. Bugs and unrecoverable failures, such as storage engine
2022
+ * errors the {@link Task} cannot usefully handle, may throw or reject.
2019
2023
  *
2020
2024
  * {@link Run.onEvent} handler defects are different: event handlers are
2021
2025
  * monitoring code, so their defects are reported globally but do not panic the
@@ -2593,7 +2597,7 @@ const createRunInternal = <D extends object>(
2593
2597
  try {
2594
2598
  await finalizersToDispose.disposeAsync();
2595
2599
  } catch (error: unknown) {
2596
- finalizerAbortError = root.panic(error);
2600
+ finalizerAbortError = thrownToAbortError(error) ?? root.panic(error);
2597
2601
  exit ??= err(finalizerAbortError);
2598
2602
  }
2599
2603
  return settle();
@@ -2738,7 +2742,7 @@ const createRunInternal = <D extends object>(
2738
2742
 
2739
2743
  exit = ok(result as UnknownResult);
2740
2744
  } catch (error: unknown) {
2741
- exit = err(AbortError.is(error) ? error : root.panic(error));
2745
+ exit = err(thrownToAbortError(error) ?? root.panic(error));
2742
2746
  }
2743
2747
 
2744
2748
  // Internal Child Run disposal cannot reject; finalizer defects become Err
@@ -2893,7 +2897,7 @@ const createRunInternal = <D extends object>(
2893
2897
 
2894
2898
  run[Symbol.asyncDispose] = async (): Promise<void> => {
2895
2899
  await dispose();
2896
- // oxlint-disable-next-line typescript/only-throw-error -- AbortError is Task abort control flow; rethrowing it avoids reporting the finalizer defect twice in Task code.
2900
+ // oxlint-disable-next-line typescript/only-throw-error -- AbortError is Task abort control flow; rethrowing the finalizer AbortError avoids reporting a defect twice in Task code.
2897
2901
  if (finalizerAbortError) throw finalizerAbortError;
2898
2902
  };
2899
2903
 
@@ -2906,6 +2910,40 @@ const createRunInternal = <D extends object>(
2906
2910
  return run;
2907
2911
  };
2908
2912
 
2913
+ // Cleanup that observes the abort a Task is propagating, for example a
2914
+ // DisposableRun whose finalizer defected rejecting with its already-reported
2915
+ // AbortError, makes `await using` throw a SuppressedError. When every nested
2916
+ // value is an AbortError, it is abort control flow like the same cleanup in
2917
+ // try/finally, so the latest error wins: `error`, the last cleanup failure.
2918
+ const thrownToAbortError = (thrown: unknown): AbortError | null => {
2919
+ // Nothing here may throw out of runTask's catch. Inspecting the thrown value
2920
+ // can throw, for example from a getter, so the value is then a defect.
2921
+ try {
2922
+ // A loop, not recursion: each failing cleanup nests one more
2923
+ // SuppressedError, and a deep chain would overflow the stack.
2924
+ const values: Array<unknown> = [thrown];
2925
+ while (values.length > 0) {
2926
+ const value = values.pop();
2927
+ if (isSuppressedError(value)) values.push(value.error, value.suppressed);
2928
+ else if (!AbortError.is(value)) return null;
2929
+ }
2930
+ let latest = thrown;
2931
+ while (isSuppressedError(latest)) latest = latest.error;
2932
+ return latest as AbortError;
2933
+ } catch {
2934
+ return null;
2935
+ }
2936
+ };
2937
+
2938
+ // The internal tag survives crossing realms, such as from an iframe, where
2939
+ // instanceof fails. The name also matches TypeScript's downleveled `using`,
2940
+ // which captures SuppressedError when a module evaluates, before
2941
+ // installPolyfills, and without a native one throws a plain Error named
2942
+ // SuppressedError.
2943
+ const isSuppressedError = (value: unknown): value is SuppressedError =>
2944
+ Object.prototype.toString.call(value) === "[object Error]" &&
2945
+ (value as Error).name === "SuppressedError";
2946
+
2909
2947
  const withTaskMeta =
2910
2948
  (meta: TaskMeta) =>
2911
2949
  <T, E, D>(task: Task<T, E, D>): Task<T, E, D> => {
@@ -5713,9 +5751,6 @@ export interface SemaphoreSnapshot {
5713
5751
  * is `0` until enough permits are released.
5714
5752
  */
5715
5753
  readonly available: NonNegativeInt;
5716
-
5717
- /** Whether no permits are held and no requests are queued. */
5718
- readonly isIdle: boolean;
5719
5754
  }
5720
5755
 
5721
5756
  /**
@@ -5898,7 +5933,6 @@ export const createSemaphore = (
5898
5933
  available: NonNegativeInt.orThrow(
5899
5934
  !isGreedy && waiters.size > 0 ? 0 : Math.max(0, permits - taken),
5900
5935
  ),
5901
- isIdle: isIdle(),
5902
5936
  }),
5903
5937
  isIdle,
5904
5938
  };
package/src/Worker.ts CHANGED
@@ -39,9 +39,9 @@ export interface SharedWorker<Input, Output = never> extends Disposable {
39
39
  *
40
40
  * Note: There is no reliable way to detect when a port is closed or
41
41
  * disconnected. Calling `postMessage` on a disposed port does not throw — it
42
- * silently fails. To detect dead ports, use a heartbeat pattern where the other
43
- * end periodically sends "alive" messages and stale ports are pruned after a
44
- * timeout.
42
+ * silently fails. To detect that the other end is gone, let it hold a Web Lock
43
+ * for its lifetime and request the same lock: the browser grants it once the
44
+ * holder releases it or its context closes, with no timeout to tune.
45
45
  *
46
46
  * @group Core
47
47
  * @see https://developer.mozilla.org/en-US/docs/Web/API/MessagePort
@@ -40,6 +40,7 @@
40
40
  import {
41
41
  appendToArray,
42
42
  firstInArray,
43
+ isNonEmptyArray,
43
44
  type NonEmptyArray,
44
45
  type NonEmptyReadonlyArray,
45
46
  } from "../Array.ts";
@@ -50,12 +51,20 @@ import {
50
51
  assertNotUndefined,
51
52
  } from "../Assert.ts";
52
53
  import type { ConsoleLevel } from "../Console.ts";
53
- import { EncryptionKey, type RandomBytesDep } from "../Crypto.ts";
54
+ import {
55
+ EncryptionKey,
56
+ type DecryptWithXChaCha20Poly1305Error,
57
+ type RandomBytesDep,
58
+ } from "../Crypto.ts";
59
+ import { createUnknownError } from "../Error.ts";
54
60
  import { constFalse, constVoid } from "../Function.ts";
55
61
  import type { LockManagerDep } from "../LockManager.ts";
56
- import { acquireLeaderLock } from "../LockManager.ts";
62
+ import {
63
+ acquireLeaderLock,
64
+ acquireLeaderLockCallback,
65
+ } from "../LockManager.ts";
57
66
  import { createMutableRecord, getOwnProp, objectToEntries } from "../Object.ts";
58
- import { err, getOk, ok, type Result } from "../Result.ts";
67
+ import { err, getOk, ok, trySync, type Result } from "../Result.ts";
59
68
  import type {
60
69
  CreateSqliteDriverDep,
61
70
  SqliteDep,
@@ -107,7 +116,9 @@ import {
107
116
  decryptAndDecodeDbChange,
108
117
  encodeAndEncryptDbChange,
109
118
  SubscriptionFlags,
119
+ type ProtocolInvalidDataError,
110
120
  type ProtocolMessage,
121
+ type ProtocolTimestampMismatchError,
111
122
  } from "./Protocol.ts";
112
123
  import type { Query, RowsByQueryMap } from "./Query.ts";
113
124
  import type { MutationChange, SqliteSchemaDep } from "./Schema.ts";
@@ -124,6 +135,7 @@ import type {
124
135
  DbWorkerOutput,
125
136
  DbWorkerQueuedResponse,
126
137
  EvoluInput,
138
+ SharedWorkerId,
127
139
  } from "./Shared.ts";
128
140
  import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.ts";
129
141
  import {
@@ -162,6 +174,11 @@ export interface DbWorkerInit {
162
174
  readonly sqliteSchema: SqliteSchema;
163
175
  readonly encryptionKey: EncryptionKey;
164
176
  readonly memoryOnly: boolean;
177
+ /**
178
+ * The SharedWorker that requested this DbWorker. It leads for this ID while
179
+ * it runs, so the DbWorker stops serving once it can lead for it.
180
+ */
181
+ readonly sharedWorkerId: SharedWorkerId;
165
182
  readonly port: NativeMessagePort<DbWorkerOutput, DbWorkerInput>;
166
183
  }
167
184
 
@@ -197,8 +214,8 @@ export interface UnsupportedDbVersionError extends Typed<"UnsupportedDbVersionEr
197
214
 
198
215
  /**
199
216
  * Starts the platform-agnostic Evolu DbWorker and owns its resources until
200
- * startup is refused, the worker receives a dispose message, or its {@link Run}
201
- * is aborted.
217
+ * startup is refused, the worker receives a dispose message, the SharedWorker
218
+ * that requested it ends, or its {@link Run} is aborted.
202
219
  */
203
220
  export const startDbWorker =
204
221
  (self: WorkerSelf<DbWorkerInit>): Task<void, never, DbWorkerDeps> =>
@@ -289,7 +306,7 @@ export const startDbWorker =
289
306
  );
290
307
  if (!startup.ok) {
291
308
  // Nothing was written. Returning lets the disposer close SQLite and
292
- // release the database lock, which the SharedWorker's tenant disposal
309
+ // release the database lock, which the next DbWorker for this database
293
310
  // waits on. The tab leader lock is unaffected.
294
311
  port.postMessage({
295
312
  type: "LeaderRefused",
@@ -362,19 +379,30 @@ export const startDbWorker =
362
379
  clock: context.clock.get(),
363
380
  ownerId: owner.id,
364
381
  didWriteMessages: storage.didWriteMessages(),
382
+ skippedError: storage.skippedError(),
365
383
  result,
366
384
  },
367
385
  });
368
386
  return ok();
369
387
  });
370
388
  } else {
389
+ // SQLite can fail a write the app cannot prevent, such as on a
390
+ // full disk. The transaction has rolled back, so the mutation is
391
+ // answered, or every later request of this database would wait.
392
+ const mutation = trySync(
393
+ () =>
394
+ handleMutation({
395
+ ...dbDeps,
396
+ clock: context.clock,
397
+ })(request.message, now),
398
+ createUnknownError,
399
+ );
371
400
  postQueuedResponse({
372
401
  type: "ForEvolu",
373
402
  id: request.id,
374
- message: handleMutation({
375
- ...dbDeps,
376
- clock: context.clock,
377
- })(request.message, now),
403
+ message: mutation.ok
404
+ ? mutation.value
405
+ : { type: "MutateFailed", error: mutation.error },
378
406
  });
379
407
  }
380
408
  return;
@@ -441,8 +469,17 @@ export const startDbWorker =
441
469
  }
442
470
  };
443
471
 
472
+ // Stops once the SharedWorker no longer leads for its ID, which the
473
+ // platform releases when it closes, because Firefox can lose a Dispose
474
+ // posted right before that.
475
+ const sharedWorkerEnded = acquireLeaderLockCallback(deps)(
476
+ initMessage.sharedWorkerId,
477
+ () => resolve(ok()),
478
+ );
479
+
444
480
  return () => {
445
481
  port.onMessage = null;
482
+ sharedWorkerEnded[Symbol.dispose]();
446
483
  };
447
484
  }),
448
485
  );
@@ -478,8 +515,10 @@ interface WriteContext extends ClockDep {
478
515
  *
479
516
  * Local-only mutations and sync requests that do not invoke `writeMessages`
480
517
  * skip this. Successfully processed batches in `writeMessages` call this even
481
- * when every message is duplicated or quarantined. Duplicate receipts within
482
- * the drift limit can advance the clock without storing new messages.
518
+ * when every message is duplicated or quarantined, but not when every message
519
+ * was skipped, because a skipped message does not affect the clock. Duplicate
520
+ * receipts within the drift limit can advance the clock without storing new
521
+ * messages.
483
522
  */
484
523
  const saveClock =
485
524
  (deps: SqliteDep) =>
@@ -873,6 +912,15 @@ interface ClientStorage extends Storage, BaseSqliteStorage {
873
912
  writeContext?: WriteContext,
874
913
  ) => void;
875
914
  readonly didWriteMessages: () => boolean;
915
+ /**
916
+ * The first error of a message that {@link Storage.writeMessages} skipped in
917
+ * this request, or null.
918
+ */
919
+ readonly skippedError: () =>
920
+ | DecryptWithXChaCha20Poly1305Error
921
+ | ProtocolInvalidDataError
922
+ | ProtocolTimestampMismatchError
923
+ | null;
876
924
  }
877
925
 
878
926
  const createClientStorage = (
@@ -884,6 +932,7 @@ const createClientStorage = (
884
932
  ): ClientStorage => {
885
933
  let encryptionKey: EncryptionKey | null = null;
886
934
  let didWriteMessages = false;
935
+ let skippedError: ReturnType<ClientStorage["skippedError"]> = null;
887
936
  let writeContext: WriteContext | undefined;
888
937
 
889
938
  const getEncryptionKey = (): EncryptionKey => {
@@ -903,9 +952,11 @@ const createClientStorage = (
903
952
  encryptionKey = nextEncryptionKey;
904
953
  writeContext = nextWriteContext;
905
954
  didWriteMessages = false;
955
+ skippedError = null;
906
956
  },
907
957
 
908
958
  didWriteMessages: () => didWriteMessages,
959
+ skippedError: () => skippedError,
909
960
 
910
961
  // Not implemented yet.
911
962
  validateWriteKey: constFalse,
@@ -921,20 +972,41 @@ const createClientStorage = (
921
972
  const messages: Array<CrdtMessage> = [];
922
973
  const currentEncryptionKey = getEncryptionKey();
923
974
 
975
+ // Each message is authenticated on its own, so one this client cannot
976
+ // decrypt, verify, or decode is skipped while the rest are stored. A
977
+ // relay can serve anything, and anyone with the owner's write key can
978
+ // store anything on a relay, so Evolu cannot tell who is at fault; the
979
+ // SharedWorker shows the skip on the route.
980
+ //
981
+ // A skipped message leaves nothing, not even its timestamp. A stored
982
+ // timestamp would stop this client from fetching the real change with
983
+ // that timestamp from another relay, and quarantine rows are synced to
984
+ // other relays. The relay offers the message again on each sync, so once
985
+ // this client is fixed, the next sync stores it.
986
+ //
987
+ // The error is not returned, because that would end the round: the
988
+ // relay's ranges would go unanswered, so changes it lacks might never be
989
+ // uploaded to it, and every round would end the same way.
924
990
  for (const message of encryptedMessages) {
925
991
  const change = decryptAndDecodeDbChange(message, currentEncryptionKey);
926
- if (!change.ok) return err(change.error);
992
+ if (!change.ok) {
993
+ skippedError ??= change.error;
994
+ continue;
995
+ }
927
996
  messages.push({ timestamp: message.timestamp, change: change.value });
928
997
  }
929
998
 
999
+ if (!isNonEmptyArray(messages)) return ok();
1000
+
930
1001
  assertNonNullable(writeContext);
931
1002
  const { clock, now } = writeContext;
932
1003
  let clockTimestamp = clock.get();
933
1004
  const receive = receiveTimestamp(deps);
934
1005
 
935
- // The clock is computed over every message, duplicates included, so a
936
- // retry with the same inputs reports the same clock. Writes for
937
- // timestamps already in the owner's set are skipped by applyMessages.
1006
+ // The clock is computed over every message that was not skipped,
1007
+ // duplicates included, so a retry with the same inputs reports the same
1008
+ // clock. Writes for timestamps already in the owner's set are skipped by
1009
+ // applyMessages.
938
1010
  for (const message of messages) {
939
1011
  const nextTimestamp = receive(clockTimestamp, message.timestamp, now);
940
1012
  if (!nextTimestamp.ok) {
@@ -943,8 +1015,6 @@ const createClientStorage = (
943
1015
  } else clockTimestamp = nextTimestamp.value;
944
1016
  }
945
1017
 
946
- assertNonEmptyReadonlyArray(messages);
947
-
948
1018
  let wroteNewMessages = false;
949
1019
  deps.sqlite.transaction(() => {
950
1020
  wroteNewMessages = applyMessages(deps)(