@buenos-nachos/time-sync 0.3.2 → 0.4.1

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,17 @@
1
1
  # @buenos-nachos/time-sync
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5f37f1a: refactored class to remove private setSnapshost method
8
+
9
+ ## 0.4.0
10
+
11
+ ### Breaking Changes
12
+
13
+ - 663479e: Removed `isSubscribed` property from context and made all other context properties readonly.
14
+
3
15
  ## 0.3.2
4
16
 
5
17
  ### Patch Changes
@@ -14,7 +26,7 @@
14
26
 
15
27
  ## 0.3.0
16
28
 
17
- ### Minor Changes
29
+ ### Breaking Changes
18
30
 
19
31
  - 122f6c1: Updated `SubscriptionContext.timeSync` type to be readonly and non-nullable, and renamed `SubscriptionContext.isLive` to `SubscriptionContext.isSubscribed`.
20
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buenos-nachos/time-sync",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": "Michael Smith <hello@nachos.dev> (https://www.nachos.dev)",
@@ -131,7 +131,6 @@ describe(TimeSync, () => {
131
131
 
132
132
  const expectedCtx: SubscriptionContext = {
133
133
  unsubscribe,
134
- isSubscribed: true,
135
134
  timeSync: sync,
136
135
  intervalLastFulfilledAt: dateAfter,
137
136
  registeredAt: dateBefore,
@@ -563,28 +562,6 @@ describe(TimeSync, () => {
563
562
  const dateAfter = sync.getStateSnapshot().date;
564
563
  expect(dateAfter).not.toEqual(dateBefore);
565
564
  });
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
565
  });
589
566
 
590
567
  describe("Subscriptions: context values", () => {
@@ -827,7 +804,6 @@ describe(TimeSync, () => {
827
804
 
828
805
  await vi.advanceTimersByTimeAsync(refreshRates.oneSecond);
829
806
  expect(ejectedContext).toEqual<SubscriptionContext>({
830
- isSubscribed: true,
831
807
  intervalLastFulfilledAt: null,
832
808
  registeredAt: snapBefore,
833
809
  targetRefreshIntervalMs: refreshRates.oneHour,
@@ -840,7 +816,6 @@ describe(TimeSync, () => {
840
816
 
841
817
  const snapAfter = sync.getStateSnapshot().date;
842
818
  expect(ejectedContext).toEqual<SubscriptionContext>({
843
- isSubscribed: true,
844
819
  intervalLastFulfilledAt: snapAfter,
845
820
  registeredAt: snapBefore,
846
821
  targetRefreshIntervalMs: refreshRates.oneHour,
@@ -1086,28 +1061,6 @@ describe(TimeSync, () => {
1086
1061
  await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
1087
1062
  expect(sharedOnUpdate).not.toHaveBeenCalled();
1088
1063
  });
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
- });
1111
1064
  });
1112
1065
 
1113
1066
  /**
package/src/TimeSync.ts CHANGED
@@ -135,9 +135,8 @@ export interface Snapshot {
135
135
  * TimeSync.
136
136
  *
137
137
  * For performance reasons, this object has ZERO readonly guarantees enforced at
138
- * runtime. A few properties are flagged as readonly at the type level, but
139
- * misuse of this value has a risk of breaking a TimeSync instance's internal
140
- * state. Proceed with caution.
138
+ * runtime. All properties are defined as readonly at the type level, but an
139
+ * accidental mutation can still slip through.
141
140
  */
142
141
  export interface SubscriptionContext {
143
142
  /**
@@ -162,12 +161,6 @@ export interface SubscriptionContext {
162
161
  */
163
162
  readonly timeSync: TimeSync;
164
163
 
165
- /**
166
- * Indicates whether the subscription is still live. Will be mutated to be
167
- * false when a subscription is
168
- */
169
- isSubscribed: boolean;
170
-
171
164
  /**
172
165
  * Indicates when the last time the subscription had its explicit interval
173
166
  * "satisfied".
@@ -176,7 +169,7 @@ export interface SubscriptionContext {
176
169
  * the active interval is set to fire every second, you may need to know
177
170
  * which update actually happened five minutes later.
178
171
  */
179
- intervalLastFulfilledAt: ReadonlyDate | null;
172
+ readonly intervalLastFulfilledAt: ReadonlyDate | null;
180
173
  }
181
174
 
182
175
  /**
@@ -237,6 +230,23 @@ interface TimeSyncApi {
237
230
  actually want a completely empty function body. */
238
231
  function noOp(..._: readonly unknown[]): void {}
239
232
 
233
+ /**
234
+ * This function is just a convenience for us to sidestep some problems around
235
+ * TypeScript's LSP and Object.freeze. Because Object.freeze can accept any
236
+ * arbitrary type, it basically acts as a "type boundary" between the left and
237
+ * right sides of any snapshot assignments.
238
+ *
239
+ * That means that if you rename a property a a value that is passed to
240
+ * Object.freeze, the LSP can't auto-rename it, and you potentially get missing
241
+ * properties. This is a bit hokey, but because the function is defined strictly
242
+ * in terms of concrete snapshots, any value passed to this function won't have
243
+ * to worry about mismatches.
244
+ */
245
+ function freezeSnapshot(snap: Snapshot): Snapshot {
246
+ Object.freeze(snap.config);
247
+ return Object.freeze(snap);
248
+ }
249
+
240
250
  const defaultMinimumRefreshIntervalMs = 200;
241
251
 
242
252
  /**
@@ -370,43 +380,17 @@ export class TimeSync implements TimeSyncApi {
370
380
  this.#latestSnapshot = Object.freeze(initialSnapshot);
371
381
  }
372
382
 
373
- #setSnapshot(update: Partial<Snapshot>): boolean {
374
- const { date, subscriberCount, config } = this.#latestSnapshot;
375
- if (config.freezeUpdates) {
376
- return false;
377
- }
378
-
379
- // Avoiding both direct property assignment or spread syntax because
380
- // Object.freeze causes weird TypeScript LSP issues around assignability
381
- // where trying to rename a property. If you rename a property on a
382
- // type, it WON'T rename the runtime properties. Object.freeze
383
- // introduces an extra type boundary that break the linking
384
- const updated: Snapshot = {
385
- // Always reject any new configs because trying to remove them at
386
- // the type level isn't worth it for an internal implementation
387
- // detail
388
- config,
389
- date: update.date ?? date,
390
- subscriberCount: update.subscriberCount ?? subscriberCount,
391
- };
392
-
393
- this.#latestSnapshot = Object.freeze(updated);
394
- return true;
395
- }
396
-
397
- #notifyAllSubscriptions(): void {
383
+ #processSubscriptionUpdate(): void {
398
384
  // It's more important that we copy the date object into a separate
399
385
  // variable here than normal, because need make sure the `this` context
400
386
  // can't magically change between updates and cause subscribers to
401
- // receive different values (e.g., one of the subscribers calls the
402
- // invalidate method)
387
+ // receive different values
403
388
  const { date, config } = this.#latestSnapshot;
404
389
 
405
- // We still need to let the logic go through if the current fastest
406
- // interval is Infinity, so that we can support letting any arbitrary
407
- // consumer invalidate the date immediately
408
390
  const subscriptionsPaused =
409
- config.freezeUpdates || this.#subscriptions.size === 0;
391
+ config.freezeUpdates ||
392
+ this.#subscriptions.size === 0 ||
393
+ this.#fastestRefreshInterval === Number.POSITIVE_INFINITY;
410
394
  if (subscriptionsPaused) {
411
395
  return;
412
396
  }
@@ -439,7 +423,7 @@ export class TimeSync implements TimeSyncApi {
439
423
  // first context in a sub array gets removed by unsubscribing, we
440
424
  // want what was the the second element to still be up to date
441
425
  let shouldCallOnUpdate = true;
442
- for (const context of subs) {
426
+ for (const ctx of subs as readonly Writeable<SubscriptionContext>[]) {
443
427
  // We're not doing anything more sophisticated here because
444
428
  // we're assuming that any systems that can clear out the
445
429
  // subscriptions will handle cleaning up each context, too
@@ -448,17 +432,15 @@ export class TimeSync implements TimeSyncApi {
448
432
  break outer;
449
433
  }
450
434
 
451
- const comparisonDate =
452
- context.intervalLastFulfilledAt ?? context.registeredAt;
435
+ const comparisonDate = ctx.intervalLastFulfilledAt ?? ctx.registeredAt;
453
436
  const isIntervalMatch =
454
- dateTime - comparisonDate.getTime() >=
455
- context.targetRefreshIntervalMs;
437
+ dateTime - comparisonDate.getTime() >= ctx.targetRefreshIntervalMs;
456
438
  if (isIntervalMatch) {
457
- context.intervalLastFulfilledAt = date;
439
+ ctx.intervalLastFulfilledAt = date;
458
440
  }
459
441
 
460
442
  if (shouldCallOnUpdate) {
461
- onUpdate(date, context);
443
+ onUpdate(date, ctx);
462
444
  shouldCallOnUpdate = config.allowDuplicateOnUpdateCalls;
463
445
  }
464
446
  }
@@ -474,18 +456,24 @@ export class TimeSync implements TimeSyncApi {
474
456
  * is one of them.
475
457
  */
476
458
  readonly #onTick = (): void => {
477
- // Defensive step to make sure that an invalid tick wasn't started
478
- const { config } = this.#latestSnapshot;
459
+ const { config, date } = this.#latestSnapshot;
479
460
  if (config.freezeUpdates) {
461
+ // Defensive step to make sure that an invalid tick wasn't started
480
462
  clearInterval(this.#intervalId);
481
463
  this.#intervalId = undefined;
482
464
  return;
483
465
  }
484
466
 
485
- const wasChanged = this.#setSnapshot({ date: new ReadonlyDate() });
486
- if (wasChanged) {
487
- this.#notifyAllSubscriptions();
467
+ const newDate = new ReadonlyDate();
468
+ if (newDate.getTime() === date.getTime()) {
469
+ return;
488
470
  }
471
+
472
+ this.#latestSnapshot = freezeSnapshot({
473
+ ...this.#latestSnapshot,
474
+ date: newDate,
475
+ });
476
+ this.#processSubscriptionUpdate();
489
477
  };
490
478
 
491
479
  #onFastestIntervalChange(): void {
@@ -506,10 +494,15 @@ export class TimeSync implements TimeSyncApi {
506
494
  clearInterval(this.#intervalId);
507
495
 
508
496
  if (timeBeforeNextUpdate <= 0) {
509
- const wasChanged = this.#setSnapshot({ date: new ReadonlyDate() });
510
- if (wasChanged) {
511
- this.#notifyAllSubscriptions();
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();
512
504
  }
505
+
513
506
  this.#intervalId = setInterval(this.#onTick, fastest);
514
507
  return;
515
508
  }
@@ -586,7 +579,6 @@ export class TimeSync implements TimeSyncApi {
586
579
  // Have to define this as a writeable to avoid a chicken-and-the-egg
587
580
  // problem for the unsubscribe callback
588
581
  const context: Writeable<SubscriptionContext> = {
589
- isSubscribed: true,
590
582
  timeSync: this,
591
583
  unsubscribe: noOp,
592
584
  registeredAt: new ReadonlyDate(),
@@ -604,7 +596,6 @@ export class TimeSync implements TimeSyncApi {
604
596
  const subsOnSetup = this.#subscriptions;
605
597
  const unsubscribe = (): void => {
606
598
  if (!subscribed || this.#subscriptions !== subsOnSetup) {
607
- context.isSubscribed = false;
608
599
  subscribed = false;
609
600
  return;
610
601
  }
@@ -627,11 +618,11 @@ export class TimeSync implements TimeSyncApi {
627
618
  subsOnSetup.set(onUpdate, filtered);
628
619
  }
629
620
 
630
- void this.#setSnapshot({
621
+ this.#latestSnapshot = freezeSnapshot({
622
+ ...this.#latestSnapshot,
631
623
  subscriberCount: Math.max(0, this.#latestSnapshot.subscriberCount - 1),
632
624
  });
633
625
 
634
- context.isSubscribed = false;
635
626
  subscribed = false;
636
627
  };
637
628
  context.unsubscribe = unsubscribe;
@@ -651,7 +642,8 @@ export class TimeSync implements TimeSyncApi {
651
642
  (e1, e2) => e1.targetRefreshIntervalMs - e2.targetRefreshIntervalMs,
652
643
  );
653
644
 
654
- void this.#setSnapshot({
645
+ this.#latestSnapshot = freezeSnapshot({
646
+ ...this.#latestSnapshot,
655
647
  subscriberCount: this.#latestSnapshot.subscriberCount + 1,
656
648
  });
657
649
 
@@ -671,17 +663,14 @@ export class TimeSync implements TimeSyncApi {
671
663
  // As long as we clean things the internal state, it's safe not to
672
664
  // bother calling each unsubscribe callback. Not calling them one by
673
665
  // one actually has much better time complexity
674
- for (const subArray of this.#subscriptions.values()) {
675
- for (const ctx of subArray) {
676
- ctx.isSubscribed = false;
677
- }
678
- }
679
-
680
666
  this.#subscriptions.clear();
681
667
 
682
668
  // We swap the map out so that the unsubscribe callbacks can detect
683
669
  // whether their functionality is still relevant
684
670
  this.#subscriptions = new Map();
685
- void this.#setSnapshot({ subscriberCount: 0 });
671
+ this.#latestSnapshot = freezeSnapshot({
672
+ ...this.#latestSnapshot,
673
+ subscriberCount: 0,
674
+ });
686
675
  }
687
676
  }