@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/src/store.ts ADDED
@@ -0,0 +1,336 @@
1
+ import type { SyncFailure } from './error.js';
2
+
3
+ /** Sync activity, as named by the offline-readiness contract. */
4
+ export type SyncStatus = 'idle' | 'syncing' | 'pending' | 'attention' | 'auth' | 'error';
5
+
6
+ /** Progress of the initial pull that a cold start must complete. */
7
+ export type InitialPullStatus = 'uninitialized' | 'pulling' | 'settled';
8
+
9
+ /** Readiness of the local store, derived from pull progress and delivery. */
10
+ export type LocalStoreReadiness =
11
+ 'uninitialized' | 'pulling' | 'hydrated-empty' | 'hydrated-populated';
12
+
13
+ /** Initial readiness as the offline-readiness contract names it. */
14
+ export type InitialSyncState =
15
+ 'unknown' | 'syncing' | 'offline-cached' | 'sync-error-cached' | 'settled';
16
+
17
+ /**
18
+ * One rejection that needs product or user action. `entityType` and `entityId`
19
+ * are opaque to baukit; products name their own entities.
20
+ */
21
+ export type SyncAttentionItem<
22
+ T = {
23
+ entityType: string;
24
+ entityId: string;
25
+ },
26
+ > = T;
27
+
28
+ export interface SyncStatusSnapshot<TAttention = SyncAttentionItem> {
29
+ status: SyncStatus;
30
+ lastAttemptAt: string | null;
31
+ lastSuccessAt: string | null;
32
+ /** @deprecated Use `lastSuccessAt`. This field will be removed after one release cycle. */
33
+ lastSyncAt: string | null;
34
+ error: string | null;
35
+ failure: SyncFailure | null;
36
+ retrying: boolean;
37
+ retryAt: string | null;
38
+ attention: readonly TAttention[];
39
+ pendingCount: number;
40
+ initialPullStatus: InitialPullStatus;
41
+ /** Increments whenever a run delivers a new authoritative snapshot. */
42
+ refreshRevision: number;
43
+ /** Set when identity or corruption makes the local store unusable. */
44
+ securityBlock: string | null;
45
+ }
46
+
47
+ export interface LocalStoreReadinessInput {
48
+ initialPullStatus: InitialPullStatus;
49
+ refreshRevision: number;
50
+ deliveredRevision: number | null;
51
+ hasData: boolean;
52
+ }
53
+
54
+ export interface SyncStatusHydration<TAttention = SyncAttentionItem> {
55
+ lastAttemptAt: string | null;
56
+ lastSuccessAt: string | null;
57
+ attention?: readonly TAttention[];
58
+ pendingCount?: number;
59
+ }
60
+
61
+ export interface SyncFailureUpdate {
62
+ /** Defaults to the store's current pending count. */
63
+ pendingCount?: number;
64
+ /** Defaults to the current time. */
65
+ attemptAt?: string;
66
+ /** Marks a retry as scheduled. Rate-limit failures use their own `retryAt`. */
67
+ retryAt?: string | null;
68
+ }
69
+
70
+ export interface SyncStatusStoreOptions {
71
+ clock?: () => string;
72
+ }
73
+
74
+ function initialSnapshot<TAttention>(): SyncStatusSnapshot<TAttention> {
75
+ return {
76
+ status: 'idle',
77
+ lastAttemptAt: null,
78
+ lastSuccessAt: null,
79
+ lastSyncAt: null,
80
+ error: null,
81
+ failure: null,
82
+ retrying: false,
83
+ retryAt: null,
84
+ attention: [],
85
+ pendingCount: 0,
86
+ initialPullStatus: 'uninitialized',
87
+ refreshRevision: 0,
88
+ securityBlock: null,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Maps pull progress and snapshot delivery onto local-store readiness.
94
+ *
95
+ * A consumer that has not yet received the newest `refreshRevision` still reads
96
+ * stale data, so it stays `pulling` rather than claiming a settled empty state.
97
+ */
98
+ export function deriveLocalStoreReadiness({
99
+ initialPullStatus,
100
+ refreshRevision,
101
+ deliveredRevision,
102
+ hasData,
103
+ }: LocalStoreReadinessInput): LocalStoreReadiness {
104
+ if (initialPullStatus === 'uninitialized') return 'uninitialized';
105
+ if (initialPullStatus === 'pulling' || deliveredRevision !== refreshRevision) return 'pulling';
106
+ return hasData ? 'hydrated-populated' : 'hydrated-empty';
107
+ }
108
+
109
+ /** Maps a snapshot onto the contract's initial-readiness vocabulary. */
110
+ export function deriveInitialSyncState({
111
+ initialPullStatus,
112
+ lastSuccessAt,
113
+ status,
114
+ failure,
115
+ }: Pick<
116
+ SyncStatusSnapshot,
117
+ 'failure' | 'initialPullStatus' | 'lastSuccessAt' | 'status'
118
+ >): InitialSyncState {
119
+ if (initialPullStatus === 'uninitialized') return 'unknown';
120
+ if (initialPullStatus === 'pulling') return 'syncing';
121
+ if (lastSuccessAt === null && failure !== null) {
122
+ return failure.kind === 'network' ? 'offline-cached' : 'sync-error-cached';
123
+ }
124
+ if (status === 'error' && lastSuccessAt === null) return 'sync-error-cached';
125
+ return 'settled';
126
+ }
127
+
128
+ function settledStatus(attentionCount: number, pendingCount: number): SyncStatus {
129
+ if (attentionCount > 0) return 'attention';
130
+ return pendingCount > 0 ? 'pending' : 'idle';
131
+ }
132
+
133
+ /**
134
+ * Observable sync status, with no React and no state library.
135
+ *
136
+ * Products subscribe directly, or adapt {@link SyncStatusStore.subscribe} and
137
+ * {@link SyncStatusStore.getSnapshot} to whatever their UI layer expects.
138
+ */
139
+ export class SyncStatusStore<TAttention = SyncAttentionItem> {
140
+ private snapshot: SyncStatusSnapshot<TAttention> = initialSnapshot();
141
+ private readonly listeners = new Set<(snapshot: SyncStatusSnapshot<TAttention>) => void>();
142
+ private readonly clock: () => string;
143
+
144
+ constructor(options: SyncStatusStoreOptions = {}) {
145
+ this.clock = options.clock ?? (() => new Date().toISOString());
146
+ }
147
+
148
+ getSnapshot(): SyncStatusSnapshot<TAttention> {
149
+ return this.snapshot;
150
+ }
151
+
152
+ subscribe(listener: (snapshot: SyncStatusSnapshot<TAttention>) => void): () => void {
153
+ this.listeners.add(listener);
154
+ return () => {
155
+ this.listeners.delete(listener);
156
+ };
157
+ }
158
+
159
+ setSyncing(attemptAt = this.clock()): void {
160
+ this.set((state) => ({
161
+ status: 'syncing',
162
+ lastAttemptAt: attemptAt,
163
+ error: null,
164
+ retryAt: null,
165
+ initialPullStatus: state.lastSuccessAt === null ? 'pulling' : 'settled',
166
+ }));
167
+ }
168
+
169
+ setIdle(successAt: string): void {
170
+ this.set((state) => ({
171
+ status: 'idle',
172
+ lastAttemptAt: successAt,
173
+ lastSuccessAt: successAt,
174
+ error: null,
175
+ failure: null,
176
+ retrying: false,
177
+ retryAt: null,
178
+ attention: [],
179
+ pendingCount: 0,
180
+ initialPullStatus: 'settled',
181
+ refreshRevision: state.refreshRevision + 1,
182
+ }));
183
+ }
184
+
185
+ setAttention(items: readonly TAttention[], pendingCount: number): void {
186
+ this.set((state) => ({
187
+ status: items.length > 0 ? 'attention' : 'pending',
188
+ error: null,
189
+ failure: null,
190
+ retrying: false,
191
+ retryAt: null,
192
+ attention: [...items],
193
+ pendingCount,
194
+ initialPullStatus: 'settled',
195
+ refreshRevision: state.refreshRevision + 1,
196
+ }));
197
+ }
198
+
199
+ setAuth(message: string, update: SyncFailureUpdate = {}): void {
200
+ this.setFailure({ kind: 'auth' }, message, update);
201
+ }
202
+
203
+ setFailure(failure: SyncFailure, message: string, update: SyncFailureUpdate = {}): void {
204
+ this.set((state) => {
205
+ const nextPendingCount = update.pendingCount ?? state.pendingCount;
206
+ const retryAt = failure.kind === 'rate_limited' ? failure.retryAt : (update.retryAt ?? null);
207
+ return {
208
+ status:
209
+ failure.kind === 'auth'
210
+ ? 'auth'
211
+ : state.attention.length > 0
212
+ ? 'attention'
213
+ : nextPendingCount > 0
214
+ ? 'pending'
215
+ : 'error',
216
+ lastAttemptAt: update.attemptAt ?? this.clock(),
217
+ error: message,
218
+ failure,
219
+ retrying: retryAt !== null,
220
+ retryAt,
221
+ pendingCount: nextPendingCount,
222
+ initialPullStatus: 'settled',
223
+ refreshRevision: state.refreshRevision + 1,
224
+ };
225
+ });
226
+ }
227
+
228
+ setRetrying(retryAt: string, pendingCount?: number): void {
229
+ this.set((state) => {
230
+ const nextPendingCount = pendingCount ?? state.pendingCount;
231
+ return {
232
+ status:
233
+ state.attention.length > 0 ? 'attention' : nextPendingCount > 0 ? 'pending' : 'error',
234
+ retrying: true,
235
+ retryAt,
236
+ pendingCount: nextPendingCount,
237
+ };
238
+ });
239
+ }
240
+
241
+ /** @deprecated Use `setFailure` with typed metadata. */
242
+ setError(message: string, pendingCount?: number): void {
243
+ this.setFailure(
244
+ { kind: 'network' },
245
+ message,
246
+ pendingCount === undefined ? {} : { pendingCount },
247
+ );
248
+ }
249
+
250
+ /** Restores persisted status on a cold start, before the first run. */
251
+ hydrate(hydration: SyncStatusHydration<TAttention>): void;
252
+ /** @deprecated Pass a `SyncStatusHydration` object with both timestamps. */
253
+ hydrate(lastSyncAt: string | null, items?: readonly TAttention[], pendingCount?: number): void;
254
+ hydrate(
255
+ hydrationOrLastSyncAt: SyncStatusHydration<TAttention> | string | null,
256
+ legacyItems: readonly TAttention[] = [],
257
+ legacyPendingCount = 0,
258
+ ): void {
259
+ const hydration: SyncStatusHydration<TAttention> =
260
+ typeof hydrationOrLastSyncAt === 'object' && hydrationOrLastSyncAt !== null
261
+ ? hydrationOrLastSyncAt
262
+ : {
263
+ lastAttemptAt: hydrationOrLastSyncAt,
264
+ lastSuccessAt: hydrationOrLastSyncAt,
265
+ attention: legacyItems,
266
+ pendingCount: legacyPendingCount,
267
+ };
268
+ const items = hydration.attention ?? [];
269
+ const pendingCount = hydration.pendingCount ?? 0;
270
+ this.set((state) => ({
271
+ status: settledStatus(items.length, pendingCount),
272
+ lastAttemptAt: hydration.lastAttemptAt,
273
+ lastSuccessAt: hydration.lastSuccessAt,
274
+ error: null,
275
+ failure: null,
276
+ retrying: false,
277
+ retryAt: null,
278
+ attention: [...items],
279
+ pendingCount,
280
+ initialPullStatus: hydration.lastSuccessAt === null ? 'uninitialized' : 'settled',
281
+ refreshRevision: state.refreshRevision + 1,
282
+ }));
283
+ }
284
+
285
+ reset(): void {
286
+ this.set(() => initialSnapshot());
287
+ }
288
+
289
+ setSecurityBlock(
290
+ message: string,
291
+ failure: Extract<SyncFailure, { kind: 'partition_mismatch' | 'local_apply' }> = {
292
+ kind: 'local_apply',
293
+ },
294
+ ): void {
295
+ this.set(() => ({
296
+ status: 'error',
297
+ lastAttemptAt: this.clock(),
298
+ error: message,
299
+ failure,
300
+ retrying: false,
301
+ retryAt: null,
302
+ initialPullStatus: 'settled',
303
+ securityBlock: message,
304
+ }));
305
+ }
306
+
307
+ resumeAfterAuth(): void {
308
+ this.set((state) => ({
309
+ status: settledStatus(state.attention.length, state.pendingCount),
310
+ error: null,
311
+ failure: null,
312
+ retrying: false,
313
+ retryAt: null,
314
+ }));
315
+ }
316
+
317
+ private set(
318
+ update: (state: SyncStatusSnapshot<TAttention>) => Partial<SyncStatusSnapshot<TAttention>>,
319
+ ): void {
320
+ const next = { ...this.snapshot, ...update(this.snapshot) };
321
+ this.snapshot = { ...next, lastSyncAt: next.lastSuccessAt };
322
+ this.listeners.forEach((listener) => {
323
+ listener(this.snapshot);
324
+ });
325
+ }
326
+ }
327
+
328
+ /** Write-only view a sync run uses to report progress. */
329
+ export interface SyncStatusSink<TAttention = SyncAttentionItem> {
330
+ setSyncing(attemptAt?: string): void;
331
+ setIdle(successAt: string): void;
332
+ setAttention(items: readonly TAttention[], pendingCount: number): void;
333
+ setAuth(message: string, update?: SyncFailureUpdate): void;
334
+ setFailure(failure: SyncFailure, message: string, update?: SyncFailureUpdate): void;
335
+ setRetrying(retryAt: string, pendingCount?: number): void;
336
+ }
@@ -0,0 +1,360 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ SyncAuthError,
5
+ SyncLocalApplyError,
6
+ SyncNetworkError,
7
+ SyncPartitionMismatchError,
8
+ SyncPayloadCompatibilityError,
9
+ SyncRateLimitError,
10
+ SyncServerError,
11
+ SyncTransportError,
12
+ syncFailureFromError,
13
+ } from './error.js';
14
+ import {
15
+ commitCursorAfterLocalTransaction,
16
+ parseRetryAfter,
17
+ SyncTransport,
18
+ validatePullPage,
19
+ type PullPagePosition,
20
+ type SyncFetch,
21
+ type SyncFetchResponse,
22
+ type SyncPrebuiltRequest,
23
+ } from './transport.js';
24
+
25
+ function response(status: number, body: string, retryAfter?: string): SyncFetchResponse {
26
+ return {
27
+ ok: status >= 200 && status < 300,
28
+ status,
29
+ headers: {
30
+ get: (name) => (name.toLowerCase() === 'retry-after' ? (retryAfter ?? null) : null),
31
+ },
32
+ text: () => Promise.resolve(body),
33
+ };
34
+ }
35
+
36
+ function transport(fetch: SyncFetch, overrides: { partitionHeader?: string } = {}): SyncTransport {
37
+ return new SyncTransport({
38
+ baseUrl: 'https://api.example.test/',
39
+ fetch,
40
+ authHeaders: () => ({ Authorization: 'Bearer token' }),
41
+ ...overrides,
42
+ });
43
+ }
44
+
45
+ describe('SyncTransport', () => {
46
+ it('sends auth headers, the partition header, and a JSON body', async () => {
47
+ const fetch = vi.fn<SyncFetch>(() => Promise.resolve(response(200, '{"accepted":1}')));
48
+
49
+ const result = await transport(fetch).request<{ accepted: number }>('/sync/push', {
50
+ method: 'POST',
51
+ body: { changes: [] },
52
+ partitionId: 'partition-1',
53
+ });
54
+
55
+ expect(result).toEqual({ accepted: 1 });
56
+ expect(fetch).toHaveBeenCalledWith('https://api.example.test/sync/push', {
57
+ method: 'POST',
58
+ headers: {
59
+ Authorization: 'Bearer token',
60
+ 'Content-Type': 'application/json',
61
+ 'X-Partition-Id': 'partition-1',
62
+ },
63
+ body: '{"changes":[]}',
64
+ });
65
+ });
66
+
67
+ it('awaits an async auth header provider', async () => {
68
+ const fetch = vi.fn<SyncFetch>(() => Promise.resolve(response(200, '{}')));
69
+ const client = new SyncTransport({
70
+ baseUrl: 'https://api.example.test',
71
+ fetch,
72
+ authHeaders: () => Promise.resolve({ Authorization: 'Bearer fresh' }),
73
+ });
74
+
75
+ await client.request('/sync/pull');
76
+
77
+ expect(fetch.mock.calls[0]?.[1]?.headers).toEqual({ Authorization: 'Bearer fresh' });
78
+ });
79
+
80
+ it('delegates a relative request to a prebuilt product API client', async () => {
81
+ const request = vi.fn(() => Promise.resolve({ accepted: 1 }));
82
+ const client = new SyncTransport({ request: request as SyncPrebuiltRequest });
83
+
84
+ const result = await client.request<{ accepted: number }>('/sync/push', {
85
+ method: 'POST',
86
+ query: { limit: '50' },
87
+ body: { changes: [] },
88
+ partitionId: 'partition-1',
89
+ });
90
+
91
+ expect(result).toEqual({ accepted: 1 });
92
+ expect(request).toHaveBeenCalledWith('/sync/push?limit=50', {
93
+ method: 'POST',
94
+ headers: {
95
+ 'Content-Type': 'application/json',
96
+ 'X-Partition-Id': 'partition-1',
97
+ },
98
+ body: '{"changes":[]}',
99
+ });
100
+ });
101
+
102
+ it('leaves a prebuilt request function in charge of its errors', async () => {
103
+ const failure = new Error('session recovery failed');
104
+ const request: SyncPrebuiltRequest = () => Promise.reject(failure);
105
+ const client = new SyncTransport({ request });
106
+
107
+ await expect(client.request('/sync/pull')).rejects.toBe(failure);
108
+ });
109
+
110
+ it('appends query parameters and honours a custom partition header', async () => {
111
+ const fetch = vi.fn<SyncFetch>(() => Promise.resolve(response(200, '{}')));
112
+
113
+ await transport(fetch, { partitionHeader: 'X-Profile' }).request('/sync/pull', {
114
+ query: { sinceRevision: '12', limit: '500' },
115
+ partitionId: 'profile-9',
116
+ });
117
+
118
+ expect(fetch.mock.calls[0]?.[0]).toBe(
119
+ 'https://api.example.test/sync/pull?sinceRevision=12&limit=500',
120
+ );
121
+ expect(fetch.mock.calls[0]?.[1]?.headers).toMatchObject({ 'X-Profile': 'profile-9' });
122
+ });
123
+
124
+ it('omits the partition header when no partition is supplied', async () => {
125
+ const fetch = vi.fn<SyncFetch>(() => Promise.resolve(response(200, '{}')));
126
+
127
+ await transport(fetch).request('/sync/pull', { partitionId: null });
128
+
129
+ expect(fetch.mock.calls[0]?.[1]?.headers).toEqual({ Authorization: 'Bearer token' });
130
+ });
131
+
132
+ it('maps 401 to a non-retryable auth error', async () => {
133
+ const fetch: SyncFetch = () =>
134
+ Promise.resolve(response(401, '{"message":"token expired","code":"unauthorized"}'));
135
+
136
+ const error = await transport(fetch)
137
+ .request('/sync/push')
138
+ .catch((caught: unknown) => caught);
139
+
140
+ expect(error).toBeInstanceOf(SyncAuthError);
141
+ expect((error as SyncAuthError).retryable).toBe(false);
142
+ expect((error as SyncAuthError).message).toBe('token expired');
143
+ });
144
+
145
+ it('maps the partition mismatch code ahead of the status', async () => {
146
+ const fetch: SyncFetch = () =>
147
+ Promise.resolve(
148
+ response(
149
+ 401,
150
+ '{"code":"partition_identity_mismatch","message":"this database was erased"}',
151
+ ),
152
+ );
153
+
154
+ const error = await transport(fetch)
155
+ .request('/sync/push')
156
+ .catch((caught: unknown) => caught);
157
+
158
+ expect(error).toBeInstanceOf(SyncPartitionMismatchError);
159
+ expect((error as SyncPartitionMismatchError).retryable).toBe(false);
160
+ });
161
+
162
+ it.each([408, 429, 500, 503])('treats %i as retryable', async (status) => {
163
+ const fetch: SyncFetch = () => Promise.resolve(response(status, '{}'));
164
+
165
+ const error = await transport(fetch)
166
+ .request('/sync/push')
167
+ .catch((caught: unknown) => caught);
168
+
169
+ expect(error).toBeInstanceOf(SyncTransportError);
170
+ expect((error as SyncTransportError).retryable).toBe(true);
171
+ });
172
+
173
+ it('maps 429 to a typed rate-limit error with the parsed retry time', async () => {
174
+ const now = Date.parse('2026-08-22T10:00:00Z');
175
+ const fetch: SyncFetch = () => Promise.resolve(response(429, '{"message":"slow down"}', '17'));
176
+ const client = new SyncTransport({
177
+ baseUrl: 'https://api.example.test',
178
+ fetch,
179
+ authHeaders: () => ({}),
180
+ now: () => now,
181
+ });
182
+
183
+ const error = await client.request('/sync/pull').catch((caught: unknown) => caught);
184
+
185
+ expect(error).toBeInstanceOf(SyncRateLimitError);
186
+ expect(error).toMatchObject({
187
+ kind: 'rate_limited',
188
+ retryable: true,
189
+ retryAfter: '17',
190
+ retryAt: '2026-08-22T10:00:17.000Z',
191
+ });
192
+ expect(error).not.toBeInstanceOf(SyncNetworkError);
193
+ });
194
+
195
+ it.each([400, 404, 409, 422])('treats %i as non-retryable', async (status) => {
196
+ const fetch: SyncFetch = () => Promise.resolve(response(status, '{}'));
197
+
198
+ const error = await transport(fetch)
199
+ .request('/sync/push')
200
+ .catch((caught: unknown) => caught);
201
+
202
+ expect(error).toBeInstanceOf(SyncTransportError);
203
+ expect(error).toBeInstanceOf(SyncServerError);
204
+ expect((error as SyncTransportError).retryable).toBe(false);
205
+ expect((error as SyncTransportError).message).toBe(
206
+ `sync request failed with status ${String(status)}`,
207
+ );
208
+ });
209
+
210
+ it('maps a network failure to a retryable error carrying the cause', async () => {
211
+ const cause = new Error('network down');
212
+ const fetch: SyncFetch = () => Promise.reject(cause);
213
+
214
+ const error = await transport(fetch)
215
+ .request('/sync/push')
216
+ .catch((caught: unknown) => caught);
217
+
218
+ expect(error).toBeInstanceOf(SyncTransportError);
219
+ expect(error).toBeInstanceOf(SyncNetworkError);
220
+ expect((error as SyncTransportError).retryable).toBe(true);
221
+ expect((error as SyncTransportError).message).toBe('network down');
222
+ expect((error as SyncTransportError).cause).toBe(cause);
223
+ });
224
+
225
+ it('maps an unparsable success body to a non-retryable error', async () => {
226
+ const fetch: SyncFetch = () => Promise.resolve(response(200, 'not json'));
227
+
228
+ const error = await transport(fetch)
229
+ .request('/sync/push')
230
+ .catch((caught: unknown) => caught);
231
+
232
+ expect(error).toBeInstanceOf(SyncPayloadCompatibilityError);
233
+ expect(error).toBeInstanceOf(SyncTransportError);
234
+ expect((error as SyncTransportError).retryable).toBe(false);
235
+ });
236
+
237
+ it('falls back to a status message when the error body is not JSON', async () => {
238
+ const fetch: SyncFetch = () => Promise.resolve(response(500, '<html>gateway</html>'));
239
+
240
+ const error = await transport(fetch)
241
+ .request('/sync/push')
242
+ .catch((caught: unknown) => caught);
243
+
244
+ expect((error as SyncTransportError).message).toBe('sync request failed with status 500');
245
+ expect((error as SyncTransportError).retryable).toBe(true);
246
+ });
247
+
248
+ it('accepts an empty success body', async () => {
249
+ const fetch: SyncFetch = () => Promise.resolve(response(204, ''));
250
+
251
+ await expect(transport(fetch).request('/sync/ack')).resolves.toBeUndefined();
252
+ });
253
+ });
254
+
255
+ describe('parseRetryAfter', () => {
256
+ const now = Date.parse('2026-08-22T10:00:00Z');
257
+ const fallback = '2026-08-22T10:00:42.000Z';
258
+
259
+ it('accepts delta seconds', () => {
260
+ expect(parseRetryAfter('12', { now, fallbackMs: 42_000 })).toBe('2026-08-22T10:00:12.000Z');
261
+ });
262
+
263
+ it('accepts an HTTP date', () => {
264
+ expect(parseRetryAfter('Sat, 22 Aug 2026 10:02:00 GMT', { now, fallbackMs: 42_000 })).toBe(
265
+ '2026-08-22T10:02:00.000Z',
266
+ );
267
+ });
268
+
269
+ it.each([
270
+ ['a past date', 'Sat, 22 Aug 2026 09:59:00 GMT'],
271
+ ['a negative delta', '-5'],
272
+ ['garbage', 'later'],
273
+ ['a missing header', null],
274
+ ])('uses the injected fallback for %s', (_label, value) => {
275
+ expect(parseRetryAfter(value, { now, fallbackMs: 42_000 })).toBe(fallback);
276
+ });
277
+ });
278
+
279
+ describe('syncFailureFromError', () => {
280
+ it('keeps every failure category distinct', () => {
281
+ const retryAt = '2026-08-22T10:02:00.000Z';
282
+
283
+ expect([
284
+ syncFailureFromError(new SyncAuthError('auth')),
285
+ syncFailureFromError(new SyncPartitionMismatchError('partition')),
286
+ syncFailureFromError(new SyncRateLimitError('limited', retryAt, '120')),
287
+ syncFailureFromError(new SyncNetworkError('offline')),
288
+ syncFailureFromError(new SyncServerError('server', true)),
289
+ syncFailureFromError(new SyncPayloadCompatibilityError('payload')),
290
+ syncFailureFromError(new SyncLocalApplyError('apply')),
291
+ ]).toEqual([
292
+ { kind: 'auth' },
293
+ { kind: 'partition_mismatch' },
294
+ { kind: 'rate_limited', retryAt },
295
+ { kind: 'network' },
296
+ { kind: 'server' },
297
+ { kind: 'payload_compatibility' },
298
+ { kind: 'local_apply' },
299
+ ]);
300
+ });
301
+ });
302
+
303
+ describe('validatePullPage', () => {
304
+ const compare = (left: number, right: number): number => left - right;
305
+ const validate = (current: number, page: PullPagePosition<number>) =>
306
+ validatePullPage(current, page, compare);
307
+
308
+ it('returns a progressing page', () => {
309
+ const page = { nextCursor: 4, hasMore: true, changes: ['row'] };
310
+
311
+ expect(validate(3, page)).toBe(page);
312
+ });
313
+
314
+ it('rejects a regressing cursor', () => {
315
+ expect(() => validate(3, { nextCursor: 2, hasMore: false })).toThrow(
316
+ SyncPayloadCompatibilityError,
317
+ );
318
+ });
319
+
320
+ it('rejects hasMore without progress instead of allowing another loop', () => {
321
+ expect(() => validate(3, { nextCursor: 3, hasMore: true })).toThrow(
322
+ SyncPayloadCompatibilityError,
323
+ );
324
+ });
325
+ });
326
+
327
+ describe('commitCursorAfterLocalTransaction', () => {
328
+ it('commits the cursor after the local transaction resolves', async () => {
329
+ const calls: string[] = [];
330
+
331
+ const result = await commitCursorAfterLocalTransaction({
332
+ nextCursor: 8,
333
+ transaction: () => {
334
+ calls.push('transaction');
335
+ return Promise.resolve('applied');
336
+ },
337
+ commitCursor: (cursor) => {
338
+ calls.push(`cursor:${String(cursor)}`);
339
+ },
340
+ });
341
+
342
+ expect(result).toBe('applied');
343
+ expect(calls).toEqual(['transaction', 'cursor:8']);
344
+ });
345
+
346
+ it('leaves the cursor unchanged when the local transaction fails', async () => {
347
+ let cursor = 3;
348
+
349
+ const task = commitCursorAfterLocalTransaction({
350
+ nextCursor: 8,
351
+ transaction: () => Promise.reject(new Error('constraint failed')),
352
+ commitCursor: (nextCursor) => {
353
+ cursor = nextCursor;
354
+ },
355
+ });
356
+
357
+ await expect(task).rejects.toBeInstanceOf(SyncLocalApplyError);
358
+ expect(cursor).toBe(3);
359
+ });
360
+ });