@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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { captureSyncException, countSyncMetric, distributionSyncMetric, isRecord, SyncTransportError, startSyncSpan, } from '@syncular/core';
|
|
8
8
|
import { sql } from 'kysely';
|
|
9
|
+
import { SyncClientStageError } from '../errors.js';
|
|
9
10
|
import { getClientHandler } from '../handlers/collection.js';
|
|
10
11
|
import { ensureClientSyncSchema } from '../migrate.js';
|
|
11
12
|
import { withDefaultClientPlugins } from '../plugins/index.js';
|
|
@@ -55,7 +56,10 @@ function createSyncError(args) {
|
|
|
55
56
|
timestamp: Date.now(),
|
|
56
57
|
retryable: args.retryable ?? false,
|
|
57
58
|
httpStatus: args.httpStatus,
|
|
59
|
+
stage: args.stage,
|
|
58
60
|
subscriptionId: args.subscriptionId,
|
|
61
|
+
chunkId: args.chunkId,
|
|
62
|
+
table: args.table,
|
|
59
63
|
stateId: args.stateId,
|
|
60
64
|
};
|
|
61
65
|
}
|
|
@@ -63,17 +67,18 @@ function classifySyncFailure(error) {
|
|
|
63
67
|
const cause = error instanceof Error ? error : new Error(String(error));
|
|
64
68
|
const message = cause.message || 'Sync failed';
|
|
65
69
|
const normalized = message.toLowerCase();
|
|
66
|
-
|
|
67
|
-
if (
|
|
70
|
+
const classifyTransportFailure = (transportError, stage) => {
|
|
71
|
+
if (transportError.status === 401 || transportError.status === 403) {
|
|
68
72
|
return {
|
|
69
73
|
code: 'AUTH_FAILED',
|
|
70
74
|
message,
|
|
71
75
|
cause,
|
|
72
76
|
retryable: false,
|
|
73
|
-
httpStatus:
|
|
77
|
+
httpStatus: transportError.status,
|
|
78
|
+
stage,
|
|
74
79
|
};
|
|
75
80
|
}
|
|
76
|
-
if (
|
|
81
|
+
if (transportError.status === 404 &&
|
|
77
82
|
normalized.includes('snapshot') &&
|
|
78
83
|
normalized.includes('chunk')) {
|
|
79
84
|
return {
|
|
@@ -81,17 +86,21 @@ function classifySyncFailure(error) {
|
|
|
81
86
|
message,
|
|
82
87
|
cause,
|
|
83
88
|
retryable: false,
|
|
84
|
-
httpStatus:
|
|
89
|
+
httpStatus: transportError.status,
|
|
90
|
+
stage,
|
|
85
91
|
};
|
|
86
92
|
}
|
|
87
|
-
if (
|
|
88
|
-
(
|
|
93
|
+
if (transportError.status !== undefined &&
|
|
94
|
+
(transportError.status >= 500 ||
|
|
95
|
+
transportError.status === 408 ||
|
|
96
|
+
transportError.status === 429)) {
|
|
89
97
|
return {
|
|
90
98
|
code: 'NETWORK_ERROR',
|
|
91
99
|
message,
|
|
92
100
|
cause,
|
|
93
101
|
retryable: true,
|
|
94
|
-
httpStatus:
|
|
102
|
+
httpStatus: transportError.status,
|
|
103
|
+
stage,
|
|
95
104
|
};
|
|
96
105
|
}
|
|
97
106
|
return {
|
|
@@ -99,8 +108,82 @@ function classifySyncFailure(error) {
|
|
|
99
108
|
message,
|
|
100
109
|
cause,
|
|
101
110
|
retryable: false,
|
|
102
|
-
httpStatus:
|
|
111
|
+
httpStatus: transportError.status,
|
|
112
|
+
stage,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
if (cause instanceof SyncClientStageError) {
|
|
116
|
+
const stageContext = {
|
|
117
|
+
stage: cause.stage,
|
|
118
|
+
subscriptionId: cause.subscriptionId,
|
|
119
|
+
chunkId: cause.chunkId,
|
|
120
|
+
table: cause.table,
|
|
121
|
+
stateId: cause.stateId,
|
|
103
122
|
};
|
|
123
|
+
const stageCause = cause.cause instanceof Error ? cause.cause : cause;
|
|
124
|
+
if (stageCause instanceof SyncTransportError) {
|
|
125
|
+
return {
|
|
126
|
+
...classifyTransportFailure(stageCause, cause.stage),
|
|
127
|
+
...stageContext,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (normalized.includes('network') ||
|
|
131
|
+
normalized.includes('fetch') ||
|
|
132
|
+
normalized.includes('timeout') ||
|
|
133
|
+
normalized.includes('offline')) {
|
|
134
|
+
return {
|
|
135
|
+
code: 'NETWORK_ERROR',
|
|
136
|
+
message,
|
|
137
|
+
cause,
|
|
138
|
+
retryable: true,
|
|
139
|
+
...stageContext,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
switch (cause.stage) {
|
|
143
|
+
case 'snapshot-gzip-decode':
|
|
144
|
+
return {
|
|
145
|
+
code: 'SNAPSHOT_GZIP_DECODE_FAILED',
|
|
146
|
+
message,
|
|
147
|
+
cause,
|
|
148
|
+
retryable: false,
|
|
149
|
+
...stageContext,
|
|
150
|
+
};
|
|
151
|
+
case 'snapshot-chunk-decode':
|
|
152
|
+
return {
|
|
153
|
+
code: 'SNAPSHOT_CHUNK_DECODE_FAILED',
|
|
154
|
+
message,
|
|
155
|
+
cause,
|
|
156
|
+
retryable: false,
|
|
157
|
+
...stageContext,
|
|
158
|
+
};
|
|
159
|
+
case 'snapshot-integrity':
|
|
160
|
+
return {
|
|
161
|
+
code: 'SNAPSHOT_INTEGRITY_FAILED',
|
|
162
|
+
message,
|
|
163
|
+
cause,
|
|
164
|
+
retryable: false,
|
|
165
|
+
...stageContext,
|
|
166
|
+
};
|
|
167
|
+
case 'snapshot-apply':
|
|
168
|
+
return {
|
|
169
|
+
code: 'SNAPSHOT_APPLY_FAILED',
|
|
170
|
+
message,
|
|
171
|
+
cause,
|
|
172
|
+
retryable: false,
|
|
173
|
+
...stageContext,
|
|
174
|
+
};
|
|
175
|
+
default:
|
|
176
|
+
return {
|
|
177
|
+
code: 'SYNC_ERROR',
|
|
178
|
+
message,
|
|
179
|
+
cause,
|
|
180
|
+
retryable: false,
|
|
181
|
+
...stageContext,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (cause instanceof SyncTransportError) {
|
|
186
|
+
return classifyTransportFailure(cause, 'pull');
|
|
104
187
|
}
|
|
105
188
|
if (normalized.includes('network') ||
|
|
106
189
|
normalized.includes('fetch') ||
|
|
@@ -159,6 +242,11 @@ function serializeInspectorRecord(value) {
|
|
|
159
242
|
function defaultSelectorEquality(left, right) {
|
|
160
243
|
return Object.is(left, right);
|
|
161
244
|
}
|
|
245
|
+
function normalizeBootstrapPhase(value) {
|
|
246
|
+
if (value === undefined)
|
|
247
|
+
return 0;
|
|
248
|
+
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
|
249
|
+
}
|
|
162
250
|
function areMetadataRecordsEqual(left, right) {
|
|
163
251
|
if (left === right)
|
|
164
252
|
return true;
|
|
@@ -536,31 +624,77 @@ export class SyncEngine {
|
|
|
536
624
|
completedAt: progress?.completedAt,
|
|
537
625
|
lastErrorCode: progress?.lastErrorCode,
|
|
538
626
|
lastErrorMessage: progress?.lastErrorMessage,
|
|
627
|
+
bootstrapPhase: normalizeBootstrapPhase(configuredSub?.bootstrapPhase),
|
|
539
628
|
};
|
|
540
629
|
});
|
|
541
630
|
const expectedSubscriptions = subscriptions.filter((sub) => sub.expected);
|
|
542
|
-
const
|
|
631
|
+
const activePhase = expectedSubscriptions
|
|
632
|
+
.filter((sub) => !sub.ready)
|
|
633
|
+
.reduce((lowest, sub) => lowest === null || sub.bootstrapPhase < lowest
|
|
634
|
+
? sub.bootstrapPhase
|
|
635
|
+
: lowest, null) ?? null;
|
|
636
|
+
const selectedMaxPhase = options.maxPhase ??
|
|
637
|
+
(explicitIdSet.size > 0
|
|
638
|
+
? 'all'
|
|
639
|
+
: expectedSubscriptions.length > 0
|
|
640
|
+
? expectedSubscriptions.reduce((lowest, sub) => Math.min(lowest, sub.bootstrapPhase), Number.POSITIVE_INFINITY)
|
|
641
|
+
: 0);
|
|
642
|
+
const blockingSubscriptions = expectedSubscriptions.filter((sub) => selectedMaxPhase === 'all' ? true : sub.bootstrapPhase <= selectedMaxPhase);
|
|
643
|
+
const readySubscriptionIds = blockingSubscriptions
|
|
543
644
|
.filter((sub) => sub.ready)
|
|
544
645
|
.map((sub) => sub.id);
|
|
545
|
-
const pendingSubscriptionIds =
|
|
646
|
+
const pendingSubscriptionIds = blockingSubscriptions
|
|
546
647
|
.filter((sub) => !sub.ready)
|
|
547
648
|
.map((sub) => sub.id);
|
|
548
649
|
const channelPhase = this.resolveChannelPhase(filteredStates.map((sub) => this.mapSubscriptionToProgress(sub)));
|
|
549
|
-
const progressPercent =
|
|
650
|
+
const progressPercent = blockingSubscriptions.length === 0
|
|
550
651
|
? pendingSubscriptionIds.length === 0
|
|
551
652
|
? 100
|
|
552
653
|
: 0
|
|
553
|
-
: Math.round(
|
|
654
|
+
: Math.round(blockingSubscriptions.reduce((sum, sub) => sum + sub.progressPercent, 0) / blockingSubscriptions.length);
|
|
655
|
+
const phases = Array.from(expectedSubscriptions.reduce((acc, sub) => {
|
|
656
|
+
const current = acc.get(sub.bootstrapPhase) ?? {
|
|
657
|
+
phase: sub.bootstrapPhase,
|
|
658
|
+
expectedSubscriptionIds: [],
|
|
659
|
+
readySubscriptionIds: [],
|
|
660
|
+
pendingSubscriptionIds: [],
|
|
661
|
+
progressPercent: 0,
|
|
662
|
+
};
|
|
663
|
+
current.expectedSubscriptionIds.push(sub.id);
|
|
664
|
+
if (sub.ready) {
|
|
665
|
+
current.readySubscriptionIds.push(sub.id);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
current.pendingSubscriptionIds.push(sub.id);
|
|
669
|
+
}
|
|
670
|
+
current.progressPercent += sub.progressPercent;
|
|
671
|
+
acc.set(sub.bootstrapPhase, current);
|
|
672
|
+
return acc;
|
|
673
|
+
}, new Map()))
|
|
674
|
+
.sort(([left], [right]) => left - right)
|
|
675
|
+
.map(([, phase]) => ({
|
|
676
|
+
phase: phase.phase,
|
|
677
|
+
expectedSubscriptionIds: phase.expectedSubscriptionIds,
|
|
678
|
+
readySubscriptionIds: phase.readySubscriptionIds,
|
|
679
|
+
pendingSubscriptionIds: phase.pendingSubscriptionIds,
|
|
680
|
+
isReady: phase.pendingSubscriptionIds.length === 0,
|
|
681
|
+
progressPercent: phase.expectedSubscriptionIds.length === 0
|
|
682
|
+
? 100
|
|
683
|
+
: Math.round(phase.progressPercent / phase.expectedSubscriptionIds.length),
|
|
684
|
+
}));
|
|
554
685
|
return {
|
|
555
686
|
stateId,
|
|
556
687
|
channelPhase,
|
|
557
688
|
progressPercent,
|
|
558
689
|
isBootstrapping: pendingSubscriptionIds.length > 0,
|
|
559
690
|
isReady: pendingSubscriptionIds.length === 0,
|
|
560
|
-
expectedSubscriptionIds:
|
|
691
|
+
expectedSubscriptionIds: blockingSubscriptions.map((sub) => sub.id),
|
|
561
692
|
readySubscriptionIds,
|
|
562
693
|
pendingSubscriptionIds,
|
|
563
694
|
subscriptions,
|
|
695
|
+
activePhase,
|
|
696
|
+
selectedMaxPhase,
|
|
697
|
+
phases,
|
|
564
698
|
};
|
|
565
699
|
}
|
|
566
700
|
/**
|
|
@@ -593,23 +727,49 @@ export class SyncEngine {
|
|
|
593
727
|
const stateId = options.stateId ?? this.getStateId();
|
|
594
728
|
const deadline = Date.now() + timeoutMs;
|
|
595
729
|
while (true) {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
730
|
+
if (options.subscriptionId !== undefined) {
|
|
731
|
+
const state = await this.getSubscriptionState(options.subscriptionId, {
|
|
732
|
+
stateId,
|
|
733
|
+
});
|
|
734
|
+
const hasPendingBootstrap = state?.status === 'active' && state.bootstrapState !== null;
|
|
735
|
+
if (!hasPendingBootstrap) {
|
|
736
|
+
return this.getProgress();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
const bootstrap = await this.getBootstrapStatus({
|
|
741
|
+
stateId,
|
|
742
|
+
maxPhase: options.maxPhase,
|
|
743
|
+
});
|
|
744
|
+
if (bootstrap.isReady) {
|
|
745
|
+
return this.getProgress();
|
|
746
|
+
}
|
|
603
747
|
}
|
|
604
748
|
if (this.state.error) {
|
|
605
|
-
|
|
749
|
+
const detailSuffix = this.state.error.stage
|
|
750
|
+
? ` [stage=${this.state.error.stage}]`
|
|
751
|
+
: '';
|
|
752
|
+
throw new Error(`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion${detailSuffix}: ${this.state.error.message}`);
|
|
606
753
|
}
|
|
607
754
|
const remainingMs = deadline - Date.now();
|
|
608
755
|
if (remainingMs <= 0) {
|
|
609
756
|
const target = options.subscriptionId === undefined
|
|
610
757
|
? `state "${stateId}"`
|
|
611
758
|
: `subscription "${options.subscriptionId}" in state "${stateId}"`;
|
|
612
|
-
|
|
759
|
+
const bootstrap = await this.getBootstrapStatus({
|
|
760
|
+
stateId,
|
|
761
|
+
subscriptionIds: options.subscriptionId === undefined
|
|
762
|
+
? undefined
|
|
763
|
+
: [options.subscriptionId],
|
|
764
|
+
maxPhase: options.maxPhase,
|
|
765
|
+
}).catch(() => null);
|
|
766
|
+
const pendingSummary = bootstrap && bootstrap.pendingSubscriptionIds.length > 0
|
|
767
|
+
? ` Pending subscriptions: ${bootstrap.pendingSubscriptionIds.join(', ')}.`
|
|
768
|
+
: '';
|
|
769
|
+
const phaseSummary = bootstrap && bootstrap.activePhase !== null
|
|
770
|
+
? ` Active phase: ${bootstrap.activePhase}.`
|
|
771
|
+
: '';
|
|
772
|
+
throw new Error(`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}. Bootstrap is still in progress.${phaseSummary}${pendingSummary}`);
|
|
613
773
|
}
|
|
614
774
|
await this.waitForProgressSignal(remainingMs);
|
|
615
775
|
}
|
|
@@ -1248,6 +1408,15 @@ export class SyncEngine {
|
|
|
1248
1408
|
}
|
|
1249
1409
|
}
|
|
1250
1410
|
}
|
|
1411
|
+
shouldTrace() {
|
|
1412
|
+
return (this.config.traceEnabled === true ||
|
|
1413
|
+
(this.listeners.get('sync:trace')?.size ?? 0) > 0);
|
|
1414
|
+
}
|
|
1415
|
+
emitTrace(payload) {
|
|
1416
|
+
if (!this.shouldTrace())
|
|
1417
|
+
return;
|
|
1418
|
+
this.emit('sync:trace', payload);
|
|
1419
|
+
}
|
|
1251
1420
|
updateState(partial) {
|
|
1252
1421
|
const nextState = { ...this.state, ...partial };
|
|
1253
1422
|
const unchanged = this.state.enabled === nextState.enabled &&
|
|
@@ -1489,14 +1658,14 @@ export class SyncEngine {
|
|
|
1489
1658
|
clientId: this.config.clientId,
|
|
1490
1659
|
actorId: this.config.actorId ?? undefined,
|
|
1491
1660
|
plugins: this.config.plugins,
|
|
1492
|
-
subscriptions: this.config
|
|
1493
|
-
.subscriptions,
|
|
1661
|
+
subscriptions: this.config.subscriptions,
|
|
1494
1662
|
limitCommits: this.config.limitCommits,
|
|
1495
1663
|
limitSnapshotRows: this.config.limitSnapshotRows,
|
|
1496
1664
|
maxSnapshotPages: this.config.maxSnapshotPages,
|
|
1497
1665
|
dedupeRows: this.config.dedupeRows,
|
|
1498
1666
|
stateId: this.config.stateId,
|
|
1499
1667
|
sha256: this.config.sha256,
|
|
1668
|
+
onTrace: (event) => this.emitTrace(event),
|
|
1500
1669
|
trigger,
|
|
1501
1670
|
allowSkipPullOnLocalWsPush: this.state.transportMode === 'realtime',
|
|
1502
1671
|
}));
|
|
@@ -1578,7 +1747,11 @@ export class SyncEngine {
|
|
|
1578
1747
|
cause: classified.cause,
|
|
1579
1748
|
retryable: classified.retryable,
|
|
1580
1749
|
httpStatus: classified.httpStatus,
|
|
1581
|
-
|
|
1750
|
+
stage: classified.stage,
|
|
1751
|
+
subscriptionId: classified.subscriptionId,
|
|
1752
|
+
chunkId: classified.chunkId,
|
|
1753
|
+
table: classified.table,
|
|
1754
|
+
stateId: classified.stateId ?? this.getStateId(),
|
|
1582
1755
|
});
|
|
1583
1756
|
this.updateState({
|
|
1584
1757
|
isSyncing: false,
|