@syncular/client 0.0.6-136 → 0.0.6-139

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.0.6-136",
3
+ "version": "0.0.6-139",
4
4
  "description": "Client-side sync engine with offline-first support, outbox, and conflict resolution",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -46,8 +46,8 @@
46
46
  "release": "bunx syncular-publish"
47
47
  },
48
48
  "dependencies": {
49
- "@syncular/core": "0.0.6-136",
50
- "@syncular/transport-http": "0.0.6-136"
49
+ "@syncular/core": "0.0.6-139",
50
+ "@syncular/transport-http": "0.0.6-139"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "kysely": "*"
@@ -141,13 +141,13 @@ describe('Client conflict events', () => {
141
141
  }
142
142
 
143
143
  async function runConflictCheck(
144
- clientInstance: Client<TestDb>
144
+ engineInstance: SyncEngine<TestDb>
145
145
  ): Promise<void> {
146
- const checker = Reflect.get(clientInstance, 'checkForNewConflicts');
146
+ const checker = Reflect.get(engineInstance, 'emitNewConflicts');
147
147
  if (typeof checker !== 'function') {
148
- throw new Error('Expected checkForNewConflicts to be callable');
148
+ throw new Error('Expected emitNewConflicts to be callable');
149
149
  }
150
- await checker.call(clientInstance);
150
+ await checker.call(engineInstance);
151
151
  }
152
152
 
153
153
  beforeEach(async () => {
@@ -184,6 +184,11 @@ describe('Client conflict events', () => {
184
184
  subscriptions: [],
185
185
  });
186
186
  Reflect.set(client, 'engine', engine);
187
+ const wireEngineEvents = Reflect.get(client, 'wireEngineEvents');
188
+ if (typeof wireEngineEvents !== 'function') {
189
+ throw new Error('Expected wireEngineEvents to be callable');
190
+ }
191
+ wireEngineEvents.call(client);
187
192
  });
188
193
 
189
194
  afterEach(async () => {
@@ -220,11 +225,43 @@ describe('Client conflict events', () => {
220
225
  newEvents.push(conflict.id);
221
226
  });
222
227
 
223
- await runConflictCheck(client);
224
- await runConflictCheck(client);
228
+ await runConflictCheck(engine);
229
+ await runConflictCheck(engine);
225
230
 
226
231
  expect(newEvents).toEqual(['conflict-1']);
227
232
  });
233
+
234
+ it('forwards push:result events from the engine', () => {
235
+ const pushResults: Array<{ clientCommitId: string; status: string }> = [];
236
+ client.on('push:result', (result) => {
237
+ pushResults.push({
238
+ clientCommitId: result.clientCommitId,
239
+ status: result.status,
240
+ });
241
+ });
242
+
243
+ const emit = Reflect.get(engine, 'emit');
244
+ if (typeof emit !== 'function') {
245
+ throw new Error('Expected SyncEngine.emit to be callable');
246
+ }
247
+
248
+ emit.call(engine, 'push:result', {
249
+ outboxCommitId: 'outbox-1',
250
+ clientCommitId: 'commit-1',
251
+ status: 'rejected',
252
+ commitSeq: null,
253
+ results: [],
254
+ errorCode: 'CONFLICT',
255
+ timestamp: Date.now(),
256
+ });
257
+
258
+ expect(pushResults).toEqual([
259
+ {
260
+ clientCommitId: 'commit-1',
261
+ status: 'rejected',
262
+ },
263
+ ]);
264
+ });
228
265
  });
229
266
 
230
267
  describe('Client blob upload queue recovery', () => {
package/src/client.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  ConflictInfo,
25
25
  OutboxStats,
26
26
  PresenceEntry,
27
+ PushResultInfo,
27
28
  SubscriptionProgress,
28
29
  SyncAwaitBootstrapOptions,
29
30
  SyncAwaitPhaseOptions,
@@ -122,21 +123,22 @@ export interface ClientOptions<DB extends SyncClientDb> {
122
123
 
123
124
  /**
124
125
  * Optional: Debounce window (ms) for coalescing `data:change` events.
125
- * - `0` (default): emit immediately
126
+ * - default: `10`
127
+ * - `0`/`false`: emit immediately (disable debounce)
126
128
  * - `>0`: merge scopes and emit once per window
127
129
  */
128
- dataChangeDebounceMs?: number;
130
+ dataChangeDebounceMs?: number | false;
129
131
  /**
130
132
  * Optional: Debounce override while sync is actively running.
131
133
  * Falls back to `dataChangeDebounceMs` when omitted.
132
134
  */
133
- dataChangeDebounceMsWhenSyncing?: number;
135
+ dataChangeDebounceMsWhenSyncing?: number | false;
134
136
  /**
135
137
  * Optional: Debounce override while connection is reconnecting.
136
138
  * Falls back to `dataChangeDebounceMsWhenSyncing` (if syncing) and then
137
139
  * `dataChangeDebounceMs` when omitted.
138
140
  */
139
- dataChangeDebounceMsWhenReconnecting?: number;
141
+ dataChangeDebounceMsWhenReconnecting?: number | false;
140
142
 
141
143
  /** Optional: State ID for multi-tenant scenarios */
142
144
  stateId?: string;
@@ -249,6 +251,7 @@ type ClientEventType =
249
251
  | 'sync:complete'
250
252
  | 'sync:live'
251
253
  | 'sync:error'
254
+ | 'push:result'
252
255
  | 'bootstrap:start'
253
256
  | 'bootstrap:progress'
254
257
  | 'bootstrap:complete'
@@ -266,6 +269,7 @@ type ClientEventPayloads = {
266
269
  'sync:complete': SyncResult;
267
270
  'sync:live': { timestamp: number };
268
271
  'sync:error': { code: string; message: string };
272
+ 'push:result': PushResultInfo;
269
273
  'bootstrap:start': {
270
274
  timestamp: number;
271
275
  stateId: string;
@@ -332,7 +336,6 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
332
336
  private engine: SyncEngine<DB> | null = null;
333
337
  private started = false;
334
338
  private destroyed = false;
335
- private emittedConflictIds = new Set<string>();
336
339
  private eventListeners = new Map<
337
340
  ClientEventType,
338
341
  Set<ClientEventHandler<any>>
@@ -758,7 +761,6 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
758
761
  },
759
762
  });
760
763
 
761
- this.emittedConflictIds.delete(id);
762
764
  if (resolvedConflict) {
763
765
  this.emit('conflict:resolved', resolvedConflict);
764
766
  }
@@ -975,9 +977,14 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
975
977
 
976
978
  this.engine.on('sync:error', (error) => {
977
979
  this.emit('sync:error', { code: error.code, message: error.message });
980
+ });
981
+
982
+ this.engine.on('push:result', (payload) => {
983
+ this.emit('push:result', payload);
984
+ });
978
985
 
979
- // Check for new conflicts after sync error
980
- this.checkForNewConflicts();
986
+ this.engine.on('conflict:new', (payload) => {
987
+ this.emit('conflict:new', this.mapConflictInfo(payload));
981
988
  });
982
989
 
983
990
  this.engine.on('bootstrap:start', (payload) => {
@@ -1020,25 +1027,6 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
1020
1027
  });
1021
1028
  }
1022
1029
 
1023
- private async checkForNewConflicts(): Promise<void> {
1024
- const conflicts = await this.getConflicts();
1025
- const activeIds = new Set(conflicts.map((conflict) => conflict.id));
1026
-
1027
- for (const id of this.emittedConflictIds) {
1028
- if (!activeIds.has(id)) {
1029
- this.emittedConflictIds.delete(id);
1030
- }
1031
- }
1032
-
1033
- for (const conflict of conflicts) {
1034
- if (this.emittedConflictIds.has(conflict.id)) {
1035
- continue;
1036
- }
1037
- this.emittedConflictIds.add(conflict.id);
1038
- this.emit('conflict:new', conflict);
1039
- }
1040
- }
1041
-
1042
1030
  private mapConflictInfo(info: ConflictInfo): Conflict {
1043
1031
  let serverPayload: Record<string, unknown> | null = null;
1044
1032
  if (info.serverRowJson) {
@@ -143,21 +143,22 @@ interface CreateClientOptions<DB extends SyncClientDb> {
143
143
  pollIntervalMs?: number;
144
144
  /**
145
145
  * Debounce window (ms) for coalescing `data:change` events.
146
- * - `0` (default): emit immediately
146
+ * - default: `10`
147
+ * - `0`/`false`: emit immediately (disable debounce)
147
148
  * - `>0`: merge scopes and emit once per window
148
149
  */
149
- dataChangeDebounceMs?: number;
150
+ dataChangeDebounceMs?: number | false;
150
151
  /**
151
152
  * Debounce override while sync is actively running.
152
153
  * Falls back to `dataChangeDebounceMs` when omitted.
153
154
  */
154
- dataChangeDebounceMsWhenSyncing?: number;
155
+ dataChangeDebounceMsWhenSyncing?: number | false;
155
156
  /**
156
157
  * Debounce override while connection is reconnecting.
157
158
  * Falls back to `dataChangeDebounceMsWhenSyncing` (if syncing) and then
158
159
  * `dataChangeDebounceMs` when omitted.
159
160
  */
160
- dataChangeDebounceMsWhenReconnecting?: number;
161
+ dataChangeDebounceMsWhenReconnecting?: number | false;
161
162
  };
162
163
 
163
164
  /** Optional: Local blob storage adapter */
@@ -273,6 +273,89 @@ describe('SyncEngine WS inline apply', () => {
273
273
  expect(onDataChangeCalls).toEqual([['tasks']]);
274
274
  });
275
275
 
276
+ it('uses 10ms debounce by default and supports 0/false opt-out', async () => {
277
+ const handlers: ClientHandlerCollection<TestDb> = [
278
+ {
279
+ table: 'tasks',
280
+ async applySnapshot() {},
281
+ async clearAll() {},
282
+ async applyChange() {},
283
+ },
284
+ ];
285
+
286
+ const defaultEngine = new SyncEngine<TestDb>({
287
+ db,
288
+ transport: noopTransport,
289
+ handlers,
290
+ actorId: 'u1',
291
+ clientId: 'client-default-debounce',
292
+ subscriptions: [],
293
+ stateId: 'default',
294
+ });
295
+
296
+ const defaultEvents: string[][] = [];
297
+ defaultEngine.on('data:change', (payload) => {
298
+ defaultEvents.push(payload.scopes);
299
+ });
300
+
301
+ defaultEngine.recordLocalMutations([
302
+ { table: 'tasks', rowId: 'd1', op: 'upsert' },
303
+ ]);
304
+ defaultEngine.recordLocalMutations([
305
+ { table: 'tasks', rowId: 'd2', op: 'upsert' },
306
+ ]);
307
+ expect(defaultEvents).toEqual([]);
308
+
309
+ await new Promise<void>((resolve) => setTimeout(resolve, 20));
310
+ expect(defaultEvents).toEqual([['tasks']]);
311
+
312
+ const noDebounceEngine = new SyncEngine<TestDb>({
313
+ db,
314
+ transport: noopTransport,
315
+ handlers,
316
+ actorId: 'u1',
317
+ clientId: 'client-no-debounce',
318
+ subscriptions: [],
319
+ stateId: 'default',
320
+ dataChangeDebounceMs: false,
321
+ });
322
+
323
+ const immediateEvents: string[][] = [];
324
+ noDebounceEngine.on('data:change', (payload) => {
325
+ immediateEvents.push(payload.scopes);
326
+ });
327
+
328
+ noDebounceEngine.recordLocalMutations([
329
+ { table: 'tasks', rowId: 'n1', op: 'upsert' },
330
+ ]);
331
+ expect(immediateEvents).toEqual([['tasks']]);
332
+
333
+ const zeroDebounceEngine = new SyncEngine<TestDb>({
334
+ db,
335
+ transport: noopTransport,
336
+ handlers,
337
+ actorId: 'u1',
338
+ clientId: 'client-zero-debounce',
339
+ subscriptions: [],
340
+ stateId: 'default',
341
+ dataChangeDebounceMs: 0,
342
+ });
343
+
344
+ const zeroEvents: string[][] = [];
345
+ zeroDebounceEngine.on('data:change', (payload) => {
346
+ zeroEvents.push(payload.scopes);
347
+ });
348
+
349
+ zeroDebounceEngine.recordLocalMutations([
350
+ { table: 'tasks', rowId: 'z1', op: 'upsert' },
351
+ ]);
352
+ expect(zeroEvents).toEqual([['tasks']]);
353
+
354
+ defaultEngine.destroy();
355
+ noDebounceEngine.destroy();
356
+ zeroDebounceEngine.destroy();
357
+ });
358
+
276
359
  it('supports adaptive debounce overrides while syncing and reconnecting', async () => {
277
360
  const handlers: ClientHandlerCollection<TestDb> = [
278
361
  {
@@ -301,6 +384,11 @@ describe('SyncEngine WS inline apply', () => {
301
384
  eventScopes.push(payload.scopes);
302
385
  });
303
386
 
387
+ const setConnectionState = Reflect.get(engine, 'setConnectionState');
388
+ if (typeof setConnectionState !== 'function') {
389
+ throw new Error('Expected setConnectionState to be callable');
390
+ }
391
+
304
392
  const updateState = Reflect.get(engine, 'updateState');
305
393
  if (typeof updateState !== 'function') {
306
394
  throw new Error('Expected updateState to be callable');
@@ -320,17 +408,28 @@ describe('SyncEngine WS inline apply', () => {
320
408
  expect(eventScopes).toEqual([['tasks']]);
321
409
 
322
410
  updateState.call(engine, {
323
- isSyncing: true,
324
- connectionState: 'reconnecting',
411
+ isSyncing: false,
412
+ connectionState: 'connected',
325
413
  });
414
+ setConnectionState.call(engine, 'reconnecting');
326
415
 
327
416
  engine.recordLocalMutations([
328
417
  { table: 'tasks', rowId: 'reconnecting-1', op: 'upsert' },
329
418
  ]);
330
- await new Promise<void>((resolve) => setTimeout(resolve, 20));
419
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
331
420
  expect(eventScopes).toEqual([['tasks']]);
332
421
 
333
- await new Promise<void>((resolve) => setTimeout(resolve, 25));
422
+ setConnectionState.call(engine, 'connected');
423
+ const flushReconnectBatch = Reflect.get(
424
+ engine,
425
+ 'flushReconnectBatchedDataChangesIfReady'
426
+ );
427
+ if (typeof flushReconnectBatch !== 'function') {
428
+ throw new Error(
429
+ 'Expected flushReconnectBatchedDataChangesIfReady to be callable'
430
+ );
431
+ }
432
+ flushReconnectBatch.call(engine);
334
433
  expect(eventScopes).toEqual([['tasks'], ['tasks']]);
335
434
  });
336
435