@glance-apps/sync 0.4.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.4.0",
3
+ "version": "1.0.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/autoBackup.js CHANGED
@@ -1 +1,272 @@
1
- // Placeholder — auto-backup module is extracted in Step 5.
1
+ import { encryptData, decryptData, isEncryptedEnvelope, hasEncryptionReady } from './crypto.js';
2
+ import { webdavFetch } from './providers.js';
3
+
4
+ // btoa() throws InvalidCharacterError for codepoints > 255 (CJK, emoji, etc.).
5
+ const toBase64 = (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str)));
6
+
7
+ /**
8
+ * Creates an IndexedDB-backed auto-backup store.
9
+ *
10
+ * @param {object} config
11
+ * @param {string} config.autoBackupDBName - IDB database name (e.g. 'dayglance-auto-backups')
12
+ * @returns {object} autoBackupDB instance
13
+ */
14
+ export function createAutoBackupDB(config) {
15
+ return {
16
+ _db: null,
17
+ async open() {
18
+ if (this._db) return this._db;
19
+ return new Promise((resolve, reject) => {
20
+ const req = indexedDB.open(config.autoBackupDBName, 1);
21
+ req.onupgradeneeded = (e) => {
22
+ const db = e.target.result;
23
+ if (!db.objectStoreNames.contains('backups')) {
24
+ const store = db.createObjectStore('backups', { keyPath: 'id' });
25
+ store.createIndex('timestamp', 'timestamp', { unique: false });
26
+ store.createIndex('frequency', 'frequency', { unique: false });
27
+ }
28
+ };
29
+ req.onsuccess = () => { this._db = req.result; resolve(req.result); };
30
+ req.onerror = () => reject(req.error);
31
+ });
32
+ },
33
+ async saveBackup(frequency, data) {
34
+ const db = await this.open();
35
+ const timestamp = new Date().toISOString();
36
+ const id = `auto-${frequency}-${timestamp}`;
37
+ const record = { id, timestamp, frequency, data };
38
+ return new Promise((resolve, reject) => {
39
+ const tx = db.transaction('backups', 'readwrite');
40
+ tx.objectStore('backups').put(record);
41
+ tx.oncomplete = () => resolve(record);
42
+ tx.onerror = () => reject(tx.error);
43
+ });
44
+ },
45
+ async listBackups(frequency) {
46
+ const db = await this.open();
47
+ return new Promise((resolve, reject) => {
48
+ const tx = db.transaction('backups', 'readonly');
49
+ const store = tx.objectStore('backups');
50
+ const req = frequency
51
+ ? store.index('frequency').getAll(frequency)
52
+ : store.getAll();
53
+ req.onsuccess = () => resolve(req.result.sort((a, b) => b.timestamp.localeCompare(a.timestamp)));
54
+ req.onerror = () => reject(req.error);
55
+ });
56
+ },
57
+ async getBackup(id) {
58
+ const db = await this.open();
59
+ return new Promise((resolve, reject) => {
60
+ const tx = db.transaction('backups', 'readonly');
61
+ const req = tx.objectStore('backups').get(id);
62
+ req.onsuccess = () => resolve(req.result);
63
+ req.onerror = () => reject(req.error);
64
+ });
65
+ },
66
+ async deleteBackup(id) {
67
+ const db = await this.open();
68
+ return new Promise((resolve, reject) => {
69
+ const tx = db.transaction('backups', 'readwrite');
70
+ tx.objectStore('backups').delete(id);
71
+ tx.oncomplete = () => resolve();
72
+ tx.onerror = () => reject(tx.error);
73
+ });
74
+ },
75
+ async pruneBackups(frequency, maxCount) {
76
+ const all = await this.listBackups(frequency);
77
+ if (all.length <= maxCount) return;
78
+ const toDelete = all.slice(maxCount);
79
+ const db = await this.open();
80
+ return new Promise((resolve, reject) => {
81
+ const tx = db.transaction('backups', 'readwrite');
82
+ const store = tx.objectStore('backups');
83
+ toDelete.forEach(b => store.delete(b.id));
84
+ tx.oncomplete = () => resolve(toDelete.length);
85
+ tx.onerror = () => reject(tx.error);
86
+ });
87
+ }
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Creates the auto-backup remote provider objects.
93
+ * These are separate from cloudSyncProviders (real-time sync); they handle
94
+ * periodic snapshot uploads.
95
+ *
96
+ * @param {object} config
97
+ * @param {string} config.backupFilenamePrefix - e.g. 'dayglance-backup-'
98
+ * @param {string} config.appFolderName - e.g. 'dayglance' (used in Nextcloud path)
99
+ * @param {Function} config.webdavFetch - bound webdavFetch(method, url, auth, body, extra)
100
+ * @returns {object} provider map keyed by provider id
101
+ */
102
+ export function createAutoBackupProviders(config) {
103
+ const wdFetch = config.webdavFetch;
104
+ const prefix = config.backupFilenamePrefix;
105
+ const appFolder = config.appFolderName;
106
+
107
+ return {
108
+ nextcloud: {
109
+ name: 'Nextcloud / WebDAV',
110
+ configFields: [
111
+ { key: 'nextcloudUrl', label: 'Nextcloud URL', type: 'url', placeholder: 'https://cloud.example.com' },
112
+ { key: 'username', label: 'Username', type: 'text', placeholder: 'your-username' },
113
+ { key: 'appPassword', label: 'App Password', type: 'password', placeholder: 'xxxxx-xxxxx-xxxxx-xxxxx-xxxxx' }
114
+ ],
115
+ _getBackupDirUrl(providerConfig) {
116
+ if (!providerConfig.nextcloudUrl) throw new Error('Nextcloud URL is not configured');
117
+ return `${providerConfig.nextcloudUrl.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(providerConfig.username)}/${appFolder}/backups/`;
118
+ },
119
+ _getAuthHeaders(providerConfig) {
120
+ return { 'X-WebDAV-Auth': 'Basic ' + toBase64(providerConfig.username + ':' + providerConfig.appPassword) };
121
+ },
122
+ async uploadBackup(providerConfig, data) {
123
+ const dirUrl = this._getBackupDirUrl(providerConfig);
124
+ const authHeaders = this._getAuthHeaders(providerConfig);
125
+ const now = new Date();
126
+ const timestamp = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}-${String(now.getMinutes()).padStart(2,'0')}-${String(now.getSeconds()).padStart(2,'0')}`;
127
+ const filename = `${prefix}${timestamp}.json`;
128
+ const fileUrl = dirUrl + filename;
129
+ const payload = hasEncryptionReady() ? await encryptData(data) : data;
130
+ const body = JSON.stringify(payload);
131
+
132
+ const doUpload = () =>
133
+ wdFetch('PUT', fileUrl, authHeaders, body, { 'Content-Type': 'application/json' });
134
+
135
+ let res = await doUpload();
136
+ if (res.status === 404 || res.status === 409) {
137
+ const parentDir = `${providerConfig.nextcloudUrl.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(providerConfig.username)}/${appFolder}/`;
138
+ await wdFetch('MKCOL', parentDir, authHeaders);
139
+ await wdFetch('MKCOL', dirUrl, authHeaders);
140
+ res = await doUpload();
141
+ }
142
+ if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
143
+ return filename;
144
+ },
145
+ async listBackups(providerConfig) {
146
+ const dirUrl = this._getBackupDirUrl(providerConfig);
147
+ const authHeaders = this._getAuthHeaders(providerConfig);
148
+ const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '1' });
149
+ if (res.status === 404) return [];
150
+ if (!res.ok) throw new Error(`List failed: ${res.status}`);
151
+ const xml = await res.text();
152
+ const parser = new DOMParser();
153
+ const doc = parser.parseFromString(xml, 'application/xml');
154
+ const responses = doc.querySelectorAll('response');
155
+ const files = [];
156
+ responses.forEach(r => {
157
+ const href = r.querySelector('href')?.textContent || '';
158
+ const filename = decodeURIComponent(href.split('/').filter(Boolean).pop());
159
+ if (filename.startsWith(prefix) && filename.endsWith('.json')) {
160
+ const lastModified = r.querySelector('getlastmodified')?.textContent;
161
+ files.push({ filename, lastModified: lastModified ? new Date(lastModified).toISOString() : null });
162
+ }
163
+ });
164
+ return files.sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || ''));
165
+ },
166
+ async downloadBackup(providerConfig, filename) {
167
+ const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
168
+ const authHeaders = this._getAuthHeaders(providerConfig);
169
+ const res = await wdFetch('GET', fileUrl, authHeaders);
170
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
171
+ const parsed = await res.json();
172
+ if (isEncryptedEnvelope(parsed)) return decryptData(parsed);
173
+ return parsed;
174
+ },
175
+ async deleteBackup(providerConfig, filename) {
176
+ const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
177
+ const authHeaders = this._getAuthHeaders(providerConfig);
178
+ const res = await wdFetch('DELETE', fileUrl, authHeaders);
179
+ if (!res.ok && res.status !== 404) throw new Error(`Delete failed: ${res.status}`);
180
+ },
181
+ async testConnection(providerConfig) {
182
+ const dirUrl = this._getBackupDirUrl(providerConfig);
183
+ const authHeaders = this._getAuthHeaders(providerConfig);
184
+ const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '0' });
185
+ if (res.status === 200 || res.status === 207 || res.status === 404) return { success: true };
186
+ if (res.status === 401) return { success: false, error: 'Invalid credentials.' };
187
+ return { success: false, error: `Unexpected response: ${res.status}` };
188
+ }
189
+ },
190
+ webdav: {
191
+ name: 'Generic WebDAV',
192
+ configFields: [
193
+ { key: 'webdavUrl', label: 'WebDAV URL', type: 'url', placeholder: 'https://app.koofr.net/dav/Koofr/dayGLANCE/' },
194
+ { key: 'username', label: 'Username', type: 'text', placeholder: 'your-username' },
195
+ { key: 'appPassword', label: 'Password / App Password', type: 'password', placeholder: 'your-password' }
196
+ ],
197
+ _getBackupDirUrl(providerConfig) {
198
+ return `${providerConfig.webdavUrl.replace(/\/+$/, '')}/`;
199
+ },
200
+ _getAuthHeaders(providerConfig) {
201
+ return { 'X-WebDAV-Auth': 'Basic ' + toBase64(providerConfig.username + ':' + providerConfig.appPassword) };
202
+ },
203
+ async uploadBackup(providerConfig, data) {
204
+ const dirUrl = this._getBackupDirUrl(providerConfig);
205
+ const authHeaders = this._getAuthHeaders(providerConfig);
206
+ const now = new Date();
207
+ const timestamp = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}-${String(now.getMinutes()).padStart(2,'0')}-${String(now.getSeconds()).padStart(2,'0')}`;
208
+ const filename = `${prefix}${timestamp}.json`;
209
+ const fileUrl = dirUrl + filename;
210
+ const payload = hasEncryptionReady() ? await encryptData(data) : data;
211
+ const body = JSON.stringify(payload);
212
+ const doUpload = () =>
213
+ wdFetch('PUT', fileUrl, authHeaders, body, { 'Content-Type': 'application/json' });
214
+ let res = await doUpload();
215
+ if (res.status === 404 || res.status === 409) {
216
+ await wdFetch('MKCOL', dirUrl, authHeaders);
217
+ res = await doUpload();
218
+ }
219
+ if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
220
+ return filename;
221
+ },
222
+ async listBackups(providerConfig) {
223
+ const dirUrl = this._getBackupDirUrl(providerConfig);
224
+ const authHeaders = this._getAuthHeaders(providerConfig);
225
+ const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '1' });
226
+ if (res.status === 404) return [];
227
+ if (!res.ok) throw new Error(`List failed: ${res.status}`);
228
+ const xml = await res.text();
229
+ const parser = new DOMParser();
230
+ const doc = parser.parseFromString(xml, 'application/xml');
231
+ const responses = doc.querySelectorAll('response');
232
+ const files = [];
233
+ responses.forEach(r => {
234
+ const href = r.querySelector('href')?.textContent || '';
235
+ const filename = decodeURIComponent(href.split('/').filter(Boolean).pop());
236
+ if (filename.startsWith(prefix) && filename.endsWith('.json')) {
237
+ const lastModified = r.querySelector('getlastmodified')?.textContent;
238
+ files.push({ filename, lastModified: lastModified ? new Date(lastModified).toISOString() : null });
239
+ }
240
+ });
241
+ return files.sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || ''));
242
+ },
243
+ async downloadBackup(providerConfig, filename) {
244
+ const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
245
+ const authHeaders = this._getAuthHeaders(providerConfig);
246
+ const res = await wdFetch('GET', fileUrl, authHeaders);
247
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
248
+ const parsed = await res.json();
249
+ if (isEncryptedEnvelope(parsed)) return decryptData(parsed);
250
+ return parsed;
251
+ },
252
+ async deleteBackup(providerConfig, filename) {
253
+ const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
254
+ const authHeaders = this._getAuthHeaders(providerConfig);
255
+ const res = await wdFetch('DELETE', fileUrl, authHeaders);
256
+ if (!res.ok && res.status !== 404) throw new Error(`Delete failed: ${res.status}`);
257
+ },
258
+ async testConnection(providerConfig) {
259
+ const dirUrl = this._getBackupDirUrl(providerConfig);
260
+ const authHeaders = this._getAuthHeaders(providerConfig);
261
+ const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '0' });
262
+ if (res.status === 200 || res.status === 207 || res.status === 404) return { success: true };
263
+ if (res.status === 401) return { success: false, error: 'Invalid credentials.' };
264
+ return { success: false, error: `Unexpected response: ${res.status}` };
265
+ }
266
+ }
267
+ };
268
+ }
269
+
270
+ // Auto-backup retention limits (app-agnostic constants)
271
+ export const AUTO_BACKUP_RETENTION = { hourly: 24, daily: 30, weekly: 12 };
272
+ export const AUTO_BACKUP_INTERVALS = { hourly: 3600, daily: 86400, weekly: 604800 };
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
@@ -23,3 +23,16 @@ export {
23
23
  // webdavFetch and createProviders are internal to the engine (Step 6) but
24
24
  // exported here so shims can wire them before the engine exists.
25
25
  export { webdavFetch, createProviders } from './providers.js';
26
+
27
+ export {
28
+ createAutoBackupDB,
29
+ createAutoBackupProviders,
30
+ AUTO_BACKUP_RETENTION,
31
+ AUTO_BACKUP_INTERVALS,
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;