@buenos-nachos/time-sync 0.4.1 → 0.5.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 +14 -0
- package/package.json +1 -1
- package/src/TimeSync.test.ts +58 -71
- package/src/TimeSync.ts +171 -156
- package/src/utilities.ts +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @buenos-nachos/time-sync
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5fdc201: Updated wording on `Snapshot.date` to be less misleading.
|
|
8
|
+
|
|
9
|
+
## 0.5.0
|
|
10
|
+
|
|
11
|
+
### Breaking Changes
|
|
12
|
+
|
|
13
|
+
- c3986e9: revamped all state management and APIs to be based on monotonic time
|
|
14
|
+
- c3986e9: Removed `registeredAt` and `intervalLastFulfilledAt` properties from `SubscriptionContext` and added monotonic `registeredAtMs`
|
|
15
|
+
- c3986e9: Added monotonic `lastUpdatedAt` property to `Snapshot` type.
|
|
16
|
+
|
|
3
17
|
## 0.4.1
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/package.json
CHANGED
package/src/TimeSync.test.ts
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
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.
|
|
592
|
+
ejectedInterval = ctx.refreshIntervalMs;
|
|
596
593
|
});
|
|
597
594
|
|
|
598
|
-
|
|
595
|
+
sync.subscribe({
|
|
599
596
|
onUpdate,
|
|
600
|
-
targetRefreshIntervalMs:
|
|
597
|
+
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
601
598
|
});
|
|
602
599
|
|
|
603
|
-
await vi.advanceTimersByTimeAsync(
|
|
604
|
-
expect(ejectedInterval).toBe(
|
|
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
|
|
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(
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
targetRefreshIntervalMs: refreshRates.thirtySeconds,
|
|
642
|
+
onUpdate: (_, ctx) => {
|
|
643
|
+
ejectedSetupTime2 = ctx.registeredAtMs;
|
|
644
|
+
},
|
|
661
645
|
});
|
|
662
646
|
|
|
663
647
|
await vi.advanceTimersByTimeAsync(refreshRates.oneMinute);
|
|
664
|
-
|
|
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
|
-
|
|
779
|
+
sync.subscribe({
|
|
797
780
|
onUpdate,
|
|
798
781
|
targetRefreshIntervalMs: refreshRates.oneMinute,
|
|
799
782
|
});
|
|
800
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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("
|
|
1207
|
+
it("Does not break update cadence if system time jumps around", async ({
|
|
1220
1208
|
expect,
|
|
1221
1209
|
}) => {
|
|
1222
|
-
const
|
|
1223
|
-
const
|
|
1210
|
+
const initialDate = setInitialTime("2022-03-15T08:00:00Z");
|
|
1211
|
+
const sync = new TimeSync({ initialDate });
|
|
1212
|
+
const thirtyMinutes = 30 * refreshRates.oneMinute;
|
|
1224
1213
|
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
onUpdate: vi.fn(),
|
|
1239
|
-
});
|
|
1217
|
+
await vi.advanceTimersByTimeAsync(thirtyMinutes);
|
|
1218
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
1240
1219
|
|
|
1241
|
-
|
|
1242
|
-
|
|
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).
|
|
@@ -114,10 +113,20 @@ export interface SubscriptionInitOptions {
|
|
|
114
113
|
*/
|
|
115
114
|
export interface Snapshot {
|
|
116
115
|
/**
|
|
117
|
-
* The date that
|
|
116
|
+
* The date that TimeSync last processed. This will always match the date that
|
|
117
|
+
* was last dispatched to all subscribers, but if no updates have been issued,
|
|
118
|
+
* this value will match the date used to instantiate the TimeSync.
|
|
118
119
|
*/
|
|
119
120
|
readonly date: ReadonlyDate;
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The monotonic milliseconds that elapsed between the TimeSync being
|
|
124
|
+
* instantiated and the last update being dispatched.
|
|
125
|
+
*
|
|
126
|
+
* Will be null if no updates have ever been dispatched.
|
|
127
|
+
*/
|
|
128
|
+
readonly lastUpdatedAtMs: number | null;
|
|
129
|
+
|
|
121
130
|
/**
|
|
122
131
|
* The number of subscribers registered with TimeSync.
|
|
123
132
|
*/
|
|
@@ -132,44 +141,33 @@ export interface Snapshot {
|
|
|
132
141
|
|
|
133
142
|
/**
|
|
134
143
|
* 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.
|
|
144
|
+
* TimeSync. The entire context is frozen at runtime.
|
|
140
145
|
*/
|
|
141
146
|
export interface SubscriptionContext {
|
|
142
147
|
/**
|
|
143
|
-
*
|
|
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`.
|
|
148
|
+
* A reference to the TimeSync instance that the subscription was registered
|
|
149
|
+
* with.
|
|
150
150
|
*/
|
|
151
|
-
readonly
|
|
151
|
+
readonly timeSync: TimeSync;
|
|
152
152
|
|
|
153
153
|
/**
|
|
154
|
-
*
|
|
154
|
+
* The effective interval that the subscription is updating at. This may be a
|
|
155
|
+
* value larger than than the target refresh interval, depending on whether
|
|
156
|
+
* TimeSync was configured with a minimum refresh value.
|
|
155
157
|
*/
|
|
156
|
-
readonly
|
|
158
|
+
readonly refreshIntervalMs: number;
|
|
157
159
|
|
|
158
160
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
+
* The unsubscribe callback associated with a subscription. This is the same
|
|
162
|
+
* callback returned by `TimeSync.subscribe`.
|
|
161
163
|
*/
|
|
162
|
-
readonly
|
|
164
|
+
readonly unsubscribe: () => void;
|
|
163
165
|
|
|
164
166
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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.
|
|
167
|
+
* The monotonic milliseconds that elapsed between the TimeSync being
|
|
168
|
+
* instantiated and the subscription being registered.
|
|
171
169
|
*/
|
|
172
|
-
readonly
|
|
170
|
+
readonly registeredAtMs: number;
|
|
173
171
|
}
|
|
174
172
|
|
|
175
173
|
/**
|
|
@@ -226,9 +224,25 @@ interface TimeSyncApi {
|
|
|
226
224
|
clearAll: () => void;
|
|
227
225
|
}
|
|
228
226
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Even though both the browser and the server are able to give monotonic times
|
|
229
|
+
* that are at least as precise as a nanosecond, we're using milliseconds for
|
|
230
|
+
* consistency with useInterval, which cannot be more precise than a
|
|
231
|
+
* millisecond.
|
|
232
|
+
*/
|
|
233
|
+
function getMonotonicTimeMs(): number {
|
|
234
|
+
// If we're on the server, we can use process.hrtime, which is defined for
|
|
235
|
+
// Node, Deno, and Bun
|
|
236
|
+
if (typeof window === "undefined") {
|
|
237
|
+
const timeInNanoseconds = process.hrtime.bigint();
|
|
238
|
+
return Number(timeInNanoseconds / 1000n);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Otherwise, we need to get the high-resolution timestamp from the browser.
|
|
242
|
+
// This value is fractional and goes to nine decimal places
|
|
243
|
+
const highResTimestamp = window.performance.now();
|
|
244
|
+
return Math.floor(highResTimestamp);
|
|
245
|
+
}
|
|
232
246
|
|
|
233
247
|
/**
|
|
234
248
|
* This function is just a convenience for us to sidestep some problems around
|
|
@@ -243,8 +257,13 @@ function noOp(..._: readonly unknown[]): void {}
|
|
|
243
257
|
* to worry about mismatches.
|
|
244
258
|
*/
|
|
245
259
|
function freezeSnapshot(snap: Snapshot): Snapshot {
|
|
246
|
-
Object.
|
|
247
|
-
|
|
260
|
+
if (!Object.isFrozen(snap.config)) {
|
|
261
|
+
Object.freeze(snap.config);
|
|
262
|
+
}
|
|
263
|
+
if (!Object.isFrozen(snap)) {
|
|
264
|
+
Object.freeze(snap);
|
|
265
|
+
}
|
|
266
|
+
return snap;
|
|
248
267
|
}
|
|
249
268
|
|
|
250
269
|
const defaultMinimumRefreshIntervalMs = 200;
|
|
@@ -285,6 +304,12 @@ const defaultMinimumRefreshIntervalMs = 200;
|
|
|
285
304
|
* some parts of the screen.)
|
|
286
305
|
*/
|
|
287
306
|
export class TimeSync implements TimeSyncApi {
|
|
307
|
+
/**
|
|
308
|
+
* The monotonic time in milliseconds from when the TimeSync instance was
|
|
309
|
+
* first instantiated.
|
|
310
|
+
*/
|
|
311
|
+
readonly #initializedAtMs: number;
|
|
312
|
+
|
|
288
313
|
/**
|
|
289
314
|
* Stores all refresh intervals actively associated with an onUpdate
|
|
290
315
|
* callback (along with their associated unsubscribe callbacks).
|
|
@@ -333,7 +358,8 @@ export class TimeSync implements TimeSyncApi {
|
|
|
333
358
|
|
|
334
359
|
/**
|
|
335
360
|
* Used for both its intended purpose (creating interval), but also as a
|
|
336
|
-
* janky version of setTimeout.
|
|
361
|
+
* janky version of setTimeout. Also, all versions of setInterval are
|
|
362
|
+
* monotonic, so we don't have to do anything special for it.
|
|
337
363
|
*
|
|
338
364
|
* There are a few times when we need timeout-like logic, but if we use
|
|
339
365
|
* setInterval for everything, we have fewer IDs to juggle, and less risk of
|
|
@@ -365,22 +391,30 @@ export class TimeSync implements TimeSyncApi {
|
|
|
365
391
|
this.#subscriptions = new Map();
|
|
366
392
|
this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
|
|
367
393
|
this.#intervalId = undefined;
|
|
394
|
+
this.#initializedAtMs = getMonotonicTimeMs();
|
|
395
|
+
|
|
396
|
+
let date: ReadonlyDate;
|
|
397
|
+
if (initialDate instanceof ReadonlyDate) {
|
|
398
|
+
date = initialDate;
|
|
399
|
+
} else if (initialDate instanceof Date) {
|
|
400
|
+
date = new ReadonlyDate(initialDate);
|
|
401
|
+
} else {
|
|
402
|
+
date = new ReadonlyDate();
|
|
403
|
+
}
|
|
368
404
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const initialSnapshot: Snapshot = {
|
|
405
|
+
this.#latestSnapshot = freezeSnapshot({
|
|
406
|
+
date,
|
|
372
407
|
subscriberCount: 0,
|
|
373
|
-
|
|
374
|
-
config:
|
|
408
|
+
lastUpdatedAtMs: null,
|
|
409
|
+
config: {
|
|
375
410
|
freezeUpdates,
|
|
376
411
|
minimumRefreshIntervalMs,
|
|
377
412
|
allowDuplicateOnUpdateCalls,
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
this.#latestSnapshot = Object.freeze(initialSnapshot);
|
|
413
|
+
},
|
|
414
|
+
});
|
|
381
415
|
}
|
|
382
416
|
|
|
383
|
-
#
|
|
417
|
+
#notifyAllSubscriptions(): void {
|
|
384
418
|
// It's more important that we copy the date object into a separate
|
|
385
419
|
// variable here than normal, because need make sure the `this` context
|
|
386
420
|
// can't magically change between updates and cause subscribers to
|
|
@@ -395,8 +429,6 @@ export class TimeSync implements TimeSyncApi {
|
|
|
395
429
|
return;
|
|
396
430
|
}
|
|
397
431
|
|
|
398
|
-
const dateTime = date.getTime();
|
|
399
|
-
|
|
400
432
|
/**
|
|
401
433
|
* Two things:
|
|
402
434
|
* 1. Even though the context arrays are defined as readonly (which
|
|
@@ -416,32 +448,20 @@ export class TimeSync implements TimeSyncApi {
|
|
|
416
448
|
* to check on each iteration to see if we should continue.
|
|
417
449
|
*/
|
|
418
450
|
const subsBeforeUpdate = this.#subscriptions;
|
|
419
|
-
const
|
|
420
|
-
outer: for (const [onUpdate, subs] of
|
|
421
|
-
|
|
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>[]) {
|
|
451
|
+
const localEntries = Array.from(subsBeforeUpdate);
|
|
452
|
+
outer: for (const [onUpdate, subs] of localEntries) {
|
|
453
|
+
for (const ctx of subs) {
|
|
427
454
|
// We're not doing anything more sophisticated here because
|
|
428
455
|
// we're assuming that any systems that can clear out the
|
|
429
456
|
// subscriptions will handle cleaning up each context, too
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
457
|
+
const wasClearedBetweenUpdates = subsBeforeUpdate.size === 0;
|
|
458
|
+
if (wasClearedBetweenUpdates) {
|
|
432
459
|
break outer;
|
|
433
460
|
}
|
|
434
461
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (isIntervalMatch) {
|
|
439
|
-
ctx.intervalLastFulfilledAt = date;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (shouldCallOnUpdate) {
|
|
443
|
-
onUpdate(date, ctx);
|
|
444
|
-
shouldCallOnUpdate = config.allowDuplicateOnUpdateCalls;
|
|
462
|
+
onUpdate(date, ctx);
|
|
463
|
+
if (!config.allowDuplicateOnUpdateCalls) {
|
|
464
|
+
continue outer;
|
|
445
465
|
}
|
|
446
466
|
}
|
|
447
467
|
}
|
|
@@ -456,7 +476,7 @@ export class TimeSync implements TimeSyncApi {
|
|
|
456
476
|
* is one of them.
|
|
457
477
|
*/
|
|
458
478
|
readonly #onTick = (): void => {
|
|
459
|
-
const { config
|
|
479
|
+
const { config } = this.#latestSnapshot;
|
|
460
480
|
if (config.freezeUpdates) {
|
|
461
481
|
// Defensive step to make sure that an invalid tick wasn't started
|
|
462
482
|
clearInterval(this.#intervalId);
|
|
@@ -464,59 +484,57 @@ export class TimeSync implements TimeSyncApi {
|
|
|
464
484
|
return;
|
|
465
485
|
}
|
|
466
486
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
487
|
+
// onTick is expected to be called in response to monotonic time changes
|
|
488
|
+
// (either from calculating them manually to decide when to call onTick
|
|
489
|
+
// synchronously or from letting setInterval handle the calls). So while
|
|
490
|
+
// this edge case should basically be impossible, we need to make sure that
|
|
491
|
+
// we always dispatch a date, even if its time is exactly the same.
|
|
472
492
|
this.#latestSnapshot = freezeSnapshot({
|
|
473
493
|
...this.#latestSnapshot,
|
|
474
|
-
date:
|
|
494
|
+
date: new ReadonlyDate(),
|
|
495
|
+
lastUpdatedAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
|
|
475
496
|
});
|
|
476
|
-
this.#
|
|
497
|
+
this.#notifyAllSubscriptions();
|
|
477
498
|
};
|
|
478
499
|
|
|
479
500
|
#onFastestIntervalChange(): void {
|
|
480
501
|
const fastest = this.#fastestRefreshInterval;
|
|
481
|
-
const {
|
|
502
|
+
const { lastUpdatedAtMs, config } = this.#latestSnapshot;
|
|
503
|
+
|
|
482
504
|
const updatesShouldStop =
|
|
483
|
-
config.freezeUpdates ||
|
|
505
|
+
config.freezeUpdates ||
|
|
506
|
+
this.#subscriptions.size === 0 ||
|
|
507
|
+
fastest === Number.POSITIVE_INFINITY;
|
|
484
508
|
if (updatesShouldStop) {
|
|
485
509
|
clearInterval(this.#intervalId);
|
|
486
510
|
this.#intervalId = undefined;
|
|
487
511
|
return;
|
|
488
512
|
}
|
|
489
513
|
|
|
490
|
-
const
|
|
514
|
+
const newTime = getMonotonicTimeMs();
|
|
515
|
+
const elapsed = newTime - (lastUpdatedAtMs ?? this.#initializedAtMs);
|
|
491
516
|
const timeBeforeNextUpdate = fastest - elapsed;
|
|
492
517
|
|
|
493
|
-
// Clear previous interval
|
|
518
|
+
// Clear previous interval no matter what just to be on the safe side
|
|
494
519
|
clearInterval(this.#intervalId);
|
|
495
520
|
|
|
496
521
|
if (timeBeforeNextUpdate <= 0) {
|
|
497
|
-
|
|
498
|
-
if (newDate.getTime() !== date.getTime()) {
|
|
499
|
-
this.#latestSnapshot = freezeSnapshot({
|
|
500
|
-
...this.#latestSnapshot,
|
|
501
|
-
date: newDate,
|
|
502
|
-
});
|
|
503
|
-
this.#processSubscriptionUpdate();
|
|
504
|
-
}
|
|
505
|
-
|
|
522
|
+
this.#onTick();
|
|
506
523
|
this.#intervalId = setInterval(this.#onTick, fastest);
|
|
507
524
|
return;
|
|
508
525
|
}
|
|
509
526
|
|
|
510
527
|
// Most common case for this branch is the very first subscription
|
|
511
528
|
// getting added, but there's still the small chance that the fastest
|
|
512
|
-
// interval could change right after an update got flushed
|
|
529
|
+
// interval could change right after an update got flushed, so there would
|
|
530
|
+
// be zero elapsed time to worry about
|
|
513
531
|
if (timeBeforeNextUpdate === fastest) {
|
|
514
532
|
this.#intervalId = setInterval(this.#onTick, timeBeforeNextUpdate);
|
|
515
533
|
return;
|
|
516
534
|
}
|
|
517
535
|
|
|
518
|
-
// Otherwise, use
|
|
519
|
-
//
|
|
536
|
+
// Otherwise, use setInterval as pseudo-timeout to resolve the remaining
|
|
537
|
+
// time as a one-time update, and then go back to using normal intervals
|
|
520
538
|
this.#intervalId = setInterval(() => {
|
|
521
539
|
clearInterval(this.#intervalId);
|
|
522
540
|
|
|
@@ -537,14 +555,13 @@ export class TimeSync implements TimeSyncApi {
|
|
|
537
555
|
return;
|
|
538
556
|
}
|
|
539
557
|
|
|
540
|
-
const prevFastest = this.#fastestRefreshInterval;
|
|
541
|
-
let newFastest = Number.POSITIVE_INFINITY;
|
|
542
|
-
|
|
543
558
|
// This setup requires that every interval array stay sorted. It
|
|
544
559
|
// immediately falls apart if this isn't guaranteed.
|
|
545
|
-
|
|
560
|
+
const prevFastest = this.#fastestRefreshInterval;
|
|
561
|
+
let newFastest = Number.POSITIVE_INFINITY;
|
|
562
|
+
for (const contexts of this.#subscriptions.values()) {
|
|
546
563
|
const subFastest =
|
|
547
|
-
|
|
564
|
+
contexts[0]?.refreshIntervalMs ?? Number.POSITIVE_INFINITY;
|
|
548
565
|
if (subFastest < newFastest) {
|
|
549
566
|
newFastest = subFastest;
|
|
550
567
|
}
|
|
@@ -557,14 +574,10 @@ export class TimeSync implements TimeSyncApi {
|
|
|
557
574
|
}
|
|
558
575
|
|
|
559
576
|
subscribe(options: SubscriptionInitOptions): () => void {
|
|
560
|
-
const { config } = this.#latestSnapshot;
|
|
561
|
-
if (config.freezeUpdates) {
|
|
562
|
-
return noOp;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
577
|
// Destructuring properties so that they can't be fiddled with after
|
|
566
578
|
// this function call ends
|
|
567
579
|
const { targetRefreshIntervalMs, onUpdate } = options;
|
|
580
|
+
const { minimumRefreshIntervalMs } = this.#latestSnapshot.config;
|
|
568
581
|
|
|
569
582
|
const isTargetValid =
|
|
570
583
|
targetRefreshIntervalMs === Number.POSITIVE_INFINITY ||
|
|
@@ -576,71 +589,72 @@ export class TimeSync implements TimeSyncApi {
|
|
|
576
589
|
);
|
|
577
590
|
}
|
|
578
591
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const
|
|
592
|
+
const subsOnSetup = this.#subscriptions;
|
|
593
|
+
let subscribed = true;
|
|
594
|
+
const ctx: SubscriptionContext = {
|
|
582
595
|
timeSync: this,
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
targetRefreshIntervalMs: Math.max(
|
|
587
|
-
config.minimumRefreshIntervalMs,
|
|
596
|
+
registeredAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
|
|
597
|
+
refreshIntervalMs: Math.max(
|
|
598
|
+
minimumRefreshIntervalMs,
|
|
588
599
|
targetRefreshIntervalMs,
|
|
589
600
|
),
|
|
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
601
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
602
|
+
unsubscribe: () => {
|
|
603
|
+
// Not super conventional, but basically using try/finally as a form of
|
|
604
|
+
// Go's defer. There are a lot of branches we need to worry about for
|
|
605
|
+
// the unsubscribe callback, and we need to make sure we flip subscribed
|
|
606
|
+
// to false after each one
|
|
607
|
+
try {
|
|
608
|
+
if (!subscribed || this.#subscriptions !== subsOnSetup) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const contexts = subsOnSetup.get(onUpdate);
|
|
612
|
+
if (contexts === undefined) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const filtered = contexts.filter(
|
|
616
|
+
(c) => c.unsubscribe !== ctx.unsubscribe,
|
|
617
|
+
);
|
|
618
|
+
if (filtered.length === contexts.length) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const dropped = Math.max(0, this.#latestSnapshot.subscriberCount - 1);
|
|
623
|
+
this.#latestSnapshot = freezeSnapshot({
|
|
624
|
+
...this.#latestSnapshot,
|
|
625
|
+
subscriberCount: dropped,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
if (filtered.length > 0) {
|
|
629
|
+
// No need to sort on removal because everything gets sorted as
|
|
630
|
+
// it enters the subscriptions map
|
|
631
|
+
subsOnSetup.set(onUpdate, filtered);
|
|
632
|
+
} else {
|
|
633
|
+
subsOnSetup.delete(onUpdate);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
this.#updateFastestInterval();
|
|
637
|
+
} finally {
|
|
638
|
+
subscribed = false;
|
|
639
|
+
}
|
|
640
|
+
},
|
|
627
641
|
};
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
642
|
+
Object.freeze(ctx);
|
|
643
|
+
|
|
644
|
+
// We need to make sure that each array for tracking subscriptions is
|
|
645
|
+
// readonly, and because dispatching updates should be far more common than
|
|
646
|
+
// adding subscriptions, we're placing the immutable copying here to
|
|
647
|
+
// minimize overall pressure on the system.
|
|
648
|
+
let newContexts: SubscriptionContext[];
|
|
649
|
+
const prevContexts = subsOnSetup.get(onUpdate);
|
|
650
|
+
if (prevContexts !== undefined) {
|
|
651
|
+
newContexts = [...prevContexts, ctx];
|
|
634
652
|
} else {
|
|
635
|
-
|
|
636
|
-
subsOnSetup.set(onUpdate, contexts);
|
|
653
|
+
newContexts = [ctx];
|
|
637
654
|
}
|
|
638
655
|
|
|
639
|
-
subsOnSetup.set(onUpdate,
|
|
640
|
-
|
|
641
|
-
contexts.sort(
|
|
642
|
-
(e1, e2) => e1.targetRefreshIntervalMs - e2.targetRefreshIntervalMs,
|
|
643
|
-
);
|
|
656
|
+
subsOnSetup.set(onUpdate, newContexts);
|
|
657
|
+
newContexts.sort((c1, c2) => c1.refreshIntervalMs - c2.refreshIntervalMs);
|
|
644
658
|
|
|
645
659
|
this.#latestSnapshot = freezeSnapshot({
|
|
646
660
|
...this.#latestSnapshot,
|
|
@@ -648,7 +662,7 @@ export class TimeSync implements TimeSyncApi {
|
|
|
648
662
|
});
|
|
649
663
|
|
|
650
664
|
this.#updateFastestInterval();
|
|
651
|
-
return unsubscribe;
|
|
665
|
+
return ctx.unsubscribe;
|
|
652
666
|
}
|
|
653
667
|
|
|
654
668
|
getStateSnapshot(): Snapshot {
|
|
@@ -668,6 +682,7 @@ export class TimeSync implements TimeSyncApi {
|
|
|
668
682
|
// We swap the map out so that the unsubscribe callbacks can detect
|
|
669
683
|
// whether their functionality is still relevant
|
|
670
684
|
this.#subscriptions = new Map();
|
|
685
|
+
|
|
671
686
|
this.#latestSnapshot = freezeSnapshot({
|
|
672
687
|
...this.#latestSnapshot,
|
|
673
688
|
subscriberCount: 0,
|
package/src/utilities.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
|