@syncular/client 0.0.6-219 → 0.0.6-223

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/src/client.ts CHANGED
@@ -26,6 +26,9 @@ import type {
26
26
  SubscriptionProgress,
27
27
  SyncAwaitBootstrapOptions,
28
28
  SyncAwaitPhaseOptions,
29
+ SyncBootstrapStatus,
30
+ SyncBootstrapStatusOptions,
31
+ SyncClientSubscription,
29
32
  SyncDiagnostics,
30
33
  SyncEngineState,
31
34
  SyncInspectorOptions,
@@ -35,6 +38,7 @@ import type {
35
38
  SyncResetOptions,
36
39
  SyncResetResult,
37
40
  SyncResult,
41
+ SyncTraceEvent,
38
42
  TransportHealth,
39
43
  } from './engine/types';
40
44
  import type { ClientHandlerCollection } from './handlers/collection';
@@ -74,16 +78,14 @@ export interface ClientOptions<DB extends SyncClientDb> {
74
78
  actorId: string;
75
79
 
76
80
  /** Subscriptions to sync */
77
- subscriptions: Array<{
78
- id: string;
79
- table: string;
80
- scopes?: Record<string, string | string[]>;
81
- params?: Record<string, unknown>;
82
- }>;
81
+ subscriptions: SyncClientSubscription[];
83
82
 
84
83
  /** Optional: Sync plugins */
85
84
  plugins?: SyncClientPlugin[];
86
85
 
86
+ /** Optional: Emit structured pull/apply tracing to client events and inspector snapshots. */
87
+ traceEnabled?: boolean;
88
+
87
89
  /** Optional: Enable realtime transport mode */
88
90
  realtimeEnabled?: boolean;
89
91
 
@@ -145,7 +147,17 @@ export interface ClientState {
145
147
  /** Last successful sync timestamp */
146
148
  lastSyncAt: number | null;
147
149
  /** Current error if any */
148
- error: { code: string; message: string } | null;
150
+ error: {
151
+ code: string;
152
+ message: string;
153
+ stage?: string;
154
+ retryable?: boolean;
155
+ httpStatus?: number;
156
+ subscriptionId?: string;
157
+ chunkId?: string;
158
+ table?: string;
159
+ stateId?: string;
160
+ } | null;
149
161
  /** Outbox statistics */
150
162
  outbox: OutboxStats;
151
163
  }
@@ -175,6 +187,7 @@ export interface MigrationInfo {
175
187
  type ClientEventType =
176
188
  | 'sync:start'
177
189
  | 'sync:complete'
190
+ | 'sync:trace'
178
191
  | 'sync:live'
179
192
  | 'sync:error'
180
193
  | 'push:result'
@@ -193,8 +206,19 @@ type ClientEventType =
193
206
  type ClientEventPayloads = {
194
207
  'sync:start': { timestamp: number };
195
208
  'sync:complete': SyncResult;
209
+ 'sync:trace': SyncTraceEvent;
196
210
  'sync:live': { timestamp: number };
197
- 'sync:error': { code: string; message: string };
211
+ 'sync:error': {
212
+ code: string;
213
+ message: string;
214
+ stage?: string;
215
+ retryable?: boolean;
216
+ httpStatus?: number;
217
+ subscriptionId?: string;
218
+ chunkId?: string;
219
+ table?: string;
220
+ stateId?: string;
221
+ };
198
222
  'push:result': PushResultInfo;
199
223
  'bootstrap:start': {
200
224
  timestamp: number;
@@ -361,8 +385,11 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
361
385
  table: s.table,
362
386
  scopes: s.scopes ?? {},
363
387
  params: s.params ?? {},
388
+ bootstrapState: s.bootstrapState,
389
+ bootstrapPhase: s.bootstrapPhase,
364
390
  })),
365
391
  plugins: this.options.plugins,
392
+ traceEnabled: this.options.traceEnabled,
366
393
  realtimeEnabled: this.options.realtimeEnabled,
367
394
  pollIntervalMs: this.options.pollIntervalMs,
368
395
  dedupeRows: this.options.dedupeRows,
@@ -423,22 +450,14 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
423
450
  /**
424
451
  * Update subscriptions.
425
452
  */
426
- updateSubscriptions(
427
- subscriptions: Array<{
428
- id: string;
429
- table: string;
430
- scopes?: Record<string, string | string[]>;
431
- params?: Record<string, unknown>;
432
- }>
433
- ): void {
453
+ updateSubscriptions(subscriptions: SyncClientSubscription[]): void {
434
454
  this.options.subscriptions = subscriptions;
435
455
  if (this.engine) {
436
456
  this.engine.updateSubscriptions(
437
- subscriptions.map((s) => ({
438
- id: s.id,
439
- table: s.table,
440
- scopes: s.scopes ?? {},
441
- params: s.params ?? {},
457
+ subscriptions.map((subscription) => ({
458
+ ...subscription,
459
+ scopes: subscription.scopes ?? {},
460
+ params: subscription.params ?? {},
442
461
  }))
443
462
  );
444
463
  }
@@ -447,17 +466,14 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
447
466
  /**
448
467
  * Get current subscriptions.
449
468
  */
450
- getSubscriptions(): Array<{
451
- id: string;
452
- table: string;
453
- scopes: Record<string, string | string[]>;
454
- params: Record<string, unknown>;
455
- }> {
469
+ getSubscriptions(): SyncClientSubscription[] {
456
470
  return this.options.subscriptions.map((s) => ({
457
471
  id: s.id,
458
472
  table: s.table,
459
473
  scopes: s.scopes ?? {},
460
474
  params: s.params ?? {},
475
+ bootstrapState: s.bootstrapState,
476
+ bootstrapPhase: s.bootstrapPhase,
461
477
  }));
462
478
  }
463
479
 
@@ -502,7 +518,17 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
502
518
  connectionState: engineState.connectionState,
503
519
  lastSyncAt: engineState.lastSyncAt,
504
520
  error: engineState.error
505
- ? { code: engineState.error.code, message: engineState.error.message }
521
+ ? {
522
+ code: engineState.error.code,
523
+ message: engineState.error.message,
524
+ stage: engineState.error.stage,
525
+ retryable: engineState.error.retryable,
526
+ httpStatus: engineState.error.httpStatus,
527
+ subscriptionId: engineState.error.subscriptionId,
528
+ chunkId: engineState.error.chunkId,
529
+ table: engineState.error.table,
530
+ stateId: engineState.error.stateId,
531
+ }
506
532
  : null,
507
533
  outbox: this.outboxStats,
508
534
  };
@@ -524,6 +550,16 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
524
550
  return this.engine.getProgress();
525
551
  }
526
552
 
553
+ /**
554
+ * Get bootstrap readiness for the configured blocking phase or a selected subset.
555
+ */
556
+ async getBootstrapStatus(
557
+ options?: SyncBootstrapStatusOptions
558
+ ): Promise<SyncBootstrapStatus | null> {
559
+ if (!this.engine) return null;
560
+ return this.engine.getBootstrapStatus(options);
561
+ }
562
+
527
563
  /**
528
564
  * Get a diagnostics snapshot for support/debug flows.
529
565
  */
@@ -957,12 +993,26 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
957
993
  });
958
994
  });
959
995
 
996
+ this.engine.on('sync:trace', (payload) => {
997
+ this.emit('sync:trace', payload);
998
+ });
999
+
960
1000
  this.engine.on('sync:live', (payload) => {
961
1001
  this.emit('sync:live', payload);
962
1002
  });
963
1003
 
964
1004
  this.engine.on('sync:error', (error) => {
965
- this.emit('sync:error', { code: error.code, message: error.message });
1005
+ this.emit('sync:error', {
1006
+ code: error.code,
1007
+ message: error.message,
1008
+ stage: error.stage,
1009
+ retryable: error.retryable,
1010
+ httpStatus: error.httpStatus,
1011
+ subscriptionId: error.subscriptionId,
1012
+ chunkId: error.chunkId,
1013
+ table: error.table,
1014
+ stateId: error.stateId,
1015
+ });
966
1016
  });
967
1017
 
968
1018
  this.engine.on('push:result', (payload) => {
@@ -225,6 +225,70 @@ describe('SyncEngine WS inline apply', () => {
225
225
  expect(snapshot.diagnostics).toBeDefined();
226
226
  });
227
227
 
228
+ it('emits structured pull/apply trace events when tracing is enabled', async () => {
229
+ const handlers: ClientHandlerCollection<TestDb> = [
230
+ {
231
+ table: 'tasks',
232
+ async applySnapshot() {},
233
+ async clearAll() {},
234
+ async applyChange() {},
235
+ },
236
+ ];
237
+
238
+ const transport: SyncTransport = {
239
+ async sync() {
240
+ return {
241
+ pull: {
242
+ ok: true,
243
+ subscriptions: [
244
+ {
245
+ id: 'sub-1',
246
+ status: 'active',
247
+ table: 'tasks',
248
+ scopes: {},
249
+ bootstrap: false,
250
+ commits: [],
251
+ snapshots: [],
252
+ nextCursor: 0,
253
+ },
254
+ ],
255
+ },
256
+ };
257
+ },
258
+ };
259
+
260
+ const engine = new SyncEngine<TestDb>({
261
+ db,
262
+ transport,
263
+ handlers,
264
+ actorId: 'u1',
265
+ clientId: 'client-trace',
266
+ subscriptions: [{ id: 'sub-1', table: 'tasks', scopes: {} }],
267
+ stateId: 'default',
268
+ traceEnabled: true,
269
+ });
270
+
271
+ const stages: string[] = [];
272
+ engine.on('sync:trace', (payload) => {
273
+ stages.push(payload.stage);
274
+ });
275
+
276
+ await engine.start();
277
+
278
+ expect(stages).toContain('pull:start');
279
+ expect(stages).toContain('pull:response');
280
+ expect(stages).toContain('apply:transaction:start');
281
+ expect(stages).toContain('apply:transaction:complete');
282
+ expect(stages).toContain('apply:subscription:start');
283
+ expect(stages).toContain('apply:subscription:complete');
284
+
285
+ const snapshot = await engine.getInspectorSnapshot({ eventLimit: 50 });
286
+ const traceEvents = snapshot.recentEvents.filter(
287
+ (event) => event.event === 'sync:trace'
288
+ );
289
+ expect(traceEvents.length).toBeGreaterThan(0);
290
+ });
291
+
228
292
  it('coalesces rapid data:change emissions when debounce is configured', async () => {
229
293
  const handlers: ClientHandlerCollection<TestDb> = [
230
294
  {
@@ -681,11 +745,185 @@ describe('SyncEngine WS inline apply', () => {
681
745
 
682
746
  const state = engine.getState();
683
747
  expect(state.error?.code).toBe('SNAPSHOT_CHUNK_NOT_FOUND');
748
+ expect(state.error?.stage).toBe('pull');
684
749
  expect(state.error?.retryable).toBe(false);
685
750
  expect(state.retryCount).toBe(1);
686
751
  expect(state.isRetrying).toBe(false);
687
752
  });
688
753
 
754
+ it('classifies gzip decode failures with chunk metadata', async () => {
755
+ const invalidCompressed = new Uint8Array(
756
+ gzipSync(new TextEncoder().encode('truncated-gzip')).subarray(0, 8)
757
+ );
758
+ const transport: SyncTransport = {
759
+ capabilities: {
760
+ snapshotChunkReadMode: 'bytes',
761
+ preferMaterializedSnapshots: true,
762
+ },
763
+ async sync() {
764
+ return {
765
+ pull: {
766
+ ok: true,
767
+ subscriptions: [
768
+ {
769
+ id: 'sub-1',
770
+ status: 'active',
771
+ scopes: {},
772
+ bootstrap: true,
773
+ bootstrapState: null,
774
+ nextCursor: 1,
775
+ commits: [],
776
+ snapshots: [
777
+ {
778
+ table: 'tasks',
779
+ rows: [],
780
+ chunks: [
781
+ {
782
+ id: 'chunk-1',
783
+ byteLength: invalidCompressed.length,
784
+ sha256: '',
785
+ encoding: 'json-row-frame-v1',
786
+ compression: 'gzip',
787
+ },
788
+ ],
789
+ isFirstPage: true,
790
+ isLastPage: true,
791
+ },
792
+ ],
793
+ },
794
+ ],
795
+ },
796
+ };
797
+ },
798
+ async fetchSnapshotChunk() {
799
+ return invalidCompressed;
800
+ },
801
+ };
802
+
803
+ const handlers: ClientHandlerCollection<TestDb> = [
804
+ {
805
+ table: 'tasks',
806
+ async applySnapshot() {},
807
+ async clearAll() {},
808
+ async applyChange() {},
809
+ },
810
+ ];
811
+
812
+ const engine = new SyncEngine<TestDb>({
813
+ db,
814
+ transport,
815
+ handlers,
816
+ actorId: 'u1',
817
+ clientId: 'client-gzip-failure',
818
+ subscriptions: [
819
+ {
820
+ id: 'sub-1',
821
+ table: 'tasks',
822
+ scopes: {},
823
+ },
824
+ ],
825
+ stateId: 'default',
826
+ pollIntervalMs: 60_000,
827
+ maxRetries: 1,
828
+ });
829
+
830
+ await engine.start();
831
+ engine.stop();
832
+
833
+ const state = engine.getState();
834
+ expect(state.error?.code).toBe('SNAPSHOT_GZIP_DECODE_FAILED');
835
+ expect(state.error?.stage).toBe('snapshot-gzip-decode');
836
+ expect(state.error?.subscriptionId).toBe('sub-1');
837
+ expect(state.error?.chunkId).toBe('chunk-1');
838
+ expect(state.error?.table).toBe('tasks');
839
+ expect(state.error?.retryable).toBe(false);
840
+ });
841
+
842
+ it('classifies snapshot apply failures with stage metadata', async () => {
843
+ const rows = [{ id: 't2', title: 'new', server_version: 1 }];
844
+ const encoded = encodeSnapshotRows(rows);
845
+ const compressed = new Uint8Array(gzipSync(encoded));
846
+ const transport: SyncTransport = {
847
+ async sync() {
848
+ return {
849
+ pull: {
850
+ ok: true,
851
+ subscriptions: [
852
+ {
853
+ id: 'sub-1',
854
+ status: 'active',
855
+ scopes: {},
856
+ bootstrap: true,
857
+ bootstrapState: null,
858
+ nextCursor: 1,
859
+ commits: [],
860
+ snapshots: [
861
+ {
862
+ table: 'tasks',
863
+ rows: [],
864
+ chunks: [
865
+ {
866
+ id: 'chunk-1',
867
+ byteLength: compressed.length,
868
+ sha256: '',
869
+ encoding: 'json-row-frame-v1',
870
+ compression: 'gzip',
871
+ },
872
+ ],
873
+ isFirstPage: true,
874
+ isLastPage: true,
875
+ },
876
+ ],
877
+ },
878
+ ],
879
+ },
880
+ };
881
+ },
882
+ async fetchSnapshotChunk() {
883
+ return compressed;
884
+ },
885
+ };
886
+
887
+ const handlers: ClientHandlerCollection<TestDb> = [
888
+ {
889
+ table: 'tasks',
890
+ async applySnapshot() {
891
+ throw new Error('forced snapshot apply failure');
892
+ },
893
+ async clearAll() {},
894
+ async applyChange() {},
895
+ },
896
+ ];
897
+
898
+ const engine = new SyncEngine<TestDb>({
899
+ db,
900
+ transport,
901
+ handlers,
902
+ actorId: 'u1',
903
+ clientId: 'client-apply-failure',
904
+ subscriptions: [
905
+ {
906
+ id: 'sub-1',
907
+ table: 'tasks',
908
+ scopes: {},
909
+ },
910
+ ],
911
+ stateId: 'default',
912
+ pollIntervalMs: 60_000,
913
+ maxRetries: 1,
914
+ });
915
+
916
+ await engine.start();
917
+ engine.stop();
918
+
919
+ const state = engine.getState();
920
+ expect(state.error?.code).toBe('SNAPSHOT_APPLY_FAILED');
921
+ expect(state.error?.stage).toBe('snapshot-apply');
922
+ expect(state.error?.subscriptionId).toBe('sub-1');
923
+ expect(state.error?.table).toBe('tasks');
924
+ expect(state.error?.retryable).toBe(false);
925
+ });
926
+
689
927
  it('skips outbox and conflict refresh after a read-only successful sync', async () => {
690
928
  const handlers: ClientHandlerCollection<TestDb> = [
691
929
  {