@buenos-nachos/time-sync 0.1.2 → 0.3.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,21 @@
1
1
  # @buenos-nachos/time-sync
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 122f6c1: Updated `SubscriptionContext.timeSync` type to be readonly and non-nullable, and renamed `SubscriptionContext.isLive` to `SubscriptionContext.isSubscribed`.
8
+
9
+ ## 0.2.0
10
+
11
+ ### Breaking Changes
12
+
13
+ - 2f527dd: Changed the default value of `allowDuplicateFunctionCalls` from `false` to `true`
14
+
15
+ ### Minor Changes
16
+
17
+ - 5f86fac: Added second parameter to `onUpdate` callback. This value is a value of type `SubscriptionContext` and provides information about the current subscription.
18
+
3
19
  ## 0.1.2
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buenos-nachos/time-sync",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": "Michael Smith <hello@nachos.dev> (https://www.nachos.dev)",
@@ -4,8 +4,10 @@ import {
4
4
  type Configuration,
5
5
  refreshRates,
6
6
  type Snapshot,
7
+ type SubscriptionContext,
7
8
  TimeSync,
8
9
  } from "./TimeSync";
10
+ import type { Writeable } from "./utilities";
9
11
 
10
12
  const invalidIntervals: readonly number[] = [
11
13
  Number.NaN,
@@ -116,17 +118,26 @@ describe(TimeSync, () => {
116
118
  const sync = new TimeSync();
117
119
  const onUpdate = vi.fn();
118
120
 
119
- void sync.subscribe({
121
+ const dateBefore = sync.getStateSnapshot().date;
122
+ const unsubscribe = sync.subscribe({
120
123
  onUpdate,
121
124
  targetRefreshIntervalMs: rate,
122
125
  });
123
126
  expect(onUpdate).not.toHaveBeenCalled();
124
127
 
125
- const dateBefore = sync.getStateSnapshot().date;
126
128
  await vi.advanceTimersByTimeAsync(rate);
127
129
  const dateAfter = sync.getStateSnapshot().date;
128
130
  expect(onUpdate).toHaveBeenCalledTimes(1);
129
- expect(onUpdate).toHaveBeenCalledWith(dateAfter);
131
+
132
+ const expectedCtx: SubscriptionContext = {
133
+ unsubscribe,
134
+ isSubscribed: true,
135
+ timeSync: sync,
136
+ intervalLastFulfilledAt: dateAfter,
137
+ registeredAt: dateBefore,
138
+ targetRefreshIntervalMs: rate,
139
+ };
140
+ expect(onUpdate).toHaveBeenCalledWith(dateAfter, expectedCtx);
130
141
 
131
142
  const diff = dateAfter.getTime() - dateBefore.getTime();
132
143
  expect(diff).toBe(rate);
@@ -171,7 +182,7 @@ describe(TimeSync, () => {
171
182
  it("Always dispatches updates in the order that callbacks were first registered", async ({
172
183
  expect,
173
184
  }) => {
174
- const sync = new TimeSync();
185
+ const sync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
175
186
  const callOrder: number[] = [];
176
187
 
177
188
  const onUpdate1 = vi.fn(() => {
@@ -260,7 +271,7 @@ describe(TimeSync, () => {
260
271
  expect(secondOnUpdate).toHaveBeenCalledTimes(1);
261
272
  });
262
273
 
263
- it("Calls onUpdate callback one time total if callback is registered multiple times for the same time interval", async ({
274
+ it("Calls onUpdate callback once for each subscription (even if the same callback was registered multiple times)", async ({
264
275
  expect,
265
276
  }) => {
266
277
  const sync = new TimeSync();
@@ -274,59 +285,7 @@ describe(TimeSync, () => {
274
285
  }
275
286
 
276
287
  await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
277
- expect(sharedOnUpdate).toHaveBeenCalledTimes(1);
278
- });
279
-
280
- it("Calls onUpdate callback one time total if callback is registered multiple times for different time intervals", async ({
281
- expect,
282
- }) => {
283
- const sync = new TimeSync();
284
- const sharedOnUpdate = vi.fn();
285
-
286
- void sync.subscribe({
287
- onUpdate: sharedOnUpdate,
288
- targetRefreshIntervalMs: refreshRates.oneHour,
289
- });
290
- void sync.subscribe({
291
- onUpdate: sharedOnUpdate,
292
- targetRefreshIntervalMs: refreshRates.oneMinute,
293
- });
294
- void sync.subscribe({
295
- onUpdate: sharedOnUpdate,
296
- targetRefreshIntervalMs: refreshRates.oneSecond,
297
- });
298
-
299
- // Testing like this to ensure that for really, really long spans of
300
- // time, the no duplicated calls logic still holds up
301
- await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
302
- const secondsInOneHour = 3600;
303
- expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
304
- });
305
-
306
- it("Calls onUpdate callback one time total if callback is registered multiple times with a mix of redundant/different intervals", async ({
307
- expect,
308
- }) => {
309
- const sync = new TimeSync();
310
- const sharedOnUpdate = vi.fn();
311
-
312
- for (let i = 0; i < 10; i++) {
313
- void sync.subscribe({
314
- onUpdate: sharedOnUpdate,
315
- targetRefreshIntervalMs: refreshRates.oneHour,
316
- });
317
- void sync.subscribe({
318
- onUpdate: sharedOnUpdate,
319
- targetRefreshIntervalMs: refreshRates.oneMinute,
320
- });
321
- void sync.subscribe({
322
- onUpdate: sharedOnUpdate,
323
- targetRefreshIntervalMs: refreshRates.oneSecond,
324
- });
325
- }
326
-
327
- await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
328
- const secondsInOneHour = 3600;
329
- expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
288
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(3);
330
289
  });
331
290
 
332
291
  it("Lets an external system unsubscribe", async ({ expect }) => {
@@ -405,7 +364,7 @@ describe(TimeSync, () => {
405
364
  const snap2 = sync.getStateSnapshot();
406
365
  expect(snap2.subscriberCount).toBe(30);
407
366
  await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
408
- expect(sharedOnUpdate).toHaveBeenCalledTimes(1);
367
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(30);
409
368
  });
410
369
 
411
370
  it("Speeds up interval when new subscriber is added that is faster than all other subscribers", async ({
@@ -604,6 +563,132 @@ describe(TimeSync, () => {
604
563
  const dateAfter = sync.getStateSnapshot().date;
605
564
  expect(dateAfter).not.toEqual(dateBefore);
606
565
  });
566
+
567
+ it("Mutates the isLive context value to be false on unsubscribe", async ({
568
+ expect,
569
+ }) => {
570
+ const sync = new TimeSync();
571
+
572
+ let ejectedContext: SubscriptionContext | undefined;
573
+ const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
574
+ ejectedContext = ctx;
575
+ });
576
+
577
+ const unsub = sync.subscribe({
578
+ onUpdate,
579
+ targetRefreshIntervalMs: refreshRates.oneMinute,
580
+ });
581
+
582
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
583
+ expect(ejectedContext?.isSubscribed).toBe(true);
584
+
585
+ unsub();
586
+ expect(ejectedContext?.isSubscribed).toBe(false);
587
+ });
588
+ });
589
+
590
+ describe("Subscriptions: context values", () => {
591
+ it("Defaults to exposing the TimeSync instance the subscription was registered with", async ({
592
+ expect,
593
+ }) => {
594
+ const sync = new TimeSync();
595
+
596
+ let ejectedSync: TimeSync | null = null;
597
+ const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
598
+ ejectedSync = ctx.timeSync;
599
+ });
600
+
601
+ void sync.subscribe({
602
+ onUpdate,
603
+ targetRefreshIntervalMs: refreshRates.oneMinute,
604
+ });
605
+
606
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
607
+ expect(ejectedSync).toBe(sync);
608
+ });
609
+
610
+ it("Exposes refresh interval used on initialization", async ({
611
+ expect,
612
+ }) => {
613
+ const sync = new TimeSync();
614
+ const interval = refreshRates.oneMinute;
615
+
616
+ let ejectedInterval: number | undefined;
617
+ const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
618
+ ejectedInterval = ctx.targetRefreshIntervalMs;
619
+ });
620
+
621
+ void sync.subscribe({
622
+ onUpdate,
623
+ targetRefreshIntervalMs: interval,
624
+ });
625
+
626
+ await vi.advanceTimersByTimeAsync(interval);
627
+ expect(ejectedInterval).toBe(interval);
628
+ });
629
+
630
+ it("Exposes exact same unsubscribe callback as the one returned from the subscribe call", async ({
631
+ expect,
632
+ }) => {
633
+ const sync = new TimeSync();
634
+
635
+ let ejectedUnsub: (() => void) | undefined;
636
+ const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
637
+ ejectedUnsub = ctx.unsubscribe;
638
+ });
639
+
640
+ const unsub = sync.subscribe({
641
+ onUpdate,
642
+ targetRefreshIntervalMs: refreshRates.oneMinute,
643
+ });
644
+
645
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
646
+ expect(ejectedUnsub).toBe(unsub);
647
+ });
648
+
649
+ it("Exposes when the subscription was first set up", async ({ expect }) => {
650
+ const sync = new TimeSync();
651
+ const start = sync.getStateSnapshot().date;
652
+
653
+ let ejectedDate: Date | undefined;
654
+ const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
655
+ ejectedDate = ctx.registeredAt;
656
+ });
657
+
658
+ void sync.subscribe({
659
+ onUpdate,
660
+ targetRefreshIntervalMs: refreshRates.oneMinute,
661
+ });
662
+
663
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
664
+ expect(ejectedDate).toEqual(start);
665
+ });
666
+
667
+ it("Indicates when the last requested interval was fulfilled", async ({
668
+ expect,
669
+ }) => {
670
+ const sync = new TimeSync();
671
+
672
+ const fulfilledValues: (ReadonlyDate | null)[] = [];
673
+ const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
674
+ fulfilledValues.push(ctx.intervalLastFulfilledAt);
675
+ });
676
+
677
+ void sync.subscribe({
678
+ onUpdate,
679
+ targetRefreshIntervalMs: refreshRates.oneMinute,
680
+ });
681
+ void sync.subscribe({
682
+ onUpdate: vi.fn(),
683
+ targetRefreshIntervalMs: refreshRates.thirtySeconds,
684
+ });
685
+
686
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
687
+ const snapAfter = sync.getStateSnapshot().date;
688
+
689
+ expect(onUpdate).toHaveBeenCalledTimes(2);
690
+ expect(fulfilledValues).toEqual([null, snapAfter]);
691
+ });
607
692
  });
608
693
 
609
694
  describe("Subscriptions: custom `minimumRefreshIntervalMs` value", () => {
@@ -646,11 +731,14 @@ describe(TimeSync, () => {
646
731
  });
647
732
  });
648
733
 
649
- describe("Subscriptions: duplicating function calls", () => {
650
- it("Defaults to de-duplicating", async ({ expect }) => {
651
- const sync = new TimeSync();
734
+ describe("Subscriptions: turning off duplicate function calls", () => {
735
+ it("Calls onUpdate callback once for each subscription (even if the same callback was registered multiple times)", async ({
736
+ expect,
737
+ }) => {
738
+ const sync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
652
739
  const sharedOnUpdate = vi.fn();
653
- for (let i = 0; i < 100; i++) {
740
+
741
+ for (let i = 1; i <= 3; i++) {
654
742
  void sync.subscribe({
655
743
  onUpdate: sharedOnUpdate,
656
744
  targetRefreshIntervalMs: refreshRates.oneMinute,
@@ -661,21 +749,104 @@ describe(TimeSync, () => {
661
749
  expect(sharedOnUpdate).toHaveBeenCalledTimes(1);
662
750
  });
663
751
 
664
- it("Lets user turn on duplication", async ({ expect }) => {
665
- const sync = new TimeSync({
666
- allowDuplicateOnUpdateCalls: true,
752
+ it("Calls onUpdate callback one time total if callback is registered multiple times for different time intervals", async ({
753
+ expect,
754
+ }) => {
755
+ const sync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
756
+ const sharedOnUpdate = vi.fn();
757
+
758
+ void sync.subscribe({
759
+ onUpdate: sharedOnUpdate,
760
+ targetRefreshIntervalMs: refreshRates.oneHour,
761
+ });
762
+ void sync.subscribe({
763
+ onUpdate: sharedOnUpdate,
764
+ targetRefreshIntervalMs: refreshRates.oneMinute,
765
+ });
766
+ void sync.subscribe({
767
+ onUpdate: sharedOnUpdate,
768
+ targetRefreshIntervalMs: refreshRates.oneSecond,
667
769
  });
668
770
 
771
+ // Testing like this to ensure that for really, really long spans of
772
+ // time, the no duplicated calls logic still holds up
773
+ await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
774
+ const secondsInOneHour = 3600;
775
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
776
+ });
777
+
778
+ it("Calls onUpdate callback one time total if callback is registered multiple times with a mix of redundant/different intervals", async ({
779
+ expect,
780
+ }) => {
781
+ const sync = new TimeSync({ allowDuplicateOnUpdateCalls: false });
669
782
  const sharedOnUpdate = vi.fn();
670
- for (let i = 0; i < 100; i++) {
783
+
784
+ for (let i = 0; i < 10; i++) {
785
+ void sync.subscribe({
786
+ onUpdate: sharedOnUpdate,
787
+ targetRefreshIntervalMs: refreshRates.oneHour,
788
+ });
671
789
  void sync.subscribe({
672
790
  onUpdate: sharedOnUpdate,
673
791
  targetRefreshIntervalMs: refreshRates.oneMinute,
674
792
  });
793
+ void sync.subscribe({
794
+ onUpdate: sharedOnUpdate,
795
+ targetRefreshIntervalMs: refreshRates.oneSecond,
796
+ });
675
797
  }
676
798
 
677
- await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
678
- expect(sharedOnUpdate).toHaveBeenCalledTimes(100);
799
+ await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
800
+ const secondsInOneHour = 3600;
801
+ expect(sharedOnUpdate).toHaveBeenCalledTimes(secondsInOneHour);
802
+ });
803
+
804
+ it("Always exposes the context for the subscription that first set up onUpdate", async ({
805
+ expect,
806
+ }) => {
807
+ const sync = new TimeSync();
808
+ const snapBefore = sync.getStateSnapshot().date;
809
+
810
+ let ejectedContext: SubscriptionContext | undefined;
811
+ const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
812
+ ejectedContext = ctx;
813
+ });
814
+
815
+ const unsub = sync.subscribe({
816
+ onUpdate,
817
+ targetRefreshIntervalMs: refreshRates.oneHour,
818
+ });
819
+ void sync.subscribe({
820
+ onUpdate,
821
+ targetRefreshIntervalMs: refreshRates.oneMinute,
822
+ });
823
+ void sync.subscribe({
824
+ onUpdate,
825
+ targetRefreshIntervalMs: refreshRates.oneSecond,
826
+ });
827
+
828
+ await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
829
+ expect(ejectedContext).toEqual<SubscriptionContext>({
830
+ isSubscribed: true,
831
+ intervalLastFulfilledAt: null,
832
+ registeredAt: snapBefore,
833
+ targetRefreshIntervalMs: refreshRates.oneHour,
834
+ timeSync: sync,
835
+ unsubscribe: unsub,
836
+ });
837
+
838
+ const remainingSecondsToOneHour = refreshRates.oneHour - 1000;
839
+ await vi.advanceTimersByTimeAsync(remainingSecondsToOneHour);
840
+
841
+ const snapAfter = sync.getStateSnapshot().date;
842
+ expect(ejectedContext).toEqual<SubscriptionContext>({
843
+ isSubscribed: true,
844
+ intervalLastFulfilledAt: snapAfter,
845
+ registeredAt: snapBefore,
846
+ targetRefreshIntervalMs: refreshRates.oneHour,
847
+ timeSync: sync,
848
+ unsubscribe: unsub,
849
+ });
679
850
  });
680
851
  });
681
852
 
@@ -685,7 +856,11 @@ describe(TimeSync, () => {
685
856
  }) => {
686
857
  const initialDate = setInitialTime("July 4, 1999");
687
858
  const minimumRefreshIntervalMs = 5_000_000;
688
- const sync = new TimeSync({ initialDate, minimumRefreshIntervalMs });
859
+ const sync = new TimeSync({
860
+ initialDate,
861
+ minimumRefreshIntervalMs,
862
+ allowDuplicateOnUpdateCalls: false,
863
+ });
689
864
 
690
865
  const snap = sync.getStateSnapshot();
691
866
  expect(snap).toEqual<Snapshot>({
@@ -748,9 +923,8 @@ describe(TimeSync, () => {
748
923
  await vi.advanceTimersByTimeAsync(refreshRates.oneHour);
749
924
 
750
925
  expect(onUpdate).toHaveBeenCalledTimes(1);
751
- expect(onUpdate).toHaveBeenCalledWith(expect.any(Date));
752
-
753
926
  const newSnap = sync.getStateSnapshot();
927
+ expect(onUpdate).toHaveBeenCalledWith(newSnap.date, expect.any(Object));
754
928
  expect(newSnap).not.toEqual(initialSnap);
755
929
  });
756
930
 
@@ -818,7 +992,6 @@ describe(TimeSync, () => {
818
992
  });
819
993
 
820
994
  it("Prevents mutating properties at runtime", ({ expect }) => {
821
- type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
822
995
  const sync = new TimeSync();
823
996
 
824
997
  // We have readonly modifiers on the types, but we need to make sure
@@ -913,6 +1086,28 @@ describe(TimeSync, () => {
913
1086
  await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
914
1087
  expect(sharedOnUpdate).not.toHaveBeenCalled();
915
1088
  });
1089
+
1090
+ it("Mutates the isLive context value to be false", async ({ expect }) => {
1091
+ const sync = new TimeSync();
1092
+
1093
+ let ejectedContext: SubscriptionContext | undefined;
1094
+ const onUpdate = vi.fn((_: unknown, ctx: SubscriptionContext) => {
1095
+ if (ejectedContext === undefined) {
1096
+ ejectedContext = ctx;
1097
+ }
1098
+ });
1099
+
1100
+ void sync.subscribe({
1101
+ onUpdate,
1102
+ targetRefreshIntervalMs: refreshRates.oneMinute,
1103
+ });
1104
+
1105
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
1106
+ expect(ejectedContext?.isSubscribed).toBe(true);
1107
+
1108
+ sync.clearAll();
1109
+ expect(ejectedContext?.isSubscribed).toBe(false);
1110
+ });
916
1111
  });
917
1112
 
918
1113
  /**
@@ -1067,5 +1262,31 @@ describe(TimeSync, () => {
1067
1262
  const snap3 = sync.getStateSnapshot().subscriberCount;
1068
1263
  expect(snap3).toBe(0);
1069
1264
  });
1265
+
1266
+ it("Lets consumers detect whether an update corresponds to the subscription they explicitly set up", async ({
1267
+ expect,
1268
+ }) => {
1269
+ const sync = new TimeSync();
1270
+ const innerOnUpdate = vi.fn();
1271
+
1272
+ void sync.subscribe({
1273
+ targetRefreshIntervalMs: refreshRates.oneMinute,
1274
+ onUpdate: (date, ctx) => {
1275
+ const intervalMatches =
1276
+ date.getTime() === ctx.intervalLastFulfilledAt?.getTime();
1277
+ if (intervalMatches) {
1278
+ innerOnUpdate();
1279
+ }
1280
+ },
1281
+ });
1282
+
1283
+ void sync.subscribe({
1284
+ targetRefreshIntervalMs: refreshRates.thirtySeconds,
1285
+ onUpdate: vi.fn(),
1286
+ });
1287
+
1288
+ await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
1289
+ expect(innerOnUpdate).toHaveBeenCalledTimes(1);
1290
+ });
1070
1291
  });
1071
1292
  });
package/src/TimeSync.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ReadonlyDate } from "./ReadonlyDate";
2
+ import type { Writeable } from "./utilities";
2
3
 
3
4
  /**
4
5
  * A collection of commonly-needed intervals (all defined in milliseconds).
@@ -26,14 +27,14 @@ export const refreshRates = Object.freeze({
26
27
  /**
27
28
  * The set of readonly options that the TimeSync has been configured with.
28
29
  */
29
- export type Configuration = Readonly<{
30
+ export interface Configuration {
30
31
  /**
31
32
  * Indicates whether the TimeSync instance should be frozen for Snapshot
32
33
  * tests.
33
34
  *
34
35
  * Defaults to false.
35
36
  */
36
- freezeUpdates: boolean;
37
+ readonly freezeUpdates: boolean;
37
38
 
38
39
  /**
39
40
  * The minimum refresh interval (in milliseconds) to use when dispatching
@@ -48,49 +49,44 @@ export type Configuration = Readonly<{
48
49
  *
49
50
  * Defaults to 200ms.
50
51
  */
51
- minimumRefreshIntervalMs: number;
52
+ readonly minimumRefreshIntervalMs: number;
52
53
 
53
54
  /**
54
55
  * Indicates whether the same `onUpdate` callback (by reference) should be
55
56
  * called multiple time if registered by multiple systems.
56
57
  *
57
- * Defaults to false.
58
+ * Defaults to true. If this value is flipped to false, each onUpdate
59
+ * callback will receive the subscription context for the FIRST subscriber
60
+ * that registered the onUpdate callback.
58
61
  */
59
- allowDuplicateOnUpdateCalls: boolean;
60
- }>;
62
+ readonly allowDuplicateOnUpdateCalls: boolean;
63
+ }
61
64
 
62
65
  /**
63
66
  * The set of options that can be used to instantiate a TimeSync.
64
67
  */
65
- export type InitOptions = Readonly<
66
- Configuration & {
67
- /**
68
- * Indicates whether the TimeSync instance should be frozen for snapshot
69
- * tests. Highly encouraged that you use this together with
70
- * `initialDate`.
71
- *
72
- * Defaults to false.
73
- */
74
- // Duplicated property to override the LSP comment
75
- freezeUpdates: boolean;
76
-
77
- /**
78
- * The Date object to use when initializing TimeSync to make the
79
- * constructor more pure and deterministic.
80
- */
81
- initialDate: Date;
82
- }
83
- >;
68
+ export interface InitOptions extends Configuration {
69
+ /**
70
+ * Indicates whether the TimeSync instance should be frozen for snapshot
71
+ * tests. Highly encouraged that you use this together with
72
+ * `initialDate`.
73
+ *
74
+ * Defaults to false.
75
+ */
76
+ // Duplicated property to override the LSP comment
77
+ readonly freezeUpdates: boolean;
84
78
 
85
- /**
86
- * The callback to call when a new state update is ready to be dispatched.
87
- */
88
- export type OnTimeSyncUpdate = (dateSnapshot: ReadonlyDate) => void;
79
+ /**
80
+ * The Date object to use when initializing TimeSync to make the
81
+ * constructor more pure and deterministic.
82
+ */
83
+ readonly initialDate: Date;
84
+ }
89
85
 
90
86
  /**
91
87
  * An object used to initialize a new subscription for TimeSync.
92
88
  */
93
- export type SubscriptionOptions = Readonly<{
89
+ export interface SubscriptionInitOptions {
94
90
  /**
95
91
  * The maximum update interval that a subscriber needs. A value of
96
92
  * Number.POSITIVE_INFINITY indicates that the subscriber does not strictly
@@ -110,36 +106,93 @@ export type SubscriptionOptions = Readonly<{
110
106
  * after A, updates will pause completely until a new subscriber gets
111
107
  * added, and it has a non-infinite interval.
112
108
  */
113
- targetRefreshIntervalMs: number;
109
+ readonly targetRefreshIntervalMs: number;
114
110
 
115
111
  /**
116
112
  * The callback to call when a new state update needs to be flushed amongst
117
113
  * all subscribers.
118
114
  */
119
- onUpdate: OnTimeSyncUpdate;
120
- }>;
115
+ readonly onUpdate: OnTimeSyncUpdate;
116
+ }
121
117
 
122
118
  /**
123
119
  * A complete snapshot of the user-relevant internal state from TimeSync. This
124
120
  * value is treated as immutable at both runtime and compile time.
125
121
  */
126
- export type Snapshot = Readonly<{
122
+ export interface Snapshot {
127
123
  /**
128
124
  * The date that was last dispatched to all subscribers.
129
125
  */
130
- date: ReadonlyDate;
126
+ readonly date: ReadonlyDate;
131
127
 
132
128
  /**
133
129
  * The number of subscribers registered with TimeSync.
134
130
  */
135
- subscriberCount: number;
131
+ readonly subscriberCount: number;
136
132
 
137
133
  /**
138
134
  * The configuration options used when instantiating the TimeSync instance.
139
135
  * The value is guaranteed to be stable for the entire lifetime of TimeSync.
140
136
  */
141
- config: Configuration;
142
- }>;
137
+ readonly config: Configuration;
138
+ }
139
+
140
+ /**
141
+ * An object with information about a specific subscription registered with
142
+ * TimeSync.
143
+ *
144
+ * For performance reasons, this object has ZERO readonly guarantees enforced at
145
+ * runtime. A few properties are flagged as readonly at the type level, but
146
+ * misuse of this value has a risk of breaking a TimeSync instance's internal
147
+ * state. Proceed with caution.
148
+ */
149
+ export interface SubscriptionContext {
150
+ /**
151
+ * The interval that the subscription was registered with.
152
+ */
153
+ readonly targetRefreshIntervalMs: number;
154
+
155
+ /**
156
+ * The unsubscribe callback associated with a subscription. This is the same
157
+ * callback returned by `TimeSync.subscribe`.
158
+ */
159
+ readonly unsubscribe: () => void;
160
+
161
+ /**
162
+ * A timestamp of when the subscription was first set up.
163
+ */
164
+ readonly registeredAt: ReadonlyDate;
165
+
166
+ /**
167
+ * A reference to the TimeSync instance that the subscription was registered
168
+ * with.
169
+ */
170
+ readonly timeSync: TimeSync;
171
+
172
+ /**
173
+ * Indicates whether the subscription is still live. Will be mutated to be
174
+ * false when a subscription is
175
+ */
176
+ isSubscribed: boolean;
177
+
178
+ /**
179
+ * Indicates when the last time the subscription had its explicit interval
180
+ * "satisfied".
181
+ *
182
+ * For example, if a subscription is registered for every five minutes, but
183
+ * the active interval is set to fire every second, you may need to know
184
+ * which update actually happened five minutes later.
185
+ */
186
+ intervalLastFulfilledAt: ReadonlyDate | null;
187
+ }
188
+
189
+ /**
190
+ * The callback to call when a new state update is ready to be dispatched.
191
+ */
192
+ export type OnTimeSyncUpdate = (
193
+ newDate: ReadonlyDate,
194
+ context: SubscriptionContext,
195
+ ) => void;
143
196
 
144
197
  interface TimeSyncApi {
145
198
  /**
@@ -155,7 +208,7 @@ interface TimeSyncApi {
155
208
  * @returns An unsubscribe callback. Calling the callback more than once
156
209
  * results in a no-op.
157
210
  */
158
- subscribe: (options: SubscriptionOptions) => () => void;
211
+ subscribe: (options: SubscriptionInitOptions) => () => void;
159
212
 
160
213
  /**
161
214
  * Allows an external system to pull an immutable snapshot of some of the
@@ -179,11 +232,6 @@ interface TimeSyncApi {
179
232
  clearAll: () => void;
180
233
  }
181
234
 
182
- type SubscriptionEntry = Readonly<{
183
- targetInterval: number;
184
- unsubscribe: () => void;
185
- }>;
186
-
187
235
  /* biome-ignore lint:suspicious/noEmptyBlockStatements -- Rare case where we do
188
236
  actually want a completely empty function body. */
189
237
  function noOp(..._: readonly unknown[]): void {}
@@ -238,8 +286,27 @@ export class TimeSync implements TimeSyncApi {
238
286
  *
239
287
  * Each map value should stay sorted by refresh interval, in ascending
240
288
  * order.
289
+ *
290
+ * ---
291
+ *
292
+ * This is a rare case where we actually REALLY need the readonly modifier
293
+ * to avoid infinite loops. JavaScript's iterator protocol is really great
294
+ * for making loops simple and type-safe, but because subscriptions have the
295
+ * ability to add more subscriptions, we need to make an immutable version
296
+ * of each array at some point to make sure that we're not iterating through
297
+ * values forever
298
+ *
299
+ * We can choose to do that at one of two points:
300
+ * 1. When adding a new subscription
301
+ * 2. When dispatching a new round of updates
302
+ *
303
+ * Because this library assumes that dispatches will be much more common
304
+ * than new subscriptions (a single subscription that subscribes for one
305
+ * second will receive 360 updates in five minutes), operations should be
306
+ * done to optimize that use case. So we should move the immutability costs
307
+ * to the subscribe and unsubscribe operations.
241
308
  */
242
- #subscriptions: Map<OnTimeSyncUpdate, SubscriptionEntry[]>;
309
+ #subscriptions: Map<OnTimeSyncUpdate, readonly SubscriptionContext[]>;
243
310
 
244
311
  /**
245
312
  * The latest public snapshot of TimeSync's internal state. The snapshot
@@ -271,7 +338,7 @@ export class TimeSync implements TimeSyncApi {
271
338
  const {
272
339
  initialDate,
273
340
  freezeUpdates = false,
274
- allowDuplicateOnUpdateCalls = false,
341
+ allowDuplicateOnUpdateCalls = true,
275
342
  minimumRefreshIntervalMs = defaultMinimumRefreshIntervalMs,
276
343
  } = options ?? {};
277
344
 
@@ -343,50 +410,57 @@ export class TimeSync implements TimeSyncApi {
343
410
  return;
344
411
  }
345
412
 
413
+ const dateTime = date.getTime();
414
+
346
415
  /**
347
- * Two things for both paths:
348
- * 1. We need to make sure that we do one-time serializations of the map
349
- * entries into an array instead of constantly pulling from the map via
350
- * the iterator protocol in the off chance that subscriptions add new
351
- * subscriptions. We need to make infinite loops impossible. If new
352
- * subscriptions get added, they'll just have to wait until the next
353
- * update round.
416
+ * Two things:
417
+ * 1. Even though the context arrays are defined as readonly (which
418
+ * removes on the worst edge cases during dispatching), the
419
+ * subscriptions map itself is still mutable, so there are a few edge
420
+ * cases we need to deal with. While the risk of infinite loops should
421
+ * be much lower, there's still the risk that an onUpdate callback could
422
+ * add a subscriber for an interval that wasn't registered before, which
423
+ * the iterator protocol will pick up. Need to make a local,
424
+ * fixed-length copy of the map entries before starting iteration. Any
425
+ * subscriptions added during update will just have to wait until the
426
+ * next round of updates.
354
427
  *
355
428
  * 2. The trade off of the serialization is that we do lose the ability
356
- * to auto-break the loops if one of the subscribers ends up resetting
429
+ * to auto-break the loop if one of the subscribers ends up resetting
357
430
  * all state, because we'll still have local copies of entries. We need
358
431
  * to check on each iteration to see if we should continue.
359
432
  */
360
- if (config.allowDuplicateOnUpdateCalls) {
361
- // Not super happy about this, but because each subscription array
362
- // is mutable, we have to make an immutable copy of the count of
363
- // each sub before starting any dispatches. If we wait until the
364
- // inner loop to store the length of the subs before iterating over
365
- // them, that's too late. It's possible that a subscription could
366
- // cause data to be pushed to an array for a different interval
367
- const entries = Array.from(
368
- this.#subscriptions,
369
- ([onUpdate, subs]) => [onUpdate, subs.length] as const,
370
- );
371
- outer: for (const [onUpdate, subCount] of entries) {
372
- for (let i = 0; i < subCount; i++) {
373
- const wasCleared = this.#subscriptions.size === 0;
374
- if (wasCleared) {
375
- break outer;
376
- }
377
- onUpdate(date);
433
+ const subsBeforeUpdate = this.#subscriptions;
434
+ const entries = Array.from(subsBeforeUpdate);
435
+ outer: for (const [onUpdate, subs] of entries) {
436
+ // Even if duplicate onUpdate calls are disabled, we still need to
437
+ // iterate through everything and update any internal data. If the
438
+ // first context in a sub array gets removed by unsubscribing, we
439
+ // want what was the the second element to still be up to date
440
+ let shouldCallOnUpdate = true;
441
+ for (const context of subs) {
442
+ // We're not doing anything more sophisticated here because
443
+ // we're assuming that any systems that can clear out the
444
+ // subscriptions will handle cleaning up each context, too
445
+ const wasCleared = subsBeforeUpdate.size === 0;
446
+ if (wasCleared) {
447
+ break outer;
448
+ }
449
+
450
+ const comparisonDate =
451
+ context.intervalLastFulfilledAt ?? context.registeredAt;
452
+ const isIntervalMatch =
453
+ dateTime - comparisonDate.getTime() >=
454
+ context.targetRefreshIntervalMs;
455
+ if (isIntervalMatch) {
456
+ context.intervalLastFulfilledAt = date;
378
457
  }
379
- }
380
- return;
381
- }
382
458
 
383
- const funcs = [...this.#subscriptions.keys()];
384
- for (const onUpdate of funcs) {
385
- const wasCleared = this.#subscriptions.size === 0;
386
- if (wasCleared) {
387
- break;
459
+ if (shouldCallOnUpdate) {
460
+ onUpdate(date, context);
461
+ shouldCallOnUpdate = config.allowDuplicateOnUpdateCalls;
462
+ }
388
463
  }
389
- onUpdate(date);
390
464
  }
391
465
  }
392
466
 
@@ -475,7 +549,8 @@ export class TimeSync implements TimeSyncApi {
475
549
  // This setup requires that every interval array stay sorted. It
476
550
  // immediately falls apart if this isn't guaranteed.
477
551
  for (const entries of this.#subscriptions.values()) {
478
- const subFastest = entries[0]?.targetInterval ?? Number.POSITIVE_INFINITY;
552
+ const subFastest =
553
+ entries[0]?.targetRefreshIntervalMs ?? Number.POSITIVE_INFINITY;
479
554
  if (subFastest < newFastest) {
480
555
  newFastest = subFastest;
481
556
  }
@@ -487,7 +562,7 @@ export class TimeSync implements TimeSyncApi {
487
562
  }
488
563
  }
489
564
 
490
- subscribe(sh: SubscriptionOptions): () => void {
565
+ subscribe(options: SubscriptionInitOptions): () => void {
491
566
  const { config } = this.#latestSnapshot;
492
567
  if (config.freezeUpdates) {
493
568
  return noOp;
@@ -495,7 +570,7 @@ export class TimeSync implements TimeSyncApi {
495
570
 
496
571
  // Destructuring properties so that they can't be fiddled with after
497
572
  // this function call ends
498
- const { targetRefreshIntervalMs, onUpdate } = sh;
573
+ const { targetRefreshIntervalMs, onUpdate } = options;
499
574
 
500
575
  const isTargetValid =
501
576
  targetRefreshIntervalMs === Number.POSITIVE_INFINITY ||
@@ -507,50 +582,73 @@ export class TimeSync implements TimeSyncApi {
507
582
  );
508
583
  }
509
584
 
510
- const subsOnSetup = this.#subscriptions;
585
+ // Have to define this as a writeable to avoid a chicken-and-the-egg
586
+ // problem for the unsubscribe callback
587
+ const context: Writeable<SubscriptionContext> = {
588
+ isSubscribed: true,
589
+ timeSync: this,
590
+ unsubscribe: noOp,
591
+ registeredAt: new ReadonlyDate(),
592
+ intervalLastFulfilledAt: null,
593
+ targetRefreshIntervalMs: Math.max(
594
+ config.minimumRefreshIntervalMs,
595
+ targetRefreshIntervalMs,
596
+ ),
597
+ };
598
+
599
+ // Not reading from context value to decide whether to bail out of
600
+ // unsubscribes in off chance that outside consumer accidentally mutates
601
+ // the value
511
602
  let subscribed = true;
603
+ const subsOnSetup = this.#subscriptions;
512
604
  const unsubscribe = (): void => {
513
605
  if (!subscribed || this.#subscriptions !== subsOnSetup) {
606
+ context.isSubscribed = false;
514
607
  subscribed = false;
515
608
  return;
516
609
  }
517
610
 
518
- const entries = subsOnSetup.get(onUpdate);
519
- if (entries === undefined) {
611
+ const contexts = subsOnSetup.get(onUpdate);
612
+ if (contexts === undefined) {
520
613
  return;
521
614
  }
522
- const matchIndex = entries.findIndex(
523
- (e) => e.unsubscribe === unsubscribe,
524
- );
525
- if (matchIndex === -1) {
615
+ const filtered = contexts.filter((e) => e.unsubscribe !== unsubscribe);
616
+ if (filtered.length === contexts.length) {
526
617
  return;
527
618
  }
528
- // No need to sort on removal because everything gets sorted as it
529
- // enters the subscriptions map
530
- entries.splice(matchIndex, 1);
531
- if (entries.length === 0) {
619
+
620
+ if (filtered.length === 0) {
532
621
  subsOnSetup.delete(onUpdate);
533
622
  this.#updateFastestInterval();
623
+ } else {
624
+ // No need to sort on removal because everything gets sorted as
625
+ // it enters the subscriptions map
626
+ subsOnSetup.set(onUpdate, filtered);
534
627
  }
535
628
 
536
629
  void this.#setSnapshot({
537
630
  subscriberCount: Math.max(0, this.#latestSnapshot.subscriberCount - 1),
538
631
  });
632
+
633
+ context.isSubscribed = false;
539
634
  subscribed = false;
540
635
  };
541
-
542
- let entries = subsOnSetup.get(onUpdate);
543
- if (entries === undefined) {
544
- entries = [];
545
- subsOnSetup.set(onUpdate, entries);
636
+ context.unsubscribe = unsubscribe;
637
+
638
+ let contexts: SubscriptionContext[];
639
+ if (this.#subscriptions.has(onUpdate)) {
640
+ const prev = this.#subscriptions.get(onUpdate) as SubscriptionContext[];
641
+ contexts = [...prev];
642
+ } else {
643
+ contexts = [];
644
+ subsOnSetup.set(onUpdate, contexts);
546
645
  }
547
646
 
548
- const targetInterval = Math.max(
549
- config.minimumRefreshIntervalMs,
550
- targetRefreshIntervalMs,
647
+ subsOnSetup.set(onUpdate, contexts);
648
+ contexts.push(context);
649
+ contexts.sort(
650
+ (e1, e2) => e1.targetRefreshIntervalMs - e2.targetRefreshIntervalMs,
551
651
  );
552
- entries.push({ unsubscribe, targetInterval });
553
- entries.sort((e1, e2) => e1.targetInterval - e2.targetInterval);
554
652
 
555
653
  void this.#setSnapshot({
556
654
  subscriberCount: this.#latestSnapshot.subscriberCount + 1,
@@ -567,17 +665,22 @@ export class TimeSync implements TimeSyncApi {
567
665
  clearAll(): void {
568
666
  clearInterval(this.#intervalId);
569
667
  this.#intervalId = undefined;
570
- this.#fastestRefreshInterval = 0;
571
-
572
- // If we know for a fact that we're going to toss everything, we don't
573
- // need to bother iterating through the unsubscribe callbacks. We can
574
- // just swap in a new map, and then completely erase the old map (likely
575
- // leaning into more efficient code than we could write). As long as the
576
- // unsubscribe callbacks are set up to check a local version of the
577
- // subscriptions, this won't ever cause problems.
578
- const subsBefore = this.#subscriptions;
668
+ this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
669
+
670
+ // As long as we clean things the internal state, it's safe not to
671
+ // bother calling each unsubscribe callback. Not calling them one by
672
+ // one actually has much better time complexity
673
+ for (const subArray of this.#subscriptions.values()) {
674
+ for (const ctx of subArray) {
675
+ ctx.isSubscribed = false;
676
+ }
677
+ }
678
+
679
+ this.#subscriptions.clear();
680
+
681
+ // We swap the map out so that the unsubscribe callbacks can detect
682
+ // whether their functionality is still relevant
579
683
  this.#subscriptions = new Map();
580
- subsBefore.clear();
581
684
  void this.#setSnapshot({ subscriberCount: 0 });
582
685
  }
583
686
  }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export {
6
6
  type OnTimeSyncUpdate,
7
7
  refreshRates,
8
8
  type Snapshot,
9
- type SubscriptionOptions,
9
+ type SubscriptionContext,
10
+ type SubscriptionInitOptions,
10
11
  TimeSync,
11
12
  } from "./TimeSync";
@@ -0,0 +1 @@
1
+ export type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };