@baukit/sync-client 0.7.1 → 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.
@@ -0,0 +1,875 @@
1
+ import { validatePullPage } from './transport.js';
2
+
3
+ export type SyncConformanceOperation = 'upsert' | 'delete';
4
+ export type SyncConformancePushFailure = 'network' | 'server';
5
+ export type SyncConformancePullFault = 'regress' | 'stall';
6
+
7
+ export interface SyncConformanceEntityRef {
8
+ entityType: string;
9
+ entityId: string;
10
+ }
11
+
12
+ export interface SyncConformanceChange extends SyncConformanceEntityRef {
13
+ changeId: string;
14
+ operation: SyncConformanceOperation;
15
+ value: string | null;
16
+ logicalTime: number;
17
+ dependsOn?: SyncConformanceEntityRef;
18
+ }
19
+
20
+ export interface SyncConformanceRow extends SyncConformanceEntityRef {
21
+ value: string | null;
22
+ logicalTime: number;
23
+ deleted: boolean;
24
+ }
25
+
26
+ export interface SyncConformanceRejection extends SyncConformanceEntityRef {
27
+ reason: string;
28
+ }
29
+
30
+ export interface SyncConformancePullPage<TChange, TCursor> {
31
+ changes: readonly TChange[];
32
+ nextCursor: TCursor;
33
+ hasMore: boolean;
34
+ }
35
+
36
+ export interface SyncConformancePushResult<TPending, TRemoteChange = never> {
37
+ acknowledged: readonly TPending[];
38
+ rejected: readonly SyncConformanceRejection[];
39
+ rejectedRows?: readonly TRemoteChange[];
40
+ }
41
+
42
+ export interface SyncConformanceSubmittedBatchOutcome<
43
+ TPending,
44
+ TPushResponse,
45
+ TRemoteChange,
46
+ > extends SyncConformancePushResult<TPending, TRemoteChange> {
47
+ submitted: readonly TPending[];
48
+ response: TPushResponse;
49
+ }
50
+
51
+ export interface SyncConformancePendingState {
52
+ pending: boolean;
53
+ pendingCount: number;
54
+ }
55
+
56
+ export interface SyncConformanceLocalApplyOptions {
57
+ failAfterChanges?: number;
58
+ }
59
+
60
+ export interface SyncConformanceStaleCursor<TCursor> {
61
+ code: 'resync_required';
62
+ horizon: TCursor;
63
+ }
64
+
65
+ export interface SyncConformanceFullResyncOptions {
66
+ failAfterRows?: number;
67
+ }
68
+
69
+ export interface SyncConformanceFullResyncCallbacks<TClient, TServer, TCursor> {
70
+ zeroCursor: TCursor;
71
+ decodeStaleCursor(error: unknown): SyncConformanceStaleCursor<TCursor> | null;
72
+ isResetSafe(
73
+ client: TClient,
74
+ stale: SyncConformanceStaleCursor<TCursor>,
75
+ ): Promise<boolean> | boolean;
76
+ setResetSafe(client: TClient, safe: boolean): Promise<void> | void;
77
+ reset(
78
+ client: TClient,
79
+ stale: SyncConformanceStaleCursor<TCursor>,
80
+ options?: SyncConformanceFullResyncOptions,
81
+ ): Promise<void> | void;
82
+ readResetCount(client: TClient): Promise<number> | number;
83
+ purgeTombstones(
84
+ server: TServer,
85
+ rows: readonly SyncConformanceEntityRef[],
86
+ ): Promise<TCursor> | TCursor;
87
+ rejectNextZeroCursor(server: TServer, horizon: TCursor): Promise<void> | void;
88
+ }
89
+
90
+ export interface SyncConformanceScenario<TClient, TServer> {
91
+ clients: readonly [TClient, TClient];
92
+ server: TServer;
93
+ }
94
+
95
+ export interface SyncConformanceAdapter<
96
+ TClient,
97
+ TServer,
98
+ TPending,
99
+ TPushRequest,
100
+ TPushResponse,
101
+ TPullResponse,
102
+ TRemoteChange,
103
+ TCursor,
104
+ > {
105
+ createScenario():
106
+ Promise<SyncConformanceScenario<TClient, TServer>> | SyncConformanceScenario<TClient, TServer>;
107
+ disposeScenario?(scenario: SyncConformanceScenario<TClient, TServer>): Promise<void> | void;
108
+ outbox: {
109
+ enqueue(client: TClient, change: SyncConformanceChange): Promise<void> | void;
110
+ listPending(client: TClient): Promise<readonly TPending[]> | readonly TPending[];
111
+ pendingId?(pending: TPending): string;
112
+ markAcknowledged(client: TClient, pending: readonly TPending[]): Promise<void> | void;
113
+ recordRejected(
114
+ client: TClient,
115
+ rejected: readonly SyncConformanceRejection[],
116
+ ): Promise<void> | void;
117
+ listRejected(
118
+ client: TClient,
119
+ ): Promise<readonly SyncConformanceRejection[]> | readonly SyncConformanceRejection[];
120
+ };
121
+ local: {
122
+ readCursor(client: TClient): Promise<TCursor> | TCursor;
123
+ applyPullPage(
124
+ client: TClient,
125
+ page: SyncConformancePullPage<TRemoteChange, TCursor>,
126
+ options?: SyncConformanceLocalApplyOptions,
127
+ ): Promise<void> | void;
128
+ snapshot(
129
+ client: TClient,
130
+ ): Promise<readonly SyncConformanceRow[]> | readonly SyncConformanceRow[];
131
+ readPendingState(
132
+ client: TClient,
133
+ ): Promise<SyncConformancePendingState> | SyncConformancePendingState;
134
+ applySubmittedBatchOutcome?(
135
+ client: TClient,
136
+ outcome: SyncConformanceSubmittedBatchOutcome<TPending, TPushResponse, TRemoteChange>,
137
+ ): Promise<void> | void;
138
+ };
139
+ wire: {
140
+ encodePush(pending: readonly TPending[]): Promise<TPushRequest> | TPushRequest;
141
+ decodePush(
142
+ response: TPushResponse,
143
+ submitted: readonly TPending[],
144
+ ):
145
+ | Promise<SyncConformancePushResult<TPending, TRemoteChange>>
146
+ | SyncConformancePushResult<TPending, TRemoteChange>;
147
+ decodePull(
148
+ response: TPullResponse,
149
+ ):
150
+ | Promise<SyncConformancePullPage<TRemoteChange, TCursor>>
151
+ | SyncConformancePullPage<TRemoteChange, TCursor>;
152
+ compareCursors(left: TCursor, right: TCursor): number;
153
+ };
154
+ server: {
155
+ acceptPush(server: TServer, request: TPushRequest): Promise<TPushResponse>;
156
+ servePull(server: TServer, cursor: TCursor, pageSize: number): Promise<TPullResponse>;
157
+ seed(server: TServer, change: SyncConformanceChange): Promise<void> | void;
158
+ snapshot(
159
+ server: TServer,
160
+ ): Promise<readonly SyncConformanceRow[]> | readonly SyncConformanceRow[];
161
+ failNextPush(server: TServer, failure: SyncConformancePushFailure): Promise<void> | void;
162
+ omitNextPushOutcome(server: TServer): Promise<void> | void;
163
+ faultNextPull(server: TServer, fault: SyncConformancePullFault): Promise<void> | void;
164
+ };
165
+ fullResync?: SyncConformanceFullResyncCallbacks<TClient, TServer, TCursor>;
166
+ }
167
+
168
+ export interface SyncConformanceTestCase {
169
+ readonly name: string;
170
+ readonly run: () => Promise<void>;
171
+ }
172
+
173
+ interface TestContext<TClient, TServer> {
174
+ clientA: TClient;
175
+ clientB: TClient;
176
+ server: TServer;
177
+ }
178
+
179
+ const PAGE_SIZE = 2;
180
+
181
+ const parentChange: SyncConformanceChange = {
182
+ changeId: 'change-parent',
183
+ entityType: 'container',
184
+ entityId: 'parent',
185
+ operation: 'upsert',
186
+ value: 'parent value',
187
+ logicalTime: 10,
188
+ };
189
+
190
+ const childChange: SyncConformanceChange = {
191
+ changeId: 'change-child',
192
+ entityType: 'item',
193
+ entityId: 'child',
194
+ operation: 'upsert',
195
+ value: 'child value',
196
+ logicalTime: 11,
197
+ dependsOn: { entityType: 'container', entityId: 'parent' },
198
+ };
199
+
200
+ function change(
201
+ changeId: string,
202
+ entityId: string,
203
+ logicalTime: number,
204
+ value: string,
205
+ ): SyncConformanceChange {
206
+ return {
207
+ changeId,
208
+ entityType: 'record',
209
+ entityId,
210
+ operation: 'upsert',
211
+ value,
212
+ logicalTime,
213
+ };
214
+ }
215
+
216
+ function tombstone(changeId: string, entityId: string, logicalTime: number): SyncConformanceChange {
217
+ return {
218
+ changeId,
219
+ entityType: 'record',
220
+ entityId,
221
+ operation: 'delete',
222
+ value: null,
223
+ logicalTime,
224
+ };
225
+ }
226
+
227
+ function fail(message: string): never {
228
+ throw new Error(`Sync conformance failed: ${message}`);
229
+ }
230
+
231
+ function assert(condition: boolean, message: string): asserts condition {
232
+ if (!condition) fail(message);
233
+ }
234
+
235
+ function sortedRows(rows: readonly SyncConformanceRow[]): SyncConformanceRow[] {
236
+ return [...rows].sort(
237
+ (left, right) =>
238
+ left.entityType.localeCompare(right.entityType) ||
239
+ left.entityId.localeCompare(right.entityId),
240
+ );
241
+ }
242
+
243
+ function sameRows(
244
+ left: readonly SyncConformanceRow[],
245
+ right: readonly SyncConformanceRow[],
246
+ ): boolean {
247
+ return JSON.stringify(sortedRows(left)) === JSON.stringify(sortedRows(right));
248
+ }
249
+
250
+ async function expectFailure(run: () => Promise<void>, message: string): Promise<void> {
251
+ try {
252
+ await run();
253
+ } catch {
254
+ return;
255
+ }
256
+ fail(message);
257
+ }
258
+
259
+ export function createSyncConformanceTests<
260
+ TClient,
261
+ TServer,
262
+ TPending,
263
+ TPushRequest,
264
+ TPushResponse,
265
+ TPullResponse,
266
+ TRemoteChange,
267
+ TCursor,
268
+ >(
269
+ adapter: SyncConformanceAdapter<
270
+ TClient,
271
+ TServer,
272
+ TPending,
273
+ TPushRequest,
274
+ TPushResponse,
275
+ TPullResponse,
276
+ TRemoteChange,
277
+ TCursor
278
+ >,
279
+ ): readonly SyncConformanceTestCase[] {
280
+ type Scenario = SyncConformanceScenario<TClient, TServer>;
281
+
282
+ async function inScenario(run: (context: TestContext<TClient, TServer>) => Promise<void>) {
283
+ const scenario: Scenario = await adapter.createScenario();
284
+ const [clientA, clientB] = scenario.clients;
285
+ try {
286
+ await run({ clientA, clientB, server: scenario.server });
287
+ } finally {
288
+ await adapter.disposeScenario?.(scenario);
289
+ }
290
+ }
291
+
292
+ async function pending(client: TClient): Promise<readonly TPending[]> {
293
+ return adapter.outbox.listPending(client);
294
+ }
295
+
296
+ async function assertPendingState(client: TClient, expectedCount: number): Promise<void> {
297
+ const actual = await pending(client);
298
+ const reported = await adapter.local.readPendingState(client);
299
+ assert(
300
+ actual.length === expectedCount,
301
+ `expected ${String(expectedCount)} pending changes, received ${String(actual.length)}`,
302
+ );
303
+ assert(
304
+ reported.pendingCount === actual.length,
305
+ 'reported pending count differs from the outbox',
306
+ );
307
+ assert(reported.pending === actual.length > 0, 'reported pending flag differs from the outbox');
308
+ }
309
+
310
+ async function submit(client: TClient, server: TServer) {
311
+ const submitted = await pending(client);
312
+ assert(submitted.length > 0, 'cannot submit an empty pending batch');
313
+ const request = await adapter.wire.encodePush(submitted);
314
+ const response = await adapter.server.acceptPush(server, request);
315
+ const outcome = await adapter.wire.decodePush(response, submitted);
316
+ return { submitted, response, outcome };
317
+ }
318
+
319
+ async function applySubmittedBatchOutcome(
320
+ client: TClient,
321
+ submission: Awaited<ReturnType<typeof submit>>,
322
+ ): Promise<void> {
323
+ if (adapter.local.applySubmittedBatchOutcome === undefined) {
324
+ fail('adapter must implement local.applySubmittedBatchOutcome for submitted outcomes');
325
+ }
326
+ await adapter.local.applySubmittedBatchOutcome(client, {
327
+ submitted: submission.submitted,
328
+ response: submission.response,
329
+ ...submission.outcome,
330
+ });
331
+ }
332
+
333
+ async function push(client: TClient, server: TServer): Promise<void> {
334
+ const submitted = await pending(client);
335
+ if (submitted.length === 0) return;
336
+ await applySubmittedBatchOutcome(client, await submit(client, server));
337
+ }
338
+
339
+ async function pullPage(
340
+ client: TClient,
341
+ server: TServer,
342
+ options?: SyncConformanceLocalApplyOptions,
343
+ ): Promise<boolean> {
344
+ const cursor = (await adapter.local.readCursor(client)) as TCursor;
345
+ const response = await adapter.server.servePull(server, cursor, PAGE_SIZE);
346
+ const page = await adapter.wire.decodePull(response);
347
+ validatePullPage(cursor, page, (left, right) => adapter.wire.compareCursors(left, right));
348
+ await adapter.local.applyPullPage(client, page, options);
349
+ return page.hasMore;
350
+ }
351
+
352
+ async function pullAll(client: TClient, server: TServer): Promise<void> {
353
+ let hasMore = true;
354
+ while (hasMore) hasMore = await pullPage(client, server);
355
+ }
356
+
357
+ async function expectStalePull(
358
+ client: TClient,
359
+ server: TServer,
360
+ fullResync: SyncConformanceFullResyncCallbacks<TClient, TServer, TCursor>,
361
+ ): Promise<SyncConformanceStaleCursor<TCursor>> {
362
+ const cursor = await adapter.local.readCursor(client);
363
+ try {
364
+ await adapter.server.servePull(server, cursor, PAGE_SIZE);
365
+ } catch (error) {
366
+ const stale = fullResync.decodeStaleCursor(error);
367
+ if (stale === null) throw error;
368
+ assert(
369
+ adapter.wire.compareCursors(stale.horizon, cursor) > 0,
370
+ 'stale cursor response did not contain a newer purge horizon',
371
+ );
372
+ return stale;
373
+ }
374
+ fail('stale cursor returned a pull page');
375
+ }
376
+
377
+ async function recoverFromStaleCursor(
378
+ client: TClient,
379
+ server: TServer,
380
+ fullResync: SyncConformanceFullResyncCallbacks<TClient, TServer, TCursor>,
381
+ ): Promise<'completed' | 'deferred'> {
382
+ const stale = await expectStalePull(client, server, fullResync);
383
+ if (!(await fullResync.isResetSafe(client, stale))) return 'deferred';
384
+ await fullResync.reset(client, stale);
385
+ assert(
386
+ adapter.wire.compareCursors(await adapter.local.readCursor(client), fullResync.zeroCursor) ===
387
+ 0,
388
+ 'full-resync reset did not store cursor zero',
389
+ );
390
+ await pullAll(client, server);
391
+ return 'completed';
392
+ }
393
+
394
+ const tests: SyncConformanceTestCase[] = [
395
+ {
396
+ name: 'replayed pushes create one server change',
397
+ run: () =>
398
+ inScenario(async ({ clientA, server }) => {
399
+ await adapter.outbox.enqueue(clientA, change('change-replay', 'replay', 10, 'once'));
400
+ const submitted = await pending(clientA);
401
+ const request = await adapter.wire.encodePush(submitted);
402
+ const first = await adapter.server.acceptPush(server, request);
403
+ const second = await adapter.server.acceptPush(server, request);
404
+ await adapter.wire.decodePush(first, submitted);
405
+ await adapter.wire.decodePush(second, submitted);
406
+ const rows = (await adapter.server.snapshot(server)).filter(
407
+ (row) => row.entityType === 'record' && row.entityId === 'replay',
408
+ );
409
+ assert(rows.length === 1, 'replay created a duplicate server row');
410
+ }),
411
+ },
412
+ {
413
+ name: 'an interrupted local transaction preserves data, cursor, and pending work',
414
+ run: () =>
415
+ inScenario(async ({ clientA, server }) => {
416
+ await adapter.outbox.enqueue(clientA, change('change-pending', 'pending', 30, 'local'));
417
+ await adapter.server.seed(server, change('seed-first', 'first', 10, 'remote first'));
418
+ await adapter.server.seed(server, change('seed-second', 'second', 20, 'remote second'));
419
+ const cursorBefore = await adapter.local.readCursor(clientA);
420
+ const rowsBefore = await adapter.local.snapshot(clientA);
421
+ await expectFailure(
422
+ () => pullPage(clientA, server, { failAfterChanges: 1 }).then(() => undefined),
423
+ 'local apply failure resolved successfully',
424
+ );
425
+ const cursorAfter = await adapter.local.readCursor(clientA);
426
+ const rowsAfter = await adapter.local.snapshot(clientA);
427
+ assert(
428
+ adapter.wire.compareCursors(cursorAfter, cursorBefore) === 0,
429
+ 'local apply failure advanced the cursor',
430
+ );
431
+ assert(sameRows(rowsAfter, rowsBefore), 'local apply failure committed part of the page');
432
+ await assertPendingState(clientA, 1);
433
+ }),
434
+ },
435
+ {
436
+ name: 'pull cursors advance monotonically across pages',
437
+ run: () =>
438
+ inScenario(async ({ clientA, server }) => {
439
+ await adapter.server.seed(server, change('seed-one', 'one', 10, 'one'));
440
+ await adapter.server.seed(server, change('seed-two', 'two', 20, 'two'));
441
+ await adapter.server.seed(server, change('seed-three', 'three', 30, 'three'));
442
+ const start = await adapter.local.readCursor(clientA);
443
+ await pullAll(clientA, server);
444
+ const end = await adapter.local.readCursor(clientA);
445
+ assert(adapter.wire.compareCursors(end, start) > 0, 'pull did not advance the cursor');
446
+ assert(
447
+ sameRows(await adapter.local.snapshot(clientA), await adapter.server.snapshot(server)),
448
+ 'paged pull skipped or duplicated server state',
449
+ );
450
+ }),
451
+ },
452
+ {
453
+ name: 'a regressing pull cursor is rejected before local apply',
454
+ run: () =>
455
+ inScenario(async ({ clientA, server }) => {
456
+ await adapter.server.seed(server, change('seed-regress', 'regress', 10, 'remote'));
457
+ await pullAll(clientA, server);
458
+ const cursor = await adapter.local.readCursor(clientA);
459
+ const rows = await adapter.local.snapshot(clientA);
460
+ await adapter.server.faultNextPull(server, 'regress');
461
+ await expectFailure(
462
+ () => pullPage(clientA, server).then(() => undefined),
463
+ 'regressing cursor was accepted',
464
+ );
465
+ assert(
466
+ adapter.wire.compareCursors(await adapter.local.readCursor(clientA), cursor) === 0,
467
+ 'regressing page changed the cursor',
468
+ );
469
+ assert(
470
+ sameRows(await adapter.local.snapshot(clientA), rows),
471
+ 'regressing page changed data',
472
+ );
473
+ }),
474
+ },
475
+ {
476
+ name: 'hasMore requires cursor progress',
477
+ run: () =>
478
+ inScenario(async ({ clientA, server }) => {
479
+ const cursor = await adapter.local.readCursor(clientA);
480
+ await adapter.server.faultNextPull(server, 'stall');
481
+ await expectFailure(
482
+ () => pullPage(clientA, server).then(() => undefined),
483
+ 'hasMore without cursor progress was accepted',
484
+ );
485
+ assert(
486
+ adapter.wire.compareCursors(await adapter.local.readCursor(clientA), cursor) === 0,
487
+ 'stalled page changed the cursor',
488
+ );
489
+ }),
490
+ },
491
+ {
492
+ name: 'partial push outcomes are rejected before acknowledgement',
493
+ run: () =>
494
+ inScenario(async ({ clientA, server }) => {
495
+ await adapter.outbox.enqueue(clientA, change('change-covered-a', 'covered-a', 10, 'a'));
496
+ await adapter.outbox.enqueue(clientA, change('change-covered-b', 'covered-b', 20, 'b'));
497
+ await adapter.server.omitNextPushOutcome(server);
498
+ await expectFailure(() => push(clientA, server), 'partial push outcomes were accepted');
499
+ await assertPendingState(clientA, 2);
500
+ }),
501
+ },
502
+ {
503
+ name: 'an older accepted submission settles only its exact pending rows',
504
+ run: () =>
505
+ inScenario(async ({ clientA, server }) => {
506
+ await adapter.outbox.enqueue(clientA, change('change-overlap-a', 'overlap', 10, 'A'));
507
+ const submissionA = await submit(clientA, server);
508
+ await adapter.outbox.enqueue(clientA, change('change-overlap-b', 'overlap', 30, 'B'));
509
+ await submit(clientA, server);
510
+
511
+ await applySubmittedBatchOutcome(clientA, submissionA);
512
+
513
+ const remaining = await pending(clientA);
514
+ assert(remaining.length === 1, 'accepted submission removed a newer pending row');
515
+ if (adapter.outbox.pendingId === undefined) {
516
+ fail('adapter must implement outbox.pendingId for overlapping outcomes');
517
+ }
518
+ assert(
519
+ remaining[0] !== undefined &&
520
+ adapter.outbox.pendingId(remaining[0]) === 'change-overlap-b',
521
+ 'accepted submission settled the wrong pending ID',
522
+ );
523
+ const rows = await adapter.local.snapshot(clientA);
524
+ const visible = rows.find(
525
+ (row) => row.entityType === 'record' && row.entityId === 'overlap',
526
+ );
527
+ assert(visible?.value === 'B', 'accepted submission replaced the newer local value');
528
+ await assertPendingState(clientA, 1);
529
+ }),
530
+ },
531
+ {
532
+ name: 'an older rejected submission cannot replace a newer pending value',
533
+ run: () =>
534
+ inScenario(async ({ clientA, server }) => {
535
+ await adapter.server.seed(server, change('seed-overlap', 'overlap', 20, 'server'));
536
+ await adapter.outbox.enqueue(clientA, change('change-overlap-a', 'overlap', 10, 'A'));
537
+ const submissionA = await submit(clientA, server);
538
+ await adapter.outbox.enqueue(clientA, change('change-overlap-b', 'overlap', 30, 'B'));
539
+ await submit(clientA, server);
540
+
541
+ await applySubmittedBatchOutcome(clientA, submissionA);
542
+
543
+ const remaining = await pending(clientA);
544
+ assert(remaining.length === 1, 'rejected submission removed a newer pending row');
545
+ if (adapter.outbox.pendingId === undefined) {
546
+ fail('adapter must implement outbox.pendingId for overlapping outcomes');
547
+ }
548
+ assert(
549
+ remaining[0] !== undefined &&
550
+ adapter.outbox.pendingId(remaining[0]) === 'change-overlap-b',
551
+ 'rejected submission settled the wrong pending ID',
552
+ );
553
+ const rows = await adapter.local.snapshot(clientA);
554
+ const visible = rows.find(
555
+ (row) => row.entityType === 'record' && row.entityId === 'overlap',
556
+ );
557
+ assert(visible?.value === 'B', 'rejected server row replaced the newer local value');
558
+ assert(
559
+ (await adapter.outbox.listRejected(clientA)).length === 1,
560
+ 'rejected submission did not record its rejection',
561
+ );
562
+ await assertPendingState(clientA, 1);
563
+ }),
564
+ },
565
+ {
566
+ name: 'a stale pull commits its cursor without replacing a pending local value',
567
+ run: () =>
568
+ inScenario(async ({ clientA, server }) => {
569
+ await adapter.server.seed(server, change('seed-stale-pull', 'stale-pull', 20, 'server'));
570
+ await adapter.outbox.enqueue(
571
+ clientA,
572
+ change('change-stale-pull', 'stale-pull', 20, 'local'),
573
+ );
574
+ const cursorBefore = await adapter.local.readCursor(clientA);
575
+
576
+ await pullPage(clientA, server);
577
+
578
+ const cursorAfter = await adapter.local.readCursor(clientA);
579
+ assert(
580
+ adapter.wire.compareCursors(cursorAfter, cursorBefore) > 0,
581
+ 'stale pull did not commit its cursor',
582
+ );
583
+ const rows = await adapter.local.snapshot(clientA);
584
+ const visible = rows.find(
585
+ (row) => row.entityType === 'record' && row.entityId === 'stale-pull',
586
+ );
587
+ assert(visible?.value === 'local', 'stale pull replaced the pending local value');
588
+ await assertPendingState(clientA, 1);
589
+ }),
590
+ },
591
+ {
592
+ name: 'network failure keeps pending state truthful',
593
+ run: () =>
594
+ inScenario(async ({ clientA, server }) => {
595
+ await adapter.outbox.enqueue(clientA, change('change-network', 'network', 10, 'local'));
596
+ await adapter.server.failNextPush(server, 'network');
597
+ await expectFailure(() => push(clientA, server), 'network failure resolved successfully');
598
+ await assertPendingState(clientA, 1);
599
+ }),
600
+ },
601
+ {
602
+ name: 'server failure keeps pending state truthful',
603
+ run: () =>
604
+ inScenario(async ({ clientA, server }) => {
605
+ await adapter.outbox.enqueue(clientA, change('change-server', 'server', 10, 'local'));
606
+ await adapter.server.failNextPush(server, 'server');
607
+ await expectFailure(() => push(clientA, server), 'server failure resolved successfully');
608
+ await assertPendingState(clientA, 1);
609
+ }),
610
+ },
611
+ {
612
+ name: 'complete rejection outcomes are recorded without hidden pending work',
613
+ run: () =>
614
+ inScenario(async ({ clientA, server }) => {
615
+ await adapter.server.seed(server, change('seed-newer', 'rejected', 20, 'server'));
616
+ await adapter.outbox.enqueue(clientA, change('change-older', 'rejected', 10, 'local'));
617
+ await push(clientA, server);
618
+ await assertPendingState(clientA, 0);
619
+ const rejected = await adapter.outbox.listRejected(clientA);
620
+ assert(rejected.length === 1, 'actionable rejection was not recorded');
621
+ assert(rejected[0]?.entityId === 'rejected', 'wrong rejection was recorded');
622
+ }),
623
+ },
624
+ {
625
+ name: 'pushes referenced records before dependent records',
626
+ run: () =>
627
+ inScenario(async ({ clientA, server }) => {
628
+ await adapter.outbox.enqueue(clientA, childChange);
629
+ await adapter.outbox.enqueue(clientA, parentChange);
630
+ await push(clientA, server);
631
+ await assertPendingState(clientA, 0);
632
+ const rows = await adapter.server.snapshot(server);
633
+ assert(
634
+ rows.some((row) => row.entityType === 'container' && row.entityId === 'parent'),
635
+ 'referenced record did not reach the server',
636
+ );
637
+ assert(
638
+ rows.some((row) => row.entityType === 'item' && row.entityId === 'child'),
639
+ 'dependent record was rejected because push order was unsafe',
640
+ );
641
+ }),
642
+ },
643
+ {
644
+ name: 'two clients converge after alternating sync, including a tombstone',
645
+ run: () =>
646
+ inScenario(async ({ clientA, clientB, server }) => {
647
+ await adapter.outbox.enqueue(clientA, change('change-a', 'alpha', 10, 'from A'));
648
+ await adapter.outbox.enqueue(clientB, change('change-b', 'beta', 20, 'from B'));
649
+ await push(clientA, server);
650
+ await pullAll(clientB, server);
651
+ await push(clientB, server);
652
+ await pullAll(clientA, server);
653
+ await adapter.outbox.enqueue(clientB, tombstone('change-delete', 'alpha', 30));
654
+ await push(clientB, server);
655
+ await pullAll(clientA, server);
656
+ await pullAll(clientB, server);
657
+
658
+ const rowsA = await adapter.local.snapshot(clientA);
659
+ const rowsB = await adapter.local.snapshot(clientB);
660
+ const serverRows = await adapter.server.snapshot(server);
661
+ assert(sameRows(rowsA, rowsB), 'clients did not converge to the same state');
662
+ assert(sameRows(rowsA, serverRows), 'clients did not converge to server state');
663
+ const deleted = rowsA.find(
664
+ (row) => row.entityType === 'record' && row.entityId === 'alpha',
665
+ );
666
+ assert(deleted?.deleted === true, 'tombstone did not converge');
667
+ await assertPendingState(clientA, 0);
668
+ await assertPendingState(clientB, 0);
669
+ }),
670
+ },
671
+ ];
672
+
673
+ const fullResync = adapter.fullResync;
674
+ if (fullResync === undefined) return tests;
675
+
676
+ tests.push(
677
+ {
678
+ name: 'purge horizons are monotonic and cursor zero requests a full rebuild',
679
+ run: () =>
680
+ inScenario(async ({ clientA, server }) => {
681
+ const first = change('horizon-first', 'horizon-first', 10, 'first');
682
+ const second = change('horizon-second', 'horizon-second', 20, 'second');
683
+ await adapter.server.seed(server, first);
684
+ await adapter.server.seed(server, second);
685
+ await pullAll(clientA, server);
686
+ const oldCursor = await adapter.local.readCursor(clientA);
687
+
688
+ const firstHorizon = await fullResync.purgeTombstones(server, [first]);
689
+ const secondHorizon = await fullResync.purgeTombstones(server, [second]);
690
+ assert(
691
+ adapter.wire.compareCursors(firstHorizon, oldCursor) > 0,
692
+ 'first purge did not advance the owner horizon past the old cursor',
693
+ );
694
+ assert(
695
+ adapter.wire.compareCursors(secondHorizon, firstHorizon) >= 0,
696
+ 'owner purge horizon regressed',
697
+ );
698
+
699
+ const stale = await expectStalePull(clientA, server, fullResync);
700
+ assert(
701
+ adapter.wire.compareCursors(stale.horizon, secondHorizon) === 0,
702
+ 'stale cursor response did not contain the current owner horizon',
703
+ );
704
+
705
+ const response = await adapter.server.servePull(server, fullResync.zeroCursor, PAGE_SIZE);
706
+ const page = await adapter.wire.decodePull(response);
707
+ validatePullPage(fullResync.zeroCursor, page, (left, right) =>
708
+ adapter.wire.compareCursors(left, right),
709
+ );
710
+ }),
711
+ },
712
+ {
713
+ name: 'full resync clears parent, child, and pull-only rows but preserves repair work',
714
+ run: () =>
715
+ inScenario(async ({ clientA, server }) => {
716
+ const pullOnly = change('pull-only-seed', 'pull-only', 12, 'server only');
717
+ const pendingRemote = change('pending-seed', 'pending-edit', 20, 'server value');
718
+ const rejectedRemote = change('rejected-seed', 'rejected-edit', 50, 'server value');
719
+ await adapter.server.seed(server, parentChange);
720
+ await adapter.server.seed(server, childChange);
721
+ await adapter.server.seed(server, pullOnly);
722
+ await adapter.server.seed(server, pendingRemote);
723
+ await adapter.server.seed(server, rejectedRemote);
724
+ await pullAll(clientA, server);
725
+
726
+ await adapter.outbox.enqueue(
727
+ clientA,
728
+ change('rejected-local', 'rejected-edit', 40, 'rejected value'),
729
+ );
730
+ await push(clientA, server);
731
+ await adapter.outbox.enqueue(
732
+ clientA,
733
+ change('pending-local', 'pending-edit', 60, 'pending value'),
734
+ );
735
+ await fullResync.purgeTombstones(server, [childChange, parentChange, pullOnly]);
736
+ const stale = await expectStalePull(clientA, server, fullResync);
737
+
738
+ await fullResync.reset(clientA, stale);
739
+
740
+ assert(
741
+ adapter.wire.compareCursors(
742
+ await adapter.local.readCursor(clientA),
743
+ fullResync.zeroCursor,
744
+ ) === 0,
745
+ 'reset did not atomically store cursor zero',
746
+ );
747
+ await assertPendingState(clientA, 1);
748
+ assert(
749
+ (await adapter.outbox.listRejected(clientA)).length === 1,
750
+ 'reset removed an explicit rejection record',
751
+ );
752
+ const resetRows = await adapter.local.snapshot(clientA);
753
+ assert(
754
+ !resetRows.some((row) => row.entityType === 'container' && row.entityId === 'parent'),
755
+ 'reset retained a server-backed parent',
756
+ );
757
+ assert(
758
+ !resetRows.some((row) => row.entityType === 'item' && row.entityId === 'child'),
759
+ 'reset retained a server-backed child',
760
+ );
761
+ assert(
762
+ !resetRows.some((row) => row.entityType === 'record' && row.entityId === 'pull-only'),
763
+ 'reset retained a pull-only row',
764
+ );
765
+ assert(
766
+ resetRows.some(
767
+ (row) => row.entityId === 'pending-edit' && row.value === 'pending value',
768
+ ),
769
+ 'reset removed the row for a pending local edit',
770
+ );
771
+ assert(
772
+ resetRows.some((row) => row.entityId === 'rejected-edit'),
773
+ 'reset removed the row attached to an explicit rejection',
774
+ );
775
+
776
+ await push(clientA, server);
777
+ await pullAll(clientA, server);
778
+ await assertPendingState(clientA, 0);
779
+ assert(
780
+ (await adapter.outbox.listRejected(clientA)).length === 1,
781
+ 'full rebuild removed an unresolved rejection',
782
+ );
783
+ }),
784
+ },
785
+ {
786
+ name: 'an interrupted full-resync reset rolls back rows and cursor',
787
+ run: () =>
788
+ inScenario(async ({ clientA, server }) => {
789
+ const first = change('reset-first', 'reset-first', 10, 'first');
790
+ const second = change('reset-second', 'reset-second', 20, 'second');
791
+ await adapter.server.seed(server, first);
792
+ await adapter.server.seed(server, second);
793
+ await pullAll(clientA, server);
794
+ await adapter.outbox.enqueue(clientA, change('reset-pending', 'pending', 30, 'local'));
795
+ await fullResync.purgeTombstones(server, [first, second]);
796
+ const stale = await expectStalePull(clientA, server, fullResync);
797
+ const cursorBefore = await adapter.local.readCursor(clientA);
798
+ const rowsBefore = await adapter.local.snapshot(clientA);
799
+
800
+ await expectFailure(
801
+ () => Promise.resolve(fullResync.reset(clientA, stale, { failAfterRows: 1 })),
802
+ 'injected full-resync reset failure resolved successfully',
803
+ );
804
+
805
+ assert(
806
+ adapter.wire.compareCursors(await adapter.local.readCursor(clientA), cursorBefore) ===
807
+ 0,
808
+ 'failed full-resync reset changed the cursor',
809
+ );
810
+ assert(
811
+ sameRows(await adapter.local.snapshot(clientA), rowsBefore),
812
+ 'failed full-resync reset committed a partial row deletion',
813
+ );
814
+ await assertPendingState(clientA, 1);
815
+ }),
816
+ },
817
+ {
818
+ name: 'the product safety hook can defer a disruptive reset',
819
+ run: () =>
820
+ inScenario(async ({ clientA, server }) => {
821
+ const remote = change('defer-seed', 'defer', 10, 'remote');
822
+ await adapter.server.seed(server, remote);
823
+ await pullAll(clientA, server);
824
+ await fullResync.purgeTombstones(server, [remote]);
825
+ await fullResync.setResetSafe(clientA, false);
826
+ const cursorBefore = await adapter.local.readCursor(clientA);
827
+ const rowsBefore = await adapter.local.snapshot(clientA);
828
+
829
+ const result = await recoverFromStaleCursor(clientA, server, fullResync);
830
+
831
+ assert(result === 'deferred', 'unsafe reset was not deferred');
832
+ assert((await fullResync.readResetCount(clientA)) === 0, 'deferred reset ran anyway');
833
+ assert(
834
+ adapter.wire.compareCursors(await adapter.local.readCursor(clientA), cursorBefore) ===
835
+ 0,
836
+ 'deferred reset changed the cursor',
837
+ );
838
+ assert(
839
+ sameRows(await adapter.local.snapshot(clientA), rowsBefore),
840
+ 'deferred reset changed local rows',
841
+ );
842
+ }),
843
+ },
844
+ {
845
+ name: 'a second stale cursor after reset stops without another reset',
846
+ run: () =>
847
+ inScenario(async ({ clientA, server }) => {
848
+ const remote = change('repeat-stale-seed', 'repeat-stale', 10, 'remote');
849
+ await adapter.server.seed(server, remote);
850
+ await pullAll(clientA, server);
851
+ const horizon = await fullResync.purgeTombstones(server, [remote]);
852
+ await fullResync.rejectNextZeroCursor(server, horizon);
853
+
854
+ await expectFailure(
855
+ () => recoverFromStaleCursor(clientA, server, fullResync).then(() => undefined),
856
+ 'second stale cursor restarted full resync',
857
+ );
858
+
859
+ assert(
860
+ (await fullResync.readResetCount(clientA)) === 1,
861
+ 'second stale cursor caused another reset',
862
+ );
863
+ assert(
864
+ adapter.wire.compareCursors(
865
+ await adapter.local.readCursor(clientA),
866
+ fullResync.zeroCursor,
867
+ ) === 0,
868
+ 'failed full rebuild did not leave the explicit rebuild cursor',
869
+ );
870
+ }),
871
+ },
872
+ );
873
+
874
+ return tests;
875
+ }