@evolu/common 8.10.0 → 8.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/src/Bytes.d.ts +39 -2
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +50 -2
  4. package/dist/src/Config.d.ts +22 -22
  5. package/dist/src/Config.d.ts.map +1 -1
  6. package/dist/src/Console.d.ts +62 -7
  7. package/dist/src/Console.d.ts.map +1 -1
  8. package/dist/src/Console.js +20 -4
  9. package/dist/src/Crypto.d.ts +76 -4
  10. package/dist/src/Crypto.d.ts.map +1 -1
  11. package/dist/src/Crypto.js +55 -4
  12. package/dist/src/Error.d.ts +45 -0
  13. package/dist/src/Error.d.ts.map +1 -1
  14. package/dist/src/Error.js +69 -0
  15. package/dist/src/Fs.d.ts +92 -18
  16. package/dist/src/Fs.d.ts.map +1 -1
  17. package/dist/src/Fs.js +2 -0
  18. package/dist/src/Identicon.d.ts +2 -2
  19. package/dist/src/Identicon.js +2 -2
  20. package/dist/src/LeakDetector.d.ts +22 -3
  21. package/dist/src/LeakDetector.d.ts.map +1 -1
  22. package/dist/src/LeakDetector.js +12 -2
  23. package/dist/src/LockManager.d.ts +8 -0
  24. package/dist/src/LockManager.d.ts.map +1 -1
  25. package/dist/src/LockManager.js +6 -0
  26. package/dist/src/Object.d.ts.map +1 -1
  27. package/dist/src/Object.js +5 -0
  28. package/dist/src/Platform.d.ts +47 -7
  29. package/dist/src/Platform.d.ts.map +1 -1
  30. package/dist/src/Platform.js +24 -5
  31. package/dist/src/Random.d.ts +25 -2
  32. package/dist/src/Random.d.ts.map +1 -1
  33. package/dist/src/Random.js +14 -2
  34. package/dist/src/Resource.d.ts +156 -1
  35. package/dist/src/Resource.d.ts.map +1 -1
  36. package/dist/src/Resource.js +201 -72
  37. package/dist/src/Schedule.d.ts +11 -10
  38. package/dist/src/Schedule.d.ts.map +1 -1
  39. package/dist/src/Schedule.js +1 -1
  40. package/dist/src/Sqlite.d.ts +132 -16
  41. package/dist/src/Sqlite.d.ts.map +1 -1
  42. package/dist/src/Sqlite.js +63 -9
  43. package/dist/src/Task.d.ts +15 -4
  44. package/dist/src/Task.d.ts.map +1 -1
  45. package/dist/src/Task.js +41 -15
  46. package/dist/src/Test.d.ts +9 -0
  47. package/dist/src/Test.d.ts.map +1 -1
  48. package/dist/src/Test.js +4 -0
  49. package/dist/src/Time.d.ts +106 -9
  50. package/dist/src/Time.d.ts.map +1 -1
  51. package/dist/src/Time.js +55 -4
  52. package/dist/src/Type.d.ts +1455 -1310
  53. package/dist/src/Type.d.ts.map +1 -1
  54. package/dist/src/Type.js +1274 -517
  55. package/dist/src/WebSocket.d.ts +164 -13
  56. package/dist/src/WebSocket.d.ts.map +1 -1
  57. package/dist/src/WebSocket.js +133 -24
  58. package/dist/src/Worker.d.ts +90 -8
  59. package/dist/src/Worker.d.ts.map +1 -1
  60. package/dist/src/Worker.js +28 -2
  61. package/dist/src/index.d.ts +6 -7
  62. package/dist/src/index.d.ts.map +1 -1
  63. package/dist/src/index.js +2 -3
  64. package/dist/src/local-first/Db.d.ts +52 -3
  65. package/dist/src/local-first/Db.d.ts.map +1 -1
  66. package/dist/src/local-first/Db.js +412 -137
  67. package/dist/src/local-first/Evolu.d.ts +412 -213
  68. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  69. package/dist/src/local-first/Evolu.js +181 -18
  70. package/dist/src/local-first/Owner.d.ts +13 -30
  71. package/dist/src/local-first/Owner.d.ts.map +1 -1
  72. package/dist/src/local-first/Owner.js +13 -30
  73. package/dist/src/local-first/Protocol.d.ts +106 -19
  74. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  75. package/dist/src/local-first/Protocol.js +162 -60
  76. package/dist/src/local-first/Query.d.ts +8 -15
  77. package/dist/src/local-first/Query.d.ts.map +1 -1
  78. package/dist/src/local-first/Relay.d.ts.map +1 -1
  79. package/dist/src/local-first/Relay.js +4 -2
  80. package/dist/src/local-first/Schema.d.ts +346 -23
  81. package/dist/src/local-first/Schema.d.ts.map +1 -1
  82. package/dist/src/local-first/Schema.js +214 -17
  83. package/dist/src/local-first/Shared.d.ts +537 -22
  84. package/dist/src/local-first/Shared.d.ts.map +1 -1
  85. package/dist/src/local-first/Shared.js +1437 -234
  86. package/dist/src/local-first/Storage.d.ts +195 -17
  87. package/dist/src/local-first/Storage.d.ts.map +1 -1
  88. package/dist/src/local-first/Storage.js +85 -22
  89. package/dist/src/local-first/Timestamp.d.ts +392 -41
  90. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  91. package/dist/src/local-first/Timestamp.js +403 -81
  92. package/dist/src/local-first/index.d.ts +0 -1
  93. package/dist/src/local-first/index.d.ts.map +1 -1
  94. package/dist/src/local-first/index.js +0 -1
  95. package/package.json +1 -1
  96. package/src/Assert.test.ts +2 -5
  97. package/src/Bytes.test.ts +27 -0
  98. package/src/Bytes.ts +58 -2
  99. package/src/Config.test.ts +2 -6
  100. package/src/Config.ts +133 -133
  101. package/src/Console.ts +62 -7
  102. package/src/Crypto.ts +76 -4
  103. package/src/Eq.test.ts +2 -3
  104. package/src/Error.test.ts +76 -3
  105. package/src/Error.ts +71 -0
  106. package/src/Fs.ts +92 -18
  107. package/src/Identicon.ts +2 -2
  108. package/src/LeakDetector.ts +22 -3
  109. package/src/LockManager.ts +8 -0
  110. package/src/Object.test.ts +27 -12
  111. package/src/Object.ts +5 -0
  112. package/src/Platform.ts +50 -8
  113. package/src/Random.ts +25 -2
  114. package/src/Resource.test.ts +837 -0
  115. package/src/Resource.ts +235 -15
  116. package/src/Schedule.test.ts +50 -12
  117. package/src/Schedule.ts +24 -14
  118. package/src/Sqlite.ts +137 -17
  119. package/src/Task.test.ts +189 -8
  120. package/src/Task.ts +56 -17
  121. package/src/Test.ts +9 -0
  122. package/src/Time.ts +106 -9
  123. package/src/Type.test.ts +946 -1028
  124. package/src/Type.ts +4195 -3136
  125. package/src/Types.test.ts +4 -14
  126. package/src/WebSocket.ts +313 -40
  127. package/src/Worker.ts +90 -8
  128. package/src/index.ts +20 -6
  129. package/src/local-first/Db.ts +644 -339
  130. package/src/local-first/Evolu.test.ts +994 -22
  131. package/src/local-first/Evolu.ts +625 -232
  132. package/src/local-first/Owner.ts +13 -30
  133. package/src/local-first/Protocol.test.ts +634 -10
  134. package/src/local-first/Protocol.ts +255 -109
  135. package/src/local-first/Query.ts +8 -15
  136. package/src/local-first/Relay.ts +4 -2
  137. package/src/local-first/Schema.test.ts +143 -0
  138. package/src/local-first/Schema.ts +376 -26
  139. package/src/local-first/Shared.test.ts +7731 -559
  140. package/src/local-first/Shared.ts +2036 -267
  141. package/src/local-first/Storage.ts +224 -36
  142. package/src/local-first/Timestamp.test.ts +344 -70
  143. package/src/local-first/Timestamp.ts +434 -118
  144. package/src/local-first/index.ts +0 -1
  145. package/dist/src/local-first/Error.d.ts +0 -12
  146. package/dist/src/local-first/Error.d.ts.map +0 -1
  147. package/dist/src/local-first/Error.js +0 -6
  148. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  149. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  150. package/dist/src/local-first/LocalAuth.js +0 -179
  151. package/src/local-first/Error.ts +0 -17
  152. package/src/local-first/LocalAuth.ts +0 -457
package/src/Resource.ts CHANGED
@@ -1,5 +1,29 @@
1
1
  /**
2
- * Concurrency-safe helpers for efficient reuse of disposable resources.
2
+ * Concurrency-safe helpers for efficient reuse and replacement of disposable
3
+ * resources.
4
+ *
5
+ * ## Replacement policies
6
+ *
7
+ * Replacing a live resource has two policies.
8
+ *
9
+ * Exclusive replacement disposes the current resource and then creates the
10
+ * next, so at most one exists and there is an observable gap with none.
11
+ * {@link ResettableResource} implements it. Leases do not fit this policy: a
12
+ * reset is requested precisely when holders are stuck on a dead resource, so
13
+ * waiting for them inverts the purpose, and revoking them is safe only when the
14
+ * resource's own API is total after disposal, in which case a lease adds
15
+ * nothing.
16
+ *
17
+ * Overlapping replacement creates the next generation first and disposes the
18
+ * previous one when its last lease is released, so work in progress finishes on
19
+ * the generation it started with. It keeps the old generation when creation
20
+ * fails, at the cost of both existing for a while. It is not implemented. It
21
+ * belongs on {@link SharedResource} as an invalidate operation: mark the current
22
+ * generation stale, stop leasing it, dispose it on its last release, and create
23
+ * the next on the next acquire. Before that can ship,
24
+ * {@link SharedResourceByKey} must stop removing a key when its resource is
25
+ * disposed, and {@link SharedResourceByKeyWithClaims} must stop keeping one
26
+ * resource object per key for the lifetime of its claims.
3
27
  *
4
28
  * @module
5
29
  */
@@ -21,6 +45,8 @@ import {
21
45
  sleep,
22
46
  type AbortableFiber,
23
47
  type DisposableRun,
48
+ type Fiber,
49
+ type Run,
24
50
  type SemaphoreSnapshot,
25
51
  type Task,
26
52
  } from "./Task.ts";
@@ -46,6 +72,7 @@ import { type DistributiveOmit } from "./Types.ts";
46
72
  * APIs let that error propagate as a defect. The purpose of resource helpers is
47
73
  * to guarantee cleanup and prevent leaks.
48
74
  *
75
+ * @see {@link ResettableResource}
49
76
  * @see {@link SharedResource}
50
77
  * @see {@link createSharedResource}
51
78
  */
@@ -501,6 +528,208 @@ export const createSharedResource =
501
528
  return ok(sharedResource);
502
529
  };
503
530
 
531
+ /**
532
+ * A {@link Resource} reset in place.
533
+ *
534
+ * Holds one current resource and resets it on request: the current resource is
535
+ * disposed first, then `create` runs again. At most one resource exists, and
536
+ * there is an observable gap with none. Consumers keep the ResettableResource
537
+ * and read the current resource through {@link ResettableResource.get | get}, so
538
+ * a reset needs no re-registration. A resource that cannot reset itself is the
539
+ * case for it. One that can, as `WebSocket.reconnect` does, keeps a stable
540
+ * identity and needs no wrapper.
541
+ *
542
+ * The ResettableResource keeps a stable identity while its current resource
543
+ * changes. {@link SharedResourceByKeyWithClaims} can retain it directly;
544
+ * consumers read the current resource through `get`.
545
+ *
546
+ * Repeated async disposal calls await the same cleanup and preserve any
547
+ * disposal failure.
548
+ *
549
+ * ### Example
550
+ *
551
+ * ```ts
552
+ * import {
553
+ * assertEqual,
554
+ * assertSame,
555
+ * createRun,
556
+ * createResettableResource,
557
+ * ok,
558
+ * type Task,
559
+ * } from "@evolu/common";
560
+ *
561
+ * interface Connection extends Disposable {
562
+ * readonly id: number;
563
+ * readonly isClosed: () => boolean;
564
+ * }
565
+ *
566
+ * let nextId = 1;
567
+ * const createConnection: Task<Connection> = () => {
568
+ * const id = nextId++;
569
+ * let isClosed = false;
570
+ * return ok({
571
+ * id,
572
+ * isClosed: () => isClosed,
573
+ * [Symbol.dispose]: () => {
574
+ * isClosed = true;
575
+ * },
576
+ * });
577
+ * };
578
+ *
579
+ * await using run = createRun();
580
+ * await using connection = await run.ok(
581
+ * createResettableResource(createConnection),
582
+ * );
583
+ * const first = connection.get();
584
+ * assertEqual(first?.id, 1);
585
+ *
586
+ * await run.ok(connection.reset(first));
587
+ * assertSame(first?.isClosed(), true);
588
+ * assertEqual(connection.get()?.id, 2);
589
+ * ```
590
+ */
591
+ export interface ResettableResource<
592
+ T extends Resource,
593
+ > extends AsyncDisposable {
594
+ /**
595
+ * Returns the current resource, or `undefined` when there is none: while a
596
+ * reset is in progress, after a reset whose creation aborted, and after
597
+ * disposal. Callers treat every absence alike, as the resource's own
598
+ * not-ready state.
599
+ */
600
+ readonly get: () => BorrowedResource<T> | undefined;
601
+
602
+ /**
603
+ * Resets the current resource: disposes it, then creates the next one.
604
+ *
605
+ * A reset is requested because `observed` misbehaved, and it runs only while
606
+ * `observed` is still current or there is no current resource. A request that
607
+ * finds a different resource current returns without resetting, so every
608
+ * observer of one dead resource shares one reset, however late its request
609
+ * arrives. An `observed` of `undefined` is an observation of absence, as
610
+ * {@link ResettableResource.get | get} returns during a reset: the request
611
+ * runs only while nothing is current, so it retries an aborted creation and
612
+ * skips once a replacement exists.
613
+ *
614
+ * Resets are serialized. Owner disposal aborts a pending reset with
615
+ * `runDisposedAbortReason`, and a resource that `create` returns after
616
+ * disposal started is disposed and never becomes current.
617
+ *
618
+ * Resets follow the resource-owned {@link Run}'s lifecycle: an abort request
619
+ * can cancel them, and calling reset after that Run starts disposal is a
620
+ * programmer error. Aborting the caller's {@link Fiber} does not cancel a
621
+ * started reset; dependencies remain those captured at creation.
622
+ */
623
+ readonly reset: (observed: BorrowedResource<T> | undefined) => Task<void>;
624
+ }
625
+
626
+ /**
627
+ * Creates {@link ResettableResource}.
628
+ *
629
+ * The first resource is created before this Task returns; if that creation
630
+ * aborts, nothing is left running. If the caller's Fiber aborts during initial
631
+ * creation, this Task waits for creation to settle, disposes any created
632
+ * resource, and returns the abort instead of a resource.
633
+ *
634
+ * The `create` Task must not fail: the previous resource is already disposed
635
+ * when it runs, so a failure would leave nothing to fall back to. Handle
636
+ * recoverable failures inside `create`, or return a resource whose state models
637
+ * them.
638
+ *
639
+ * The resource's own API must model its not-ready state, as a reconnecting
640
+ * connection models "connecting": `get` returns `undefined` during a reset, and
641
+ * callers treat that absence the same way. A resource whose API assumes
642
+ * readiness gains nothing here; every caller would rebuild that state around
643
+ * it.
644
+ *
645
+ * As with {@link createSharedResource}, a returned resource must be live and
646
+ * independently owned, and `create` runs on a Run created from the Run that
647
+ * executes this Task, so dependencies are captured at creation time.
648
+ *
649
+ * Every successful `create` invocation must return a fresh object identity,
650
+ * never one returned earlier. A reset skips when the observed resource is no
651
+ * longer current, and that comparison is by identity; a reused object makes a
652
+ * stale observation match the replacement and reset it again.
653
+ *
654
+ * A reset runs `create` and the resource's disposer while holding a
655
+ * non-reentrant lock. Neither may directly or transitively await another
656
+ * {@link ResettableResource.reset | reset} of this ResettableResource, because
657
+ * that reset waits for the same lock.
658
+ */
659
+ export const createResettableResource =
660
+ <T extends Resource, D>(
661
+ create: Task<T, never, D>,
662
+ ): Task<ResettableResource<T>, never, D> =>
663
+ async (run) => {
664
+ const { leakDetector } = run.deps;
665
+
666
+ let current: T | undefined;
667
+
668
+ await using disposer = new AsyncDisposableStack();
669
+ const resettableResourceRun = disposer.use(run.create());
670
+ const mutex = createMutex();
671
+ const resettableResourceHandle = {};
672
+
673
+ const resetFrom = (
674
+ observed: BorrowedResource<T> | undefined,
675
+ ): Task<void, never, D> =>
676
+ mutex.withLock(async (run) => {
677
+ // A resource other than the observed one is current. An aborted
678
+ // creation leaves no current resource, so a queued request still runs.
679
+ if (current !== undefined && (current as unknown) !== observed) {
680
+ return ok();
681
+ }
682
+ const previous = current;
683
+ await disposeCurrent();
684
+ const next = await run.ok(create);
685
+ assert(
686
+ next !== previous,
687
+ "ResettableResource create must return a fresh object identity.",
688
+ );
689
+ // A resource returned after disposal started is disposed here, so
690
+ // `get` never exposes it while owner finalization runs.
691
+ if (run.signal.aborted) {
692
+ await using _next: Resource = next;
693
+ }
694
+ run.signal.throwIfAborted();
695
+ current = next;
696
+ return ok();
697
+ });
698
+
699
+ const disposeCurrent = async (): Promise<void> => {
700
+ await using _resource: Resource | undefined = current;
701
+ current = undefined;
702
+ };
703
+ resettableResourceRun.defer(() => {
704
+ leakDetector.untrack(resettableResourceHandle);
705
+ });
706
+ resettableResourceRun.defer(disposeCurrent);
707
+
708
+ await resettableResourceRun.ok(resetFrom(undefined));
709
+ // The caller can abort independently of the resource-owned Run.
710
+ run.signal.throwIfAborted();
711
+
712
+ const resettableResource: ResettableResource<T> = {
713
+ get: () => current as unknown as BorrowedResource<T> | undefined,
714
+
715
+ reset: (observed) => () => resettableResourceRun(resetFrom(observed)),
716
+
717
+ [Symbol.asyncDispose]: () => resettableResourceRun[Symbol.asyncDispose](),
718
+ };
719
+
720
+ leakDetector.track(
721
+ resettableResource,
722
+ {
723
+ name: "ResettableResource",
724
+ isLeaked: () => resettableResourceRun.getState().type === "Running",
725
+ },
726
+ resettableResourceHandle,
727
+ );
728
+
729
+ disposer.move();
730
+ return ok(resettableResource);
731
+ };
732
+
504
733
  /**
505
734
  * Shared {@link Resource}s keyed by logical identity.
506
735
  *
@@ -895,6 +1124,11 @@ export function createSharedResourceByKey<
895
1124
  * leases by key and the application does not need to associate those leases
896
1125
  * with logical owners.
897
1126
  *
1127
+ * One resource object per key is kept for the lifetime of its claims, and
1128
+ * callbacks receive that object. A {@link ResettableResource} can be retained
1129
+ * directly because its identity is stable; consumers read its current resource
1130
+ * through {@link ResettableResource.get | get}.
1131
+ *
898
1132
  * Two accounts can share a relay connection while one also uses a local-network
899
1133
  * transport:
900
1134
  *
@@ -1486,17 +1720,3 @@ export const createSharedResourceByKeyWithClaims =
1486
1720
 
1487
1721
  return ok(sharedResourceByKeyWithClaims);
1488
1722
  };
1489
-
1490
- // TODO: Add a lease-based reloadable resource when a concrete use case needs
1491
- // resource swapping. A ResourceRef-style get/set API is unsafe because set can
1492
- // dispose a resource still held by a caller of get. Model resource generations
1493
- // explicitly and distinguish two replacement policies, probably as separate
1494
- // APIs rather than a boolean option:
1495
- // - Overlapping: create and validate the replacement, publish it to new leases,
1496
- // then dispose the old generation after its existing leases drain. This
1497
- // supports zero-downtime reload and keeps the old generation when creation
1498
- // fails, but both generations temporarily exist.
1499
- // - Exclusive: stop or queue new leases, drain and dispose the old generation,
1500
- // then create and publish the replacement. This guarantees at most one live
1501
- // resource, but introduces downtime and leaves no valid resource when creation
1502
- // fails unless the owner retries or recreates the old configuration.
@@ -58,36 +58,62 @@ import {
58
58
  } from "./Schedule.ts";
59
59
  import { testCreateDeps } from "./Task.ts";
60
60
  import {
61
+ type Duration,
62
+ durationToMillis,
61
63
  maxMillis,
62
64
  Millis,
65
+ millisToDateIso,
63
66
  minMillis,
67
+ type PerformanceTime,
64
68
  PositiveMillis,
65
69
  testCreateTime,
66
70
  } from "./Time.ts";
67
71
  import { type DateIso, NonNegativeInt, Ratio } from "./Type.ts";
68
72
 
69
73
  // Helper to create scheduleDeps with controllable time
70
- const createScheduleDeps = (startAt = 0) => {
74
+ const createScheduleDeps = () => {
71
75
  const deps = testCreateDeps();
72
- const time = testCreateTime({ startAt: Millis.orThrow(startAt) });
76
+ const time = testCreateTime();
73
77
  return { ...deps, time };
74
78
  };
75
79
 
76
- const createScheduleDepsWithNow = (...times: ReadonlyArray<number>) => {
80
+ /**
81
+ * Drives the clock schedules measure elapsed time on, including backwards, to
82
+ * exercise clamping a monotonic clock is not supposed to need.
83
+ */
84
+ const createScheduleDepsWithElapsed = (...times: ReadonlyArray<number>) => {
77
85
  const deps = testCreateDeps();
78
86
  let index = 0;
79
- const time = testCreateTime({ startAt: Millis.orThrow(times[0] ?? 0) });
87
+ const time = testCreateTime();
88
+ return {
89
+ ...deps,
90
+ time: {
91
+ ...time,
92
+ performance: {
93
+ ...time.performance,
94
+ now: () =>
95
+ (times[Math.min(index++, times.length - 1)] ?? 0) as PerformanceTime,
96
+ },
97
+ },
98
+ };
99
+ };
100
+
101
+ /** Moves the wall clock without moving the clock schedules measure with. */
102
+ const createScheduleDepsWithClockJump = () => {
103
+ const deps = testCreateDeps();
104
+ const time = testCreateTime();
105
+ let offset = 0;
80
106
  function now(): Millis;
81
107
  function now(type: "DateIso"): DateIso;
82
108
  function now(type?: "DateIso"): Millis | DateIso {
83
- if (type === "DateIso") return time.now(type);
84
- return Millis.orThrow(times[Math.min(index++, times.length - 1)]);
109
+ const millis = Millis.orThrow(time.now() + offset);
110
+ return type === "DateIso" ? millisToDateIso(millis) : millis;
85
111
  }
86
112
  return {
87
113
  ...deps,
88
- time: {
89
- ...time,
90
- now,
114
+ time: { ...time, now },
115
+ jumpClock: (duration: Duration) => {
116
+ offset += durationToMillis(duration);
91
117
  },
92
118
  };
93
119
  };
@@ -526,7 +552,7 @@ describe("elapsed", () => {
526
552
  });
527
553
 
528
554
  it("returns zero elapsed time when time moves backwards", () => {
529
- const deps = createScheduleDepsWithNow(100, 50);
555
+ const deps = createScheduleDepsWithElapsed(100, 50);
530
556
  const step = elapsed(deps);
531
557
 
532
558
  expectOk(step(undefined), [0, 0]);
@@ -683,7 +709,7 @@ describe("maxElapsed", () => {
683
709
 
684
710
  it("keeps terminal done when time moves backwards", () => {
685
711
  const step = maxElapsed("250ms")(exponential("100ms"))(
686
- createScheduleDepsWithNow(0, 250, 0),
712
+ createScheduleDepsWithElapsed(0, 250, 0),
687
713
  );
688
714
 
689
715
  expectOk(step(undefined), [100, 100]);
@@ -919,7 +945,7 @@ describe("compensate", () => {
919
945
  });
920
946
 
921
947
  it("keeps full delay when time moves backwards", () => {
922
- const deps = createScheduleDepsWithNow(100, 50);
948
+ const deps = createScheduleDepsWithElapsed(100, 50);
923
949
  const step = compensate(spaced("1s"))(deps);
924
950
 
925
951
  expectOk(step(undefined), [1000, 1000]);
@@ -1059,6 +1085,18 @@ describe("resetScheduleAfter", () => {
1059
1085
  expectOk(step(undefined), [200, 200]);
1060
1086
  });
1061
1087
 
1088
+ it("ignores a system clock adjustment", () => {
1089
+ const deps = createScheduleDepsWithClockJump();
1090
+ const step = resetScheduleAfter("1s")(exponential("100ms"))(deps);
1091
+
1092
+ expectOk(step(undefined), [100, 100]);
1093
+ // An hour of wall clock passes while no time elapses.
1094
+ const before = deps.time.now();
1095
+ deps.jumpClock("1h");
1096
+ assertEqual(deps.time.now() - before, 3_600_000);
1097
+ expectOk(step(undefined), [200, 200]);
1098
+ });
1099
+
1062
1100
  it("keeps terminal done after inactivity", () => {
1063
1101
  const deps = createScheduleDeps();
1064
1102
  const step = resetScheduleAfter("1s")(take(1)(spaced("100ms")))(deps);
package/src/Schedule.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  durationToMillis,
24
24
  Millis,
25
25
  minMillis,
26
+ type PerformanceTime,
26
27
  PositiveMillis,
27
28
  saturateMillis,
28
29
  type TimeDep,
@@ -56,6 +57,15 @@ import type { Predicate } from "./Types.ts";
56
57
  * time origin on the first step call, not when `schedule(deps)` creates the
57
58
  * step.
58
59
  *
60
+ * They measure elapsed time on `Time.performance`, so a system clock adjustment
61
+ * cannot distort it. On platforms where that clock stops while the device
62
+ * sleeps, a suspended interval measures as little or no elapsed time, so a
63
+ * schedule does not see the gap the wall clock saw. A time box outlives the
64
+ * wall-clock deadline it was given, as {@link during} and {@link maxElapsed} do.
65
+ * {@link resetScheduleAfter} does not treat the sleep as inactivity, and
66
+ * {@link compensate}, {@link fixed} and {@link windowed} do not treat it as time
67
+ * to catch up on.
68
+ *
59
69
  * ### Composing a retry policy
60
70
  *
61
71
  * ```ts
@@ -94,18 +104,10 @@ import type { Predicate } from "./Types.ts";
94
104
  * Or use a preset:
95
105
  *
96
106
  * ```ts
97
- * import {
98
- * assertTrue,
99
- * ok,
100
- * retry,
101
- * retryStrategyAws,
102
- * type Task,
103
- * } from "@evolu/common";
107
+ * import { ok, retry, retryStrategyAws, type Task } from "@evolu/common";
104
108
  *
105
109
  * const fetchData: Task<string> = () => ok("data");
106
- * const fetchWithRetry = retry(fetchData, retryStrategyAws);
107
- *
108
- * assertTrue(typeof fetchWithRetry === "function");
110
+ * const _fetchWithRetry = retry(fetchData, retryStrategyAws);
109
111
  * ```
110
112
  */
111
113
  export type Schedule<out Output, in Input = unknown> = (
@@ -441,7 +443,15 @@ export const fixed =
441
443
  /**
442
444
  * Internal per-step metrics computed from timestamps.
443
445
  *
444
- * The schedule computes this internally from deps.time.now().
446
+ * The schedule computes this internally from deps.time.performance.now(), so a
447
+ * system clock adjustment cannot shorten or lengthen a measured elapsed time.
448
+ * An interval the device spent suspended can measure as no elapsed time at
449
+ * all.
450
+ *
451
+ * A reading that precedes the one before it is clamped to zero rather than
452
+ * thrown on, which is why these subtract directly instead of using
453
+ * `performanceDurationBetween`. A schedule measures its own progress, so a
454
+ * clock that misbehaves must not fail the operation being scheduled.
445
455
  */
446
456
  interface ScheduleStepMetrics {
447
457
  /** Milliseconds elapsed since the first step. */
@@ -458,11 +468,11 @@ interface ScheduleStepMetrics {
458
468
  const createScheduleStepMetrics = (
459
469
  deps: TimeDep,
460
470
  ): (() => ScheduleStepMetrics) => {
461
- let start: Millis | null = null;
462
- let previous: Millis | null = null;
471
+ let start: PerformanceTime | null = null;
472
+ let previous: PerformanceTime | null = null;
463
473
 
464
474
  return () => {
465
- const now = deps.time.now();
475
+ const now = deps.time.performance.now();
466
476
  start ??= now;
467
477
  const elapsed = saturateComputedMillis(now - start);
468
478
  const elapsedSincePrevious =