@glance-apps/sync 1.8.0 → 1.10.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/CHANGELOG.md +210 -0
- package/package.json +1 -1
- package/src/dbEngine.js +319 -28
- package/src/vaultClient.js +51 -1
- package/types/index.d.ts +96 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,180 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.10.0] - 2026-08-01
|
|
11
|
+
|
|
12
|
+
**Should you take this release?** Yes, for any deployment using the database
|
|
13
|
+
(GLANCEvault) transport. Until now the DB sync engine had **no backoff of any
|
|
14
|
+
kind**: on a persistent failure it re-sent the same request on every cycle,
|
|
15
|
+
forever, at whatever rate the app polls. A server that was down, a device
|
|
16
|
+
offline, a wrong token — all of them produced an unbounded request loop. This
|
|
17
|
+
release adds the backoff the file tier has always had, and fixes two related
|
|
18
|
+
defects found alongside it. The file-tier (WebDAV) engine is untouched.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **The DB engine no longer hammers a failing server.** Both directions now
|
|
23
|
+
back off: 30s doubling to a 15-minute cap for pushes and a 5-minute cap for
|
|
24
|
+
pulls, with a flat one hour on an authentication failure — the file tier's
|
|
25
|
+
numbers, escalation curve and reset rule. Any success resets the window and
|
|
26
|
+
its strike count. A window only ever **delays**: the next ordinary cycle
|
|
27
|
+
after it expires probes again, with no user action and no restart, and
|
|
28
|
+
dirty rows are never dropped — backoff changes *when* a push is retried,
|
|
29
|
+
never *whether* it is.
|
|
30
|
+
- Unlike the file tier's, the DB engine's backoff is **self-enforcing**: the
|
|
31
|
+
engine skips its own work when called too soon. (The file tier's is
|
|
32
|
+
advisory — nothing reads `uploadBackoffUntil` except its getter, so
|
|
33
|
+
honouring it is the app's job. That is exactly why the hammering was live
|
|
34
|
+
in every published version.) `getUploadBackoffUntil()` and
|
|
35
|
+
`getDownloadBackoffUntil()` are exposed with the same names and meaning as
|
|
36
|
+
the file tier's so one scheduler can treat both tiers alike, but honouring
|
|
37
|
+
them is now an optimisation rather than a duty.
|
|
38
|
+
- **A failed pull no longer aborts the cycle.** 1.9.0 contained push failures
|
|
39
|
+
but left pull failures throwing past the device-cursor report. A pull error
|
|
40
|
+
is now captured the same way, so the cursor still reports on a cycle whose
|
|
41
|
+
pull failed.
|
|
42
|
+
- **The pull cursor is persisted per page.** It was written only after the
|
|
43
|
+
whole pagination loop, so a failure on page 3 discarded the progress of
|
|
44
|
+
pages 1 and 2 — a large backlog on a flaky connection could re-download the
|
|
45
|
+
same pages indefinitely and never converge. Every row in a page reaches a
|
|
46
|
+
terminal outcome (applied, quarantined, or skipped as engine-reserved)
|
|
47
|
+
before the page ends, so the cursor may safely advance past each completed
|
|
48
|
+
page.
|
|
49
|
+
- **A rejected credential surfacing from the push or pull now halts.** Once
|
|
50
|
+
the key is verified in-session, `ensureRootKey` makes no network call, so a
|
|
51
|
+
mid-session revocation is first seen by the push or the pull — and 1.9.0's
|
|
52
|
+
error capture routed those away from the halt path. They now reach exactly
|
|
53
|
+
the same halt logic as before; the halt itself, its identity rule, and the
|
|
54
|
+
`invalid credential` body match are unchanged.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **Over-quota suppression is now one case of the backoff ladder**, not a
|
|
59
|
+
parallel mechanism. Its behaviour is unchanged — same curve, same
|
|
60
|
+
in-memory lifetime, still surfacing `QUOTA_EXCEEDED` with its descriptor on
|
|
61
|
+
every suppressed cycle so apps can render "X of Y used" — but
|
|
62
|
+
`getQuotaState()` and `isQuotaSuppressed()` are now derived from the push
|
|
63
|
+
window rather than a separate variable.
|
|
64
|
+
- **A shared-mode 401 is now delayed rather than retried immediately.** It
|
|
65
|
+
opens the one-hour auth window instead of being re-sent every cycle. It is
|
|
66
|
+
still not terminal and still not the credential halt: it self-resumes.
|
|
67
|
+
- New: `getBackoffState()` returns `{ push, pull }` windows with `until`,
|
|
68
|
+
`reason` (`'quota' | 'auth' | 'transport'`), `code`, `strikes` and `since`.
|
|
69
|
+
The `reason` is what lets an app distinguish a one-hour auth backoff from a
|
|
70
|
+
thirty-second transport one without inferring it from the timestamp.
|
|
71
|
+
- The cycle result gains `pullFailed`, `pullSkipped` and `pullErrorCode`,
|
|
72
|
+
mirroring the push fields added in 1.9.0.
|
|
73
|
+
|
|
74
|
+
### Notes
|
|
75
|
+
|
|
76
|
+
- **Classes that are never delayed**, deliberately: `PASSPHRASE_REQUIRED` and
|
|
77
|
+
`ACCOUNT_ID_REQUIRED` fire before any network call and clear the instant the
|
|
78
|
+
app supplies the value, so a window would make the app feel broken for 30
|
|
79
|
+
seconds after a user types their passphrase; and `CREDENTIAL_INVALID` is
|
|
80
|
+
owned by the (terminal) credential halt, so stacking a window on it would be
|
|
81
|
+
meaningless at best. `QUOTA_EXCEEDED` opens exactly one window, its own.
|
|
82
|
+
- **For app integration — do not render backoff from the last `onError`
|
|
83
|
+
message.** On a cycle suppressed by a quiet (transport or auth) window, the
|
|
84
|
+
cycle's usual `onError(null, null, false)` reset still fires and nothing is
|
|
85
|
+
re-surfaced, so an app that remembers the last error string will see it
|
|
86
|
+
*cleared* while `onStatusChange` reads `'error'`. This is deliberate (quiet
|
|
87
|
+
windows report once; repeating every cycle is the noise this release
|
|
88
|
+
removes). Render standing backoff from `getBackoffState()` — `reason` plus
|
|
89
|
+
`until` gives "retrying in Xs" — and treat `onError` as an event stream, not
|
|
90
|
+
a status store. Quota windows are the exception and keep re-surfacing
|
|
91
|
+
`QUOTA_EXCEEDED` with the descriptor each cycle.
|
|
92
|
+
- The cycle result now reports a superseded stale instance truthfully:
|
|
93
|
+
`{ superseded: true }` rather than `{ halted: true }`, matching
|
|
94
|
+
`isSuperseded()` / `isCredentialHalted()` whichever path the credential
|
|
95
|
+
rejection arrived by.
|
|
96
|
+
- A healthy client is unaffected: no window is ever opened, no added latency,
|
|
97
|
+
and an identical request pattern.
|
|
98
|
+
|
|
99
|
+
## [1.9.0] - 2026-08-01
|
|
100
|
+
|
|
101
|
+
**Should you take this release?** Yes if you run, or might run, a GLANCEvault
|
|
102
|
+
server with a row cap configured (`GLANCEVAULT_QUOTA_*`, server Phase 3.2).
|
|
103
|
+
Also yes for the sync-reliability fix below, which applies to **every**
|
|
104
|
+
deployment including shared mode and unconfigured servers: before this
|
|
105
|
+
release, a failed push also cost you the pull, so a device whose writes were
|
|
106
|
+
failing silently stopped receiving other devices' changes too. Nothing here
|
|
107
|
+
changes behavior against a server with no quota configured, which is and will
|
|
108
|
+
remain the common case.
|
|
109
|
+
|
|
110
|
+
### Fixed
|
|
111
|
+
|
|
112
|
+
- **A failed push no longer prevents the pull.** The DB sync cycle threw
|
|
113
|
+
straight from a push failure to its error handler, so `pullRemoteChanges`
|
|
114
|
+
and the device-cursor report never ran. A device that could not write — for
|
|
115
|
+
*any* reason: a transport blip, a 500, a server-side cap — also stopped
|
|
116
|
+
**reading**, and silently fell behind its peers. The push error is now
|
|
117
|
+
captured, the pull and cursor run regardless, and the error is reported
|
|
118
|
+
after. Applies to every transport failure, not just the quota case that
|
|
119
|
+
exposed it. Behavior on a fully successful cycle is unchanged.
|
|
120
|
+
- A cycle whose push failed but whose pull succeeded now returns
|
|
121
|
+
`{ applied, skipped, skippedEntityIds, pushFailed: true, pushErrorCode,
|
|
122
|
+
quota }` — the pull's own numbers are reported as usual, and `onError`
|
|
123
|
+
fires with the push error's code and `isHardStop: false`. `lastSynced` is
|
|
124
|
+
deliberately **not** advanced (the device is not fully in step), and the
|
|
125
|
+
dirty rows are retained for the next cycle exactly as before.
|
|
126
|
+
- **A quota rejection is no longer mislabelled "update your server".** The key
|
|
127
|
+
verifier's establishing write is a net-new entity, so a fresh device on an
|
|
128
|
+
account at the row cap gets a 413 there — and every error on that path was
|
|
129
|
+
relabelled `VERIFIER_UNSUPPORTED` ("Your GLANCEvault server needs to be
|
|
130
|
+
updated to support key verification"), sending the user to fix a server
|
|
131
|
+
that is working correctly. `QUOTA_EXCEEDED` now passes through that
|
|
132
|
+
classifier, the same way `CREDENTIAL_INVALID` and the client-readiness
|
|
133
|
+
codes already did.
|
|
134
|
+
|
|
135
|
+
### Added
|
|
136
|
+
|
|
137
|
+
- **Over-quota handling for the reachable dimension (server Phase 3.2 /
|
|
138
|
+
client Phase 3.3).** A 413 or 429 carrying the server's quota body
|
|
139
|
+
(`{ error: "quota exceeded", quota, limit, used, requested }`) is now typed
|
|
140
|
+
as `QUOTA_EXCEEDED` and carries the parsed descriptor, so consumers no
|
|
141
|
+
longer have to substring-match an error message to tell a quota rejection
|
|
142
|
+
from any other failure.
|
|
143
|
+
- **Never a halt.** A quota condition is the opposite of a rejected
|
|
144
|
+
credential: it clears when the operator raises the limit or reclaim runs
|
|
145
|
+
— with **no client action at all** — so the engine keeps running.
|
|
146
|
+
`isHardStop` is always `false`, nothing is persisted, and there is no
|
|
147
|
+
recovery entry point and nothing to clear.
|
|
148
|
+
- **Bounded, self-resuming suppression** instead of retrying a doomed write
|
|
149
|
+
every cycle: after a rejection the engine skips the *write* for 30s,
|
|
150
|
+
doubling on repeat to a 15-minute cap (the file tier's backoff shape),
|
|
151
|
+
while pulls and cursor reports continue untouched. The next ordinary
|
|
152
|
+
cycle after the window probes again; a write that gets through clears the
|
|
153
|
+
state by itself. Raising the server limit is picked up with no restart,
|
|
154
|
+
no re-enrollment, and no user action.
|
|
155
|
+
- New state queries: `getQuotaState()` (the descriptor plus `since` /
|
|
156
|
+
`retryAt`, in memory only) and `isQuotaSuppressed()`.
|
|
157
|
+
- **Defensive parse.** A body earns the typed classification only with the
|
|
158
|
+
exact wording, a non-empty `quota` string, and three finite numbers.
|
|
159
|
+
A missing field, a non-JSON body, a 413 with no quota fields, the legacy
|
|
160
|
+
SSE `{"error":"too many connections for account"}` 429, or an older
|
|
161
|
+
server that never sends this shape all degrade to the generic
|
|
162
|
+
`VAULT_ERROR` handled before — never a throw, never a mis-classification.
|
|
163
|
+
An **unrecognised** `quota` dimension from a newer server is passed
|
|
164
|
+
through verbatim and still treated as a quota condition, since the remedy
|
|
165
|
+
is the same; render an unfamiliar dimension generically.
|
|
166
|
+
|
|
167
|
+
### Notes
|
|
168
|
+
|
|
169
|
+
- **Scope: `rows` is the only quota dimension this package can reach.** The
|
|
170
|
+
server enforces `storage` and `concurrent-uploads` at the blob path, and
|
|
171
|
+
`intents` at the intents route; this package has no blob path (media is
|
|
172
|
+
stripped at the adapter boundary) and no intents path (that is
|
|
173
|
+
`@glance-apps/intents`), and no SSE client. So "text syncs while media
|
|
174
|
+
fails" is not something this package can exercise. Handling for those
|
|
175
|
+
dimensions belongs with the media transport work that would call them, and
|
|
176
|
+
was deliberately not written here.
|
|
177
|
+
- **Known gap, not fixed here:** the DB engine has no backoff of any kind
|
|
178
|
+
outside the quota window above. On a persistent write failure the same
|
|
179
|
+
dirty rows are re-pushed on every cycle indefinitely, where the file tier
|
|
180
|
+
has exponential backoff with an auth-failure escalation. This is a real
|
|
181
|
+
defect with a live blast radius and it wants its own change rather than a
|
|
182
|
+
corner of this one.
|
|
183
|
+
|
|
10
184
|
## [1.8.0] - 2026-07-31
|
|
11
185
|
|
|
12
186
|
### Added
|
|
@@ -62,6 +236,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
62
236
|
credential minted and then lost to a persistence failure is superseded by
|
|
63
237
|
the next successful enrollment for the same `(accountId, deviceId)`.
|
|
64
238
|
|
|
239
|
+
### Fixed
|
|
240
|
+
|
|
241
|
+
- **Device cursors now report on installs that never configured a
|
|
242
|
+
`deviceId`, automatically on upgrade.** Such installs have never updated
|
|
243
|
+
their device cursor: the cursor step silently no-opped (`{updated:
|
|
244
|
+
false}`) whenever no `deviceId` was passed, so the server's tombstone GC
|
|
245
|
+
had no cursor for those devices. On upgrade, a device id is generated and
|
|
246
|
+
persisted (`{prefix}-device-id`) at engine construction, and cursor
|
|
247
|
+
reporting begins on the first completed sync cycle — **no app changes
|
|
248
|
+
required**; this applies to plain `createDbSyncEngine` construction, not
|
|
249
|
+
only the new connect flow, and identically in shared and per-account
|
|
250
|
+
mode. Installs that already pass an explicit `deviceId` are unaffected
|
|
251
|
+
and keep using it. Operator note: an install this fixes has no prior
|
|
252
|
+
cursor history, so the newly generated id strands nothing.
|
|
253
|
+
(This shipped in 1.7.0's device-identity work but 1.7.0 was never
|
|
254
|
+
published; it reaches consumers with this release.)
|
|
255
|
+
|
|
256
|
+
### Upgrade notes
|
|
257
|
+
|
|
258
|
+
Two behavior changes arrive with the fix above, without opt-in:
|
|
259
|
+
|
|
260
|
+
- **Engine construction now touches localStorage.** `getOrCreateDeviceId`
|
|
261
|
+
reads and writes `{prefix}-device-id` at construction time; previously
|
|
262
|
+
construction deferred all localStorage access to cycle time. In an
|
|
263
|
+
environment without a `localStorage` global, construction now fails where
|
|
264
|
+
it previously succeeded and failed later at first sync. Theoretical for
|
|
265
|
+
browser and WebView hosts (all three GLANCE apps), but if construction
|
|
266
|
+
throws before any network activity, look here first.
|
|
267
|
+
- **Injected `vaultClient` consumers see new `device()` calls.** An engine
|
|
268
|
+
constructed with `config.vaultClient` and no `deviceId` previously made
|
|
269
|
+
zero `device()` calls; it now makes one per cycle. A mock or bridge
|
|
270
|
+
client without a `device()` method will throw inside the cursor step —
|
|
271
|
+
swallowed and warned, never fatal — but the console warning is new and
|
|
272
|
+
recurring. Affects tests with mock clients and native shells that inject
|
|
273
|
+
a bridge client.
|
|
274
|
+
|
|
65
275
|
## [1.7.0] - 2026-07-31
|
|
66
276
|
|
|
67
277
|
### Added
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
731
|
-
|
|
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/vaultClient.js
CHANGED
|
@@ -39,6 +39,45 @@ class VaultError extends Error {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Quota rejection parsing (GLANCEvault Phase 3.2 / client Phase 3.3).
|
|
43
|
+
//
|
|
44
|
+
// The server rejects an over-quota write with 413 (storage-shaped: `storage`,
|
|
45
|
+
// `rows`) or 429 (volume/concurrency-shaped: `intents`, `concurrent-uploads`)
|
|
46
|
+
// and a uniform body:
|
|
47
|
+
// { error: "quota exceeded", quota, limit, used, requested }
|
|
48
|
+
// The three numbers are bytes for `storage` and counts otherwise, and are
|
|
49
|
+
// meant to render "X of Y used" directly.
|
|
50
|
+
//
|
|
51
|
+
// DEFENSIVE BY CONSTRUCTION — this shape is a contract across two repos, so
|
|
52
|
+
// a body must earn the typed classification rather than be assumed into it.
|
|
53
|
+
// ALL of: the exact `error` wording, a non-empty string `quota`, and three
|
|
54
|
+
// finite numbers. Anything short of that (a missing field, a non-JSON body, a
|
|
55
|
+
// 413 with no quota fields at all, an older server that never sends this
|
|
56
|
+
// shape) degrades to the generic VAULT_ERROR the client already handled —
|
|
57
|
+
// never a throw, never a mis-classification.
|
|
58
|
+
//
|
|
59
|
+
// The `quota` DIMENSION is deliberately NOT allowlisted. A newer server may
|
|
60
|
+
// add dimensions, and an unknown one is still a quota condition with the same
|
|
61
|
+
// remedy (wait; it clears when the operator acts, with no client action), so
|
|
62
|
+
// passing the string through verbatim is more truthful than pretending the
|
|
63
|
+
// rejection was a generic failure. Consumers that do not recognise a
|
|
64
|
+
// dimension render it generically. See the PR for this decision.
|
|
65
|
+
//
|
|
66
|
+
// The legacy SSE per-account cap (429 {"error":"too many connections for
|
|
67
|
+
// account"}) predates this shape and is NOT a quota rejection: its wording
|
|
68
|
+
// fails the `error` check on the first line, so it degrades generically with
|
|
69
|
+
// no special-casing. Nothing in this package speaks SSE regardless.
|
|
70
|
+
const parseQuotaBody = (status, body, bodyError) => {
|
|
71
|
+
if (status !== 413 && status !== 429) return null;
|
|
72
|
+
if (bodyError !== 'quota exceeded') return null;
|
|
73
|
+
if (!body || typeof body.quota !== 'string' || body.quota === '') return null;
|
|
74
|
+
const { limit, used, requested } = body;
|
|
75
|
+
if (typeof limit !== 'number' || !Number.isFinite(limit)) return null;
|
|
76
|
+
if (typeof used !== 'number' || !Number.isFinite(used)) return null;
|
|
77
|
+
if (typeof requested !== 'number' || !Number.isFinite(requested)) return null;
|
|
78
|
+
return { quota: body.quota, limit, used, requested };
|
|
79
|
+
};
|
|
80
|
+
|
|
42
81
|
// Shared by createVaultClient and the standalone pre-credential helpers below.
|
|
43
82
|
const resolveFetch = (fetchImpl, context) => {
|
|
44
83
|
const doFetch = fetchImpl || globalThis.fetch;
|
|
@@ -200,9 +239,10 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
200
239
|
// non-JSON, or unrecognized body degrades to the generic VAULT_ERROR, so
|
|
201
240
|
// the client's correctness never depends on server wording.
|
|
202
241
|
const errorFromResponse = async (res, context) => {
|
|
242
|
+
let body = null;
|
|
203
243
|
let bodyError = null;
|
|
204
244
|
try {
|
|
205
|
-
|
|
245
|
+
body = await res.json();
|
|
206
246
|
if (body && typeof body.error === 'string') bodyError = body.error;
|
|
207
247
|
} catch {
|
|
208
248
|
// Empty or non-JSON error body: keep the generic classification.
|
|
@@ -212,6 +252,16 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
212
252
|
err.code = 'CREDENTIAL_INVALID';
|
|
213
253
|
return err;
|
|
214
254
|
}
|
|
255
|
+
const quota = parseQuotaBody(res.status, body, bodyError);
|
|
256
|
+
if (quota) {
|
|
257
|
+
const err = new VaultError(
|
|
258
|
+
`${context} failed: ${res.status} — ${quota.quota} quota exceeded (${quota.used} of ${quota.limit}, requested ${quota.requested})`,
|
|
259
|
+
res.status
|
|
260
|
+
);
|
|
261
|
+
err.code = 'QUOTA_EXCEEDED';
|
|
262
|
+
err.quota = quota;
|
|
263
|
+
return err;
|
|
264
|
+
}
|
|
215
265
|
return new VaultError(`${context} failed: ${res.status}`, res.status);
|
|
216
266
|
};
|
|
217
267
|
|
package/types/index.d.ts
CHANGED
|
@@ -32,7 +32,15 @@ export type SyncErrorCode =
|
|
|
32
32
|
// credential (401 "invalid credential"). Surfaced with isHardStop === true;
|
|
33
33
|
// the engine persists a halt and stops retrying. Recovery (re-enrollment)
|
|
34
34
|
// is Phase 2.2 — nothing in this version clears the halt.
|
|
35
|
-
| 'CREDENTIAL_INVALID'
|
|
35
|
+
| 'CREDENTIAL_INVALID'
|
|
36
|
+
// DB transport: the server rejected a write because the account is over a
|
|
37
|
+
// configured quota (413 storage-shaped / 429 volume-shaped, Phase 3.2).
|
|
38
|
+
// ALWAYS isHardStop === false — a quota condition clears when the operator
|
|
39
|
+
// raises the limit or reclaim runs, with NO client action, so the engine
|
|
40
|
+
// never halts on it. The parsed descriptor rides on the engine's
|
|
41
|
+
// getQuotaState(); the engine suppresses further writes for a bounded,
|
|
42
|
+
// self-resuming window while the pull keeps running.
|
|
43
|
+
| 'QUOTA_EXCEEDED';
|
|
36
44
|
|
|
37
45
|
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
38
46
|
|
|
@@ -519,6 +527,74 @@ export interface DbSyncResult {
|
|
|
519
527
|
applied: number;
|
|
520
528
|
skipped: number;
|
|
521
529
|
skippedEntityIds: string[];
|
|
530
|
+
/** True when the push threw but the pull still ran (Phase 3.3 partial function). */
|
|
531
|
+
pushFailed?: boolean;
|
|
532
|
+
/** True when the push was skipped because its backoff window is open. */
|
|
533
|
+
pushSkipped?: boolean;
|
|
534
|
+
/** The push error's code when pushFailed/pushSkipped — the pull's own result is still reported above. */
|
|
535
|
+
pushErrorCode?: SyncErrorCode | string;
|
|
536
|
+
/** True when the pull threw but the cursor report still ran. */
|
|
537
|
+
pullFailed?: boolean;
|
|
538
|
+
/** True when the pull was skipped because its backoff window is open. */
|
|
539
|
+
pullSkipped?: boolean;
|
|
540
|
+
/** The pull error's code when pullFailed/pullSkipped. */
|
|
541
|
+
pullErrorCode?: SyncErrorCode | string;
|
|
542
|
+
/** The standing over-quota descriptor, when one applies. */
|
|
543
|
+
quota?: QuotaState | null;
|
|
544
|
+
/** True when the cycle no-opped because the credential halt is set. */
|
|
545
|
+
halted?: boolean;
|
|
546
|
+
/** True when the cycle no-opped because this instance's credential was superseded. */
|
|
547
|
+
superseded?: boolean;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Over-quota state (Phase 3.3). IN MEMORY ONLY — this describes server-side
|
|
552
|
+
* state that changes without the client (an operator raises a limit, reclaim
|
|
553
|
+
* runs, intents expire), so it is never persisted and there is nothing to
|
|
554
|
+
* clear: it lifts by itself when a write succeeds again.
|
|
555
|
+
*
|
|
556
|
+
* `quota` is the server's dimension string, passed through verbatim — the
|
|
557
|
+
* known values are 'storage' | 'rows' | 'intents' | 'concurrent-uploads', but
|
|
558
|
+
* a newer server may add dimensions, so treat an unrecognised one generically
|
|
559
|
+
* rather than assuming it cannot happen. The numbers are bytes for 'storage'
|
|
560
|
+
* and counts otherwise, and render "X of Y used" directly; they are null only
|
|
561
|
+
* in the defensive case where a rejection was typed without a full body.
|
|
562
|
+
*/
|
|
563
|
+
/**
|
|
564
|
+
* One backoff window. The DB engine keeps two (push and pull) and enforces
|
|
565
|
+
* them itself; both are IN MEMORY ONLY, like the file tier's, so a restart
|
|
566
|
+
* clears them — a relaunch is a user saying "try now", and a persisting
|
|
567
|
+
* failure re-opens the window immediately.
|
|
568
|
+
*
|
|
569
|
+
* A window only ever DELAYS: the next cycle after `until` probes again, with
|
|
570
|
+
* no user action and no restart. Failures that must not be delayed at all
|
|
571
|
+
* (PASSPHRASE_REQUIRED / ACCOUNT_ID_REQUIRED, which clear the moment the app
|
|
572
|
+
* supplies the value) and failures owned by another state (CREDENTIAL_INVALID,
|
|
573
|
+
* which halts) never open one.
|
|
574
|
+
*/
|
|
575
|
+
export interface BackoffWindow {
|
|
576
|
+
/** Epoch ms; the direction is skipped while this is in the future. 0 when clear. */
|
|
577
|
+
until: number;
|
|
578
|
+
/** Consecutive failures driving the escalation (30s doubling; 15 min push cap, 5 min pull cap). */
|
|
579
|
+
strikes: number;
|
|
580
|
+
/** 'quota' (keeps surfacing its descriptor), 'auth' (flat hour on a 401), 'transport' (quiet after one signal). */
|
|
581
|
+
reason: 'quota' | 'auth' | 'transport' | null;
|
|
582
|
+
/** The typed error code that opened the window. */
|
|
583
|
+
code: SyncErrorCode | string | null;
|
|
584
|
+
/** ISO timestamp of the most recent failure. */
|
|
585
|
+
since: string | null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export interface QuotaState {
|
|
589
|
+
quota: string;
|
|
590
|
+
limit: number | null;
|
|
591
|
+
used: number | null;
|
|
592
|
+
requested: number | null;
|
|
593
|
+
message: string;
|
|
594
|
+
/** ISO timestamp of the most recent rejection. */
|
|
595
|
+
since: string;
|
|
596
|
+
/** ISO timestamp after which the engine probes again, on its own. */
|
|
597
|
+
retryAt: string;
|
|
522
598
|
}
|
|
523
599
|
|
|
524
600
|
/** A row quarantined after failing to decrypt under the (verified) account key. */
|
|
@@ -564,6 +640,25 @@ export interface DbSyncEngine {
|
|
|
564
640
|
getCredentialHalt(): { message: string; at: string } | null;
|
|
565
641
|
/** True once this instance proved its bearer was superseded by recovery and went inert (in-memory only). */
|
|
566
642
|
isSuperseded(): boolean;
|
|
643
|
+
/** The standing over-quota descriptor, or null. In-memory only; never a hard stop; nothing to clear. */
|
|
644
|
+
getQuotaState(): QuotaState | null;
|
|
645
|
+
/** True while the bounded over-quota window is open (writes skipped, pulls continue). */
|
|
646
|
+
isQuotaSuppressed(): boolean;
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Epoch ms before which the push/pull will not be attempted, or 0. Same
|
|
650
|
+
* getter names and meaning as the file engine's, so one scheduler can treat
|
|
651
|
+
* both tiers identically — but unlike the file tier, the DB engine also
|
|
652
|
+
* ENFORCES these itself, so honouring them is an optimisation, not a duty.
|
|
653
|
+
*/
|
|
654
|
+
getUploadBackoffUntil(): number;
|
|
655
|
+
getDownloadBackoffUntil(): number;
|
|
656
|
+
/**
|
|
657
|
+
* The full backoff picture. `reason` distinguishes a one-hour auth window
|
|
658
|
+
* from a thirty-second transport one without inferring from the timestamp's
|
|
659
|
+
* magnitude, which is what an app needs to tell a user why and for how long.
|
|
660
|
+
*/
|
|
661
|
+
getBackoffState(): { push: BackoffWindow; pull: BackoffWindow };
|
|
567
662
|
/** The resolved stable device identity this engine runs as. */
|
|
568
663
|
deviceId: string;
|
|
569
664
|
|