@syncular/client 0.0.6-126 → 0.0.6-136
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/blobs/index.d.ts +0 -1
- package/dist/blobs/index.d.ts.map +1 -1
- package/dist/blobs/index.js +0 -1
- package/dist/blobs/index.js.map +1 -1
- package/dist/client.d.ts +21 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -0
- package/dist/client.js.map +1 -1
- package/dist/create-client.d.ts +17 -0
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js +3 -0
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +11 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +181 -27
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +17 -0
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/migrate.d.ts +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +0 -126
- package/dist/migrate.js.map +1 -1
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +9 -1
- package/dist/mutations.js.map +1 -1
- package/dist/query/fingerprint.d.ts +1 -1
- package/dist/query/fingerprint.d.ts.map +1 -1
- package/dist/query/fingerprint.js +29 -6
- package/dist/query/fingerprint.js.map +1 -1
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +29 -19
- package/dist/sync-loop.js.map +1 -1
- package/package.json +3 -3
- package/src/blobs/index.ts +0 -1
- package/src/client.ts +37 -0
- package/src/create-client.ts +21 -0
- package/src/engine/SyncEngine.test.ts +257 -0
- package/src/engine/SyncEngine.ts +214 -27
- package/src/engine/types.ts +17 -0
- package/src/migrate.ts +1 -190
- package/src/mutations.ts +9 -1
- package/src/query/fingerprint.test.ts +73 -0
- package/src/query/fingerprint.ts +33 -6
- package/src/sync-loop.ts +29 -19
- package/dist/blobs/manager.d.ts +0 -345
- package/dist/blobs/manager.d.ts.map +0 -1
- package/dist/blobs/manager.js +0 -749
- package/dist/blobs/manager.js.map +0 -1
- package/src/blobs/manager.ts +0 -1027
package/src/blobs/index.ts
CHANGED
package/src/client.ts
CHANGED
|
@@ -120,6 +120,24 @@ export interface ClientOptions<DB extends SyncClientDb> {
|
|
|
120
120
|
/** Optional: Polling interval in milliseconds (default: 10000) */
|
|
121
121
|
pollIntervalMs?: number;
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Optional: Debounce window (ms) for coalescing `data:change` events.
|
|
125
|
+
* - `0` (default): emit immediately
|
|
126
|
+
* - `>0`: merge scopes and emit once per window
|
|
127
|
+
*/
|
|
128
|
+
dataChangeDebounceMs?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Optional: Debounce override while sync is actively running.
|
|
131
|
+
* Falls back to `dataChangeDebounceMs` when omitted.
|
|
132
|
+
*/
|
|
133
|
+
dataChangeDebounceMsWhenSyncing?: number;
|
|
134
|
+
/**
|
|
135
|
+
* Optional: Debounce override while connection is reconnecting.
|
|
136
|
+
* Falls back to `dataChangeDebounceMsWhenSyncing` (if syncing) and then
|
|
137
|
+
* `dataChangeDebounceMs` when omitted.
|
|
138
|
+
*/
|
|
139
|
+
dataChangeDebounceMsWhenReconnecting?: number;
|
|
140
|
+
|
|
123
141
|
/** Optional: State ID for multi-tenant scenarios */
|
|
124
142
|
stateId?: string;
|
|
125
143
|
|
|
@@ -413,6 +431,11 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
413
431
|
plugins: this.options.plugins,
|
|
414
432
|
realtimeEnabled: this.options.realtimeEnabled,
|
|
415
433
|
pollIntervalMs: this.options.pollIntervalMs,
|
|
434
|
+
dataChangeDebounceMs: this.options.dataChangeDebounceMs,
|
|
435
|
+
dataChangeDebounceMsWhenSyncing:
|
|
436
|
+
this.options.dataChangeDebounceMsWhenSyncing,
|
|
437
|
+
dataChangeDebounceMsWhenReconnecting:
|
|
438
|
+
this.options.dataChangeDebounceMsWhenReconnecting,
|
|
416
439
|
stateId: this.options.stateId,
|
|
417
440
|
migrate: undefined, // We already ran migrations
|
|
418
441
|
});
|
|
@@ -642,6 +665,20 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
|
|
|
642
665
|
return this.engine.subscribe(callback);
|
|
643
666
|
}
|
|
644
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Subscribe to state changes with selector-based equality filtering.
|
|
670
|
+
*/
|
|
671
|
+
subscribeSelector<T>(
|
|
672
|
+
selector: () => T,
|
|
673
|
+
callback: () => void,
|
|
674
|
+
isEqual?: (previous: T, next: T) => boolean
|
|
675
|
+
): () => void {
|
|
676
|
+
if (!this.engine) {
|
|
677
|
+
return () => {};
|
|
678
|
+
}
|
|
679
|
+
return this.engine.subscribeSelector(selector, callback, isEqual);
|
|
680
|
+
}
|
|
681
|
+
|
|
645
682
|
// ===========================================================================
|
|
646
683
|
// Events
|
|
647
684
|
// ===========================================================================
|
package/src/create-client.ts
CHANGED
|
@@ -141,6 +141,23 @@ interface CreateClientOptions<DB extends SyncClientDb> {
|
|
|
141
141
|
realtime?: boolean;
|
|
142
142
|
/** Polling interval in ms (default: 10000) */
|
|
143
143
|
pollIntervalMs?: number;
|
|
144
|
+
/**
|
|
145
|
+
* Debounce window (ms) for coalescing `data:change` events.
|
|
146
|
+
* - `0` (default): emit immediately
|
|
147
|
+
* - `>0`: merge scopes and emit once per window
|
|
148
|
+
*/
|
|
149
|
+
dataChangeDebounceMs?: number;
|
|
150
|
+
/**
|
|
151
|
+
* Debounce override while sync is actively running.
|
|
152
|
+
* Falls back to `dataChangeDebounceMs` when omitted.
|
|
153
|
+
*/
|
|
154
|
+
dataChangeDebounceMsWhenSyncing?: number;
|
|
155
|
+
/**
|
|
156
|
+
* Debounce override while connection is reconnecting.
|
|
157
|
+
* Falls back to `dataChangeDebounceMsWhenSyncing` (if syncing) and then
|
|
158
|
+
* `dataChangeDebounceMs` when omitted.
|
|
159
|
+
*/
|
|
160
|
+
dataChangeDebounceMsWhenReconnecting?: number;
|
|
144
161
|
};
|
|
145
162
|
|
|
146
163
|
/** Optional: Local blob storage adapter */
|
|
@@ -315,6 +332,10 @@ export async function createClient<DB extends SyncClientDb>(
|
|
|
315
332
|
codecDialect,
|
|
316
333
|
realtimeEnabled: sync.realtime ?? true,
|
|
317
334
|
pollIntervalMs: sync.pollIntervalMs,
|
|
335
|
+
dataChangeDebounceMs: sync.dataChangeDebounceMs,
|
|
336
|
+
dataChangeDebounceMsWhenSyncing: sync.dataChangeDebounceMsWhenSyncing,
|
|
337
|
+
dataChangeDebounceMsWhenReconnecting:
|
|
338
|
+
sync.dataChangeDebounceMsWhenReconnecting,
|
|
318
339
|
});
|
|
319
340
|
|
|
320
341
|
// Auto-start
|
|
@@ -224,6 +224,263 @@ describe('SyncEngine WS inline apply', () => {
|
|
|
224
224
|
expect(snapshot.diagnostics).toBeDefined();
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
it('coalesces rapid data:change emissions when debounce is configured', async () => {
|
|
228
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
229
|
+
{
|
|
230
|
+
table: 'tasks',
|
|
231
|
+
async applySnapshot() {},
|
|
232
|
+
async clearAll() {},
|
|
233
|
+
async applyChange() {},
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
const onDataChangeCalls: string[][] = [];
|
|
238
|
+
const engine = new SyncEngine<TestDb>({
|
|
239
|
+
db,
|
|
240
|
+
transport: noopTransport,
|
|
241
|
+
handlers,
|
|
242
|
+
actorId: 'u1',
|
|
243
|
+
clientId: 'client-debounce',
|
|
244
|
+
subscriptions: [],
|
|
245
|
+
stateId: 'default',
|
|
246
|
+
dataChangeDebounceMs: 25,
|
|
247
|
+
onDataChange(scopes) {
|
|
248
|
+
onDataChangeCalls.push(scopes);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const eventScopes: string[][] = [];
|
|
253
|
+
engine.on('data:change', (payload) => {
|
|
254
|
+
eventScopes.push(payload.scopes);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
engine.recordLocalMutations([
|
|
258
|
+
{ table: 'tasks', rowId: 't1', op: 'upsert' },
|
|
259
|
+
]);
|
|
260
|
+
engine.recordLocalMutations([
|
|
261
|
+
{ table: 'tasks', rowId: 't2', op: 'upsert' },
|
|
262
|
+
]);
|
|
263
|
+
engine.recordLocalMutations([
|
|
264
|
+
{ table: 'tasks', rowId: 't3', op: 'delete' },
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
expect(eventScopes).toEqual([]);
|
|
268
|
+
expect(onDataChangeCalls).toEqual([]);
|
|
269
|
+
|
|
270
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 40));
|
|
271
|
+
|
|
272
|
+
expect(eventScopes).toEqual([['tasks']]);
|
|
273
|
+
expect(onDataChangeCalls).toEqual([['tasks']]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('supports adaptive debounce overrides while syncing and reconnecting', async () => {
|
|
277
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
278
|
+
{
|
|
279
|
+
table: 'tasks',
|
|
280
|
+
async applySnapshot() {},
|
|
281
|
+
async clearAll() {},
|
|
282
|
+
async applyChange() {},
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const engine = new SyncEngine<TestDb>({
|
|
287
|
+
db,
|
|
288
|
+
transport: noopTransport,
|
|
289
|
+
handlers,
|
|
290
|
+
actorId: 'u1',
|
|
291
|
+
clientId: 'client-adaptive-debounce',
|
|
292
|
+
subscriptions: [],
|
|
293
|
+
stateId: 'default',
|
|
294
|
+
dataChangeDebounceMs: 0,
|
|
295
|
+
dataChangeDebounceMsWhenSyncing: 15,
|
|
296
|
+
dataChangeDebounceMsWhenReconnecting: 35,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const eventScopes: string[][] = [];
|
|
300
|
+
engine.on('data:change', (payload) => {
|
|
301
|
+
eventScopes.push(payload.scopes);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const updateState = Reflect.get(engine, 'updateState');
|
|
305
|
+
if (typeof updateState !== 'function') {
|
|
306
|
+
throw new Error('Expected updateState to be callable');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
updateState.call(engine, {
|
|
310
|
+
isSyncing: true,
|
|
311
|
+
connectionState: 'connected',
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
engine.recordLocalMutations([
|
|
315
|
+
{ table: 'tasks', rowId: 'syncing-1', op: 'upsert' },
|
|
316
|
+
]);
|
|
317
|
+
expect(eventScopes).toEqual([]);
|
|
318
|
+
|
|
319
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
320
|
+
expect(eventScopes).toEqual([['tasks']]);
|
|
321
|
+
|
|
322
|
+
updateState.call(engine, {
|
|
323
|
+
isSyncing: true,
|
|
324
|
+
connectionState: 'reconnecting',
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
engine.recordLocalMutations([
|
|
328
|
+
{ table: 'tasks', rowId: 'reconnecting-1', op: 'upsert' },
|
|
329
|
+
]);
|
|
330
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
|
331
|
+
expect(eventScopes).toEqual([['tasks']]);
|
|
332
|
+
|
|
333
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
334
|
+
expect(eventScopes).toEqual([['tasks'], ['tasks']]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('does not emit state:change for no-op state updates', async () => {
|
|
338
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
339
|
+
{
|
|
340
|
+
table: 'tasks',
|
|
341
|
+
async applySnapshot() {},
|
|
342
|
+
async clearAll() {},
|
|
343
|
+
async applyChange() {},
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
const engine = new SyncEngine<TestDb>({
|
|
348
|
+
db,
|
|
349
|
+
transport: noopTransport,
|
|
350
|
+
handlers,
|
|
351
|
+
actorId: 'u1',
|
|
352
|
+
clientId: 'client-noop-state',
|
|
353
|
+
subscriptions: [],
|
|
354
|
+
stateId: 'default',
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
let stateChangeCount = 0;
|
|
358
|
+
engine.subscribe(() => {
|
|
359
|
+
stateChangeCount += 1;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const updateState = Reflect.get(engine, 'updateState');
|
|
363
|
+
if (typeof updateState !== 'function') {
|
|
364
|
+
throw new Error('Expected updateState to be callable');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const initialState = engine.getState();
|
|
368
|
+
updateState.call(engine, { enabled: initialState.enabled });
|
|
369
|
+
updateState.call(engine, { error: initialState.error });
|
|
370
|
+
expect(stateChangeCount).toBe(0);
|
|
371
|
+
|
|
372
|
+
updateState.call(engine, { enabled: !initialState.enabled });
|
|
373
|
+
expect(stateChangeCount).toBe(1);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('supports selector subscriptions without notifying on unrelated state changes', async () => {
|
|
377
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
378
|
+
{
|
|
379
|
+
table: 'tasks',
|
|
380
|
+
async applySnapshot() {},
|
|
381
|
+
async clearAll() {},
|
|
382
|
+
async applyChange() {},
|
|
383
|
+
},
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
const engine = new SyncEngine<TestDb>({
|
|
387
|
+
db,
|
|
388
|
+
transport: noopTransport,
|
|
389
|
+
handlers,
|
|
390
|
+
actorId: 'u1',
|
|
391
|
+
clientId: 'client-selector',
|
|
392
|
+
subscriptions: [],
|
|
393
|
+
stateId: 'default',
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
let calls = 0;
|
|
397
|
+
const unsubscribe = engine.subscribeSelector(
|
|
398
|
+
() => engine.getState().lastSyncAt,
|
|
399
|
+
() => {
|
|
400
|
+
calls += 1;
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const updateState = Reflect.get(engine, 'updateState');
|
|
405
|
+
if (typeof updateState !== 'function') {
|
|
406
|
+
throw new Error('Expected updateState to be callable');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
updateState.call(engine, { retryCount: 1 });
|
|
410
|
+
expect(calls).toBe(0);
|
|
411
|
+
|
|
412
|
+
updateState.call(engine, { lastSyncAt: Date.now() });
|
|
413
|
+
expect(calls).toBe(1);
|
|
414
|
+
|
|
415
|
+
unsubscribe();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('skips presence:change emissions for no-op presence updates', async () => {
|
|
419
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
420
|
+
{
|
|
421
|
+
table: 'tasks',
|
|
422
|
+
async applySnapshot() {},
|
|
423
|
+
async clearAll() {},
|
|
424
|
+
async applyChange() {},
|
|
425
|
+
},
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
const engine = new SyncEngine<TestDb>({
|
|
429
|
+
db,
|
|
430
|
+
transport: noopTransport,
|
|
431
|
+
handlers,
|
|
432
|
+
actorId: 'u1',
|
|
433
|
+
clientId: 'client-presence',
|
|
434
|
+
subscriptions: [],
|
|
435
|
+
stateId: 'default',
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
let eventCount = 0;
|
|
439
|
+
engine.on('presence:change', () => {
|
|
440
|
+
eventCount += 1;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const basePresence = [
|
|
444
|
+
{
|
|
445
|
+
clientId: 'c1',
|
|
446
|
+
actorId: 'u1',
|
|
447
|
+
joinedAt: 1000,
|
|
448
|
+
metadata: { name: 'Alice' },
|
|
449
|
+
},
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
engine.updatePresence('room:1', basePresence);
|
|
453
|
+
expect(eventCount).toBe(1);
|
|
454
|
+
|
|
455
|
+
engine.updatePresence('room:1', [
|
|
456
|
+
{
|
|
457
|
+
clientId: 'c1',
|
|
458
|
+
actorId: 'u1',
|
|
459
|
+
joinedAt: 1000,
|
|
460
|
+
metadata: { name: 'Alice' },
|
|
461
|
+
},
|
|
462
|
+
]);
|
|
463
|
+
expect(eventCount).toBe(1);
|
|
464
|
+
|
|
465
|
+
engine.handlePresenceEvent({
|
|
466
|
+
action: 'update',
|
|
467
|
+
scopeKey: 'room:1',
|
|
468
|
+
clientId: 'c1',
|
|
469
|
+
actorId: 'u1',
|
|
470
|
+
metadata: { name: 'Alice' },
|
|
471
|
+
});
|
|
472
|
+
expect(eventCount).toBe(1);
|
|
473
|
+
|
|
474
|
+
engine.handlePresenceEvent({
|
|
475
|
+
action: 'join',
|
|
476
|
+
scopeKey: 'room:1',
|
|
477
|
+
clientId: 'c1',
|
|
478
|
+
actorId: 'u1',
|
|
479
|
+
metadata: { name: 'Alice' },
|
|
480
|
+
});
|
|
481
|
+
expect(eventCount).toBe(1);
|
|
482
|
+
});
|
|
483
|
+
|
|
227
484
|
it('ensures sync schema on start without custom migrate callback', async () => {
|
|
228
485
|
const coldDb = createDatabase<TestDb>({
|
|
229
486
|
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -231,6 +231,49 @@ function serializeInspectorRecord(value: unknown): Record<string, unknown> {
|
|
|
231
231
|
return { value: serialized };
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
function defaultSelectorEquality<T>(left: T, right: T): boolean {
|
|
235
|
+
return Object.is(left, right);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function areMetadataRecordsEqual(
|
|
239
|
+
left: Record<string, unknown> | undefined,
|
|
240
|
+
right: Record<string, unknown> | undefined
|
|
241
|
+
): boolean {
|
|
242
|
+
if (left === right) return true;
|
|
243
|
+
if (!left || !right) return false;
|
|
244
|
+
|
|
245
|
+
const leftKeys = Object.keys(left);
|
|
246
|
+
const rightKeys = Object.keys(right);
|
|
247
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
248
|
+
|
|
249
|
+
for (const key of leftKeys) {
|
|
250
|
+
if (!(key in right)) return false;
|
|
251
|
+
if (!Object.is(left[key], right[key])) return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function arePresenceEntriesEqual(
|
|
258
|
+
left: PresenceEntry[],
|
|
259
|
+
right: PresenceEntry[]
|
|
260
|
+
): boolean {
|
|
261
|
+
if (left === right) return true;
|
|
262
|
+
if (left.length !== right.length) return false;
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < left.length; i++) {
|
|
265
|
+
const l = left[i];
|
|
266
|
+
const r = right[i];
|
|
267
|
+
if (!l || !r) return false;
|
|
268
|
+
if (l.clientId !== r.clientId) return false;
|
|
269
|
+
if (l.actorId !== r.actorId) return false;
|
|
270
|
+
if (l.joinedAt !== r.joinedAt) return false;
|
|
271
|
+
if (!areMetadataRecordsEqual(l.metadata, r.metadata)) return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
234
277
|
/**
|
|
235
278
|
* Sync engine that orchestrates push/pull cycles with proper lifecycle management.
|
|
236
279
|
*
|
|
@@ -257,6 +300,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
257
300
|
private syncRequestedWhileRunning = false;
|
|
258
301
|
private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
259
302
|
private realtimeCatchupTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
303
|
+
private dataChangeDebounceTimeoutId: ReturnType<typeof setTimeout> | null =
|
|
304
|
+
null;
|
|
305
|
+
private pendingDataChangeScopes = new Set<string>();
|
|
260
306
|
private hasRealtimeConnectedOnce = false;
|
|
261
307
|
private transportHealth: TransportHealth = {
|
|
262
308
|
mode: 'disconnected',
|
|
@@ -329,6 +375,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
329
375
|
* Emits presence:change event for listeners.
|
|
330
376
|
*/
|
|
331
377
|
updatePresence(scopeKey: string, presence: PresenceEntry[]): void {
|
|
378
|
+
const current = this.presenceByScopeKey.get(scopeKey) ?? [];
|
|
379
|
+
if (arePresenceEntriesEqual(current, presence)) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
332
382
|
this.presenceByScopeKey.set(scopeKey, presence);
|
|
333
383
|
this.emit('presence:change', { scopeKey, presence });
|
|
334
384
|
}
|
|
@@ -403,26 +453,52 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
403
453
|
|
|
404
454
|
let updated: PresenceEntry[];
|
|
405
455
|
switch (event.action) {
|
|
406
|
-
case 'join':
|
|
456
|
+
case 'join': {
|
|
457
|
+
const existing = current.find((e) => e.clientId === event.clientId);
|
|
458
|
+
if (
|
|
459
|
+
existing &&
|
|
460
|
+
existing.actorId === event.actorId &&
|
|
461
|
+
areMetadataRecordsEqual(existing.metadata, event.metadata)
|
|
462
|
+
) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
407
465
|
// Add new entry (remove existing if present to update)
|
|
408
466
|
updated = [
|
|
409
467
|
...current.filter((e) => e.clientId !== event.clientId),
|
|
410
468
|
{
|
|
411
469
|
clientId: event.clientId,
|
|
412
470
|
actorId: event.actorId,
|
|
413
|
-
joinedAt: Date.now(),
|
|
471
|
+
joinedAt: existing?.joinedAt ?? Date.now(),
|
|
414
472
|
metadata: event.metadata,
|
|
415
473
|
},
|
|
416
474
|
];
|
|
417
475
|
break;
|
|
418
|
-
|
|
476
|
+
}
|
|
477
|
+
case 'leave': {
|
|
478
|
+
const hasEntry = current.some((e) => e.clientId === event.clientId);
|
|
479
|
+
if (!hasEntry) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
419
482
|
updated = current.filter((e) => e.clientId !== event.clientId);
|
|
420
483
|
break;
|
|
421
|
-
|
|
484
|
+
}
|
|
485
|
+
case 'update': {
|
|
486
|
+
const target = current.find((e) => e.clientId === event.clientId);
|
|
487
|
+
if (!target) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (areMetadataRecordsEqual(target.metadata, event.metadata)) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
422
493
|
updated = current.map((e) =>
|
|
423
494
|
e.clientId === event.clientId ? { ...e, metadata: event.metadata } : e
|
|
424
495
|
);
|
|
425
496
|
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (arePresenceEntriesEqual(current, updated)) {
|
|
501
|
+
return;
|
|
426
502
|
}
|
|
427
503
|
|
|
428
504
|
this.presenceByScopeKey.set(event.scopeKey, updated);
|
|
@@ -706,14 +782,103 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
706
782
|
return `${stateId}:${subscriptionId}`;
|
|
707
783
|
}
|
|
708
784
|
|
|
785
|
+
private static areTransportHealthEqual(
|
|
786
|
+
left: TransportHealth,
|
|
787
|
+
right: TransportHealth
|
|
788
|
+
): boolean {
|
|
789
|
+
return (
|
|
790
|
+
left.mode === right.mode &&
|
|
791
|
+
left.connected === right.connected &&
|
|
792
|
+
left.lastSuccessfulPollAt === right.lastSuccessfulPollAt &&
|
|
793
|
+
left.lastRealtimeMessageAt === right.lastRealtimeMessageAt &&
|
|
794
|
+
left.fallbackReason === right.fallbackReason
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
709
798
|
private updateTransportHealth(partial: Partial<TransportHealth>): void {
|
|
710
|
-
|
|
799
|
+
const next = {
|
|
711
800
|
...this.transportHealth,
|
|
712
801
|
...partial,
|
|
713
802
|
};
|
|
803
|
+
|
|
804
|
+
if (SyncEngine.areTransportHealthEqual(this.transportHealth, next)) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
this.transportHealth = next;
|
|
714
809
|
this.emit('state:change', {});
|
|
715
810
|
}
|
|
716
811
|
|
|
812
|
+
private resolveDataChangeDebounceMs(): number {
|
|
813
|
+
const normalize = (value: number | undefined): number | undefined => {
|
|
814
|
+
if (value === undefined) return undefined;
|
|
815
|
+
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
if (this.state.connectionState === 'reconnecting') {
|
|
819
|
+
const reconnectDebounce = normalize(
|
|
820
|
+
this.config.dataChangeDebounceMsWhenReconnecting
|
|
821
|
+
);
|
|
822
|
+
if (reconnectDebounce !== undefined) {
|
|
823
|
+
return reconnectDebounce;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (this.state.isSyncing) {
|
|
828
|
+
const syncingDebounce = normalize(
|
|
829
|
+
this.config.dataChangeDebounceMsWhenSyncing
|
|
830
|
+
);
|
|
831
|
+
if (syncingDebounce !== undefined) {
|
|
832
|
+
return syncingDebounce;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return normalize(this.config.dataChangeDebounceMs) ?? 0;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private emitDataChange(scopes: Iterable<string>): void {
|
|
840
|
+
const normalizedScopes = new Set<string>();
|
|
841
|
+
for (const scope of scopes) {
|
|
842
|
+
if (scope) {
|
|
843
|
+
normalizedScopes.add(scope);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (normalizedScopes.size === 0) return;
|
|
848
|
+
|
|
849
|
+
const debounceMs = this.resolveDataChangeDebounceMs();
|
|
850
|
+
if (debounceMs <= 0) {
|
|
851
|
+
this.flushDataChange(normalizedScopes);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
for (const scope of normalizedScopes) {
|
|
856
|
+
this.pendingDataChangeScopes.add(scope);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (this.dataChangeDebounceTimeoutId) return;
|
|
860
|
+
|
|
861
|
+
this.dataChangeDebounceTimeoutId = setTimeout(() => {
|
|
862
|
+
this.dataChangeDebounceTimeoutId = null;
|
|
863
|
+
this.flushDataChange();
|
|
864
|
+
}, debounceMs);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private flushDataChange(scopes?: Iterable<string>): void {
|
|
868
|
+
const scopedList = scopes
|
|
869
|
+
? Array.from(new Set(scopes))
|
|
870
|
+
: Array.from(this.pendingDataChangeScopes);
|
|
871
|
+
this.pendingDataChangeScopes.clear();
|
|
872
|
+
|
|
873
|
+
if (scopedList.length === 0) return;
|
|
874
|
+
|
|
875
|
+
this.emit('data:change', {
|
|
876
|
+
scopes: scopedList,
|
|
877
|
+
timestamp: Date.now(),
|
|
878
|
+
});
|
|
879
|
+
this.config.onDataChange?.(scopedList);
|
|
880
|
+
}
|
|
881
|
+
|
|
717
882
|
private waitForProgressSignal(timeoutMs: number): Promise<void> {
|
|
718
883
|
return new Promise((resolve) => {
|
|
719
884
|
const cleanups: Array<() => void> = [];
|
|
@@ -1164,6 +1329,26 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1164
1329
|
return this.on('state:change', callback);
|
|
1165
1330
|
}
|
|
1166
1331
|
|
|
1332
|
+
/**
|
|
1333
|
+
* Subscribe to state changes with selector-based equality filtering.
|
|
1334
|
+
* Callback is only invoked when the selected snapshot actually changes.
|
|
1335
|
+
*/
|
|
1336
|
+
subscribeSelector<T>(
|
|
1337
|
+
selector: () => T,
|
|
1338
|
+
callback: () => void,
|
|
1339
|
+
isEqual: (previous: T, next: T) => boolean = defaultSelectorEquality
|
|
1340
|
+
): () => void {
|
|
1341
|
+
let previous = selector();
|
|
1342
|
+
return this.subscribe(() => {
|
|
1343
|
+
const next = selector();
|
|
1344
|
+
if (isEqual(previous, next)) {
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
previous = next;
|
|
1348
|
+
callback();
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1167
1352
|
private emit<T extends SyncEventType>(
|
|
1168
1353
|
event: T,
|
|
1169
1354
|
payload: SyncEventPayloads[T]
|
|
@@ -1194,7 +1379,20 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1194
1379
|
}
|
|
1195
1380
|
|
|
1196
1381
|
private updateState(partial: Partial<SyncEngineState>): void {
|
|
1197
|
-
|
|
1382
|
+
const nextState = { ...this.state, ...partial };
|
|
1383
|
+
const unchanged =
|
|
1384
|
+
this.state.enabled === nextState.enabled &&
|
|
1385
|
+
this.state.isSyncing === nextState.isSyncing &&
|
|
1386
|
+
this.state.connectionState === nextState.connectionState &&
|
|
1387
|
+
this.state.transportMode === nextState.transportMode &&
|
|
1388
|
+
this.state.lastSyncAt === nextState.lastSyncAt &&
|
|
1389
|
+
this.state.error === nextState.error &&
|
|
1390
|
+
this.state.pendingCount === nextState.pendingCount &&
|
|
1391
|
+
this.state.retryCount === nextState.retryCount &&
|
|
1392
|
+
this.state.isRetrying === nextState.isRetrying;
|
|
1393
|
+
if (unchanged) return;
|
|
1394
|
+
|
|
1395
|
+
this.state = nextState;
|
|
1198
1396
|
// Emit state:change to notify useSyncExternalStore subscribers
|
|
1199
1397
|
this.emit('state:change', {});
|
|
1200
1398
|
}
|
|
@@ -1296,6 +1494,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1296
1494
|
* Stop the sync engine (cleanup without destroy)
|
|
1297
1495
|
*/
|
|
1298
1496
|
stop(): void {
|
|
1497
|
+
if (this.dataChangeDebounceTimeoutId) {
|
|
1498
|
+
clearTimeout(this.dataChangeDebounceTimeoutId);
|
|
1499
|
+
this.dataChangeDebounceTimeoutId = null;
|
|
1500
|
+
}
|
|
1501
|
+
this.flushDataChange();
|
|
1299
1502
|
this.stopPolling();
|
|
1300
1503
|
this.stopRealtime();
|
|
1301
1504
|
this.setConnectionState('disconnected');
|
|
@@ -1468,11 +1671,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1468
1671
|
// Emit data change for any tables that had changes
|
|
1469
1672
|
const changedTables = this.extractChangedTables(result.pullResponse);
|
|
1470
1673
|
if (changedTables.length > 0) {
|
|
1471
|
-
this.
|
|
1472
|
-
scopes: changedTables,
|
|
1473
|
-
timestamp: Date.now(),
|
|
1474
|
-
});
|
|
1475
|
-
this.config.onDataChange?.(changedTables);
|
|
1674
|
+
this.emitDataChange(changedTables);
|
|
1476
1675
|
}
|
|
1477
1676
|
this.handleBootstrapLifecycle(result.pullResponse);
|
|
1478
1677
|
|
|
@@ -1634,14 +1833,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1634
1833
|
}
|
|
1635
1834
|
}
|
|
1636
1835
|
|
|
1637
|
-
// Emit data change for
|
|
1836
|
+
// Emit (or debounce) data change for UI update
|
|
1638
1837
|
const changedTables = [...new Set(changes.map((c) => c.table))];
|
|
1639
1838
|
if (changedTables.length > 0) {
|
|
1640
|
-
this.
|
|
1641
|
-
scopes: changedTables,
|
|
1642
|
-
timestamp: Date.now(),
|
|
1643
|
-
});
|
|
1644
|
-
this.config.onDataChange?.(changedTables);
|
|
1839
|
+
this.emitDataChange(changedTables);
|
|
1645
1840
|
}
|
|
1646
1841
|
|
|
1647
1842
|
return true;
|
|
@@ -1816,11 +2011,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1816
2011
|
}
|
|
1817
2012
|
|
|
1818
2013
|
if (affectedTables.size > 0) {
|
|
1819
|
-
this.
|
|
1820
|
-
scopes: Array.from(affectedTables),
|
|
1821
|
-
timestamp: Date.now(),
|
|
1822
|
-
});
|
|
1823
|
-
this.config.onDataChange?.(Array.from(affectedTables));
|
|
2014
|
+
this.emitDataChange(affectedTables);
|
|
1824
2015
|
}
|
|
1825
2016
|
}
|
|
1826
2017
|
|
|
@@ -2098,11 +2289,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
2098
2289
|
this.tableMutationTimestamps.clear();
|
|
2099
2290
|
|
|
2100
2291
|
if (tables.length > 0) {
|
|
2101
|
-
this.
|
|
2102
|
-
scopes: tables,
|
|
2103
|
-
timestamp: Date.now(),
|
|
2104
|
-
});
|
|
2105
|
-
this.config.onDataChange?.(tables);
|
|
2292
|
+
this.emitDataChange(tables);
|
|
2106
2293
|
}
|
|
2107
2294
|
}
|
|
2108
2295
|
|