@baukit/sync-client 0.7.2 → 0.7.3
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/package.json +2 -1
- package/src/browser.test.ts +173 -0
- package/src/browser.ts +91 -0
- package/src/conformance.test.ts +385 -0
- package/src/conformance.ts +875 -0
- package/src/error.ts +116 -0
- package/src/expo.test.ts +137 -0
- package/src/expo.ts +101 -0
- package/src/hlc.test.ts +249 -0
- package/src/hlc.ts +244 -0
- package/src/index.ts +80 -0
- package/src/push-batch.test.ts +207 -0
- package/src/push-batch.ts +115 -0
- package/src/scheduler-retry.test.ts +307 -0
- package/src/scheduler.test.ts +295 -0
- package/src/scheduler.ts +260 -0
- package/src/store.test.ts +402 -0
- package/src/store.ts +336 -0
- package/src/transport.test.ts +360 -0
- package/src/transport.ts +344 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SyncScheduler,
|
|
5
|
+
type SyncSchedulerEnvironment,
|
|
6
|
+
type SyncSchedulerRetry,
|
|
7
|
+
type SyncSchedulerRetryOptions,
|
|
8
|
+
type SyncSchedulerTimer,
|
|
9
|
+
} from './scheduler.js';
|
|
10
|
+
|
|
11
|
+
const INTERVAL_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
interface Timer {
|
|
14
|
+
readonly callback: () => void;
|
|
15
|
+
readonly ms: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RetryEnvironment extends SyncSchedulerEnvironment {
|
|
19
|
+
setActive(active: boolean): void;
|
|
20
|
+
goOnline(): void;
|
|
21
|
+
/** Fires the pending timers that are not the periodic interval. */
|
|
22
|
+
elapseRetryDelay(): void;
|
|
23
|
+
retryDelays(): number[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function retryEnvironment(): RetryEnvironment {
|
|
27
|
+
let active = true;
|
|
28
|
+
const activeListeners = new Set<(next: boolean) => void>();
|
|
29
|
+
const onlineListeners = new Set<() => void>();
|
|
30
|
+
const timers = new Map<SyncSchedulerTimer, Timer>();
|
|
31
|
+
const retryTimers = () => [...timers.values()].filter((timer) => timer.ms !== INTERVAL_MS);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
isActive: () => active,
|
|
35
|
+
subscribeActive(listener) {
|
|
36
|
+
activeListeners.add(listener);
|
|
37
|
+
return () => activeListeners.delete(listener);
|
|
38
|
+
},
|
|
39
|
+
subscribeOnline(listener) {
|
|
40
|
+
onlineListeners.add(listener);
|
|
41
|
+
return () => onlineListeners.delete(listener);
|
|
42
|
+
},
|
|
43
|
+
setInterval(callback, ms) {
|
|
44
|
+
const handle = {};
|
|
45
|
+
timers.set(handle, { callback, ms });
|
|
46
|
+
return handle;
|
|
47
|
+
},
|
|
48
|
+
clearInterval(handle) {
|
|
49
|
+
timers.delete(handle);
|
|
50
|
+
},
|
|
51
|
+
setActive(next) {
|
|
52
|
+
active = next;
|
|
53
|
+
activeListeners.forEach((listener) => {
|
|
54
|
+
listener(next);
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
goOnline() {
|
|
58
|
+
onlineListeners.forEach((listener) => {
|
|
59
|
+
listener();
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
elapseRetryDelay() {
|
|
63
|
+
retryTimers().forEach(({ callback }) => {
|
|
64
|
+
callback();
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
retryDelays: () => retryTimers().map((timer) => timer.ms),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function failingRun(failures: number) {
|
|
72
|
+
let calls = 0;
|
|
73
|
+
return {
|
|
74
|
+
run: vi.fn(() => {
|
|
75
|
+
calls += 1;
|
|
76
|
+
return calls <= failures
|
|
77
|
+
? Promise.reject(new Error(`attempt ${String(calls)} failed`))
|
|
78
|
+
: Promise.resolve();
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const settle = async () => {
|
|
84
|
+
for (let tick = 0; tick < 5; tick += 1) {
|
|
85
|
+
await Promise.resolve();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function retryOptions(overrides: Partial<SyncSchedulerRetryOptions> = {}) {
|
|
90
|
+
const scheduled: SyncSchedulerRetry[] = [];
|
|
91
|
+
const options: SyncSchedulerRetryOptions = {
|
|
92
|
+
maxRetries: 3,
|
|
93
|
+
delayMs: (_error, retryIndex) => 100 * 2 ** retryIndex,
|
|
94
|
+
onRetryScheduled: (retry) => {
|
|
95
|
+
scheduled.push(retry);
|
|
96
|
+
},
|
|
97
|
+
...overrides,
|
|
98
|
+
};
|
|
99
|
+
return { options, scheduled };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('SyncScheduler retry', () => {
|
|
103
|
+
it('retries a failed run after the chosen delays until it succeeds', async () => {
|
|
104
|
+
const environment = retryEnvironment();
|
|
105
|
+
const { run } = failingRun(2);
|
|
106
|
+
const onError = vi.fn();
|
|
107
|
+
const { options, scheduled } = retryOptions();
|
|
108
|
+
const scheduler = new SyncScheduler(run, environment, {
|
|
109
|
+
intervalMs: INTERVAL_MS,
|
|
110
|
+
onError,
|
|
111
|
+
retry: options,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const done = scheduler.trigger();
|
|
115
|
+
await settle();
|
|
116
|
+
expect(environment.retryDelays()).toEqual([100]);
|
|
117
|
+
|
|
118
|
+
environment.elapseRetryDelay();
|
|
119
|
+
await settle();
|
|
120
|
+
expect(environment.retryDelays()).toEqual([200]);
|
|
121
|
+
|
|
122
|
+
environment.elapseRetryDelay();
|
|
123
|
+
await done;
|
|
124
|
+
|
|
125
|
+
expect(run).toHaveBeenCalledTimes(3);
|
|
126
|
+
expect(onError).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(scheduled.map(({ retryIndex, delayMs }) => [retryIndex, delayMs])).toEqual([
|
|
128
|
+
[0, 100],
|
|
129
|
+
[1, 200],
|
|
130
|
+
]);
|
|
131
|
+
expect(environment.retryDelays()).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('stops after maxRetries and waits for the next trigger', async () => {
|
|
135
|
+
const environment = retryEnvironment();
|
|
136
|
+
const { run } = failingRun(10);
|
|
137
|
+
const { options } = retryOptions({ maxRetries: 1 });
|
|
138
|
+
const scheduler = new SyncScheduler(run, environment, { retry: options });
|
|
139
|
+
|
|
140
|
+
const done = scheduler.trigger();
|
|
141
|
+
await settle();
|
|
142
|
+
environment.elapseRetryDelay();
|
|
143
|
+
await done;
|
|
144
|
+
|
|
145
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
146
|
+
expect(environment.retryDelays()).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('does not retry when the delay is null', async () => {
|
|
150
|
+
const environment = retryEnvironment();
|
|
151
|
+
const { run } = failingRun(1);
|
|
152
|
+
const { options } = retryOptions({ delayMs: () => null });
|
|
153
|
+
|
|
154
|
+
await new SyncScheduler(run, environment, { retry: options }).trigger();
|
|
155
|
+
|
|
156
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('passes the failure to the delay function', async () => {
|
|
160
|
+
const environment = retryEnvironment();
|
|
161
|
+
const { run } = failingRun(1);
|
|
162
|
+
const delayMs = vi.fn(() => null);
|
|
163
|
+
|
|
164
|
+
await new SyncScheduler(run, environment, { retry: { maxRetries: 2, delayMs } }).trigger();
|
|
165
|
+
|
|
166
|
+
expect(delayMs).toHaveBeenCalledWith(new Error('attempt 1 failed'), 0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('ends the delay and restarts the count when a trigger arrives', async () => {
|
|
170
|
+
const environment = retryEnvironment();
|
|
171
|
+
const { run } = failingRun(3);
|
|
172
|
+
const { options, scheduled } = retryOptions();
|
|
173
|
+
const scheduler = new SyncScheduler(run, environment, { retry: options });
|
|
174
|
+
|
|
175
|
+
const done = scheduler.trigger();
|
|
176
|
+
await settle();
|
|
177
|
+
environment.elapseRetryDelay();
|
|
178
|
+
await settle();
|
|
179
|
+
expect(scheduled.at(-1)?.retryIndex).toBe(1);
|
|
180
|
+
|
|
181
|
+
const joined = scheduler.trigger();
|
|
182
|
+
await settle();
|
|
183
|
+
|
|
184
|
+
expect(joined).toBe(done);
|
|
185
|
+
expect(scheduled.at(-1)?.retryIndex).toBe(0);
|
|
186
|
+
environment.elapseRetryDelay();
|
|
187
|
+
await done;
|
|
188
|
+
expect(run).toHaveBeenCalledTimes(4);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('retries at once when the app returns to the foreground or connectivity returns', async () => {
|
|
192
|
+
const environment = retryEnvironment();
|
|
193
|
+
const { run } = failingRun(2);
|
|
194
|
+
const signals: string[] = [];
|
|
195
|
+
const { options } = retryOptions();
|
|
196
|
+
const scheduler = new SyncScheduler(run, environment, {
|
|
197
|
+
intervalMs: INTERVAL_MS,
|
|
198
|
+
retry: options,
|
|
199
|
+
onRecoverySignal: (signal) => signals.push(signal),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
scheduler.start();
|
|
203
|
+
await settle();
|
|
204
|
+
environment.goOnline();
|
|
205
|
+
await settle();
|
|
206
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
207
|
+
|
|
208
|
+
environment.setActive(false);
|
|
209
|
+
environment.setActive(true);
|
|
210
|
+
await settle();
|
|
211
|
+
|
|
212
|
+
expect(run).toHaveBeenCalledTimes(3);
|
|
213
|
+
expect(signals).toEqual(['online', 'active']);
|
|
214
|
+
expect(environment.retryDelays()).toEqual([]);
|
|
215
|
+
scheduler.stop();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('stops retrying when the app is backgrounded during the delay', async () => {
|
|
219
|
+
const environment = retryEnvironment();
|
|
220
|
+
const { run } = failingRun(5);
|
|
221
|
+
const { options } = retryOptions();
|
|
222
|
+
const scheduler = new SyncScheduler(run, environment, {
|
|
223
|
+
intervalMs: INTERVAL_MS,
|
|
224
|
+
retry: options,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
scheduler.start();
|
|
228
|
+
await settle();
|
|
229
|
+
environment.setActive(false);
|
|
230
|
+
environment.elapseRetryDelay();
|
|
231
|
+
await settle();
|
|
232
|
+
|
|
233
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
234
|
+
scheduler.stop();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('ends the delay without another run when scheduling stops', async () => {
|
|
238
|
+
const environment = retryEnvironment();
|
|
239
|
+
const { run } = failingRun(5);
|
|
240
|
+
const { options } = retryOptions();
|
|
241
|
+
const scheduler = new SyncScheduler(run, environment, {
|
|
242
|
+
intervalMs: INTERVAL_MS,
|
|
243
|
+
retry: options,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
scheduler.start();
|
|
247
|
+
await settle();
|
|
248
|
+
const pending = scheduler.trigger();
|
|
249
|
+
scheduler.stop();
|
|
250
|
+
await pending;
|
|
251
|
+
|
|
252
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
253
|
+
expect(environment.retryDelays()).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('covers a follow-up with the retry instead of an immediate rerun', async () => {
|
|
257
|
+
const environment = retryEnvironment();
|
|
258
|
+
const { run } = failingRun(1);
|
|
259
|
+
const { options } = retryOptions();
|
|
260
|
+
const scheduler = new SyncScheduler(run, environment, { retry: options });
|
|
261
|
+
|
|
262
|
+
const done = scheduler.trigger();
|
|
263
|
+
void scheduler.requestFollowUp();
|
|
264
|
+
await settle();
|
|
265
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
266
|
+
|
|
267
|
+
environment.elapseRetryDelay();
|
|
268
|
+
await done;
|
|
269
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('reports a delay function that throws or returns an invalid delay, and stops retrying', async () => {
|
|
273
|
+
const environment = retryEnvironment();
|
|
274
|
+
const onError = vi.fn();
|
|
275
|
+
const broken = new Error('delay failed');
|
|
276
|
+
|
|
277
|
+
await new SyncScheduler(failingRun(1).run, environment, {
|
|
278
|
+
onError,
|
|
279
|
+
retry: {
|
|
280
|
+
maxRetries: 1,
|
|
281
|
+
delayMs: () => {
|
|
282
|
+
throw broken;
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
}).trigger();
|
|
286
|
+
await new SyncScheduler(failingRun(1).run, environment, {
|
|
287
|
+
onError,
|
|
288
|
+
retry: { maxRetries: 1, delayMs: () => Number.NaN },
|
|
289
|
+
}).trigger();
|
|
290
|
+
|
|
291
|
+
expect(onError).toHaveBeenCalledWith(broken);
|
|
292
|
+
expect(onError).toHaveBeenLastCalledWith(expect.any(RangeError));
|
|
293
|
+
expect(environment.retryDelays()).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('rejects a maxRetries that is not a non-negative integer', () => {
|
|
297
|
+
const environment = retryEnvironment();
|
|
298
|
+
const run = () => Promise.resolve();
|
|
299
|
+
|
|
300
|
+
expect(
|
|
301
|
+
() => new SyncScheduler(run, environment, { retry: { maxRetries: -1, delayMs: () => 0 } }),
|
|
302
|
+
).toThrow(RangeError);
|
|
303
|
+
expect(
|
|
304
|
+
() => new SyncScheduler(run, environment, { retry: { maxRetries: 1.5, delayMs: () => 0 } }),
|
|
305
|
+
).toThrow(RangeError);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SyncScheduler,
|
|
5
|
+
type SyncSchedulerEnvironment,
|
|
6
|
+
type SyncSchedulerTimer,
|
|
7
|
+
} from './scheduler.js';
|
|
8
|
+
|
|
9
|
+
interface FakeEnvironment extends SyncSchedulerEnvironment {
|
|
10
|
+
setActive(active: boolean): void;
|
|
11
|
+
goOnline(): void;
|
|
12
|
+
fireInterval(): void;
|
|
13
|
+
intervalCount(): number;
|
|
14
|
+
lastIntervalMs(): number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fakeEnvironment(initiallyActive = true): FakeEnvironment {
|
|
18
|
+
let active = initiallyActive;
|
|
19
|
+
const activeListeners = new Set<(next: boolean) => void>();
|
|
20
|
+
const onlineListeners = new Set<() => void>();
|
|
21
|
+
const timers = new Map<SyncSchedulerTimer, { callback: () => void; ms: number }>();
|
|
22
|
+
let lastMs: number | null = null;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
isActive: () => active,
|
|
26
|
+
subscribeActive(listener) {
|
|
27
|
+
activeListeners.add(listener);
|
|
28
|
+
return () => activeListeners.delete(listener);
|
|
29
|
+
},
|
|
30
|
+
subscribeOnline(listener) {
|
|
31
|
+
onlineListeners.add(listener);
|
|
32
|
+
return () => onlineListeners.delete(listener);
|
|
33
|
+
},
|
|
34
|
+
setInterval(callback, milliseconds) {
|
|
35
|
+
const handle = {};
|
|
36
|
+
lastMs = milliseconds;
|
|
37
|
+
timers.set(handle, { callback, ms: milliseconds });
|
|
38
|
+
return handle;
|
|
39
|
+
},
|
|
40
|
+
clearInterval(handle) {
|
|
41
|
+
timers.delete(handle);
|
|
42
|
+
},
|
|
43
|
+
setActive(next) {
|
|
44
|
+
active = next;
|
|
45
|
+
activeListeners.forEach((listener) => {
|
|
46
|
+
listener(next);
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
goOnline() {
|
|
50
|
+
onlineListeners.forEach((listener) => {
|
|
51
|
+
listener();
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
fireInterval() {
|
|
55
|
+
[...timers.values()].forEach(({ callback }) => {
|
|
56
|
+
callback();
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
intervalCount: () => timers.size,
|
|
60
|
+
lastIntervalMs: () => lastMs,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function deferredRun(): { run: () => Promise<void>; finish: () => void; calls: () => number } {
|
|
65
|
+
const finishes: (() => void)[] = [];
|
|
66
|
+
let calls = 0;
|
|
67
|
+
return {
|
|
68
|
+
run: () => {
|
|
69
|
+
calls += 1;
|
|
70
|
+
return new Promise<void>((resolve) => finishes.push(resolve));
|
|
71
|
+
},
|
|
72
|
+
finish: () => finishes.shift()?.(),
|
|
73
|
+
calls: () => calls,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('SyncScheduler', () => {
|
|
78
|
+
it('runs once on start and installs the periodic interval', async () => {
|
|
79
|
+
const environment = fakeEnvironment();
|
|
80
|
+
const run = vi.fn(async () => Promise.resolve());
|
|
81
|
+
const scheduler = new SyncScheduler(run, environment, { intervalMs: 1000 });
|
|
82
|
+
|
|
83
|
+
scheduler.start();
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
|
|
86
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(environment.intervalCount()).toBe(1);
|
|
88
|
+
expect(environment.lastIntervalMs()).toBe(1000);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('does not start a run or an interval while the app is backgrounded', async () => {
|
|
92
|
+
const environment = fakeEnvironment(false);
|
|
93
|
+
const run = vi.fn(async () => Promise.resolve());
|
|
94
|
+
const scheduler = new SyncScheduler(run, environment);
|
|
95
|
+
|
|
96
|
+
scheduler.start();
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
|
|
99
|
+
expect(run).not.toHaveBeenCalled();
|
|
100
|
+
expect(environment.intervalCount()).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('runs when the app returns to the foreground and when connectivity returns', async () => {
|
|
104
|
+
const environment = fakeEnvironment(false);
|
|
105
|
+
const run = vi.fn(async () => Promise.resolve());
|
|
106
|
+
const scheduler = new SyncScheduler(run, environment);
|
|
107
|
+
|
|
108
|
+
scheduler.start();
|
|
109
|
+
environment.setActive(true);
|
|
110
|
+
await scheduler.trigger();
|
|
111
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
112
|
+
expect(environment.intervalCount()).toBe(1);
|
|
113
|
+
|
|
114
|
+
environment.goOnline();
|
|
115
|
+
await scheduler.trigger();
|
|
116
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('ignores connectivity while the app is backgrounded', async () => {
|
|
120
|
+
const environment = fakeEnvironment(false);
|
|
121
|
+
const run = vi.fn(async () => Promise.resolve());
|
|
122
|
+
new SyncScheduler(run, environment).start();
|
|
123
|
+
|
|
124
|
+
environment.goOnline();
|
|
125
|
+
await Promise.resolve();
|
|
126
|
+
|
|
127
|
+
expect(run).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reports recovery signals before triggering a scheduler run', async () => {
|
|
131
|
+
const environment = fakeEnvironment(false);
|
|
132
|
+
const calls: string[] = [];
|
|
133
|
+
const scheduler = new SyncScheduler(
|
|
134
|
+
() => {
|
|
135
|
+
calls.push('run');
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
},
|
|
138
|
+
environment,
|
|
139
|
+
{ onRecoverySignal: (signal) => calls.push(signal) },
|
|
140
|
+
);
|
|
141
|
+
scheduler.start();
|
|
142
|
+
|
|
143
|
+
environment.setActive(true);
|
|
144
|
+
await scheduler.trigger();
|
|
145
|
+
environment.goOnline();
|
|
146
|
+
await scheduler.trigger();
|
|
147
|
+
|
|
148
|
+
expect(calls).toEqual(['active', 'run', 'online', 'run']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports an online recovery signal while backgrounded without starting a run', async () => {
|
|
152
|
+
const environment = fakeEnvironment(false);
|
|
153
|
+
const onRecoverySignal = vi.fn();
|
|
154
|
+
const run = vi.fn(() => Promise.resolve());
|
|
155
|
+
new SyncScheduler(run, environment, { onRecoverySignal }).start();
|
|
156
|
+
|
|
157
|
+
environment.goOnline();
|
|
158
|
+
await Promise.resolve();
|
|
159
|
+
|
|
160
|
+
expect(onRecoverySignal).toHaveBeenCalledWith('online');
|
|
161
|
+
expect(run).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('runs on every interval tick', async () => {
|
|
165
|
+
const environment = fakeEnvironment();
|
|
166
|
+
const run = vi.fn(async () => Promise.resolve());
|
|
167
|
+
const scheduler = new SyncScheduler(run, environment, { intervalMs: 1000 });
|
|
168
|
+
|
|
169
|
+
scheduler.start();
|
|
170
|
+
await scheduler.trigger();
|
|
171
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
172
|
+
|
|
173
|
+
environment.fireInterval();
|
|
174
|
+
await scheduler.trigger();
|
|
175
|
+
|
|
176
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('coalesces a manual trigger that overlaps the active run', async () => {
|
|
180
|
+
const { run, finish, calls } = deferredRun();
|
|
181
|
+
const scheduler = new SyncScheduler(run, fakeEnvironment());
|
|
182
|
+
|
|
183
|
+
scheduler.start();
|
|
184
|
+
const manual = scheduler.trigger();
|
|
185
|
+
await Promise.resolve();
|
|
186
|
+
|
|
187
|
+
expect(calls()).toBe(1);
|
|
188
|
+
finish();
|
|
189
|
+
await manual;
|
|
190
|
+
expect(calls()).toBe(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('queues exactly one follow-up when writes arrive during a run', async () => {
|
|
194
|
+
const { run, finish, calls } = deferredRun();
|
|
195
|
+
const scheduler = new SyncScheduler(run, fakeEnvironment(false));
|
|
196
|
+
scheduler.start();
|
|
197
|
+
|
|
198
|
+
const first = scheduler.trigger();
|
|
199
|
+
await Promise.resolve();
|
|
200
|
+
const second = scheduler.requestFollowUp();
|
|
201
|
+
const third = scheduler.requestFollowUp();
|
|
202
|
+
expect(calls()).toBe(1);
|
|
203
|
+
|
|
204
|
+
finish();
|
|
205
|
+
await Promise.resolve();
|
|
206
|
+
await Promise.resolve();
|
|
207
|
+
expect(calls()).toBe(2);
|
|
208
|
+
|
|
209
|
+
finish();
|
|
210
|
+
await Promise.all([first, second, third]);
|
|
211
|
+
expect(calls()).toBe(2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('observes a follow-up requested from inside the run without going parallel', async () => {
|
|
215
|
+
const environment = fakeEnvironment(false);
|
|
216
|
+
let active = 0;
|
|
217
|
+
let maximumActive = 0;
|
|
218
|
+
let calls = 0;
|
|
219
|
+
const run = async (): Promise<void> => {
|
|
220
|
+
calls += 1;
|
|
221
|
+
active += 1;
|
|
222
|
+
maximumActive = Math.max(maximumActive, active);
|
|
223
|
+
if (calls === 1) void scheduler.requestFollowUp();
|
|
224
|
+
await Promise.resolve();
|
|
225
|
+
active -= 1;
|
|
226
|
+
};
|
|
227
|
+
const scheduler: SyncScheduler = new SyncScheduler(run, environment);
|
|
228
|
+
|
|
229
|
+
await scheduler.trigger();
|
|
230
|
+
|
|
231
|
+
expect(calls).toBe(2);
|
|
232
|
+
expect(maximumActive).toBe(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('drops a queued follow-up when scheduling stops during the run', async () => {
|
|
236
|
+
const { run, finish, calls } = deferredRun();
|
|
237
|
+
const scheduler = new SyncScheduler(run, fakeEnvironment());
|
|
238
|
+
scheduler.start();
|
|
239
|
+
await Promise.resolve();
|
|
240
|
+
|
|
241
|
+
void scheduler.requestFollowUp();
|
|
242
|
+
scheduler.stop();
|
|
243
|
+
finish();
|
|
244
|
+
await Promise.resolve();
|
|
245
|
+
await Promise.resolve();
|
|
246
|
+
|
|
247
|
+
expect(calls()).toBe(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('reports a failing run to onError and keeps scheduling', async () => {
|
|
251
|
+
const onError = vi.fn();
|
|
252
|
+
const failure = new Error('offline');
|
|
253
|
+
const run = vi.fn(async () => {
|
|
254
|
+
await Promise.resolve();
|
|
255
|
+
throw failure;
|
|
256
|
+
});
|
|
257
|
+
const scheduler = new SyncScheduler(run, fakeEnvironment(false), { onError });
|
|
258
|
+
|
|
259
|
+
await scheduler.trigger();
|
|
260
|
+
await scheduler.trigger();
|
|
261
|
+
|
|
262
|
+
expect(onError).toHaveBeenCalledTimes(2);
|
|
263
|
+
expect(onError).toHaveBeenCalledWith(failure);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('stop clears the interval and every subscription', async () => {
|
|
267
|
+
const environment = fakeEnvironment();
|
|
268
|
+
const run = vi.fn(async () => Promise.resolve());
|
|
269
|
+
const scheduler = new SyncScheduler(run, environment);
|
|
270
|
+
|
|
271
|
+
scheduler.start();
|
|
272
|
+
await Promise.resolve();
|
|
273
|
+
scheduler.stop();
|
|
274
|
+
environment.setActive(true);
|
|
275
|
+
environment.goOnline();
|
|
276
|
+
await Promise.resolve();
|
|
277
|
+
|
|
278
|
+
expect(environment.intervalCount()).toBe(0);
|
|
279
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('start is idempotent', async () => {
|
|
283
|
+
const environment = fakeEnvironment();
|
|
284
|
+
const run = vi.fn(async () => Promise.resolve());
|
|
285
|
+
const scheduler = new SyncScheduler(run, environment);
|
|
286
|
+
|
|
287
|
+
scheduler.start();
|
|
288
|
+
scheduler.start();
|
|
289
|
+
await Promise.resolve();
|
|
290
|
+
await Promise.resolve();
|
|
291
|
+
|
|
292
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(environment.intervalCount()).toBe(1);
|
|
294
|
+
});
|
|
295
|
+
});
|