@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/CHANGELOG.md CHANGED
@@ -7,6 +7,299 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.11.0] - 2026-08-30
11
+
12
+ **Should you take this release?** Yes if you use the GLANCEvault transport, and
13
+ immediately if anything in your bundle writes to it on a timer. This release is
14
+ the client-side answer to a 2026-08-30 incident in which an intent-cleanup loop
15
+ in the dayGLANCE Obsidian plugin deleted rows that were already tombstones. The
16
+ server re-tombstoned on every call — fresh seq, fresh SSE nudge — which put the
17
+ row back above the caller's cursor and fed the loop its own output. It ran for
18
+ hours and saturated the server's per-IP rate budget, and it was **invisible**:
19
+ every delete was fire-and-forget behind a `.catch(() => {})`, so nothing failed
20
+ loudly enough to notice. Two patches landed elsewhere (the plugin no longer
21
+ deletes tombstones; the vault's `softDeleteRow` is now idempotent). This release
22
+ adds the protection and the visibility **by construction**, so no caller has to
23
+ remember them and no loop can be silent. No server changes: the wire contract is
24
+ untouched.
25
+
26
+ ### Added
27
+
28
+ - **The intents transport.** `intentsBatch(accountId, events)` and
29
+ `intentsList(accountId, { since, limit })` wrap the server's existing
30
+ `/intents/*` endpoints, so apps can drop their hand-rolled raw-fetch
31
+ transports and inherit this client's auth, error classification, brake and
32
+ meter. `intentsBatch` is insert-only server-side — a re-sent `eventId` is a
33
+ no-op — so retrying an ambiguous failure is safe by construction.
34
+ - **Pure transport**: the `envelope` is opaque here and is never decoded or
35
+ inspected. The codec, and the per-envelope key derivation it needs, stays
36
+ in `@glance-apps/intents`; this package carries the transport without
37
+ carrying the crypto.
38
+ - **A device-wide request brake.** Any real 429 now pauses *every* vault
39
+ request in the bundle realm: 30s, doubling per burst to a 10-minute ceiling,
40
+ one arming per burst (a concurrent fan-out that all meets the limiter costs
41
+ one escalation step, not ten). While paused, calls fail fast — **before
42
+ touching the network** — with a `VaultError` carrying `status: 429`,
43
+ `code: 'RATE_LIMITED'` and `retryInMs`.
44
+ - It is at **module scope, not per client instance**, deliberately: the thing
45
+ it protects is shared by every client in the process (the server's per-IP
46
+ budget), so the state that models it belongs to the realm. A separately
47
+ bundled copy — the Obsidian plugin's, say — is its own realm, which is
48
+ correct: separate process, separate traffic.
49
+ - **Decay, never amnesty.** A 2xx releases the pause but only *halves* the
50
+ escalation memory (below the 30s base it drains to zero). This is the fix
51
+ for the earlier app-side brake, which reset escalation to zero on *any*
52
+ success: on a saturated shared budget an occasional cheap request slips
53
+ into a fresh limiter window and returns 200, and each lucky 200 wiped the
54
+ whole 30s→480s escalation and re-licensed full cadence. Halving keeps the
55
+ storm's level in memory — the next 429 re-arms at 2× what is left — while a
56
+ genuine recovery still drains to nothing in a few quiet successes.
57
+ - Reads: `isVaultRateLimited()` and `vaultBrakeStatus()`, for callers that
58
+ prefer to sit a whole cycle out pre-flight. Escape hatch:
59
+ `createVaultClient({ brake: false })` opts a client out of both the gate
60
+ and the arming.
61
+ - **A budget meter.** Requests are counted per rolling minute and attributed by
62
+ method name. Crossing a soft threshold (default 300/min — half the server's
63
+ default per-IP budget, configurable) logs **one** line per window naming the
64
+ top contributors: `[vault] budget: 412 requests in the last minute (soft
65
+ limit 300) — top: intentsList 210, batch 130, deleteRow 72`. Visibility only;
66
+ it never throttles. Its whole argument is that it needs no theory about *why*
67
+ a loop is running, so the next storm is an attributed event on day one rather
68
+ than an afternoon of archaeology.
69
+ - **A write-loop detector — the success-side signal.** Failure visibility
70
+ already existed; what was missing on the day was the signal for everything
71
+ "succeeding" pathologically. The client now keeps a bounded per-`entityId`
72
+ history across `batch` upserts and `deleteRow`, and warns loudly (once per id
73
+ per window) when four **redundant** writes to one id land inside ten minutes.
74
+ Diagnostic only — never a brake.
75
+ - A write is redundant when it cannot have changed the row's state from what
76
+ the previous write to that id left it. Three shapes qualify: a **polarity
77
+ flip** (the delete-and-resupply loop), a **repeat delete** (re-deleting a
78
+ row that is already a tombstone — the incident's own shape: same polarity
79
+ every time, no content at all), and an **identical-content rewrite**.
80
+ - **Fails open by construction**: the rule needs repeated events on the
81
+ *same* id, so a first sync, a large import, or a bulk cleanup deleting many
82
+ different ids — many ids, one event each — can never trigger it, however
83
+ large. A bounded retry of one failed delete does not reach the threshold
84
+ either.
85
+ - Both thresholds are tunable via `configureVaultDiagnostics`. Four redundant
86
+ writes means five events (upsert/delete/upsert/delete/upsert, or five
87
+ deletes of one tombstone), which no human edit pattern produces and every
88
+ delete/resupply or re-tombstone loop produces within seconds; ten minutes
89
+ is long enough to catch a loop running on a 60s sync cycle and short enough
90
+ that a week of ordinary edits never accumulates into it.
91
+ - `getVaultStats()` returns all three in one read: brake status, the last
92
+ rolling minute of requests by method, and the current write-loop suspects.
93
+ `configureVaultDiagnostics(options)` tunes the visibility-only thresholds and
94
+ the log sink; `resetVaultDiagnostics()` clears everything (for tests).
95
+
96
+ ### Changed
97
+
98
+ - Every `createVaultClient` method now passes through the brake and the meter.
99
+ The only behaviour change a healthy caller can observe is the new
100
+ `RATE_LIMITED` rejection while a window is open — and only after the server
101
+ has already answered a real 429.
102
+ - A **429 arms the brake only when it is a rate-limiter hit**, never when it is
103
+ an over-quota rejection. The server uses 429 for both and they call for
104
+ opposite handling: a limiter hit means "stop asking for a while" (exactly
105
+ what the brake does), while a quota rejection means the account is full,
106
+ clears only when an operator acts, and already has its own self-resuming
107
+ window in the DB engine. Gating on it would hide `QUOTA_EXCEEDED` and its
108
+ descriptor behind `RATE_LIMITED` and leave apps unable to render "X of Y
109
+ used". The quota shape is recognised as narrowly as before, so anything that
110
+ is not unmistakably a quota body is treated as a limiter hit.
111
+
112
+ ### Notes
113
+
114
+ - **Not gated**: `fetchVaultHealth` and `enrollVaultDevice`. Both are
115
+ pre-credential, user-initiated, one-shot calls — `/healthz` is the
116
+ unauthenticated probe you reach for *while* diagnosing a stuck client, and
117
+ refusing it during a braked window would take away the diagnostic just when
118
+ it is wanted.
119
+ - **For lastGLANCE / lifeGLANCE**: safe to take at leisure (semver-minor).
120
+ During a braked window, client calls fail fast with `VaultError { status:
121
+ 429, code: 'RATE_LIMITED' }` instead of hitting the wire; any ladder that
122
+ already treats a real 429 as transient — they all do — handles it unchanged.
123
+ The meter and the write-loop warnings come along for free.
124
+ - An AES-GCM envelope carries a fresh random IV, so identical plaintext does
125
+ not produce an identical envelope: for encrypted payloads it is the polarity
126
+ flip that fires the detector, and the identical-content rule covers plaintext
127
+ and deterministic payloads.
128
+
129
+ ## [1.10.0] - 2026-08-01
130
+
131
+ **Should you take this release?** Yes, for any deployment using the database
132
+ (GLANCEvault) transport. Until now the DB sync engine had **no backoff of any
133
+ kind**: on a persistent failure it re-sent the same request on every cycle,
134
+ forever, at whatever rate the app polls. A server that was down, a device
135
+ offline, a wrong token — all of them produced an unbounded request loop. This
136
+ release adds the backoff the file tier has always had, and fixes two related
137
+ defects found alongside it. The file-tier (WebDAV) engine is untouched.
138
+
139
+ ### Fixed
140
+
141
+ - **The DB engine no longer hammers a failing server.** Both directions now
142
+ back off: 30s doubling to a 15-minute cap for pushes and a 5-minute cap for
143
+ pulls, with a flat one hour on an authentication failure — the file tier's
144
+ numbers, escalation curve and reset rule. Any success resets the window and
145
+ its strike count. A window only ever **delays**: the next ordinary cycle
146
+ after it expires probes again, with no user action and no restart, and
147
+ dirty rows are never dropped — backoff changes *when* a push is retried,
148
+ never *whether* it is.
149
+ - Unlike the file tier's, the DB engine's backoff is **self-enforcing**: the
150
+ engine skips its own work when called too soon. (The file tier's is
151
+ advisory — nothing reads `uploadBackoffUntil` except its getter, so
152
+ honouring it is the app's job. That is exactly why the hammering was live
153
+ in every published version.) `getUploadBackoffUntil()` and
154
+ `getDownloadBackoffUntil()` are exposed with the same names and meaning as
155
+ the file tier's so one scheduler can treat both tiers alike, but honouring
156
+ them is now an optimisation rather than a duty.
157
+ - **A failed pull no longer aborts the cycle.** 1.9.0 contained push failures
158
+ but left pull failures throwing past the device-cursor report. A pull error
159
+ is now captured the same way, so the cursor still reports on a cycle whose
160
+ pull failed.
161
+ - **The pull cursor is persisted per page.** It was written only after the
162
+ whole pagination loop, so a failure on page 3 discarded the progress of
163
+ pages 1 and 2 — a large backlog on a flaky connection could re-download the
164
+ same pages indefinitely and never converge. Every row in a page reaches a
165
+ terminal outcome (applied, quarantined, or skipped as engine-reserved)
166
+ before the page ends, so the cursor may safely advance past each completed
167
+ page.
168
+ - **A rejected credential surfacing from the push or pull now halts.** Once
169
+ the key is verified in-session, `ensureRootKey` makes no network call, so a
170
+ mid-session revocation is first seen by the push or the pull — and 1.9.0's
171
+ error capture routed those away from the halt path. They now reach exactly
172
+ the same halt logic as before; the halt itself, its identity rule, and the
173
+ `invalid credential` body match are unchanged.
174
+
175
+ ### Changed
176
+
177
+ - **Over-quota suppression is now one case of the backoff ladder**, not a
178
+ parallel mechanism. Its behaviour is unchanged — same curve, same
179
+ in-memory lifetime, still surfacing `QUOTA_EXCEEDED` with its descriptor on
180
+ every suppressed cycle so apps can render "X of Y used" — but
181
+ `getQuotaState()` and `isQuotaSuppressed()` are now derived from the push
182
+ window rather than a separate variable.
183
+ - **A shared-mode 401 is now delayed rather than retried immediately.** It
184
+ opens the one-hour auth window instead of being re-sent every cycle. It is
185
+ still not terminal and still not the credential halt: it self-resumes.
186
+ - New: `getBackoffState()` returns `{ push, pull }` windows with `until`,
187
+ `reason` (`'quota' | 'auth' | 'transport'`), `code`, `strikes` and `since`.
188
+ The `reason` is what lets an app distinguish a one-hour auth backoff from a
189
+ thirty-second transport one without inferring it from the timestamp.
190
+ - The cycle result gains `pullFailed`, `pullSkipped` and `pullErrorCode`,
191
+ mirroring the push fields added in 1.9.0.
192
+
193
+ ### Notes
194
+
195
+ - **Classes that are never delayed**, deliberately: `PASSPHRASE_REQUIRED` and
196
+ `ACCOUNT_ID_REQUIRED` fire before any network call and clear the instant the
197
+ app supplies the value, so a window would make the app feel broken for 30
198
+ seconds after a user types their passphrase; and `CREDENTIAL_INVALID` is
199
+ owned by the (terminal) credential halt, so stacking a window on it would be
200
+ meaningless at best. `QUOTA_EXCEEDED` opens exactly one window, its own.
201
+ - **For app integration — do not render backoff from the last `onError`
202
+ message.** On a cycle suppressed by a quiet (transport or auth) window, the
203
+ cycle's usual `onError(null, null, false)` reset still fires and nothing is
204
+ re-surfaced, so an app that remembers the last error string will see it
205
+ *cleared* while `onStatusChange` reads `'error'`. This is deliberate (quiet
206
+ windows report once; repeating every cycle is the noise this release
207
+ removes). Render standing backoff from `getBackoffState()` — `reason` plus
208
+ `until` gives "retrying in Xs" — and treat `onError` as an event stream, not
209
+ a status store. Quota windows are the exception and keep re-surfacing
210
+ `QUOTA_EXCEEDED` with the descriptor each cycle.
211
+ - The cycle result now reports a superseded stale instance truthfully:
212
+ `{ superseded: true }` rather than `{ halted: true }`, matching
213
+ `isSuperseded()` / `isCredentialHalted()` whichever path the credential
214
+ rejection arrived by.
215
+ - A healthy client is unaffected: no window is ever opened, no added latency,
216
+ and an identical request pattern.
217
+
218
+ ## [1.9.0] - 2026-08-01
219
+
220
+ **Should you take this release?** Yes if you run, or might run, a GLANCEvault
221
+ server with a row cap configured (`GLANCEVAULT_QUOTA_*`, server Phase 3.2).
222
+ Also yes for the sync-reliability fix below, which applies to **every**
223
+ deployment including shared mode and unconfigured servers: before this
224
+ release, a failed push also cost you the pull, so a device whose writes were
225
+ failing silently stopped receiving other devices' changes too. Nothing here
226
+ changes behavior against a server with no quota configured, which is and will
227
+ remain the common case.
228
+
229
+ ### Fixed
230
+
231
+ - **A failed push no longer prevents the pull.** The DB sync cycle threw
232
+ straight from a push failure to its error handler, so `pullRemoteChanges`
233
+ and the device-cursor report never ran. A device that could not write — for
234
+ *any* reason: a transport blip, a 500, a server-side cap — also stopped
235
+ **reading**, and silently fell behind its peers. The push error is now
236
+ captured, the pull and cursor run regardless, and the error is reported
237
+ after. Applies to every transport failure, not just the quota case that
238
+ exposed it. Behavior on a fully successful cycle is unchanged.
239
+ - A cycle whose push failed but whose pull succeeded now returns
240
+ `{ applied, skipped, skippedEntityIds, pushFailed: true, pushErrorCode,
241
+ quota }` — the pull's own numbers are reported as usual, and `onError`
242
+ fires with the push error's code and `isHardStop: false`. `lastSynced` is
243
+ deliberately **not** advanced (the device is not fully in step), and the
244
+ dirty rows are retained for the next cycle exactly as before.
245
+ - **A quota rejection is no longer mislabelled "update your server".** The key
246
+ verifier's establishing write is a net-new entity, so a fresh device on an
247
+ account at the row cap gets a 413 there — and every error on that path was
248
+ relabelled `VERIFIER_UNSUPPORTED` ("Your GLANCEvault server needs to be
249
+ updated to support key verification"), sending the user to fix a server
250
+ that is working correctly. `QUOTA_EXCEEDED` now passes through that
251
+ classifier, the same way `CREDENTIAL_INVALID` and the client-readiness
252
+ codes already did.
253
+
254
+ ### Added
255
+
256
+ - **Over-quota handling for the reachable dimension (server Phase 3.2 /
257
+ client Phase 3.3).** A 413 or 429 carrying the server's quota body
258
+ (`{ error: "quota exceeded", quota, limit, used, requested }`) is now typed
259
+ as `QUOTA_EXCEEDED` and carries the parsed descriptor, so consumers no
260
+ longer have to substring-match an error message to tell a quota rejection
261
+ from any other failure.
262
+ - **Never a halt.** A quota condition is the opposite of a rejected
263
+ credential: it clears when the operator raises the limit or reclaim runs
264
+ — with **no client action at all** — so the engine keeps running.
265
+ `isHardStop` is always `false`, nothing is persisted, and there is no
266
+ recovery entry point and nothing to clear.
267
+ - **Bounded, self-resuming suppression** instead of retrying a doomed write
268
+ every cycle: after a rejection the engine skips the *write* for 30s,
269
+ doubling on repeat to a 15-minute cap (the file tier's backoff shape),
270
+ while pulls and cursor reports continue untouched. The next ordinary
271
+ cycle after the window probes again; a write that gets through clears the
272
+ state by itself. Raising the server limit is picked up with no restart,
273
+ no re-enrollment, and no user action.
274
+ - New state queries: `getQuotaState()` (the descriptor plus `since` /
275
+ `retryAt`, in memory only) and `isQuotaSuppressed()`.
276
+ - **Defensive parse.** A body earns the typed classification only with the
277
+ exact wording, a non-empty `quota` string, and three finite numbers.
278
+ A missing field, a non-JSON body, a 413 with no quota fields, the legacy
279
+ SSE `{"error":"too many connections for account"}` 429, or an older
280
+ server that never sends this shape all degrade to the generic
281
+ `VAULT_ERROR` handled before — never a throw, never a mis-classification.
282
+ An **unrecognised** `quota` dimension from a newer server is passed
283
+ through verbatim and still treated as a quota condition, since the remedy
284
+ is the same; render an unfamiliar dimension generically.
285
+
286
+ ### Notes
287
+
288
+ - **Scope: `rows` is the only quota dimension this package can reach.** The
289
+ server enforces `storage` and `concurrent-uploads` at the blob path, and
290
+ `intents` at the intents route; this package has no blob path (media is
291
+ stripped at the adapter boundary) and no intents path (that is
292
+ `@glance-apps/intents`), and no SSE client. So "text syncs while media
293
+ fails" is not something this package can exercise. Handling for those
294
+ dimensions belongs with the media transport work that would call them, and
295
+ was deliberately not written here.
296
+ - **Known gap, not fixed here:** the DB engine has no backoff of any kind
297
+ outside the quota window above. On a persistent write failure the same
298
+ dirty rows are re-pushed on every cycle indefinitely, where the file tier
299
+ has exponential backoff with an auth-failure escalation. This is a real
300
+ defect with a live blast radius and it wants its own change rather than a
301
+ corner of this one.
302
+
10
303
  ## [1.8.0] - 2026-07-31
11
304
 
12
305
  ### Added
@@ -62,6 +355,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
62
355
  credential minted and then lost to a persistence failure is superseded by
63
356
  the next successful enrollment for the same `(accountId, deviceId)`.
64
357
 
358
+ ### Fixed
359
+
360
+ - **Device cursors now report on installs that never configured a
361
+ `deviceId`, automatically on upgrade.** Such installs have never updated
362
+ their device cursor: the cursor step silently no-opped (`{updated:
363
+ false}`) whenever no `deviceId` was passed, so the server's tombstone GC
364
+ had no cursor for those devices. On upgrade, a device id is generated and
365
+ persisted (`{prefix}-device-id`) at engine construction, and cursor
366
+ reporting begins on the first completed sync cycle — **no app changes
367
+ required**; this applies to plain `createDbSyncEngine` construction, not
368
+ only the new connect flow, and identically in shared and per-account
369
+ mode. Installs that already pass an explicit `deviceId` are unaffected
370
+ and keep using it. Operator note: an install this fixes has no prior
371
+ cursor history, so the newly generated id strands nothing.
372
+ (This shipped in 1.7.0's device-identity work but 1.7.0 was never
373
+ published; it reaches consumers with this release.)
374
+
375
+ ### Upgrade notes
376
+
377
+ Two behavior changes arrive with the fix above, without opt-in:
378
+
379
+ - **Engine construction now touches localStorage.** `getOrCreateDeviceId`
380
+ reads and writes `{prefix}-device-id` at construction time; previously
381
+ construction deferred all localStorage access to cycle time. In an
382
+ environment without a `localStorage` global, construction now fails where
383
+ it previously succeeded and failed later at first sync. Theoretical for
384
+ browser and WebView hosts (all three GLANCE apps), but if construction
385
+ throws before any network activity, look here first.
386
+ - **Injected `vaultClient` consumers see new `device()` calls.** An engine
387
+ constructed with `config.vaultClient` and no `deviceId` previously made
388
+ zero `device()` calls; it now makes one per cycle. A mock or bridge
389
+ client without a `device()` method will throw inside the cursor step —
390
+ swallowed and warned, never fatal — but the console warning is new and
391
+ recurring. Affects tests with mock clients and native shells that inject
392
+ a bridge client.
393
+
65
394
  ## [1.7.0] - 2026-07-31
66
395
 
67
396
  ### Added
package/README.md CHANGED
@@ -158,6 +158,71 @@ Pass `encryptionEnabled: true` in the sync config object stored via `engine.setC
158
158
  | `createProviders(config)` | Creates cloud sync provider objects (`nextcloud`, `koofr`, `webdav`). |
159
159
  | `normalizeEtag(raw)` | Normalizes a raw ETag header value for If-Match use: strips a weak-validator prefix (`W/"abc"` → `"abc"`) and the content-coding suffixes some servers append inside the quotes (`"abc-gzip"` / `"abc-br"` → `"abc"`). Quotes are preserved; `null`/`undefined` pass through unchanged. |
160
160
 
161
+ ### GLANCEvault client
162
+
163
+ | Export | Description |
164
+ |--------|-------------|
165
+ | `createVaultClient(config)` | HTTP client for a GLANCEvault server. `config` is `{ vaultUrl, vaultToken, fetchImpl?, brake? }`. Every method carries the Bearer token, classifies errors uniformly (`CREDENTIAL_INVALID`, `QUOTA_EXCEEDED`, `RATE_LIMITED`, else `VaultError` with `.status`), and passes through the module-scope brake and budget meter. |
166
+ | `fetchVaultHealth(config)` | Unauthenticated `GET /healthz`. Returns `{ status, version, schemaVersion, authMode }`, with `authMode` normalized to `'shared'` on servers that predate the field. |
167
+ | `enrollVaultDevice(config)` | Exchanges the admin-configured bootstrap secret for this device's own credential (`POST /enroll`). The credential is returned once and never again. |
168
+
169
+ **`VaultClient` methods:**
170
+
171
+ | Method | Description |
172
+ |--------|-------------|
173
+ | `batch(app, { accountId, rows })` | Upserts rows (`{ entityId, envelope, createdAt }`). Returns `{ written, maxSeq }`. |
174
+ | `list(app, { accountId, since })` | One page of rows with `seq > since`. Returns `{ rows, hasMore }`. |
175
+ | `getRow(app, entityId, accountId)` | Fetches one row; `null` on 404. |
176
+ | `deleteRow(app, entityId, accountId, opts?)` | Soft-deletes a row. `opts.deletedAt` (epoch ms) stamps the tombstone for delete-vs-edit LWW. |
177
+ | `device(app, { accountId, deviceId, lastSeenSeq })` | Updates this device's cursor. |
178
+ | `getSalt(accountId)` / `putSalt(accountId, salt)` | Reads/registers the account root-key salt as a `Uint8Array` (first-write-wins). |
179
+ | `intentsBatch(accountId, events)` | Appends intent events (`{ eventId, envelope, expiresAt }`) via `POST /intents/batch`. **Insert-only** — a re-sent `eventId` is a server no-op, so retrying an ambiguous failure is safe. Returns `{ written, maxSeq }`. |
180
+ | `intentsList(accountId, { since, limit })` | One ascending page of non-expired intent events with `seq > since` via `GET /intents/list`. Returns `{ rows, hasMore }`; the server pages at 500. |
181
+
182
+ The intents surface is **pure transport**: the `envelope` is opaque here and is
183
+ never decoded or inspected. The codec (and the per-envelope key derivation it
184
+ needs) stays in `@glance-apps/intents` — see `deriveKeyForSalt` above.
185
+
186
+ ### Request diagnostics
187
+
188
+ One brake, one budget meter and one write-loop history per **bundle realm** —
189
+ not per client instance. They model a resource every client in the process
190
+ shares: the server's per-IP request budget. A separately bundled copy of this
191
+ package (an Obsidian plugin, say) is its own realm, which is correct — it is a
192
+ separate process with its own traffic.
193
+
194
+ | Export | Description |
195
+ |--------|-------------|
196
+ | `isVaultRateLimited()` | `true` while the brake is engaged. For callers that would rather sit a whole cycle out pre-flight than fire a call and catch the rejection. |
197
+ | `vaultBrakeStatus()` | `{ braked, until, memoryMs, retryInMs }`. |
198
+ | `getVaultStats()` | The brake status, the last rolling minute of requests attributed by method, and the entityIds whose write history looks loop-shaped. |
199
+ | `configureVaultDiagnostics(options?)` | Tunes the visibility-only thresholds (`softLimitPerMinute`, `loopTransitions`, `loopWindowMs`) and the log sink. The brake's curve is deliberately not configurable. |
200
+ | `resetVaultDiagnostics()` | Clears all three and restores the defaults. For tests. |
201
+
202
+ **The brake.** Any real 429 pauses *every* vault request in the realm: 30s,
203
+ doubling per burst to a 10-minute ceiling, with one arming per burst. While
204
+ paused, calls fail fast — before touching the network — with a `VaultError`
205
+ carrying `status: 429`, `code: 'RATE_LIMITED'` and `retryInMs`, so retry
206
+ ladders that already treat a real 429 as transient need no new code. A 2xx
207
+ releases the pause but only **halves** the escalation memory, so the next 429
208
+ re-arms at the storm's level; a genuine recovery drains to zero in a few quiet
209
+ successes. An over-quota 429 (`QUOTA_EXCEEDED`) is not a limiter hit and does
210
+ not arm it. `createVaultClient({ brake: false })` opts a client out of both the
211
+ gate and the arming — an escape hatch for tests.
212
+
213
+ **The budget meter and the write-loop detector** are visibility only; neither
214
+ ever throttles. The meter logs one attributable line per minute when a realm
215
+ crosses a soft threshold (default 300/min, half the server's default per-IP
216
+ budget). The detector keeps a bounded per-`entityId` write history across
217
+ `batch` upserts and `deleteRow` and warns once per id per window when four
218
+ **redundant** writes to one id land inside ten minutes. A write is redundant
219
+ when it cannot have changed the row's state from what the previous write to
220
+ that id left it: a polarity flip, a **repeat delete** (re-deleting a row that
221
+ is already a tombstone — the shape of the incident this was built for), or a
222
+ rewrite with identical content. It fails open by construction: the rule needs
223
+ repeated events on the *same* id, so a first sync, a large import, or a bulk
224
+ cleanup deleting many different ids can never trigger it.
225
+
161
226
  ### Auto-backup
162
227
 
163
228
  | Export | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.8.0",
3
+ "version": "1.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",