@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 +329 -0
- package/README.md +65 -0
- package/package.json +1 -1
- package/src/dbEngine.js +319 -28
- package/src/index.js +8 -0
- package/src/vaultClient.js +606 -19
- package/types/index.d.ts +204 -1
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 |
|