@buenos-nachos/time-sync 0.4.1 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @buenos-nachos/time-sync
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Breaking Changes
6
+
7
+ - c3986e9: revamped all state management and APIs to be based on monotonic time
8
+ - c3986e9: Removed `registeredAt` and `intervalLastFulfilledAt` properties from `SubscriptionContext` and added monotonic `registeredAtMs`
9
+ - c3986e9: Added monotonic `lastUpdatedAt` property to `Snapshot` type.
10
+
3
11
  ## 0.4.1
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buenos-nachos/time-sync",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": "Michael Smith <hello@nachos.dev> (https://www.nachos.dev)",
@@ -7,7 +7,6 @@ import {
7
7
  type SubscriptionContext,
8
8
  TimeSync,
9
9
  } from "./TimeSync";
10
- import type { Writeable } from "./utilities";
11
10
 
12
11
  const invalidIntervals: readonly number[] = [
13
12
  Number.NaN,
@@ -132,9 +131,8 @@ describe(TimeSync, () => {
132
131
  const expectedCtx: SubscriptionContext = {
133
132
  unsubscribe,
134
133
  timeSync: sync,
135
- intervalLastFulfilledAt: dateAfter,
136
- registeredAt: dateBefore,
137
- targetRefreshIntervalMs: rate,
134
+ refreshIntervalMs: rate,
135
+ registeredAtMs: 0,
138
136
  };
139
137
  expect(onUpdate).toHaveBeenCalledWith(dateAfter, expectedCtx);
140
138
 
@@ -588,20 +586,19 @@ describe(TimeSync, () => {
588
586
  expect,
589
587
  }) => {
590
588
  const sync = new TimeSync();
591
- const interval = refreshRates.oneMinute;
592
589
 
593
590
  let ejectedInterval: number | undefined;
594
591
  const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
595
- ejectedInterval = ctx.targetRefreshIntervalMs;
592
+ ejectedInterval = ctx.refreshIntervalMs;
596
593
  });
597
594
 
598
- void sync.subscribe({
595
+ sync.subscribe({
599
596
  onUpdate,
600
- targetRefreshIntervalMs: interval,
597
+ targetRefreshIntervalMs: refreshRates.oneMinute,
601
598
  });
602
599
 
603
- await vi.advanceTimersByTimeAsync(interval);
604
- expect(ejectedInterval).toBe(interval);
600
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
601
+ expect(ejectedInterval).toBe(refreshRates.oneMinute);
605
602
  });
606
603
 
607
604
  it("Exposes exact same unsubscribe callback as the one returned from the subscribe call", async ({
@@ -623,48 +620,32 @@ describe(TimeSync, () => {
623
620
  expect(ejectedUnsub).toBe(unsub);
624
621
  });
625
622
 
626
- it("Exposes when the subscription was first set up", async ({ expect }) => {
623
+ it("Exposes when the subscription was first set up, relative to the TimeSync instantiation", async ({
624
+ expect,
625
+ }) => {
627
626
  const sync = new TimeSync();
628
- const start = sync.getStateSnapshot().date;
629
-
630
- let ejectedDate: Date | undefined;
631
- const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
632
- ejectedDate = ctx.registeredAt;
633
- });
634
627
 
628
+ let ejectedSetupTime1: number | undefined;
635
629
  void sync.subscribe({
636
- onUpdate,
637
630
  targetRefreshIntervalMs: refreshRates.oneMinute,
631
+ onUpdate: (_, ctx) => {
632
+ ejectedSetupTime1 = ctx.registeredAtMs;
633
+ },
638
634
  });
639
635
 
640
636
  await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
641
- expect(ejectedDate).toEqual(start);
642
- });
643
-
644
- it("Indicates when the last requested interval was fulfilled", async ({
645
- expect,
646
- }) => {
647
- const sync = new TimeSync();
648
-
649
- const fulfilledValues: (ReadonlyDate | null)[] = [];
650
- const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
651
- fulfilledValues.push(ctx.intervalLastFulfilledAt);
652
- });
637
+ expect(ejectedSetupTime1).toEqual(0);
653
638
 
639
+ let ejectedSetupTime2: number | undefined;
654
640
  void sync.subscribe({
655
- onUpdate,
656
641
  targetRefreshIntervalMs: refreshRates.oneMinute,
657
- });
658
- void sync.subscribe({
659
- onUpdate: vi.fn(),
660
- targetRefreshIntervalMs: refreshRates.thirtySeconds,
642
+ onUpdate: (_, ctx) => {
643
+ ejectedSetupTime2 = ctx.registeredAtMs;
644
+ },
661
645
  });
662
646
 
663
647
  await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
664
- const snapAfter = sync.getStateSnapshot().date;
665
-
666
- expect(onUpdate).toHaveBeenCalledTimes(2);
667
- expect(fulfilledValues).toEqual([null, snapAfter]);
648
+ expect(ejectedSetupTime2).toEqual(refreshRates.oneMinute);
668
649
  });
669
650
  });
670
651
 
@@ -782,45 +763,45 @@ describe(TimeSync, () => {
782
763
  expect,
783
764
  }) => {
784
765
  const sync = new TimeSync();
785
- const snapBefore = sync.getStateSnapshot().date;
786
766
 
787
767
  let ejectedContext: SubscriptionContext | undefined;
788
768
  const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
789
769
  ejectedContext = ctx;
790
770
  });
791
771
 
772
+ // Registering all three with the exact same callback. That way, if either
773
+ // of the others get processed, their contexts should overwrite the
774
+ // ejected context and make the tests fail
792
775
  const unsub = sync.subscribe({
793
776
  onUpdate,
794
777
  targetRefreshIntervalMs: refreshRates.oneHour,
795
778
  });
796
- void sync.subscribe({
779
+ sync.subscribe({
797
780
  onUpdate,
798
781
  targetRefreshIntervalMs: refreshRates.oneMinute,
799
782
  });
800
- void sync.subscribe({
783
+ sync.subscribe({
801
784
  onUpdate,
802
785
  targetRefreshIntervalMs: refreshRates.oneSecond,
803
786
  });
804
787
 
805
788
  await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
806
789
  expect(ejectedContext).toEqual<SubscriptionContext>({
807
- intervalLastFulfilledAt: null,
808
- registeredAt: snapBefore,
809
- targetRefreshIntervalMs: refreshRates.oneHour,
790
+ refreshIntervalMs: refreshRates.oneHour,
810
791
  timeSync: sync,
811
792
  unsubscribe: unsub,
793
+ registeredAtMs: 0,
812
794
  });
813
795
 
814
- const remainingSecondsToOneHour = refreshRates.oneHour - 1000;
796
+ const remainingSecondsToOneHour =
797
+ refreshRates.oneHour - refreshRates.oneSecond;
815
798
  await vi.advanceTimersByTimeAsync(remainingSecondsToOneHour);
816
799
 
817
- const snapAfter = sync.getStateSnapshot().date;
818
800
  expect(ejectedContext).toEqual<SubscriptionContext>({
819
- intervalLastFulfilledAt: snapAfter,
820
- registeredAt: snapBefore,
821
- targetRefreshIntervalMs: refreshRates.oneHour,
801
+ refreshIntervalMs: refreshRates.oneHour,
822
802
  timeSync: sync,
823
803
  unsubscribe: unsub,
804
+ registeredAtMs: 0,
824
805
  });
825
806
  });
826
807
  });
@@ -841,6 +822,7 @@ describe(TimeSync, () => {
841
822
  expect(snap).toEqual<Snapshot>({
842
823
  date: initialDate,
843
824
  subscriberCount: 0,
825
+ lastUpdatedAtMs: null,
844
826
  config: {
845
827
  freezeUpdates: false,
846
828
  minimumRefreshIntervalMs,
@@ -971,6 +953,7 @@ describe(TimeSync, () => {
971
953
 
972
954
  // We have readonly modifiers on the types, but we need to make sure
973
955
  // nothing can break at runtime
956
+ type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
974
957
  const snap = sync.getStateSnapshot() as Writeable<Snapshot>;
975
958
  const config = snap.config as Writeable<Configuration>;
976
959
  const copyBeforeMutations = { ...snap, config: { ...config } };
@@ -978,6 +961,7 @@ describe(TimeSync, () => {
978
961
  const mutationSource: Snapshot = {
979
962
  date: new ReadonlyDate("April 1, 1970"),
980
963
  subscriberCount: Number.POSITIVE_INFINITY,
964
+ lastUpdatedAtMs: 1_000_000,
981
965
  config: {
982
966
  freezeUpdates: true,
983
967
  minimumRefreshIntervalMs: Number.POSITIVE_INFINITY,
@@ -989,6 +973,9 @@ describe(TimeSync, () => {
989
973
  () => {
990
974
  snap.date = mutationSource.date;
991
975
  },
976
+ () => {
977
+ snap.lastUpdatedAtMs = mutationSource.lastUpdatedAtMs;
978
+ },
992
979
  () => {
993
980
  snap.subscriberCount = mutationSource.subscriberCount;
994
981
  },
@@ -1071,7 +1058,7 @@ describe(TimeSync, () => {
1071
1058
  * Not sure how to codify that in tests yet, but ideally it should be.
1072
1059
  */
1073
1060
  describe("Freezing updates on init", () => {
1074
- it("Never updates internal state, no matter how many subscribers subscribe", ({
1061
+ it("Always updates internal state to reflect subscription count", async ({
1075
1062
  expect,
1076
1063
  }) => {
1077
1064
  const initialDate = new Date("August 25, 1832");
@@ -1085,8 +1072,9 @@ describe(TimeSync, () => {
1085
1072
  });
1086
1073
  }
1087
1074
 
1075
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
1088
1076
  const snap = sync.getStateSnapshot();
1089
- expect(snap.subscriberCount).toBe(0);
1077
+ expect(snap.subscriberCount).toBe(1000);
1090
1078
  expect(snap.date).toEqual(initialDate);
1091
1079
  });
1092
1080
  });
@@ -1216,30 +1204,29 @@ describe(TimeSync, () => {
1216
1204
  expect(snap3).toBe(0);
1217
1205
  });
1218
1206
 
1219
- it("Lets consumers detect whether an update corresponds to the subscription they explicitly set up", async ({
1207
+ it("Does not break update cadence if system time jumps around", async ({
1220
1208
  expect,
1221
1209
  }) => {
1222
- const sync = new TimeSync();
1223
- const innerOnUpdate = vi.fn();
1210
+ const initialDate = setInitialTime("2022-03-15T08:00:00Z");
1211
+ const sync = new TimeSync({ initialDate });
1212
+ const thirtyMinutes = 30 * refreshRates.oneMinute;
1224
1213
 
1225
- void sync.subscribe({
1226
- targetRefreshIntervalMs: refreshRates.oneMinute,
1227
- onUpdate: (date, ctx) => {
1228
- const intervalMatches =
1229
- date.getTime() === ctx.intervalLastFulfilledAt?.getTime();
1230
- if (intervalMatches) {
1231
- innerOnUpdate();
1232
- }
1233
- },
1234
- });
1214
+ const onUpdate = vi.fn();
1215
+ sync.subscribe({ onUpdate, targetRefreshIntervalMs: thirtyMinutes });
1235
1216
 
1236
- void sync.subscribe({
1237
- targetRefreshIntervalMs: refreshRates.thirtySeconds,
1238
- onUpdate: vi.fn(),
1239
- });
1217
+ await vi.advanceTimersByTimeAsync(thirtyMinutes);
1218
+ expect(onUpdate).toHaveBeenCalledTimes(1);
1240
1219
 
1241
- await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
1242
- expect(innerOnUpdate).toHaveBeenCalledTimes(1);
1220
+ // Go one hour into the past and then advance 30 minutes to go back to
1221
+ // where we started
1222
+ vi.setSystemTime("2022-03-15T07:30:00Z");
1223
+ await vi.advanceTimersByTimeAsync(thirtyMinutes);
1224
+ expect(onUpdate).toHaveBeenCalledTimes(2);
1225
+
1226
+ // Go one day into the past and then advance another 30 minutes
1227
+ vi.setSystemTime("2022-03-14T08:00:00Z");
1228
+ await vi.advanceTimersByTimeAsync(thirtyMinutes);
1229
+ expect(onUpdate).toHaveBeenCalledTimes(3);
1243
1230
  });
1244
1231
  });
1245
1232
  });
package/src/TimeSync.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { ReadonlyDate } from "./ReadonlyDate";
2
- import type { Writeable } from "./utilities";
3
2
 
4
3
  /**
5
4
  * A collection of commonly-needed intervals (all defined in milliseconds).
@@ -118,6 +117,14 @@ export interface Snapshot {
118
117
  */
119
118
  readonly date: ReadonlyDate;
120
119
 
120
+ /**
121
+ * The monotonic milliseconds that elapsed between the TimeSync being
122
+ * instantiated and the last update being dispatched.
123
+ *
124
+ * Will be null if no updates have ever been dispatched.
125
+ */
126
+ readonly lastUpdatedAtMs: number | null;
127
+
121
128
  /**
122
129
  * The number of subscribers registered with TimeSync.
123
130
  */
@@ -132,44 +139,33 @@ export interface Snapshot {
132
139
 
133
140
  /**
134
141
  * An object with information about a specific subscription registered with
135
- * TimeSync.
136
- *
137
- * For performance reasons, this object has ZERO readonly guarantees enforced at
138
- * runtime. All properties are defined as readonly at the type level, but an
139
- * accidental mutation can still slip through.
142
+ * TimeSync. The entire context is frozen at runtime.
140
143
  */
141
144
  export interface SubscriptionContext {
142
145
  /**
143
- * The interval that the subscription was registered with.
144
- */
145
- readonly targetRefreshIntervalMs: number;
146
-
147
- /**
148
- * The unsubscribe callback associated with a subscription. This is the same
149
- * callback returned by `TimeSync.subscribe`.
146
+ * A reference to the TimeSync instance that the subscription was registered
147
+ * with.
150
148
  */
151
- readonly unsubscribe: () => void;
149
+ readonly timeSync: TimeSync;
152
150
 
153
151
  /**
154
- * A timestamp of when the subscription was first set up.
152
+ * The effective interval that the subscription is updating at. This may be a
153
+ * value larger than than the target refresh interval, depending on whether
154
+ * TimeSync was configured with a minimum refresh value.
155
155
  */
156
- readonly registeredAt: ReadonlyDate;
156
+ readonly refreshIntervalMs: number;
157
157
 
158
158
  /**
159
- * A reference to the TimeSync instance that the subscription was registered
160
- * with.
159
+ * The unsubscribe callback associated with a subscription. This is the same
160
+ * callback returned by `TimeSync.subscribe`.
161
161
  */
162
- readonly timeSync: TimeSync;
162
+ readonly unsubscribe: () => void;
163
163
 
164
164
  /**
165
- * Indicates when the last time the subscription had its explicit interval
166
- * "satisfied".
167
- *
168
- * For example, if a subscription is registered for every five minutes, but
169
- * the active interval is set to fire every second, you may need to know
170
- * which update actually happened five minutes later.
165
+ * The monotonic milliseconds that elapsed between the TimeSync being
166
+ * instantiated and the subscription being registered.
171
167
  */
172
- readonly intervalLastFulfilledAt: ReadonlyDate | null;
168
+ readonly registeredAtMs: number;
173
169
  }
174
170
 
175
171
  /**
@@ -226,9 +222,25 @@ interface TimeSyncApi {
226
222
  clearAll: () => void;
227
223
  }
228
224
 
229
- /* biome-ignore lint:suspicious/noEmptyBlockStatements -- Rare case where we do
230
- actually want a completely empty function body. */
231
- function noOp(..._: readonly unknown[]): void {}
225
+ /**
226
+ * Even though both the browser and the server are able to give monotonic times
227
+ * that are at least as precise as a nanosecond, we're using milliseconds for
228
+ * consistency with useInterval, which cannot be more precise than a
229
+ * millisecond.
230
+ */
231
+ function getMonotonicTimeMs(): number {
232
+ // If we're on the server, we can use process.hrtime, which is defined for
233
+ // Node, Deno, and Bun
234
+ if (typeof window === "undefined") {
235
+ const timeInNanoseconds = process.hrtime.bigint();
236
+ return Number(timeInNanoseconds / 1000n);
237
+ }
238
+
239
+ // Otherwise, we need to get the high-resolution timestamp from the browser.
240
+ // This value is fractional and goes to nine decimal places
241
+ const highResTimestamp = window.performance.now();
242
+ return Math.floor(highResTimestamp);
243
+ }
232
244
 
233
245
  /**
234
246
  * This function is just a convenience for us to sidestep some problems around
@@ -243,8 +255,13 @@ function noOp(..._: readonly unknown[]): void {}
243
255
  * to worry about mismatches.
244
256
  */
245
257
  function freezeSnapshot(snap: Snapshot): Snapshot {
246
- Object.freeze(snap.config);
247
- return Object.freeze(snap);
258
+ if (!Object.isFrozen(snap.config)) {
259
+ Object.freeze(snap.config);
260
+ }
261
+ if (!Object.isFrozen(snap)) {
262
+ Object.freeze(snap);
263
+ }
264
+ return snap;
248
265
  }
249
266
 
250
267
  const defaultMinimumRefreshIntervalMs = 200;
@@ -285,6 +302,12 @@ const defaultMinimumRefreshIntervalMs = 200;
285
302
  * some parts of the screen.)
286
303
  */
287
304
  export class TimeSync implements TimeSyncApi {
305
+ /**
306
+ * The monotonic time in milliseconds from when the TimeSync instance was
307
+ * first instantiated.
308
+ */
309
+ readonly #initializedAtMs: number;
310
+
288
311
  /**
289
312
  * Stores all refresh intervals actively associated with an onUpdate
290
313
  * callback (along with their associated unsubscribe callbacks).
@@ -333,7 +356,8 @@ export class TimeSync implements TimeSyncApi {
333
356
 
334
357
  /**
335
358
  * Used for both its intended purpose (creating interval), but also as a
336
- * janky version of setTimeout.
359
+ * janky version of setTimeout. Also, all versions of setInterval are
360
+ * monotonic, so we don't have to do anything special for it.
337
361
  *
338
362
  * There are a few times when we need timeout-like logic, but if we use
339
363
  * setInterval for everything, we have fewer IDs to juggle, and less risk of
@@ -365,22 +389,30 @@ export class TimeSync implements TimeSyncApi {
365
389
  this.#subscriptions = new Map();
366
390
  this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
367
391
  this.#intervalId = undefined;
392
+ this.#initializedAtMs = getMonotonicTimeMs();
393
+
394
+ let date: ReadonlyDate;
395
+ if (initialDate instanceof ReadonlyDate) {
396
+ date = initialDate;
397
+ } else if (initialDate instanceof Date) {
398
+ date = new ReadonlyDate(initialDate);
399
+ } else {
400
+ date = new ReadonlyDate();
401
+ }
368
402
 
369
- // Not defined inline to avoid wonkiness that Object.freeze introduces
370
- // when you rename a property on a frozen object
371
- const initialSnapshot: Snapshot = {
403
+ this.#latestSnapshot = freezeSnapshot({
404
+ date,
372
405
  subscriberCount: 0,
373
- date: initialDate ? new ReadonlyDate(initialDate) : new ReadonlyDate(),
374
- config: Object.freeze({
406
+ lastUpdatedAtMs: null,
407
+ config: {
375
408
  freezeUpdates,
376
409
  minimumRefreshIntervalMs,
377
410
  allowDuplicateOnUpdateCalls,
378
- }),
379
- };
380
- this.#latestSnapshot = Object.freeze(initialSnapshot);
411
+ },
412
+ });
381
413
  }
382
414
 
383
- #processSubscriptionUpdate(): void {
415
+ #notifyAllSubscriptions(): void {
384
416
  // It's more important that we copy the date object into a separate
385
417
  // variable here than normal, because need make sure the `this` context
386
418
  // can't magically change between updates and cause subscribers to
@@ -395,8 +427,6 @@ export class TimeSync implements TimeSyncApi {
395
427
  return;
396
428
  }
397
429
 
398
- const dateTime = date.getTime();
399
-
400
430
  /**
401
431
  * Two things:
402
432
  * 1. Even though the context arrays are defined as readonly (which
@@ -416,32 +446,20 @@ export class TimeSync implements TimeSyncApi {
416
446
  * to check on each iteration to see if we should continue.
417
447
  */
418
448
  const subsBeforeUpdate = this.#subscriptions;
419
- const entries = Array.from(subsBeforeUpdate);
420
- outer: for (const [onUpdate, subs] of entries) {
421
- // Even if duplicate onUpdate calls are disabled, we still need to
422
- // iterate through everything and update any internal data. If the
423
- // first context in a sub array gets removed by unsubscribing, we
424
- // want what was the the second element to still be up to date
425
- let shouldCallOnUpdate = true;
426
- for (const ctx of subs as readonly Writeable<SubscriptionContext>[]) {
449
+ const localEntries = Array.from(subsBeforeUpdate);
450
+ outer: for (const [onUpdate, subs] of localEntries) {
451
+ for (const ctx of subs) {
427
452
  // We're not doing anything more sophisticated here because
428
453
  // we're assuming that any systems that can clear out the
429
454
  // subscriptions will handle cleaning up each context, too
430
- const wasCleared = subsBeforeUpdate.size === 0;
431
- if (wasCleared) {
455
+ const wasClearedBetweenUpdates = subsBeforeUpdate.size === 0;
456
+ if (wasClearedBetweenUpdates) {
432
457
  break outer;
433
458
  }
434
459
 
435
- const comparisonDate = ctx.intervalLastFulfilledAt ?? ctx.registeredAt;
436
- const isIntervalMatch =
437
- dateTime - comparisonDate.getTime() >= ctx.targetRefreshIntervalMs;
438
- if (isIntervalMatch) {
439
- ctx.intervalLastFulfilledAt = date;
440
- }
441
-
442
- if (shouldCallOnUpdate) {
443
- onUpdate(date, ctx);
444
- shouldCallOnUpdate = config.allowDuplicateOnUpdateCalls;
460
+ onUpdate(date, ctx);
461
+ if (!config.allowDuplicateOnUpdateCalls) {
462
+ continue outer;
445
463
  }
446
464
  }
447
465
  }
@@ -456,7 +474,7 @@ export class TimeSync implements TimeSyncApi {
456
474
  * is one of them.
457
475
  */
458
476
  readonly #onTick = (): void => {
459
- const { config, date } = this.#latestSnapshot;
477
+ const { config } = this.#latestSnapshot;
460
478
  if (config.freezeUpdates) {
461
479
  // Defensive step to make sure that an invalid tick wasn't started
462
480
  clearInterval(this.#intervalId);
@@ -464,59 +482,57 @@ export class TimeSync implements TimeSyncApi {
464
482
  return;
465
483
  }
466
484
 
467
- const newDate = new ReadonlyDate();
468
- if (newDate.getTime() === date.getTime()) {
469
- return;
470
- }
471
-
485
+ // onTick is expected to be called in response to monotonic time changes
486
+ // (either from calculating them manually to decide when to call onTick
487
+ // synchronously or from letting setInterval handle the calls). So while
488
+ // this edge case should basically be impossible, we need to make sure that
489
+ // we always dispatch a date, even if its time is exactly the same.
472
490
  this.#latestSnapshot = freezeSnapshot({
473
491
  ...this.#latestSnapshot,
474
- date: newDate,
492
+ date: new ReadonlyDate(),
493
+ lastUpdatedAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
475
494
  });
476
- this.#processSubscriptionUpdate();
495
+ this.#notifyAllSubscriptions();
477
496
  };
478
497
 
479
498
  #onFastestIntervalChange(): void {
480
499
  const fastest = this.#fastestRefreshInterval;
481
- const { date, config } = this.#latestSnapshot;
500
+ const { lastUpdatedAtMs, config } = this.#latestSnapshot;
501
+
482
502
  const updatesShouldStop =
483
- config.freezeUpdates || fastest === Number.POSITIVE_INFINITY;
503
+ config.freezeUpdates ||
504
+ this.#subscriptions.size === 0 ||
505
+ fastest === Number.POSITIVE_INFINITY;
484
506
  if (updatesShouldStop) {
485
507
  clearInterval(this.#intervalId);
486
508
  this.#intervalId = undefined;
487
509
  return;
488
510
  }
489
511
 
490
- const elapsed = Date.now() - date.getTime();
512
+ const newTime = getMonotonicTimeMs();
513
+ const elapsed = newTime - (lastUpdatedAtMs ?? this.#initializedAtMs);
491
514
  const timeBeforeNextUpdate = fastest - elapsed;
492
515
 
493
- // Clear previous interval sight unseen just to be on the safe side
516
+ // Clear previous interval no matter what just to be on the safe side
494
517
  clearInterval(this.#intervalId);
495
518
 
496
519
  if (timeBeforeNextUpdate <= 0) {
497
- const newDate = new ReadonlyDate();
498
- if (newDate.getTime() !== date.getTime()) {
499
- this.#latestSnapshot = freezeSnapshot({
500
- ...this.#latestSnapshot,
501
- date: newDate,
502
- });
503
- this.#processSubscriptionUpdate();
504
- }
505
-
520
+ this.#onTick();
506
521
  this.#intervalId = setInterval(this.#onTick, fastest);
507
522
  return;
508
523
  }
509
524
 
510
525
  // Most common case for this branch is the very first subscription
511
526
  // getting added, but there's still the small chance that the fastest
512
- // interval could change right after an update got flushed
527
+ // interval could change right after an update got flushed, so there would
528
+ // be zero elapsed time to worry about
513
529
  if (timeBeforeNextUpdate === fastest) {
514
530
  this.#intervalId = setInterval(this.#onTick, timeBeforeNextUpdate);
515
531
  return;
516
532
  }
517
533
 
518
- // Otherwise, use interval as pseudo-timeout, and then go back to using
519
- // it as a normal interval afterwards
534
+ // Otherwise, use setInterval as pseudo-timeout to resolve the remaining
535
+ // time as a one-time update, and then go back to using normal intervals
520
536
  this.#intervalId = setInterval(() => {
521
537
  clearInterval(this.#intervalId);
522
538
 
@@ -537,14 +553,13 @@ export class TimeSync implements TimeSyncApi {
537
553
  return;
538
554
  }
539
555
 
540
- const prevFastest = this.#fastestRefreshInterval;
541
- let newFastest = Number.POSITIVE_INFINITY;
542
-
543
556
  // This setup requires that every interval array stay sorted. It
544
557
  // immediately falls apart if this isn't guaranteed.
545
- for (const entries of this.#subscriptions.values()) {
558
+ const prevFastest = this.#fastestRefreshInterval;
559
+ let newFastest = Number.POSITIVE_INFINITY;
560
+ for (const contexts of this.#subscriptions.values()) {
546
561
  const subFastest =
547
- entries[0]?.targetRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
562
+ contexts[0]?.refreshIntervalMs ?? Number.POSITIVE_INFINITY;
548
563
  if (subFastest < newFastest) {
549
564
  newFastest = subFastest;
550
565
  }
@@ -557,14 +572,10 @@ export class TimeSync implements TimeSyncApi {
557
572
  }
558
573
 
559
574
  subscribe(options: SubscriptionInitOptions): () => void {
560
- const { config } = this.#latestSnapshot;
561
- if (config.freezeUpdates) {
562
- return noOp;
563
- }
564
-
565
575
  // Destructuring properties so that they can't be fiddled with after
566
576
  // this function call ends
567
577
  const { targetRefreshIntervalMs, onUpdate } = options;
578
+ const { minimumRefreshIntervalMs } = this.#latestSnapshot.config;
568
579
 
569
580
  const isTargetValid =
570
581
  targetRefreshIntervalMs === Number.POSITIVE_INFINITY ||
@@ -576,71 +587,72 @@ export class TimeSync implements TimeSyncApi {
576
587
  );
577
588
  }
578
589
 
579
- // Have to define this as a writeable to avoid a chicken-and-the-egg
580
- // problem for the unsubscribe callback
581
- const context: Writeable<SubscriptionContext> = {
590
+ const subsOnSetup = this.#subscriptions;
591
+ let subscribed = true;
592
+ const ctx: SubscriptionContext = {
582
593
  timeSync: this,
583
- unsubscribe: noOp,
584
- registeredAt: new ReadonlyDate(),
585
- intervalLastFulfilledAt: null,
586
- targetRefreshIntervalMs: Math.max(
587
- config.minimumRefreshIntervalMs,
594
+ registeredAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
595
+ refreshIntervalMs: Math.max(
596
+ minimumRefreshIntervalMs,
588
597
  targetRefreshIntervalMs,
589
598
  ),
590
- };
591
-
592
- // Not reading from context value to decide whether to bail out of
593
- // unsubscribes in off chance that outside consumer accidentally mutates
594
- // the value
595
- let subscribed = true;
596
- const subsOnSetup = this.#subscriptions;
597
- const unsubscribe = (): void => {
598
- if (!subscribed || this.#subscriptions !== subsOnSetup) {
599
- subscribed = false;
600
- return;
601
- }
602
599
 
603
- const contexts = subsOnSetup.get(onUpdate);
604
- if (contexts === undefined) {
605
- return;
606
- }
607
- const filtered = contexts.filter((e) => e.unsubscribe !== unsubscribe);
608
- if (filtered.length === contexts.length) {
609
- return;
610
- }
611
-
612
- if (filtered.length === 0) {
613
- subsOnSetup.delete(onUpdate);
614
- this.#updateFastestInterval();
615
- } else {
616
- // No need to sort on removal because everything gets sorted as
617
- // it enters the subscriptions map
618
- subsOnSetup.set(onUpdate, filtered);
619
- }
620
-
621
- this.#latestSnapshot = freezeSnapshot({
622
- ...this.#latestSnapshot,
623
- subscriberCount: Math.max(0, this.#latestSnapshot.subscriberCount - 1),
624
- });
625
-
626
- subscribed = false;
600
+ unsubscribe: () => {
601
+ // Not super conventional, but basically using try/finally as a form of
602
+ // Go's defer. There are a lot of branches we need to worry about for
603
+ // the unsubscribe callback, and we need to make sure we flip subscribed
604
+ // to false after each one
605
+ try {
606
+ if (!subscribed || this.#subscriptions !== subsOnSetup) {
607
+ return;
608
+ }
609
+ const contexts = subsOnSetup.get(onUpdate);
610
+ if (contexts === undefined) {
611
+ return;
612
+ }
613
+ const filtered = contexts.filter(
614
+ (c) => c.unsubscribe !== ctx.unsubscribe,
615
+ );
616
+ if (filtered.length === contexts.length) {
617
+ return;
618
+ }
619
+
620
+ const dropped = Math.max(0, this.#latestSnapshot.subscriberCount - 1);
621
+ this.#latestSnapshot = freezeSnapshot({
622
+ ...this.#latestSnapshot,
623
+ subscriberCount: dropped,
624
+ });
625
+
626
+ if (filtered.length > 0) {
627
+ // No need to sort on removal because everything gets sorted as
628
+ // it enters the subscriptions map
629
+ subsOnSetup.set(onUpdate, filtered);
630
+ } else {
631
+ subsOnSetup.delete(onUpdate);
632
+ }
633
+
634
+ this.#updateFastestInterval();
635
+ } finally {
636
+ subscribed = false;
637
+ }
638
+ },
627
639
  };
628
- context.unsubscribe = unsubscribe;
629
-
630
- let contexts: SubscriptionContext[];
631
- if (this.#subscriptions.has(onUpdate)) {
632
- const prev = this.#subscriptions.get(onUpdate) as SubscriptionContext[];
633
- contexts = [...prev];
640
+ Object.freeze(ctx);
641
+
642
+ // We need to make sure that each array for tracking subscriptions is
643
+ // readonly, and because dispatching updates should be far more common than
644
+ // adding subscriptions, we're placing the immutable copying here to
645
+ // minimize overall pressure on the system.
646
+ let newContexts: SubscriptionContext[];
647
+ const prevContexts = subsOnSetup.get(onUpdate);
648
+ if (prevContexts !== undefined) {
649
+ newContexts = [...prevContexts, ctx];
634
650
  } else {
635
- contexts = [];
636
- subsOnSetup.set(onUpdate, contexts);
651
+ newContexts = [ctx];
637
652
  }
638
653
 
639
- subsOnSetup.set(onUpdate, contexts);
640
- contexts.push(context);
641
- contexts.sort(
642
- (e1, e2) => e1.targetRefreshIntervalMs - e2.targetRefreshIntervalMs,
643
- );
654
+ subsOnSetup.set(onUpdate, newContexts);
655
+ newContexts.sort((c1, c2) => c1.refreshIntervalMs - c2.refreshIntervalMs);
644
656
 
645
657
  this.#latestSnapshot = freezeSnapshot({
646
658
  ...this.#latestSnapshot,
@@ -648,7 +660,7 @@ export class TimeSync implements TimeSyncApi {
648
660
  });
649
661
 
650
662
  this.#updateFastestInterval();
651
- return unsubscribe;
663
+ return ctx.unsubscribe;
652
664
  }
653
665
 
654
666
  getStateSnapshot(): Snapshot {
@@ -668,6 +680,7 @@ export class TimeSync implements TimeSyncApi {
668
680
  // We swap the map out so that the unsubscribe callbacks can detect
669
681
  // whether their functionality is still relevant
670
682
  this.#subscriptions = new Map();
683
+
671
684
  this.#latestSnapshot = freezeSnapshot({
672
685
  ...this.#latestSnapshot,
673
686
  subscriberCount: 0,
package/src/utilities.ts DELETED
@@ -1 +0,0 @@
1
- export type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };