@pol-studios/powersync 1.0.0
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/dist/attachments/index.d.ts +399 -0
- package/dist/attachments/index.js +16 -0
- package/dist/attachments/index.js.map +1 -0
- package/dist/chunk-32OLICZO.js +1 -0
- package/dist/chunk-32OLICZO.js.map +1 -0
- package/dist/chunk-4FJVBR3X.js +227 -0
- package/dist/chunk-4FJVBR3X.js.map +1 -0
- package/dist/chunk-7BPTGEVG.js +1 -0
- package/dist/chunk-7BPTGEVG.js.map +1 -0
- package/dist/chunk-7JQZBZ5N.js +1 -0
- package/dist/chunk-7JQZBZ5N.js.map +1 -0
- package/dist/chunk-BJ36QDFN.js +290 -0
- package/dist/chunk-BJ36QDFN.js.map +1 -0
- package/dist/chunk-CFCK2LHI.js +1002 -0
- package/dist/chunk-CFCK2LHI.js.map +1 -0
- package/dist/chunk-CHRTN5PF.js +322 -0
- package/dist/chunk-CHRTN5PF.js.map +1 -0
- package/dist/chunk-FLHDT4TS.js +327 -0
- package/dist/chunk-FLHDT4TS.js.map +1 -0
- package/dist/chunk-GBGATW2S.js +749 -0
- package/dist/chunk-GBGATW2S.js.map +1 -0
- package/dist/chunk-NPNBGCRC.js +65 -0
- package/dist/chunk-NPNBGCRC.js.map +1 -0
- package/dist/chunk-Q3LFFMRR.js +925 -0
- package/dist/chunk-Q3LFFMRR.js.map +1 -0
- package/dist/chunk-T225XEML.js +298 -0
- package/dist/chunk-T225XEML.js.map +1 -0
- package/dist/chunk-W7HSR35B.js +1 -0
- package/dist/chunk-W7HSR35B.js.map +1 -0
- package/dist/connector/index.d.ts +5 -0
- package/dist/connector/index.js +14 -0
- package/dist/connector/index.js.map +1 -0
- package/dist/core/index.d.ts +197 -0
- package/dist/core/index.js +96 -0
- package/dist/core/index.js.map +1 -0
- package/dist/index-nae7nzib.d.ts +147 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/index.native.d.ts +14 -0
- package/dist/index.native.js +195 -0
- package/dist/index.native.js.map +1 -0
- package/dist/index.web.d.ts +14 -0
- package/dist/index.web.js +195 -0
- package/dist/index.web.js.map +1 -0
- package/dist/platform/index.d.ts +280 -0
- package/dist/platform/index.js +14 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/index.native.d.ts +37 -0
- package/dist/platform/index.native.js +7 -0
- package/dist/platform/index.native.js.map +1 -0
- package/dist/platform/index.web.d.ts +37 -0
- package/dist/platform/index.web.js +7 -0
- package/dist/platform/index.web.js.map +1 -0
- package/dist/provider/index.d.ts +873 -0
- package/dist/provider/index.js +63 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/supabase-connector-D14-kl5v.d.ts +232 -0
- package/dist/sync/index.d.ts +421 -0
- package/dist/sync/index.js +14 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/types-Cd7RhNqf.d.ts +224 -0
- package/dist/types-afHtE1U_.d.ts +391 -0
- package/package.json +101 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HEALTH_CHECK_INTERVAL_MS,
|
|
3
|
+
HEALTH_CHECK_TIMEOUT_MS,
|
|
4
|
+
LATENCY_DEGRADED_THRESHOLD_MS,
|
|
5
|
+
MAX_CONSECUTIVE_FAILURES,
|
|
6
|
+
STATUS_NOTIFY_THROTTLE_MS,
|
|
7
|
+
STORAGE_KEY_METRICS,
|
|
8
|
+
STORAGE_KEY_PAUSED,
|
|
9
|
+
STORAGE_KEY_SYNC_MODE
|
|
10
|
+
} from "./chunk-NPNBGCRC.js";
|
|
11
|
+
import {
|
|
12
|
+
classifyError,
|
|
13
|
+
generateFailureId
|
|
14
|
+
} from "./chunk-CHRTN5PF.js";
|
|
15
|
+
|
|
16
|
+
// src/provider/types.ts
|
|
17
|
+
var DEFAULT_SYNC_STATUS = {
|
|
18
|
+
connected: false,
|
|
19
|
+
connecting: false,
|
|
20
|
+
hasSynced: false,
|
|
21
|
+
lastSyncedAt: null,
|
|
22
|
+
uploading: false,
|
|
23
|
+
downloading: false,
|
|
24
|
+
downloadProgress: null,
|
|
25
|
+
failedTransactions: [],
|
|
26
|
+
hasUploadErrors: false,
|
|
27
|
+
permanentErrorCount: 0
|
|
28
|
+
};
|
|
29
|
+
var DEFAULT_CONNECTION_HEALTH = {
|
|
30
|
+
status: "disconnected",
|
|
31
|
+
latency: null,
|
|
32
|
+
lastHealthCheck: null,
|
|
33
|
+
consecutiveFailures: 0,
|
|
34
|
+
reconnectAttempts: 0
|
|
35
|
+
};
|
|
36
|
+
var DEFAULT_SYNC_METRICS = {
|
|
37
|
+
totalSyncs: 0,
|
|
38
|
+
successfulSyncs: 0,
|
|
39
|
+
failedSyncs: 0,
|
|
40
|
+
lastSyncDuration: null,
|
|
41
|
+
averageSyncDuration: null,
|
|
42
|
+
totalDataDownloaded: 0,
|
|
43
|
+
totalDataUploaded: 0,
|
|
44
|
+
lastError: null
|
|
45
|
+
};
|
|
46
|
+
var DEFAULT_SYNC_CONFIG = {
|
|
47
|
+
autoConnect: true,
|
|
48
|
+
syncInterval: 0,
|
|
49
|
+
enableHealthMonitoring: true,
|
|
50
|
+
enableMetrics: true
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// src/sync/status-tracker.ts
|
|
54
|
+
var SyncStatusTracker = class _SyncStatusTracker {
|
|
55
|
+
storage;
|
|
56
|
+
logger;
|
|
57
|
+
notifyThrottleMs;
|
|
58
|
+
onStatusChange;
|
|
59
|
+
_state;
|
|
60
|
+
_pendingMutations = [];
|
|
61
|
+
_lastNotifyTime = 0;
|
|
62
|
+
_notifyTimer = null;
|
|
63
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
64
|
+
_syncModeListeners = /* @__PURE__ */ new Set();
|
|
65
|
+
// Force next upload flag for "Sync Now" functionality
|
|
66
|
+
_forceNextUpload = false;
|
|
67
|
+
// Track download progress separately to preserve it when offline
|
|
68
|
+
_lastProgress = null;
|
|
69
|
+
// Failed transaction tracking
|
|
70
|
+
_failedTransactions = [];
|
|
71
|
+
_maxStoredFailures = 50;
|
|
72
|
+
_failureTTLMs = 24 * 60 * 60 * 1e3;
|
|
73
|
+
// 24 hours
|
|
74
|
+
_failureListeners = /* @__PURE__ */ new Set();
|
|
75
|
+
// Completed transaction tracking
|
|
76
|
+
_completedTransactions = [];
|
|
77
|
+
_maxCompletedHistory = 20;
|
|
78
|
+
_completedListeners = /* @__PURE__ */ new Set();
|
|
79
|
+
constructor(storage, logger, options = {}) {
|
|
80
|
+
this.storage = storage;
|
|
81
|
+
this.logger = logger;
|
|
82
|
+
this.notifyThrottleMs = options.notifyThrottleMs ?? STATUS_NOTIFY_THROTTLE_MS;
|
|
83
|
+
this.onStatusChange = options.onStatusChange;
|
|
84
|
+
this._state = {
|
|
85
|
+
status: { ...DEFAULT_SYNC_STATUS },
|
|
86
|
+
syncMode: "push-pull",
|
|
87
|
+
lastUpdated: /* @__PURE__ */ new Date()
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// ─── Initialization ────────────────────────────────────────────────────────
|
|
91
|
+
/**
|
|
92
|
+
* Initialize the tracker by loading persisted state.
|
|
93
|
+
* Includes migration from old isPaused boolean to new syncMode.
|
|
94
|
+
*/
|
|
95
|
+
async init() {
|
|
96
|
+
try {
|
|
97
|
+
const modeValue = await this.storage.getItem(STORAGE_KEY_SYNC_MODE);
|
|
98
|
+
if (modeValue && ["push-pull", "pull-only", "offline"].includes(modeValue)) {
|
|
99
|
+
this._state.syncMode = modeValue;
|
|
100
|
+
this.logger.debug("[StatusTracker] Loaded sync mode:", this._state.syncMode);
|
|
101
|
+
} else {
|
|
102
|
+
const pausedValue = await this.storage.getItem(STORAGE_KEY_PAUSED);
|
|
103
|
+
if (pausedValue === "true") {
|
|
104
|
+
this._state.syncMode = "offline";
|
|
105
|
+
await this.storage.setItem(STORAGE_KEY_SYNC_MODE, "offline");
|
|
106
|
+
this.logger.debug("[StatusTracker] Migrated isPaused=true to syncMode=offline");
|
|
107
|
+
} else {
|
|
108
|
+
this._state.syncMode = "push-pull";
|
|
109
|
+
}
|
|
110
|
+
await this.storage.removeItem(STORAGE_KEY_PAUSED);
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
this.logger.warn("[StatusTracker] Failed to load sync mode:", err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Dispose the tracker and clear timers.
|
|
118
|
+
*/
|
|
119
|
+
dispose() {
|
|
120
|
+
if (this._notifyTimer) {
|
|
121
|
+
clearTimeout(this._notifyTimer);
|
|
122
|
+
this._notifyTimer = null;
|
|
123
|
+
}
|
|
124
|
+
this._listeners.clear();
|
|
125
|
+
this._syncModeListeners.clear();
|
|
126
|
+
this._failureListeners.clear();
|
|
127
|
+
this._completedListeners.clear();
|
|
128
|
+
}
|
|
129
|
+
// ─── Status Getters ────────────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Get the current sync status.
|
|
132
|
+
*/
|
|
133
|
+
getStatus() {
|
|
134
|
+
const baseStatus = this._state.status;
|
|
135
|
+
const status = {
|
|
136
|
+
...baseStatus,
|
|
137
|
+
failedTransactions: this._failedTransactions,
|
|
138
|
+
hasUploadErrors: this._failedTransactions.length > 0,
|
|
139
|
+
permanentErrorCount: this._failedTransactions.filter((f) => f.isPermanent).length
|
|
140
|
+
};
|
|
141
|
+
if (this._state.syncMode === "offline" && this._lastProgress) {
|
|
142
|
+
return {
|
|
143
|
+
...status,
|
|
144
|
+
downloadProgress: this._lastProgress
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return status;
|
|
148
|
+
}
|
|
149
|
+
// ─── Sync Mode Getters ────────────────────────────────────────────────────────
|
|
150
|
+
/**
|
|
151
|
+
* Get the current sync mode.
|
|
152
|
+
*/
|
|
153
|
+
getSyncMode() {
|
|
154
|
+
return this._state.syncMode;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get whether sync is paused (offline mode).
|
|
158
|
+
* @deprecated Use getSyncMode() instead
|
|
159
|
+
*/
|
|
160
|
+
isPaused() {
|
|
161
|
+
return this._state.syncMode === "offline";
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Check if uploads are allowed based on current sync mode.
|
|
165
|
+
*/
|
|
166
|
+
canUpload() {
|
|
167
|
+
return this._state.syncMode === "push-pull";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Check if downloads are allowed based on current sync mode.
|
|
171
|
+
*/
|
|
172
|
+
canDownload() {
|
|
173
|
+
return this._state.syncMode !== "offline";
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Set the force next upload flag for "Sync Now" functionality.
|
|
177
|
+
*/
|
|
178
|
+
setForceNextUpload(force) {
|
|
179
|
+
this._forceNextUpload = force;
|
|
180
|
+
this.logger.debug("[StatusTracker] Force next upload set to:", force);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Clear the force next upload flag.
|
|
184
|
+
* Should be called after all pending uploads have been processed.
|
|
185
|
+
*/
|
|
186
|
+
clearForceNextUpload() {
|
|
187
|
+
if (this._forceNextUpload) {
|
|
188
|
+
this._forceNextUpload = false;
|
|
189
|
+
this.logger.debug("[StatusTracker] Force next upload flag cleared");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Check if upload should proceed, considering force flag.
|
|
194
|
+
* NOTE: Does NOT auto-reset the flag - caller must use clearForceNextUpload()
|
|
195
|
+
* after all uploads are complete. This prevents race conditions when
|
|
196
|
+
* PowerSync calls uploadData() multiple times for multiple transactions.
|
|
197
|
+
*/
|
|
198
|
+
shouldUpload() {
|
|
199
|
+
if (this._forceNextUpload) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
return this._state.syncMode === "push-pull";
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Get pending mutations.
|
|
206
|
+
*/
|
|
207
|
+
getPendingMutations() {
|
|
208
|
+
return this._pendingMutations;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Get pending mutation count.
|
|
212
|
+
*/
|
|
213
|
+
getPendingCount() {
|
|
214
|
+
return this._pendingMutations.length;
|
|
215
|
+
}
|
|
216
|
+
// ─── Status Updates ────────────────────────────────────────────────────────
|
|
217
|
+
/**
|
|
218
|
+
* Handle a raw status update from PowerSync.
|
|
219
|
+
*/
|
|
220
|
+
handleStatusChange(rawStatus) {
|
|
221
|
+
const progress = rawStatus.downloadProgress;
|
|
222
|
+
const dataFlow = rawStatus.dataFlowStatus;
|
|
223
|
+
let downloadProgress = null;
|
|
224
|
+
if (progress && progress.totalOperations && progress.totalOperations > 0) {
|
|
225
|
+
downloadProgress = {
|
|
226
|
+
current: progress.downloadedOperations ?? 0,
|
|
227
|
+
target: progress.totalOperations,
|
|
228
|
+
percentage: Math.round((progress.downloadedFraction ?? 0) * 100)
|
|
229
|
+
};
|
|
230
|
+
this._lastProgress = downloadProgress;
|
|
231
|
+
}
|
|
232
|
+
const newStatus = {
|
|
233
|
+
connected: rawStatus.connected ?? false,
|
|
234
|
+
connecting: rawStatus.connecting ?? false,
|
|
235
|
+
hasSynced: rawStatus.hasSynced ?? false,
|
|
236
|
+
lastSyncedAt: rawStatus.lastSyncedAt ?? null,
|
|
237
|
+
uploading: dataFlow?.uploading ?? false,
|
|
238
|
+
downloading: dataFlow?.downloading ?? false,
|
|
239
|
+
downloadProgress,
|
|
240
|
+
// These are computed from _failedTransactions in getStatus()
|
|
241
|
+
failedTransactions: [],
|
|
242
|
+
hasUploadErrors: false,
|
|
243
|
+
permanentErrorCount: 0
|
|
244
|
+
};
|
|
245
|
+
const changed = this._hasStatusChanged(newStatus);
|
|
246
|
+
this._state = {
|
|
247
|
+
status: newStatus,
|
|
248
|
+
syncMode: this._state.syncMode,
|
|
249
|
+
lastUpdated: /* @__PURE__ */ new Date()
|
|
250
|
+
};
|
|
251
|
+
if (changed) {
|
|
252
|
+
this._notifyListeners();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Update pending mutations from a CRUD transaction.
|
|
257
|
+
*/
|
|
258
|
+
updatePendingMutations(mutations) {
|
|
259
|
+
this._pendingMutations = mutations;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Valid sync modes for runtime validation.
|
|
263
|
+
*/
|
|
264
|
+
static VALID_SYNC_MODES = ["push-pull", "pull-only", "offline"];
|
|
265
|
+
/**
|
|
266
|
+
* Set the sync mode.
|
|
267
|
+
*/
|
|
268
|
+
async setSyncMode(mode) {
|
|
269
|
+
if (!_SyncStatusTracker.VALID_SYNC_MODES.includes(mode)) {
|
|
270
|
+
this.logger.warn("[StatusTracker] Invalid sync mode, ignoring:", mode);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (this._state.syncMode === mode) return;
|
|
274
|
+
const previousMode = this._state.syncMode;
|
|
275
|
+
this._state.syncMode = mode;
|
|
276
|
+
try {
|
|
277
|
+
await this.storage.setItem(STORAGE_KEY_SYNC_MODE, mode);
|
|
278
|
+
this.logger.info("[StatusTracker] Sync mode changed:", previousMode, "->", mode);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
this.logger.warn("[StatusTracker] Failed to persist sync mode:", err);
|
|
281
|
+
}
|
|
282
|
+
for (const listener of this._syncModeListeners) {
|
|
283
|
+
try {
|
|
284
|
+
listener(mode);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
this.logger.warn("[StatusTracker] Sync mode listener error:", err);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
this._notifyListeners(true);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Set paused state.
|
|
293
|
+
* @deprecated Use setSyncMode() instead
|
|
294
|
+
*/
|
|
295
|
+
async setPaused(paused) {
|
|
296
|
+
await this.setSyncMode(paused ? "offline" : "push-pull");
|
|
297
|
+
}
|
|
298
|
+
// ─── Subscriptions ─────────────────────────────────────────────────────────
|
|
299
|
+
/**
|
|
300
|
+
* Subscribe to status changes.
|
|
301
|
+
* @returns Unsubscribe function
|
|
302
|
+
*/
|
|
303
|
+
onStatusUpdate(listener) {
|
|
304
|
+
this._listeners.add(listener);
|
|
305
|
+
listener(this.getStatus());
|
|
306
|
+
return () => {
|
|
307
|
+
this._listeners.delete(listener);
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Subscribe to sync mode changes.
|
|
312
|
+
* @returns Unsubscribe function
|
|
313
|
+
*/
|
|
314
|
+
onSyncModeChange(listener) {
|
|
315
|
+
this._syncModeListeners.add(listener);
|
|
316
|
+
listener(this._state.syncMode);
|
|
317
|
+
return () => {
|
|
318
|
+
this._syncModeListeners.delete(listener);
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Subscribe to paused state changes.
|
|
323
|
+
* @deprecated Use onSyncModeChange() instead
|
|
324
|
+
* @returns Unsubscribe function
|
|
325
|
+
*/
|
|
326
|
+
onPausedChange(listener) {
|
|
327
|
+
const wrappedListener = (mode) => {
|
|
328
|
+
listener(mode === "offline");
|
|
329
|
+
};
|
|
330
|
+
this._syncModeListeners.add(wrappedListener);
|
|
331
|
+
listener(this._state.syncMode === "offline");
|
|
332
|
+
return () => {
|
|
333
|
+
this._syncModeListeners.delete(wrappedListener);
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// ─── Failed Transaction Tracking ────────────────────────────────────────────
|
|
337
|
+
/**
|
|
338
|
+
* Record a transaction failure.
|
|
339
|
+
* If a failure for the same entries already exists, updates the retry count.
|
|
340
|
+
* Otherwise, creates a new failure record.
|
|
341
|
+
*/
|
|
342
|
+
recordTransactionFailure(entries, error, isPermanent, affectedEntityIds, affectedTables) {
|
|
343
|
+
const now = /* @__PURE__ */ new Date();
|
|
344
|
+
const entryIds = entries.map((e) => e.id).sort().join(",");
|
|
345
|
+
const existingIndex = this._failedTransactions.findIndex((f) => {
|
|
346
|
+
const existingIds = f.entries.map((e) => e.id).sort().join(",");
|
|
347
|
+
return existingIds === entryIds;
|
|
348
|
+
});
|
|
349
|
+
if (existingIndex !== -1) {
|
|
350
|
+
const existing = this._failedTransactions[existingIndex];
|
|
351
|
+
this._failedTransactions[existingIndex] = {
|
|
352
|
+
...existing,
|
|
353
|
+
error,
|
|
354
|
+
retryCount: existing.retryCount + 1,
|
|
355
|
+
lastFailedAt: now,
|
|
356
|
+
isPermanent
|
|
357
|
+
};
|
|
358
|
+
} else {
|
|
359
|
+
const newFailure = {
|
|
360
|
+
id: generateFailureId(entries),
|
|
361
|
+
entries,
|
|
362
|
+
error,
|
|
363
|
+
retryCount: 1,
|
|
364
|
+
firstFailedAt: now,
|
|
365
|
+
lastFailedAt: now,
|
|
366
|
+
isPermanent,
|
|
367
|
+
affectedEntityIds,
|
|
368
|
+
affectedTables
|
|
369
|
+
};
|
|
370
|
+
this._failedTransactions.push(newFailure);
|
|
371
|
+
if (this._failedTransactions.length > this._maxStoredFailures) {
|
|
372
|
+
this._failedTransactions.sort(
|
|
373
|
+
(a, b) => a.firstFailedAt.getTime() - b.firstFailedAt.getTime()
|
|
374
|
+
);
|
|
375
|
+
this._failedTransactions = this._failedTransactions.slice(-this._maxStoredFailures);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
this._notifyFailureListeners();
|
|
379
|
+
this._notifyListeners();
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Clear a specific failure by ID.
|
|
383
|
+
*/
|
|
384
|
+
clearFailure(failureId) {
|
|
385
|
+
const initialLength = this._failedTransactions.length;
|
|
386
|
+
this._failedTransactions = this._failedTransactions.filter((f) => f.id !== failureId);
|
|
387
|
+
if (this._failedTransactions.length !== initialLength) {
|
|
388
|
+
this._notifyFailureListeners();
|
|
389
|
+
this._notifyListeners();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Clear all failures.
|
|
394
|
+
*/
|
|
395
|
+
clearAllFailures() {
|
|
396
|
+
if (this._failedTransactions.length === 0) return;
|
|
397
|
+
this._failedTransactions = [];
|
|
398
|
+
this._notifyFailureListeners();
|
|
399
|
+
this._notifyListeners();
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Get failures affecting a specific entity.
|
|
403
|
+
*/
|
|
404
|
+
getFailuresForEntity(entityId) {
|
|
405
|
+
return this._failedTransactions.filter(
|
|
406
|
+
(f) => f.affectedEntityIds.includes(entityId)
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Get all failed transactions.
|
|
411
|
+
*/
|
|
412
|
+
getFailedTransactions() {
|
|
413
|
+
return [...this._failedTransactions];
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Check if there are any upload errors.
|
|
417
|
+
*/
|
|
418
|
+
hasUploadErrors() {
|
|
419
|
+
return this._failedTransactions.length > 0;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Get count of permanent errors.
|
|
423
|
+
*/
|
|
424
|
+
getPermanentErrorCount() {
|
|
425
|
+
return this._failedTransactions.filter((f) => f.isPermanent).length;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Subscribe to failure changes.
|
|
429
|
+
* @returns Unsubscribe function
|
|
430
|
+
*/
|
|
431
|
+
onFailureChange(listener) {
|
|
432
|
+
this._failureListeners.add(listener);
|
|
433
|
+
listener(this.getFailedTransactions());
|
|
434
|
+
return () => {
|
|
435
|
+
this._failureListeners.delete(listener);
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Clean up stale failures (older than TTL).
|
|
440
|
+
*/
|
|
441
|
+
cleanupStaleFailures() {
|
|
442
|
+
const cutoff = Date.now() - this._failureTTLMs;
|
|
443
|
+
const initialLength = this._failedTransactions.length;
|
|
444
|
+
this._failedTransactions = this._failedTransactions.filter(
|
|
445
|
+
(f) => f.lastFailedAt.getTime() > cutoff
|
|
446
|
+
);
|
|
447
|
+
if (this._failedTransactions.length !== initialLength) {
|
|
448
|
+
this.logger.debug(
|
|
449
|
+
`[StatusTracker] Cleaned up ${initialLength - this._failedTransactions.length} stale failures`
|
|
450
|
+
);
|
|
451
|
+
this._notifyFailureListeners();
|
|
452
|
+
this._notifyListeners();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// ─── Completed Transaction Tracking ────────────────────────────────────────
|
|
456
|
+
/**
|
|
457
|
+
* Record a successfully completed transaction.
|
|
458
|
+
* Creates a CompletedTransaction record and adds it to history.
|
|
459
|
+
*/
|
|
460
|
+
recordTransactionComplete(entries) {
|
|
461
|
+
const affectedTables = [...new Set(entries.map((e) => e.table))];
|
|
462
|
+
const affectedEntityIds = [...new Set(entries.map((e) => e.id))];
|
|
463
|
+
const id = `completed_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
464
|
+
const completed = {
|
|
465
|
+
id,
|
|
466
|
+
entries,
|
|
467
|
+
completedAt: /* @__PURE__ */ new Date(),
|
|
468
|
+
affectedTables,
|
|
469
|
+
affectedEntityIds
|
|
470
|
+
};
|
|
471
|
+
this._completedTransactions.unshift(completed);
|
|
472
|
+
if (this._completedTransactions.length > this._maxCompletedHistory) {
|
|
473
|
+
this._completedTransactions = this._completedTransactions.slice(0, this._maxCompletedHistory);
|
|
474
|
+
}
|
|
475
|
+
this._notifyCompletedListeners();
|
|
476
|
+
this.logger.debug(
|
|
477
|
+
`[StatusTracker] Recorded completed transaction: ${completed.id} (${entries.length} entries)`
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Get all completed transactions.
|
|
482
|
+
*/
|
|
483
|
+
getCompletedTransactions() {
|
|
484
|
+
return [...this._completedTransactions];
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Clear completed transaction history.
|
|
488
|
+
*/
|
|
489
|
+
clearCompletedHistory() {
|
|
490
|
+
if (this._completedTransactions.length === 0) return;
|
|
491
|
+
this._completedTransactions = [];
|
|
492
|
+
this._notifyCompletedListeners();
|
|
493
|
+
this.logger.debug("[StatusTracker] Cleared completed transaction history");
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Subscribe to completed transaction changes.
|
|
497
|
+
* @returns Unsubscribe function
|
|
498
|
+
*/
|
|
499
|
+
onCompletedChange(listener) {
|
|
500
|
+
this._completedListeners.add(listener);
|
|
501
|
+
listener(this.getCompletedTransactions());
|
|
502
|
+
return () => {
|
|
503
|
+
this._completedListeners.delete(listener);
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
// ─── Private Methods ───────────────────────────────────────────────────────
|
|
507
|
+
_hasStatusChanged(newStatus) {
|
|
508
|
+
const old = this._state.status;
|
|
509
|
+
return old.connected !== newStatus.connected || old.connecting !== newStatus.connecting || old.hasSynced !== newStatus.hasSynced || old.uploading !== newStatus.uploading || old.downloading !== newStatus.downloading || old.lastSyncedAt?.getTime() !== newStatus.lastSyncedAt?.getTime() || old.downloadProgress?.current !== newStatus.downloadProgress?.current || old.downloadProgress?.target !== newStatus.downloadProgress?.target;
|
|
510
|
+
}
|
|
511
|
+
_notifyListeners(forceImmediate = false) {
|
|
512
|
+
const now = Date.now();
|
|
513
|
+
const timeSinceLastNotify = now - this._lastNotifyTime;
|
|
514
|
+
if (this._notifyTimer) {
|
|
515
|
+
clearTimeout(this._notifyTimer);
|
|
516
|
+
this._notifyTimer = null;
|
|
517
|
+
}
|
|
518
|
+
const notify = () => {
|
|
519
|
+
this._lastNotifyTime = Date.now();
|
|
520
|
+
const status = this.getStatus();
|
|
521
|
+
this.onStatusChange?.(status);
|
|
522
|
+
for (const listener of this._listeners) {
|
|
523
|
+
try {
|
|
524
|
+
listener(status);
|
|
525
|
+
} catch (err) {
|
|
526
|
+
this.logger.warn("[StatusTracker] Listener error:", err);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
if (forceImmediate || timeSinceLastNotify >= this.notifyThrottleMs) {
|
|
531
|
+
notify();
|
|
532
|
+
} else {
|
|
533
|
+
const delay = this.notifyThrottleMs - timeSinceLastNotify;
|
|
534
|
+
this._notifyTimer = setTimeout(notify, delay);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
_notifyFailureListeners() {
|
|
538
|
+
const failures = this.getFailedTransactions();
|
|
539
|
+
for (const listener of this._failureListeners) {
|
|
540
|
+
try {
|
|
541
|
+
listener(failures);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
this.logger.warn("[StatusTracker] Failure listener error:", err);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
_notifyCompletedListeners() {
|
|
548
|
+
const completed = this.getCompletedTransactions();
|
|
549
|
+
for (const listener of this._completedListeners) {
|
|
550
|
+
try {
|
|
551
|
+
listener(completed);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
this.logger.warn("[StatusTracker] Completed listener error:", err);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// src/sync/metrics-collector.ts
|
|
560
|
+
var MetricsCollector = class {
|
|
561
|
+
storage;
|
|
562
|
+
logger;
|
|
563
|
+
storageKey;
|
|
564
|
+
persistMetrics;
|
|
565
|
+
onMetricsChange;
|
|
566
|
+
_metrics;
|
|
567
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
568
|
+
_initialized = false;
|
|
569
|
+
// Track active sync for timing
|
|
570
|
+
_syncStartTime = null;
|
|
571
|
+
_wasSyncing = false;
|
|
572
|
+
constructor(storage, logger, options = {}) {
|
|
573
|
+
this.storage = storage;
|
|
574
|
+
this.logger = logger;
|
|
575
|
+
this.storageKey = options.storageKey ?? STORAGE_KEY_METRICS;
|
|
576
|
+
this.persistMetrics = options.persistMetrics ?? true;
|
|
577
|
+
this.onMetricsChange = options.onMetricsChange;
|
|
578
|
+
this._metrics = { ...DEFAULT_SYNC_METRICS };
|
|
579
|
+
}
|
|
580
|
+
// ─── Initialization ────────────────────────────────────────────────────────
|
|
581
|
+
/**
|
|
582
|
+
* Initialize the collector by loading persisted metrics.
|
|
583
|
+
*/
|
|
584
|
+
async init() {
|
|
585
|
+
if (this._initialized) return;
|
|
586
|
+
try {
|
|
587
|
+
const stored = await this.storage.getItem(this.storageKey);
|
|
588
|
+
if (stored) {
|
|
589
|
+
const parsed = JSON.parse(stored);
|
|
590
|
+
if (parsed.lastError?.timestamp) {
|
|
591
|
+
parsed.lastError.timestamp = new Date(parsed.lastError.timestamp);
|
|
592
|
+
}
|
|
593
|
+
this._metrics = { ...DEFAULT_SYNC_METRICS, ...parsed };
|
|
594
|
+
this.logger.debug("[MetricsCollector] Loaded persisted metrics");
|
|
595
|
+
}
|
|
596
|
+
} catch (err) {
|
|
597
|
+
this.logger.warn("[MetricsCollector] Failed to load metrics:", err);
|
|
598
|
+
}
|
|
599
|
+
this._initialized = true;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Dispose the collector.
|
|
603
|
+
*/
|
|
604
|
+
dispose() {
|
|
605
|
+
this._listeners.clear();
|
|
606
|
+
}
|
|
607
|
+
// ─── Getters ───────────────────────────────────────────────────────────────
|
|
608
|
+
/**
|
|
609
|
+
* Get current sync metrics.
|
|
610
|
+
*/
|
|
611
|
+
getMetrics() {
|
|
612
|
+
return { ...this._metrics };
|
|
613
|
+
}
|
|
614
|
+
// ─── Recording ─────────────────────────────────────────────────────────────
|
|
615
|
+
/**
|
|
616
|
+
* Record a completed sync operation.
|
|
617
|
+
*/
|
|
618
|
+
async recordSync(data) {
|
|
619
|
+
const { durationMs, success, operationsDownloaded, operationsUploaded, error } = data;
|
|
620
|
+
const totalSyncs = this._metrics.totalSyncs + 1;
|
|
621
|
+
const successfulSyncs = this._metrics.successfulSyncs + (success ? 1 : 0);
|
|
622
|
+
const failedSyncs = this._metrics.failedSyncs + (success ? 0 : 1);
|
|
623
|
+
let averageSyncDuration = this._metrics.averageSyncDuration;
|
|
624
|
+
if (success) {
|
|
625
|
+
if (averageSyncDuration !== null) {
|
|
626
|
+
averageSyncDuration = (averageSyncDuration * (successfulSyncs - 1) + durationMs) / successfulSyncs;
|
|
627
|
+
} else {
|
|
628
|
+
averageSyncDuration = durationMs;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const bytesPerOp = 100;
|
|
632
|
+
const downloaded = (operationsDownloaded ?? 0) * bytesPerOp;
|
|
633
|
+
const uploaded = (operationsUploaded ?? 0) * bytesPerOp;
|
|
634
|
+
let lastError = this._metrics.lastError;
|
|
635
|
+
if (!success && error) {
|
|
636
|
+
const errorType = classifyError(error);
|
|
637
|
+
lastError = {
|
|
638
|
+
type: errorType,
|
|
639
|
+
message: error.message,
|
|
640
|
+
userMessage: error.message,
|
|
641
|
+
// Use original message as user message for metrics
|
|
642
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
643
|
+
isPermanent: false
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
this._metrics = {
|
|
647
|
+
totalSyncs,
|
|
648
|
+
successfulSyncs,
|
|
649
|
+
failedSyncs,
|
|
650
|
+
lastSyncDuration: durationMs,
|
|
651
|
+
averageSyncDuration: averageSyncDuration !== null ? Math.round(averageSyncDuration) : null,
|
|
652
|
+
totalDataDownloaded: this._metrics.totalDataDownloaded + downloaded,
|
|
653
|
+
totalDataUploaded: this._metrics.totalDataUploaded + uploaded,
|
|
654
|
+
lastError
|
|
655
|
+
};
|
|
656
|
+
await this._persist();
|
|
657
|
+
this._notifyListeners();
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Record a sync error without a full sync operation.
|
|
661
|
+
*/
|
|
662
|
+
async recordError(error) {
|
|
663
|
+
this._metrics = {
|
|
664
|
+
...this._metrics,
|
|
665
|
+
failedSyncs: this._metrics.failedSyncs + 1,
|
|
666
|
+
lastError: {
|
|
667
|
+
type: classifyError(error),
|
|
668
|
+
message: error.message,
|
|
669
|
+
userMessage: error.message,
|
|
670
|
+
// Use original message as user message for metrics
|
|
671
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
672
|
+
isPermanent: false
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
await this._persist();
|
|
676
|
+
this._notifyListeners();
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Record an upload operation.
|
|
680
|
+
*/
|
|
681
|
+
async recordUpload(operationCount) {
|
|
682
|
+
const bytesPerOp = 100;
|
|
683
|
+
this._metrics = {
|
|
684
|
+
...this._metrics,
|
|
685
|
+
totalDataUploaded: this._metrics.totalDataUploaded + operationCount * bytesPerOp
|
|
686
|
+
};
|
|
687
|
+
await this._persist();
|
|
688
|
+
this._notifyListeners();
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Clear all metrics and start fresh.
|
|
692
|
+
*/
|
|
693
|
+
async reset() {
|
|
694
|
+
this._metrics = { ...DEFAULT_SYNC_METRICS };
|
|
695
|
+
await this._persist();
|
|
696
|
+
this._notifyListeners();
|
|
697
|
+
}
|
|
698
|
+
// ─── Sync Timing Helpers ───────────────────────────────────────────────────
|
|
699
|
+
/**
|
|
700
|
+
* Called when sync starts (downloading becomes true).
|
|
701
|
+
*/
|
|
702
|
+
markSyncStart() {
|
|
703
|
+
if (!this._wasSyncing) {
|
|
704
|
+
this._syncStartTime = Date.now();
|
|
705
|
+
this._wasSyncing = true;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Called when sync ends (downloading becomes false).
|
|
710
|
+
* Returns the duration if a sync was in progress.
|
|
711
|
+
*/
|
|
712
|
+
markSyncEnd() {
|
|
713
|
+
if (this._wasSyncing && this._syncStartTime !== null) {
|
|
714
|
+
const duration = Date.now() - this._syncStartTime;
|
|
715
|
+
this._syncStartTime = null;
|
|
716
|
+
this._wasSyncing = false;
|
|
717
|
+
return duration;
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Check if sync is currently in progress.
|
|
723
|
+
*/
|
|
724
|
+
isSyncInProgress() {
|
|
725
|
+
return this._wasSyncing;
|
|
726
|
+
}
|
|
727
|
+
// ─── Subscriptions ─────────────────────────────────────────────────────────
|
|
728
|
+
/**
|
|
729
|
+
* Subscribe to metrics changes.
|
|
730
|
+
* @returns Unsubscribe function
|
|
731
|
+
*/
|
|
732
|
+
onMetricsUpdate(listener) {
|
|
733
|
+
this._listeners.add(listener);
|
|
734
|
+
listener(this.getMetrics());
|
|
735
|
+
return () => {
|
|
736
|
+
this._listeners.delete(listener);
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
// ─── Private Methods ───────────────────────────────────────────────────────
|
|
740
|
+
async _persist() {
|
|
741
|
+
if (!this.persistMetrics) return;
|
|
742
|
+
try {
|
|
743
|
+
await this.storage.setItem(this.storageKey, JSON.stringify(this._metrics));
|
|
744
|
+
} catch (err) {
|
|
745
|
+
this.logger.warn("[MetricsCollector] Failed to persist metrics:", err);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
_notifyListeners() {
|
|
749
|
+
const metrics = this.getMetrics();
|
|
750
|
+
this.onMetricsChange?.(metrics);
|
|
751
|
+
for (const listener of this._listeners) {
|
|
752
|
+
try {
|
|
753
|
+
listener(metrics);
|
|
754
|
+
} catch (err) {
|
|
755
|
+
this.logger.warn("[MetricsCollector] Listener error:", err);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// src/sync/health-monitor.ts
|
|
762
|
+
var HealthMonitor = class {
|
|
763
|
+
logger;
|
|
764
|
+
checkIntervalMs;
|
|
765
|
+
checkTimeoutMs;
|
|
766
|
+
degradedThresholdMs;
|
|
767
|
+
maxConsecutiveFailures;
|
|
768
|
+
onHealthChange;
|
|
769
|
+
_db = null;
|
|
770
|
+
_health;
|
|
771
|
+
_intervalId = null;
|
|
772
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
773
|
+
_running = false;
|
|
774
|
+
_paused = false;
|
|
775
|
+
constructor(logger, options = {}) {
|
|
776
|
+
this.logger = logger;
|
|
777
|
+
this.checkIntervalMs = options.checkIntervalMs ?? HEALTH_CHECK_INTERVAL_MS;
|
|
778
|
+
this.checkTimeoutMs = options.checkTimeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
|
|
779
|
+
this.degradedThresholdMs = options.degradedThresholdMs ?? LATENCY_DEGRADED_THRESHOLD_MS;
|
|
780
|
+
this.maxConsecutiveFailures = options.maxConsecutiveFailures ?? MAX_CONSECUTIVE_FAILURES;
|
|
781
|
+
this.onHealthChange = options.onHealthChange;
|
|
782
|
+
this._health = { ...DEFAULT_CONNECTION_HEALTH };
|
|
783
|
+
}
|
|
784
|
+
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
|
785
|
+
/**
|
|
786
|
+
* Set the database instance to monitor.
|
|
787
|
+
*/
|
|
788
|
+
setDatabase(db) {
|
|
789
|
+
this._db = db;
|
|
790
|
+
if (!db) {
|
|
791
|
+
this._updateHealth({
|
|
792
|
+
status: "disconnected",
|
|
793
|
+
latency: null,
|
|
794
|
+
lastHealthCheck: /* @__PURE__ */ new Date(),
|
|
795
|
+
consecutiveFailures: 0,
|
|
796
|
+
reconnectAttempts: this._health.reconnectAttempts
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Start the health monitor.
|
|
802
|
+
*/
|
|
803
|
+
start() {
|
|
804
|
+
if (this._running) return;
|
|
805
|
+
this.logger.info("[HealthMonitor] Starting");
|
|
806
|
+
this._running = true;
|
|
807
|
+
this._checkHealth();
|
|
808
|
+
this._intervalId = setInterval(() => {
|
|
809
|
+
if (!this._paused) {
|
|
810
|
+
this._checkHealth();
|
|
811
|
+
}
|
|
812
|
+
}, this.checkIntervalMs);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Stop the health monitor.
|
|
816
|
+
*/
|
|
817
|
+
stop() {
|
|
818
|
+
if (!this._running) return;
|
|
819
|
+
this.logger.info("[HealthMonitor] Stopping");
|
|
820
|
+
this._running = false;
|
|
821
|
+
if (this._intervalId) {
|
|
822
|
+
clearInterval(this._intervalId);
|
|
823
|
+
this._intervalId = null;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Pause health checks temporarily.
|
|
828
|
+
*/
|
|
829
|
+
pause() {
|
|
830
|
+
this._paused = true;
|
|
831
|
+
this._updateHealth({
|
|
832
|
+
...this._health,
|
|
833
|
+
status: "disconnected"
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Resume health checks.
|
|
838
|
+
*/
|
|
839
|
+
resume() {
|
|
840
|
+
this._paused = false;
|
|
841
|
+
this._checkHealth();
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Dispose the monitor and clear all resources.
|
|
845
|
+
*/
|
|
846
|
+
dispose() {
|
|
847
|
+
this.stop();
|
|
848
|
+
this._listeners.clear();
|
|
849
|
+
}
|
|
850
|
+
// ─── Getters ───────────────────────────────────────────────────────────────
|
|
851
|
+
/**
|
|
852
|
+
* Get current connection health.
|
|
853
|
+
*/
|
|
854
|
+
getHealth() {
|
|
855
|
+
return { ...this._health };
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Check if the monitor is running.
|
|
859
|
+
*/
|
|
860
|
+
isRunning() {
|
|
861
|
+
return this._running;
|
|
862
|
+
}
|
|
863
|
+
// ─── Manual Checks ─────────────────────────────────────────────────────────
|
|
864
|
+
/**
|
|
865
|
+
* Perform an immediate health check.
|
|
866
|
+
* @returns The result of the health check
|
|
867
|
+
*/
|
|
868
|
+
async checkNow() {
|
|
869
|
+
return this._checkHealth();
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Record a reconnection attempt.
|
|
873
|
+
*/
|
|
874
|
+
recordReconnectAttempt() {
|
|
875
|
+
this._updateHealth({
|
|
876
|
+
...this._health,
|
|
877
|
+
reconnectAttempts: this._health.reconnectAttempts + 1
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Reset reconnection attempts counter (call on successful connection).
|
|
882
|
+
*/
|
|
883
|
+
resetReconnectAttempts() {
|
|
884
|
+
if (this._health.reconnectAttempts > 0) {
|
|
885
|
+
this._updateHealth({
|
|
886
|
+
...this._health,
|
|
887
|
+
reconnectAttempts: 0
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// ─── Subscriptions ─────────────────────────────────────────────────────────
|
|
892
|
+
/**
|
|
893
|
+
* Subscribe to health changes.
|
|
894
|
+
* @returns Unsubscribe function
|
|
895
|
+
*/
|
|
896
|
+
onHealthUpdate(listener) {
|
|
897
|
+
this._listeners.add(listener);
|
|
898
|
+
listener(this.getHealth());
|
|
899
|
+
return () => {
|
|
900
|
+
this._listeners.delete(listener);
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
// ─── Private Methods ───────────────────────────────────────────────────────
|
|
904
|
+
async _checkHealth() {
|
|
905
|
+
if (!this._db || this._paused) {
|
|
906
|
+
return {
|
|
907
|
+
success: false,
|
|
908
|
+
error: new Error("Database not available or paused"),
|
|
909
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
const startTime = Date.now();
|
|
913
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
914
|
+
try {
|
|
915
|
+
await this._withTimeout(
|
|
916
|
+
this._db.get("SELECT 1"),
|
|
917
|
+
this.checkTimeoutMs
|
|
918
|
+
);
|
|
919
|
+
const latencyMs = Date.now() - startTime;
|
|
920
|
+
const status = latencyMs < this.degradedThresholdMs ? "healthy" : "degraded";
|
|
921
|
+
this._updateHealth({
|
|
922
|
+
status,
|
|
923
|
+
latency: latencyMs,
|
|
924
|
+
lastHealthCheck: timestamp,
|
|
925
|
+
consecutiveFailures: 0,
|
|
926
|
+
reconnectAttempts: this._health.reconnectAttempts
|
|
927
|
+
});
|
|
928
|
+
return {
|
|
929
|
+
success: true,
|
|
930
|
+
latencyMs,
|
|
931
|
+
timestamp
|
|
932
|
+
};
|
|
933
|
+
} catch (err) {
|
|
934
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
935
|
+
this.logger.warn("[HealthMonitor] Health check failed:", error.message);
|
|
936
|
+
const consecutiveFailures = this._health.consecutiveFailures + 1;
|
|
937
|
+
const status = consecutiveFailures >= this.maxConsecutiveFailures ? "disconnected" : "degraded";
|
|
938
|
+
this._updateHealth({
|
|
939
|
+
status,
|
|
940
|
+
latency: null,
|
|
941
|
+
lastHealthCheck: timestamp,
|
|
942
|
+
consecutiveFailures,
|
|
943
|
+
reconnectAttempts: this._health.reconnectAttempts
|
|
944
|
+
});
|
|
945
|
+
return {
|
|
946
|
+
success: false,
|
|
947
|
+
error,
|
|
948
|
+
timestamp
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
_updateHealth(health) {
|
|
953
|
+
const changed = this._hasHealthChanged(health);
|
|
954
|
+
this._health = health;
|
|
955
|
+
if (changed) {
|
|
956
|
+
this._notifyListeners();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
_hasHealthChanged(newHealth) {
|
|
960
|
+
const old = this._health;
|
|
961
|
+
return old.status !== newHealth.status || old.latency !== newHealth.latency || old.consecutiveFailures !== newHealth.consecutiveFailures || old.reconnectAttempts !== newHealth.reconnectAttempts;
|
|
962
|
+
}
|
|
963
|
+
_notifyListeners() {
|
|
964
|
+
const health = this.getHealth();
|
|
965
|
+
this.onHealthChange?.(health);
|
|
966
|
+
for (const listener of this._listeners) {
|
|
967
|
+
try {
|
|
968
|
+
listener(health);
|
|
969
|
+
} catch (err) {
|
|
970
|
+
this.logger.warn("[HealthMonitor] Listener error:", err);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
_withTimeout(promise, timeoutMs) {
|
|
975
|
+
return new Promise((resolve, reject) => {
|
|
976
|
+
const timer = setTimeout(() => {
|
|
977
|
+
reject(new Error(`Health check timeout after ${timeoutMs}ms`));
|
|
978
|
+
}, timeoutMs);
|
|
979
|
+
promise.then(
|
|
980
|
+
(result) => {
|
|
981
|
+
clearTimeout(timer);
|
|
982
|
+
resolve(result);
|
|
983
|
+
},
|
|
984
|
+
(error) => {
|
|
985
|
+
clearTimeout(timer);
|
|
986
|
+
reject(error);
|
|
987
|
+
}
|
|
988
|
+
);
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
export {
|
|
994
|
+
DEFAULT_SYNC_STATUS,
|
|
995
|
+
DEFAULT_CONNECTION_HEALTH,
|
|
996
|
+
DEFAULT_SYNC_METRICS,
|
|
997
|
+
DEFAULT_SYNC_CONFIG,
|
|
998
|
+
SyncStatusTracker,
|
|
999
|
+
MetricsCollector,
|
|
1000
|
+
HealthMonitor
|
|
1001
|
+
};
|
|
1002
|
+
//# sourceMappingURL=chunk-CFCK2LHI.js.map
|