@glance-apps/sync 1.8.0 → 1.11.0

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/src/dbEngine.js CHANGED
@@ -36,6 +36,35 @@ const ts = (v) => {
36
36
  return Number.isNaN(t) ? 0 : t;
37
37
  };
38
38
 
39
+ // ── The backoff ladder ──────────────────────────────────────────────────────
40
+ //
41
+ // ONE windowing mechanism for every recoverable failure, carrying a `reason`.
42
+ // It replaces the separate quota-suppression window added in 3.3, which was
43
+ // already built to this shape — this consolidates two parallel things rather
44
+ // than forcing two different ones together.
45
+ //
46
+ // The numbers, the escalation curve, the reset rule and the in-memory
47
+ // lifetime are the FILE TIER'S (src/engine.js): 30s doubling to a 15-minute
48
+ // cap for writes and a 5-minute cap for reads, a flat hour on an auth
49
+ // failure, and any success resets both the counter and the window. That file
50
+ // was read and deliberately not refactored.
51
+ //
52
+ // The ONE thing that differs, and the reason this fix exists: the file tier's
53
+ // backoff is ADVISORY — nothing reads uploadBackoffUntil except its getter,
54
+ // so honouring it is the calling app's job. That is why "the DB engine
55
+ // hammers" is live in every published version. Here the engine ENFORCES its
56
+ // own window (it skips the work when called too soon, exactly as 3.3's quota
57
+ // suppression did) AND exposes the same getters, so a scheduler can still be
58
+ // smart without being obliged to be.
59
+ //
60
+ // A window never becomes terminal: it only ever delays. Whatever the
61
+ // interval, the next ordinary cycle after it expires probes again, with no
62
+ // user action and no restart.
63
+ const BACKOFF_BASE_S = 30;
64
+ const MAX_PUSH_BACKOFF_S = 15 * 60; // writes, matching the file tier's upload cap
65
+ const MAX_PULL_BACKOFF_S = 5 * 60; // reads, matching the file tier's download cap
66
+ const AUTH_BACKOFF_MS = 60 * 60 * 1000; // flat hour, matching AUTH_FAILURE_BACKOFF_MS
67
+
39
68
  // randomUUID is missing in some non-secure-context WebViews; getRandomValues
40
69
  // is everywhere the rest of this package's crypto already runs.
41
70
  const randomUUID = () => {
@@ -197,6 +226,22 @@ export const createDbSyncEngine = (config) => {
197
226
  // stale reference the app forgot after recovery dies quietly instead of
198
227
  // hammering the server with a dead credential.
199
228
  let supersededInert = false;
229
+ // The two backoff windows. IN MEMORY ONLY, like the file tier's: a restart
230
+ // clears them, which is the right default — a relaunch is a user signalling
231
+ // "try now", and the failure re-opens the window immediately if it persists.
232
+ // Nothing here is persisted, so nothing can outlive the condition it
233
+ // describes or need a clearing path.
234
+ const newWindow = () => ({
235
+ until: 0, // epoch ms; the work is skipped while this is in the future
236
+ strikes: 0, // consecutive failures, driving the escalation
237
+ reason: null, // 'quota' | 'auth' | 'transport'
238
+ code: null, // the typed error code that opened it
239
+ message: null,
240
+ quota: null, // the parsed descriptor, when reason === 'quota'
241
+ since: null, // ISO of the most recent failure
242
+ });
243
+ const pushBackoff = newWindow();
244
+ const pullBackoff = newWindow();
200
245
  // Per-session cache: once the derived root key is proven against the account's
201
246
  // existing data (Part A), we never re-verify for the life of this engine.
202
247
  let verified = false;
@@ -293,6 +338,132 @@ export const createDbSyncEngine = (config) => {
293
338
  localStorage.setItem(KEY_CRED_HALT, JSON.stringify({ message, at: new Date().toISOString() }));
294
339
  };
295
340
 
341
+ // ── The backoff ladder: classify, open, clear ─────────────────────────────
342
+
343
+ // WHICH FAILURES OPEN A WINDOW. Returns null for the classes that must NOT
344
+ // be delayed:
345
+ // - Readiness (PASSPHRASE_REQUIRED / ACCOUNT_ID_REQUIRED) fires BEFORE any
346
+ // network call and clears the instant the app supplies the value. A
347
+ // 30-second delay after a user types their passphrase would read as
348
+ // broken. The file tier has no equivalent class, so copying it faithfully
349
+ // would have missed this.
350
+ // - CREDENTIAL_INVALID is owned by the credential halt, which is terminal.
351
+ // Stacking a window on top would be meaningless at best and could soften
352
+ // a terminal state at worst.
353
+ const backoffReasonFor = (err) => {
354
+ const code = err && err.code;
355
+ if (code === 'PASSPHRASE_REQUIRED' || code === 'ACCOUNT_ID_REQUIRED') return null;
356
+ if (code === 'CREDENTIAL_INVALID') return null;
357
+ // QUOTA_EXCEEDED keeps its own reason so its descriptor keeps surfacing —
358
+ // but it is ONE window, not a second one stacked on a transport window.
359
+ if (code === 'QUOTA_EXCEEDED') return 'quota';
360
+ // A shared-mode 401 ("invalid device token") is a wrong/rotated token: it
361
+ // will not fix itself in 30 seconds and hammering an auth endpoint is the
362
+ // worst kind of noise, so it gets the file tier's flat hour. This is NOT
363
+ // the credential halt — it stays retryable and self-resuming.
364
+ //
365
+ // `cause` is consulted because the key verifier re-wraps whatever it hits
366
+ // as VERIFIER_UNSUPPORTED (keeping the original on err.cause), which would
367
+ // otherwise hide a 401 behind a fresh error carrying no status.
368
+ if (err && (err.status === 401 || (err.cause && err.cause.status === 401))) return 'auth';
369
+ return 'transport';
370
+ };
371
+
372
+ const openWindow = (w, err, reason, maxSeconds) => {
373
+ w.strikes += 1;
374
+ const ms = reason === 'auth'
375
+ ? AUTH_BACKOFF_MS
376
+ : Math.min(BACKOFF_BASE_S * Math.pow(2, w.strikes - 1), maxSeconds) * 1000;
377
+ w.until = Date.now() + ms;
378
+ w.reason = reason;
379
+ w.code = (err && err.code) || 'NETWORK_ERROR';
380
+ w.message = err?.message || String(err);
381
+ w.quota = reason === 'quota' && err && err.quota ? err.quota : null;
382
+ w.since = new Date().toISOString();
383
+ };
384
+
385
+ const clearWindow = (w) => {
386
+ w.until = 0; w.strikes = 0; w.reason = null;
387
+ w.code = null; w.message = null; w.quota = null; w.since = null;
388
+ };
389
+
390
+ const isOpen = (w) => w.until > Date.now();
391
+
392
+ // Record a failure against a window, honouring the do-not-delay classes.
393
+ // Returns the reason, or null when the failure opened no window.
394
+ const recordFailure = (w, err, maxSeconds) => {
395
+ const reason = backoffReasonFor(err);
396
+ if (reason) openWindow(w, err, reason, maxSeconds);
397
+ return reason;
398
+ };
399
+
400
+ // Over-quota state, preserved exactly as 3.3 exposed it: non-null from the
401
+ // rejection until a write actually succeeds (not merely until the window
402
+ // expires), so an app can keep rendering "X of Y used" while the client
403
+ // probes. Now derived from the push window rather than a parallel variable.
404
+ const getQuotaState = () => {
405
+ if (pushBackoff.reason !== 'quota') return null;
406
+ const d = pushBackoff.quota;
407
+ return {
408
+ quota: d ? d.quota : 'unknown',
409
+ limit: d ? d.limit : null,
410
+ used: d ? d.used : null,
411
+ requested: d ? d.requested : null,
412
+ message: pushBackoff.message,
413
+ since: pushBackoff.since,
414
+ retryAt: new Date(pushBackoff.until).toISOString(),
415
+ };
416
+ };
417
+ const isQuotaSuppressed = () => pushBackoff.reason === 'quota' && isOpen(pushBackoff);
418
+
419
+ // PER-REASON SURFACING on a cycle whose work was SKIPPED because a window is
420
+ // open. A quota window keeps surfacing QUOTA_EXCEEDED with its descriptor
421
+ // every cycle, because apps render "X of Y used" from it and a UI built
422
+ // after the rejection must still learn about it. A transport or auth window
423
+ // stays quiet after its first signal — the failure was already reported and
424
+ // repeating it every cycle is the noise this fix exists to remove. The
425
+ // status still reads 'error' in both cases: sync genuinely is not working.
426
+ //
427
+ // APP-INTEGRATION CONSEQUENCE (deliberate, do not "fix" by re-surfacing):
428
+ // because the cycle's onError(null) reset still fires on suppressed cycles,
429
+ // a quiet window means the app's LAST onError MESSAGE is cleared while
430
+ // onStatusChange reads 'error'. An app must therefore render standing
431
+ // backoff from getBackoffState() (reason + until — "retrying in Xs"), not
432
+ // from a remembered onError string. Surface this when integrating the
433
+ // three apps.
434
+ const surfaceStandingWindow = (w) => {
435
+ if (w.reason === 'quota') onError?.(w.message, 'QUOTA_EXCEEDED', false);
436
+ };
437
+
438
+ // The credential-rejection path, extracted UNCHANGED from the cycle's catch
439
+ // so a 401 surfacing from the push or the pull reaches exactly the same
440
+ // logic it always reached from ensureRootKey. Nothing about the halt, the
441
+ // identity rule, or the superseded-inertness decision is altered — this is
442
+ // only about where the error is allowed to arrive from. Under NO
443
+ // circumstances does the engine re-enroll here; recovery is user-initiated
444
+ // (recoverVaultSyncEngine).
445
+ // Returns the truthful result flag for the cycle to spread — { superseded }
446
+ // when this instance went inert, { halted } when the device halted — so the
447
+ // cycle result matches what isSuperseded()/isCredentialHalted() report,
448
+ // whichever path the rejection arrived by.
449
+ const applyCredentialRejection = (err) => {
450
+ const message = err?.message || String(err);
451
+ let outcome;
452
+ if (isBearerSuperseded()) {
453
+ // Replaced by recovery and the app kept a stale reference: go inert in
454
+ // memory — surfaced once, then silent — leaving the shared halt key
455
+ // alone so the recovered engine keeps syncing.
456
+ supersededInert = true;
457
+ outcome = { superseded: true };
458
+ } else {
459
+ setCredentialHalt(message);
460
+ outcome = { halted: true };
461
+ }
462
+ onError?.(message, 'CREDENTIAL_INVALID', true);
463
+ onStatusChange?.('error');
464
+ return outcome;
465
+ };
466
+
296
467
  // The halt-set identity rule (Phase 2.2). The halt key is shared by every
297
468
  // engine instance on this device, so a STALE instance — one still holding a
298
469
  // credential that recovery has since replaced — must not be able to re-set
@@ -355,8 +526,13 @@ export const createDbSyncEngine = (config) => {
355
526
  // Likewise a rejected credential (401 "invalid credential") is an auth
356
527
  // outcome, not a capability one: pass it through so the cycle's halt
357
528
  // handling sees it — and so allowUnverified can never wave a revoked
358
- // device through.
359
- const isPassThrough = (err) => isClientNotReady(err) || (err && err.code === 'CREDENTIAL_INVALID');
529
+ // device through. A quota rejection (Phase 3.3) is the same kind of
530
+ // mistake to make: the verifier's establishing write is a net-new entity,
531
+ // so an account at the row cap gets a 413 here, and relabelling it
532
+ // VERIFIER_UNSUPPORTED tells the user to update a server that is working
533
+ // correctly. Pass it through so the cycle reports the truth.
534
+ const isPassThrough = (err) =>
535
+ isClientNotReady(err) || (err && (err.code === 'CREDENTIAL_INVALID' || err.code === 'QUOTA_EXCEEDED'));
360
536
 
361
537
  const verifierUnsupported = (cause) => {
362
538
  if (isPassThrough(cause)) throw cause;
@@ -652,11 +828,22 @@ export const createDbSyncEngine = (config) => {
652
828
  if (typeof R.seq === 'number') since = Math.max(since, R.seq);
653
829
  }
654
830
 
831
+ // Persist the cursor PER PAGE, not once at the end. Every row in a page
832
+ // reaches a terminal outcome before we get here — applied, quarantined,
833
+ // or skipped as engine-reserved — so the cursor may safely advance past
834
+ // the whole page. Previously a failure on page 3 discarded the progress
835
+ // of pages 1 and 2, so a large backlog on a flaky connection could
836
+ // re-download the same pages forever and never converge; that is exactly
837
+ // the situation backoff is meant to help, so the two changes belong
838
+ // together.
839
+ setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
840
+
655
841
  hasMore = !!more;
656
842
  }
657
843
 
658
844
  // Pull is the sole writer of the pull cursor. Advance it (monotonically)
659
- // from the highest seq we actually listed this run.
845
+ // from the highest seq we actually listed this run. Retained for the
846
+ // zero-page case, and harmless after the per-page advance above.
660
847
  setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
661
848
  return { maxSeq, appliedRemote, applied, skipped, skippedEntityIds };
662
849
  };
@@ -702,52 +889,135 @@ export const createDbSyncEngine = (config) => {
702
889
  return { applied: 0, skipped: 0, skippedEntityIds: [], halted: true };
703
890
  }
704
891
  if (syncing) return;
892
+
893
+ // Nothing at all can proceed while the key is unverified AND both windows
894
+ // are open: proving the key needs the network, and both directions are
895
+ // backed off. (Once verified in-session, ensureRootKey is a local check,
896
+ // so a single open window only skips its own half of the cycle.)
897
+ if (!verified && isOpen(pushBackoff) && isOpen(pullBackoff)) {
898
+ surfaceStandingWindow(pushBackoff);
899
+ onStatusChange?.('error');
900
+ return {
901
+ applied: 0, skipped: 0, skippedEntityIds: [],
902
+ pushSkipped: true, pullSkipped: true, quota: getQuotaState(),
903
+ };
904
+ }
905
+
705
906
  syncing = true;
706
907
  onStatusChange?.('uploading');
707
908
  onError?.(null, null, false);
708
909
  try {
709
910
  // Part A first: prove the key before we push anything. A globally wrong
710
911
  // key aborts here with KEY_MISMATCH (push gated, nothing uploaded) rather
711
- // than quarantining thousands of rows one by one on the pull.
912
+ // than quarantining thousands of rows one by one on the pull. A failure
913
+ // here blocks BOTH directions, so it opens both windows (see the catch).
712
914
  await ensureRootKey();
713
915
 
714
- await pushDirtyRows();
916
+ // PARTIAL FUNCTION (3.3, extended here to the pull). A failure in one
917
+ // direction must never cost the other: the push error is captured, the
918
+ // pull runs, and the pull's own error is captured too so the cursor
919
+ // report still happens. The dirty set is untouched by a failed push
920
+ // (pushDirtyRows only clears it on full ack), so backoff changes WHEN
921
+ // rows are retried, never WHETHER they are.
922
+ let pushError = null;
923
+ let pushSkipped = false;
924
+ if (isOpen(pushBackoff)) {
925
+ pushSkipped = true;
926
+ } else {
927
+ try {
928
+ await pushDirtyRows();
929
+ // Any success resets the window and its strike count, exactly as the
930
+ // file tier does. For a quota window this is also what clears the
931
+ // over-quota state: the operator acted, and no client action was
932
+ // needed or taken.
933
+ clearWindow(pushBackoff);
934
+ } catch (err) {
935
+ pushError = err;
936
+ recordFailure(pushBackoff, err, MAX_PUSH_BACKOFF_S);
937
+ }
938
+ }
939
+
715
940
  onStatusChange?.('downloading');
716
- const pull = await pullRemoteChanges();
717
- await updateDeviceCursor();
941
+ let pullError = null;
942
+ let pullSkipped = false;
943
+ let pull = { applied: 0, skipped: 0, skippedEntityIds: [] };
944
+ if (isOpen(pullBackoff)) {
945
+ pullSkipped = true;
946
+ } else {
947
+ try {
948
+ pull = await pullRemoteChanges();
949
+ clearWindow(pullBackoff);
950
+ } catch (err) {
951
+ pullError = err;
952
+ recordFailure(pullBackoff, err, MAX_PULL_BACKOFF_S);
953
+ }
954
+ // The cursor report runs whenever the pull was ATTEMPTED, success or
955
+ // failure — that is the 3.3 decoupling applied to the pull. It is
956
+ // skipped only when the pull itself was skipped, because firing a
957
+ // write every cycle at a server we are backing off from is the exact
958
+ // hammering this fix removes.
959
+ await updateDeviceCursor();
960
+ }
961
+
962
+ // A rejected credential can surface from the push or the pull once the
963
+ // key is verified in-session (ensureRootKey then makes no network call,
964
+ // so it is no longer the first thing to see a 401). Route it to the
965
+ // halt path from wherever it surfaced — the halt logic itself is
966
+ // untouched.
967
+ const credentialRejection =
968
+ (pushError && pushError.code === 'CREDENTIAL_INVALID' && pushError) ||
969
+ (pullError && pullError.code === 'CREDENTIAL_INVALID' && pullError) || null;
970
+ if (credentialRejection) {
971
+ const outcome = applyCredentialRejection(credentialRejection);
972
+ return { applied: pull.applied, skipped: pull.skipped, skippedEntityIds: pull.skippedEntityIds, ...outcome };
973
+ }
974
+
975
+ if (pull.skipped > 0) onRowsSkipped?.(pull.skipped, pull.skippedEntityIds);
976
+
977
+ // lastSynced means "this device is fully in step with the server", so a
978
+ // cycle with any failed or skipped half does not claim it.
979
+ if (pushError || pushSkipped || pullError || pullSkipped) {
980
+ // Report the fresh failure if there was one; otherwise this is a
981
+ // suppressed cycle, and only a quota window keeps speaking.
982
+ if (pushError || pullError) {
983
+ const err = pushError || pullError;
984
+ onError?.(err?.message || String(err), (err && err.code) || 'NETWORK_ERROR', false);
985
+ } else {
986
+ surfaceStandingWindow(pushSkipped ? pushBackoff : pullBackoff);
987
+ }
988
+ onStatusChange?.('error');
989
+ return {
990
+ applied: pull.applied,
991
+ skipped: pull.skipped,
992
+ skippedEntityIds: pull.skippedEntityIds,
993
+ pushFailed: !!pushError,
994
+ pushSkipped,
995
+ pushErrorCode: pushError ? ((pushError.code) || 'NETWORK_ERROR') : (pushSkipped ? pushBackoff.code : undefined),
996
+ pullFailed: !!pullError,
997
+ pullSkipped,
998
+ pullErrorCode: pullError ? ((pullError.code) || 'NETWORK_ERROR') : (pullSkipped ? pullBackoff.code : undefined),
999
+ quota: getQuotaState(),
1000
+ };
1001
+ }
718
1002
 
719
1003
  const now = new Date().toISOString();
720
1004
  localStorage.setItem(KEY_LAST_SYNCED, now);
721
1005
  onStatusChange?.('success');
722
1006
 
723
- if (pull.skipped > 0) onRowsSkipped?.(pull.skipped, pull.skippedEntityIds);
724
1007
  return { applied: pull.applied, skipped: pull.skipped, skippedEntityIds: pull.skippedEntityIds };
725
1008
  } catch (err) {
1009
+ // Only ensureRootKey reaches here now (push and pull are captured
1010
+ // above). It gates both directions, so its failure opens both windows.
726
1011
  // eslint-disable-next-line no-console
727
1012
  console.error(`[${appId}] db sync cycle error:`, err);
728
1013
  const code = err && err.code ? err.code : 'NETWORK_ERROR';
729
1014
  if (code === 'CREDENTIAL_INVALID') {
730
- // The server rejected this engine's credential. Under NO circumstances
731
- // does the engine re-enroll here: enrollment is non-idempotent, so an
732
- // automatic retry would mint a server row per failure and would let a
733
- // revoked device silently re-admit itself. Recovery is user-initiated
734
- // only (recoverVaultSyncEngine).
735
- if (isBearerSuperseded()) {
736
- // This instance was replaced by recovery and the app kept a stale
737
- // reference. Go inert in memory — surfaced once, then silent — and
738
- // leave the shared halt key alone so the recovered engine keeps
739
- // syncing.
740
- supersededInert = true;
741
- onError?.(err?.message || String(err), 'CREDENTIAL_INVALID', true);
742
- } else {
743
- // Persist the stop so no future cycle retries (a rejected
744
- // credential never heals by retry) and surface it as a hard stop.
745
- setCredentialHalt(err?.message || String(err));
746
- onError?.(err?.message || String(err), 'CREDENTIAL_INVALID', true);
747
- }
748
- } else {
749
- onError?.(err?.message || String(err), code, false);
1015
+ const outcome = applyCredentialRejection(err);
1016
+ return { applied: 0, skipped: 0, skippedEntityIds: [], ...outcome };
750
1017
  }
1018
+ const reason = recordFailure(pushBackoff, err, MAX_PUSH_BACKOFF_S);
1019
+ if (reason) recordFailure(pullBackoff, err, MAX_PULL_BACKOFF_S);
1020
+ onError?.(err?.message || String(err), code, false);
751
1021
  onStatusChange?.('error');
752
1022
  return { applied: 0, skipped: 0, skippedEntityIds: [] };
753
1023
  } finally {
@@ -797,6 +1067,27 @@ export const createDbSyncEngine = (config) => {
797
1067
  // True once this instance proved its bearer was superseded by recovery
798
1068
  // and went inert (in-memory only; a fresh instance is unaffected).
799
1069
  isSuperseded: () => supersededInert,
1070
+ // Over-quota state (Phase 3.3): null when clear, else the parsed
1071
+ // descriptor plus when the next probe is due. In-memory only, never a
1072
+ // hard stop, and there is nothing to clear — it lifts by itself once the
1073
+ // server accepts a write again. Now derived from the push backoff window.
1074
+ getQuotaState,
1075
+ isQuotaSuppressed,
1076
+
1077
+ // Backoff. The timestamps use the file tier's getter names and meaning
1078
+ // (epoch ms before which that direction will not be attempted), so a
1079
+ // scheduler can treat both tiers identically. Unlike the file tier the
1080
+ // engine ALSO enforces them itself, so honouring them is optional.
1081
+ getUploadBackoffUntil: () => pushBackoff.until,
1082
+ getDownloadBackoffUntil: () => pullBackoff.until,
1083
+ // The full picture, because the timestamp alone cannot distinguish a
1084
+ // one-hour auth backoff from a thirty-second transport one without
1085
+ // inferring from its magnitude. `reason` is 'quota' | 'auth' |
1086
+ // 'transport'; `code` is the typed error that opened the window.
1087
+ getBackoffState: () => ({
1088
+ push: { until: pushBackoff.until, reason: pushBackoff.reason, code: pushBackoff.code, strikes: pushBackoff.strikes, since: pushBackoff.since },
1089
+ pull: { until: pullBackoff.until, reason: pullBackoff.reason, code: pullBackoff.code, strikes: pullBackoff.strikes, since: pullBackoff.since },
1090
+ }),
800
1091
  deviceId: resolvedDeviceId,
801
1092
 
802
1093
  // Sub-modules
package/src/index.js CHANGED
@@ -47,6 +47,14 @@ export {
47
47
  // discovery and the bootstrap-secret -> per-device-credential exchange.
48
48
  fetchVaultHealth,
49
49
  enrollVaultDevice,
50
+ // Module-scope request diagnostics (1.11.0). The brake gates every client
51
+ // call after a real 429; the budget meter and the write-loop detector are
52
+ // visibility only. All three are per bundle realm, not per client.
53
+ isVaultRateLimited,
54
+ vaultBrakeStatus,
55
+ getVaultStats,
56
+ configureVaultDiagnostics,
57
+ resetVaultDiagnostics,
50
58
  } from './vaultClient.js';
51
59
  // The packaged connect flow: discover -> branch -> enroll if needed ->
52
60
  // persist credential -> build engine. Apps hand it a secret or token and get