@glance-apps/sync 2.1.0 → 2.1.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,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.1.1] - 2026-10-11
11
+
12
+ ### Fixed
13
+
14
+ - **A `download()` cycle whose upload fails no longer reports success.**
15
+ `upload()` reported and then swallowed every failure except 412, so when
16
+ it ran inside `download()` (seeding an empty remote, or pushing a merge)
17
+ the cycle carried on, stamped the last-synced key, called
18
+ `onLastSyncedChange` and reported `success`. The error showed for five
19
+ seconds before the auto-revert, and from then on "Last synced" kept
20
+ advancing on every poll while nothing reached the server. Reported against
21
+ dayGLANCE (krelltunez/dayGLANCE#2037) with a misconfigured WebDAV folder,
22
+ but any upload failure did it: 403, 409, 5xx, a refused MKCOL, a network
23
+ drop that left GET working. The cycle now ends without the last-synced
24
+ stamp or the success status. The upload's own error report is unchanged.
25
+ Remote changes are still applied locally when only the upload failed, so a
26
+ device that can read but not write keeps receiving; the download backoff
27
+ still resets, because the download half worked.
28
+ - **An upload refused by `validateUploadPayload` inside a cycle** is treated
29
+ the same way: nothing was written, so the cycle is not a success.
30
+ - **A hard-stopped cycle** (`APP_ID_MISMATCH`, `SCHEMA_FORWARD_INCOMPATIBLE`)
31
+ no longer falls through to the success bookkeeping after reporting its
32
+ error.
33
+
34
+ ### Changed
35
+
36
+ - `engine.upload()` resolves to `true` when the provider accepted the write
37
+ and `false` otherwise (it resolved to `undefined`). Callers that ignore the
38
+ result are unaffected.
39
+
10
40
  ## [2.1.0] - 2026-10-11
11
41
 
12
42
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/engine.js CHANGED
@@ -213,17 +213,20 @@ export const createSyncEngine = (config) => {
213
213
  * @param {string} [opts.etag] - If-Match etag for optimistic concurrency.
214
214
  * @param {boolean} [opts.skipLockCheck] - Bypass the in-progress guard (used
215
215
  * when called from within download()'s merge cycle to avoid self-deadlock).
216
+ * @returns {Promise<boolean>} true only when the provider accepted the write.
217
+ * download() reads it: a cycle whose upload did not land must not record
218
+ * "last synced" or report success.
216
219
  */
217
220
  const upload = async ({ prebuiltPayload, etag = null, skipLockCheck = false } = {}) => {
218
- if (hardStopped) return;
221
+ if (hardStopped) return false;
219
222
  const cfg = getConfig();
220
- if (!cfg?.enabled) return;
223
+ if (!cfg?.enabled) return false;
221
224
  if (!skipLockCheck && syncing) {
222
225
  pendingFollowup = true;
223
- return;
226
+ return false;
224
227
  }
225
228
  const provider = providers[cfg.provider];
226
- if (!provider) return;
229
+ if (!provider) return false;
227
230
 
228
231
  if (!skipLockCheck) syncing = true;
229
232
  const t0 = Date.now();
@@ -239,7 +242,7 @@ export const createSyncEngine = (config) => {
239
242
  // eslint-disable-next-line no-console
240
243
  console.error(`[${appId}] cloud sync upload aborted:`, check?.reason || 'validation failed');
241
244
  onStatusChange?.('idle');
242
- return;
245
+ return false;
243
246
  }
244
247
  }
245
248
 
@@ -258,6 +261,7 @@ export const createSyncEngine = (config) => {
258
261
  onLastSyncedChange?.(now);
259
262
  onStatusChange?.('success');
260
263
  scheduleAutoRevert('success', SUCCESS_HOLD_MS);
264
+ return true;
261
265
  } catch (err) {
262
266
  // eslint-disable-next-line no-console
263
267
  console.error(`[${appId}] cloud sync upload error:`, err);
@@ -267,7 +271,7 @@ export const createSyncEngine = (config) => {
267
271
  // When download() is the caller, it handles the 412 retry itself. Bubble.
268
272
  if (skipLockCheck) throw err;
269
273
  onStatusChange?.('idle');
270
- return;
274
+ return false;
271
275
  }
272
276
 
273
277
  if (cls.code === 'AUTH_FAILURE') {
@@ -282,6 +286,7 @@ export const createSyncEngine = (config) => {
282
286
  onError?.(formatErrorMessage(cls), cls.code, cls.isHardStop);
283
287
  onStatusChange?.('error');
284
288
  scheduleAutoRevert('error', ERROR_HOLD_MS);
289
+ return false;
285
290
  } finally {
286
291
  if (!skipLockCheck) syncing = false;
287
292
  }
@@ -311,16 +316,23 @@ export const createSyncEngine = (config) => {
311
316
 
312
317
  let conflictShown = false;
313
318
  let passphraseRequired = false;
319
+ // Set when a cycle's upload did not land (it failed, or validation refused
320
+ // it). upload() has already reported it, so the cycle ends without stamping
321
+ // "last synced" or reporting success: a device whose writes never reach the
322
+ // server must not look synced. Remote changes are still applied locally,
323
+ // as before, so a device that can read but not write keeps receiving.
324
+ let uploadFailed = false;
314
325
 
315
326
  // Inner helper: download, merge, apply, upload (if anything changed).
316
327
  // Returns true if a reload was triggered (caller should bail).
317
328
  // skipIfMatch forces an unconditional upload (no If-Match) — the
318
329
  // last-writer-wins fallback used after two consecutive 412s.
319
330
  const doCycle = async (overrideEtag, { skipIfMatch = false } = {}) => {
331
+ uploadFailed = false;
320
332
  const downloaded = await provider.download(cfg);
321
333
  if (!downloaded) {
322
334
  // Empty remote — seed it with local state.
323
- await upload({ skipLockCheck: true });
335
+ if (!(await upload({ skipLockCheck: true }))) uploadFailed = true;
324
336
  return false;
325
337
  }
326
338
  const { payload: remoteRaw, etag } = downloaded;
@@ -364,11 +376,12 @@ export const createSyncEngine = (config) => {
364
376
  };
365
377
  // Upload first — if this throws (e.g. 412 PRECONDITION_FAILED), the
366
378
  // local DB must remain untouched so the retry starts from clean state.
367
- await upload({
379
+ const uploaded = await upload({
368
380
  prebuiltPayload: mergedPayload,
369
381
  skipLockCheck: true,
370
382
  etag: skipIfMatch ? null : (etag || overrideEtag || null),
371
383
  });
384
+ if (!uploaded) uploadFailed = true;
372
385
 
373
386
  if (localChanged) {
374
387
  let mayApply = true;
@@ -398,8 +411,13 @@ export const createSyncEngine = (config) => {
398
411
  const reloaded = await doCycle(null);
399
412
  if (reloaded || conflictShown) return;
400
413
 
414
+ // A hard stop (foreign or newer envelope) has already reported its
415
+ // error; it is not a successful cycle either.
416
+ if (hardStopped) return;
417
+ // The download half worked, so its backoff resets either way.
401
418
  downloadErrorCount = 0;
402
419
  downloadBackoffUntil = 0;
420
+ if (uploadFailed) return;
403
421
  const elapsed = Date.now() - t0;
404
422
  if (elapsed < MIN_SYNC_DURATION_MS) {
405
423
  await new Promise(r => setTimeout(r, MIN_SYNC_DURATION_MS - elapsed));
package/types/index.d.ts CHANGED
@@ -362,7 +362,7 @@ export interface SyncEngine {
362
362
  prebuiltPayload?: unknown;
363
363
  etag?: string | null;
364
364
  skipLockCheck?: boolean;
365
- }): Promise<void>;
365
+ }): Promise<boolean>;
366
366
  download(): Promise<void>;
367
367
  runBackup(frequency: BackupFrequency): Promise<void>;
368
368
  test(config: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;