@syncular/client-react 0.0.1
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 +221 -0
- package/dist/createSyncularReact.d.ts.map +1 -0
- package/dist/createSyncularReact.js +773 -0
- package/dist/createSyncularReact.js.map +1 -0
- package/dist/index.d.ts +8 -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 +50 -0
- package/src/__tests__/SyncEngine.test.ts +653 -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 +291 -0
- package/src/__tests__/integration/push-flow.test.ts +320 -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 +538 -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 +187 -0
- package/src/__tests__/useMutations.test.tsx +198 -0
- package/src/createSyncularReact.tsx +1340 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,653 @@
|
|
|
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
|
+
} from '@syncular/client';
|
|
13
|
+
import type { Kysely } from 'kysely';
|
|
14
|
+
import {
|
|
15
|
+
createMockDb,
|
|
16
|
+
createMockShapeRegistry,
|
|
17
|
+
createMockTransport,
|
|
18
|
+
flushPromises,
|
|
19
|
+
waitFor,
|
|
20
|
+
} from './test-utils';
|
|
21
|
+
|
|
22
|
+
describe('SyncEngine', () => {
|
|
23
|
+
let db: Kysely<SyncClientDb>;
|
|
24
|
+
let engine: SyncEngine;
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
db = await createMockDb();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
engine?.destroy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function createEngine(overrides: Partial<SyncEngineConfig> = {}): SyncEngine {
|
|
35
|
+
const config: SyncEngineConfig = {
|
|
36
|
+
db,
|
|
37
|
+
transport: createMockTransport(),
|
|
38
|
+
shapes: createMockShapeRegistry(),
|
|
39
|
+
actorId: 'test-actor',
|
|
40
|
+
clientId: 'test-client',
|
|
41
|
+
subscriptions: [],
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
engine = new SyncEngine(config);
|
|
45
|
+
return engine;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('initialization', () => {
|
|
49
|
+
it('should create engine with initial state', () => {
|
|
50
|
+
const engine = createEngine();
|
|
51
|
+
const state = engine.getState();
|
|
52
|
+
|
|
53
|
+
expect(state.enabled).toBe(true);
|
|
54
|
+
expect(state.isSyncing).toBe(false);
|
|
55
|
+
expect(state.connectionState).toBe('disconnected');
|
|
56
|
+
expect(state.lastSyncAt).toBe(null);
|
|
57
|
+
expect(state.error).toBe(null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should be disabled when actorId is null', () => {
|
|
61
|
+
const engine = createEngine({ actorId: null });
|
|
62
|
+
const state = engine.getState();
|
|
63
|
+
|
|
64
|
+
expect(state.enabled).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should be disabled when clientId is null', () => {
|
|
68
|
+
const engine = createEngine({ clientId: null });
|
|
69
|
+
const state = engine.getState();
|
|
70
|
+
|
|
71
|
+
expect(state.enabled).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should detect polling mode when realtimeEnabled is false', () => {
|
|
75
|
+
const engine = createEngine({ realtimeEnabled: false });
|
|
76
|
+
const state = engine.getState();
|
|
77
|
+
|
|
78
|
+
expect(state.transportMode).toBe('polling');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('start/stop lifecycle', () => {
|
|
83
|
+
it('should start and set connection state', async () => {
|
|
84
|
+
const engine = createEngine();
|
|
85
|
+
await engine.start();
|
|
86
|
+
|
|
87
|
+
const state = engine.getState();
|
|
88
|
+
expect(state.connectionState).toBe('connected');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should reconnect in polling mode after disconnect', async () => {
|
|
92
|
+
const engine = createEngine({ realtimeEnabled: false });
|
|
93
|
+
await engine.start();
|
|
94
|
+
|
|
95
|
+
engine.disconnect();
|
|
96
|
+
expect(engine.getState().connectionState).toBe('disconnected');
|
|
97
|
+
|
|
98
|
+
engine.reconnect();
|
|
99
|
+
await waitFor(
|
|
100
|
+
() => engine.getState().connectionState === 'connected',
|
|
101
|
+
500
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should reconnect in realtime mode after disconnect', async () => {
|
|
106
|
+
type ConnState = 'disconnected' | 'connecting' | 'connected';
|
|
107
|
+
|
|
108
|
+
let connectCount = 0;
|
|
109
|
+
let reconnectCount = 0;
|
|
110
|
+
let currentState: ConnState = 'disconnected';
|
|
111
|
+
let currentStateCallback: ((state: ConnState) => void) | null = null;
|
|
112
|
+
|
|
113
|
+
const base = createMockTransport();
|
|
114
|
+
const sseTransport = {
|
|
115
|
+
...base,
|
|
116
|
+
connect(
|
|
117
|
+
_args: { clientId: string },
|
|
118
|
+
_onEvent: (_event: unknown) => void,
|
|
119
|
+
onStateChange?: (state: ConnState) => void
|
|
120
|
+
) {
|
|
121
|
+
connectCount += 1;
|
|
122
|
+
currentStateCallback = onStateChange ?? null;
|
|
123
|
+
currentState = 'connecting';
|
|
124
|
+
currentStateCallback?.('connecting');
|
|
125
|
+
queueMicrotask(() => {
|
|
126
|
+
currentState = 'connected';
|
|
127
|
+
currentStateCallback?.('connected');
|
|
128
|
+
});
|
|
129
|
+
return () => {
|
|
130
|
+
currentState = 'disconnected';
|
|
131
|
+
currentStateCallback?.('disconnected');
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
getConnectionState(): ConnState {
|
|
135
|
+
return currentState;
|
|
136
|
+
},
|
|
137
|
+
reconnect() {
|
|
138
|
+
reconnectCount += 1;
|
|
139
|
+
currentState = 'connecting';
|
|
140
|
+
currentStateCallback?.('connecting');
|
|
141
|
+
queueMicrotask(() => {
|
|
142
|
+
currentState = 'connected';
|
|
143
|
+
currentStateCallback?.('connected');
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const engine = createEngine({
|
|
149
|
+
transport: sseTransport,
|
|
150
|
+
realtimeEnabled: true,
|
|
151
|
+
});
|
|
152
|
+
await engine.start();
|
|
153
|
+
await waitFor(
|
|
154
|
+
() => engine.getState().connectionState === 'connected',
|
|
155
|
+
500
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// While connected, reconnect should call transport.reconnect().
|
|
159
|
+
engine.reconnect();
|
|
160
|
+
expect(reconnectCount).toBe(1);
|
|
161
|
+
|
|
162
|
+
// After disconnect, reconnect should re-register callbacks via connect().
|
|
163
|
+
engine.disconnect();
|
|
164
|
+
expect(engine.getState().connectionState).toBe('disconnected');
|
|
165
|
+
|
|
166
|
+
engine.reconnect();
|
|
167
|
+
await waitFor(
|
|
168
|
+
() => engine.getState().connectionState === 'connected',
|
|
169
|
+
500
|
|
170
|
+
);
|
|
171
|
+
expect(connectCount).toBe(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should stop and disconnect', async () => {
|
|
175
|
+
const engine = createEngine();
|
|
176
|
+
await engine.start();
|
|
177
|
+
engine.stop();
|
|
178
|
+
|
|
179
|
+
const state = engine.getState();
|
|
180
|
+
expect(state.connectionState).toBe('disconnected');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should throw when starting destroyed engine', async () => {
|
|
184
|
+
const engine = createEngine();
|
|
185
|
+
engine.destroy();
|
|
186
|
+
|
|
187
|
+
await expect(engine.start()).rejects.toThrow('destroyed');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('sync cycle', () => {
|
|
192
|
+
it('should perform sync and update state', async () => {
|
|
193
|
+
const engine = createEngine();
|
|
194
|
+
await engine.start();
|
|
195
|
+
|
|
196
|
+
const result = await engine.sync();
|
|
197
|
+
|
|
198
|
+
expect(result.success).toBe(true);
|
|
199
|
+
expect(engine.getState().lastSyncAt).not.toBe(null);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should emit sync:start event', async () => {
|
|
203
|
+
const engine = createEngine();
|
|
204
|
+
await engine.start();
|
|
205
|
+
|
|
206
|
+
let eventReceived = false;
|
|
207
|
+
engine.on('sync:start', () => {
|
|
208
|
+
eventReceived = true;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await engine.sync();
|
|
212
|
+
expect(eventReceived).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should emit sync:complete event', async () => {
|
|
216
|
+
const engine = createEngine();
|
|
217
|
+
await engine.start();
|
|
218
|
+
|
|
219
|
+
let eventPayload: { timestamp: number } = { timestamp: 0 };
|
|
220
|
+
engine.on('sync:complete', (payload) => {
|
|
221
|
+
eventPayload = payload;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await engine.sync();
|
|
225
|
+
|
|
226
|
+
expect(eventPayload.timestamp).toBeGreaterThan(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should emit sync:error on failure', async () => {
|
|
230
|
+
const transport = createMockTransport();
|
|
231
|
+
transport.pull = async () => {
|
|
232
|
+
throw new Error('Network error');
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const engine = createEngine({ transport });
|
|
236
|
+
await engine.start();
|
|
237
|
+
|
|
238
|
+
let errorPayload: { message: string } = { message: '' };
|
|
239
|
+
engine.on('sync:error', (payload) => {
|
|
240
|
+
errorPayload = payload;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const result = await engine.sync();
|
|
244
|
+
|
|
245
|
+
expect(result.success).toBe(false);
|
|
246
|
+
expect(errorPayload.message).toBe('Network error');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should dedupe concurrent sync calls', async () => {
|
|
250
|
+
let pullCount = 0;
|
|
251
|
+
const transport = createMockTransport({
|
|
252
|
+
onPull: () => {
|
|
253
|
+
pullCount++;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const engine = createEngine({ transport });
|
|
258
|
+
await engine.start();
|
|
259
|
+
|
|
260
|
+
// Reset count after start's initial sync
|
|
261
|
+
pullCount = 0;
|
|
262
|
+
|
|
263
|
+
// Trigger multiple concurrent syncs
|
|
264
|
+
const [r1, r2, r3] = await Promise.all([
|
|
265
|
+
engine.sync(),
|
|
266
|
+
engine.sync(),
|
|
267
|
+
engine.sync(),
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
// All should resolve to the same result
|
|
271
|
+
expect(r1.success).toBe(true);
|
|
272
|
+
expect(r2.success).toBe(true);
|
|
273
|
+
expect(r3.success).toBe(true);
|
|
274
|
+
|
|
275
|
+
// Only one sync runs at a time, but we should schedule at most one extra
|
|
276
|
+
// pass if sync() is requested while a sync is already in-flight.
|
|
277
|
+
expect(pullCount).toBe(2);
|
|
278
|
+
});
|
|
279
|
+
|
|
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: () => {},
|
|
285
|
+
});
|
|
286
|
+
|
|
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: [
|
|
293
|
+
{
|
|
294
|
+
table: 'tasks',
|
|
295
|
+
row_id: 'late-commit',
|
|
296
|
+
op: 'upsert',
|
|
297
|
+
payload: { title: 'Late' },
|
|
298
|
+
base_version: null,
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Request another sync while this pull is in-flight.
|
|
304
|
+
void engine.sync();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Small delay so the second sync request is definitely concurrent.
|
|
308
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
309
|
+
|
|
310
|
+
return { ok: true, subscriptions: [] };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const engine = createEngine({ transport });
|
|
314
|
+
await engine.start();
|
|
315
|
+
enableInjection = true;
|
|
316
|
+
|
|
317
|
+
// Enqueue a commit that will be pushed in the first cycle.
|
|
318
|
+
await enqueueOutboxCommit(db, {
|
|
319
|
+
operations: [
|
|
320
|
+
{
|
|
321
|
+
table: 'tasks',
|
|
322
|
+
row_id: 'first-commit',
|
|
323
|
+
op: 'upsert',
|
|
324
|
+
payload: { title: 'First' },
|
|
325
|
+
base_version: null,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await engine.sync();
|
|
331
|
+
|
|
332
|
+
// Both commits should be acked (late commit is pushed by the queued follow-up sync).
|
|
333
|
+
const remaining = await db
|
|
334
|
+
.selectFrom('sync_outbox_commits')
|
|
335
|
+
.select(['status'])
|
|
336
|
+
.where('status', '!=', 'acked')
|
|
337
|
+
.execute();
|
|
338
|
+
|
|
339
|
+
expect(remaining.length).toBe(0);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('event subscriptions', () => {
|
|
344
|
+
it('should allow subscribing to events', async () => {
|
|
345
|
+
const engine = createEngine();
|
|
346
|
+
|
|
347
|
+
const events: string[] = [];
|
|
348
|
+
engine.on('sync:start', () => events.push('start'));
|
|
349
|
+
engine.on('sync:complete', () => events.push('complete'));
|
|
350
|
+
|
|
351
|
+
await engine.start();
|
|
352
|
+
await engine.sync();
|
|
353
|
+
|
|
354
|
+
expect(events).toContain('start');
|
|
355
|
+
expect(events).toContain('complete');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should allow unsubscribing from events', async () => {
|
|
359
|
+
const engine = createEngine();
|
|
360
|
+
|
|
361
|
+
let callCount = 0;
|
|
362
|
+
const unsubscribe = engine.on('sync:complete', () => {
|
|
363
|
+
callCount++;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await engine.start();
|
|
367
|
+
unsubscribe();
|
|
368
|
+
await engine.sync();
|
|
369
|
+
|
|
370
|
+
// Only the initial sync from start() should have triggered
|
|
371
|
+
expect(callCount).toBe(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should support subscribe() for all events', async () => {
|
|
375
|
+
const engine = createEngine();
|
|
376
|
+
|
|
377
|
+
let callCount = 0;
|
|
378
|
+
engine.subscribe(() => {
|
|
379
|
+
callCount++;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await engine.start();
|
|
383
|
+
await engine.sync();
|
|
384
|
+
|
|
385
|
+
// Multiple events should trigger the callback
|
|
386
|
+
expect(callCount).toBeGreaterThan(1);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('outbox stats', () => {
|
|
391
|
+
it('should refresh outbox stats', async () => {
|
|
392
|
+
const engine = createEngine();
|
|
393
|
+
await engine.start();
|
|
394
|
+
|
|
395
|
+
const stats = await engine.refreshOutboxStats();
|
|
396
|
+
|
|
397
|
+
expect(stats).toEqual({
|
|
398
|
+
pending: 0,
|
|
399
|
+
sending: 0,
|
|
400
|
+
failed: 0,
|
|
401
|
+
acked: 0,
|
|
402
|
+
total: 0,
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should emit outbox:change event', async () => {
|
|
407
|
+
const engine = createEngine();
|
|
408
|
+
await engine.start();
|
|
409
|
+
|
|
410
|
+
let eventPayload: { pendingCount: number } = { pendingCount: -1 };
|
|
411
|
+
engine.on('outbox:change', (payload) => {
|
|
412
|
+
eventPayload = payload;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await engine.refreshOutboxStats();
|
|
416
|
+
|
|
417
|
+
expect(eventPayload.pendingCount).toBe(0);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('conflicts', () => {
|
|
422
|
+
it('should return empty conflicts list initially', async () => {
|
|
423
|
+
const engine = createEngine();
|
|
424
|
+
await engine.start();
|
|
425
|
+
|
|
426
|
+
const conflicts = await engine.getConflicts();
|
|
427
|
+
|
|
428
|
+
expect(conflicts).toEqual([]);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('subscriptions', () => {
|
|
433
|
+
it('should update subscriptions and trigger sync', async () => {
|
|
434
|
+
let pullCount = 0;
|
|
435
|
+
const transport = createMockTransport({
|
|
436
|
+
onPull: () => {
|
|
437
|
+
pullCount++;
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const engine = createEngine({ transport });
|
|
442
|
+
await engine.start();
|
|
443
|
+
|
|
444
|
+
const initialPullCount = pullCount;
|
|
445
|
+
|
|
446
|
+
engine.updateSubscriptions([
|
|
447
|
+
{ id: 'new-sub', shape: 'test', scopes: {} },
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
await flushPromises();
|
|
451
|
+
await waitFor(() => pullCount > initialPullCount, 500);
|
|
452
|
+
|
|
453
|
+
expect(pullCount).toBeGreaterThan(initialPullCount);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe('mutation timestamps', () => {
|
|
458
|
+
interface TestDb extends SyncClientDb {
|
|
459
|
+
tasks: { id: string; title: string };
|
|
460
|
+
projects: { id: string; name: string };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let local: {
|
|
464
|
+
engine: SyncEngine<TestDb>;
|
|
465
|
+
db: Kysely<TestDb>;
|
|
466
|
+
} | null = null;
|
|
467
|
+
|
|
468
|
+
afterEach(() => {
|
|
469
|
+
local?.engine.destroy();
|
|
470
|
+
void local?.db.destroy();
|
|
471
|
+
local = null;
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
async function createTestEngine(
|
|
475
|
+
args: { includeProjects?: boolean } = {}
|
|
476
|
+
): Promise<SyncEngine<TestDb>> {
|
|
477
|
+
const shapes = new ClientTableRegistry<TestDb>();
|
|
478
|
+
shapes.register({
|
|
479
|
+
table: 'tasks',
|
|
480
|
+
applySnapshot: async () => {},
|
|
481
|
+
clearAll: async () => {},
|
|
482
|
+
applyChange: async () => {},
|
|
483
|
+
});
|
|
484
|
+
if (args.includeProjects) {
|
|
485
|
+
shapes.register({
|
|
486
|
+
table: 'projects',
|
|
487
|
+
applySnapshot: async () => {},
|
|
488
|
+
clearAll: async () => {},
|
|
489
|
+
applyChange: async () => {},
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const testDb = await createMockDb<TestDb>();
|
|
494
|
+
const config: SyncEngineConfig<TestDb> = {
|
|
495
|
+
db: testDb,
|
|
496
|
+
transport: createMockTransport(),
|
|
497
|
+
shapes,
|
|
498
|
+
actorId: 'test-actor',
|
|
499
|
+
clientId: 'test-client',
|
|
500
|
+
subscriptions: [],
|
|
501
|
+
};
|
|
502
|
+
const engine = new SyncEngine<TestDb>(config);
|
|
503
|
+
await engine.start();
|
|
504
|
+
|
|
505
|
+
local = { engine, db: testDb };
|
|
506
|
+
return engine;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
it('should return 0 for rows with no mutations', () => {
|
|
510
|
+
const engine = createEngine();
|
|
511
|
+
|
|
512
|
+
expect(engine.getMutationTimestamp('tasks', 'unknown-id')).toBe(0);
|
|
513
|
+
expect(engine.getMutationTimestamp('other_table', 'any-id')).toBe(0);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('should track mutation timestamps after applyLocalMutation', async () => {
|
|
517
|
+
const engine = await createTestEngine();
|
|
518
|
+
|
|
519
|
+
// Initially no timestamp
|
|
520
|
+
expect(engine.getMutationTimestamp('tasks', 'task-1')).toBe(0);
|
|
521
|
+
|
|
522
|
+
const beforeMutation = Date.now();
|
|
523
|
+
|
|
524
|
+
// Apply a local mutation
|
|
525
|
+
await engine.applyLocalMutation([
|
|
526
|
+
{
|
|
527
|
+
table: 'tasks',
|
|
528
|
+
rowId: 'task-1',
|
|
529
|
+
op: 'upsert',
|
|
530
|
+
payload: { id: 'task-1', title: 'Test Task' },
|
|
531
|
+
},
|
|
532
|
+
]);
|
|
533
|
+
|
|
534
|
+
const afterMutation = Date.now();
|
|
535
|
+
|
|
536
|
+
// Should now have a timestamp
|
|
537
|
+
const timestamp = engine.getMutationTimestamp('tasks', 'task-1');
|
|
538
|
+
expect(timestamp).toBeGreaterThanOrEqual(beforeMutation);
|
|
539
|
+
expect(timestamp).toBeLessThanOrEqual(afterMutation);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should remove timestamp on delete mutation', async () => {
|
|
543
|
+
const engine = await createTestEngine();
|
|
544
|
+
|
|
545
|
+
// First create an entry
|
|
546
|
+
await engine.applyLocalMutation([
|
|
547
|
+
{
|
|
548
|
+
table: 'tasks',
|
|
549
|
+
rowId: 'task-1',
|
|
550
|
+
op: 'upsert',
|
|
551
|
+
payload: { id: 'task-1', title: 'Test Task' },
|
|
552
|
+
},
|
|
553
|
+
]);
|
|
554
|
+
|
|
555
|
+
// Should have a timestamp
|
|
556
|
+
expect(engine.getMutationTimestamp('tasks', 'task-1')).toBeGreaterThan(0);
|
|
557
|
+
|
|
558
|
+
// Now delete it
|
|
559
|
+
await engine.applyLocalMutation([
|
|
560
|
+
{
|
|
561
|
+
table: 'tasks',
|
|
562
|
+
rowId: 'task-1',
|
|
563
|
+
op: 'delete',
|
|
564
|
+
},
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
// Timestamp should be removed (back to 0)
|
|
568
|
+
expect(engine.getMutationTimestamp('tasks', 'task-1')).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should track multiple rows independently', async () => {
|
|
572
|
+
const engine = await createTestEngine();
|
|
573
|
+
|
|
574
|
+
// Mutate task-1
|
|
575
|
+
await engine.applyLocalMutation([
|
|
576
|
+
{
|
|
577
|
+
table: 'tasks',
|
|
578
|
+
rowId: 'task-1',
|
|
579
|
+
op: 'upsert',
|
|
580
|
+
payload: { id: 'task-1' },
|
|
581
|
+
},
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
const ts1 = engine.getMutationTimestamp('tasks', 'task-1');
|
|
585
|
+
|
|
586
|
+
// Small delay to ensure different timestamp
|
|
587
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
588
|
+
|
|
589
|
+
// Mutate task-2
|
|
590
|
+
await engine.applyLocalMutation([
|
|
591
|
+
{
|
|
592
|
+
table: 'tasks',
|
|
593
|
+
rowId: 'task-2',
|
|
594
|
+
op: 'upsert',
|
|
595
|
+
payload: { id: 'task-2' },
|
|
596
|
+
},
|
|
597
|
+
]);
|
|
598
|
+
|
|
599
|
+
const ts2 = engine.getMutationTimestamp('tasks', 'task-2');
|
|
600
|
+
|
|
601
|
+
expect(ts1).toBeGreaterThan(0);
|
|
602
|
+
expect(ts2).toBeGreaterThan(0);
|
|
603
|
+
expect(ts2).toBeGreaterThanOrEqual(ts1);
|
|
604
|
+
|
|
605
|
+
// task-3 should still be 0
|
|
606
|
+
expect(engine.getMutationTimestamp('tasks', 'task-3')).toBe(0);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('should use composite key with table:rowId', async () => {
|
|
610
|
+
const engine = await createTestEngine({ includeProjects: true });
|
|
611
|
+
|
|
612
|
+
// Same rowId in different tables
|
|
613
|
+
await engine.applyLocalMutation([
|
|
614
|
+
{
|
|
615
|
+
table: 'tasks',
|
|
616
|
+
rowId: 'id-1',
|
|
617
|
+
op: 'upsert',
|
|
618
|
+
payload: { id: 'id-1' },
|
|
619
|
+
},
|
|
620
|
+
]);
|
|
621
|
+
|
|
622
|
+
// tasks:id-1 should have timestamp
|
|
623
|
+
expect(engine.getMutationTimestamp('tasks', 'id-1')).toBeGreaterThan(0);
|
|
624
|
+
|
|
625
|
+
// projects:id-1 should NOT have timestamp (different table)
|
|
626
|
+
expect(engine.getMutationTimestamp('projects', 'id-1')).toBe(0);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('should emit data:change event after mutation', async () => {
|
|
630
|
+
const engine = await createTestEngine();
|
|
631
|
+
|
|
632
|
+
let dataChangeEvent: { scopes: string[]; timestamp: number } = {
|
|
633
|
+
scopes: [],
|
|
634
|
+
timestamp: 0,
|
|
635
|
+
};
|
|
636
|
+
engine.on('data:change', (payload) => {
|
|
637
|
+
dataChangeEvent = payload;
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
await engine.applyLocalMutation([
|
|
641
|
+
{
|
|
642
|
+
table: 'tasks',
|
|
643
|
+
rowId: 'task-1',
|
|
644
|
+
op: 'upsert',
|
|
645
|
+
payload: { id: 'task-1' },
|
|
646
|
+
},
|
|
647
|
+
]);
|
|
648
|
+
|
|
649
|
+
expect(dataChangeEvent.scopes).toContain('tasks');
|
|
650
|
+
expect(dataChangeEvent.timestamp).toBeGreaterThan(0);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
});
|