@glance-apps/sync 1.6.1 → 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 CHANGED
@@ -7,6 +7,349 @@ 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
+
184
+ ## [1.8.0] - 2026-07-31
185
+
186
+ ### Added
187
+
188
+ - **Recovery from a halted credential (GLANCEvault Phase 2.2).** The 1.7.0
189
+ credential halt was built with no exit; this release adds the one exit:
190
+ **`recoverVaultSyncEngine(config)`**, user-initiated re-enrollment with the
191
+ bootstrap secret. Since server Phase 2.1, enrolling the same byte-exact
192
+ `(accountId, deviceId)` revokes every still-active predecessor credential
193
+ in the same transaction, so recovery is a rotation — the dead credential is
194
+ superseded, not accumulated alongside.
195
+ - **Halt-gated, structurally.** The call refuses with `NOT_HALTED` unless
196
+ the device is credential-halted, so it cannot be wired into a startup or
197
+ retry path as an on-demand rotator. Combined with the package holding no
198
+ secret (one must be supplied fresh, and it is confined to the call
199
+ exactly as in `connectVaultSyncEngine` — never stored, logged, or
200
+ retained), user initiation is structural, not procedural. **No code path
201
+ recovers automatically**, including transport failures, startup, or a
202
+ halt discovered at launch.
203
+ - **Per-account only, no fallback.** Unlike connect, recovery never falls
204
+ back on discovery failure: an unreachable `/healthz` is
205
+ `VAULT_UNREACHABLE` and a non-per-account mode is
206
+ `RECOVERY_UNSUPPORTED`; both leave the device halted and untouched.
207
+ Shared mode has no credentials, no halt, and no reachable recovery path.
208
+ - **Order of operations**, each failure leaving the device halted rather
209
+ than ambiguous: halt gate → deviceId resolution → mode guard → storage
210
+ canary (now restores the slot's prior contents, so a failed recovery
211
+ keeps the stale record byte-identical) → enroll → persist + read-back
212
+ verify (overwriting the stale record — it does not survive recovery) →
213
+ **clear the halt last** → return a fresh engine. The old engine's client
214
+ closes over the dead credential; apps must swap to the returned engine.
215
+ - **deviceId: the stale record's value wins.** Rotation only lands if
216
+ enrollment uses the identity the dead credential is bound to, and the
217
+ stored record is ground truth for that. An explicit `config.deviceId`
218
+ that differs is surfaced as `DEVICE_ID_CONFLICT`, never resolved
219
+ silently (either choice orphans a credential or a cursor). With no
220
+ readable record: explicit value, else the persisted package-owned id.
221
+ Never minted fresh.
222
+ - **Halt-set identity rule** (the stale-engine hazard fix). The halt key is
223
+ shared by every engine instance on a device, so an old instance still
224
+ holding the superseded credential could previously re-halt the device
225
+ after recovery. Now, on `CREDENTIAL_INVALID`, the engine halts **unless**
226
+ the stored credential record exists, is readable, and holds a *different*
227
+ credential than the engine's bearer — definitive proof the instance was
228
+ superseded. **The rule fails toward halting**: a missing or unreadable
229
+ record still halts (the naive "no record → no halt" would retry a dead
230
+ credential forever, exactly what the halt exists to prevent). A superseded
231
+ instance goes **inert in memory** instead — surfaced once via
232
+ `onError('CREDENTIAL_INVALID', isHardStop true)`, then silent, zero
233
+ network, never touching the shared halt key — killing both the bricking
234
+ and the pointless server load. New state query: `isSuperseded()`.
235
+ - The 1.4b minted-but-lost-persistence residual is now self-healing: a
236
+ credential minted and then lost to a persistence failure is superseded by
237
+ the next successful enrollment for the same `(accountId, deviceId)`.
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
+
275
+ ## [1.7.0] - 2026-07-31
276
+
277
+ ### Added
278
+
279
+ - **Per-account vault auth, client half (GLANCEvault Phase 1.4b).** A
280
+ GLANCEvault server can run `authMode: 'per-account'`, where each device
281
+ authenticates with its own enrolled credential instead of the instance-wide
282
+ shared device token. The package owns the whole client-side flow — apps hand
283
+ it a bootstrap secret (or the shared token) and get a working engine back,
284
+ without implementing discovery, branching, credential persistence, or
285
+ secret discard themselves. Shared mode remains the default path and is
286
+ byte-for-byte unchanged.
287
+ - **`connectVaultSyncEngine(config)`** — the packaged connect flow.
288
+ Discovers the server's auth mode from `/healthz`, then branches:
289
+ `shared` → builds the engine from `config.vaultToken` exactly as before;
290
+ `per-account` → uses the credential persisted under
291
+ `{prefix}-vault-credential`, enrolling with `config.enrollmentSecret`
292
+ only when no credential is stored for this exact (server, account). The
293
+ credential is persisted durably (write + read-back verify) **before**
294
+ enrollment is treated as complete, and a storage canary runs **before**
295
+ minting so broken storage cannot orphan one server row per launch. The
296
+ bootstrap secret is never written to storage, never logged, never placed
297
+ on the engine config, and not retained past the call. **No code path
298
+ re-enrolls automatically** — once a credential is stored it is always
299
+ reused, even after rejection (recovery is Phase 2.2), so revocation
300
+ (vault Phase 2.1) cannot be silently undone by a client loop. Discovery
301
+ failure (server unreachable, unexpected body) falls back to the last
302
+ known auth state — stored credential, else `vaultToken` — which is
303
+ exactly the pre-1.4b behavior for existing installs; an unrecognized
304
+ future mode string does the same rather than guessing. A `/healthz`
305
+ without `authMode` (a pre-1.4a server) is ordinary shared mode, not an
306
+ error. Typed failures: `VAULT_TOKEN_REQUIRED`,
307
+ `ENROLLMENT_SECRET_REQUIRED`, `CREDENTIAL_PERSIST_FAILED`,
308
+ `VAULT_UNREACHABLE`, plus the enrollment errors below.
309
+ - **`getOrCreateDeviceId(storageKeyPrefix)`** — the package now owns the
310
+ stable device identity: generated once (`crypto.randomUUID`, with a
311
+ `getRandomValues` fallback for old WebViews) and persisted under
312
+ `{prefix}-device-id`; used for both the device cursor and enrollment. An
313
+ explicit `config.deviceId` still wins. This also fixes a latent bug: the
314
+ engine's device-cursor update silently no-oped (`{updated:false}`) when
315
+ no `deviceId` was configured, so such installs never reported cursors —
316
+ on upgrade they get a generated id and start reporting for the first
317
+ time (server-side effect: a `devices` row appears and tombstone GC gains
318
+ an accurate cursor; pure improvement, no data risk).
319
+ - **Substrate exports** (used by the flow, available standalone):
320
+ `fetchVaultHealth({ vaultUrl, fetchImpl? })` — unauthenticated
321
+ `GET /healthz`, `authMode` normalized to `'shared'` when the server
322
+ predates the field; and `enrollVaultDevice({ vaultUrl, enrollmentSecret,
323
+ accountId, deviceId, fetchImpl? })` — `POST /enroll`, secret in the body
324
+ only, values sent byte-exact, typed `ENROLLMENT_REJECTED` (401) /
325
+ `ENROLLMENT_UNSUPPORTED` (404, shared-mode server) failures, and
326
+ `*_REQUIRED` codes thrown before touching the wire on missing fields.
327
+ - **`CREDENTIAL_INVALID` and the credential halt.** `createVaultClient`
328
+ now reads error bodies best-effort: a 401 whose body says
329
+ `invalid credential` (the server's chosen signal for "this device must
330
+ re-enroll") surfaces as code `CREDENTIAL_INVALID`; a missing, non-JSON,
331
+ or unrecognized body degrades to the generic `VAULT_ERROR`, so client
332
+ correctness never depends on server wording. On `CREDENTIAL_INVALID` the
333
+ DB engine persists a stop under `{prefix}-db-sync-credential-halt`,
334
+ surfaces `onError(message, 'CREDENTIAL_INVALID', /* isHardStop */ true)`,
335
+ and makes **no further network calls** — across restarts. Nothing in
336
+ this version clears the halt (that is Phase 2.2's recovery flow);
337
+ `isCredentialHalted()` / `getCredentialHalt()` expose the state.
338
+ Shared-mode 401s (`invalid device token`) keep today's retryable
339
+ behavior. The key verifier passes `CREDENTIAL_INVALID` through instead
340
+ of relabeling it `VERIFIER_UNSUPPORTED`, so `allowUnverified` can never
341
+ wave a rejected device through.
342
+ - `createVaultClient` is otherwise unchanged on the wire; `vaultToken`
343
+ accepts either the shared device token or an enrolled credential — the
344
+ credential IS the Bearer token.
345
+
346
+ ### Security notes
347
+
348
+ - The persisted credential lives in localStorage, exactly as exposed as the
349
+ shared device token and the file tier's provider passwords are today:
350
+ parity, not a regression. Secure-storage migration is explicitly out of
351
+ scope for this phase.
352
+
10
353
  ## [1.6.1] - 2026-07-19
11
354
 
12
355
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.6.1",
3
+ "version": "1.10.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",