@glance-apps/sync 0.5.0 → 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/package.json +1 -1
- package/src/engine.js +529 -1
- package/src/index.js +6 -0
- package/types/index.d.ts +328 -3
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -1 +1,529 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Sync engine for @glance-apps/sync.
|
|
2
|
+
//
|
|
3
|
+
// Orchestrates the download → validate → merge → apply → upload cycle that
|
|
4
|
+
// previously lived inline in dayGLANCE's App.jsx. The engine is app-agnostic
|
|
5
|
+
// — every data-shape decision is delegated to four required callbacks:
|
|
6
|
+
// buildPayload, buildBackupPayload, applyPayload, mergePayloads.
|
|
7
|
+
//
|
|
8
|
+
// Behavior preservation contract (vs. dayGLANCE pre-extraction):
|
|
9
|
+
// - 5-second debounce stays in the caller (the engine does not debounce).
|
|
10
|
+
// - 60-second poll stays in the caller; it calls engine.download().
|
|
11
|
+
// - Visibility/focus listeners stay in the caller.
|
|
12
|
+
// - Status sequence: emits 'uploading' | 'downloading' | 'success' | 'error' | 'idle'.
|
|
13
|
+
// Auto-reverts 'success' → 'idle' after 3 s and 'error' → 'idle' after 5 s
|
|
14
|
+
// unconditionally; the caller's onStatusChange handler is responsible for
|
|
15
|
+
// guarding against overwriting a newer status (the dayGLANCE adapter does
|
|
16
|
+
// this with a functional setState).
|
|
17
|
+
// - Two-second minimum status hold on success (matches the original UX).
|
|
18
|
+
// - Hard-stop error codes (APP_ID_MISMATCH, SCHEMA_FORWARD_INCOMPATIBLE) freeze
|
|
19
|
+
// the engine — auto-retry paths are inert until clearHardStop() is called.
|
|
20
|
+
// - Backwards compat: envelopes missing `schemaVersion` are treated as v1,
|
|
21
|
+
// envelopes missing `appId` skip the identity check (legacy dayGLANCE files
|
|
22
|
+
// written before Step 6 don't include either field).
|
|
23
|
+
|
|
24
|
+
import { createProviders, webdavFetch } from './providers.js';
|
|
25
|
+
import {
|
|
26
|
+
hasEncryptionReady,
|
|
27
|
+
} from './crypto.js';
|
|
28
|
+
import {
|
|
29
|
+
createAutoBackupDB,
|
|
30
|
+
createAutoBackupProviders,
|
|
31
|
+
AUTO_BACKUP_RETENTION,
|
|
32
|
+
AUTO_BACKUP_INTERVALS,
|
|
33
|
+
} from './autoBackup.js';
|
|
34
|
+
|
|
35
|
+
// Package-level schema version written into every envelope. Bump alongside
|
|
36
|
+
// any breaking change to the envelope shape; consumers older than this will
|
|
37
|
+
// hard-stop with SCHEMA_FORWARD_INCOMPATIBLE.
|
|
38
|
+
export const SCHEMA_VERSION = 1;
|
|
39
|
+
export const SUPPORTED_MAX_SCHEMA_VERSION = 1;
|
|
40
|
+
|
|
41
|
+
// Envelope's `version` field. Currently a constant (matches dayGLANCE pre-
|
|
42
|
+
// extraction); reserved as a monotonic counter for a future major version.
|
|
43
|
+
const ENVELOPE_DATA_VERSION = 2;
|
|
44
|
+
|
|
45
|
+
const MAX_UPLOAD_BACKOFF_S = 15 * 60; // 15 minutes
|
|
46
|
+
const MAX_DOWNLOAD_BACKOFF_S = 5 * 60; // 5 minutes
|
|
47
|
+
const AUTH_FAILURE_BACKOFF_MS = 60 * 60 * 1000; // 1 hour
|
|
48
|
+
const LOCK_BACKOFF_MS = 30_000; // 30 s for 423 Locked
|
|
49
|
+
const SUCCESS_HOLD_MS = 3000;
|
|
50
|
+
const ERROR_HOLD_MS = 5000;
|
|
51
|
+
const MIN_SYNC_DURATION_MS = 2000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a sync engine bound to the given app/transport/data config.
|
|
55
|
+
* See SYNC_PACKAGE_SPEC.md (`createSyncEngine(config)` section) for the full
|
|
56
|
+
* config schema and behavioral contract.
|
|
57
|
+
*/
|
|
58
|
+
export const createSyncEngine = (config) => {
|
|
59
|
+
const {
|
|
60
|
+
storageKeyPrefix,
|
|
61
|
+
appId,
|
|
62
|
+
appName,
|
|
63
|
+
buildPayload,
|
|
64
|
+
buildBackupPayload,
|
|
65
|
+
applyPayload,
|
|
66
|
+
mergePayloads,
|
|
67
|
+
validateUploadPayload,
|
|
68
|
+
validateApplyPayload,
|
|
69
|
+
onStatusChange,
|
|
70
|
+
onError,
|
|
71
|
+
onLastSyncedChange,
|
|
72
|
+
onConflict,
|
|
73
|
+
onPassphraseRequired,
|
|
74
|
+
onFirstSyncReload,
|
|
75
|
+
} = config;
|
|
76
|
+
|
|
77
|
+
if (!storageKeyPrefix) throw new Error('createSyncEngine: storageKeyPrefix is required');
|
|
78
|
+
if (!appId) throw new Error('createSyncEngine: appId is required');
|
|
79
|
+
if (typeof buildPayload !== 'function') throw new Error('createSyncEngine: buildPayload is required');
|
|
80
|
+
if (typeof applyPayload !== 'function') throw new Error('createSyncEngine: applyPayload is required');
|
|
81
|
+
if (typeof mergePayloads !== 'function') throw new Error('createSyncEngine: mergePayloads is required');
|
|
82
|
+
|
|
83
|
+
// Resolve sub-modules with the shared config so they share crypto/proxy wiring.
|
|
84
|
+
const providers = createProviders(config);
|
|
85
|
+
const wf = webdavFetch(config);
|
|
86
|
+
const autoBackupDB = createAutoBackupDB({ autoBackupDBName: config.autoBackupDBName });
|
|
87
|
+
const autoBackupProviders = createAutoBackupProviders({
|
|
88
|
+
backupFilenamePrefix: config.backupFilenamePrefix,
|
|
89
|
+
appFolderName: config.appFolderName,
|
|
90
|
+
webdavFetch: wf,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// localStorage keys owned by this engine.
|
|
94
|
+
const KEY_CONFIG = `${storageKeyPrefix}-cloud-sync-config`;
|
|
95
|
+
const KEY_LAST_SYNCED = `${storageKeyPrefix}-cloud-sync-last-synced`;
|
|
96
|
+
const KEY_LOCAL_MOD = `${storageKeyPrefix}-cloud-sync-local-modified`;
|
|
97
|
+
|
|
98
|
+
// ── In-memory state ─────────────────────────────────────────────────────
|
|
99
|
+
let syncing = false;
|
|
100
|
+
let pendingFollowup = false;
|
|
101
|
+
let hardStopped = false;
|
|
102
|
+
let uploadErrorCount = 0;
|
|
103
|
+
let uploadBackoffUntil = 0;
|
|
104
|
+
let downloadErrorCount = 0;
|
|
105
|
+
let downloadBackoffUntil = 0;
|
|
106
|
+
|
|
107
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
108
|
+
const getConfig = () => {
|
|
109
|
+
const saved = localStorage.getItem(KEY_CONFIG);
|
|
110
|
+
return saved ? JSON.parse(saved) : null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const setConfig = (cfg) => {
|
|
114
|
+
if (cfg) localStorage.setItem(KEY_CONFIG, JSON.stringify(cfg));
|
|
115
|
+
else localStorage.removeItem(KEY_CONFIG);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const getLastSynced = () => localStorage.getItem(KEY_LAST_SYNCED) || null;
|
|
119
|
+
|
|
120
|
+
// Wraps a `{ data, lastModified? }` object with the envelope metadata required
|
|
121
|
+
// by the package contract. Accepts either a bare data object or a pre-wrapped
|
|
122
|
+
// envelope-like object (in which case existing lastModified is preserved).
|
|
123
|
+
const wrapEnvelope = (built) => {
|
|
124
|
+
const isWrapped = built && typeof built === 'object' && 'data' in built;
|
|
125
|
+
const data = isWrapped ? built.data : built;
|
|
126
|
+
const lastModified = (isWrapped && built.lastModified) || new Date().toISOString();
|
|
127
|
+
return {
|
|
128
|
+
schemaVersion: SCHEMA_VERSION,
|
|
129
|
+
appId,
|
|
130
|
+
version: (isWrapped && typeof built.version === 'number') ? built.version : ENVELOPE_DATA_VERSION,
|
|
131
|
+
lastModified,
|
|
132
|
+
data,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Validates a downloaded envelope. Returns the validated envelope or throws
|
|
137
|
+
// a hard-stop Error. Backwards-compat: missing schemaVersion/appId are
|
|
138
|
+
// accepted (legacy files written before Step 6).
|
|
139
|
+
const validateEnvelope = (envelope) => {
|
|
140
|
+
if (!envelope || typeof envelope !== 'object' || !envelope.data) {
|
|
141
|
+
const err = new Error('Downloaded sync file is malformed (missing data field).');
|
|
142
|
+
err.code = 'NETWORK_ERROR';
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
if (envelope.schemaVersion !== undefined && envelope.schemaVersion > SUPPORTED_MAX_SCHEMA_VERSION) {
|
|
146
|
+
const err = new Error(
|
|
147
|
+
`Remote sync file uses schema version ${envelope.schemaVersion}, ` +
|
|
148
|
+
`which is newer than this version of ${appName || appId} supports. Please update the app.`
|
|
149
|
+
);
|
|
150
|
+
err.code = 'SCHEMA_FORWARD_INCOMPATIBLE';
|
|
151
|
+
err.isHardStop = true;
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
if (envelope.appId !== undefined && envelope.appId !== appId) {
|
|
155
|
+
const err = new Error(
|
|
156
|
+
`Remote sync file belongs to "${envelope.appId}", not ${appName || appId}. ` +
|
|
157
|
+
`Refusing to merge to prevent data loss.`
|
|
158
|
+
);
|
|
159
|
+
err.code = 'APP_ID_MISMATCH';
|
|
160
|
+
err.isHardStop = true;
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
return envelope;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Categorises a raw error into { code, isHardStop, message }.
|
|
167
|
+
const classifyError = (err) => {
|
|
168
|
+
if (err?.code === 'PASSPHRASE_REQUIRED') {
|
|
169
|
+
return { code: 'PASSPHRASE_REQUIRED', isHardStop: false, message: err.message };
|
|
170
|
+
}
|
|
171
|
+
if (err?.code === 'APP_ID_MISMATCH' || err?.code === 'SCHEMA_FORWARD_INCOMPATIBLE') {
|
|
172
|
+
return { code: err.code, isHardStop: true, message: err.message };
|
|
173
|
+
}
|
|
174
|
+
const msg = err?.message || String(err);
|
|
175
|
+
if (msg === 'PRECONDITION_FAILED') return { code: 'PRECONDITION_FAILED', isHardStop: false, message: msg };
|
|
176
|
+
if (msg === 'FORBIDDEN') return { code: 'FORBIDDEN', isHardStop: true, message: msg };
|
|
177
|
+
if (msg.includes('401')) return { code: 'AUTH_FAILURE', isHardStop: false, message: msg };
|
|
178
|
+
if (msg.includes('423')) return { code: 'LOCKED', isHardStop: false, message: msg };
|
|
179
|
+
return { code: 'NETWORK_ERROR', isHardStop: false, message: msg };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Renders a user-facing error string preserving the exact wording used by
|
|
183
|
+
// dayGLANCE pre-extraction so the UX is unchanged.
|
|
184
|
+
const formatErrorMessage = (cls) => {
|
|
185
|
+
if (cls.code === 'AUTH_FAILURE') {
|
|
186
|
+
return 'Authentication failed (401) — check your username and password in sync settings.';
|
|
187
|
+
}
|
|
188
|
+
if (cls.code === 'FORBIDDEN') {
|
|
189
|
+
// Upload and download had slightly different wording pre-extraction. We
|
|
190
|
+
// unify on the upload variant (it's the one users see most often).
|
|
191
|
+
return "Sync blocked (403) — your server may be blocking Vercel's IP addresses.";
|
|
192
|
+
}
|
|
193
|
+
return cls.message;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const scheduleAutoRevert = (status, ms) => {
|
|
197
|
+
setTimeout(() => onStatusChange?.('idle', { from: status }), ms);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// ── upload ──────────────────────────────────────────────────────────────
|
|
201
|
+
/**
|
|
202
|
+
* Uploads the current local state to the configured provider.
|
|
203
|
+
* @param {object} [opts]
|
|
204
|
+
* @param {object} [opts.prebuiltPayload] - Skip buildPayload(), use this instead.
|
|
205
|
+
* @param {string} [opts.etag] - If-Match etag for optimistic concurrency.
|
|
206
|
+
* @param {boolean} [opts.skipLockCheck] - Bypass the in-progress guard (used
|
|
207
|
+
* when called from within download()'s merge cycle to avoid self-deadlock).
|
|
208
|
+
*/
|
|
209
|
+
const upload = async ({ prebuiltPayload, etag = null, skipLockCheck = false } = {}) => {
|
|
210
|
+
if (hardStopped) return;
|
|
211
|
+
const cfg = getConfig();
|
|
212
|
+
if (!cfg?.enabled) return;
|
|
213
|
+
if (!skipLockCheck && syncing) {
|
|
214
|
+
pendingFollowup = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const provider = providers[cfg.provider];
|
|
218
|
+
if (!provider) return;
|
|
219
|
+
|
|
220
|
+
if (!skipLockCheck) syncing = true;
|
|
221
|
+
const t0 = Date.now();
|
|
222
|
+
onStatusChange?.('uploading');
|
|
223
|
+
onError?.(null, null, false); // clear previous error
|
|
224
|
+
try {
|
|
225
|
+
const built = prebuiltPayload || await buildPayload();
|
|
226
|
+
const envelope = wrapEnvelope(built);
|
|
227
|
+
|
|
228
|
+
if (validateUploadPayload) {
|
|
229
|
+
const check = await validateUploadPayload(envelope);
|
|
230
|
+
if (!check?.valid) {
|
|
231
|
+
// eslint-disable-next-line no-console
|
|
232
|
+
console.error(`[${appId}] cloud sync upload aborted:`, check?.reason || 'validation failed');
|
|
233
|
+
onStatusChange?.('idle');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await provider.upload(cfg, envelope, etag);
|
|
239
|
+
|
|
240
|
+
const elapsed = Date.now() - t0;
|
|
241
|
+
if (elapsed < MIN_SYNC_DURATION_MS) {
|
|
242
|
+
await new Promise(r => setTimeout(r, MIN_SYNC_DURATION_MS - elapsed));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
uploadErrorCount = 0;
|
|
246
|
+
uploadBackoffUntil = 0;
|
|
247
|
+
const now = new Date().toISOString();
|
|
248
|
+
localStorage.setItem(KEY_LAST_SYNCED, now);
|
|
249
|
+
localStorage.setItem(KEY_LOCAL_MOD, envelope.lastModified);
|
|
250
|
+
onLastSyncedChange?.(now);
|
|
251
|
+
onStatusChange?.('success');
|
|
252
|
+
scheduleAutoRevert('success', SUCCESS_HOLD_MS);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
// eslint-disable-next-line no-console
|
|
255
|
+
console.error(`[${appId}] cloud sync upload error:`, err);
|
|
256
|
+
const cls = classifyError(err);
|
|
257
|
+
|
|
258
|
+
if (cls.code === 'PRECONDITION_FAILED') {
|
|
259
|
+
// When download() is the caller, it handles the 412 retry itself. Bubble.
|
|
260
|
+
if (skipLockCheck) throw err;
|
|
261
|
+
onStatusChange?.('idle');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (cls.code === 'AUTH_FAILURE') {
|
|
266
|
+
uploadBackoffUntil = Date.now() + AUTH_FAILURE_BACKOFF_MS;
|
|
267
|
+
} else {
|
|
268
|
+
uploadErrorCount += 1;
|
|
269
|
+
const ms = Math.min(30 * Math.pow(2, uploadErrorCount - 1), MAX_UPLOAD_BACKOFF_S) * 1000;
|
|
270
|
+
uploadBackoffUntil = Date.now() + ms;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (cls.isHardStop) hardStopped = true;
|
|
274
|
+
onError?.(formatErrorMessage(cls), cls.code, cls.isHardStop);
|
|
275
|
+
onStatusChange?.('error');
|
|
276
|
+
scheduleAutoRevert('error', ERROR_HOLD_MS);
|
|
277
|
+
} finally {
|
|
278
|
+
if (!skipLockCheck) syncing = false;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// ── download ────────────────────────────────────────────────────────────
|
|
283
|
+
/**
|
|
284
|
+
* Runs a full sync cycle: download → validate → merge → apply → upload-if-changed.
|
|
285
|
+
* Idempotent — if another sync is in progress, sets a follow-up flag so the
|
|
286
|
+
* current cycle reruns at completion to pick up the queued local change.
|
|
287
|
+
*/
|
|
288
|
+
const download = async () => {
|
|
289
|
+
if (hardStopped) return;
|
|
290
|
+
const cfg = getConfig();
|
|
291
|
+
if (!cfg?.enabled) return;
|
|
292
|
+
const provider = providers[cfg.provider];
|
|
293
|
+
if (!provider) return;
|
|
294
|
+
|
|
295
|
+
if (syncing) {
|
|
296
|
+
pendingFollowup = true;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
syncing = true;
|
|
300
|
+
const t0 = Date.now();
|
|
301
|
+
onStatusChange?.('downloading');
|
|
302
|
+
onError?.(null, null, false);
|
|
303
|
+
|
|
304
|
+
let conflictShown = false;
|
|
305
|
+
let passphraseRequired = false;
|
|
306
|
+
|
|
307
|
+
// Inner helper: download, merge, apply, upload (if anything changed).
|
|
308
|
+
// Returns true if a reload was triggered (caller should bail).
|
|
309
|
+
const doCycle = async (overrideEtag) => {
|
|
310
|
+
const downloaded = await provider.download(cfg);
|
|
311
|
+
if (!downloaded) {
|
|
312
|
+
// Empty remote — seed it with local state.
|
|
313
|
+
await upload({ skipLockCheck: true });
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
const { payload: remoteRaw, etag } = downloaded;
|
|
317
|
+
|
|
318
|
+
// Validate envelope; hard-stop errors halt the sync without uploading.
|
|
319
|
+
let remote;
|
|
320
|
+
try {
|
|
321
|
+
remote = validateEnvelope(remoteRaw);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (err.isHardStop) {
|
|
324
|
+
hardStopped = true;
|
|
325
|
+
onError?.(err.message, err.code, true);
|
|
326
|
+
onStatusChange?.('error');
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const hasNeverSynced = !localStorage.getItem(KEY_LAST_SYNCED);
|
|
333
|
+
if (hasNeverSynced && remote.lastModified) {
|
|
334
|
+
// First-sync conflict: don't auto-merge; let the user decide via UI.
|
|
335
|
+
conflictShown = true;
|
|
336
|
+
onConflict?.(remote.data, remote.lastModified, etag || null);
|
|
337
|
+
onStatusChange?.('idle');
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const localBuilt = await buildPayload();
|
|
342
|
+
const localData = (localBuilt && typeof localBuilt === 'object' && 'data' in localBuilt)
|
|
343
|
+
? localBuilt.data
|
|
344
|
+
: localBuilt;
|
|
345
|
+
|
|
346
|
+
const mergeResult = mergePayloads(localData, remote.data);
|
|
347
|
+
const { data: mergedData, localChanged, remoteChanged } = mergeResult;
|
|
348
|
+
|
|
349
|
+
if (localChanged) {
|
|
350
|
+
let mayApply = true;
|
|
351
|
+
if (validateApplyPayload) {
|
|
352
|
+
const check = await validateApplyPayload({ ...remote, data: mergedData });
|
|
353
|
+
if (!check?.valid) {
|
|
354
|
+
// eslint-disable-next-line no-console
|
|
355
|
+
console.error(`[${appId}] applyRemoteData aborted:`, check?.reason || 'validation failed');
|
|
356
|
+
mayApply = false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (mayApply) {
|
|
360
|
+
await applyPayload(mergedData, { allowEmpty: !!remote.lastModified });
|
|
361
|
+
localStorage.setItem(KEY_LOCAL_MOD, new Date().toISOString());
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (remoteChanged || localChanged) {
|
|
366
|
+
const mergedPayload = {
|
|
367
|
+
version: ENVELOPE_DATA_VERSION,
|
|
368
|
+
lastModified: new Date().toISOString(),
|
|
369
|
+
data: mergedData,
|
|
370
|
+
};
|
|
371
|
+
await upload({
|
|
372
|
+
prebuiltPayload: mergedPayload,
|
|
373
|
+
skipLockCheck: true,
|
|
374
|
+
etag: etag || overrideEtag || null,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
if (hasNeverSynced && localChanged) {
|
|
378
|
+
onFirstSyncReload?.();
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const reloaded = await doCycle(null);
|
|
387
|
+
if (reloaded || conflictShown) return;
|
|
388
|
+
|
|
389
|
+
downloadErrorCount = 0;
|
|
390
|
+
downloadBackoffUntil = 0;
|
|
391
|
+
const elapsed = Date.now() - t0;
|
|
392
|
+
if (elapsed < MIN_SYNC_DURATION_MS) {
|
|
393
|
+
await new Promise(r => setTimeout(r, MIN_SYNC_DURATION_MS - elapsed));
|
|
394
|
+
}
|
|
395
|
+
const now = new Date().toISOString();
|
|
396
|
+
localStorage.setItem(KEY_LAST_SYNCED, now);
|
|
397
|
+
onLastSyncedChange?.(now);
|
|
398
|
+
onStatusChange?.('success');
|
|
399
|
+
scheduleAutoRevert('success', SUCCESS_HOLD_MS);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
// eslint-disable-next-line no-console
|
|
402
|
+
console.error(`[${appId}] cloud sync download error:`, err);
|
|
403
|
+
const cls = classifyError(err);
|
|
404
|
+
|
|
405
|
+
if (cls.code === 'PASSPHRASE_REQUIRED') {
|
|
406
|
+
passphraseRequired = true;
|
|
407
|
+
onPassphraseRequired?.();
|
|
408
|
+
onError?.(cls.message, 'PASSPHRASE_REQUIRED', false);
|
|
409
|
+
onStatusChange?.('idle');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (cls.code === 'PRECONDITION_FAILED') {
|
|
414
|
+
// Another device wrote between our download and upload — retry once.
|
|
415
|
+
try {
|
|
416
|
+
await doCycle(null);
|
|
417
|
+
downloadErrorCount = 0;
|
|
418
|
+
downloadBackoffUntil = 0;
|
|
419
|
+
} catch (retryErr) {
|
|
420
|
+
// eslint-disable-next-line no-console
|
|
421
|
+
console.error(`[${appId}] cloud sync retry after 412 failed:`, retryErr);
|
|
422
|
+
}
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (cls.code === 'AUTH_FAILURE') {
|
|
427
|
+
downloadBackoffUntil = Date.now() + AUTH_FAILURE_BACKOFF_MS;
|
|
428
|
+
} else if (cls.code === 'LOCKED') {
|
|
429
|
+
downloadBackoffUntil = Date.now() + LOCK_BACKOFF_MS;
|
|
430
|
+
} else {
|
|
431
|
+
downloadErrorCount += 1;
|
|
432
|
+
const ms = Math.min(30 * Math.pow(2, downloadErrorCount - 1), MAX_DOWNLOAD_BACKOFF_S) * 1000;
|
|
433
|
+
downloadBackoffUntil = Date.now() + ms;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (cls.isHardStop) hardStopped = true;
|
|
437
|
+
// Use the download variant of the 403 message for compatibility.
|
|
438
|
+
const msg = cls.code === 'FORBIDDEN'
|
|
439
|
+
? 'Sync blocked (403) — your server may be blocking requests.'
|
|
440
|
+
: formatErrorMessage(cls);
|
|
441
|
+
onError?.(msg, cls.code, cls.isHardStop);
|
|
442
|
+
onStatusChange?.('error');
|
|
443
|
+
scheduleAutoRevert('error', ERROR_HOLD_MS);
|
|
444
|
+
} finally {
|
|
445
|
+
// Conflict path: leave lock held — the UI dialog handlers release it via
|
|
446
|
+
// resolveConflict*(). Passphrase path: release lock but don't mark sync
|
|
447
|
+
// initialised. Otherwise: release lock and drain the follow-up queue.
|
|
448
|
+
if (!conflictShown) {
|
|
449
|
+
syncing = false;
|
|
450
|
+
if (!passphraseRequired && pendingFollowup) {
|
|
451
|
+
pendingFollowup = false;
|
|
452
|
+
setTimeout(() => download(), 250);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// ── Backup ──────────────────────────────────────────────────────────────
|
|
459
|
+
/**
|
|
460
|
+
* Runs one auto-backup cycle: builds the backup payload, saves to local IDB,
|
|
461
|
+
* uploads to the remote backup directory, and prunes old copies. Independent
|
|
462
|
+
* of sync — uses buildBackupPayload (NOT buildPayload) since it may be called
|
|
463
|
+
* from a timer without React context.
|
|
464
|
+
*/
|
|
465
|
+
const runBackup = async (frequency) => {
|
|
466
|
+
if (!buildBackupPayload) {
|
|
467
|
+
throw new Error('createSyncEngine: buildBackupPayload is required to call runBackup()');
|
|
468
|
+
}
|
|
469
|
+
const cfg = getConfig();
|
|
470
|
+
if (!cfg?.enabled) return;
|
|
471
|
+
const backupProvider = autoBackupProviders[cfg.provider] || autoBackupProviders.webdav;
|
|
472
|
+
if (!backupProvider) return;
|
|
473
|
+
|
|
474
|
+
const data = await buildBackupPayload();
|
|
475
|
+
await autoBackupDB.saveBackup(frequency, data);
|
|
476
|
+
const max = AUTO_BACKUP_RETENTION[frequency];
|
|
477
|
+
if (max) await autoBackupDB.pruneBackups(frequency, max);
|
|
478
|
+
try {
|
|
479
|
+
await backupProvider.uploadBackup(cfg, data);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
// eslint-disable-next-line no-console
|
|
482
|
+
console.error(`[${appId}] auto-backup upload error:`, err);
|
|
483
|
+
onError?.(err.message, classifyError(err).code, false);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// ── Connection test ─────────────────────────────────────────────────────
|
|
488
|
+
const testConnection = async (testCfg) => {
|
|
489
|
+
const provider = providers[testCfg.provider];
|
|
490
|
+
if (!provider) return { success: false, error: 'Unknown provider' };
|
|
491
|
+
try { return await provider.test(testCfg); }
|
|
492
|
+
catch (err) { return { success: false, error: err.message }; }
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
496
|
+
return {
|
|
497
|
+
// Lifecycle
|
|
498
|
+
sync: download,
|
|
499
|
+
upload,
|
|
500
|
+
download,
|
|
501
|
+
runBackup,
|
|
502
|
+
test: testConnection,
|
|
503
|
+
|
|
504
|
+
// Config
|
|
505
|
+
getConfig,
|
|
506
|
+
setConfig,
|
|
507
|
+
getLastSynced,
|
|
508
|
+
|
|
509
|
+
// State queries
|
|
510
|
+
isSyncing: () => syncing,
|
|
511
|
+
isHardStopped: () => hardStopped,
|
|
512
|
+
clearHardStop: () => { hardStopped = false; },
|
|
513
|
+
hasEncryptionReady,
|
|
514
|
+
getUploadBackoffUntil: () => uploadBackoffUntil,
|
|
515
|
+
getDownloadBackoffUntil: () => downloadBackoffUntil,
|
|
516
|
+
|
|
517
|
+
// Sub-modules (exposed for app-level features the engine doesn't own —
|
|
518
|
+
// e.g. dayGLANCE's conflict-resolution dialog and its iCloud sync code).
|
|
519
|
+
providers,
|
|
520
|
+
autoBackupDB,
|
|
521
|
+
autoBackupProviders,
|
|
522
|
+
webdavFetch: wf,
|
|
523
|
+
};
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
export {
|
|
527
|
+
AUTO_BACKUP_RETENTION,
|
|
528
|
+
AUTO_BACKUP_INTERVALS,
|
|
529
|
+
};
|
package/src/index.js
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -1,5 +1,330 @@
|
|
|
1
1
|
// Type declarations for @glance-apps/sync
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
//
|
|
3
|
+
// Covers the full public API exported from src/index.js. The JSDoc on the
|
|
4
|
+
// source files is the source of truth for behavior; these declarations only
|
|
5
|
+
// express the shape of values crossing the package boundary.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Error codes
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export type SyncErrorCode =
|
|
12
|
+
| 'APP_ID_MISMATCH'
|
|
13
|
+
| 'SCHEMA_FORWARD_INCOMPATIBLE'
|
|
14
|
+
| 'PASSPHRASE_REQUIRED'
|
|
15
|
+
| 'PRECONDITION_FAILED'
|
|
16
|
+
| 'FORBIDDEN'
|
|
17
|
+
| 'AUTH_FAILURE'
|
|
18
|
+
| 'LOCKED'
|
|
19
|
+
| 'NETWORK_ERROR';
|
|
20
|
+
|
|
21
|
+
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Envelope shape
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface SyncEnvelope<TData = unknown> {
|
|
28
|
+
schemaVersion: number;
|
|
29
|
+
appId: string;
|
|
30
|
+
version: number;
|
|
31
|
+
lastModified: string; // ISO 8601
|
|
32
|
+
data: TData;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EncryptedEnvelope {
|
|
36
|
+
v: 1;
|
|
37
|
+
enc: 'AES-GCM-256';
|
|
38
|
+
data: string; // base64
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Transport bridges (all optional; engine selects the first non-null tier)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export interface NativeHttpResponse {
|
|
46
|
+
status: number;
|
|
47
|
+
ok: boolean;
|
|
48
|
+
body: string;
|
|
49
|
+
headers?: { etag?: string };
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type NativeHttpRequest = (
|
|
54
|
+
method: string,
|
|
55
|
+
url: string,
|
|
56
|
+
headers: Record<string, string>,
|
|
57
|
+
body: string | null,
|
|
58
|
+
) => NativeHttpResponse | null;
|
|
59
|
+
|
|
60
|
+
export type ElectronProxyFetch = (
|
|
61
|
+
method: string,
|
|
62
|
+
url: string,
|
|
63
|
+
headers: Record<string, string>,
|
|
64
|
+
body: string | null,
|
|
65
|
+
) => Promise<{
|
|
66
|
+
status: number;
|
|
67
|
+
ok: boolean;
|
|
68
|
+
statusText: string;
|
|
69
|
+
body: string;
|
|
70
|
+
headers?: { etag?: string };
|
|
71
|
+
}>;
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Merge engine
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export interface MergeResult<T> {
|
|
78
|
+
merged: T[];
|
|
79
|
+
localChanged: boolean;
|
|
80
|
+
remoteChanged: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface MergeArrayOptions {
|
|
84
|
+
idField?: string;
|
|
85
|
+
timestampField?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mergeArrayById<T extends Record<string, unknown>>(
|
|
89
|
+
localItems: T[],
|
|
90
|
+
remoteItems: T[],
|
|
91
|
+
deletedIds: Record<string, string>,
|
|
92
|
+
syncHorizon?: Date | null,
|
|
93
|
+
options?: MergeArrayOptions,
|
|
94
|
+
): MergeResult<T>;
|
|
95
|
+
|
|
96
|
+
export function mergeDailyNotes<T>(
|
|
97
|
+
local: Record<string, T>,
|
|
98
|
+
remote: Record<string, T>,
|
|
99
|
+
): { merged: Record<string, T>; localChanged: boolean; remoteChanged: boolean };
|
|
100
|
+
|
|
101
|
+
export function mergeHabits<T extends Record<string, unknown>>(
|
|
102
|
+
localHabits: T[],
|
|
103
|
+
remoteHabits: T[],
|
|
104
|
+
localDeletedIds?: Record<string, string>,
|
|
105
|
+
remoteDeletedIds?: Record<string, string>,
|
|
106
|
+
): {
|
|
107
|
+
merged: T[];
|
|
108
|
+
mergedDeletedIds: Record<string, string>;
|
|
109
|
+
localChanged: boolean;
|
|
110
|
+
remoteChanged: boolean;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function mergeHabitLogs<T = number>(
|
|
114
|
+
localLogs: Record<string, Record<string, T>>,
|
|
115
|
+
remoteLogs: Record<string, Record<string, T>>,
|
|
116
|
+
localTs?: Record<string, string>,
|
|
117
|
+
remoteTs?: Record<string, string>,
|
|
118
|
+
): {
|
|
119
|
+
merged: Record<string, Record<string, T>>;
|
|
120
|
+
mergedTimestamps: Record<string, string>;
|
|
121
|
+
localChanged: boolean;
|
|
122
|
+
remoteChanged: boolean;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export function mergeRoutineDefinitions<T extends { id: string | number }>(
|
|
126
|
+
localDefs: Record<string, T[]>,
|
|
127
|
+
remoteDefs: Record<string, T[]>,
|
|
128
|
+
deletedChipIds?: Record<string, string>,
|
|
129
|
+
): { merged: Record<string, T[]>; localChanged: boolean; remoteChanged: boolean };
|
|
130
|
+
|
|
131
|
+
export function mergeSyncData<TLocal extends Record<string, unknown>, TRemote extends Record<string, unknown>>(
|
|
132
|
+
localData: TLocal,
|
|
133
|
+
remoteData: TRemote,
|
|
134
|
+
retentionDays?: number,
|
|
135
|
+
): { data: Record<string, unknown>; localChanged: boolean; remoteChanged: boolean };
|
|
136
|
+
|
|
137
|
+
export function pruneTombstones(
|
|
138
|
+
tombstones: Record<string, string>,
|
|
139
|
+
cutoff: Date | null,
|
|
140
|
+
): Record<string, string>;
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Crypto
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export interface CryptoConfig {
|
|
147
|
+
cryptoDBName: string;
|
|
148
|
+
nativeGetSyncKey?: (() => string | null | Promise<string | null>) | null;
|
|
149
|
+
nativeStoreSyncKey?: ((value: string | null) => void) | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function initSessionKey(config: CryptoConfig): Promise<boolean>;
|
|
153
|
+
export function setupEncryptionKey(passphrase: string, config: CryptoConfig): Promise<void>;
|
|
154
|
+
export function clearEncryptionKey(config: CryptoConfig): Promise<void>;
|
|
155
|
+
export function setSyncPassphrase(passphrase: string | null): void;
|
|
156
|
+
export function getSyncPassphrase(): string | null;
|
|
157
|
+
export function hasEncryptionReady(): boolean;
|
|
158
|
+
export function encryptData<T>(data: T, config?: CryptoConfig): Promise<EncryptedEnvelope>;
|
|
159
|
+
export function decryptData<T = unknown>(envelope: EncryptedEnvelope, config?: CryptoConfig): Promise<T>;
|
|
160
|
+
export function isEncryptedEnvelope(value: unknown): value is EncryptedEnvelope;
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Auto-backup
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export type BackupFrequency = 'hourly' | 'daily' | 'weekly';
|
|
167
|
+
|
|
168
|
+
export interface BackupRecord<TData = unknown> {
|
|
169
|
+
id: string;
|
|
170
|
+
timestamp: string;
|
|
171
|
+
frequency: BackupFrequency;
|
|
172
|
+
data: TData;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface AutoBackupDB {
|
|
176
|
+
open(): Promise<IDBDatabase>;
|
|
177
|
+
saveBackup<TData>(frequency: BackupFrequency, data: TData): Promise<BackupRecord<TData>>;
|
|
178
|
+
listBackups<TData>(frequency?: BackupFrequency): Promise<BackupRecord<TData>[]>;
|
|
179
|
+
getBackup<TData>(id: string): Promise<BackupRecord<TData> | undefined>;
|
|
180
|
+
deleteBackup(id: string): Promise<void>;
|
|
181
|
+
pruneBackups(frequency: BackupFrequency, maxCount: number): Promise<number | undefined>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface AutoBackupProvider {
|
|
185
|
+
name: string;
|
|
186
|
+
uploadBackup(providerConfig: Record<string, unknown>, data: unknown): Promise<string>;
|
|
187
|
+
listBackups(providerConfig: Record<string, unknown>): Promise<Array<{ filename: string; lastModified: string | null }>>;
|
|
188
|
+
downloadBackup(providerConfig: Record<string, unknown>, filename: string): Promise<unknown>;
|
|
189
|
+
deleteBackup(providerConfig: Record<string, unknown>, filename: string): Promise<void>;
|
|
190
|
+
testConnection(providerConfig: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface AutoBackupRetention {
|
|
194
|
+
hourly: number;
|
|
195
|
+
daily: number;
|
|
196
|
+
weekly: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface AutoBackupIntervals {
|
|
200
|
+
hourly: number;
|
|
201
|
+
daily: number;
|
|
202
|
+
weekly: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const AUTO_BACKUP_RETENTION: AutoBackupRetention;
|
|
206
|
+
export const AUTO_BACKUP_INTERVALS: AutoBackupIntervals;
|
|
207
|
+
|
|
208
|
+
export function createAutoBackupDB(config: { autoBackupDBName: string }): AutoBackupDB;
|
|
209
|
+
export function createAutoBackupProviders(config: {
|
|
210
|
+
backupFilenamePrefix: string;
|
|
211
|
+
appFolderName: string;
|
|
212
|
+
webdavFetch: WebdavFetch;
|
|
213
|
+
}): Record<string, AutoBackupProvider>;
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Providers / transport
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
export type WebdavFetch = (
|
|
220
|
+
method: string,
|
|
221
|
+
url: string,
|
|
222
|
+
authHeaders: Record<string, string>,
|
|
223
|
+
body?: string | null,
|
|
224
|
+
extraHeaders?: Record<string, string>,
|
|
225
|
+
) => Promise<{
|
|
226
|
+
status: number;
|
|
227
|
+
ok: boolean;
|
|
228
|
+
statusText?: string;
|
|
229
|
+
headers: { get(name: string): string | null };
|
|
230
|
+
json(): Promise<unknown>;
|
|
231
|
+
text(): Promise<string>;
|
|
232
|
+
}>;
|
|
233
|
+
|
|
234
|
+
export interface CloudSyncProvider {
|
|
235
|
+
name: string;
|
|
236
|
+
configFields: Array<{ key: string; label: string; type: string; placeholder?: string }>;
|
|
237
|
+
helpText?: string;
|
|
238
|
+
upload(config: Record<string, unknown>, envelope: SyncEnvelope, etag?: string | null): Promise<boolean>;
|
|
239
|
+
download(config: Record<string, unknown>): Promise<{ payload: unknown; etag: string | null } | null>;
|
|
240
|
+
test(config: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function webdavFetch(config: SyncEngineConfig): WebdavFetch;
|
|
244
|
+
export function createProviders(config: SyncEngineConfig): Record<string, CloudSyncProvider>;
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Sync engine
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
export interface ValidationResult {
|
|
251
|
+
valid: boolean;
|
|
252
|
+
reason?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface SyncEngineConfig {
|
|
256
|
+
// Identity
|
|
257
|
+
storageKeyPrefix: string;
|
|
258
|
+
cryptoDBName: string;
|
|
259
|
+
autoBackupDBName: string;
|
|
260
|
+
syncFilename: string;
|
|
261
|
+
appFolderName: string;
|
|
262
|
+
backupFilenamePrefix: string;
|
|
263
|
+
appId: string;
|
|
264
|
+
appName: string;
|
|
265
|
+
|
|
266
|
+
// Transport bridges (optional, listed in priority order)
|
|
267
|
+
nativeHttpRequest?: NativeHttpRequest | null;
|
|
268
|
+
electronProxyFetch?: ElectronProxyFetch | null;
|
|
269
|
+
proxyUrl?: string;
|
|
270
|
+
|
|
271
|
+
// Crypto bridges (forwarded to crypto.js)
|
|
272
|
+
nativeGetSyncKey?: (() => string | null | Promise<string | null>) | null;
|
|
273
|
+
nativeStoreSyncKey?: ((value: string | null) => void) | null;
|
|
274
|
+
|
|
275
|
+
// Data lifecycle callbacks
|
|
276
|
+
buildPayload: () => unknown | Promise<unknown>;
|
|
277
|
+
buildBackupPayload?: () => unknown | Promise<unknown>;
|
|
278
|
+
applyPayload: (data: unknown, opts: { allowEmpty: boolean }) => void | Promise<void>;
|
|
279
|
+
mergePayloads: (local: unknown, remote: unknown) => {
|
|
280
|
+
data: unknown;
|
|
281
|
+
localChanged: boolean;
|
|
282
|
+
remoteChanged: boolean;
|
|
283
|
+
};
|
|
284
|
+
validateUploadPayload?: (payload: SyncEnvelope) => ValidationResult | Promise<ValidationResult>;
|
|
285
|
+
validateApplyPayload?: (payload: SyncEnvelope) => ValidationResult | Promise<ValidationResult>;
|
|
286
|
+
|
|
287
|
+
// Event callbacks
|
|
288
|
+
onStatusChange?: (status: SyncStatus, hints?: { from?: SyncStatus }) => void;
|
|
289
|
+
onError?: (message: string | null, code: SyncErrorCode | null, isHardStop: boolean) => void;
|
|
290
|
+
onLastSyncedChange?: (isoString: string) => void;
|
|
291
|
+
onConflict?: (remoteData: unknown, remoteModified: string, etag: string | null) => void;
|
|
292
|
+
onPassphraseRequired?: () => void;
|
|
293
|
+
onFirstSyncReload?: () => void;
|
|
294
|
+
|
|
295
|
+
// Retention
|
|
296
|
+
retentionDays?: number;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface SyncEngine {
|
|
300
|
+
sync(): Promise<void>;
|
|
301
|
+
upload(opts?: {
|
|
302
|
+
prebuiltPayload?: unknown;
|
|
303
|
+
etag?: string | null;
|
|
304
|
+
skipLockCheck?: boolean;
|
|
305
|
+
}): Promise<void>;
|
|
306
|
+
download(): Promise<void>;
|
|
307
|
+
runBackup(frequency: BackupFrequency): Promise<void>;
|
|
308
|
+
test(config: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
|
|
309
|
+
|
|
310
|
+
getConfig(): Record<string, unknown> | null;
|
|
311
|
+
setConfig(config: Record<string, unknown> | null): void;
|
|
312
|
+
getLastSynced(): string | null;
|
|
313
|
+
|
|
314
|
+
isSyncing(): boolean;
|
|
315
|
+
isHardStopped(): boolean;
|
|
316
|
+
clearHardStop(): void;
|
|
317
|
+
hasEncryptionReady(): boolean;
|
|
318
|
+
getUploadBackoffUntil(): number;
|
|
319
|
+
getDownloadBackoffUntil(): number;
|
|
320
|
+
|
|
321
|
+
providers: Record<string, CloudSyncProvider>;
|
|
322
|
+
autoBackupDB: AutoBackupDB;
|
|
323
|
+
autoBackupProviders: Record<string, AutoBackupProvider>;
|
|
324
|
+
webdavFetch: WebdavFetch;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function createSyncEngine(config: SyncEngineConfig): SyncEngine;
|
|
328
|
+
|
|
329
|
+
export const SCHEMA_VERSION: number;
|
|
330
|
+
export const SUPPORTED_MAX_SCHEMA_VERSION: number;
|