@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/README.md +10 -1
- package/dist/client.d.ts +26 -20
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +42 -7
- package/dist/client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +4 -3
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +199 -26
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +61 -3
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +31 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pull-engine.d.ts +6 -2
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +732 -234
- package/dist/pull-engine.js.map +1 -1
- package/dist/sync-loop.d.ts +5 -3
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +30 -0
- package/dist/sync-loop.js.map +1 -1
- package/dist/sync.d.ts +4 -3
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +1 -0
- package/dist/sync.js.map +1 -1
- package/package.json +3 -3
- package/src/client.ts +79 -29
- package/src/engine/SyncEngine.test.ts +238 -0
- package/src/engine/SyncEngine.ts +257 -40
- package/src/engine/types.ts +81 -1
- package/src/errors.ts +59 -0
- package/src/index.ts +1 -0
- package/src/pull-engine.test.ts +422 -7
- package/src/pull-engine.ts +906 -276
- package/src/sync-loop.ts +52 -3
- package/src/sync.ts +6 -9
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -14,11 +14,11 @@ 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';
|
|
21
20
|
import { type Kysely, sql, type Transaction } from 'kysely';
|
|
21
|
+
import { type SyncClientFailureStage, SyncClientStageError } from '../errors';
|
|
22
22
|
import { getClientHandler } from '../handlers/collection';
|
|
23
23
|
import { ensureClientSyncSchema } from '../migrate';
|
|
24
24
|
import { withDefaultClientPlugins } from '../plugins';
|
|
@@ -53,6 +53,7 @@ import type {
|
|
|
53
53
|
SyncBootstrapStatus,
|
|
54
54
|
SyncBootstrapStatusOptions,
|
|
55
55
|
SyncBootstrapSubscriptionPhase,
|
|
56
|
+
SyncClientSubscription,
|
|
56
57
|
SyncConnectionState,
|
|
57
58
|
SyncDiagnostics,
|
|
58
59
|
SyncEngineConfig,
|
|
@@ -132,7 +133,10 @@ function createSyncError(args: {
|
|
|
132
133
|
cause?: Error;
|
|
133
134
|
retryable?: boolean;
|
|
134
135
|
httpStatus?: number;
|
|
136
|
+
stage?: SyncError['stage'];
|
|
135
137
|
subscriptionId?: string;
|
|
138
|
+
chunkId?: string;
|
|
139
|
+
table?: string;
|
|
136
140
|
stateId?: string;
|
|
137
141
|
}): SyncError {
|
|
138
142
|
return {
|
|
@@ -142,7 +146,10 @@ function createSyncError(args: {
|
|
|
142
146
|
timestamp: Date.now(),
|
|
143
147
|
retryable: args.retryable ?? false,
|
|
144
148
|
httpStatus: args.httpStatus,
|
|
149
|
+
stage: args.stage,
|
|
145
150
|
subscriptionId: args.subscriptionId,
|
|
151
|
+
chunkId: args.chunkId,
|
|
152
|
+
table: args.table,
|
|
146
153
|
stateId: args.stateId,
|
|
147
154
|
};
|
|
148
155
|
}
|
|
@@ -153,56 +160,150 @@ function classifySyncFailure(error: unknown): {
|
|
|
153
160
|
cause: Error;
|
|
154
161
|
retryable: boolean;
|
|
155
162
|
httpStatus?: number;
|
|
163
|
+
stage?: SyncError['stage'];
|
|
164
|
+
subscriptionId?: string;
|
|
165
|
+
chunkId?: string;
|
|
166
|
+
table?: string;
|
|
167
|
+
stateId?: string;
|
|
156
168
|
} {
|
|
157
169
|
const cause = error instanceof Error ? error : new Error(String(error));
|
|
158
170
|
const message = cause.message || 'Sync failed';
|
|
159
171
|
const normalized = message.toLowerCase();
|
|
160
172
|
|
|
161
|
-
|
|
162
|
-
|
|
173
|
+
const classifyTransportFailure = (
|
|
174
|
+
transportError: SyncTransportError,
|
|
175
|
+
stage?: SyncClientFailureStage
|
|
176
|
+
) => {
|
|
177
|
+
if (transportError.status === 401 || transportError.status === 403) {
|
|
163
178
|
return {
|
|
164
|
-
code: 'AUTH_FAILED',
|
|
179
|
+
code: 'AUTH_FAILED' as const,
|
|
165
180
|
message,
|
|
166
181
|
cause,
|
|
167
182
|
retryable: false,
|
|
168
|
-
httpStatus:
|
|
183
|
+
httpStatus: transportError.status,
|
|
184
|
+
stage,
|
|
169
185
|
};
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
if (
|
|
173
|
-
|
|
189
|
+
transportError.status === 404 &&
|
|
174
190
|
normalized.includes('snapshot') &&
|
|
175
191
|
normalized.includes('chunk')
|
|
176
192
|
) {
|
|
177
193
|
return {
|
|
178
|
-
code: 'SNAPSHOT_CHUNK_NOT_FOUND',
|
|
194
|
+
code: 'SNAPSHOT_CHUNK_NOT_FOUND' as const,
|
|
179
195
|
message,
|
|
180
196
|
cause,
|
|
181
197
|
retryable: false,
|
|
182
|
-
httpStatus:
|
|
198
|
+
httpStatus: transportError.status,
|
|
199
|
+
stage,
|
|
183
200
|
};
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
if (
|
|
187
|
-
|
|
188
|
-
(
|
|
204
|
+
transportError.status !== undefined &&
|
|
205
|
+
(transportError.status >= 500 ||
|
|
206
|
+
transportError.status === 408 ||
|
|
207
|
+
transportError.status === 429)
|
|
189
208
|
) {
|
|
190
209
|
return {
|
|
191
|
-
code: 'NETWORK_ERROR',
|
|
210
|
+
code: 'NETWORK_ERROR' as const,
|
|
192
211
|
message,
|
|
193
212
|
cause,
|
|
194
213
|
retryable: true,
|
|
195
|
-
httpStatus:
|
|
214
|
+
httpStatus: transportError.status,
|
|
215
|
+
stage,
|
|
196
216
|
};
|
|
197
217
|
}
|
|
198
218
|
|
|
199
219
|
return {
|
|
200
|
-
code: 'SYNC_ERROR',
|
|
220
|
+
code: 'SYNC_ERROR' as const,
|
|
201
221
|
message,
|
|
202
222
|
cause,
|
|
203
223
|
retryable: false,
|
|
204
|
-
httpStatus:
|
|
224
|
+
httpStatus: transportError.status,
|
|
225
|
+
stage,
|
|
205
226
|
};
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
if (cause instanceof SyncClientStageError) {
|
|
230
|
+
const stageContext = {
|
|
231
|
+
stage: cause.stage,
|
|
232
|
+
subscriptionId: cause.subscriptionId,
|
|
233
|
+
chunkId: cause.chunkId,
|
|
234
|
+
table: cause.table,
|
|
235
|
+
stateId: cause.stateId,
|
|
236
|
+
};
|
|
237
|
+
const stageCause = cause.cause instanceof Error ? cause.cause : cause;
|
|
238
|
+
|
|
239
|
+
if (stageCause instanceof SyncTransportError) {
|
|
240
|
+
return {
|
|
241
|
+
...classifyTransportFailure(stageCause, cause.stage),
|
|
242
|
+
...stageContext,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
normalized.includes('network') ||
|
|
248
|
+
normalized.includes('fetch') ||
|
|
249
|
+
normalized.includes('timeout') ||
|
|
250
|
+
normalized.includes('offline')
|
|
251
|
+
) {
|
|
252
|
+
return {
|
|
253
|
+
code: 'NETWORK_ERROR',
|
|
254
|
+
message,
|
|
255
|
+
cause,
|
|
256
|
+
retryable: true,
|
|
257
|
+
...stageContext,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
switch (cause.stage) {
|
|
262
|
+
case 'snapshot-gzip-decode':
|
|
263
|
+
return {
|
|
264
|
+
code: 'SNAPSHOT_GZIP_DECODE_FAILED',
|
|
265
|
+
message,
|
|
266
|
+
cause,
|
|
267
|
+
retryable: false,
|
|
268
|
+
...stageContext,
|
|
269
|
+
};
|
|
270
|
+
case 'snapshot-chunk-decode':
|
|
271
|
+
return {
|
|
272
|
+
code: 'SNAPSHOT_CHUNK_DECODE_FAILED',
|
|
273
|
+
message,
|
|
274
|
+
cause,
|
|
275
|
+
retryable: false,
|
|
276
|
+
...stageContext,
|
|
277
|
+
};
|
|
278
|
+
case 'snapshot-integrity':
|
|
279
|
+
return {
|
|
280
|
+
code: 'SNAPSHOT_INTEGRITY_FAILED',
|
|
281
|
+
message,
|
|
282
|
+
cause,
|
|
283
|
+
retryable: false,
|
|
284
|
+
...stageContext,
|
|
285
|
+
};
|
|
286
|
+
case 'snapshot-apply':
|
|
287
|
+
return {
|
|
288
|
+
code: 'SNAPSHOT_APPLY_FAILED',
|
|
289
|
+
message,
|
|
290
|
+
cause,
|
|
291
|
+
retryable: false,
|
|
292
|
+
...stageContext,
|
|
293
|
+
};
|
|
294
|
+
default:
|
|
295
|
+
return {
|
|
296
|
+
code: 'SYNC_ERROR',
|
|
297
|
+
message,
|
|
298
|
+
cause,
|
|
299
|
+
retryable: false,
|
|
300
|
+
...stageContext,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (cause instanceof SyncTransportError) {
|
|
306
|
+
return classifyTransportFailure(cause, 'pull');
|
|
206
307
|
}
|
|
207
308
|
|
|
208
309
|
if (
|
|
@@ -273,6 +374,11 @@ function defaultSelectorEquality<T>(left: T, right: T): boolean {
|
|
|
273
374
|
return Object.is(left, right);
|
|
274
375
|
}
|
|
275
376
|
|
|
377
|
+
function normalizeBootstrapPhase(value: number | undefined): number {
|
|
378
|
+
if (value === undefined) return 0;
|
|
379
|
+
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
276
382
|
function areMetadataRecordsEqual(
|
|
277
383
|
left: Record<string, unknown> | undefined,
|
|
278
384
|
right: Record<string, unknown> | undefined
|
|
@@ -736,30 +842,100 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
736
842
|
completedAt: progress?.completedAt,
|
|
737
843
|
lastErrorCode: progress?.lastErrorCode,
|
|
738
844
|
lastErrorMessage: progress?.lastErrorMessage,
|
|
845
|
+
bootstrapPhase: normalizeBootstrapPhase(configuredSub?.bootstrapPhase),
|
|
739
846
|
};
|
|
740
847
|
});
|
|
741
848
|
|
|
742
849
|
const expectedSubscriptions = subscriptions.filter((sub) => sub.expected);
|
|
743
|
-
const
|
|
850
|
+
const activePhase =
|
|
851
|
+
expectedSubscriptions
|
|
852
|
+
.filter((sub) => !sub.ready)
|
|
853
|
+
.reduce<number | null>(
|
|
854
|
+
(lowest, sub) =>
|
|
855
|
+
lowest === null || sub.bootstrapPhase < lowest
|
|
856
|
+
? sub.bootstrapPhase
|
|
857
|
+
: lowest,
|
|
858
|
+
null
|
|
859
|
+
) ?? null;
|
|
860
|
+
const selectedMaxPhase =
|
|
861
|
+
options.maxPhase ??
|
|
862
|
+
(explicitIdSet.size > 0
|
|
863
|
+
? 'all'
|
|
864
|
+
: expectedSubscriptions.length > 0
|
|
865
|
+
? expectedSubscriptions.reduce(
|
|
866
|
+
(lowest, sub) => Math.min(lowest, sub.bootstrapPhase),
|
|
867
|
+
Number.POSITIVE_INFINITY
|
|
868
|
+
)
|
|
869
|
+
: 0);
|
|
870
|
+
const blockingSubscriptions = expectedSubscriptions.filter((sub) =>
|
|
871
|
+
selectedMaxPhase === 'all' ? true : sub.bootstrapPhase <= selectedMaxPhase
|
|
872
|
+
);
|
|
873
|
+
const readySubscriptionIds = blockingSubscriptions
|
|
744
874
|
.filter((sub) => sub.ready)
|
|
745
875
|
.map((sub) => sub.id);
|
|
746
|
-
const pendingSubscriptionIds =
|
|
876
|
+
const pendingSubscriptionIds = blockingSubscriptions
|
|
747
877
|
.filter((sub) => !sub.ready)
|
|
748
878
|
.map((sub) => sub.id);
|
|
749
879
|
const channelPhase = this.resolveChannelPhase(
|
|
750
880
|
filteredStates.map((sub) => this.mapSubscriptionToProgress(sub))
|
|
751
881
|
);
|
|
752
882
|
const progressPercent =
|
|
753
|
-
|
|
883
|
+
blockingSubscriptions.length === 0
|
|
754
884
|
? pendingSubscriptionIds.length === 0
|
|
755
885
|
? 100
|
|
756
886
|
: 0
|
|
757
887
|
: Math.round(
|
|
758
|
-
|
|
888
|
+
blockingSubscriptions.reduce(
|
|
759
889
|
(sum, sub) => sum + sub.progressPercent,
|
|
760
890
|
0
|
|
761
|
-
) /
|
|
891
|
+
) / blockingSubscriptions.length
|
|
762
892
|
);
|
|
893
|
+
const phases = Array.from(
|
|
894
|
+
expectedSubscriptions.reduce(
|
|
895
|
+
(acc, sub) => {
|
|
896
|
+
const current = acc.get(sub.bootstrapPhase) ?? {
|
|
897
|
+
phase: sub.bootstrapPhase,
|
|
898
|
+
expectedSubscriptionIds: [] as string[],
|
|
899
|
+
readySubscriptionIds: [] as string[],
|
|
900
|
+
pendingSubscriptionIds: [] as string[],
|
|
901
|
+
progressPercent: 0,
|
|
902
|
+
};
|
|
903
|
+
current.expectedSubscriptionIds.push(sub.id);
|
|
904
|
+
if (sub.ready) {
|
|
905
|
+
current.readySubscriptionIds.push(sub.id);
|
|
906
|
+
} else {
|
|
907
|
+
current.pendingSubscriptionIds.push(sub.id);
|
|
908
|
+
}
|
|
909
|
+
current.progressPercent += sub.progressPercent;
|
|
910
|
+
acc.set(sub.bootstrapPhase, current);
|
|
911
|
+
return acc;
|
|
912
|
+
},
|
|
913
|
+
new Map<
|
|
914
|
+
number,
|
|
915
|
+
{
|
|
916
|
+
phase: number;
|
|
917
|
+
expectedSubscriptionIds: string[];
|
|
918
|
+
readySubscriptionIds: string[];
|
|
919
|
+
pendingSubscriptionIds: string[];
|
|
920
|
+
progressPercent: number;
|
|
921
|
+
}
|
|
922
|
+
>()
|
|
923
|
+
)
|
|
924
|
+
)
|
|
925
|
+
.sort(([left], [right]) => left - right)
|
|
926
|
+
.map(([, phase]) => ({
|
|
927
|
+
phase: phase.phase,
|
|
928
|
+
expectedSubscriptionIds: phase.expectedSubscriptionIds,
|
|
929
|
+
readySubscriptionIds: phase.readySubscriptionIds,
|
|
930
|
+
pendingSubscriptionIds: phase.pendingSubscriptionIds,
|
|
931
|
+
isReady: phase.pendingSubscriptionIds.length === 0,
|
|
932
|
+
progressPercent:
|
|
933
|
+
phase.expectedSubscriptionIds.length === 0
|
|
934
|
+
? 100
|
|
935
|
+
: Math.round(
|
|
936
|
+
phase.progressPercent / phase.expectedSubscriptionIds.length
|
|
937
|
+
),
|
|
938
|
+
}));
|
|
763
939
|
|
|
764
940
|
return {
|
|
765
941
|
stateId,
|
|
@@ -767,10 +943,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
767
943
|
progressPercent,
|
|
768
944
|
isBootstrapping: pendingSubscriptionIds.length > 0,
|
|
769
945
|
isReady: pendingSubscriptionIds.length === 0,
|
|
770
|
-
expectedSubscriptionIds:
|
|
946
|
+
expectedSubscriptionIds: blockingSubscriptions.map((sub) => sub.id),
|
|
771
947
|
readySubscriptionIds,
|
|
772
948
|
pendingSubscriptionIds,
|
|
773
949
|
subscriptions,
|
|
950
|
+
activePhase,
|
|
951
|
+
selectedMaxPhase,
|
|
952
|
+
phases,
|
|
774
953
|
};
|
|
775
954
|
}
|
|
776
955
|
|
|
@@ -826,25 +1005,33 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
826
1005
|
const deadline = Date.now() + timeoutMs;
|
|
827
1006
|
|
|
828
1007
|
while (true) {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
);
|
|
1008
|
+
if (options.subscriptionId !== undefined) {
|
|
1009
|
+
const state = await this.getSubscriptionState(options.subscriptionId, {
|
|
1010
|
+
stateId,
|
|
1011
|
+
});
|
|
1012
|
+
const hasPendingBootstrap =
|
|
1013
|
+
state?.status === 'active' && state.bootstrapState !== null;
|
|
836
1014
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1015
|
+
if (!hasPendingBootstrap) {
|
|
1016
|
+
return this.getProgress();
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
const bootstrap = await this.getBootstrapStatus({
|
|
1020
|
+
stateId,
|
|
1021
|
+
maxPhase: options.maxPhase,
|
|
1022
|
+
});
|
|
840
1023
|
|
|
841
|
-
|
|
842
|
-
|
|
1024
|
+
if (bootstrap.isReady) {
|
|
1025
|
+
return this.getProgress();
|
|
1026
|
+
}
|
|
843
1027
|
}
|
|
844
1028
|
|
|
845
1029
|
if (this.state.error) {
|
|
1030
|
+
const detailSuffix = this.state.error.stage
|
|
1031
|
+
? ` [stage=${this.state.error.stage}]`
|
|
1032
|
+
: '';
|
|
846
1033
|
throw new Error(
|
|
847
|
-
`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`
|
|
1034
|
+
`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion${detailSuffix}: ${this.state.error.message}`
|
|
848
1035
|
);
|
|
849
1036
|
}
|
|
850
1037
|
|
|
@@ -854,9 +1041,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
854
1041
|
options.subscriptionId === undefined
|
|
855
1042
|
? `state "${stateId}"`
|
|
856
1043
|
: `subscription "${options.subscriptionId}" in state "${stateId}"`;
|
|
1044
|
+
const bootstrap = await this.getBootstrapStatus({
|
|
1045
|
+
stateId,
|
|
1046
|
+
subscriptionIds:
|
|
1047
|
+
options.subscriptionId === undefined
|
|
1048
|
+
? undefined
|
|
1049
|
+
: [options.subscriptionId],
|
|
1050
|
+
maxPhase: options.maxPhase,
|
|
1051
|
+
}).catch(() => null);
|
|
1052
|
+
const pendingSummary =
|
|
1053
|
+
bootstrap && bootstrap.pendingSubscriptionIds.length > 0
|
|
1054
|
+
? ` Pending subscriptions: ${bootstrap.pendingSubscriptionIds.join(', ')}.`
|
|
1055
|
+
: '';
|
|
1056
|
+
const phaseSummary =
|
|
1057
|
+
bootstrap && bootstrap.activePhase !== null
|
|
1058
|
+
? ` Active phase: ${bootstrap.activePhase}.`
|
|
1059
|
+
: '';
|
|
857
1060
|
|
|
858
1061
|
throw new Error(
|
|
859
|
-
`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`
|
|
1062
|
+
`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}. Bootstrap is still in progress.${phaseSummary}${pendingSummary}`
|
|
860
1063
|
);
|
|
861
1064
|
}
|
|
862
1065
|
|
|
@@ -1668,6 +1871,18 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1668
1871
|
}
|
|
1669
1872
|
}
|
|
1670
1873
|
|
|
1874
|
+
private shouldTrace(): boolean {
|
|
1875
|
+
return (
|
|
1876
|
+
this.config.traceEnabled === true ||
|
|
1877
|
+
(this.listeners.get('sync:trace')?.size ?? 0) > 0
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
private emitTrace(payload: SyncEventPayloads['sync:trace']): void {
|
|
1882
|
+
if (!this.shouldTrace()) return;
|
|
1883
|
+
this.emit('sync:trace', payload);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1671
1886
|
private updateState(partial: Partial<SyncEngineState>): void {
|
|
1672
1887
|
const nextState = { ...this.state, ...partial };
|
|
1673
1888
|
const unchanged =
|
|
@@ -1953,14 +2168,14 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1953
2168
|
clientId: this.config.clientId!,
|
|
1954
2169
|
actorId: this.config.actorId ?? undefined,
|
|
1955
2170
|
plugins: this.config.plugins,
|
|
1956
|
-
subscriptions: this.config
|
|
1957
|
-
.subscriptions as SyncSubscriptionRequest[],
|
|
2171
|
+
subscriptions: this.config.subscriptions,
|
|
1958
2172
|
limitCommits: this.config.limitCommits,
|
|
1959
2173
|
limitSnapshotRows: this.config.limitSnapshotRows,
|
|
1960
2174
|
maxSnapshotPages: this.config.maxSnapshotPages,
|
|
1961
2175
|
dedupeRows: this.config.dedupeRows,
|
|
1962
2176
|
stateId: this.config.stateId,
|
|
1963
2177
|
sha256: this.config.sha256,
|
|
2178
|
+
onTrace: (event) => this.emitTrace(event),
|
|
1964
2179
|
trigger,
|
|
1965
2180
|
allowSkipPullOnLocalWsPush:
|
|
1966
2181
|
this.state.transportMode === 'realtime',
|
|
@@ -2071,7 +2286,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
2071
2286
|
cause: classified.cause,
|
|
2072
2287
|
retryable: classified.retryable,
|
|
2073
2288
|
httpStatus: classified.httpStatus,
|
|
2074
|
-
|
|
2289
|
+
stage: classified.stage,
|
|
2290
|
+
subscriptionId: classified.subscriptionId,
|
|
2291
|
+
chunkId: classified.chunkId,
|
|
2292
|
+
table: classified.table,
|
|
2293
|
+
stateId: classified.stateId ?? this.getStateId(),
|
|
2075
2294
|
});
|
|
2076
2295
|
|
|
2077
2296
|
this.updateState({
|
|
@@ -2987,9 +3206,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
2987
3206
|
/**
|
|
2988
3207
|
* Update subscriptions dynamically
|
|
2989
3208
|
*/
|
|
2990
|
-
updateSubscriptions(
|
|
2991
|
-
subscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>
|
|
2992
|
-
): void {
|
|
3209
|
+
updateSubscriptions(subscriptions: SyncClientSubscription[]): void {
|
|
2993
3210
|
this.config.subscriptions = subscriptions;
|
|
2994
3211
|
// Trigger a sync to apply new subscriptions
|
|
2995
3212
|
this.triggerSyncInBackground(undefined, 'subscription update');
|
package/src/engine/types.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
SyncTransport,
|
|
14
14
|
} from '@syncular/core';
|
|
15
15
|
import type { Kysely } from 'kysely';
|
|
16
|
+
import type { SyncClientFailureStage } from '../errors';
|
|
16
17
|
import type { ClientHandlerCollection } from '../handlers/collection';
|
|
17
18
|
import type { SyncClientPlugin } from '../plugins/types';
|
|
18
19
|
import type { SyncClientDb } from '../schema';
|
|
@@ -107,6 +108,20 @@ export type SyncBootstrapSubscriptionPhase =
|
|
|
107
108
|
| SubscriptionProgressPhase
|
|
108
109
|
| 'pending';
|
|
109
110
|
|
|
111
|
+
export interface SyncClientSubscription
|
|
112
|
+
extends Omit<SyncSubscriptionRequest, 'cursor'> {
|
|
113
|
+
/**
|
|
114
|
+
* Local-only bootstrap phase for staged startup.
|
|
115
|
+
*
|
|
116
|
+
* Lower phases bootstrap first. Higher phases are deferred until all lower
|
|
117
|
+
* phases are ready, but once a higher-phase subscription is already ready it
|
|
118
|
+
* continues to participate in normal pull requests.
|
|
119
|
+
*
|
|
120
|
+
* Defaults to `0`.
|
|
121
|
+
*/
|
|
122
|
+
bootstrapPhase?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
export interface SyncBootstrapSubscriptionStatus {
|
|
111
126
|
stateId: string;
|
|
112
127
|
id: string;
|
|
@@ -122,6 +137,16 @@ export interface SyncBootstrapSubscriptionStatus {
|
|
|
122
137
|
completedAt?: number;
|
|
123
138
|
lastErrorCode?: string;
|
|
124
139
|
lastErrorMessage?: string;
|
|
140
|
+
bootstrapPhase: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface SyncBootstrapPhaseStatus {
|
|
144
|
+
phase: number;
|
|
145
|
+
expectedSubscriptionIds: string[];
|
|
146
|
+
readySubscriptionIds: string[];
|
|
147
|
+
pendingSubscriptionIds: string[];
|
|
148
|
+
isReady: boolean;
|
|
149
|
+
progressPercent: number;
|
|
125
150
|
}
|
|
126
151
|
|
|
127
152
|
export interface SyncBootstrapStatus {
|
|
@@ -134,11 +159,51 @@ export interface SyncBootstrapStatus {
|
|
|
134
159
|
readySubscriptionIds: string[];
|
|
135
160
|
pendingSubscriptionIds: string[];
|
|
136
161
|
subscriptions: SyncBootstrapSubscriptionStatus[];
|
|
162
|
+
activePhase: number | null;
|
|
163
|
+
selectedMaxPhase: number | 'all';
|
|
164
|
+
phases: SyncBootstrapPhaseStatus[];
|
|
137
165
|
}
|
|
138
166
|
|
|
139
167
|
export interface SyncBootstrapStatusOptions {
|
|
140
168
|
stateId?: string;
|
|
141
169
|
subscriptionIds?: string[];
|
|
170
|
+
maxPhase?: number | 'all';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type SyncTraceStage =
|
|
174
|
+
| 'pull:start'
|
|
175
|
+
| 'pull:response'
|
|
176
|
+
| 'pull:error'
|
|
177
|
+
| 'apply:transaction:start'
|
|
178
|
+
| 'apply:transaction:complete'
|
|
179
|
+
| 'apply:transaction:error'
|
|
180
|
+
| 'apply:subscription:start'
|
|
181
|
+
| 'apply:subscription:complete'
|
|
182
|
+
| 'apply:subscription:error'
|
|
183
|
+
| 'apply:chunk-materialize:start'
|
|
184
|
+
| 'apply:chunk-materialize:complete'
|
|
185
|
+
| 'apply:chunk-materialize:error';
|
|
186
|
+
|
|
187
|
+
export interface SyncTraceEvent {
|
|
188
|
+
stage: SyncTraceStage;
|
|
189
|
+
timestamp: number;
|
|
190
|
+
stateId?: string;
|
|
191
|
+
subscriptionId?: string;
|
|
192
|
+
table?: string;
|
|
193
|
+
bootstrap?: boolean;
|
|
194
|
+
activeBootstrapPhase?: number | null;
|
|
195
|
+
transactionMode?: 'single-transaction' | 'per-subscription';
|
|
196
|
+
subscriptionIds?: string[];
|
|
197
|
+
subscriptionCount?: number;
|
|
198
|
+
commitCount?: number;
|
|
199
|
+
snapshotCount?: number;
|
|
200
|
+
chunkCount?: number;
|
|
201
|
+
chunkId?: string;
|
|
202
|
+
chunkIndex?: number;
|
|
203
|
+
rowCount?: number;
|
|
204
|
+
nextCursor?: number | null;
|
|
205
|
+
durationMs?: number;
|
|
206
|
+
errorMessage?: string;
|
|
142
207
|
}
|
|
143
208
|
|
|
144
209
|
/**
|
|
@@ -150,6 +215,10 @@ export interface SyncError {
|
|
|
150
215
|
| 'NETWORK_ERROR'
|
|
151
216
|
| 'AUTH_FAILED'
|
|
152
217
|
| 'SNAPSHOT_CHUNK_NOT_FOUND'
|
|
218
|
+
| 'SNAPSHOT_GZIP_DECODE_FAILED'
|
|
219
|
+
| 'SNAPSHOT_CHUNK_DECODE_FAILED'
|
|
220
|
+
| 'SNAPSHOT_INTEGRITY_FAILED'
|
|
221
|
+
| 'SNAPSHOT_APPLY_FAILED'
|
|
153
222
|
| 'MIGRATION_FAILED'
|
|
154
223
|
| 'CONFLICT'
|
|
155
224
|
| 'SYNC_ERROR'
|
|
@@ -164,8 +233,14 @@ export interface SyncError {
|
|
|
164
233
|
retryable: boolean;
|
|
165
234
|
/** HTTP status code when available */
|
|
166
235
|
httpStatus?: number;
|
|
236
|
+
/** Sync stage where the error originated */
|
|
237
|
+
stage?: SyncClientFailureStage;
|
|
167
238
|
/** Related subscription id when available */
|
|
168
239
|
subscriptionId?: string;
|
|
240
|
+
/** Related snapshot chunk id when available */
|
|
241
|
+
chunkId?: string;
|
|
242
|
+
/** Related table when available */
|
|
243
|
+
table?: string;
|
|
169
244
|
/** Related state id when available */
|
|
170
245
|
stateId?: string;
|
|
171
246
|
}
|
|
@@ -177,6 +252,7 @@ export type SyncEventType =
|
|
|
177
252
|
| 'state:change'
|
|
178
253
|
| 'sync:start'
|
|
179
254
|
| 'sync:complete'
|
|
255
|
+
| 'sync:trace'
|
|
180
256
|
| 'sync:live'
|
|
181
257
|
| 'sync:error'
|
|
182
258
|
| 'push:result'
|
|
@@ -223,6 +299,7 @@ export interface SyncEventPayloads {
|
|
|
223
299
|
pullRounds: number;
|
|
224
300
|
pullResponse: SyncPullResponse;
|
|
225
301
|
};
|
|
302
|
+
'sync:trace': SyncTraceEvent;
|
|
226
303
|
'sync:live': { timestamp: number };
|
|
227
304
|
'sync:error': SyncError;
|
|
228
305
|
'push:result': PushResultInfo;
|
|
@@ -287,7 +364,7 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
287
364
|
/** Stable device/app installation id */
|
|
288
365
|
clientId: string | null | undefined;
|
|
289
366
|
/** Subscriptions for partial sync */
|
|
290
|
-
subscriptions:
|
|
367
|
+
subscriptions: SyncClientSubscription[];
|
|
291
368
|
/** Pull limit (commit count per request) */
|
|
292
369
|
limitCommits?: number;
|
|
293
370
|
/** Bootstrap snapshot rows per page */
|
|
@@ -344,6 +421,8 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
344
421
|
plugins?: SyncClientPlugin[];
|
|
345
422
|
/** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
|
|
346
423
|
sha256?: (bytes: Uint8Array) => Promise<string>;
|
|
424
|
+
/** Emit structured pull/apply tracing events to the event stream and inspector buffer. */
|
|
425
|
+
traceEnabled?: boolean;
|
|
347
426
|
}
|
|
348
427
|
|
|
349
428
|
/**
|
|
@@ -469,6 +548,7 @@ export interface SyncAwaitBootstrapOptions {
|
|
|
469
548
|
timeoutMs?: number;
|
|
470
549
|
stateId?: string;
|
|
471
550
|
subscriptionId?: string;
|
|
551
|
+
maxPhase?: number | 'all';
|
|
472
552
|
}
|
|
473
553
|
|
|
474
554
|
export interface SyncDiagnostics {
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type SyncClientFailureStage =
|
|
2
|
+
| 'pull'
|
|
3
|
+
| 'snapshot-chunk-fetch'
|
|
4
|
+
| 'snapshot-gzip-decode'
|
|
5
|
+
| 'snapshot-chunk-decode'
|
|
6
|
+
| 'snapshot-integrity'
|
|
7
|
+
| 'snapshot-apply'
|
|
8
|
+
| 'bootstrap-timeout';
|
|
9
|
+
|
|
10
|
+
export interface SyncClientFailureContext {
|
|
11
|
+
stage: SyncClientFailureStage;
|
|
12
|
+
stateId?: string;
|
|
13
|
+
subscriptionId?: string;
|
|
14
|
+
table?: string;
|
|
15
|
+
chunkId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeError(error: unknown): Error {
|
|
19
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SyncClientStageError extends Error {
|
|
23
|
+
readonly stage: SyncClientFailureStage;
|
|
24
|
+
readonly stateId?: string;
|
|
25
|
+
readonly subscriptionId?: string;
|
|
26
|
+
readonly table?: string;
|
|
27
|
+
readonly chunkId?: string;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
message: string,
|
|
31
|
+
context: SyncClientFailureContext,
|
|
32
|
+
cause?: unknown
|
|
33
|
+
) {
|
|
34
|
+
const normalizedCause =
|
|
35
|
+
cause === undefined ? undefined : normalizeError(cause);
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'SyncClientStageError';
|
|
38
|
+
this.stage = context.stage;
|
|
39
|
+
this.stateId = context.stateId;
|
|
40
|
+
this.subscriptionId = context.subscriptionId;
|
|
41
|
+
this.table = context.table;
|
|
42
|
+
this.chunkId = context.chunkId;
|
|
43
|
+
if (normalizedCause) {
|
|
44
|
+
this.cause = normalizedCause;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function wrapSyncClientStageError(
|
|
50
|
+
error: unknown,
|
|
51
|
+
context: SyncClientFailureContext,
|
|
52
|
+
message?: string
|
|
53
|
+
): SyncClientStageError {
|
|
54
|
+
if (error instanceof SyncClientStageError) {
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
const cause = normalizeError(error);
|
|
58
|
+
return new SyncClientStageError(message ?? cause.message, context, cause);
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './conflicts';
|
|
|
8
8
|
export * from './create-client';
|
|
9
9
|
export * from './engine';
|
|
10
10
|
export type * from './engine/types';
|
|
11
|
+
export * from './errors';
|
|
11
12
|
export * from './handlers/collection';
|
|
12
13
|
export * from './handlers/create-handler';
|
|
13
14
|
export * from './handlers/types';
|