@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.
@@ -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
- createMockShapeRegistry,
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
- shapes: createMockShapeRegistry(),
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.pull = async () => {
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 flush outbox commits enqueued during pull via queued sync', async () => {
281
- let enableInjection = false;
282
- let injected = false;
283
- const transport = createMockTransport({
284
- onPull: () => {},
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
- // Delay pull so we can enqueue a new commit after push finished.
288
- transport.pull = async (_request) => {
289
- if (enableInjection && !injected) {
290
- injected = true;
291
- await enqueueOutboxCommit(db, {
292
- operations: [
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
- table: 'tasks',
295
- row_id: 'late-commit',
296
- op: 'upsert',
297
- payload: { title: 'Late' },
298
- base_version: null,
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
- // Request another sync while this pull is in-flight.
304
- void engine.sync();
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
- // Small delay so the second sync request is definitely concurrent.
308
- await new Promise((r) => setTimeout(r, 10));
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
- return { ok: true, subscriptions: [] };
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', shape: 'test', scopes: {} },
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 shapes = new ClientTableRegistry<TestDb>();
478
- shapes.register({
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
- shapes.register({
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
- shapes,
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
  });