@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.
Files changed (49) hide show
  1. package/dist/blobs/index.d.ts +0 -1
  2. package/dist/blobs/index.d.ts.map +1 -1
  3. package/dist/blobs/index.js +0 -1
  4. package/dist/blobs/index.js.map +1 -1
  5. package/dist/client.d.ts +21 -0
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +12 -0
  8. package/dist/client.js.map +1 -1
  9. package/dist/create-client.d.ts +17 -0
  10. package/dist/create-client.d.ts.map +1 -1
  11. package/dist/create-client.js +3 -0
  12. package/dist/create-client.js.map +1 -1
  13. package/dist/engine/SyncEngine.d.ts +11 -0
  14. package/dist/engine/SyncEngine.d.ts.map +1 -1
  15. package/dist/engine/SyncEngine.js +181 -27
  16. package/dist/engine/SyncEngine.js.map +1 -1
  17. package/dist/engine/types.d.ts +17 -0
  18. package/dist/engine/types.d.ts.map +1 -1
  19. package/dist/migrate.d.ts +1 -1
  20. package/dist/migrate.d.ts.map +1 -1
  21. package/dist/migrate.js +0 -126
  22. package/dist/migrate.js.map +1 -1
  23. package/dist/mutations.d.ts.map +1 -1
  24. package/dist/mutations.js +9 -1
  25. package/dist/mutations.js.map +1 -1
  26. package/dist/query/fingerprint.d.ts +1 -1
  27. package/dist/query/fingerprint.d.ts.map +1 -1
  28. package/dist/query/fingerprint.js +29 -6
  29. package/dist/query/fingerprint.js.map +1 -1
  30. package/dist/sync-loop.d.ts.map +1 -1
  31. package/dist/sync-loop.js +29 -19
  32. package/dist/sync-loop.js.map +1 -1
  33. package/package.json +3 -3
  34. package/src/blobs/index.ts +0 -1
  35. package/src/client.ts +37 -0
  36. package/src/create-client.ts +21 -0
  37. package/src/engine/SyncEngine.test.ts +257 -0
  38. package/src/engine/SyncEngine.ts +214 -27
  39. package/src/engine/types.ts +17 -0
  40. package/src/migrate.ts +1 -190
  41. package/src/mutations.ts +9 -1
  42. package/src/query/fingerprint.test.ts +73 -0
  43. package/src/query/fingerprint.ts +33 -6
  44. package/src/sync-loop.ts +29 -19
  45. package/dist/blobs/manager.d.ts +0 -345
  46. package/dist/blobs/manager.d.ts.map +0 -1
  47. package/dist/blobs/manager.js +0 -749
  48. package/dist/blobs/manager.js.map +0 -1
  49. package/src/blobs/manager.ts +0 -1027
@@ -2,6 +2,5 @@
2
2
  * @syncular/client - Blob storage exports
3
3
  */
4
4
 
5
- export * from './manager';
6
5
  export * from './migrate';
7
6
  export * from './types';
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
  // ===========================================================================
@@ -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:' }),
@@ -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
- case 'leave':
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
- case 'update':
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
- this.transportHealth = {
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
- this.state = { ...this.state, ...partial };
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.emit('data:change', {
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 immediate UI update
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.emit('data:change', {
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.emit('data:change', {
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.emit('data:change', {
2102
- scopes: tables,
2103
- timestamp: Date.now(),
2104
- });
2105
- this.config.onDataChange?.(tables);
2292
+ this.emitDataChange(tables);
2106
2293
  }
2107
2294
  }
2108
2295