@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "0.5.0",
3
+ "version": "1.0.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/engine.js CHANGED
@@ -1 +1,529 @@
1
- // Placeholder — sync engine is implemented in Step 6.
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
@@ -30,3 +30,9 @@ export {
30
30
  AUTO_BACKUP_RETENTION,
31
31
  AUTO_BACKUP_INTERVALS,
32
32
  } from './autoBackup.js';
33
+
34
+ export {
35
+ createSyncEngine,
36
+ SCHEMA_VERSION,
37
+ SUPPORTED_MAX_SCHEMA_VERSION,
38
+ } from './engine.js';
package/types/index.d.ts CHANGED
@@ -1,5 +1,330 @@
1
1
  // Type declarations for @glance-apps/sync
2
- // Stub — filled in during Step 6 (see docs/sync-package/SYNC_PACKAGE_SPEC.md
3
- // and EXTRACTION_PLAN.md in the dayGLANCE repo).
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
- export {};
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;