@syncular/client 0.0.6-219 → 0.0.6-221

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.
@@ -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
  {
@@ -14,7 +14,6 @@ import {
14
14
  type SyncOperation,
15
15
  type SyncPullResponse,
16
16
  type SyncPullSubscriptionResponse,
17
- type SyncSubscriptionRequest,
18
17
  SyncTransportError,
19
18
  startSyncSpan,
20
19
  } from '@syncular/core';
@@ -53,6 +52,7 @@ import type {
53
52
  SyncBootstrapStatus,
54
53
  SyncBootstrapStatusOptions,
55
54
  SyncBootstrapSubscriptionPhase,
55
+ SyncClientSubscription,
56
56
  SyncConnectionState,
57
57
  SyncDiagnostics,
58
58
  SyncEngineConfig,
@@ -273,6 +273,11 @@ function defaultSelectorEquality<T>(left: T, right: T): boolean {
273
273
  return Object.is(left, right);
274
274
  }
275
275
 
276
+ function normalizeBootstrapPhase(value: number | undefined): number {
277
+ if (value === undefined) return 0;
278
+ return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
279
+ }
280
+
276
281
  function areMetadataRecordsEqual(
277
282
  left: Record<string, unknown> | undefined,
278
283
  right: Record<string, unknown> | undefined
@@ -736,30 +741,100 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
736
741
  completedAt: progress?.completedAt,
737
742
  lastErrorCode: progress?.lastErrorCode,
738
743
  lastErrorMessage: progress?.lastErrorMessage,
744
+ bootstrapPhase: normalizeBootstrapPhase(configuredSub?.bootstrapPhase),
739
745
  };
740
746
  });
741
747
 
742
748
  const expectedSubscriptions = subscriptions.filter((sub) => sub.expected);
743
- const readySubscriptionIds = expectedSubscriptions
749
+ const activePhase =
750
+ expectedSubscriptions
751
+ .filter((sub) => !sub.ready)
752
+ .reduce<number | null>(
753
+ (lowest, sub) =>
754
+ lowest === null || sub.bootstrapPhase < lowest
755
+ ? sub.bootstrapPhase
756
+ : lowest,
757
+ null
758
+ ) ?? null;
759
+ const selectedMaxPhase =
760
+ options.maxPhase ??
761
+ (explicitIdSet.size > 0
762
+ ? 'all'
763
+ : expectedSubscriptions.length > 0
764
+ ? expectedSubscriptions.reduce(
765
+ (lowest, sub) => Math.min(lowest, sub.bootstrapPhase),
766
+ Number.POSITIVE_INFINITY
767
+ )
768
+ : 0);
769
+ const blockingSubscriptions = expectedSubscriptions.filter((sub) =>
770
+ selectedMaxPhase === 'all' ? true : sub.bootstrapPhase <= selectedMaxPhase
771
+ );
772
+ const readySubscriptionIds = blockingSubscriptions
744
773
  .filter((sub) => sub.ready)
745
774
  .map((sub) => sub.id);
746
- const pendingSubscriptionIds = expectedSubscriptions
775
+ const pendingSubscriptionIds = blockingSubscriptions
747
776
  .filter((sub) => !sub.ready)
748
777
  .map((sub) => sub.id);
749
778
  const channelPhase = this.resolveChannelPhase(
750
779
  filteredStates.map((sub) => this.mapSubscriptionToProgress(sub))
751
780
  );
752
781
  const progressPercent =
753
- expectedSubscriptions.length === 0
782
+ blockingSubscriptions.length === 0
754
783
  ? pendingSubscriptionIds.length === 0
755
784
  ? 100
756
785
  : 0
757
786
  : Math.round(
758
- expectedSubscriptions.reduce(
787
+ blockingSubscriptions.reduce(
759
788
  (sum, sub) => sum + sub.progressPercent,
760
789
  0
761
- ) / expectedSubscriptions.length
790
+ ) / blockingSubscriptions.length
762
791
  );
792
+ const phases = Array.from(
793
+ expectedSubscriptions.reduce(
794
+ (acc, sub) => {
795
+ const current = acc.get(sub.bootstrapPhase) ?? {
796
+ phase: sub.bootstrapPhase,
797
+ expectedSubscriptionIds: [] as string[],
798
+ readySubscriptionIds: [] as string[],
799
+ pendingSubscriptionIds: [] as string[],
800
+ progressPercent: 0,
801
+ };
802
+ current.expectedSubscriptionIds.push(sub.id);
803
+ if (sub.ready) {
804
+ current.readySubscriptionIds.push(sub.id);
805
+ } else {
806
+ current.pendingSubscriptionIds.push(sub.id);
807
+ }
808
+ current.progressPercent += sub.progressPercent;
809
+ acc.set(sub.bootstrapPhase, current);
810
+ return acc;
811
+ },
812
+ new Map<
813
+ number,
814
+ {
815
+ phase: number;
816
+ expectedSubscriptionIds: string[];
817
+ readySubscriptionIds: string[];
818
+ pendingSubscriptionIds: string[];
819
+ progressPercent: number;
820
+ }
821
+ >()
822
+ )
823
+ )
824
+ .sort(([left], [right]) => left - right)
825
+ .map(([, phase]) => ({
826
+ phase: phase.phase,
827
+ expectedSubscriptionIds: phase.expectedSubscriptionIds,
828
+ readySubscriptionIds: phase.readySubscriptionIds,
829
+ pendingSubscriptionIds: phase.pendingSubscriptionIds,
830
+ isReady: phase.pendingSubscriptionIds.length === 0,
831
+ progressPercent:
832
+ phase.expectedSubscriptionIds.length === 0
833
+ ? 100
834
+ : Math.round(
835
+ phase.progressPercent / phase.expectedSubscriptionIds.length
836
+ ),
837
+ }));
763
838
 
764
839
  return {
765
840
  stateId,
@@ -767,10 +842,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
767
842
  progressPercent,
768
843
  isBootstrapping: pendingSubscriptionIds.length > 0,
769
844
  isReady: pendingSubscriptionIds.length === 0,
770
- expectedSubscriptionIds: expectedSubscriptions.map((sub) => sub.id),
845
+ expectedSubscriptionIds: blockingSubscriptions.map((sub) => sub.id),
771
846
  readySubscriptionIds,
772
847
  pendingSubscriptionIds,
773
848
  subscriptions,
849
+ activePhase,
850
+ selectedMaxPhase,
851
+ phases,
774
852
  };
775
853
  }
776
854
 
@@ -826,20 +904,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
826
904
  const deadline = Date.now() + timeoutMs;
827
905
 
828
906
  while (true) {
829
- const states = await this.listSubscriptionStates({ stateId });
830
- const relevantStates =
831
- options.subscriptionId === undefined
832
- ? states
833
- : states.filter(
834
- (state) => state.subscriptionId === options.subscriptionId
835
- );
907
+ if (options.subscriptionId !== undefined) {
908
+ const state = await this.getSubscriptionState(options.subscriptionId, {
909
+ stateId,
910
+ });
911
+ const hasPendingBootstrap =
912
+ state?.status === 'active' && state.bootstrapState !== null;
836
913
 
837
- const hasPendingBootstrap = relevantStates.some(
838
- (state) => state.status === 'active' && state.bootstrapState !== null
839
- );
914
+ if (!hasPendingBootstrap) {
915
+ return this.getProgress();
916
+ }
917
+ } else {
918
+ const bootstrap = await this.getBootstrapStatus({
919
+ stateId,
920
+ maxPhase: options.maxPhase,
921
+ });
840
922
 
841
- if (!hasPendingBootstrap) {
842
- return this.getProgress();
923
+ if (bootstrap.isReady) {
924
+ return this.getProgress();
925
+ }
843
926
  }
844
927
 
845
928
  if (this.state.error) {
@@ -1668,6 +1751,18 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1668
1751
  }
1669
1752
  }
1670
1753
 
1754
+ private shouldTrace(): boolean {
1755
+ return (
1756
+ this.config.traceEnabled === true ||
1757
+ (this.listeners.get('sync:trace')?.size ?? 0) > 0
1758
+ );
1759
+ }
1760
+
1761
+ private emitTrace(payload: SyncEventPayloads['sync:trace']): void {
1762
+ if (!this.shouldTrace()) return;
1763
+ this.emit('sync:trace', payload);
1764
+ }
1765
+
1671
1766
  private updateState(partial: Partial<SyncEngineState>): void {
1672
1767
  const nextState = { ...this.state, ...partial };
1673
1768
  const unchanged =
@@ -1953,14 +2048,14 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1953
2048
  clientId: this.config.clientId!,
1954
2049
  actorId: this.config.actorId ?? undefined,
1955
2050
  plugins: this.config.plugins,
1956
- subscriptions: this.config
1957
- .subscriptions as SyncSubscriptionRequest[],
2051
+ subscriptions: this.config.subscriptions,
1958
2052
  limitCommits: this.config.limitCommits,
1959
2053
  limitSnapshotRows: this.config.limitSnapshotRows,
1960
2054
  maxSnapshotPages: this.config.maxSnapshotPages,
1961
2055
  dedupeRows: this.config.dedupeRows,
1962
2056
  stateId: this.config.stateId,
1963
2057
  sha256: this.config.sha256,
2058
+ onTrace: (event) => this.emitTrace(event),
1964
2059
  trigger,
1965
2060
  allowSkipPullOnLocalWsPush:
1966
2061
  this.state.transportMode === 'realtime',
@@ -2987,9 +3082,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2987
3082
  /**
2988
3083
  * Update subscriptions dynamically
2989
3084
  */
2990
- updateSubscriptions(
2991
- subscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>
2992
- ): void {
3085
+ updateSubscriptions(subscriptions: SyncClientSubscription[]): void {
2993
3086
  this.config.subscriptions = subscriptions;
2994
3087
  // Trigger a sync to apply new subscriptions
2995
3088
  this.triggerSyncInBackground(undefined, 'subscription update');
@@ -107,6 +107,20 @@ export type SyncBootstrapSubscriptionPhase =
107
107
  | SubscriptionProgressPhase
108
108
  | 'pending';
109
109
 
110
+ export interface SyncClientSubscription
111
+ extends Omit<SyncSubscriptionRequest, 'cursor'> {
112
+ /**
113
+ * Local-only bootstrap phase for staged startup.
114
+ *
115
+ * Lower phases bootstrap first. Higher phases are deferred until all lower
116
+ * phases are ready, but once a higher-phase subscription is already ready it
117
+ * continues to participate in normal pull requests.
118
+ *
119
+ * Defaults to `0`.
120
+ */
121
+ bootstrapPhase?: number;
122
+ }
123
+
110
124
  export interface SyncBootstrapSubscriptionStatus {
111
125
  stateId: string;
112
126
  id: string;
@@ -122,6 +136,16 @@ export interface SyncBootstrapSubscriptionStatus {
122
136
  completedAt?: number;
123
137
  lastErrorCode?: string;
124
138
  lastErrorMessage?: string;
139
+ bootstrapPhase: number;
140
+ }
141
+
142
+ export interface SyncBootstrapPhaseStatus {
143
+ phase: number;
144
+ expectedSubscriptionIds: string[];
145
+ readySubscriptionIds: string[];
146
+ pendingSubscriptionIds: string[];
147
+ isReady: boolean;
148
+ progressPercent: number;
125
149
  }
126
150
 
127
151
  export interface SyncBootstrapStatus {
@@ -134,11 +158,51 @@ export interface SyncBootstrapStatus {
134
158
  readySubscriptionIds: string[];
135
159
  pendingSubscriptionIds: string[];
136
160
  subscriptions: SyncBootstrapSubscriptionStatus[];
161
+ activePhase: number | null;
162
+ selectedMaxPhase: number | 'all';
163
+ phases: SyncBootstrapPhaseStatus[];
137
164
  }
138
165
 
139
166
  export interface SyncBootstrapStatusOptions {
140
167
  stateId?: string;
141
168
  subscriptionIds?: string[];
169
+ maxPhase?: number | 'all';
170
+ }
171
+
172
+ export type SyncTraceStage =
173
+ | 'pull:start'
174
+ | 'pull:response'
175
+ | 'pull:error'
176
+ | 'apply:transaction:start'
177
+ | 'apply:transaction:complete'
178
+ | 'apply:transaction:error'
179
+ | 'apply:subscription:start'
180
+ | 'apply:subscription:complete'
181
+ | 'apply:subscription:error'
182
+ | 'apply:chunk-materialize:start'
183
+ | 'apply:chunk-materialize:complete'
184
+ | 'apply:chunk-materialize:error';
185
+
186
+ export interface SyncTraceEvent {
187
+ stage: SyncTraceStage;
188
+ timestamp: number;
189
+ stateId?: string;
190
+ subscriptionId?: string;
191
+ table?: string;
192
+ bootstrap?: boolean;
193
+ activeBootstrapPhase?: number | null;
194
+ transactionMode?: 'single-transaction' | 'per-subscription';
195
+ subscriptionIds?: string[];
196
+ subscriptionCount?: number;
197
+ commitCount?: number;
198
+ snapshotCount?: number;
199
+ chunkCount?: number;
200
+ chunkId?: string;
201
+ chunkIndex?: number;
202
+ rowCount?: number;
203
+ nextCursor?: number | null;
204
+ durationMs?: number;
205
+ errorMessage?: string;
142
206
  }
143
207
 
144
208
  /**
@@ -177,6 +241,7 @@ export type SyncEventType =
177
241
  | 'state:change'
178
242
  | 'sync:start'
179
243
  | 'sync:complete'
244
+ | 'sync:trace'
180
245
  | 'sync:live'
181
246
  | 'sync:error'
182
247
  | 'push:result'
@@ -223,6 +288,7 @@ export interface SyncEventPayloads {
223
288
  pullRounds: number;
224
289
  pullResponse: SyncPullResponse;
225
290
  };
291
+ 'sync:trace': SyncTraceEvent;
226
292
  'sync:live': { timestamp: number };
227
293
  'sync:error': SyncError;
228
294
  'push:result': PushResultInfo;
@@ -287,7 +353,7 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
287
353
  /** Stable device/app installation id */
288
354
  clientId: string | null | undefined;
289
355
  /** Subscriptions for partial sync */
290
- subscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
356
+ subscriptions: SyncClientSubscription[];
291
357
  /** Pull limit (commit count per request) */
292
358
  limitCommits?: number;
293
359
  /** Bootstrap snapshot rows per page */
@@ -344,6 +410,8 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
344
410
  plugins?: SyncClientPlugin[];
345
411
  /** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
346
412
  sha256?: (bytes: Uint8Array) => Promise<string>;
413
+ /** Emit structured pull/apply tracing events to the event stream and inspector buffer. */
414
+ traceEnabled?: boolean;
347
415
  }
348
416
 
349
417
  /**
@@ -469,6 +537,7 @@ export interface SyncAwaitBootstrapOptions {
469
537
  timeoutMs?: number;
470
538
  stateId?: string;
471
539
  subscriptionId?: string;
540
+ maxPhase?: number | 'all';
472
541
  }
473
542
 
474
543
  export interface SyncDiagnostics {
@@ -1790,3 +1790,121 @@ describe('applyPullResponse chunk streaming', () => {
1790
1790
  expect(clearedScopes).toEqual([{ project_id: 'p1' }]);
1791
1791
  });
1792
1792
  });
1793
+
1794
+ describe('buildPullRequest phased bootstrap selection', () => {
1795
+ let db: Kysely<TestDb>;
1796
+
1797
+ beforeEach(async () => {
1798
+ db = createDatabase<TestDb>({
1799
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
1800
+ family: 'sqlite',
1801
+ });
1802
+ await ensureClientSyncSchema(db);
1803
+ });
1804
+
1805
+ afterEach(async () => {
1806
+ await db.destroy();
1807
+ });
1808
+
1809
+ it('requests only the lowest pending bootstrap phase by default', async () => {
1810
+ const pullState = await buildPullRequest(db, {
1811
+ clientId: 'client-1',
1812
+ stateId: 'default',
1813
+ subscriptions: [
1814
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
1815
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
1816
+ ],
1817
+ });
1818
+
1819
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
1820
+ 'catalog-meta',
1821
+ ]);
1822
+ });
1823
+
1824
+ it('unlocks later phases once earlier phases are ready', async () => {
1825
+ const now = Date.now();
1826
+
1827
+ await db
1828
+ .insertInto('sync_subscription_state')
1829
+ .values({
1830
+ state_id: 'default',
1831
+ subscription_id: 'catalog-meta',
1832
+ table: 'items',
1833
+ scopes_json: '{}',
1834
+ params_json: '{}',
1835
+ cursor: 12,
1836
+ bootstrap_state_json: null,
1837
+ status: 'active',
1838
+ created_at: now,
1839
+ updated_at: now,
1840
+ })
1841
+ .execute();
1842
+
1843
+ const pullState = await buildPullRequest(db, {
1844
+ clientId: 'client-1',
1845
+ stateId: 'default',
1846
+ subscriptions: [
1847
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
1848
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
1849
+ ],
1850
+ });
1851
+
1852
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
1853
+ 'catalog-meta',
1854
+ 'catalog-relations',
1855
+ ]);
1856
+ });
1857
+
1858
+ it('keeps already-ready later phases live while earlier phases rebootstrap', async () => {
1859
+ const now = Date.now();
1860
+
1861
+ await db
1862
+ .insertInto('sync_subscription_state')
1863
+ .values([
1864
+ {
1865
+ state_id: 'default',
1866
+ subscription_id: 'catalog-meta',
1867
+ table: 'items',
1868
+ scopes_json: '{}',
1869
+ params_json: '{}',
1870
+ cursor: -1,
1871
+ bootstrap_state_json: JSON.stringify({
1872
+ asOfCommitSeq: 0,
1873
+ tables: ['items'],
1874
+ tableIndex: 0,
1875
+ rowCursor: null,
1876
+ }),
1877
+ status: 'active',
1878
+ created_at: now,
1879
+ updated_at: now,
1880
+ },
1881
+ {
1882
+ state_id: 'default',
1883
+ subscription_id: 'catalog-relations',
1884
+ table: 'scoped_items',
1885
+ scopes_json: '{}',
1886
+ params_json: '{}',
1887
+ cursor: 42,
1888
+ bootstrap_state_json: null,
1889
+ status: 'active',
1890
+ created_at: now,
1891
+ updated_at: now,
1892
+ },
1893
+ ])
1894
+ .execute();
1895
+
1896
+ const pullState = await buildPullRequest(db, {
1897
+ clientId: 'client-1',
1898
+ stateId: 'default',
1899
+ subscriptions: [
1900
+ { id: 'catalog-meta', table: 'items', bootstrapPhase: 0 },
1901
+ { id: 'catalog-relations', table: 'scoped_items', bootstrapPhase: 1 },
1902
+ ],
1903
+ });
1904
+
1905
+ expect(pullState.request.subscriptions.map((sub) => sub.id)).toEqual([
1906
+ 'catalog-meta',
1907
+ 'catalog-relations',
1908
+ ]);
1909
+ });
1910
+ });