@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
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host capabilities the scheduler needs. Products supply timers, foreground
|
|
3
|
+
* state, and connectivity; baukit never reaches for a global.
|
|
4
|
+
*/
|
|
5
|
+
export interface SyncSchedulerEnvironment {
|
|
6
|
+
isActive(): boolean;
|
|
7
|
+
subscribeActive(listener: (active: boolean) => void): () => void;
|
|
8
|
+
subscribeOnline(listener: () => void): () => void;
|
|
9
|
+
setInterval(callback: () => void, milliseconds: number): SyncSchedulerTimer;
|
|
10
|
+
clearInterval(handle: SyncSchedulerTimer): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Opaque timer handle returned by the host's `setInterval`. Baukit only stores
|
|
15
|
+
* it and hands it back to `clearInterval`; its runtime shape is host-defined.
|
|
16
|
+
*/
|
|
17
|
+
export type SyncSchedulerTimer = { readonly __syncSchedulerTimer?: never } & object;
|
|
18
|
+
|
|
19
|
+
/** A retry the scheduler is about to wait for. */
|
|
20
|
+
export interface SyncSchedulerRetry {
|
|
21
|
+
readonly error: unknown;
|
|
22
|
+
/** Counts retries since the last success or wake-up, from 0. */
|
|
23
|
+
readonly retryIndex: number;
|
|
24
|
+
readonly delayMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Retries a failed run after a delay the product chooses. */
|
|
28
|
+
export interface SyncSchedulerRetryOptions {
|
|
29
|
+
/** Retries after a failed run before the scheduler waits for the next trigger. */
|
|
30
|
+
readonly maxRetries: number;
|
|
31
|
+
/**
|
|
32
|
+
* The delay before retry `retryIndex`, or `null` when the failure must not be retried.
|
|
33
|
+
* `fullJitterBackoffMs` from `@baukit/api-runtime/backoff` gives bounded, jittered delays.
|
|
34
|
+
*/
|
|
35
|
+
readonly delayMs: (error: unknown, retryIndex: number) => number | null;
|
|
36
|
+
/** Runs before each wait, for example to show when the next attempt starts. */
|
|
37
|
+
readonly onRetryScheduled?: (retry: SyncSchedulerRetry) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SyncSchedulerOptions {
|
|
41
|
+
intervalMs?: number;
|
|
42
|
+
onError?: (error: unknown) => void;
|
|
43
|
+
onRecoverySignal?: (signal: SyncSchedulerRecoverySignal) => void;
|
|
44
|
+
retry?: SyncSchedulerRetryOptions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SyncSchedulerRecoverySignal = 'active' | 'online';
|
|
48
|
+
|
|
49
|
+
type RetryWake = 'elapsed' | 'triggered' | 'stopped';
|
|
50
|
+
|
|
51
|
+
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
function noop(): void {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateRetry(retry: SyncSchedulerRetryOptions | undefined): void {
|
|
58
|
+
if (retry === undefined) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!Number.isInteger(retry.maxRetries) || retry.maxRetries < 0) {
|
|
62
|
+
throw new RangeError('retry.maxRetries must be a non-negative integer');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Runs one opaque sync callback at most once at a time.
|
|
68
|
+
*
|
|
69
|
+
* A trigger that arrives while a run is active joins that run. A follow-up
|
|
70
|
+
* requested during a run replays the callback once after it settles, so writes
|
|
71
|
+
* made mid-run are never left unsent without starting a parallel run. With
|
|
72
|
+
* `retry`, a failed run is retried after the chosen delay; a trigger during
|
|
73
|
+
* that delay ends it and restarts the retry count.
|
|
74
|
+
*/
|
|
75
|
+
export class SyncScheduler {
|
|
76
|
+
private readonly intervalMs: number;
|
|
77
|
+
private readonly onError: (error: unknown) => void;
|
|
78
|
+
private readonly onRecoverySignal: (signal: SyncSchedulerRecoverySignal) => void;
|
|
79
|
+
private readonly retry: SyncSchedulerRetryOptions | undefined;
|
|
80
|
+
private active = false;
|
|
81
|
+
private started = false;
|
|
82
|
+
private inFlight: Promise<void> | null = null;
|
|
83
|
+
private rerunRequested = false;
|
|
84
|
+
private interval: SyncSchedulerTimer | null = null;
|
|
85
|
+
private subscriptions: (() => void)[] = [];
|
|
86
|
+
private wakeRetry: ((reason: RetryWake) => void) | null = null;
|
|
87
|
+
private stops = 0;
|
|
88
|
+
|
|
89
|
+
constructor(
|
|
90
|
+
private readonly run: () => Promise<unknown>,
|
|
91
|
+
private readonly environment: SyncSchedulerEnvironment,
|
|
92
|
+
options: SyncSchedulerOptions = {},
|
|
93
|
+
) {
|
|
94
|
+
validateRetry(options.retry);
|
|
95
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
96
|
+
this.onError = options.onError ?? noop;
|
|
97
|
+
this.onRecoverySignal = options.onRecoverySignal ?? noop;
|
|
98
|
+
this.retry = options.retry;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
start(): void {
|
|
102
|
+
if (this.started) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.started = true;
|
|
106
|
+
this.active = this.environment.isActive();
|
|
107
|
+
this.subscriptions = [
|
|
108
|
+
this.environment.subscribeActive((active) => {
|
|
109
|
+
this.active = active;
|
|
110
|
+
this.refreshInterval();
|
|
111
|
+
if (active) {
|
|
112
|
+
this.onRecoverySignal('active');
|
|
113
|
+
void this.trigger();
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
this.environment.subscribeOnline(() => {
|
|
117
|
+
this.onRecoverySignal('online');
|
|
118
|
+
if (this.active) {
|
|
119
|
+
void this.trigger();
|
|
120
|
+
}
|
|
121
|
+
}),
|
|
122
|
+
];
|
|
123
|
+
this.refreshInterval();
|
|
124
|
+
if (this.active) {
|
|
125
|
+
void this.trigger();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop(): void {
|
|
130
|
+
this.started = false;
|
|
131
|
+
this.stops += 1;
|
|
132
|
+
this.rerunRequested = false;
|
|
133
|
+
this.wakeRetry?.('stopped');
|
|
134
|
+
this.stopInterval();
|
|
135
|
+
this.subscriptions.forEach((unsubscribe) => {
|
|
136
|
+
unsubscribe();
|
|
137
|
+
});
|
|
138
|
+
this.subscriptions = [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Starts a run, or joins the active one and ends its retry delay. */
|
|
142
|
+
trigger(): Promise<void> {
|
|
143
|
+
if (this.inFlight) {
|
|
144
|
+
this.wakeRetry?.('triggered');
|
|
145
|
+
return this.inFlight;
|
|
146
|
+
}
|
|
147
|
+
const task: Promise<void> = Promise.resolve()
|
|
148
|
+
.then(() => this.runUntilSettled())
|
|
149
|
+
.finally(() => {
|
|
150
|
+
if (this.inFlight === task) {
|
|
151
|
+
this.inFlight = null;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
this.inFlight = task;
|
|
155
|
+
return task;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Queues exactly one more run when a run is already active. */
|
|
159
|
+
requestFollowUp(): Promise<void> {
|
|
160
|
+
if (this.inFlight) {
|
|
161
|
+
this.rerunRequested = true;
|
|
162
|
+
return this.inFlight;
|
|
163
|
+
}
|
|
164
|
+
return this.trigger();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async runUntilSettled(): Promise<void> {
|
|
168
|
+
const stopsAtStart = this.stops;
|
|
169
|
+
let retryIndex = 0;
|
|
170
|
+
for (;;) {
|
|
171
|
+
this.rerunRequested = false;
|
|
172
|
+
const failure = await this.runOnce();
|
|
173
|
+
if (failure === null) {
|
|
174
|
+
retryIndex = 0;
|
|
175
|
+
if (!this.followUpRequested()) return;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const delayMs = this.retryDelay(failure.error, retryIndex);
|
|
179
|
+
if (delayMs === null) {
|
|
180
|
+
if (!this.followUpRequested()) return;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
this.retry?.onRetryScheduled?.({ error: failure.error, retryIndex, delayMs });
|
|
184
|
+
const wake = await this.waitForRetry(delayMs);
|
|
185
|
+
const stopped = this.stops !== stopsAtStart;
|
|
186
|
+
if (stopped || (wake === 'elapsed' && this.started && !this.active)) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
retryIndex = wake === 'triggered' ? 0 : retryIndex + 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Read through a method: a follow-up can arrive while `run` is awaited. */
|
|
194
|
+
private followUpRequested(): boolean {
|
|
195
|
+
return this.rerunRequested;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async runOnce(): Promise<{ readonly error: unknown } | null> {
|
|
199
|
+
try {
|
|
200
|
+
await this.run();
|
|
201
|
+
return null;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
this.onError(error);
|
|
204
|
+
return { error };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private retryDelay(error: unknown, retryIndex: number): number | null {
|
|
209
|
+
if (this.retry === undefined || retryIndex >= this.retry.maxRetries) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
let delayMs: number | null;
|
|
213
|
+
try {
|
|
214
|
+
delayMs = this.retry.delayMs(error, retryIndex);
|
|
215
|
+
} catch (delayError) {
|
|
216
|
+
this.onError(delayError);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
if (delayMs !== null && (!Number.isFinite(delayMs) || delayMs < 0)) {
|
|
220
|
+
this.onError(new RangeError('retry.delayMs must return a finite non-negative number'));
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
return delayMs;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The environment has interval timers only, so the delay clears its interval on the first tick. */
|
|
227
|
+
private waitForRetry(delayMs: number): Promise<RetryWake> {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const wake = (reason: RetryWake) => {
|
|
230
|
+
this.environment.clearInterval(timer);
|
|
231
|
+
if (this.wakeRetry === wake) {
|
|
232
|
+
this.wakeRetry = null;
|
|
233
|
+
}
|
|
234
|
+
resolve(reason);
|
|
235
|
+
};
|
|
236
|
+
const timer = this.environment.setInterval(() => {
|
|
237
|
+
wake('elapsed');
|
|
238
|
+
}, delayMs);
|
|
239
|
+
this.wakeRetry = wake;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private refreshInterval(): void {
|
|
244
|
+
this.stopInterval();
|
|
245
|
+
if (!this.started || !this.active) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
this.interval = this.environment.setInterval(() => {
|
|
249
|
+
void this.trigger();
|
|
250
|
+
}, this.intervalMs);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private stopInterval(): void {
|
|
254
|
+
if (this.interval === null) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.environment.clearInterval(this.interval);
|
|
258
|
+
this.interval = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
deriveInitialSyncState,
|
|
5
|
+
deriveLocalStoreReadiness,
|
|
6
|
+
SyncStatusStore,
|
|
7
|
+
type SyncAttentionItem,
|
|
8
|
+
} from './store.js';
|
|
9
|
+
|
|
10
|
+
const conflict: SyncAttentionItem = { entityType: 'parent', entityId: 'parent-1' };
|
|
11
|
+
const firstAttemptAt = '2026-08-22T09:59:00Z';
|
|
12
|
+
const firstSuccessAt = '2026-08-22T10:00:00Z';
|
|
13
|
+
const failedAttemptAt = '2026-08-22T10:05:00Z';
|
|
14
|
+
|
|
15
|
+
describe('SyncStatusStore', () => {
|
|
16
|
+
it('starts uninitialized and idle', () => {
|
|
17
|
+
expect(new SyncStatusStore().getSnapshot()).toEqual({
|
|
18
|
+
status: 'idle',
|
|
19
|
+
lastAttemptAt: null,
|
|
20
|
+
lastSuccessAt: null,
|
|
21
|
+
lastSyncAt: null,
|
|
22
|
+
error: null,
|
|
23
|
+
failure: null,
|
|
24
|
+
retrying: false,
|
|
25
|
+
retryAt: null,
|
|
26
|
+
attention: [],
|
|
27
|
+
pendingCount: 0,
|
|
28
|
+
initialPullStatus: 'uninitialized',
|
|
29
|
+
refreshRevision: 0,
|
|
30
|
+
securityBlock: null,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('treats the first run as the initial pull and later runs as settled', () => {
|
|
35
|
+
const store = new SyncStatusStore();
|
|
36
|
+
|
|
37
|
+
store.setSyncing(firstAttemptAt);
|
|
38
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'syncing', initialPullStatus: 'pulling' });
|
|
39
|
+
|
|
40
|
+
store.setIdle(firstSuccessAt);
|
|
41
|
+
store.setSyncing(failedAttemptAt);
|
|
42
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'syncing', initialPullStatus: 'settled' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('clears attention and pending work on a successful run', () => {
|
|
46
|
+
const store = new SyncStatusStore();
|
|
47
|
+
store.setAttention([conflict], 3);
|
|
48
|
+
|
|
49
|
+
store.setIdle(firstSuccessAt);
|
|
50
|
+
|
|
51
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
52
|
+
status: 'idle',
|
|
53
|
+
lastAttemptAt: firstSuccessAt,
|
|
54
|
+
lastSuccessAt: firstSuccessAt,
|
|
55
|
+
lastSyncAt: firstSuccessAt,
|
|
56
|
+
attention: [],
|
|
57
|
+
pendingCount: 0,
|
|
58
|
+
error: null,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('reports pending when work remains but nothing is actionable', () => {
|
|
63
|
+
const store = new SyncStatusStore();
|
|
64
|
+
|
|
65
|
+
store.setAttention([], 2);
|
|
66
|
+
|
|
67
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'pending', pendingCount: 2 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reports attention when an actionable rejection exists', () => {
|
|
71
|
+
const store = new SyncStatusStore();
|
|
72
|
+
|
|
73
|
+
store.setAttention([conflict], 1);
|
|
74
|
+
|
|
75
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'attention', attention: [conflict] });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('keeps a product-defined attention item intact', () => {
|
|
79
|
+
type ProductAttention = SyncAttentionItem<{
|
|
80
|
+
object_entity_type: string;
|
|
81
|
+
object_entity_id: string;
|
|
82
|
+
reasons: readonly string[];
|
|
83
|
+
}>;
|
|
84
|
+
const item: ProductAttention = {
|
|
85
|
+
object_entity_type: 'workout_sessions',
|
|
86
|
+
object_entity_id: 'session-1',
|
|
87
|
+
reasons: ['future_server_rule'],
|
|
88
|
+
};
|
|
89
|
+
const store = new SyncStatusStore<ProductAttention>();
|
|
90
|
+
|
|
91
|
+
store.setAttention([item], 1);
|
|
92
|
+
|
|
93
|
+
expect(store.getSnapshot().attention).toEqual([item]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('keeps attention ahead of a transport failure', () => {
|
|
97
|
+
const store = new SyncStatusStore();
|
|
98
|
+
store.setAttention([conflict], 1);
|
|
99
|
+
|
|
100
|
+
store.setFailure({ kind: 'network' }, 'network down');
|
|
101
|
+
|
|
102
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'attention', error: 'network down' });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('reports pending rather than error while unsent work remains', () => {
|
|
106
|
+
const store = new SyncStatusStore();
|
|
107
|
+
|
|
108
|
+
store.setFailure({ kind: 'network' }, 'network down', { pendingCount: 4 });
|
|
109
|
+
|
|
110
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'pending', pendingCount: 4 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reports error only when nothing is pending or actionable', () => {
|
|
114
|
+
const store = new SyncStatusStore();
|
|
115
|
+
|
|
116
|
+
store.setFailure({ kind: 'network' }, 'network down', { pendingCount: 0 });
|
|
117
|
+
|
|
118
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'error', pendingCount: 0 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps the previous pending count when setFailure omits one', () => {
|
|
122
|
+
const store = new SyncStatusStore();
|
|
123
|
+
store.setAttention([], 5);
|
|
124
|
+
|
|
125
|
+
store.setFailure({ kind: 'network' }, 'network down');
|
|
126
|
+
|
|
127
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'pending', pendingCount: 5 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('advances only the attempt timestamp when a retry fails', () => {
|
|
131
|
+
const store = new SyncStatusStore();
|
|
132
|
+
store.setIdle(firstSuccessAt);
|
|
133
|
+
store.setSyncing(failedAttemptAt);
|
|
134
|
+
|
|
135
|
+
store.setFailure({ kind: 'network' }, 'offline', { attemptAt: failedAttemptAt });
|
|
136
|
+
|
|
137
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
138
|
+
lastAttemptAt: failedAttemptAt,
|
|
139
|
+
lastSuccessAt: firstSuccessAt,
|
|
140
|
+
lastSyncAt: firstSuccessAt,
|
|
141
|
+
failure: { kind: 'network' },
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it.each(['network', 'server', 'local_apply'] as const)(
|
|
146
|
+
'keeps pending work visible after a %s failure',
|
|
147
|
+
(kind) => {
|
|
148
|
+
const store = new SyncStatusStore();
|
|
149
|
+
store.setAttention([], 4);
|
|
150
|
+
|
|
151
|
+
store.setFailure({ kind }, 'run failed', { attemptAt: failedAttemptAt });
|
|
152
|
+
|
|
153
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
154
|
+
status: 'pending',
|
|
155
|
+
pendingCount: 4,
|
|
156
|
+
failure: { kind },
|
|
157
|
+
lastSuccessAt: null,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
it('exposes a rate limit and its scheduled retry without reporting offline', () => {
|
|
163
|
+
const store = new SyncStatusStore();
|
|
164
|
+
const retryAt = '2026-08-22T10:06:00Z';
|
|
165
|
+
store.setSyncing(firstAttemptAt);
|
|
166
|
+
|
|
167
|
+
store.setFailure({ kind: 'rate_limited', retryAt }, 'too many requests', {
|
|
168
|
+
attemptAt: failedAttemptAt,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
172
|
+
failure: { kind: 'rate_limited', retryAt },
|
|
173
|
+
retrying: true,
|
|
174
|
+
retryAt,
|
|
175
|
+
});
|
|
176
|
+
expect(deriveInitialSyncState(store.getSnapshot())).toBe('sync-error-cached');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('records a scheduled network retry separately from its failure', () => {
|
|
180
|
+
const store = new SyncStatusStore();
|
|
181
|
+
store.setFailure({ kind: 'network' }, 'offline', {
|
|
182
|
+
pendingCount: 2,
|
|
183
|
+
attemptAt: failedAttemptAt,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
store.setRetrying('2026-08-22T10:06:00Z');
|
|
187
|
+
|
|
188
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
189
|
+
status: 'pending',
|
|
190
|
+
failure: { kind: 'network' },
|
|
191
|
+
retrying: true,
|
|
192
|
+
retryAt: '2026-08-22T10:06:00Z',
|
|
193
|
+
pendingCount: 2,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('resumes the settled status after re-authentication', () => {
|
|
198
|
+
const store = new SyncStatusStore();
|
|
199
|
+
store.setAttention([], 2);
|
|
200
|
+
store.setAuth('sign in again');
|
|
201
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
202
|
+
status: 'auth',
|
|
203
|
+
error: 'sign in again',
|
|
204
|
+
failure: { kind: 'auth' },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
store.resumeAfterAuth();
|
|
208
|
+
|
|
209
|
+
expect(store.getSnapshot()).toMatchObject({ status: 'pending', error: null });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('hydrates a cold start from persisted status', () => {
|
|
213
|
+
const store = new SyncStatusStore();
|
|
214
|
+
|
|
215
|
+
store.hydrate({
|
|
216
|
+
lastAttemptAt: '2026-08-22T09:01:00Z',
|
|
217
|
+
lastSuccessAt: '2026-08-22T09:00:00Z',
|
|
218
|
+
attention: [conflict],
|
|
219
|
+
pendingCount: 1,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
223
|
+
status: 'attention',
|
|
224
|
+
lastAttemptAt: '2026-08-22T09:01:00Z',
|
|
225
|
+
lastSuccessAt: '2026-08-22T09:00:00Z',
|
|
226
|
+
lastSyncAt: '2026-08-22T09:00:00Z',
|
|
227
|
+
initialPullStatus: 'settled',
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('stays uninitialized when hydrating a store that never synced', () => {
|
|
232
|
+
const store = new SyncStatusStore();
|
|
233
|
+
|
|
234
|
+
store.hydrate({ lastAttemptAt: null, lastSuccessAt: null });
|
|
235
|
+
|
|
236
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
237
|
+
status: 'idle',
|
|
238
|
+
initialPullStatus: 'uninitialized',
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('records a security block without losing the message', () => {
|
|
243
|
+
const store = new SyncStatusStore();
|
|
244
|
+
|
|
245
|
+
store.setSecurityBlock('database belongs to another account', {
|
|
246
|
+
kind: 'partition_mismatch',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
250
|
+
status: 'error',
|
|
251
|
+
failure: { kind: 'partition_mismatch' },
|
|
252
|
+
securityBlock: 'database belongs to another account',
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('reset returns the initial snapshot', () => {
|
|
257
|
+
const store = new SyncStatusStore();
|
|
258
|
+
store.setAttention([conflict], 3);
|
|
259
|
+
|
|
260
|
+
store.reset();
|
|
261
|
+
|
|
262
|
+
expect(store.getSnapshot()).toMatchObject({
|
|
263
|
+
status: 'idle',
|
|
264
|
+
attention: [],
|
|
265
|
+
refreshRevision: 0,
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('bumps refreshRevision on every delivered snapshot', () => {
|
|
270
|
+
const store = new SyncStatusStore();
|
|
271
|
+
|
|
272
|
+
store.setIdle(firstSuccessAt);
|
|
273
|
+
store.setAttention([], 1);
|
|
274
|
+
|
|
275
|
+
expect(store.getSnapshot().refreshRevision).toBe(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('does not bump refreshRevision when a run merely starts', () => {
|
|
279
|
+
const store = new SyncStatusStore();
|
|
280
|
+
|
|
281
|
+
store.setSyncing();
|
|
282
|
+
|
|
283
|
+
expect(store.getSnapshot().refreshRevision).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('notifies subscribers until they unsubscribe', () => {
|
|
287
|
+
const store = new SyncStatusStore();
|
|
288
|
+
const listener = vi.fn();
|
|
289
|
+
const unsubscribe = store.subscribe(listener);
|
|
290
|
+
|
|
291
|
+
store.setSyncing();
|
|
292
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(listener).toHaveBeenCalledWith(store.getSnapshot());
|
|
294
|
+
|
|
295
|
+
unsubscribe();
|
|
296
|
+
store.setIdle(firstSuccessAt);
|
|
297
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('deriveLocalStoreReadiness', () => {
|
|
302
|
+
it('stays uninitialized before the first pull starts', () => {
|
|
303
|
+
expect(
|
|
304
|
+
deriveLocalStoreReadiness({
|
|
305
|
+
initialPullStatus: 'uninitialized',
|
|
306
|
+
refreshRevision: 0,
|
|
307
|
+
deliveredRevision: 0,
|
|
308
|
+
hasData: true,
|
|
309
|
+
}),
|
|
310
|
+
).toBe('uninitialized');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('stays pulling while the initial pull runs', () => {
|
|
314
|
+
expect(
|
|
315
|
+
deriveLocalStoreReadiness({
|
|
316
|
+
initialPullStatus: 'pulling',
|
|
317
|
+
refreshRevision: 1,
|
|
318
|
+
deliveredRevision: 1,
|
|
319
|
+
hasData: false,
|
|
320
|
+
}),
|
|
321
|
+
).toBe('pulling');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('stays pulling while the consumer still holds a stale snapshot', () => {
|
|
325
|
+
expect(
|
|
326
|
+
deriveLocalStoreReadiness({
|
|
327
|
+
initialPullStatus: 'settled',
|
|
328
|
+
refreshRevision: 2,
|
|
329
|
+
deliveredRevision: 1,
|
|
330
|
+
hasData: false,
|
|
331
|
+
}),
|
|
332
|
+
).toBe('pulling');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('reports an empty state only once the newest snapshot is delivered', () => {
|
|
336
|
+
expect(
|
|
337
|
+
deriveLocalStoreReadiness({
|
|
338
|
+
initialPullStatus: 'settled',
|
|
339
|
+
refreshRevision: 2,
|
|
340
|
+
deliveredRevision: 2,
|
|
341
|
+
hasData: false,
|
|
342
|
+
}),
|
|
343
|
+
).toBe('hydrated-empty');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('reports a populated store', () => {
|
|
347
|
+
expect(
|
|
348
|
+
deriveLocalStoreReadiness({
|
|
349
|
+
initialPullStatus: 'settled',
|
|
350
|
+
refreshRevision: 2,
|
|
351
|
+
deliveredRevision: 2,
|
|
352
|
+
hasData: true,
|
|
353
|
+
}),
|
|
354
|
+
).toBe('hydrated-populated');
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('deriveInitialSyncState', () => {
|
|
359
|
+
it('maps an unstarted pull to unknown', () => {
|
|
360
|
+
expect(
|
|
361
|
+
deriveInitialSyncState({
|
|
362
|
+
initialPullStatus: 'uninitialized',
|
|
363
|
+
lastSuccessAt: null,
|
|
364
|
+
failure: null,
|
|
365
|
+
status: 'idle',
|
|
366
|
+
}),
|
|
367
|
+
).toBe('unknown');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('maps a running initial pull to syncing', () => {
|
|
371
|
+
expect(
|
|
372
|
+
deriveInitialSyncState({
|
|
373
|
+
initialPullStatus: 'pulling',
|
|
374
|
+
lastSuccessAt: null,
|
|
375
|
+
failure: null,
|
|
376
|
+
status: 'syncing',
|
|
377
|
+
}),
|
|
378
|
+
).toBe('syncing');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('maps a first-run failure to offline-cached', () => {
|
|
382
|
+
expect(
|
|
383
|
+
deriveInitialSyncState({
|
|
384
|
+
initialPullStatus: 'settled',
|
|
385
|
+
lastSuccessAt: null,
|
|
386
|
+
failure: { kind: 'network' },
|
|
387
|
+
status: 'error',
|
|
388
|
+
}),
|
|
389
|
+
).toBe('offline-cached');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('maps a failure after a past success to settled', () => {
|
|
393
|
+
expect(
|
|
394
|
+
deriveInitialSyncState({
|
|
395
|
+
initialPullStatus: 'settled',
|
|
396
|
+
lastSuccessAt: '2026-08-22T09:00:00Z',
|
|
397
|
+
failure: { kind: 'server' },
|
|
398
|
+
status: 'error',
|
|
399
|
+
}),
|
|
400
|
+
).toBe('settled');
|
|
401
|
+
});
|
|
402
|
+
});
|