@syncular/client-react 0.0.1-60
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/dist/createSyncularReact.d.ts +222 -0
- package/dist/createSyncularReact.d.ts.map +1 -0
- package/dist/createSyncularReact.js +775 -0
- package/dist/createSyncularReact.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/SyncEngine.test.ts +1332 -0
- package/src/__tests__/SyncProvider.strictmode.test.tsx +117 -0
- package/src/__tests__/fingerprint.test.ts +181 -0
- package/src/__tests__/hooks/useMutation.test.tsx +468 -0
- package/src/__tests__/hooks.test.tsx +384 -0
- package/src/__tests__/integration/conflict-resolution.test.ts +439 -0
- package/src/__tests__/integration/provider-reconfig.test.ts +279 -0
- package/src/__tests__/integration/push-flow.test.ts +321 -0
- package/src/__tests__/integration/realtime-sync.test.ts +222 -0
- package/src/__tests__/integration/self-conflict.test.ts +91 -0
- package/src/__tests__/integration/test-setup.ts +550 -0
- package/src/__tests__/integration/two-client-sync.test.ts +373 -0
- package/src/__tests__/setup.ts +7 -0
- package/src/__tests__/test-utils.ts +199 -0
- package/src/__tests__/useMutations.test.tsx +198 -0
- package/src/createSyncularReact.tsx +1346 -0
- package/src/index.ts +36 -0
|
@@ -0,0 +1,1332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SyncEngine
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
ClientTableRegistry,
|
|
8
|
+
enqueueOutboxCommit,
|
|
9
|
+
type SyncClientDb,
|
|
10
|
+
SyncEngine,
|
|
11
|
+
type SyncEngineConfig,
|
|
12
|
+
type SyncPullSubscriptionResponse,
|
|
13
|
+
} from '@syncular/client';
|
|
14
|
+
import type { Kysely } from 'kysely';
|
|
15
|
+
import {
|
|
16
|
+
createMockDb,
|
|
17
|
+
createMockShapeRegistry,
|
|
18
|
+
createMockTransport,
|
|
19
|
+
flushPromises,
|
|
20
|
+
waitFor,
|
|
21
|
+
} from './test-utils';
|
|
22
|
+
|
|
23
|
+
describe('SyncEngine', () => {
|
|
24
|
+
let db: Kysely<SyncClientDb>;
|
|
25
|
+
let engine: SyncEngine;
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
db = await createMockDb();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
engine?.destroy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function createEngine(overrides: Partial<SyncEngineConfig> = {}): SyncEngine {
|
|
36
|
+
const config: SyncEngineConfig = {
|
|
37
|
+
db,
|
|
38
|
+
transport: createMockTransport(),
|
|
39
|
+
shapes: createMockShapeRegistry(),
|
|
40
|
+
actorId: 'test-actor',
|
|
41
|
+
clientId: 'test-client',
|
|
42
|
+
subscriptions: [],
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
engine = new SyncEngine(config);
|
|
46
|
+
return engine;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('initialization', () => {
|
|
50
|
+
it('should create engine with initial state', () => {
|
|
51
|
+
const engine = createEngine();
|
|
52
|
+
const state = engine.getState();
|
|
53
|
+
|
|
54
|
+
expect(state.enabled).toBe(true);
|
|
55
|
+
expect(state.isSyncing).toBe(false);
|
|
56
|
+
expect(state.connectionState).toBe('disconnected');
|
|
57
|
+
expect(state.lastSyncAt).toBe(null);
|
|
58
|
+
expect(state.error).toBe(null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should be disabled when actorId is null', () => {
|
|
62
|
+
const engine = createEngine({ actorId: null });
|
|
63
|
+
const state = engine.getState();
|
|
64
|
+
|
|
65
|
+
expect(state.enabled).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should be disabled when clientId is null', () => {
|
|
69
|
+
const engine = createEngine({ clientId: null });
|
|
70
|
+
const state = engine.getState();
|
|
71
|
+
|
|
72
|
+
expect(state.enabled).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should detect polling mode when realtimeEnabled is false', () => {
|
|
76
|
+
const engine = createEngine({ realtimeEnabled: false });
|
|
77
|
+
const state = engine.getState();
|
|
78
|
+
|
|
79
|
+
expect(state.transportMode).toBe('polling');
|
|
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
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('start/stop lifecycle', () => {
|
|
117
|
+
it('should start and set connection state', async () => {
|
|
118
|
+
const engine = createEngine();
|
|
119
|
+
await engine.start();
|
|
120
|
+
|
|
121
|
+
const state = engine.getState();
|
|
122
|
+
expect(state.connectionState).toBe('connected');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should reconnect in polling mode after disconnect', async () => {
|
|
126
|
+
const engine = createEngine({ realtimeEnabled: false });
|
|
127
|
+
await engine.start();
|
|
128
|
+
|
|
129
|
+
engine.disconnect();
|
|
130
|
+
expect(engine.getState().connectionState).toBe('disconnected');
|
|
131
|
+
|
|
132
|
+
engine.reconnect();
|
|
133
|
+
await waitFor(
|
|
134
|
+
() => engine.getState().connectionState === 'connected',
|
|
135
|
+
500
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should reconnect in realtime mode after disconnect', async () => {
|
|
140
|
+
type ConnState = 'disconnected' | 'connecting' | 'connected';
|
|
141
|
+
|
|
142
|
+
let connectCount = 0;
|
|
143
|
+
let reconnectCount = 0;
|
|
144
|
+
let currentState: ConnState = 'disconnected';
|
|
145
|
+
let currentStateCallback: ((state: ConnState) => void) | null = null;
|
|
146
|
+
|
|
147
|
+
const base = createMockTransport();
|
|
148
|
+
const sseTransport = {
|
|
149
|
+
...base,
|
|
150
|
+
connect(
|
|
151
|
+
_args: { clientId: string },
|
|
152
|
+
_onEvent: (_event: unknown) => void,
|
|
153
|
+
onStateChange?: (state: ConnState) => void
|
|
154
|
+
) {
|
|
155
|
+
connectCount += 1;
|
|
156
|
+
currentStateCallback = onStateChange ?? null;
|
|
157
|
+
currentState = 'connecting';
|
|
158
|
+
currentStateCallback?.('connecting');
|
|
159
|
+
queueMicrotask(() => {
|
|
160
|
+
currentState = 'connected';
|
|
161
|
+
currentStateCallback?.('connected');
|
|
162
|
+
});
|
|
163
|
+
return () => {
|
|
164
|
+
currentState = 'disconnected';
|
|
165
|
+
currentStateCallback?.('disconnected');
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
getConnectionState(): ConnState {
|
|
169
|
+
return currentState;
|
|
170
|
+
},
|
|
171
|
+
reconnect() {
|
|
172
|
+
reconnectCount += 1;
|
|
173
|
+
currentState = 'connecting';
|
|
174
|
+
currentStateCallback?.('connecting');
|
|
175
|
+
queueMicrotask(() => {
|
|
176
|
+
currentState = 'connected';
|
|
177
|
+
currentStateCallback?.('connected');
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const engine = createEngine({
|
|
183
|
+
transport: sseTransport,
|
|
184
|
+
realtimeEnabled: true,
|
|
185
|
+
});
|
|
186
|
+
await engine.start();
|
|
187
|
+
await waitFor(
|
|
188
|
+
() => engine.getState().connectionState === 'connected',
|
|
189
|
+
500
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// While connected, reconnect should call transport.reconnect().
|
|
193
|
+
engine.reconnect();
|
|
194
|
+
expect(reconnectCount).toBe(1);
|
|
195
|
+
|
|
196
|
+
// After disconnect, reconnect should re-register callbacks via connect().
|
|
197
|
+
engine.disconnect();
|
|
198
|
+
expect(engine.getState().connectionState).toBe('disconnected');
|
|
199
|
+
|
|
200
|
+
engine.reconnect();
|
|
201
|
+
await waitFor(
|
|
202
|
+
() => engine.getState().connectionState === 'connected',
|
|
203
|
+
500
|
|
204
|
+
);
|
|
205
|
+
expect(connectCount).toBe(2);
|
|
206
|
+
});
|
|
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
|
+
|
|
272
|
+
it('should stop and disconnect', async () => {
|
|
273
|
+
const engine = createEngine();
|
|
274
|
+
await engine.start();
|
|
275
|
+
engine.stop();
|
|
276
|
+
|
|
277
|
+
const state = engine.getState();
|
|
278
|
+
expect(state.connectionState).toBe('disconnected');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should throw when starting destroyed engine', async () => {
|
|
282
|
+
const engine = createEngine();
|
|
283
|
+
engine.destroy();
|
|
284
|
+
|
|
285
|
+
await expect(engine.start()).rejects.toThrow('destroyed');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('sync cycle', () => {
|
|
290
|
+
it('should perform sync and update state', async () => {
|
|
291
|
+
const engine = createEngine();
|
|
292
|
+
await engine.start();
|
|
293
|
+
|
|
294
|
+
const result = await engine.sync();
|
|
295
|
+
|
|
296
|
+
expect(result.success).toBe(true);
|
|
297
|
+
expect(engine.getState().lastSyncAt).not.toBe(null);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should emit sync:start event', async () => {
|
|
301
|
+
const engine = createEngine();
|
|
302
|
+
await engine.start();
|
|
303
|
+
|
|
304
|
+
let eventReceived = false;
|
|
305
|
+
engine.on('sync:start', () => {
|
|
306
|
+
eventReceived = true;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await engine.sync();
|
|
310
|
+
expect(eventReceived).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should emit sync:complete event', async () => {
|
|
314
|
+
const engine = createEngine();
|
|
315
|
+
await engine.start();
|
|
316
|
+
|
|
317
|
+
let eventPayload: { timestamp: number } = { timestamp: 0 };
|
|
318
|
+
engine.on('sync:complete', (payload) => {
|
|
319
|
+
eventPayload = payload;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await engine.sync();
|
|
323
|
+
|
|
324
|
+
expect(eventPayload.timestamp).toBeGreaterThan(0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should emit sync:error on failure', async () => {
|
|
328
|
+
const transport = createMockTransport();
|
|
329
|
+
transport.sync = async () => {
|
|
330
|
+
throw new Error('Network error');
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const engine = createEngine({ transport });
|
|
334
|
+
await engine.start();
|
|
335
|
+
|
|
336
|
+
let errorPayload: { message: string } = { message: '' };
|
|
337
|
+
engine.on('sync:error', (payload) => {
|
|
338
|
+
errorPayload = payload;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const result = await engine.sync();
|
|
342
|
+
|
|
343
|
+
expect(result.success).toBe(false);
|
|
344
|
+
expect(errorPayload.message).toBe('Network error');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should dedupe concurrent sync calls', async () => {
|
|
348
|
+
let pullCount = 0;
|
|
349
|
+
const transport = createMockTransport({
|
|
350
|
+
onPull: () => {
|
|
351
|
+
pullCount++;
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const engine = createEngine({ transport });
|
|
356
|
+
await engine.start();
|
|
357
|
+
|
|
358
|
+
// Reset count after start's initial sync
|
|
359
|
+
pullCount = 0;
|
|
360
|
+
|
|
361
|
+
// Trigger multiple concurrent syncs
|
|
362
|
+
const [r1, r2, r3] = await Promise.all([
|
|
363
|
+
engine.sync(),
|
|
364
|
+
engine.sync(),
|
|
365
|
+
engine.sync(),
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
// All should resolve to the same result
|
|
369
|
+
expect(r1.success).toBe(true);
|
|
370
|
+
expect(r2.success).toBe(true);
|
|
371
|
+
expect(r3.success).toBe(true);
|
|
372
|
+
|
|
373
|
+
// Only one sync runs at a time, but we should schedule at most one extra
|
|
374
|
+
// pass if sync() is requested while a sync is already in-flight.
|
|
375
|
+
expect(pullCount).toBe(2);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should preserve first pull round commits when additional rounds run', async () => {
|
|
379
|
+
const shapes = createMockShapeRegistry();
|
|
380
|
+
shapes.register({
|
|
381
|
+
table: 'sync_outbox_commits',
|
|
382
|
+
applySnapshot: async () => {},
|
|
383
|
+
clearAll: async () => {},
|
|
384
|
+
applyChange: async () => {},
|
|
385
|
+
});
|
|
386
|
+
|
|
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: [
|
|
405
|
+
{
|
|
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: [],
|
|
429
|
+
},
|
|
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
|
+
shapes,
|
|
455
|
+
subscriptions: [
|
|
456
|
+
{
|
|
457
|
+
id: 'sub-1',
|
|
458
|
+
shape: '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,
|
|
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
|
+
};
|
|
509
|
+
|
|
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
|
+
});
|
|
543
|
+
|
|
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;
|
|
553
|
+
|
|
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);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const engine = createEngine({ transport });
|
|
633
|
+
await engine.start();
|
|
634
|
+
enableInjection = true;
|
|
635
|
+
|
|
636
|
+
// Enqueue a commit that will be pushed in the first cycle.
|
|
637
|
+
await enqueueOutboxCommit(db, {
|
|
638
|
+
operations: [
|
|
639
|
+
{
|
|
640
|
+
table: 'tasks',
|
|
641
|
+
row_id: 'first-commit',
|
|
642
|
+
op: 'upsert',
|
|
643
|
+
payload: { title: 'First' },
|
|
644
|
+
base_version: null,
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
await engine.sync();
|
|
650
|
+
|
|
651
|
+
// Both commits should be acked (late commit is pushed by the queued follow-up sync).
|
|
652
|
+
const remaining = await db
|
|
653
|
+
.selectFrom('sync_outbox_commits')
|
|
654
|
+
.select(['status'])
|
|
655
|
+
.where('status', '!=', 'acked')
|
|
656
|
+
.execute();
|
|
657
|
+
|
|
658
|
+
expect(remaining.length).toBe(0);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
describe('event subscriptions', () => {
|
|
663
|
+
it('should allow subscribing to events', async () => {
|
|
664
|
+
const engine = createEngine();
|
|
665
|
+
|
|
666
|
+
const events: string[] = [];
|
|
667
|
+
engine.on('sync:start', () => events.push('start'));
|
|
668
|
+
engine.on('sync:complete', () => events.push('complete'));
|
|
669
|
+
|
|
670
|
+
await engine.start();
|
|
671
|
+
await engine.sync();
|
|
672
|
+
|
|
673
|
+
expect(events).toContain('start');
|
|
674
|
+
expect(events).toContain('complete');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('should allow unsubscribing from events', async () => {
|
|
678
|
+
const engine = createEngine();
|
|
679
|
+
|
|
680
|
+
let callCount = 0;
|
|
681
|
+
const unsubscribe = engine.on('sync:complete', () => {
|
|
682
|
+
callCount++;
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
await engine.start();
|
|
686
|
+
unsubscribe();
|
|
687
|
+
await engine.sync();
|
|
688
|
+
|
|
689
|
+
// Only the initial sync from start() should have triggered
|
|
690
|
+
expect(callCount).toBe(1);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('should support subscribe() for all events', async () => {
|
|
694
|
+
const engine = createEngine();
|
|
695
|
+
|
|
696
|
+
let callCount = 0;
|
|
697
|
+
engine.subscribe(() => {
|
|
698
|
+
callCount++;
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
await engine.start();
|
|
702
|
+
await engine.sync();
|
|
703
|
+
|
|
704
|
+
// Multiple events should trigger the callback
|
|
705
|
+
expect(callCount).toBeGreaterThan(1);
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
describe('outbox stats', () => {
|
|
710
|
+
it('should refresh outbox stats', async () => {
|
|
711
|
+
const engine = createEngine();
|
|
712
|
+
await engine.start();
|
|
713
|
+
|
|
714
|
+
const stats = await engine.refreshOutboxStats();
|
|
715
|
+
|
|
716
|
+
expect(stats).toEqual({
|
|
717
|
+
pending: 0,
|
|
718
|
+
sending: 0,
|
|
719
|
+
failed: 0,
|
|
720
|
+
acked: 0,
|
|
721
|
+
total: 0,
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('should emit outbox:change event', async () => {
|
|
726
|
+
const engine = createEngine();
|
|
727
|
+
await engine.start();
|
|
728
|
+
|
|
729
|
+
let eventPayload: { pendingCount: number } = { pendingCount: -1 };
|
|
730
|
+
engine.on('outbox:change', (payload) => {
|
|
731
|
+
eventPayload = payload;
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
await engine.refreshOutboxStats();
|
|
735
|
+
|
|
736
|
+
expect(eventPayload.pendingCount).toBe(0);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
describe('conflicts', () => {
|
|
741
|
+
it('should return empty conflicts list initially', async () => {
|
|
742
|
+
const engine = createEngine();
|
|
743
|
+
await engine.start();
|
|
744
|
+
|
|
745
|
+
const conflicts = await engine.getConflicts();
|
|
746
|
+
|
|
747
|
+
expect(conflicts).toEqual([]);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
describe('subscriptions', () => {
|
|
752
|
+
it('should update subscriptions and trigger sync', async () => {
|
|
753
|
+
let pullCount = 0;
|
|
754
|
+
const transport = createMockTransport({
|
|
755
|
+
onPull: () => {
|
|
756
|
+
pullCount++;
|
|
757
|
+
},
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const engine = createEngine({ transport });
|
|
761
|
+
await engine.start();
|
|
762
|
+
|
|
763
|
+
const initialPullCount = pullCount;
|
|
764
|
+
|
|
765
|
+
engine.updateSubscriptions([
|
|
766
|
+
{ id: 'new-sub', shape: 'test', scopes: {} },
|
|
767
|
+
]);
|
|
768
|
+
|
|
769
|
+
await flushPromises();
|
|
770
|
+
await waitFor(() => pullCount > initialPullCount, 500);
|
|
771
|
+
|
|
772
|
+
expect(pullCount).toBeGreaterThan(initialPullCount);
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
describe('mutation timestamps', () => {
|
|
777
|
+
interface TestDb extends SyncClientDb {
|
|
778
|
+
tasks: { id: string; title: string };
|
|
779
|
+
projects: { id: string; name: string };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
let local: {
|
|
783
|
+
engine: SyncEngine<TestDb>;
|
|
784
|
+
db: Kysely<TestDb>;
|
|
785
|
+
} | null = null;
|
|
786
|
+
|
|
787
|
+
afterEach(() => {
|
|
788
|
+
local?.engine.destroy();
|
|
789
|
+
void local?.db.destroy();
|
|
790
|
+
local = null;
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
async function createTestEngine(
|
|
794
|
+
args: { includeProjects?: boolean } = {}
|
|
795
|
+
): Promise<SyncEngine<TestDb>> {
|
|
796
|
+
const shapes = new ClientTableRegistry<TestDb>();
|
|
797
|
+
shapes.register({
|
|
798
|
+
table: 'tasks',
|
|
799
|
+
applySnapshot: async () => {},
|
|
800
|
+
clearAll: async () => {},
|
|
801
|
+
applyChange: async () => {},
|
|
802
|
+
});
|
|
803
|
+
if (args.includeProjects) {
|
|
804
|
+
shapes.register({
|
|
805
|
+
table: 'projects',
|
|
806
|
+
applySnapshot: async () => {},
|
|
807
|
+
clearAll: async () => {},
|
|
808
|
+
applyChange: async () => {},
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const testDb = await createMockDb<TestDb>();
|
|
813
|
+
const config: SyncEngineConfig<TestDb> = {
|
|
814
|
+
db: testDb,
|
|
815
|
+
transport: createMockTransport(),
|
|
816
|
+
shapes,
|
|
817
|
+
actorId: 'test-actor',
|
|
818
|
+
clientId: 'test-client',
|
|
819
|
+
subscriptions: [],
|
|
820
|
+
};
|
|
821
|
+
const engine = new SyncEngine<TestDb>(config);
|
|
822
|
+
await engine.start();
|
|
823
|
+
|
|
824
|
+
local = { engine, db: testDb };
|
|
825
|
+
return engine;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
it('should return 0 for rows with no mutations', () => {
|
|
829
|
+
const engine = createEngine();
|
|
830
|
+
|
|
831
|
+
expect(engine.getMutationTimestamp('tasks', 'unknown-id')).toBe(0);
|
|
832
|
+
expect(engine.getMutationTimestamp('other_table', 'any-id')).toBe(0);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it('should track mutation timestamps after applyLocalMutation', async () => {
|
|
836
|
+
const engine = await createTestEngine();
|
|
837
|
+
|
|
838
|
+
// Initially no timestamp
|
|
839
|
+
expect(engine.getMutationTimestamp('tasks', 'task-1')).toBe(0);
|
|
840
|
+
|
|
841
|
+
const beforeMutation = Date.now();
|
|
842
|
+
|
|
843
|
+
// Apply a local mutation
|
|
844
|
+
await engine.applyLocalMutation([
|
|
845
|
+
{
|
|
846
|
+
table: 'tasks',
|
|
847
|
+
rowId: 'task-1',
|
|
848
|
+
op: 'upsert',
|
|
849
|
+
payload: { id: 'task-1', title: 'Test Task' },
|
|
850
|
+
},
|
|
851
|
+
]);
|
|
852
|
+
|
|
853
|
+
const afterMutation = Date.now();
|
|
854
|
+
|
|
855
|
+
// Should now have a timestamp
|
|
856
|
+
const timestamp = engine.getMutationTimestamp('tasks', 'task-1');
|
|
857
|
+
expect(timestamp).toBeGreaterThanOrEqual(beforeMutation);
|
|
858
|
+
expect(timestamp).toBeLessThanOrEqual(afterMutation);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('should remove timestamp on delete mutation', async () => {
|
|
862
|
+
const engine = await createTestEngine();
|
|
863
|
+
|
|
864
|
+
// First create an entry
|
|
865
|
+
await engine.applyLocalMutation([
|
|
866
|
+
{
|
|
867
|
+
table: 'tasks',
|
|
868
|
+
rowId: 'task-1',
|
|
869
|
+
op: 'upsert',
|
|
870
|
+
payload: { id: 'task-1', title: 'Test Task' },
|
|
871
|
+
},
|
|
872
|
+
]);
|
|
873
|
+
|
|
874
|
+
// Should have a timestamp
|
|
875
|
+
expect(engine.getMutationTimestamp('tasks', 'task-1')).toBeGreaterThan(0);
|
|
876
|
+
|
|
877
|
+
// Now delete it
|
|
878
|
+
await engine.applyLocalMutation([
|
|
879
|
+
{
|
|
880
|
+
table: 'tasks',
|
|
881
|
+
rowId: 'task-1',
|
|
882
|
+
op: 'delete',
|
|
883
|
+
},
|
|
884
|
+
]);
|
|
885
|
+
|
|
886
|
+
// Timestamp should be removed (back to 0)
|
|
887
|
+
expect(engine.getMutationTimestamp('tasks', 'task-1')).toBe(0);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('should track multiple rows independently', async () => {
|
|
891
|
+
const engine = await createTestEngine();
|
|
892
|
+
|
|
893
|
+
// Mutate task-1
|
|
894
|
+
await engine.applyLocalMutation([
|
|
895
|
+
{
|
|
896
|
+
table: 'tasks',
|
|
897
|
+
rowId: 'task-1',
|
|
898
|
+
op: 'upsert',
|
|
899
|
+
payload: { id: 'task-1' },
|
|
900
|
+
},
|
|
901
|
+
]);
|
|
902
|
+
|
|
903
|
+
const ts1 = engine.getMutationTimestamp('tasks', 'task-1');
|
|
904
|
+
|
|
905
|
+
// Small delay to ensure different timestamp
|
|
906
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
907
|
+
|
|
908
|
+
// Mutate task-2
|
|
909
|
+
await engine.applyLocalMutation([
|
|
910
|
+
{
|
|
911
|
+
table: 'tasks',
|
|
912
|
+
rowId: 'task-2',
|
|
913
|
+
op: 'upsert',
|
|
914
|
+
payload: { id: 'task-2' },
|
|
915
|
+
},
|
|
916
|
+
]);
|
|
917
|
+
|
|
918
|
+
const ts2 = engine.getMutationTimestamp('tasks', 'task-2');
|
|
919
|
+
|
|
920
|
+
expect(ts1).toBeGreaterThan(0);
|
|
921
|
+
expect(ts2).toBeGreaterThan(0);
|
|
922
|
+
expect(ts2).toBeGreaterThanOrEqual(ts1);
|
|
923
|
+
|
|
924
|
+
// task-3 should still be 0
|
|
925
|
+
expect(engine.getMutationTimestamp('tasks', 'task-3')).toBe(0);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('should use composite key with table:rowId', async () => {
|
|
929
|
+
const engine = await createTestEngine({ includeProjects: true });
|
|
930
|
+
|
|
931
|
+
// Same rowId in different tables
|
|
932
|
+
await engine.applyLocalMutation([
|
|
933
|
+
{
|
|
934
|
+
table: 'tasks',
|
|
935
|
+
rowId: 'id-1',
|
|
936
|
+
op: 'upsert',
|
|
937
|
+
payload: { id: 'id-1' },
|
|
938
|
+
},
|
|
939
|
+
]);
|
|
940
|
+
|
|
941
|
+
// tasks:id-1 should have timestamp
|
|
942
|
+
expect(engine.getMutationTimestamp('tasks', 'id-1')).toBeGreaterThan(0);
|
|
943
|
+
|
|
944
|
+
// projects:id-1 should NOT have timestamp (different table)
|
|
945
|
+
expect(engine.getMutationTimestamp('projects', 'id-1')).toBe(0);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('should emit data:change event after mutation', async () => {
|
|
949
|
+
const engine = await createTestEngine();
|
|
950
|
+
|
|
951
|
+
let dataChangeEvent: { scopes: string[]; timestamp: number } = {
|
|
952
|
+
scopes: [],
|
|
953
|
+
timestamp: 0,
|
|
954
|
+
};
|
|
955
|
+
engine.on('data:change', (payload) => {
|
|
956
|
+
dataChangeEvent = payload;
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
await engine.applyLocalMutation([
|
|
960
|
+
{
|
|
961
|
+
table: 'tasks',
|
|
962
|
+
rowId: 'task-1',
|
|
963
|
+
op: 'upsert',
|
|
964
|
+
payload: { id: 'task-1' },
|
|
965
|
+
},
|
|
966
|
+
]);
|
|
967
|
+
|
|
968
|
+
expect(dataChangeEvent.scopes).toContain('tasks');
|
|
969
|
+
expect(dataChangeEvent.timestamp).toBeGreaterThan(0);
|
|
970
|
+
});
|
|
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 shapes = new ClientTableRegistry();
|
|
1028
|
+
shapes.register({
|
|
1029
|
+
table: 'tasks',
|
|
1030
|
+
applySnapshot: async () => {},
|
|
1031
|
+
clearAll: async () => {},
|
|
1032
|
+
applyChange: async () => {},
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
const engine = createEngine({
|
|
1036
|
+
transport: rt,
|
|
1037
|
+
shapes,
|
|
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 shapes = new ClientTableRegistry();
|
|
1162
|
+
shapes.register({
|
|
1163
|
+
table: 'tasks',
|
|
1164
|
+
applySnapshot: async () => {},
|
|
1165
|
+
clearAll: async () => {},
|
|
1166
|
+
applyChange: async () => {},
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
const engine = createEngine({
|
|
1170
|
+
transport: rt,
|
|
1171
|
+
shapes,
|
|
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 shapes = new ClientTableRegistry();
|
|
1229
|
+
shapes.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
|
+
shapes,
|
|
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 shapes = new ClientTableRegistry();
|
|
1288
|
+
shapes.register({
|
|
1289
|
+
table: 'tasks',
|
|
1290
|
+
applySnapshot: async () => {},
|
|
1291
|
+
clearAll: async () => {},
|
|
1292
|
+
applyChange: async () => {},
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
const engine = createEngine({
|
|
1296
|
+
transport: rt,
|
|
1297
|
+
shapes,
|
|
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
|
+
});
|
|
1332
|
+
});
|