@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baukit/sync-client",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Client-side scheduling, transport, status reporting, and ordering for offline-capable sync loops.",
5
5
  "license": "MIT",
6
6
  "author": "Patrick Koss",
@@ -50,6 +50,7 @@
50
50
  },
51
51
  "files": [
52
52
  "dist",
53
+ "src",
53
54
  "README.md",
54
55
  "LICENSE"
55
56
  ],
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ createBrowserSyncEnvironment,
5
+ type BrowserSyncDocument,
6
+ type BrowserSyncWindow,
7
+ } from '@baukit/sync-client/browser';
8
+
9
+ interface MutableBrowserSyncDocument extends BrowserSyncDocument {
10
+ visibilityState: string;
11
+ dispatchVisibilityChange(): void;
12
+ readonly removeVisibility: ReturnType<typeof vi.fn>;
13
+ }
14
+
15
+ interface TestBrowserSyncWindow extends BrowserSyncWindow {
16
+ dispatchOnline(): void;
17
+ readonly removeOnline: ReturnType<typeof vi.fn>;
18
+ }
19
+
20
+ function documentSource(initialState: string): MutableBrowserSyncDocument {
21
+ const listeners = new Set<() => void>();
22
+ const removeVisibility = vi.fn((_type: 'visibilitychange', listener: () => void) => {
23
+ listeners.delete(listener);
24
+ });
25
+ return {
26
+ visibilityState: initialState,
27
+ addEventListener: (_type, listener) => {
28
+ listeners.add(listener);
29
+ },
30
+ removeEventListener: removeVisibility,
31
+ dispatchVisibilityChange: () => {
32
+ listeners.forEach((listener) => {
33
+ listener();
34
+ });
35
+ },
36
+ removeVisibility,
37
+ };
38
+ }
39
+
40
+ function windowSource(): TestBrowserSyncWindow {
41
+ const listeners = new Set<() => void>();
42
+ const removeOnline = vi.fn((_type: 'online', listener: () => void) => {
43
+ listeners.delete(listener);
44
+ });
45
+ return {
46
+ addEventListener: (_type, listener) => {
47
+ listeners.add(listener);
48
+ },
49
+ removeEventListener: removeOnline,
50
+ setInterval: vi.fn(() => 1),
51
+ clearInterval: vi.fn(),
52
+ dispatchOnline: () => {
53
+ listeners.forEach((listener) => {
54
+ listener();
55
+ });
56
+ },
57
+ removeOnline,
58
+ };
59
+ }
60
+
61
+ describe('createBrowserSyncEnvironment', () => {
62
+ it.each([
63
+ ['hidden', false],
64
+ ['visible', true],
65
+ ])('reports %s startup state', (visibilityState, expected) => {
66
+ const environment = createBrowserSyncEnvironment({
67
+ document: documentSource(visibilityState),
68
+ window: windowSource(),
69
+ });
70
+
71
+ expect(environment.isActive()).toBe(expected);
72
+ });
73
+
74
+ it('publishes hidden and visible transitions', () => {
75
+ const document = documentSource('visible');
76
+ const active = vi.fn();
77
+ const environment = createBrowserSyncEnvironment({ document, window: windowSource() });
78
+ environment.subscribeActive(active);
79
+
80
+ document.visibilityState = 'hidden';
81
+ document.dispatchVisibilityChange();
82
+ document.visibilityState = 'visible';
83
+ document.dispatchVisibilityChange();
84
+
85
+ expect(active.mock.calls).toEqual([[false], [true]]);
86
+ });
87
+
88
+ it('publishes browser online events', () => {
89
+ const window = windowSource();
90
+ const online = vi.fn();
91
+ const environment = createBrowserSyncEnvironment({
92
+ document: documentSource('visible'),
93
+ window,
94
+ });
95
+ environment.subscribeOnline(online);
96
+
97
+ window.dispatchOnline();
98
+
99
+ expect(online).toHaveBeenCalledOnce();
100
+ });
101
+
102
+ it('delegates timers to injected functions', () => {
103
+ const handle = {};
104
+ const timers = {
105
+ setInterval: vi.fn(() => handle),
106
+ clearInterval: vi.fn(),
107
+ };
108
+ const environment = createBrowserSyncEnvironment({
109
+ document: documentSource('visible'),
110
+ window: windowSource(),
111
+ timers,
112
+ });
113
+ const callback = vi.fn();
114
+
115
+ expect(environment.setInterval(callback, 1234)).toBe(handle);
116
+ environment.clearInterval(handle);
117
+
118
+ expect(timers.setInterval).toHaveBeenCalledWith(callback, 1234);
119
+ expect(timers.clearInterval).toHaveBeenCalledWith(handle);
120
+ });
121
+
122
+ it('removes both subscriptions once when cleanup repeats', () => {
123
+ const document = documentSource('visible');
124
+ const window = windowSource();
125
+ const environment = createBrowserSyncEnvironment({
126
+ document,
127
+ window,
128
+ });
129
+ const active = vi.fn();
130
+ const online = vi.fn();
131
+ const unsubscribeActive = environment.subscribeActive(active);
132
+ const unsubscribeOnline = environment.subscribeOnline(online);
133
+
134
+ unsubscribeActive();
135
+ unsubscribeActive();
136
+ unsubscribeOnline();
137
+ unsubscribeOnline();
138
+ document.dispatchVisibilityChange();
139
+ window.dispatchOnline();
140
+
141
+ expect(document.removeVisibility).toHaveBeenCalledOnce();
142
+ expect(window.removeOnline).toHaveBeenCalledOnce();
143
+ expect(active).not.toHaveBeenCalled();
144
+ expect(online).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('uses browser globals when they are present', () => {
148
+ const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
149
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
150
+ const document = documentSource('visible');
151
+ const window = windowSource();
152
+ Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
153
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: window });
154
+
155
+ try {
156
+ expect(createBrowserSyncEnvironment().isActive()).toBe(true);
157
+ } finally {
158
+ if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument);
159
+ else Reflect.deleteProperty(globalThis, 'document');
160
+ if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
161
+ else Reflect.deleteProperty(globalThis, 'window');
162
+ }
163
+ });
164
+
165
+ it('throws a specific error when browser globals are absent', () => {
166
+ expect(() => createBrowserSyncEnvironment()).toThrow(
167
+ 'Browser sync environment requires a document',
168
+ );
169
+ expect(() => createBrowserSyncEnvironment({ document: documentSource('visible') })).toThrow(
170
+ 'Browser sync environment requires a window',
171
+ );
172
+ });
173
+ });
package/src/browser.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { SyncSchedulerEnvironment, SyncSchedulerTimer } from './scheduler.js';
2
+
3
+ export interface BrowserSyncDocument {
4
+ readonly visibilityState: string;
5
+ addEventListener(type: 'visibilitychange', listener: () => void): void;
6
+ removeEventListener(type: 'visibilitychange', listener: () => void): void;
7
+ }
8
+
9
+ export interface BrowserSyncWindow {
10
+ addEventListener(type: 'online', listener: () => void): void;
11
+ removeEventListener(type: 'online', listener: () => void): void;
12
+ setInterval(callback: () => void, milliseconds: number): number;
13
+ clearInterval(handle: number): void;
14
+ }
15
+
16
+ export interface BrowserSyncTimers {
17
+ setInterval(callback: () => void, milliseconds: number): SyncSchedulerTimer;
18
+ clearInterval(handle: SyncSchedulerTimer): void;
19
+ }
20
+
21
+ export interface BrowserSyncEnvironmentOptions {
22
+ document?: BrowserSyncDocument;
23
+ window?: BrowserSyncWindow;
24
+ timers?: BrowserSyncTimers;
25
+ }
26
+
27
+ function resolveDocument(document: BrowserSyncDocument | undefined): BrowserSyncDocument {
28
+ const globalDocument = globalThis.document as BrowserSyncDocument | undefined;
29
+ const resolved = document ?? globalDocument;
30
+ if (resolved === undefined) {
31
+ throw new Error('Browser sync environment requires a document');
32
+ }
33
+ return resolved;
34
+ }
35
+
36
+ function resolveWindow(window: BrowserSyncWindow | undefined): BrowserSyncWindow {
37
+ const globalWindow = globalThis.window as unknown as BrowserSyncWindow | undefined;
38
+ const resolved = window ?? globalWindow;
39
+ if (resolved === undefined) {
40
+ throw new Error('Browser sync environment requires a window');
41
+ }
42
+ return resolved;
43
+ }
44
+
45
+ function once(cleanup: () => void): () => void {
46
+ let active = true;
47
+ return () => {
48
+ if (!active) return;
49
+ active = false;
50
+ cleanup();
51
+ };
52
+ }
53
+
54
+ export function createBrowserSyncEnvironment(
55
+ options: BrowserSyncEnvironmentOptions = {},
56
+ ): SyncSchedulerEnvironment {
57
+ const browserDocument = resolveDocument(options.document);
58
+ const browserWindow = resolveWindow(options.window);
59
+ const timers: BrowserSyncTimers = options.timers ?? {
60
+ setInterval: (callback, milliseconds) =>
61
+ browserWindow.setInterval(callback, milliseconds) as unknown as SyncSchedulerTimer,
62
+ clearInterval: (handle) => {
63
+ browserWindow.clearInterval(handle as unknown as number);
64
+ },
65
+ };
66
+
67
+ return {
68
+ isActive: () => browserDocument.visibilityState === 'visible',
69
+ subscribeActive(listener) {
70
+ const onVisibilityChange = () => {
71
+ listener(browserDocument.visibilityState === 'visible');
72
+ };
73
+ browserDocument.addEventListener('visibilitychange', onVisibilityChange);
74
+ return once(() => {
75
+ browserDocument.removeEventListener('visibilitychange', onVisibilityChange);
76
+ });
77
+ },
78
+ subscribeOnline(listener) {
79
+ browserWindow.addEventListener('online', listener);
80
+ return once(() => {
81
+ browserWindow.removeEventListener('online', listener);
82
+ });
83
+ },
84
+ setInterval(callback, milliseconds) {
85
+ return timers.setInterval(callback, milliseconds);
86
+ },
87
+ clearInterval(handle) {
88
+ timers.clearInterval(handle);
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,385 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createSyncConformanceTests } from '@baukit/sync-client/conformance';
3
+
4
+ import {
5
+ type SyncConformanceAdapter,
6
+ type SyncConformanceChange,
7
+ type SyncConformancePullFault,
8
+ type SyncConformancePullPage,
9
+ type SyncConformancePushFailure,
10
+ type SyncConformanceRejection,
11
+ type SyncConformanceRow,
12
+ } from './conformance.js';
13
+ import { SyncNetworkError, SyncServerError } from './error.js';
14
+ import { validatePushOutcomeCoverage } from './push-batch.js';
15
+
16
+ interface FakeClient {
17
+ cursor: number;
18
+ outbox: SyncConformanceChange[];
19
+ rejected: SyncConformanceRejection[];
20
+ resetCount: number;
21
+ resetSafe: boolean;
22
+ rows: Map<string, SyncConformanceRow>;
23
+ }
24
+
25
+ interface FakeServerRow {
26
+ row: SyncConformanceRow;
27
+ revision: number;
28
+ }
29
+
30
+ interface FakeServer {
31
+ appliedChangeIds: Set<string>;
32
+ nextPullFault: SyncConformancePullFault | null;
33
+ nextPushFailure: SyncConformancePushFailure | null;
34
+ omitOutcome: boolean;
35
+ purgeHorizon: number;
36
+ rejectNextZeroHorizon: number | null;
37
+ revision: number;
38
+ rows: Map<string, FakeServerRow>;
39
+ }
40
+
41
+ interface FakePushRequest {
42
+ changes: readonly SyncConformanceChange[];
43
+ }
44
+
45
+ interface FakePushOutcome {
46
+ entityType: string;
47
+ entityId: string;
48
+ status: 'accepted' | 'rejected';
49
+ reason?: string;
50
+ serverRow?: SyncConformanceRow;
51
+ }
52
+
53
+ interface FakePushResponse {
54
+ outcomes: readonly FakePushOutcome[];
55
+ }
56
+
57
+ type FakePullResponse = SyncConformancePullPage<SyncConformanceRow, number>;
58
+
59
+ class FakeStaleCursorError extends Error {
60
+ constructor(readonly horizon: number) {
61
+ super('resync_required');
62
+ }
63
+ }
64
+
65
+ function key(value: { entityType: string; entityId: string }): string {
66
+ return `${value.entityType}:${value.entityId}`;
67
+ }
68
+
69
+ function clone<T>(value: T): T {
70
+ return structuredClone(value);
71
+ }
72
+
73
+ function createClient(): FakeClient {
74
+ return {
75
+ cursor: 0,
76
+ outbox: [],
77
+ rejected: [],
78
+ resetCount: 0,
79
+ resetSafe: true,
80
+ rows: new Map(),
81
+ };
82
+ }
83
+
84
+ function createServer(): FakeServer {
85
+ return {
86
+ appliedChangeIds: new Set(),
87
+ nextPullFault: null,
88
+ nextPushFailure: null,
89
+ omitOutcome: false,
90
+ purgeHorizon: 0,
91
+ rejectNextZeroHorizon: null,
92
+ revision: 0,
93
+ rows: new Map(),
94
+ };
95
+ }
96
+
97
+ function rowFromChange(change: SyncConformanceChange): SyncConformanceRow {
98
+ return {
99
+ entityType: change.entityType,
100
+ entityId: change.entityId,
101
+ value: change.value,
102
+ logicalTime: change.logicalTime,
103
+ deleted: change.operation === 'delete',
104
+ };
105
+ }
106
+
107
+ function applyLocalChange(rows: Map<string, SyncConformanceRow>, row: SyncConformanceRow): void {
108
+ const existing = rows.get(key(row));
109
+ if (!existing || row.logicalTime >= existing.logicalTime) {
110
+ rows.set(key(row), clone(row));
111
+ }
112
+ }
113
+
114
+ function dependencyOrder(changes: readonly SyncConformanceChange[]): SyncConformanceChange[] {
115
+ const remaining = [...changes];
116
+ const ordered: SyncConformanceChange[] = [];
117
+ const available = new Set<string>();
118
+ while (remaining.length > 0) {
119
+ const index = remaining.findIndex(
120
+ (candidate) => candidate.dependsOn === undefined || available.has(key(candidate.dependsOn)),
121
+ );
122
+ const next = remaining.splice(index < 0 ? 0 : index, 1)[0];
123
+ if (next === undefined) throw new Error('Expected a pending change');
124
+ ordered.push(next);
125
+ available.add(key(next));
126
+ }
127
+ return ordered;
128
+ }
129
+
130
+ function applyServerChange(server: FakeServer, change: SyncConformanceChange): FakePushOutcome {
131
+ if (server.appliedChangeIds.has(change.changeId)) {
132
+ return { entityType: change.entityType, entityId: change.entityId, status: 'accepted' };
133
+ }
134
+ const existing = server.rows.get(key(change));
135
+ if (existing && change.logicalTime <= existing.row.logicalTime) {
136
+ return {
137
+ entityType: change.entityType,
138
+ entityId: change.entityId,
139
+ status: 'rejected',
140
+ reason: 'superseded',
141
+ serverRow: clone(existing.row),
142
+ };
143
+ }
144
+ if (change.dependsOn !== undefined && !server.rows.has(key(change.dependsOn))) {
145
+ return {
146
+ entityType: change.entityType,
147
+ entityId: change.entityId,
148
+ status: 'rejected',
149
+ reason: 'missing_reference',
150
+ };
151
+ }
152
+ server.revision += 1;
153
+ server.appliedChangeIds.add(change.changeId);
154
+ server.rows.set(key(change), { row: rowFromChange(change), revision: server.revision });
155
+ return { entityType: change.entityType, entityId: change.entityId, status: 'accepted' };
156
+ }
157
+
158
+ const adapter: SyncConformanceAdapter<
159
+ FakeClient,
160
+ FakeServer,
161
+ SyncConformanceChange,
162
+ FakePushRequest,
163
+ FakePushResponse,
164
+ FakePullResponse,
165
+ SyncConformanceRow,
166
+ number
167
+ > = {
168
+ createScenario: () => ({ clients: [createClient(), createClient()], server: createServer() }),
169
+ outbox: {
170
+ enqueue(client, change) {
171
+ client.outbox.push(clone(change));
172
+ applyLocalChange(client.rows, rowFromChange(change));
173
+ },
174
+ listPending: (client) => clone(client.outbox),
175
+ pendingId: (pending) => pending.changeId,
176
+ markAcknowledged(client, pending) {
177
+ const acknowledged = new Set(pending.map(({ changeId }) => changeId));
178
+ client.outbox = client.outbox.filter(({ changeId }) => !acknowledged.has(changeId));
179
+ },
180
+ recordRejected(client, rejected) {
181
+ client.rejected.push(...clone(rejected));
182
+ },
183
+ listRejected: (client) => clone(client.rejected),
184
+ },
185
+ local: {
186
+ readCursor: (client) => client.cursor,
187
+ applyPullPage(client, page, options) {
188
+ const staged = new Map([...client.rows].map(([rowKey, row]) => [rowKey, clone(row)]));
189
+ let applied = 0;
190
+ for (const row of page.changes) {
191
+ const hasPending = client.outbox.some(
192
+ (pending) => pending.entityType === row.entityType && pending.entityId === row.entityId,
193
+ );
194
+ if (!hasPending) applyLocalChange(staged, row);
195
+ applied += 1;
196
+ if (applied === options?.failAfterChanges) {
197
+ throw new Error('injected local transaction failure');
198
+ }
199
+ }
200
+ client.rows = staged;
201
+ client.cursor = page.nextCursor;
202
+ },
203
+ snapshot: (client) => clone([...client.rows.values()]),
204
+ readPendingState: (client) => ({
205
+ pending: client.outbox.length > 0,
206
+ pendingCount: client.outbox.length,
207
+ }),
208
+ applySubmittedBatchOutcome(client, outcome) {
209
+ const acknowledged = new Set(outcome.acknowledged.map(({ changeId }) => changeId));
210
+ const outbox = client.outbox.filter(({ changeId }) => !acknowledged.has(changeId));
211
+ const rejected = [...client.rejected, ...clone(outcome.rejected)];
212
+ const rows = new Map([...client.rows].map(([rowKey, row]) => [rowKey, clone(row)]));
213
+ for (const row of outcome.rejectedRows ?? []) {
214
+ const hasPending = outbox.some(
215
+ (pending) => pending.entityType === row.entityType && pending.entityId === row.entityId,
216
+ );
217
+ if (!hasPending) applyLocalChange(rows, row);
218
+ }
219
+ client.outbox = outbox;
220
+ client.rejected = rejected;
221
+ client.rows = rows;
222
+ },
223
+ },
224
+ wire: {
225
+ encodePush: (pending) => ({ changes: dependencyOrder(pending) }),
226
+ decodePush(response, submitted) {
227
+ validatePushOutcomeCoverage(submitted, response.outcomes, {
228
+ submittedKey: key,
229
+ outcomeKey: key,
230
+ });
231
+ return {
232
+ acknowledged: submitted,
233
+ rejected: response.outcomes
234
+ .filter((outcome) => outcome.status === 'rejected')
235
+ .map((outcome) => ({
236
+ entityType: outcome.entityType,
237
+ entityId: outcome.entityId,
238
+ reason: outcome.reason ?? 'rejected',
239
+ })),
240
+ rejectedRows: response.outcomes.flatMap((outcome) =>
241
+ outcome.status === 'rejected' && outcome.serverRow !== undefined
242
+ ? [clone(outcome.serverRow)]
243
+ : [],
244
+ ),
245
+ };
246
+ },
247
+ decodePull: (response) => clone(response),
248
+ compareCursors: (left, right) => left - right,
249
+ },
250
+ server: {
251
+ acceptPush(server, request) {
252
+ const failure = server.nextPushFailure;
253
+ server.nextPushFailure = null;
254
+ if (failure === 'network') throw new SyncNetworkError('offline');
255
+ if (failure === 'server') throw new SyncServerError('unavailable', true);
256
+ const outcomes = request.changes.map((change) => applyServerChange(server, change));
257
+ if (server.omitOutcome) {
258
+ server.omitOutcome = false;
259
+ outcomes.pop();
260
+ }
261
+ return Promise.resolve({ outcomes: clone(outcomes) });
262
+ },
263
+ servePull(server, cursor, pageSize) {
264
+ if (cursor === 0 && server.rejectNextZeroHorizon !== null) {
265
+ const horizon = server.rejectNextZeroHorizon;
266
+ server.rejectNextZeroHorizon = null;
267
+ throw new FakeStaleCursorError(horizon);
268
+ }
269
+ if (cursor > 0 && cursor < server.purgeHorizon) {
270
+ throw new FakeStaleCursorError(server.purgeHorizon);
271
+ }
272
+ const fault = server.nextPullFault;
273
+ server.nextPullFault = null;
274
+ if (fault === 'stall') {
275
+ return Promise.resolve({ changes: [], nextCursor: cursor, hasMore: true });
276
+ }
277
+ if (fault === 'regress') {
278
+ return Promise.resolve({ changes: [], nextCursor: cursor - 1, hasMore: false });
279
+ }
280
+ const available = [...server.rows.values()]
281
+ .filter(({ revision }) => revision > cursor)
282
+ .sort((left, right) => left.revision - right.revision);
283
+ const selected = available.slice(0, pageSize);
284
+ const hasMore = available.length > selected.length;
285
+ return Promise.resolve({
286
+ changes: clone(selected.map(({ row }) => row)),
287
+ nextCursor: hasMore ? (selected.at(-1)?.revision ?? cursor) : server.revision,
288
+ hasMore,
289
+ });
290
+ },
291
+ seed(server, change) {
292
+ const outcome = applyServerChange(server, change);
293
+ if (outcome.status === 'rejected') throw new Error(outcome.reason);
294
+ },
295
+ snapshot: (server) => clone([...server.rows.values()].map(({ row }) => row)),
296
+ failNextPush(server, failure) {
297
+ server.nextPushFailure = failure;
298
+ },
299
+ omitNextPushOutcome(server) {
300
+ server.omitOutcome = true;
301
+ },
302
+ faultNextPull(server, fault) {
303
+ server.nextPullFault = fault;
304
+ },
305
+ },
306
+ fullResync: {
307
+ zeroCursor: 0,
308
+ decodeStaleCursor(error) {
309
+ return error instanceof FakeStaleCursorError
310
+ ? { code: 'resync_required', horizon: error.horizon }
311
+ : null;
312
+ },
313
+ isResetSafe: (client) => client.resetSafe,
314
+ setResetSafe(client, safe) {
315
+ client.resetSafe = safe;
316
+ },
317
+ reset(client, _stale, options) {
318
+ client.resetCount += 1;
319
+ const preserved = new Set([
320
+ ...client.outbox.map((pending) => key(pending)),
321
+ ...client.rejected.map((rejected) => key(rejected)),
322
+ ]);
323
+ const rows = new Map([...client.rows].map(([rowKey, row]) => [rowKey, clone(row)]));
324
+ let removed = 0;
325
+ for (const rowKey of rows.keys()) {
326
+ if (preserved.has(rowKey)) continue;
327
+ rows.delete(rowKey);
328
+ removed += 1;
329
+ if (removed === options?.failAfterRows) {
330
+ throw new Error('injected full-resync reset failure');
331
+ }
332
+ }
333
+ client.rows = rows;
334
+ client.cursor = 0;
335
+ },
336
+ readResetCount: (client) => client.resetCount,
337
+ purgeTombstones(server, rows) {
338
+ for (const row of rows) {
339
+ server.revision += 1;
340
+ server.rows.delete(key(row));
341
+ server.purgeHorizon = Math.max(server.purgeHorizon, server.revision);
342
+ }
343
+ return server.purgeHorizon;
344
+ },
345
+ rejectNextZeroCursor(server, horizon) {
346
+ server.rejectNextZeroHorizon = horizon;
347
+ },
348
+ },
349
+ };
350
+
351
+ describe('sync conformance harness', () => {
352
+ for (const testCase of createSyncConformanceTests(adapter)) {
353
+ it(testCase.name, () => testCase.run());
354
+ }
355
+
356
+ it('keeps the full-resync callbacks optional for existing adapters', async () => {
357
+ const existingAdapter = { ...adapter };
358
+ delete existingAdapter.fullResync;
359
+ const existingCases = createSyncConformanceTests(existingAdapter);
360
+
361
+ expect(existingCases).toHaveLength(14);
362
+ for (const testCase of existingCases) await testCase.run();
363
+ });
364
+
365
+ it('keeps the legacy settlement callbacks available during migration', async () => {
366
+ const scenario = await adapter.createScenario();
367
+ const [client] = scenario.clients;
368
+ const pending: SyncConformanceChange = {
369
+ changeId: 'legacy-change',
370
+ entityType: 'record',
371
+ entityId: 'legacy',
372
+ operation: 'upsert',
373
+ value: 'legacy',
374
+ logicalTime: 1,
375
+ };
376
+ await adapter.outbox.enqueue(client, pending);
377
+ await adapter.outbox.markAcknowledged(client, [pending]);
378
+ await adapter.outbox.recordRejected(client, [
379
+ { entityType: 'record', entityId: 'legacy', reason: 'legacy' },
380
+ ]);
381
+
382
+ expect(await adapter.outbox.listPending(client)).toEqual([]);
383
+ expect(await adapter.outbox.listRejected(client)).toHaveLength(1);
384
+ });
385
+ });