@glance-apps/sync 1.6.0 → 1.6.1
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 +42 -1
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/engine.js +29 -3
- package/src/index.js +1 -1
- package/src/providers.js +34 -1
- package/types/index.d.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.1] - 2026-07-19
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **File engine: persistent PRECONDITION_FAILED (412) sync loop on servers
|
|
15
|
+
that mangle ETags** (lastGLANCE issue #232). The optimistic-concurrency
|
|
16
|
+
cycle (GET capturing an ETag, merge, PUT with If-Match) can never converge
|
|
17
|
+
against a server whose GET responses carry a mangled validator: Apache
|
|
18
|
+
mod_deflate rewrites the ETag on gzip-encoded responses to `"xyz-gzip"`
|
|
19
|
+
(Android's HttpURLConnection silently requests gzip, so native apps always
|
|
20
|
+
hit this on Apache-fronted servers such as standard Nextcloud installs), and
|
|
21
|
+
nginx's gzip filter downgrades strong ETags to weak (`W/"xyz"`), which
|
|
22
|
+
If-Match's strong comparison rejects. Either way every PUT 412s, the
|
|
23
|
+
single conditional retry fetches another mangled ETag and 412s again, and
|
|
24
|
+
sync wedges until the user hand-deletes the sync file from the server. Two
|
|
25
|
+
complementary fixes:
|
|
26
|
+
- **ETag normalization at capture time.** A new exported pure helper,
|
|
27
|
+
`normalizeEtag(raw)`, strips the weak-validator prefix (`W/"abc"` becomes
|
|
28
|
+
`"abc"`) and the known content-coding suffixes Apache appends inside the
|
|
29
|
+
quotes (`"abc-gzip"` / `"abc-br"` become `"abc"`), preserving surrounding
|
|
30
|
+
quotes and passing null/undefined through. `parseDownloadResponse` now
|
|
31
|
+
normalizes every ETag it captures, so If-Match round-trips the entity's
|
|
32
|
+
real validator. Clean ETags are unchanged, so servers that never mangle
|
|
33
|
+
see identical behavior.
|
|
34
|
+
- **Termination guarantee: a second consecutive 412 falls back to an
|
|
35
|
+
unconditional upload.** If the conditional retry after a 412 itself fails
|
|
36
|
+
with PRECONDITION_FAILED (any other error keeps the existing
|
|
37
|
+
backoff-and-surface behavior), the engine runs one final
|
|
38
|
+
download-merge-upload cycle whose PUT omits If-Match entirely
|
|
39
|
+
(last-writer-wins). The uploaded payload is the merge of local state with
|
|
40
|
+
the remote fetched milliseconds earlier in the same cycle, so the
|
|
41
|
+
lost-update window is tiny - far safer than a permanently wedged sync
|
|
42
|
+
that pushes users toward deleting the sync file. If the fallback cycle
|
|
43
|
+
itself fails, the error is surfaced exactly as the old retry-failure path
|
|
44
|
+
did (exponential backoff, `onError`, status `'error'`).
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- `normalizeEtag(raw)` exported from the package root for reuse by apps.
|
|
49
|
+
|
|
10
50
|
## [1.6.0] - 2026-07-10
|
|
11
51
|
|
|
12
52
|
### Fixed
|
|
@@ -374,7 +414,8 @@ entire sync infrastructure can be shared across the GLANCE app family.
|
|
|
374
414
|
- TypeScript declarations in `types/index.d.ts`.
|
|
375
415
|
- WebDAV CORS proxy handler in `api/webdav-proxy.js`.
|
|
376
416
|
|
|
377
|
-
[Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.6.
|
|
417
|
+
[Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.6.1...HEAD
|
|
418
|
+
[1.6.1]: https://github.com/glance-apps/glance-sync/compare/v1.6.0...v1.6.1
|
|
378
419
|
[1.6.0]: https://github.com/glance-apps/glance-sync/compare/v1.5.3...v1.6.0
|
|
379
420
|
[1.5.3]: https://github.com/glance-apps/glance-sync/compare/v1.5.2...v1.5.3
|
|
380
421
|
[1.5.2]: https://github.com/glance-apps/glance-sync/compare/v1.5.1...v1.5.2
|
package/README.md
CHANGED
|
@@ -156,6 +156,7 @@ Pass `encryptionEnabled: true` in the sync config object stored via `engine.setC
|
|
|
156
156
|
|--------|-------------|
|
|
157
157
|
| `webdavFetch(config)` | Creates a transport-selecting WebDAV fetcher. Prefers Android HTTP bridge → Electron proxy → CORS proxy, in that order. |
|
|
158
158
|
| `createProviders(config)` | Creates cloud sync provider objects (`nextcloud`, `koofr`, `webdav`). |
|
|
159
|
+
| `normalizeEtag(raw)` | Normalizes a raw ETag header value for If-Match use: strips a weak-validator prefix (`W/"abc"` → `"abc"`) and the content-coding suffixes some servers append inside the quotes (`"abc-gzip"` / `"abc-br"` → `"abc"`). Quotes are preserved; `null`/`undefined` pass through unchanged. |
|
|
159
160
|
|
|
160
161
|
### Auto-backup
|
|
161
162
|
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -314,7 +314,9 @@ export const createSyncEngine = (config) => {
|
|
|
314
314
|
|
|
315
315
|
// Inner helper: download, merge, apply, upload (if anything changed).
|
|
316
316
|
// Returns true if a reload was triggered (caller should bail).
|
|
317
|
-
|
|
317
|
+
// skipIfMatch forces an unconditional upload (no If-Match) — the
|
|
318
|
+
// last-writer-wins fallback used after two consecutive 412s.
|
|
319
|
+
const doCycle = async (overrideEtag, { skipIfMatch = false } = {}) => {
|
|
318
320
|
const downloaded = await provider.download(cfg);
|
|
319
321
|
if (!downloaded) {
|
|
320
322
|
// Empty remote — seed it with local state.
|
|
@@ -365,7 +367,7 @@ export const createSyncEngine = (config) => {
|
|
|
365
367
|
await upload({
|
|
366
368
|
prebuiltPayload: mergedPayload,
|
|
367
369
|
skipLockCheck: true,
|
|
368
|
-
etag: etag || overrideEtag || null,
|
|
370
|
+
etag: skipIfMatch ? null : (etag || overrideEtag || null),
|
|
369
371
|
});
|
|
370
372
|
|
|
371
373
|
if (localChanged) {
|
|
@@ -425,6 +427,7 @@ export const createSyncEngine = (config) => {
|
|
|
425
427
|
// jitter (1–3 s) to reduce collision probability, then retry once.
|
|
426
428
|
const jitterMs = 1000 + Math.random() * 2000;
|
|
427
429
|
await new Promise(r => setTimeout(r, jitterMs));
|
|
430
|
+
let finalErr = null;
|
|
428
431
|
try {
|
|
429
432
|
await doCycle(null);
|
|
430
433
|
downloadErrorCount = 0;
|
|
@@ -432,12 +435,35 @@ export const createSyncEngine = (config) => {
|
|
|
432
435
|
} catch (retryErr) {
|
|
433
436
|
// eslint-disable-next-line no-console
|
|
434
437
|
console.error(`[${appId}] cloud sync retry after 412 failed:`, retryErr);
|
|
438
|
+
if (classifyError(retryErr).code === 'PRECONDITION_FAILED') {
|
|
439
|
+
// Two consecutive 412s with fresh ETags is not a write race — it's
|
|
440
|
+
// a server whose ETags can't round-trip through If-Match (e.g. a
|
|
441
|
+
// proxy mangling them in a way normalizeEtag doesn't cover), and a
|
|
442
|
+
// conditional retry can never succeed. Run one last cycle whose
|
|
443
|
+
// upload omits If-Match (last-writer-wins). The payload is the
|
|
444
|
+
// merge of local state with the remote fetched milliseconds
|
|
445
|
+
// earlier in the same cycle, so the lost-update window is tiny —
|
|
446
|
+
// far safer than a permanently wedged sync.
|
|
447
|
+
try {
|
|
448
|
+
await doCycle(null, { skipIfMatch: true });
|
|
449
|
+
downloadErrorCount = 0;
|
|
450
|
+
downloadBackoffUntil = 0;
|
|
451
|
+
} catch (fallbackErr) {
|
|
452
|
+
// eslint-disable-next-line no-console
|
|
453
|
+
console.error(`[${appId}] cloud sync unconditional fallback after 412 failed:`, fallbackErr);
|
|
454
|
+
finalErr = fallbackErr;
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
finalErr = retryErr;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (finalErr) {
|
|
435
461
|
// Apply the same exponential backoff used for other transient errors so
|
|
436
462
|
// the engine backs off rather than hammering the server every poll cycle.
|
|
437
463
|
downloadErrorCount += 1;
|
|
438
464
|
const ms = Math.min(30 * Math.pow(2, downloadErrorCount - 1), MAX_DOWNLOAD_BACKOFF_S) * 1000;
|
|
439
465
|
downloadBackoffUntil = Date.now() + ms;
|
|
440
|
-
onError?.(formatErrorMessage(classifyError(
|
|
466
|
+
onError?.(formatErrorMessage(classifyError(finalErr)), classifyError(finalErr).code, false);
|
|
441
467
|
onStatusChange?.('error');
|
|
442
468
|
scheduleAutoRevert('error', ERROR_HOLD_MS);
|
|
443
469
|
}
|
package/src/index.js
CHANGED
|
@@ -24,7 +24,7 @@ export {
|
|
|
24
24
|
|
|
25
25
|
// webdavFetch and createProviders are internal to the engine (Step 6) but
|
|
26
26
|
// exported here so shims can wire them before the engine exists.
|
|
27
|
-
export { webdavFetch, createProviders } from './providers.js';
|
|
27
|
+
export { webdavFetch, createProviders, normalizeEtag } from './providers.js';
|
|
28
28
|
|
|
29
29
|
export {
|
|
30
30
|
createAutoBackupDB,
|
package/src/providers.js
CHANGED
|
@@ -89,6 +89,39 @@ export const webdavFetch = (config) => async (method, targetUrl, authHeaders, bo
|
|
|
89
89
|
});
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// ETag normalization
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalizes an ETag as captured from a GET response so it can be echoed back
|
|
98
|
+
* in If-Match. Proxies and content-encoding filters mangle ETags in ways that
|
|
99
|
+
* make the stored entity's validator unreachable:
|
|
100
|
+
* - Apache mod_deflate/mod_brotli append `-gzip`/`-br` inside the quotes
|
|
101
|
+
* (`"abc"` becomes `"abc-gzip"` on gzip-encoded responses).
|
|
102
|
+
* - nginx's gzip filter downgrades strong ETags to weak (`W/"abc"`), and
|
|
103
|
+
* If-Match requires strong comparison.
|
|
104
|
+
* Either way, a PUT with the mangled value 412s forever. Stripping the weak
|
|
105
|
+
* prefix and the known coding suffixes recovers the entity's real validator.
|
|
106
|
+
*
|
|
107
|
+
* Surrounding quotes are preserved; null/undefined pass through unchanged.
|
|
108
|
+
*
|
|
109
|
+
* @param {string|null|undefined} raw - raw ETag header value
|
|
110
|
+
* @returns {string|null|undefined} normalized ETag
|
|
111
|
+
*/
|
|
112
|
+
export function normalizeEtag(raw) {
|
|
113
|
+
if (raw == null) return raw;
|
|
114
|
+
let etag = raw;
|
|
115
|
+
if (etag.startsWith('W/')) etag = etag.slice(2);
|
|
116
|
+
if (etag.length >= 2 && etag.startsWith('"') && etag.endsWith('"')) {
|
|
117
|
+
const inner = etag.slice(1, -1).replace(/-(gzip|br)$/, '');
|
|
118
|
+
etag = `"${inner}"`;
|
|
119
|
+
} else {
|
|
120
|
+
etag = etag.replace(/-(gzip|br)$/, '');
|
|
121
|
+
}
|
|
122
|
+
return etag;
|
|
123
|
+
}
|
|
124
|
+
|
|
92
125
|
// ---------------------------------------------------------------------------
|
|
93
126
|
// Internal helpers
|
|
94
127
|
// ---------------------------------------------------------------------------
|
|
@@ -96,7 +129,7 @@ export const webdavFetch = (config) => async (method, targetUrl, authHeaders, bo
|
|
|
96
129
|
async function parseDownloadResponse(res, engineConfig) {
|
|
97
130
|
const parsed = await res.json();
|
|
98
131
|
const payload = isEncryptedEnvelope(parsed) ? await decryptData(parsed, engineConfig) : parsed;
|
|
99
|
-
const etag = res.headers.get('etag') || null;
|
|
132
|
+
const etag = normalizeEtag(res.headers.get('etag')) || null;
|
|
100
133
|
return { payload, etag };
|
|
101
134
|
}
|
|
102
135
|
|
package/types/index.d.ts
CHANGED
|
@@ -266,6 +266,14 @@ export interface CloudSyncProvider {
|
|
|
266
266
|
export function webdavFetch(config: SyncEngineConfig): WebdavFetch;
|
|
267
267
|
export function createProviders(config: SyncEngineConfig): Record<string, CloudSyncProvider>;
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Normalizes a raw ETag header value for use in If-Match: strips a weak-
|
|
271
|
+
* validator prefix (`W/"abc"` -> `"abc"`) and the content-coding suffixes
|
|
272
|
+
* Apache mod_deflate/mod_brotli append inside the quotes (`"abc-gzip"` /
|
|
273
|
+
* `"abc-br"` -> `"abc"`). Quotes are preserved; null/undefined pass through.
|
|
274
|
+
*/
|
|
275
|
+
export function normalizeEtag(raw: string | null | undefined): string | null | undefined;
|
|
276
|
+
|
|
269
277
|
// ---------------------------------------------------------------------------
|
|
270
278
|
// Sync engine
|
|
271
279
|
// ---------------------------------------------------------------------------
|