@syncular/client-react 0.0.1 → 0.0.2-127
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/README.md +30 -0
- package/dist/createSyncularReact.d.ts +6 -3
- package/dist/createSyncularReact.d.ts.map +1 -1
- package/dist/createSyncularReact.js +55 -13
- package/dist/createSyncularReact.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +35 -12
- package/src/__tests__/SyncEngine.test.ts +709 -30
- package/src/__tests__/SyncProvider.strictmode.test.tsx +3 -3
- package/src/__tests__/fingerprint.test.ts +4 -4
- package/src/__tests__/hooks/useMutation.test.tsx +3 -3
- package/src/__tests__/hooks.test.tsx +98 -3
- package/src/__tests__/integration/provider-reconfig.test.ts +17 -29
- package/src/__tests__/integration/push-flow.test.ts +20 -19
- package/src/__tests__/integration/test-setup.ts +95 -46
- package/src/__tests__/test-utils.ts +35 -23
- package/src/__tests__/useMutations.test.tsx +3 -3
- package/src/createSyncularReact.tsx +70 -15
- package/src/index.ts +27 -0
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
type SyncClientDb,
|
|
10
10
|
SyncEngine,
|
|
11
11
|
type SyncEngineConfig,
|
|
12
|
+
type SyncPullSubscriptionResponse,
|
|
12
13
|
} from '@syncular/client';
|
|
13
14
|
import type { Kysely } from 'kysely';
|
|
14
15
|
import {
|
|
15
16
|
createMockDb,
|
|
16
|
-
|
|
17
|
+
createMockHandlerRegistry,
|
|
17
18
|
createMockTransport,
|
|
18
19
|
flushPromises,
|
|
19
20
|
waitFor,
|
|
@@ -35,7 +36,7 @@ describe('SyncEngine', () => {
|
|
|
35
36
|
const config: SyncEngineConfig = {
|
|
36
37
|
db,
|
|
37
38
|
transport: createMockTransport(),
|
|
38
|
-
|
|
39
|
+
handlers: createMockHandlerRegistry(),
|
|
39
40
|
actorId: 'test-actor',
|
|
40
41
|
clientId: 'test-client',
|
|
41
42
|
subscriptions: [],
|
|
@@ -77,6 +78,39 @@ describe('SyncEngine', () => {
|
|
|
77
78
|
|
|
78
79
|
expect(state.transportMode).toBe('polling');
|
|
79
80
|
});
|
|
81
|
+
|
|
82
|
+
it('should auto-detect realtime mode for realtime-capable transport', () => {
|
|
83
|
+
type ConnState = 'disconnected' | 'connecting' | 'connected';
|
|
84
|
+
|
|
85
|
+
let currentState: ConnState = 'disconnected';
|
|
86
|
+
const base = createMockTransport();
|
|
87
|
+
const realtimeTransport = {
|
|
88
|
+
...base,
|
|
89
|
+
connect(
|
|
90
|
+
_args: { clientId: string },
|
|
91
|
+
_onEvent: (_event: unknown) => void,
|
|
92
|
+
onStateChange?: (state: ConnState) => void
|
|
93
|
+
) {
|
|
94
|
+
currentState = 'connected';
|
|
95
|
+
onStateChange?.('connected');
|
|
96
|
+
return () => {
|
|
97
|
+
currentState = 'disconnected';
|
|
98
|
+
onStateChange?.('disconnected');
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
getConnectionState(): ConnState {
|
|
102
|
+
return currentState;
|
|
103
|
+
},
|
|
104
|
+
reconnect() {
|
|
105
|
+
currentState = 'connected';
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const engine = createEngine({ transport: realtimeTransport });
|
|
110
|
+
const state = engine.getState();
|
|
111
|
+
|
|
112
|
+
expect(state.transportMode).toBe('realtime');
|
|
113
|
+
});
|
|
80
114
|
});
|
|
81
115
|
|
|
82
116
|
describe('start/stop lifecycle', () => {
|
|
@@ -171,6 +205,70 @@ describe('SyncEngine', () => {
|
|
|
171
205
|
expect(connectCount).toBe(2);
|
|
172
206
|
});
|
|
173
207
|
|
|
208
|
+
it('should run a catch-up sync after realtime reconnect', async () => {
|
|
209
|
+
type ConnState = 'disconnected' | 'connecting' | 'connected';
|
|
210
|
+
|
|
211
|
+
let currentState: ConnState = 'disconnected';
|
|
212
|
+
let currentStateCallback: ((state: ConnState) => void) | null = null;
|
|
213
|
+
let pullCount = 0;
|
|
214
|
+
|
|
215
|
+
const base = createMockTransport({
|
|
216
|
+
onPull: () => {
|
|
217
|
+
pullCount += 1;
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const realtimeTransport = {
|
|
222
|
+
...base,
|
|
223
|
+
connect(
|
|
224
|
+
_args: { clientId: string },
|
|
225
|
+
_onEvent: (_event: unknown) => void,
|
|
226
|
+
onStateChange?: (state: ConnState) => void
|
|
227
|
+
) {
|
|
228
|
+
currentStateCallback = onStateChange ?? null;
|
|
229
|
+
currentState = 'connecting';
|
|
230
|
+
currentStateCallback?.('connecting');
|
|
231
|
+
queueMicrotask(() => {
|
|
232
|
+
currentState = 'connected';
|
|
233
|
+
currentStateCallback?.('connected');
|
|
234
|
+
});
|
|
235
|
+
return () => {
|
|
236
|
+
currentState = 'disconnected';
|
|
237
|
+
currentStateCallback?.('disconnected');
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
getConnectionState(): ConnState {
|
|
241
|
+
return currentState;
|
|
242
|
+
},
|
|
243
|
+
reconnect() {
|
|
244
|
+
currentState = 'connecting';
|
|
245
|
+
currentStateCallback?.('connecting');
|
|
246
|
+
queueMicrotask(() => {
|
|
247
|
+
currentState = 'connected';
|
|
248
|
+
currentStateCallback?.('connected');
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const engine = createEngine({
|
|
254
|
+
transport: realtimeTransport,
|
|
255
|
+
realtimeEnabled: true,
|
|
256
|
+
});
|
|
257
|
+
await engine.start();
|
|
258
|
+
await waitFor(
|
|
259
|
+
() => engine.getState().connectionState === 'connected',
|
|
260
|
+
500
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
pullCount = 0;
|
|
264
|
+
|
|
265
|
+
engine.disconnect();
|
|
266
|
+
engine.reconnect();
|
|
267
|
+
|
|
268
|
+
await waitFor(() => pullCount >= 2, 2_000);
|
|
269
|
+
expect(pullCount).toBeGreaterThanOrEqual(2);
|
|
270
|
+
});
|
|
271
|
+
|
|
174
272
|
it('should stop and disconnect', async () => {
|
|
175
273
|
const engine = createEngine();
|
|
176
274
|
await engine.start();
|
|
@@ -228,7 +326,7 @@ describe('SyncEngine', () => {
|
|
|
228
326
|
|
|
229
327
|
it('should emit sync:error on failure', async () => {
|
|
230
328
|
const transport = createMockTransport();
|
|
231
|
-
transport.
|
|
329
|
+
transport.sync = async () => {
|
|
232
330
|
throw new Error('Network error');
|
|
233
331
|
};
|
|
234
332
|
|
|
@@ -277,37 +375,258 @@ describe('SyncEngine', () => {
|
|
|
277
375
|
expect(pullCount).toBe(2);
|
|
278
376
|
});
|
|
279
377
|
|
|
280
|
-
it('should
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
378
|
+
it('should preserve first pull round commits when additional rounds run', async () => {
|
|
379
|
+
const handlers = createMockHandlerRegistry();
|
|
380
|
+
handlers.register({
|
|
381
|
+
table: 'sync_outbox_commits',
|
|
382
|
+
applySnapshot: async () => {},
|
|
383
|
+
clearAll: async () => {},
|
|
384
|
+
applyChange: async () => {},
|
|
285
385
|
});
|
|
286
386
|
|
|
287
|
-
|
|
288
|
-
transport
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
387
|
+
let pullCallCount = 0;
|
|
388
|
+
const transport = createMockTransport();
|
|
389
|
+
transport.sync = async (request) => {
|
|
390
|
+
const result: {
|
|
391
|
+
ok: true;
|
|
392
|
+
pull?: { ok: true; subscriptions: SyncPullSubscriptionResponse[] };
|
|
393
|
+
} = { ok: true };
|
|
394
|
+
|
|
395
|
+
if (!request.pull) {
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
pullCallCount += 1;
|
|
400
|
+
|
|
401
|
+
if (pullCallCount === 2) {
|
|
402
|
+
result.pull = {
|
|
403
|
+
ok: true,
|
|
404
|
+
subscriptions: [
|
|
293
405
|
{
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
406
|
+
id: 'sub-1',
|
|
407
|
+
status: 'active',
|
|
408
|
+
scopes: {},
|
|
409
|
+
bootstrap: false,
|
|
410
|
+
nextCursor: 1,
|
|
411
|
+
commits: [
|
|
412
|
+
{
|
|
413
|
+
commitSeq: 1,
|
|
414
|
+
createdAt: new Date(1).toISOString(),
|
|
415
|
+
actorId: 'peer',
|
|
416
|
+
changes: [
|
|
417
|
+
{
|
|
418
|
+
table: 'sync_outbox_commits',
|
|
419
|
+
row_id: 'peer-row',
|
|
420
|
+
op: 'upsert',
|
|
421
|
+
row_json: { id: 'peer-row' },
|
|
422
|
+
row_version: 1,
|
|
423
|
+
scopes: {},
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
snapshots: [],
|
|
299
429
|
},
|
|
300
430
|
],
|
|
431
|
+
};
|
|
432
|
+
} else {
|
|
433
|
+
result.pull = {
|
|
434
|
+
ok: true,
|
|
435
|
+
subscriptions: [
|
|
436
|
+
{
|
|
437
|
+
id: 'sub-1',
|
|
438
|
+
status: 'active',
|
|
439
|
+
scopes: {},
|
|
440
|
+
bootstrap: false,
|
|
441
|
+
nextCursor: pullCallCount >= 2 ? 1 : -1,
|
|
442
|
+
commits: [],
|
|
443
|
+
snapshots: [],
|
|
444
|
+
},
|
|
445
|
+
],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return result;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const engine = createEngine({
|
|
453
|
+
transport,
|
|
454
|
+
handlers,
|
|
455
|
+
subscriptions: [
|
|
456
|
+
{
|
|
457
|
+
id: 'sub-1',
|
|
458
|
+
table: 'sync_outbox_commits',
|
|
459
|
+
scopes: {},
|
|
460
|
+
params: {},
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
await engine.start();
|
|
466
|
+
|
|
467
|
+
const result = await engine.sync();
|
|
468
|
+
expect(result.success).toBe(true);
|
|
469
|
+
expect(result.pullRounds).toBe(2);
|
|
470
|
+
expect(result.pullResponse.subscriptions).toHaveLength(1);
|
|
471
|
+
expect(result.pullResponse.subscriptions[0]?.commits).toHaveLength(1);
|
|
472
|
+
expect(
|
|
473
|
+
result.pullResponse.subscriptions[0]?.commits[0]?.changes
|
|
474
|
+
).toHaveLength(1);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should use WS push for the first outbox commit when available', async () => {
|
|
478
|
+
const base = createMockTransport();
|
|
479
|
+
const syncRequests: Array<{ hasPush: boolean; hasPull: boolean }> = [];
|
|
480
|
+
let wsPushCount = 0;
|
|
481
|
+
|
|
482
|
+
const transport = {
|
|
483
|
+
...base,
|
|
484
|
+
async sync(request: Parameters<typeof base.sync>[0]) {
|
|
485
|
+
syncRequests.push({
|
|
486
|
+
hasPush: request.push !== undefined,
|
|
487
|
+
hasPull: request.pull !== undefined,
|
|
301
488
|
});
|
|
489
|
+
return base.sync(request);
|
|
490
|
+
},
|
|
491
|
+
async pushViaWs(request: {
|
|
492
|
+
clientId: string;
|
|
493
|
+
clientCommitId: string;
|
|
494
|
+
operations: Array<{ op: 'upsert' | 'delete' }>;
|
|
495
|
+
schemaVersion: number;
|
|
496
|
+
}) {
|
|
497
|
+
wsPushCount += 1;
|
|
498
|
+
return {
|
|
499
|
+
ok: true as const,
|
|
500
|
+
status: 'applied' as const,
|
|
501
|
+
commitSeq: 101,
|
|
502
|
+
results: request.operations.map((_, i) => ({
|
|
503
|
+
opIndex: i,
|
|
504
|
+
status: 'applied' as const,
|
|
505
|
+
})),
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
};
|
|
302
509
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
510
|
+
const engine = createEngine({ transport });
|
|
511
|
+
await engine.start();
|
|
512
|
+
|
|
513
|
+
syncRequests.length = 0;
|
|
514
|
+
wsPushCount = 0;
|
|
515
|
+
|
|
516
|
+
await enqueueOutboxCommit(db, {
|
|
517
|
+
operations: [
|
|
518
|
+
{
|
|
519
|
+
table: 'tasks',
|
|
520
|
+
row_id: 'ws-first',
|
|
521
|
+
op: 'upsert',
|
|
522
|
+
payload: { title: 'WS first' },
|
|
523
|
+
base_version: null,
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const result = await engine.sync();
|
|
529
|
+
expect(result.success).toBe(true);
|
|
530
|
+
expect(wsPushCount).toBe(1);
|
|
531
|
+
expect(syncRequests.some((r) => r.hasPull)).toBe(true);
|
|
532
|
+
expect(syncRequests.some((r) => r.hasPush)).toBe(false);
|
|
533
|
+
|
|
534
|
+
const rows = await db
|
|
535
|
+
.selectFrom('sync_outbox_commits')
|
|
536
|
+
.select(['status', 'acked_commit_seq'])
|
|
537
|
+
.execute();
|
|
538
|
+
|
|
539
|
+
expect(rows).toHaveLength(1);
|
|
540
|
+
expect(rows[0]?.status).toBe('acked');
|
|
541
|
+
expect(rows[0]?.acked_commit_seq).toBe(101);
|
|
542
|
+
});
|
|
306
543
|
|
|
307
|
-
|
|
308
|
-
|
|
544
|
+
it('should fall back to HTTP push when WS push returns null', async () => {
|
|
545
|
+
let httpPushCount = 0;
|
|
546
|
+
const base = createMockTransport({
|
|
547
|
+
onPush: () => {
|
|
548
|
+
httpPushCount += 1;
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
const syncRequests: Array<{ hasPush: boolean; hasPull: boolean }> = [];
|
|
552
|
+
let wsPushCount = 0;
|
|
309
553
|
|
|
310
|
-
|
|
554
|
+
const transport = {
|
|
555
|
+
...base,
|
|
556
|
+
async sync(request: Parameters<typeof base.sync>[0]) {
|
|
557
|
+
syncRequests.push({
|
|
558
|
+
hasPush: request.push !== undefined,
|
|
559
|
+
hasPull: request.pull !== undefined,
|
|
560
|
+
});
|
|
561
|
+
return base.sync(request);
|
|
562
|
+
},
|
|
563
|
+
async pushViaWs() {
|
|
564
|
+
wsPushCount += 1;
|
|
565
|
+
return null;
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const engine = createEngine({ transport });
|
|
570
|
+
await engine.start();
|
|
571
|
+
|
|
572
|
+
syncRequests.length = 0;
|
|
573
|
+
wsPushCount = 0;
|
|
574
|
+
httpPushCount = 0;
|
|
575
|
+
|
|
576
|
+
await enqueueOutboxCommit(db, {
|
|
577
|
+
operations: [
|
|
578
|
+
{
|
|
579
|
+
table: 'tasks',
|
|
580
|
+
row_id: 'http-fallback',
|
|
581
|
+
op: 'upsert',
|
|
582
|
+
payload: { title: 'HTTP fallback' },
|
|
583
|
+
base_version: null,
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const result = await engine.sync();
|
|
589
|
+
expect(result.success).toBe(true);
|
|
590
|
+
expect(wsPushCount).toBe(1);
|
|
591
|
+
expect(httpPushCount).toBe(1);
|
|
592
|
+
expect(syncRequests.some((r) => r.hasPull)).toBe(true);
|
|
593
|
+
expect(syncRequests.some((r) => r.hasPush)).toBe(true);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('should flush outbox commits enqueued during pull via queued sync', async () => {
|
|
597
|
+
let enableInjection = false;
|
|
598
|
+
let injected = false;
|
|
599
|
+
const transport = createMockTransport({
|
|
600
|
+
onPull: () => {},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// Delay pull part of sync so we can enqueue a new commit after push finished.
|
|
604
|
+
const originalSync = transport.sync.bind(transport);
|
|
605
|
+
transport.sync = async (request) => {
|
|
606
|
+
if (request.pull) {
|
|
607
|
+
if (enableInjection && !injected) {
|
|
608
|
+
injected = true;
|
|
609
|
+
await enqueueOutboxCommit(db, {
|
|
610
|
+
operations: [
|
|
611
|
+
{
|
|
612
|
+
table: 'tasks',
|
|
613
|
+
row_id: 'late-commit',
|
|
614
|
+
op: 'upsert',
|
|
615
|
+
payload: { title: 'Late' },
|
|
616
|
+
base_version: null,
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// Request another sync while this pull is in-flight.
|
|
622
|
+
void engine.sync();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Small delay so the second sync request is definitely concurrent.
|
|
626
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return originalSync(request);
|
|
311
630
|
};
|
|
312
631
|
|
|
313
632
|
const engine = createEngine({ transport });
|
|
@@ -444,7 +763,7 @@ describe('SyncEngine', () => {
|
|
|
444
763
|
const initialPullCount = pullCount;
|
|
445
764
|
|
|
446
765
|
engine.updateSubscriptions([
|
|
447
|
-
{ id: 'new-sub',
|
|
766
|
+
{ id: 'new-sub', table: 'test', scopes: {} },
|
|
448
767
|
]);
|
|
449
768
|
|
|
450
769
|
await flushPromises();
|
|
@@ -474,15 +793,15 @@ describe('SyncEngine', () => {
|
|
|
474
793
|
async function createTestEngine(
|
|
475
794
|
args: { includeProjects?: boolean } = {}
|
|
476
795
|
): Promise<SyncEngine<TestDb>> {
|
|
477
|
-
const
|
|
478
|
-
|
|
796
|
+
const handlers = new ClientTableRegistry<TestDb>();
|
|
797
|
+
handlers.register({
|
|
479
798
|
table: 'tasks',
|
|
480
799
|
applySnapshot: async () => {},
|
|
481
800
|
clearAll: async () => {},
|
|
482
801
|
applyChange: async () => {},
|
|
483
802
|
});
|
|
484
803
|
if (args.includeProjects) {
|
|
485
|
-
|
|
804
|
+
handlers.register({
|
|
486
805
|
table: 'projects',
|
|
487
806
|
applySnapshot: async () => {},
|
|
488
807
|
clearAll: async () => {},
|
|
@@ -494,7 +813,7 @@ describe('SyncEngine', () => {
|
|
|
494
813
|
const config: SyncEngineConfig<TestDb> = {
|
|
495
814
|
db: testDb,
|
|
496
815
|
transport: createMockTransport(),
|
|
497
|
-
|
|
816
|
+
handlers,
|
|
498
817
|
actorId: 'test-actor',
|
|
499
818
|
clientId: 'test-client',
|
|
500
819
|
subscriptions: [],
|
|
@@ -650,4 +969,364 @@ describe('SyncEngine', () => {
|
|
|
650
969
|
expect(dataChangeEvent.timestamp).toBeGreaterThan(0);
|
|
651
970
|
});
|
|
652
971
|
});
|
|
972
|
+
|
|
973
|
+
describe('WS delivery skip-HTTP', () => {
|
|
974
|
+
type ConnState = 'disconnected' | 'connecting' | 'connected';
|
|
975
|
+
|
|
976
|
+
function createRealtimeTransport(
|
|
977
|
+
baseTransport: ReturnType<typeof createMockTransport>
|
|
978
|
+
) {
|
|
979
|
+
let onEventCb:
|
|
980
|
+
| ((event: {
|
|
981
|
+
event: string;
|
|
982
|
+
data: { cursor?: number; changes?: unknown[]; timestamp: number };
|
|
983
|
+
}) => void)
|
|
984
|
+
| null = null;
|
|
985
|
+
let onStateCb: ((state: ConnState) => void) | null = null;
|
|
986
|
+
|
|
987
|
+
const rt = {
|
|
988
|
+
...baseTransport,
|
|
989
|
+
connect(
|
|
990
|
+
_args: { clientId: string },
|
|
991
|
+
onEvent: typeof onEventCb,
|
|
992
|
+
onStateChange?: typeof onStateCb
|
|
993
|
+
) {
|
|
994
|
+
onEventCb = onEvent;
|
|
995
|
+
onStateCb = onStateChange ?? null;
|
|
996
|
+
queueMicrotask(() => onStateCb?.('connected'));
|
|
997
|
+
return () => {};
|
|
998
|
+
},
|
|
999
|
+
getConnectionState(): ConnState {
|
|
1000
|
+
return 'connected';
|
|
1001
|
+
},
|
|
1002
|
+
reconnect() {},
|
|
1003
|
+
// Helpers for tests
|
|
1004
|
+
simulateSyncEvent(data: {
|
|
1005
|
+
cursor?: number;
|
|
1006
|
+
changes?: unknown[];
|
|
1007
|
+
timestamp?: number;
|
|
1008
|
+
}) {
|
|
1009
|
+
onEventCb?.({
|
|
1010
|
+
event: 'sync',
|
|
1011
|
+
data: { timestamp: Date.now(), ...data },
|
|
1012
|
+
});
|
|
1013
|
+
},
|
|
1014
|
+
};
|
|
1015
|
+
return rt;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
it('should skip HTTP sync when WS delivers changes with cursor', async () => {
|
|
1019
|
+
let syncCallCount = 0;
|
|
1020
|
+
const base = createMockTransport({
|
|
1021
|
+
onPull: () => {
|
|
1022
|
+
syncCallCount++;
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
const rt = createRealtimeTransport(base);
|
|
1026
|
+
|
|
1027
|
+
const handlers = new ClientTableRegistry();
|
|
1028
|
+
handlers.register({
|
|
1029
|
+
table: 'tasks',
|
|
1030
|
+
applySnapshot: async () => {},
|
|
1031
|
+
clearAll: async () => {},
|
|
1032
|
+
applyChange: async () => {},
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
const engine = createEngine({
|
|
1036
|
+
transport: rt,
|
|
1037
|
+
handlers,
|
|
1038
|
+
realtimeEnabled: true,
|
|
1039
|
+
});
|
|
1040
|
+
await engine.start();
|
|
1041
|
+
await waitFor(
|
|
1042
|
+
() => engine.getState().connectionState === 'connected',
|
|
1043
|
+
500
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
// Reset after initial sync
|
|
1047
|
+
syncCallCount = 0;
|
|
1048
|
+
|
|
1049
|
+
let syncCompleteCount = 0;
|
|
1050
|
+
engine.on('sync:complete', () => {
|
|
1051
|
+
syncCompleteCount++;
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// Simulate WS delivering inline changes with cursor
|
|
1055
|
+
rt.simulateSyncEvent({
|
|
1056
|
+
cursor: 100,
|
|
1057
|
+
changes: [
|
|
1058
|
+
{
|
|
1059
|
+
table: 'tasks',
|
|
1060
|
+
row_id: 'task-1',
|
|
1061
|
+
op: 'upsert',
|
|
1062
|
+
row_json: { id: 'task-1', title: 'Hello' },
|
|
1063
|
+
row_version: 1,
|
|
1064
|
+
scopes: {},
|
|
1065
|
+
},
|
|
1066
|
+
],
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
// Wait for handleWsDelivery to complete
|
|
1070
|
+
await flushPromises();
|
|
1071
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1072
|
+
|
|
1073
|
+
// Should NOT have called transport.sync (HTTP pull)
|
|
1074
|
+
expect(syncCallCount).toBe(0);
|
|
1075
|
+
// Should have emitted sync:complete
|
|
1076
|
+
expect(syncCompleteCount).toBeGreaterThanOrEqual(1);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('should fall back to HTTP sync when no cursor in WS event', async () => {
|
|
1080
|
+
let syncCallCount = 0;
|
|
1081
|
+
const base = createMockTransport({
|
|
1082
|
+
onPull: () => {
|
|
1083
|
+
syncCallCount++;
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
1086
|
+
const rt = createRealtimeTransport(base);
|
|
1087
|
+
|
|
1088
|
+
const engine = createEngine({
|
|
1089
|
+
transport: rt,
|
|
1090
|
+
realtimeEnabled: true,
|
|
1091
|
+
});
|
|
1092
|
+
await engine.start();
|
|
1093
|
+
await waitFor(
|
|
1094
|
+
() => engine.getState().connectionState === 'connected',
|
|
1095
|
+
500
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
syncCallCount = 0;
|
|
1099
|
+
|
|
1100
|
+
// Simulate WS event with changes but no cursor
|
|
1101
|
+
rt.simulateSyncEvent({
|
|
1102
|
+
changes: [
|
|
1103
|
+
{
|
|
1104
|
+
table: 'tasks',
|
|
1105
|
+
row_id: 'task-1',
|
|
1106
|
+
op: 'upsert',
|
|
1107
|
+
row_json: {},
|
|
1108
|
+
row_version: 1,
|
|
1109
|
+
scopes: {},
|
|
1110
|
+
},
|
|
1111
|
+
],
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await flushPromises();
|
|
1115
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1116
|
+
|
|
1117
|
+
// Should fall back to HTTP
|
|
1118
|
+
expect(syncCallCount).toBeGreaterThanOrEqual(1);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('should fall back to HTTP sync when no changes in WS event (cursor-only)', async () => {
|
|
1122
|
+
let syncCallCount = 0;
|
|
1123
|
+
const base = createMockTransport({
|
|
1124
|
+
onPull: () => {
|
|
1125
|
+
syncCallCount++;
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1128
|
+
const rt = createRealtimeTransport(base);
|
|
1129
|
+
|
|
1130
|
+
const engine = createEngine({
|
|
1131
|
+
transport: rt,
|
|
1132
|
+
realtimeEnabled: true,
|
|
1133
|
+
});
|
|
1134
|
+
await engine.start();
|
|
1135
|
+
await waitFor(
|
|
1136
|
+
() => engine.getState().connectionState === 'connected',
|
|
1137
|
+
500
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
syncCallCount = 0;
|
|
1141
|
+
|
|
1142
|
+
// Simulate cursor-only WS event (no inline changes)
|
|
1143
|
+
rt.simulateSyncEvent({ cursor: 100 });
|
|
1144
|
+
|
|
1145
|
+
await flushPromises();
|
|
1146
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1147
|
+
|
|
1148
|
+
// Should fall back to HTTP
|
|
1149
|
+
expect(syncCallCount).toBeGreaterThanOrEqual(1);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it('should fall back to HTTP sync when outbox has pending commits', async () => {
|
|
1153
|
+
let syncCallCount = 0;
|
|
1154
|
+
const base = createMockTransport({
|
|
1155
|
+
onPull: () => {
|
|
1156
|
+
syncCallCount++;
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
const rt = createRealtimeTransport(base);
|
|
1160
|
+
|
|
1161
|
+
const handlers = new ClientTableRegistry();
|
|
1162
|
+
handlers.register({
|
|
1163
|
+
table: 'tasks',
|
|
1164
|
+
applySnapshot: async () => {},
|
|
1165
|
+
clearAll: async () => {},
|
|
1166
|
+
applyChange: async () => {},
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
const engine = createEngine({
|
|
1170
|
+
transport: rt,
|
|
1171
|
+
handlers,
|
|
1172
|
+
realtimeEnabled: true,
|
|
1173
|
+
});
|
|
1174
|
+
await engine.start();
|
|
1175
|
+
await waitFor(
|
|
1176
|
+
() => engine.getState().connectionState === 'connected',
|
|
1177
|
+
500
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
// Enqueue a commit to create pending outbox state
|
|
1181
|
+
await enqueueOutboxCommit(db, {
|
|
1182
|
+
operations: [
|
|
1183
|
+
{
|
|
1184
|
+
table: 'tasks',
|
|
1185
|
+
row_id: 'task-1',
|
|
1186
|
+
op: 'upsert',
|
|
1187
|
+
payload: { title: 'Test' },
|
|
1188
|
+
base_version: null,
|
|
1189
|
+
},
|
|
1190
|
+
],
|
|
1191
|
+
});
|
|
1192
|
+
await engine.refreshOutboxStats();
|
|
1193
|
+
|
|
1194
|
+
syncCallCount = 0;
|
|
1195
|
+
|
|
1196
|
+
// Simulate WS with inline changes
|
|
1197
|
+
rt.simulateSyncEvent({
|
|
1198
|
+
cursor: 100,
|
|
1199
|
+
changes: [
|
|
1200
|
+
{
|
|
1201
|
+
table: 'tasks',
|
|
1202
|
+
row_id: 'task-2',
|
|
1203
|
+
op: 'upsert',
|
|
1204
|
+
row_json: { id: 'task-2' },
|
|
1205
|
+
row_version: 1,
|
|
1206
|
+
scopes: {},
|
|
1207
|
+
},
|
|
1208
|
+
],
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
await flushPromises();
|
|
1212
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1213
|
+
|
|
1214
|
+
// Should fall back to HTTP to push outbox
|
|
1215
|
+
expect(syncCallCount).toBeGreaterThanOrEqual(1);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it('should fall back to HTTP sync when afterPull plugins exist', async () => {
|
|
1219
|
+
let syncCallCount = 0;
|
|
1220
|
+
let inlineApplyCount = 0;
|
|
1221
|
+
const base = createMockTransport({
|
|
1222
|
+
onPull: () => {
|
|
1223
|
+
syncCallCount++;
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
const rt = createRealtimeTransport(base);
|
|
1227
|
+
|
|
1228
|
+
const handlers = new ClientTableRegistry();
|
|
1229
|
+
handlers.register({
|
|
1230
|
+
table: 'tasks',
|
|
1231
|
+
applySnapshot: async () => {},
|
|
1232
|
+
clearAll: async () => {},
|
|
1233
|
+
applyChange: async () => {
|
|
1234
|
+
inlineApplyCount++;
|
|
1235
|
+
},
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
const engine = createEngine({
|
|
1239
|
+
transport: rt,
|
|
1240
|
+
handlers,
|
|
1241
|
+
realtimeEnabled: true,
|
|
1242
|
+
plugins: [
|
|
1243
|
+
{
|
|
1244
|
+
name: 'test-plugin',
|
|
1245
|
+
async afterPull(_ctx, args) {
|
|
1246
|
+
return args.response;
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
],
|
|
1250
|
+
});
|
|
1251
|
+
await engine.start();
|
|
1252
|
+
await waitFor(
|
|
1253
|
+
() => engine.getState().connectionState === 'connected',
|
|
1254
|
+
500
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
syncCallCount = 0;
|
|
1258
|
+
|
|
1259
|
+
// Simulate WS with inline changes
|
|
1260
|
+
rt.simulateSyncEvent({
|
|
1261
|
+
cursor: 100,
|
|
1262
|
+
changes: [
|
|
1263
|
+
{
|
|
1264
|
+
table: 'tasks',
|
|
1265
|
+
row_id: 'task-1',
|
|
1266
|
+
op: 'upsert',
|
|
1267
|
+
row_json: { id: 'task-1' },
|
|
1268
|
+
row_version: 1,
|
|
1269
|
+
scopes: {},
|
|
1270
|
+
},
|
|
1271
|
+
],
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
await flushPromises();
|
|
1275
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1276
|
+
|
|
1277
|
+
// Should fall back to HTTP because afterPull plugin exists
|
|
1278
|
+
expect(syncCallCount).toBeGreaterThanOrEqual(1);
|
|
1279
|
+
// Should not apply inline WS payload when afterPull plugins are present.
|
|
1280
|
+
expect(inlineApplyCount).toBe(0);
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it('should emit data:change when WS delivery skips HTTP', async () => {
|
|
1284
|
+
const base = createMockTransport();
|
|
1285
|
+
const rt = createRealtimeTransport(base);
|
|
1286
|
+
|
|
1287
|
+
const handlers = new ClientTableRegistry();
|
|
1288
|
+
handlers.register({
|
|
1289
|
+
table: 'tasks',
|
|
1290
|
+
applySnapshot: async () => {},
|
|
1291
|
+
clearAll: async () => {},
|
|
1292
|
+
applyChange: async () => {},
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
const engine = createEngine({
|
|
1296
|
+
transport: rt,
|
|
1297
|
+
handlers,
|
|
1298
|
+
realtimeEnabled: true,
|
|
1299
|
+
});
|
|
1300
|
+
await engine.start();
|
|
1301
|
+
await waitFor(
|
|
1302
|
+
() => engine.getState().connectionState === 'connected',
|
|
1303
|
+
500
|
|
1304
|
+
);
|
|
1305
|
+
|
|
1306
|
+
const dataChangeScopes: string[][] = [];
|
|
1307
|
+
engine.on('data:change', (payload) => {
|
|
1308
|
+
dataChangeScopes.push(payload.scopes);
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
rt.simulateSyncEvent({
|
|
1312
|
+
cursor: 100,
|
|
1313
|
+
changes: [
|
|
1314
|
+
{
|
|
1315
|
+
table: 'tasks',
|
|
1316
|
+
row_id: 'task-1',
|
|
1317
|
+
op: 'upsert',
|
|
1318
|
+
row_json: { id: 'task-1' },
|
|
1319
|
+
row_version: 1,
|
|
1320
|
+
scopes: {},
|
|
1321
|
+
},
|
|
1322
|
+
],
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
await flushPromises();
|
|
1326
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1327
|
+
|
|
1328
|
+
// Should have emitted data:change with 'tasks'
|
|
1329
|
+
expect(dataChangeScopes.some((s) => s.includes('tasks'))).toBe(true);
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
653
1332
|
});
|