@buenos-nachos/time-sync 0.1.1 → 0.2.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/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
+ timeSync: TimeSync | null;
171
+
172
+ /**
173
+ * Indicates whether the subscription is still live. Will be mutated to be
174
+ * false when a subscription is
175
+ */
176
+ isLive: 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
+ isLive: 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.isLive = 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.isLive = 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.isLive = 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] };