@glance-apps/sync 2.1.0 → 2.1.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 +43 -0
- package/package.json +1 -1
- package/src/engine.js +26 -8
- package/src/providers.js +7 -2
- package/types/index.d.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.1.2] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`webdavFetch` awaits `nativeHttpRequest`.** The native path called the
|
|
15
|
+
bridge without `await`, so it only worked with a synchronous bridge, and a
|
|
16
|
+
synchronous bridge blocks the WebView's JS thread (input and rendering) for
|
|
17
|
+
the whole request. On Android that froze dayGLANCE for minutes on cold
|
|
18
|
+
start while a failing sync server timed out
|
|
19
|
+
(krelltunez/dayGLANCE#2048). The bridge may now return its result or a
|
|
20
|
+
Promise of it, as `SYNC_PACKAGE_SPEC.md` already documented; the type
|
|
21
|
+
says so too. A synchronous bridge keeps working unchanged.
|
|
22
|
+
|
|
23
|
+
## [2.1.1] - 2026-10-11
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **A `download()` cycle whose upload fails no longer reports success.**
|
|
28
|
+
`upload()` reported and then swallowed every failure except 412, so when
|
|
29
|
+
it ran inside `download()` (seeding an empty remote, or pushing a merge)
|
|
30
|
+
the cycle carried on, stamped the last-synced key, called
|
|
31
|
+
`onLastSyncedChange` and reported `success`. The error showed for five
|
|
32
|
+
seconds before the auto-revert, and from then on "Last synced" kept
|
|
33
|
+
advancing on every poll while nothing reached the server. Reported against
|
|
34
|
+
dayGLANCE (krelltunez/dayGLANCE#2037) with a misconfigured WebDAV folder,
|
|
35
|
+
but any upload failure did it: 403, 409, 5xx, a refused MKCOL, a network
|
|
36
|
+
drop that left GET working. The cycle now ends without the last-synced
|
|
37
|
+
stamp or the success status. The upload's own error report is unchanged.
|
|
38
|
+
Remote changes are still applied locally when only the upload failed, so a
|
|
39
|
+
device that can read but not write keeps receiving; the download backoff
|
|
40
|
+
still resets, because the download half worked.
|
|
41
|
+
- **An upload refused by `validateUploadPayload` inside a cycle** is treated
|
|
42
|
+
the same way: nothing was written, so the cycle is not a success.
|
|
43
|
+
- **A hard-stopped cycle** (`APP_ID_MISMATCH`, `SCHEMA_FORWARD_INCOMPATIBLE`)
|
|
44
|
+
no longer falls through to the success bookkeeping after reporting its
|
|
45
|
+
error.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- `engine.upload()` resolves to `true` when the provider accepted the write
|
|
50
|
+
and `false` otherwise (it resolved to `undefined`). Callers that ignore the
|
|
51
|
+
result are unaffected.
|
|
52
|
+
|
|
10
53
|
## [2.1.0] - 2026-10-11
|
|
11
54
|
|
|
12
55
|
### Added
|
package/package.json
CHANGED
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/src/providers.js
CHANGED
|
@@ -24,7 +24,8 @@ const toBase64 = (str) => btoa(String.fromCharCode(...new TextEncoder().encode(s
|
|
|
24
24
|
* before deciding whether to pass a function here).
|
|
25
25
|
*
|
|
26
26
|
* @param {object} config
|
|
27
|
-
* @param {Function|null} config.nativeHttpRequest - Android bridge fn, or null
|
|
27
|
+
* @param {Function|null} config.nativeHttpRequest - Android bridge fn, or null.
|
|
28
|
+
* May return its result or a Promise of it; prefer async (see webdavFetch).
|
|
28
29
|
* @param {Function|null} config.electronProxyFetch - Electron proxy fn, or null
|
|
29
30
|
* @param {string} [config.proxyUrl=''] - base URL for CORS proxy
|
|
30
31
|
*/
|
|
@@ -40,7 +41,11 @@ export const webdavFetch = (config) => async (method, targetUrl, authHeaders, bo
|
|
|
40
41
|
if (body !== undefined && body !== null) {
|
|
41
42
|
headers['Content-Type'] = extraHeaders['Content-Type'] || 'application/octet-stream';
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
// Awaited: the bridge may return its result or a Promise of it. A
|
|
45
|
+
// synchronous bridge blocks the WebView's JS thread (input and rendering)
|
|
46
|
+
// for the whole request, so hosts should supply an async one
|
|
47
|
+
// (krelltunez/dayGLANCE#2048); awaiting a plain value is harmless.
|
|
48
|
+
const result = await config.nativeHttpRequest(method, targetUrl, headers, body ?? '');
|
|
44
49
|
if (!result) throw new Error('Native HTTP bridge unavailable');
|
|
45
50
|
return {
|
|
46
51
|
status: result.status,
|
package/types/index.d.ts
CHANGED
|
@@ -92,7 +92,7 @@ export type NativeHttpRequest = (
|
|
|
92
92
|
url: string,
|
|
93
93
|
headers: Record<string, string>,
|
|
94
94
|
body: string | null,
|
|
95
|
-
) => NativeHttpResponse | null
|
|
95
|
+
) => NativeHttpResponse | null | Promise<NativeHttpResponse | null>;
|
|
96
96
|
|
|
97
97
|
export type ElectronProxyFetch = (
|
|
98
98
|
method: string,
|
|
@@ -362,7 +362,7 @@ export interface SyncEngine {
|
|
|
362
362
|
prebuiltPayload?: unknown;
|
|
363
363
|
etag?: string | null;
|
|
364
364
|
skipLockCheck?: boolean;
|
|
365
|
-
}): Promise<
|
|
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 }>;
|