@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 +13 -1
- package/package.json +1 -1
- package/src/TimeSync.test.ts +0 -47
- package/src/TimeSync.ts +57 -68
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
|
-
###
|
|
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
package/src/TimeSync.test.ts
CHANGED
|
@@ -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.
|
|
139
|
-
*
|
|
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
|
-
#
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
439
|
+
ctx.intervalLastFulfilledAt = date;
|
|
458
440
|
}
|
|
459
441
|
|
|
460
442
|
if (shouldCallOnUpdate) {
|
|
461
|
-
onUpdate(date,
|
|
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
|
-
|
|
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
|
|
486
|
-
if (
|
|
487
|
-
|
|
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
|
|
510
|
-
if (
|
|
511
|
-
this.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
671
|
+
this.#latestSnapshot = freezeSnapshot({
|
|
672
|
+
...this.#latestSnapshot,
|
|
673
|
+
subscriberCount: 0,
|
|
674
|
+
});
|
|
686
675
|
}
|
|
687
676
|
}
|