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