@buenos-nachos/time-sync 0.4.0 → 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 +14 -0
- package/package.json +1 -1
- package/src/TimeSync.test.ts +58 -71
- package/src/TimeSync.ts +195 -175
- 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.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
|
+
|
|
11
|
+
## 0.4.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 5f37f1a: refactored class to remove private setSnapshost method
|
|
16
|
+
|
|
3
17
|
## 0.4.0
|
|
4
18
|
|
|
5
19
|
### Breaking 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,47 @@ 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
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* This function is just a convenience for us to sidestep some problems around
|
|
247
|
+
* TypeScript's LSP and Object.freeze. Because Object.freeze can accept any
|
|
248
|
+
* arbitrary type, it basically acts as a "type boundary" between the left and
|
|
249
|
+
* right sides of any snapshot assignments.
|
|
250
|
+
*
|
|
251
|
+
* That means that if you rename a property a a value that is passed to
|
|
252
|
+
* Object.freeze, the LSP can't auto-rename it, and you potentially get missing
|
|
253
|
+
* properties. This is a bit hokey, but because the function is defined strictly
|
|
254
|
+
* in terms of concrete snapshots, any value passed to this function won't have
|
|
255
|
+
* to worry about mismatches.
|
|
256
|
+
*/
|
|
257
|
+
function freezeSnapshot(snap: Snapshot): Snapshot {
|
|
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;
|
|
265
|
+
}
|
|
232
266
|
|
|
233
267
|
const defaultMinimumRefreshIntervalMs = 200;
|
|
234
268
|
|
|
@@ -268,6 +302,12 @@ const defaultMinimumRefreshIntervalMs = 200;
|
|
|
268
302
|
* some parts of the screen.)
|
|
269
303
|
*/
|
|
270
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
|
+
|
|
271
311
|
/**
|
|
272
312
|
* Stores all refresh intervals actively associated with an onUpdate
|
|
273
313
|
* callback (along with their associated unsubscribe callbacks).
|
|
@@ -316,7 +356,8 @@ export class TimeSync implements TimeSyncApi {
|
|
|
316
356
|
|
|
317
357
|
/**
|
|
318
358
|
* Used for both its intended purpose (creating interval), but also as a
|
|
319
|
-
* 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.
|
|
320
361
|
*
|
|
321
362
|
* There are a few times when we need timeout-like logic, but if we use
|
|
322
363
|
* setInterval for everything, we have fewer IDs to juggle, and less risk of
|
|
@@ -348,64 +389,44 @@ export class TimeSync implements TimeSyncApi {
|
|
|
348
389
|
this.#subscriptions = new Map();
|
|
349
390
|
this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
|
|
350
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
|
+
}
|
|
351
402
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const initialSnapshot: Snapshot = {
|
|
403
|
+
this.#latestSnapshot = freezeSnapshot({
|
|
404
|
+
date,
|
|
355
405
|
subscriberCount: 0,
|
|
356
|
-
|
|
357
|
-
config:
|
|
406
|
+
lastUpdatedAtMs: null,
|
|
407
|
+
config: {
|
|
358
408
|
freezeUpdates,
|
|
359
409
|
minimumRefreshIntervalMs,
|
|
360
410
|
allowDuplicateOnUpdateCalls,
|
|
361
|
-
}
|
|
362
|
-
};
|
|
363
|
-
this.#latestSnapshot = Object.freeze(initialSnapshot);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
#setSnapshot(update: Partial<Snapshot>): boolean {
|
|
367
|
-
const { date, subscriberCount, config } = this.#latestSnapshot;
|
|
368
|
-
if (config.freezeUpdates) {
|
|
369
|
-
return false;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Avoiding both direct property assignment or spread syntax because
|
|
373
|
-
// Object.freeze causes weird TypeScript LSP issues around assignability
|
|
374
|
-
// where trying to rename a property. If you rename a property on a
|
|
375
|
-
// type, it WON'T rename the runtime properties. Object.freeze
|
|
376
|
-
// introduces an extra type boundary that break the linking
|
|
377
|
-
const updated: Snapshot = {
|
|
378
|
-
// Always reject any new configs because trying to remove them at
|
|
379
|
-
// the type level isn't worth it for an internal implementation
|
|
380
|
-
// detail
|
|
381
|
-
config,
|
|
382
|
-
date: update.date ?? date,
|
|
383
|
-
subscriberCount: update.subscriberCount ?? subscriberCount,
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
this.#latestSnapshot = Object.freeze(updated);
|
|
387
|
-
return true;
|
|
411
|
+
},
|
|
412
|
+
});
|
|
388
413
|
}
|
|
389
414
|
|
|
390
415
|
#notifyAllSubscriptions(): void {
|
|
391
416
|
// It's more important that we copy the date object into a separate
|
|
392
417
|
// variable here than normal, because need make sure the `this` context
|
|
393
418
|
// can't magically change between updates and cause subscribers to
|
|
394
|
-
// receive different values
|
|
395
|
-
// invalidate method)
|
|
419
|
+
// receive different values
|
|
396
420
|
const { date, config } = this.#latestSnapshot;
|
|
397
421
|
|
|
398
|
-
// We still need to let the logic go through if the current fastest
|
|
399
|
-
// interval is Infinity, so that we can support letting any arbitrary
|
|
400
|
-
// consumer invalidate the date immediately
|
|
401
422
|
const subscriptionsPaused =
|
|
402
|
-
config.freezeUpdates ||
|
|
423
|
+
config.freezeUpdates ||
|
|
424
|
+
this.#subscriptions.size === 0 ||
|
|
425
|
+
this.#fastestRefreshInterval === Number.POSITIVE_INFINITY;
|
|
403
426
|
if (subscriptionsPaused) {
|
|
404
427
|
return;
|
|
405
428
|
}
|
|
406
429
|
|
|
407
|
-
const dateTime = date.getTime();
|
|
408
|
-
|
|
409
430
|
/**
|
|
410
431
|
* Two things:
|
|
411
432
|
* 1. Even though the context arrays are defined as readonly (which
|
|
@@ -425,32 +446,20 @@ export class TimeSync implements TimeSyncApi {
|
|
|
425
446
|
* to check on each iteration to see if we should continue.
|
|
426
447
|
*/
|
|
427
448
|
const subsBeforeUpdate = this.#subscriptions;
|
|
428
|
-
const
|
|
429
|
-
outer: for (const [onUpdate, subs] of
|
|
430
|
-
|
|
431
|
-
// iterate through everything and update any internal data. If the
|
|
432
|
-
// first context in a sub array gets removed by unsubscribing, we
|
|
433
|
-
// want what was the the second element to still be up to date
|
|
434
|
-
let shouldCallOnUpdate = true;
|
|
435
|
-
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) {
|
|
436
452
|
// We're not doing anything more sophisticated here because
|
|
437
453
|
// we're assuming that any systems that can clear out the
|
|
438
454
|
// subscriptions will handle cleaning up each context, too
|
|
439
|
-
const
|
|
440
|
-
if (
|
|
455
|
+
const wasClearedBetweenUpdates = subsBeforeUpdate.size === 0;
|
|
456
|
+
if (wasClearedBetweenUpdates) {
|
|
441
457
|
break outer;
|
|
442
458
|
}
|
|
443
459
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (isIntervalMatch) {
|
|
448
|
-
ctx.intervalLastFulfilledAt = date;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (shouldCallOnUpdate) {
|
|
452
|
-
onUpdate(date, ctx);
|
|
453
|
-
shouldCallOnUpdate = config.allowDuplicateOnUpdateCalls;
|
|
460
|
+
onUpdate(date, ctx);
|
|
461
|
+
if (!config.allowDuplicateOnUpdateCalls) {
|
|
462
|
+
continue outer;
|
|
454
463
|
}
|
|
455
464
|
}
|
|
456
465
|
}
|
|
@@ -465,56 +474,65 @@ export class TimeSync implements TimeSyncApi {
|
|
|
465
474
|
* is one of them.
|
|
466
475
|
*/
|
|
467
476
|
readonly #onTick = (): void => {
|
|
468
|
-
// Defensive step to make sure that an invalid tick wasn't started
|
|
469
477
|
const { config } = this.#latestSnapshot;
|
|
470
478
|
if (config.freezeUpdates) {
|
|
479
|
+
// Defensive step to make sure that an invalid tick wasn't started
|
|
471
480
|
clearInterval(this.#intervalId);
|
|
472
481
|
this.#intervalId = undefined;
|
|
473
482
|
return;
|
|
474
483
|
}
|
|
475
484
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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.
|
|
490
|
+
this.#latestSnapshot = freezeSnapshot({
|
|
491
|
+
...this.#latestSnapshot,
|
|
492
|
+
date: new ReadonlyDate(),
|
|
493
|
+
lastUpdatedAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
|
|
494
|
+
});
|
|
495
|
+
this.#notifyAllSubscriptions();
|
|
480
496
|
};
|
|
481
497
|
|
|
482
498
|
#onFastestIntervalChange(): void {
|
|
483
499
|
const fastest = this.#fastestRefreshInterval;
|
|
484
|
-
const {
|
|
500
|
+
const { lastUpdatedAtMs, config } = this.#latestSnapshot;
|
|
501
|
+
|
|
485
502
|
const updatesShouldStop =
|
|
486
|
-
config.freezeUpdates ||
|
|
503
|
+
config.freezeUpdates ||
|
|
504
|
+
this.#subscriptions.size === 0 ||
|
|
505
|
+
fastest === Number.POSITIVE_INFINITY;
|
|
487
506
|
if (updatesShouldStop) {
|
|
488
507
|
clearInterval(this.#intervalId);
|
|
489
508
|
this.#intervalId = undefined;
|
|
490
509
|
return;
|
|
491
510
|
}
|
|
492
511
|
|
|
493
|
-
const
|
|
512
|
+
const newTime = getMonotonicTimeMs();
|
|
513
|
+
const elapsed = newTime - (lastUpdatedAtMs ?? this.#initializedAtMs);
|
|
494
514
|
const timeBeforeNextUpdate = fastest - elapsed;
|
|
495
515
|
|
|
496
|
-
// Clear previous interval
|
|
516
|
+
// Clear previous interval no matter what just to be on the safe side
|
|
497
517
|
clearInterval(this.#intervalId);
|
|
498
518
|
|
|
499
519
|
if (timeBeforeNextUpdate <= 0) {
|
|
500
|
-
|
|
501
|
-
if (wasChanged) {
|
|
502
|
-
this.#notifyAllSubscriptions();
|
|
503
|
-
}
|
|
520
|
+
this.#onTick();
|
|
504
521
|
this.#intervalId = setInterval(this.#onTick, fastest);
|
|
505
522
|
return;
|
|
506
523
|
}
|
|
507
524
|
|
|
508
525
|
// Most common case for this branch is the very first subscription
|
|
509
526
|
// getting added, but there's still the small chance that the fastest
|
|
510
|
-
// 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
|
|
511
529
|
if (timeBeforeNextUpdate === fastest) {
|
|
512
530
|
this.#intervalId = setInterval(this.#onTick, timeBeforeNextUpdate);
|
|
513
531
|
return;
|
|
514
532
|
}
|
|
515
533
|
|
|
516
|
-
// Otherwise, use
|
|
517
|
-
//
|
|
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
|
|
518
536
|
this.#intervalId = setInterval(() => {
|
|
519
537
|
clearInterval(this.#intervalId);
|
|
520
538
|
|
|
@@ -535,14 +553,13 @@ export class TimeSync implements TimeSyncApi {
|
|
|
535
553
|
return;
|
|
536
554
|
}
|
|
537
555
|
|
|
538
|
-
const prevFastest = this.#fastestRefreshInterval;
|
|
539
|
-
let newFastest = Number.POSITIVE_INFINITY;
|
|
540
|
-
|
|
541
556
|
// This setup requires that every interval array stay sorted. It
|
|
542
557
|
// immediately falls apart if this isn't guaranteed.
|
|
543
|
-
|
|
558
|
+
const prevFastest = this.#fastestRefreshInterval;
|
|
559
|
+
let newFastest = Number.POSITIVE_INFINITY;
|
|
560
|
+
for (const contexts of this.#subscriptions.values()) {
|
|
544
561
|
const subFastest =
|
|
545
|
-
|
|
562
|
+
contexts[0]?.refreshIntervalMs ?? Number.POSITIVE_INFINITY;
|
|
546
563
|
if (subFastest < newFastest) {
|
|
547
564
|
newFastest = subFastest;
|
|
548
565
|
}
|
|
@@ -555,14 +572,10 @@ export class TimeSync implements TimeSyncApi {
|
|
|
555
572
|
}
|
|
556
573
|
|
|
557
574
|
subscribe(options: SubscriptionInitOptions): () => void {
|
|
558
|
-
const { config } = this.#latestSnapshot;
|
|
559
|
-
if (config.freezeUpdates) {
|
|
560
|
-
return noOp;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
575
|
// Destructuring properties so that they can't be fiddled with after
|
|
564
576
|
// this function call ends
|
|
565
577
|
const { targetRefreshIntervalMs, onUpdate } = options;
|
|
578
|
+
const { minimumRefreshIntervalMs } = this.#latestSnapshot.config;
|
|
566
579
|
|
|
567
580
|
const isTargetValid =
|
|
568
581
|
targetRefreshIntervalMs === Number.POSITIVE_INFINITY ||
|
|
@@ -574,77 +587,80 @@ export class TimeSync implements TimeSyncApi {
|
|
|
574
587
|
);
|
|
575
588
|
}
|
|
576
589
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const
|
|
590
|
+
const subsOnSetup = this.#subscriptions;
|
|
591
|
+
let subscribed = true;
|
|
592
|
+
const ctx: SubscriptionContext = {
|
|
580
593
|
timeSync: this,
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
targetRefreshIntervalMs: Math.max(
|
|
585
|
-
config.minimumRefreshIntervalMs,
|
|
594
|
+
registeredAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
|
|
595
|
+
refreshIntervalMs: Math.max(
|
|
596
|
+
minimumRefreshIntervalMs,
|
|
586
597
|
targetRefreshIntervalMs,
|
|
587
598
|
),
|
|
588
|
-
};
|
|
589
599
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
+
},
|
|
624
639
|
};
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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];
|
|
631
650
|
} else {
|
|
632
|
-
|
|
633
|
-
subsOnSetup.set(onUpdate, contexts);
|
|
651
|
+
newContexts = [ctx];
|
|
634
652
|
}
|
|
635
653
|
|
|
636
|
-
subsOnSetup.set(onUpdate,
|
|
637
|
-
|
|
638
|
-
contexts.sort(
|
|
639
|
-
(e1, e2) => e1.targetRefreshIntervalMs - e2.targetRefreshIntervalMs,
|
|
640
|
-
);
|
|
654
|
+
subsOnSetup.set(onUpdate, newContexts);
|
|
655
|
+
newContexts.sort((c1, c2) => c1.refreshIntervalMs - c2.refreshIntervalMs);
|
|
641
656
|
|
|
642
|
-
|
|
657
|
+
this.#latestSnapshot = freezeSnapshot({
|
|
658
|
+
...this.#latestSnapshot,
|
|
643
659
|
subscriberCount: this.#latestSnapshot.subscriberCount + 1,
|
|
644
660
|
});
|
|
645
661
|
|
|
646
662
|
this.#updateFastestInterval();
|
|
647
|
-
return unsubscribe;
|
|
663
|
+
return ctx.unsubscribe;
|
|
648
664
|
}
|
|
649
665
|
|
|
650
666
|
getStateSnapshot(): Snapshot {
|
|
@@ -664,6 +680,10 @@ export class TimeSync implements TimeSyncApi {
|
|
|
664
680
|
// We swap the map out so that the unsubscribe callbacks can detect
|
|
665
681
|
// whether their functionality is still relevant
|
|
666
682
|
this.#subscriptions = new Map();
|
|
667
|
-
|
|
683
|
+
|
|
684
|
+
this.#latestSnapshot = freezeSnapshot({
|
|
685
|
+
...this.#latestSnapshot,
|
|
686
|
+
subscriberCount: 0,
|
|
687
|
+
});
|
|
668
688
|
}
|
|
669
689
|
}
|
package/src/utilities.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
|