@glance-apps/sync 1.0.0 → 1.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.2] - 2026-05-22
11
+
12
+ ### Added
13
+
14
+ - `getSessionKey()` export — returns the cached non-extractable `CryptoKey` for reuse by sibling packages (e.g., `@glance-apps/intents` encryption helpers).
15
+
16
+ ## [1.0.1] - 2026-05-18
17
+
18
+ ### Fixed
19
+
20
+ - **412 retry jitter**: add a random 1–3 s delay before the single re-try
21
+ `doCycle` call that follows a `PRECONDITION_FAILED` response. Two clients
22
+ polling on similar 60 s schedules previously collided on every cycle; the
23
+ jitter breaks the lock-step pattern.
24
+ - **412 retry backoff**: when the one-shot retry itself fails, apply the same
25
+ exponential backoff (`downloadErrorCount` / `downloadBackoffUntil`) used for
26
+ all other transient errors, emit an `'error'` status, and call `onError`.
27
+ Previously the engine silently returned, which caused it to hammer the server
28
+ again on the very next 60 s poll.
29
+
30
+ ## [1.0.0] - 2026-05-17
31
+
32
+ Initial stable release. Extracted from dayGLANCE and made app-agnostic so the
33
+ entire sync infrastructure can be shared across the GLANCE app family.
34
+
35
+ ### Added
36
+
37
+ - `createSyncEngine(config)` — orchestrates the download → validate → merge →
38
+ apply → upload cycle. Supports WebDAV, Nextcloud, and Koofr providers.
39
+ Backoff on auth failures, 423 Locked, and network errors. Hard-stop on
40
+ `APP_ID_MISMATCH` and `SCHEMA_FORWARD_INCOMPATIBLE`. Fires status events
41
+ (`uploading`, `downloading`, `success`, `error`, `idle`) with auto-revert
42
+ timers.
43
+ - `mergeArrayById`, `mergeDailyNotes`, `mergeHabits`, `mergeHabitLogs`,
44
+ `mergeRoutineDefinitions`, `mergeSyncData`, `pruneTombstones` — ID-based
45
+ merge engine with tombstone propagation and sync-horizon zombie suppression.
46
+ - `encryptData`, `decryptData`, `setupEncryptionKey`, `clearEncryptionKey`,
47
+ `initSessionKey`, `setSyncPassphrase`, `getSyncPassphrase`,
48
+ `hasEncryptionReady`, `isEncryptedEnvelope` — client-side AES-256-GCM
49
+ encryption via Web Crypto API. Key derived with PBKDF2 and persisted in
50
+ IndexedDB or via native Android Keystore bridge.
51
+ - `webdavFetch(config)` — transport-selecting WebDAV fetcher (Android HTTP
52
+ bridge → Electron net.fetch → server-side CORS proxy).
53
+ - `createProviders(config)` — cloud sync provider objects for Nextcloud, Koofr,
54
+ and generic WebDAV.
55
+ - `createAutoBackupDB(config)` and `createAutoBackupProviders(config)` —
56
+ periodic snapshot backups to local IndexedDB and remote WebDAV folders.
57
+ - `AUTO_BACKUP_RETENTION` and `AUTO_BACKUP_INTERVALS` constants.
58
+ - TypeScript declarations in `types/index.d.ts`.
59
+ - WebDAV CORS proxy handler in `api/webdav-proxy.js`.
60
+
61
+ [Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.0.0...HEAD
62
+ [1.0.0]: https://github.com/glance-apps/glance-sync/releases/tag/v1.0.0
package/README.md CHANGED
@@ -1,2 +1,176 @@
1
- # glance-sync
1
+ # @glance-apps/sync
2
+
2
3
  Shared sync engine for the GLANCE app family (dayGLANCE, lifeGLANCE, lastGLANCE). WebDAV transport, AES-256-GCM encryption, conflict-free merge.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @glance-apps/sync
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Initialize the sync engine
14
+
15
+ ```js
16
+ import { createSyncEngine } from '@glance-apps/sync';
17
+
18
+ const engine = createSyncEngine({
19
+ // Identity
20
+ appId: 'com.example.myapp',
21
+ appName: 'MyApp',
22
+ storageKeyPrefix: 'myapp',
23
+ syncFilename: 'myapp-sync.json',
24
+ appFolderName: 'myapp',
25
+ cryptoDBName: 'myapp-crypto',
26
+ autoBackupDBName: 'myapp-auto-backups',
27
+ backupFilenamePrefix: 'myapp-backup-',
28
+
29
+ // Transport (browser web app — routes through server-side CORS proxy)
30
+ nativeHttpRequest: null,
31
+ electronProxyFetch: null,
32
+ proxyUrl: 'https://your-app.example.com',
33
+
34
+ // Data lifecycle
35
+ buildPayload: () => ({ tasks: store.getTasks(), notes: store.getNotes() }),
36
+ applyPayload: (data) => store.replaceAll(data),
37
+ mergePayloads: (local, remote) => mergeSyncData(local, remote),
38
+
39
+ // Status callbacks
40
+ onStatusChange: (status) => ui.setSyncStatus(status),
41
+ onError: (message, code, isHardStop) => ui.showError(message),
42
+ onLastSyncedChange: (iso) => ui.setLastSynced(iso),
43
+ onConflict: (remoteData, remoteModified, etag) => ui.showConflictDialog(remoteData),
44
+ onPassphraseRequired: () => ui.promptPassphrase(),
45
+ });
46
+ ```
47
+
48
+ ### Perform a sync cycle
49
+
50
+ The engine distinguishes between upload (local → remote) and download (download → merge → apply → upload if changed). Call `engine.sync()` for the full cycle:
51
+
52
+ ```js
53
+ // Wire to local data changes (typically with a debounce)
54
+ store.on('change', debounce(() => engine.upload(), 5000));
55
+
56
+ // Wire to a periodic poll or visibility event
57
+ setInterval(() => engine.sync(), 60_000);
58
+ document.addEventListener('visibilitychange', () => {
59
+ if (document.visibilityState === 'visible') engine.sync();
60
+ });
61
+ ```
62
+
63
+ ### Handle first-sync conflicts
64
+
65
+ On the first sync, if the remote file already exists, the engine surfaces the conflict rather than auto-merging:
66
+
67
+ ```js
68
+ const engine = createSyncEngine({
69
+ // ...
70
+ onConflict: async (remoteData, remoteModified, etag) => {
71
+ const choice = await ui.askUser('Remote data exists. Keep local or remote?');
72
+ if (choice === 'remote') {
73
+ store.replaceAll(remoteData);
74
+ engine.setConfig({ ...engine.getConfig(), lastSynced: remoteModified });
75
+ }
76
+ // Either way, proceed with an upload to stamp the chosen data
77
+ engine.upload();
78
+ },
79
+ });
80
+ ```
81
+
82
+ ### Set up encryption
83
+
84
+ ```js
85
+ import { initSessionKey, setupEncryptionKey, setSyncPassphrase } from '@glance-apps/sync';
86
+
87
+ const cryptoConfig = { cryptoDBName: 'myapp-crypto' };
88
+
89
+ // On app start: try to restore key from device storage
90
+ const restored = await initSessionKey(cryptoConfig);
91
+ if (!restored) {
92
+ // Prompt user for passphrase
93
+ const passphrase = await ui.promptPassphrase();
94
+ setSyncPassphrase(passphrase);
95
+ await setupEncryptionKey(passphrase, cryptoConfig);
96
+ }
97
+ ```
98
+
99
+ Pass `encryptionEnabled: true` in the sync config object stored via `engine.setConfig()` to enable encryption for uploads.
100
+
101
+ ## Public API
102
+
103
+ ### Sync engine
104
+
105
+ | Export | Description |
106
+ |--------|-------------|
107
+ | `createSyncEngine(config)` | Creates a sync engine instance bound to the given app/transport/data config. Returns a `SyncEngine` object. |
108
+ | `SCHEMA_VERSION` | Current envelope schema version written into every upload. |
109
+ | `SUPPORTED_MAX_SCHEMA_VERSION` | Highest schema version this build can read; downloads with a higher version trigger `SCHEMA_FORWARD_INCOMPATIBLE`. |
110
+
111
+ **`SyncEngine` methods:**
112
+
113
+ | Method | Description |
114
+ |--------|-------------|
115
+ | `sync()` / `download()` | Full cycle: download → validate → merge → apply → upload if changed. |
116
+ | `upload(opts?)` | Upload local state to the remote provider. |
117
+ | `runBackup(frequency)` | Save a snapshot to IDB and upload it to the remote backup folder. |
118
+ | `test(config)` | Test the connection to the configured provider. |
119
+ | `getConfig()` / `setConfig(cfg)` | Read/write the persisted sync config (credentials, provider, flags). |
120
+ | `getLastSynced()` | ISO 8601 timestamp of the last successful sync. |
121
+ | `isSyncing()` | Returns `true` while a sync cycle is in progress. |
122
+ | `isHardStopped()` / `clearHardStop()` | Query or clear the hard-stop flag (set on `APP_ID_MISMATCH` or `SCHEMA_FORWARD_INCOMPATIBLE`). |
123
+ | `hasEncryptionReady()` | Returns `true` if a session key is loaded and encryption is available. |
124
+ | `getUploadBackoffUntil()` / `getDownloadBackoffUntil()` | Epoch ms timestamp before which the next upload/download should not be attempted. |
125
+
126
+ ### Merge functions
127
+
128
+ | Export | Description |
129
+ |--------|-------------|
130
+ | `mergeArrayById(local, remote, deletedIds, syncHorizon?, opts?)` | Generic array merge by item ID with tombstone support. |
131
+ | `mergeDailyNotes(local, remote)` | Merge date-keyed note maps; newer `lastModified` wins per key. |
132
+ | `mergeHabits(local, remote, localDeleted?, remoteDeleted?)` | Merge habit arrays with unified tombstones. |
133
+ | `mergeHabitLogs(local, remote, localTs?, remoteTs?)` | Merge date-keyed habit log maps; last-writer-wins per entry when timestamps are available. |
134
+ | `mergeRoutineDefinitions(local, remote, deletedChipIds?)` | Merge routine definitions (bucket → chip array) with tombstone support. |
135
+ | `mergeSyncData(local, remote, retentionDays?)` | Full data-level merge for the dayGLANCE payload shape. |
136
+ | `pruneTombstones(tombstones, cutoff)` | Remove tombstone entries older than the cutoff date. |
137
+
138
+ ### Crypto
139
+
140
+ | Export | Description |
141
+ |--------|-------------|
142
+ | `initSessionKey(config)` | Restore session key from device storage (IDB or native bridge). Returns `true` if key was restored. |
143
+ | `setupEncryptionKey(passphrase, config)` | First-time setup: derive a key from the passphrase and persist it. |
144
+ | `clearEncryptionKey(config)` | Erase the cached key from device storage and session memory. |
145
+ | `encryptData(data, config?)` | Encrypt a plain JS object and return an `EncryptedEnvelope`. |
146
+ | `decryptData(envelope, config?)` | Decrypt an `EncryptedEnvelope` back to a plain JS object. |
147
+ | `isEncryptedEnvelope(value)` | Type guard — returns `true` if the value looks like an `EncryptedEnvelope`. |
148
+ | `setSyncPassphrase(p)` / `getSyncPassphrase()` | Store/retrieve the passphrase in session memory (not persisted). |
149
+ | `hasEncryptionReady()` | Returns `true` if the session key is loaded. |
150
+ | `getSessionKey()` | Returns the cached non-extractable `CryptoKey`, or `null` if no key is loaded — intended for sibling packages (e.g., `@glance-apps/intents`) that need to reuse the same key. |
151
+
152
+ ### Transport & providers
153
+
154
+ | Export | Description |
155
+ |--------|-------------|
156
+ | `webdavFetch(config)` | Creates a transport-selecting WebDAV fetcher. Prefers Android HTTP bridge → Electron proxy → CORS proxy, in that order. |
157
+ | `createProviders(config)` | Creates cloud sync provider objects (`nextcloud`, `koofr`, `webdav`). |
158
+
159
+ ### Auto-backup
160
+
161
+ | Export | Description |
162
+ |--------|-------------|
163
+ | `createAutoBackupDB(config)` | Creates an IndexedDB-backed local backup store. |
164
+ | `createAutoBackupProviders(config)` | Creates remote backup provider objects for Nextcloud and generic WebDAV. |
165
+ | `AUTO_BACKUP_RETENTION` | Default retention limits: `{ hourly: 24, daily: 30, weekly: 12 }`. |
166
+ | `AUTO_BACKUP_INTERVALS` | Interval seconds: `{ hourly: 3600, daily: 86400, weekly: 604800 }`. |
167
+
168
+ TypeScript declarations for all of the above are in `types/index.d.ts`.
169
+
170
+ ## Versioning
171
+
172
+ This package is at **v1.0.0** and follows [Semantic Versioning](https://semver.org/). Breaking changes to the public API or the sync envelope schema will result in a major version bump.
173
+
174
+ ## License
175
+
176
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -10,10 +10,12 @@
10
10
  "api",
11
11
  "types",
12
12
  "README.md",
13
+ "CHANGELOG.md",
13
14
  "LICENSE"
14
15
  ],
15
16
  "scripts": {
16
- "test": "vitest run"
17
+ "test": "vitest run",
18
+ "prepublishOnly": "npm test"
17
19
  },
18
20
  "repository": {
19
21
  "type": "git",
package/src/crypto.js CHANGED
@@ -17,6 +17,7 @@ let _sessionSalt = null; // Uint8Array | null — the salt used to derive
17
17
  export function setSyncPassphrase(p) { _sessionPassphrase = p; }
18
18
  export function getSyncPassphrase() { return _sessionPassphrase; }
19
19
  export function hasEncryptionReady() { return _sessionKey !== null; }
20
+ export function getSessionKey() { return _sessionKey; }
20
21
 
21
22
  // ---------------------------------------------------------------------------
22
23
  // IndexedDB key store
package/src/engine.js CHANGED
@@ -411,7 +411,10 @@ export const createSyncEngine = (config) => {
411
411
  }
412
412
 
413
413
  if (cls.code === 'PRECONDITION_FAILED') {
414
- // Another device wrote between our download and upload — retry once.
414
+ // Another device wrote between our download and upload — wait a random
415
+ // jitter (1–3 s) to reduce collision probability, then retry once.
416
+ const jitterMs = 1000 + Math.random() * 2000;
417
+ await new Promise(r => setTimeout(r, jitterMs));
415
418
  try {
416
419
  await doCycle(null);
417
420
  downloadErrorCount = 0;
@@ -419,6 +422,14 @@ export const createSyncEngine = (config) => {
419
422
  } catch (retryErr) {
420
423
  // eslint-disable-next-line no-console
421
424
  console.error(`[${appId}] cloud sync retry after 412 failed:`, retryErr);
425
+ // Apply the same exponential backoff used for other transient errors so
426
+ // the engine backs off rather than hammering the server every poll cycle.
427
+ downloadErrorCount += 1;
428
+ const ms = Math.min(30 * Math.pow(2, downloadErrorCount - 1), MAX_DOWNLOAD_BACKOFF_S) * 1000;
429
+ downloadBackoffUntil = Date.now() + ms;
430
+ onError?.(formatErrorMessage(classifyError(retryErr)), classifyError(retryErr).code, false);
431
+ onStatusChange?.('error');
432
+ scheduleAutoRevert('error', ERROR_HOLD_MS);
422
433
  }
423
434
  return;
424
435
  }
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  getSyncPassphrase,
17
17
  clearEncryptionKey,
18
18
  hasEncryptionReady,
19
+ getSessionKey,
19
20
  isEncryptedEnvelope,
20
21
  initSessionKey,
21
22
  } from './crypto.js';
package/types/index.d.ts CHANGED
@@ -155,6 +155,7 @@ export function clearEncryptionKey(config: CryptoConfig): Promise<void>;
155
155
  export function setSyncPassphrase(passphrase: string | null): void;
156
156
  export function getSyncPassphrase(): string | null;
157
157
  export function hasEncryptionReady(): boolean;
158
+ export function getSessionKey(): CryptoKey | null;
158
159
  export function encryptData<T>(data: T, config?: CryptoConfig): Promise<EncryptedEnvelope>;
159
160
  export function decryptData<T = unknown>(envelope: EncryptedEnvelope, config?: CryptoConfig): Promise<T>;
160
161
  export function isEncryptedEnvelope(value: unknown): value is EncryptedEnvelope;