@glance-apps/sync 1.5.3 → 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 CHANGED
@@ -7,6 +7,105 @@ 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
+
50
+ ## [1.6.0] - 2026-07-10
51
+
52
+ ### Fixed
53
+
54
+ - **DB engine: a pulled delete no longer unconditionally beats a newer local
55
+ edit.** `applyRemoteRow` applied a pulled `deleted: true` row without any
56
+ timestamp comparison AND pruned the entity from the dirty set, so a device
57
+ that edited an entity offline (with a newer `lastModified`) and then pulled a
58
+ peer's delete before pushing silently discarded its own newer edit — the
59
+ opposite of the engine's upsert conflict rule and of the file tier's
60
+ newest-write-wins over tombstones. Deletes now participate in the same
61
+ entity-grain LWW: the engine stamps each pushed soft-delete with a
62
+ `deletedAt` (epoch ms, taken at push time) and applies a pulled delete only
63
+ when the local copy's `lastModified` does not exceed that stamp. When the
64
+ local edit is strictly newer, the delete is discarded and the entity is
65
+ marked (or kept) dirty so the next push re-upserts it over the tombstone and
66
+ restores the row fleet-wide.
67
+ - **Tie-break:** on an exact timestamp tie the DELETE wins (unlike the upsert
68
+ rule, where local wins ties), because `deletedAt` is stamped at push time
69
+ and therefore already post-dates the deleting device's own last edit.
70
+ - **Backward compatibility:** a tombstone with no `deletedAt` — from a
71
+ GLANCEvault server that predates the stamp, or a row deleted by an older
72
+ client — keeps the previous unconditional delete-wins behavior. `deletedAt`
73
+ rides the DELETE request as an extra query param, which old servers ignore.
74
+ - **DB engine: push sends upserts before deletes, closing the transient
75
+ fleet-wide delete window.** `pushDirtyRows` used to issue the per-entity
76
+ `deleteRow` calls before the batched upserts. A cross-list move (delete
77
+ `unscheduledTasks:X` + upsert `tasks:X` in the same dirty set) that failed
78
+ between the two steps (5xx / network drop) left the delete on the server with
79
+ no replacement row, so every peer pulled a bare delete and the entity
80
+ vanished fleet-wide until the origin device successfully retried. The batch
81
+ upsert now runs first; a failure between the steps leaves the benign inverse
82
+ (both rows briefly exist) and the retained dirty set retries both
83
+ idempotently. No invariant depended on the old order: upserts and deletes in
84
+ one push can never share an `entityId`, and `maxSeq`/ack accounting is
85
+ order-independent.
86
+
87
+ ### Changed — behavior notes for consumers
88
+
89
+ - Delete/edit races now resolve by newest-write-wins instead of delete-always-
90
+ wins. An entity edited on one device *after* (per wall clock) another
91
+ device's deletion will be **restored on all devices** rather than deleted.
92
+ Apps relying on "delete always sticks" semantics should treat this as a
93
+ breaking behavior change. Clock skew caveat: `deletedAt` comes from the
94
+ deleting device's clock at push time, so a device with a fast clock can win
95
+ conflicts it shouldn't — same trust model as the existing `lastModified` LWW.
96
+ - During a partial push failure, peers may transiently observe a moved entity
97
+ in both its old and new lists (previously: in neither) until the retry lands.
98
+ - Unchanged (regression-guarded): upsert LWW keeps local on ties and prunes a
99
+ remotely-superseded entity from the dirty set; the split pull/push cursors
100
+ and their persistence keys; the exported `getRow` vault surface and
101
+ `decryptEntity`; push idempotency for re-sent upserts and soft-deletes.
102
+
103
+ ### Added
104
+
105
+ - `createVaultClient().deleteRow(app, entityId, accountId, { deletedAt })` —
106
+ optional fourth argument; the stamp is sent as a `deletedAt` query param.
107
+ `VaultPulledRow` gains an optional `deletedAt` field.
108
+
10
109
  ## [1.5.3] - 2026-07-04
11
110
 
12
111
  ### Fixed
@@ -315,7 +414,9 @@ entire sync infrastructure can be shared across the GLANCE app family.
315
414
  - TypeScript declarations in `types/index.d.ts`.
316
415
  - WebDAV CORS proxy handler in `api/webdav-proxy.js`.
317
416
 
318
- [Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.5.3...HEAD
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
419
+ [1.6.0]: https://github.com/glance-apps/glance-sync/compare/v1.5.3...v1.6.0
319
420
  [1.5.3]: https://github.com/glance-apps/glance-sync/compare/v1.5.2...v1.5.3
320
421
  [1.5.2]: https://github.com/glance-apps/glance-sync/compare/v1.5.1...v1.5.2
321
422
  [1.1.2]: https://github.com/glance-apps/glance-sync/compare/v1.1.1...v1.1.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.5.3",
3
+ "version": "1.6.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/dbEngine.js CHANGED
@@ -373,18 +373,34 @@ export const createDbSyncEngine = (config) => {
373
373
  }
374
374
  }
375
375
 
376
- // Soft-delete first, then batch the upserts. If any call throws, we fall out
377
- // before clearing the dirty set or advancing the push-ack marker.
376
+ // Batch the upserts FIRST, then soft-delete. If any call throws, we fall
377
+ // out before clearing the dirty set or advancing the push-ack marker.
378
+ //
379
+ // The order matters for partial-failure safety: a cross-list move puts a
380
+ // delete ("old-list:X") and its replacement upsert ("new-list:X") in the
381
+ // same dirty set. Deleting first opened a window where the delete landed,
382
+ // the batch failed, and every peer pulled a delete with no replacement —
383
+ // the entity vanished fleet-wide until this device successfully retried.
384
+ // Upserting first inverts the partial state into a benign one (both rows
385
+ // briefly exist) that the retried delete cleans up. Nothing depends on
386
+ // deletes landing first: upserts and deletes in one push never share an
387
+ // entityId (an entity is either present locally or not), and maxSeq is
388
+ // order-independent.
389
+ //
390
+ // Each delete carries a deletedAt stamp so pulling devices can run LWW
391
+ // against it (see applyRemoteRow). Servers that predate the stamp ignore
392
+ // the extra field, in which case pulled tombstones simply lack deletedAt
393
+ // and fall back to the old delete-wins behavior.
378
394
  let maxSeq = getPushAck();
379
- for (const entityId of deletes) {
380
- const r = await vault.deleteRow(app, entityId, accountId);
381
- if (r && typeof r.seq === 'number') maxSeq = Math.max(maxSeq, r.seq);
382
- if (r && typeof r.maxSeq === 'number') maxSeq = Math.max(maxSeq, r.maxSeq);
383
- }
384
395
  if (upserts.length > 0) {
385
396
  const result = await vault.batch(app, { accountId, rows: upserts });
386
397
  if (result && typeof result.maxSeq === 'number') maxSeq = Math.max(maxSeq, result.maxSeq);
387
398
  }
399
+ for (const entityId of deletes) {
400
+ const r = await vault.deleteRow(app, entityId, accountId, { deletedAt: Date.now() });
401
+ if (r && typeof r.seq === 'number') maxSeq = Math.max(maxSeq, r.seq);
402
+ if (r && typeof r.maxSeq === 'number') maxSeq = Math.max(maxSeq, r.maxSeq);
403
+ }
388
404
 
389
405
  // Full acknowledgment: safe to mark clean. Advance the push-ack marker only;
390
406
  // the pull cursor stays put so the next pull still lists from the highest
@@ -401,6 +417,29 @@ export const createDbSyncEngine = (config) => {
401
417
  // failure — the caller decides whether to quarantine.
402
418
  const applyRemoteRow = async (R, dirty) => {
403
419
  if (R.deleted) {
420
+ // A tombstone participates in the same entity-grain LWW as an upsert,
421
+ // using the deletedAt stamp the pushing device attached (see
422
+ // pushDirtyRows). A local copy KEEPS only when its lastModified strictly
423
+ // exceeds deletedAt: the delete wins ties, because deletedAt is stamped
424
+ // at push time and therefore already post-dates the edit that produced
425
+ // it on the deleting device. Tombstones from servers/devices that
426
+ // predate the stamp carry no deletedAt (ts -> 0); those keep the old
427
+ // unconditional delete-wins behavior.
428
+ const deletedAt = ts(R.deletedAt);
429
+ if (deletedAt > 0) {
430
+ const local = await getLocalEntity(R.entityId);
431
+ if (local != null && ts(lastModifiedOf(local)) > deletedAt) {
432
+ // The local edit is newer than the delete: keep it, and make sure it
433
+ // is dirty so the next push re-upserts it over the tombstone and
434
+ // restores the row fleet-wide (matching the file tier's
435
+ // newest-write-wins over tombstones).
436
+ if (!dirty.has(R.entityId)) {
437
+ dirty.add(R.entityId);
438
+ writeDirtySet([...dirty]);
439
+ }
440
+ return false;
441
+ }
442
+ }
404
443
  await applyRemoteDelete(R.entityId);
405
444
  if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
406
445
  return true;
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
- const doCycle = async (overrideEtag) => {
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(retryErr)), classifyError(retryErr).code, false);
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
 
@@ -10,7 +10,7 @@
10
10
  // POST /sync/:app/batch upsert rows, returns { written, maxSeq }
11
11
  // GET /sync/:app/list?accountId=&since= incremental fetch, { rows, hasMore }
12
12
  // GET /sync/:app/:entityId?accountId= fetch one row
13
- // DELETE /sync/:app/:entityId?accountId= soft-delete a row
13
+ // DELETE /sync/:app/:entityId?accountId=&deletedAt= soft-delete a row (deletedAt optional)
14
14
  // POST /sync/:app/device update device cursor, { updated }
15
15
  // GET /salt/:accountId fetch the account root-key salt
16
16
  // PUT /salt/:accountId register a salt (first-write-wins)
@@ -127,11 +127,18 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
127
127
  /**
128
128
  * Soft-deletes a row by entityId. Returns the server response (may include
129
129
  * the new seq for the tombstone).
130
+ *
131
+ * opts.deletedAt (epoch ms) stamps the tombstone so pulling devices can
132
+ * resolve delete-vs-edit conflicts with LWW. It rides as a query param, so
133
+ * servers that predate the stamp simply ignore it (their tombstones come
134
+ * back without deletedAt and the engine falls back to delete-wins).
130
135
  */
131
- async deleteRow(app, entityId, accountId) {
136
+ async deleteRow(app, entityId, accountId, opts = {}) {
132
137
  requireAccountId(accountId, 'delete row');
138
+ const query = { accountId };
139
+ if (opts && opts.deletedAt != null) query.deletedAt = String(opts.deletedAt);
133
140
  const res = await request('DELETE', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
134
- query: { accountId },
141
+ query,
135
142
  });
136
143
  if (res.status === 404) return null;
137
144
  return jsonOrThrow(res, 'delete row');
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
  // ---------------------------------------------------------------------------
@@ -396,6 +404,8 @@ export interface VaultPulledRow extends Partial<VaultRow> {
396
404
  entityId: string;
397
405
  seq: number;
398
406
  deleted?: boolean;
407
+ /** Tombstone LWW stamp (epoch ms or date string). Absent on rows from servers that predate it: the engine then treats the delete as unconditionally winning. */
408
+ deletedAt?: number | string;
399
409
  envelope?: string;
400
410
  }
401
411
 
@@ -403,7 +413,7 @@ export interface VaultClient {
403
413
  batch(app: string, args: { accountId: string; rows: VaultRow[] }): Promise<{ written: number; maxSeq: number }>;
404
414
  list(app: string, args: { accountId: string; since: number }): Promise<{ rows: VaultPulledRow[]; hasMore: boolean }>;
405
415
  getRow(app: string, entityId: string, accountId: string): Promise<VaultPulledRow | null>;
406
- deleteRow(app: string, entityId: string, accountId: string): Promise<{ seq?: number } | null>;
416
+ deleteRow(app: string, entityId: string, accountId: string, opts?: { deletedAt?: number }): Promise<{ seq?: number } | null>;
407
417
  device(app: string, args: { accountId: string; deviceId: string; lastSeenSeq: number }): Promise<{ updated: boolean }>;
408
418
  getSalt(accountId: string): Promise<Uint8Array | null>;
409
419
  putSalt(accountId: string, salt: Uint8Array): Promise<Uint8Array>;