@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 +62 -0
- package/README.md +175 -1
- package/package.json +4 -2
- package/src/crypto.js +1 -0
- package/src/engine.js +12 -1
- package/src/index.js +1 -0
- package/types/index.d.ts +1 -0
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.
|
|
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 —
|
|
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
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;
|