@glance-apps/sync 1.10.0 → 2.0.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 +300 -0
- package/README.md +67 -0
- package/package.json +1 -1
- package/src/dbEngine.js +334 -52
- package/src/index.js +16 -1
- package/src/vaultClient.js +557 -20
- package/types/index.d.ts +182 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,306 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.0.0] - 2026-08-30
|
|
11
|
+
|
|
12
|
+
> ### ⚠️ ACTION REQUIRED for lastGLANCE and lifeGLANCE
|
|
13
|
+
>
|
|
14
|
+
> **Add `pullCursorCommit: 'per-page'` to your `createDbSyncEngine` config when
|
|
15
|
+
> you take this release.** Without it you lose 1.10.0's mid-pagination resume
|
|
16
|
+
> and silently regain the convergence bug it fixed: a large backlog on a flaky
|
|
17
|
+
> connection re-downloads from the start on every failure instead of resuming
|
|
18
|
+
> from the last good page.
|
|
19
|
+
>
|
|
20
|
+
> It will not look like a config gap. It will look like a network problem,
|
|
21
|
+
> weeks after the bump, and nobody will connect the two. One line prevents it.
|
|
22
|
+
|
|
23
|
+
**Should you take this release?** Yes, and read this entry before you do — it is
|
|
24
|
+
the only release so far that changes what an existing caller gets **without that
|
|
25
|
+
caller changing any code**.
|
|
26
|
+
|
|
27
|
+
Since 1.10.0, `pullRemoteChanges` has persisted the pull cursor **per page**, on
|
|
28
|
+
the assumption that a row is durable the moment `applyRemoteEntity` returns.
|
|
29
|
+
That is true for a caller writing straight into its store. It is false for a
|
|
30
|
+
caller applying into a per-cycle mirror it discards on failure
|
|
31
|
+
(commit-only-on-success): those rows are consumed, the cursor advances past
|
|
32
|
+
them, they are never committed, an incremental pull never re-lists them, and a
|
|
33
|
+
glitch heal cannot reach them because they were never in a snapshot. That is
|
|
34
|
+
permanent row loss, and it was demonstrated as such.
|
|
35
|
+
|
|
36
|
+
The assumption was stated only in a source comment. The config contract
|
|
37
|
+
documenting `applyRemoteEntity` said nothing about durability at all. **A
|
|
38
|
+
caller cannot honour a contract it is never shown** — so it is now a declared,
|
|
39
|
+
validated mode, and the default is the safe one.
|
|
40
|
+
|
|
41
|
+
### Changed — BREAKING
|
|
42
|
+
|
|
43
|
+
- **`pullCursorCommit` now controls when the pull cursor is persisted, and it
|
|
44
|
+
defaults to `'end-of-pull'`.** Previously the engine always behaved as
|
|
45
|
+
`'per-page'`, with no way to say otherwise.
|
|
46
|
+
|
|
47
|
+
| Mode | When the cursor advances | For |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `'end-of-pull'` **(new default)** | Once, after a fully successful pagination loop | Any caller that has not thought about it |
|
|
50
|
+
| `'per-page'` | After each completed page — 1.10.0 behaviour | A caller whose apply is durable on return |
|
|
51
|
+
| `'caller'` | Never; the engine writes nothing | A commit-only-on-success composer |
|
|
52
|
+
|
|
53
|
+
Under `'end-of-pull'`, a mid-pagination failure re-pulls from where the run
|
|
54
|
+
started. Re-applying already-applied rows is idempotent under entity-grain
|
|
55
|
+
LWW, so the cost is bandwidth; the cost of the old default, for the wrong
|
|
56
|
+
caller, was rows.
|
|
57
|
+
|
|
58
|
+
In `'caller'` mode `pullRemoteChanges` returns `maxSeq` and the caller
|
|
59
|
+
commits it with `setHighWaterMark` once its own state is durable. No new API
|
|
60
|
+
was needed — `setHighWaterMark` was already public. `dbSyncCycle` commits for
|
|
61
|
+
itself on a fully successful cycle, so declaring `'caller'` and continuing to
|
|
62
|
+
use the cycle still converges.
|
|
63
|
+
|
|
64
|
+
- **An unrecognised `pullCursorCommit` is REFUSED at construction**, not
|
|
65
|
+
defaulted. `'perPage'`, `'per_page'` or `'PER-PAGE'` throw with a message
|
|
66
|
+
naming every valid mode and the value actually passed. A typo silently
|
|
67
|
+
falling back to a default is precisely the failure this contract exists to
|
|
68
|
+
remove: it would quietly restore the unsafe behaviour on a caller that was
|
|
69
|
+
trying to opt out of it.
|
|
70
|
+
|
|
71
|
+
### Why this is 2.0.0
|
|
72
|
+
|
|
73
|
+
No API signature changed, and by the letter of this package's old versioning
|
|
74
|
+
note — "breaking changes to the public API or the sync envelope schema" — this
|
|
75
|
+
would have been a minor. That note was too narrow, and has been widened
|
|
76
|
+
(see the README): **a behavioural default that changes what an existing caller
|
|
77
|
+
gets without that caller changing code is breaking**, because the version
|
|
78
|
+
number is the only signal that cannot be skipped. Under-signalling here costs a
|
|
79
|
+
field bug that presents as a flaky connection; over-signalling costs a digit.
|
|
80
|
+
|
|
81
|
+
### Why the default was flipped, and what argued against it
|
|
82
|
+
|
|
83
|
+
The default is what the next composed caller inherits by saying nothing, and
|
|
84
|
+
the next composed caller is an Obsidian plugin. Inheriting the unsafe path by
|
|
85
|
+
saying nothing is exactly what put a data-loss bug in front of dayGLANCE.
|
|
86
|
+
|
|
87
|
+
The alternative was real and was weighed: keep `'per-page'` as the default and
|
|
88
|
+
require an explicit opt-out from callers needing the safe path. It costs the
|
|
89
|
+
shipping consumers nothing and needs no major bump. It loses on the one point
|
|
90
|
+
that matters — the unsafe path stays the thing you get by default — and the
|
|
91
|
+
tiebreaker was sequencing rather than principle: all three consumers are
|
|
92
|
+
touching engine config for 1.12.0 anyway, so the safe default costs them one
|
|
93
|
+
extra line in a change they are already making. Had those landed far apart, the
|
|
94
|
+
decision would have gone the other way.
|
|
95
|
+
|
|
96
|
+
### Notes
|
|
97
|
+
|
|
98
|
+
- **dayGLANCE's cursor rollback stays.** It covers two loss windows and
|
|
99
|
+
`'end-of-pull'` closes only the first. The second — the pull succeeded but a
|
|
100
|
+
later step (push, heal, commit) threw, so the mirror was discarded anyway —
|
|
101
|
+
is invisible to this package. Only `'caller'` mode closes both, and adopting
|
|
102
|
+
it is dayGLANCE's call on its own schedule.
|
|
103
|
+
- **Per-page convergence does not come back for dayGLANCE under any mode.** It
|
|
104
|
+
needs per-page *durable* commits, which an all-or-nothing merge-aware commit
|
|
105
|
+
does not provide.
|
|
106
|
+
- Not closed here: the cycle-only `syncing` re-entrancy guard (see 1.12.0).
|
|
107
|
+
|
|
108
|
+
## [1.12.0] - 2026-08-30
|
|
109
|
+
|
|
110
|
+
**Should you take this release?** Yes if anything in your app calls
|
|
111
|
+
`pushDirtyRows`, `pullRemoteChanges` or `updateDeviceCursor` directly instead of
|
|
112
|
+
`dbSyncCycle`. Until now the two tiers had unequal safety: the cycle carried
|
|
113
|
+
backoff, over-quota suppression and the credential halt, and the primitives
|
|
114
|
+
beneath it carried none. A caller composing its own cycle silently opted out of
|
|
115
|
+
all three, with no signal that it had. This release moves enforcement down into
|
|
116
|
+
the primitives, so every caller inherits it by construction. **If you only ever
|
|
117
|
+
call `dbSyncCycle`, nothing about this release changes what your app does.**
|
|
118
|
+
|
|
119
|
+
### Changed
|
|
120
|
+
|
|
121
|
+
- **The three primitives now enforce their own protections.**
|
|
122
|
+
`pushDirtyRows` and `pullRemoteChanges` refuse to run while their direction's
|
|
123
|
+
backoff window is open, rejecting with a typed `SYNC_SUPPRESSED` error
|
|
124
|
+
*before* any network call, and they open, escalate and clear those windows
|
|
125
|
+
themselves. Both also refuse while the device is under the credential halt,
|
|
126
|
+
and both now SET the halt when a rejection reaches them — a guard over a halt
|
|
127
|
+
that nothing sets never fires, so it took both halves.
|
|
128
|
+
- **`dbSyncCycle` delegates entirely downward.** It no longer checks a window
|
|
129
|
+
or records a failure; it distinguishes "my own gate refused this" from "the
|
|
130
|
+
server refused this" and reports as before. Enforcing in both places would
|
|
131
|
+
have **doubled the escalation** — `strikes` incrementing once in the
|
|
132
|
+
primitive and once in the cycle turns the ladder into 30s, 120s, 480s instead
|
|
133
|
+
of 30, 60, 120. The alternative shape (primitives no-op when called from
|
|
134
|
+
inside the cycle) was rejected: it makes correctness depend on a caller
|
|
135
|
+
passing a flag, which is the exact defect this release removes, relocated one
|
|
136
|
+
layer down.
|
|
137
|
+
- **`updateDeviceCursor` is deliberately different**: halt-gated, but *not*
|
|
138
|
+
window-gated, never recording and never throwing. It reports a refusal as
|
|
139
|
+
`{ updated: false, suppressed: true }`. It cannot throw because callers await
|
|
140
|
+
it mid-cycle inside their own try blocks, and it is not window-gated because
|
|
141
|
+
the pull now records its own failure — so the window is already open by the
|
|
142
|
+
time the cursor report runs on the very cycle the pull was attempted, and
|
|
143
|
+
gating there would silently undo 1.10.0's decoupling ("the cursor report runs
|
|
144
|
+
whenever the pull was ATTEMPTED, success or failure"). Whether a cycle has
|
|
145
|
+
anything to report is a composition decision that stays with the caller.
|
|
146
|
+
|
|
147
|
+
### Added
|
|
148
|
+
|
|
149
|
+
- `SYNC_SUPPRESSED`: the typed rejection from the engine's own gate. It carries
|
|
150
|
+
`suppressed: true`, `direction` (`'push' | 'pull'`), `reason`, `causeCode`,
|
|
151
|
+
and `retryAt` / `retryInMs`.
|
|
152
|
+
- `isSuppressedError(err)` — exported so a caller can tell a pause from a
|
|
153
|
+
failure **without string matching and without hardcoding a code literal**.
|
|
154
|
+
|
|
155
|
+
### Notes for callers with their own retry ladder or circuit breaker
|
|
156
|
+
|
|
157
|
+
- **Do not treat `SYNC_SUPPRESSED` as a failure.** This is the one integration
|
|
158
|
+
hazard in the release. An app that escalates its own cooldown on every thrown
|
|
159
|
+
error will escalate on a pause this engine already scheduled — two brakes
|
|
160
|
+
compounding, each correct in isolation, with the device waiting far longer
|
|
161
|
+
than either layer intends. Branch on `isSuppressedError(err)` (or
|
|
162
|
+
`err.suppressed`) and align to `err.retryAt` rather than adding a second
|
|
163
|
+
window on top of it.
|
|
164
|
+
- **This cannot be made structurally impossible from inside the package.** The
|
|
165
|
+
app owns its breaker and reacts to what it catches; the only way to remove
|
|
166
|
+
the caller's judgement entirely would be for the package to own the whole
|
|
167
|
+
retry, which means adopting `dbSyncCycle` — exactly what composed callers
|
|
168
|
+
compose to avoid. So the signal is made as unmissable as we can: a typed
|
|
169
|
+
code, a redundant boolean, an exported predicate, and a retry deadline to
|
|
170
|
+
align to.
|
|
171
|
+
- **Why a throw and not a skipped return value.** A return lets a caller that
|
|
172
|
+
ignores it treat a suppressed cycle as a successful one — committing state
|
|
173
|
+
built from a pull that never ran, and resetting its own breaker on a cycle
|
|
174
|
+
that did nothing. Throwing converts the worst failure mode from "silently
|
|
175
|
+
wrong" to "loudly refused". The argument the other way is real: a return
|
|
176
|
+
needs no `catch`, and a caller that forgets to special-case the code turns a
|
|
177
|
+
deliberate pause into what looks like an error. The `RATE_LIMITED` precedent
|
|
178
|
+
from 1.11.0 settled it — one shape for "the client refused this", not two.
|
|
179
|
+
- **A direct call that fails now opens a window**, so an immediate retry of a
|
|
180
|
+
failed primitive is refused rather than re-sent. Retrying after `retryAt` is
|
|
181
|
+
the supported pattern; this is what the cycle has always done.
|
|
182
|
+
|
|
183
|
+
### Notes
|
|
184
|
+
|
|
185
|
+
- **Not closed by this release** (reported, deliberately unfixed): the
|
|
186
|
+
`syncing` re-entrancy guard is still cycle-only, so a composed caller can
|
|
187
|
+
overlap primitive calls. See the PR for what that does and why it is a
|
|
188
|
+
different shape of problem from the two this release addresses.
|
|
189
|
+
- The cursor durability contract is Phase 4b and lands separately.
|
|
190
|
+
|
|
191
|
+
## [1.11.0] - 2026-08-30
|
|
192
|
+
|
|
193
|
+
**Should you take this release?** Yes if you use the GLANCEvault transport, and
|
|
194
|
+
immediately if anything in your bundle writes to it on a timer. This release is
|
|
195
|
+
the client-side answer to a 2026-08-30 incident in which an intent-cleanup loop
|
|
196
|
+
in the dayGLANCE Obsidian plugin deleted rows that were already tombstones. The
|
|
197
|
+
server re-tombstoned on every call — fresh seq, fresh SSE nudge — which put the
|
|
198
|
+
row back above the caller's cursor and fed the loop its own output. It ran for
|
|
199
|
+
hours and saturated the server's per-IP rate budget, and it was **invisible**:
|
|
200
|
+
every delete was fire-and-forget behind a `.catch(() => {})`, so nothing failed
|
|
201
|
+
loudly enough to notice. Two patches landed elsewhere (the plugin no longer
|
|
202
|
+
deletes tombstones; the vault's `softDeleteRow` is now idempotent). This release
|
|
203
|
+
adds the protection and the visibility **by construction**, so no caller has to
|
|
204
|
+
remember them and no loop can be silent. No server changes: the wire contract is
|
|
205
|
+
untouched.
|
|
206
|
+
|
|
207
|
+
### Added
|
|
208
|
+
|
|
209
|
+
- **The intents transport.** `intentsBatch(accountId, events)` and
|
|
210
|
+
`intentsList(accountId, { since, limit })` wrap the server's existing
|
|
211
|
+
`/intents/*` endpoints, so apps can drop their hand-rolled raw-fetch
|
|
212
|
+
transports and inherit this client's auth, error classification, brake and
|
|
213
|
+
meter. `intentsBatch` is insert-only server-side — a re-sent `eventId` is a
|
|
214
|
+
no-op — so retrying an ambiguous failure is safe by construction.
|
|
215
|
+
- **Pure transport**: the `envelope` is opaque here and is never decoded or
|
|
216
|
+
inspected. The codec, and the per-envelope key derivation it needs, stays
|
|
217
|
+
in `@glance-apps/intents`; this package carries the transport without
|
|
218
|
+
carrying the crypto.
|
|
219
|
+
- **A device-wide request brake.** Any real 429 now pauses *every* vault
|
|
220
|
+
request in the bundle realm: 30s, doubling per burst to a 10-minute ceiling,
|
|
221
|
+
one arming per burst (a concurrent fan-out that all meets the limiter costs
|
|
222
|
+
one escalation step, not ten). While paused, calls fail fast — **before
|
|
223
|
+
touching the network** — with a `VaultError` carrying `status: 429`,
|
|
224
|
+
`code: 'RATE_LIMITED'` and `retryInMs`.
|
|
225
|
+
- It is at **module scope, not per client instance**, deliberately: the thing
|
|
226
|
+
it protects is shared by every client in the process (the server's per-IP
|
|
227
|
+
budget), so the state that models it belongs to the realm. A separately
|
|
228
|
+
bundled copy — the Obsidian plugin's, say — is its own realm, which is
|
|
229
|
+
correct: separate process, separate traffic.
|
|
230
|
+
- **Decay, never amnesty.** A 2xx releases the pause but only *halves* the
|
|
231
|
+
escalation memory (below the 30s base it drains to zero). This is the fix
|
|
232
|
+
for the earlier app-side brake, which reset escalation to zero on *any*
|
|
233
|
+
success: on a saturated shared budget an occasional cheap request slips
|
|
234
|
+
into a fresh limiter window and returns 200, and each lucky 200 wiped the
|
|
235
|
+
whole 30s→480s escalation and re-licensed full cadence. Halving keeps the
|
|
236
|
+
storm's level in memory — the next 429 re-arms at 2× what is left — while a
|
|
237
|
+
genuine recovery still drains to nothing in a few quiet successes.
|
|
238
|
+
- Reads: `isVaultRateLimited()` and `vaultBrakeStatus()`, for callers that
|
|
239
|
+
prefer to sit a whole cycle out pre-flight. Escape hatch:
|
|
240
|
+
`createVaultClient({ brake: false })` opts a client out of both the gate
|
|
241
|
+
and the arming.
|
|
242
|
+
- **A budget meter.** Requests are counted per rolling minute and attributed by
|
|
243
|
+
method name. Crossing a soft threshold (default 300/min — half the server's
|
|
244
|
+
default per-IP budget, configurable) logs **one** line per window naming the
|
|
245
|
+
top contributors: `[vault] budget: 412 requests in the last minute (soft
|
|
246
|
+
limit 300) — top: intentsList 210, batch 130, deleteRow 72`. Visibility only;
|
|
247
|
+
it never throttles. Its whole argument is that it needs no theory about *why*
|
|
248
|
+
a loop is running, so the next storm is an attributed event on day one rather
|
|
249
|
+
than an afternoon of archaeology.
|
|
250
|
+
- **A write-loop detector — the success-side signal.** Failure visibility
|
|
251
|
+
already existed; what was missing on the day was the signal for everything
|
|
252
|
+
"succeeding" pathologically. The client now keeps a bounded per-`entityId`
|
|
253
|
+
history across `batch` upserts and `deleteRow`, and warns loudly (once per id
|
|
254
|
+
per window) when four **redundant** writes to one id land inside ten minutes.
|
|
255
|
+
Diagnostic only — never a brake.
|
|
256
|
+
- A write is redundant when it cannot have changed the row's state from what
|
|
257
|
+
the previous write to that id left it. Three shapes qualify: a **polarity
|
|
258
|
+
flip** (the delete-and-resupply loop), a **repeat delete** (re-deleting a
|
|
259
|
+
row that is already a tombstone — the incident's own shape: same polarity
|
|
260
|
+
every time, no content at all), and an **identical-content rewrite**.
|
|
261
|
+
- **Fails open by construction**: the rule needs repeated events on the
|
|
262
|
+
*same* id, so a first sync, a large import, or a bulk cleanup deleting many
|
|
263
|
+
different ids — many ids, one event each — can never trigger it, however
|
|
264
|
+
large. A bounded retry of one failed delete does not reach the threshold
|
|
265
|
+
either.
|
|
266
|
+
- Both thresholds are tunable via `configureVaultDiagnostics`. Four redundant
|
|
267
|
+
writes means five events (upsert/delete/upsert/delete/upsert, or five
|
|
268
|
+
deletes of one tombstone), which no human edit pattern produces and every
|
|
269
|
+
delete/resupply or re-tombstone loop produces within seconds; ten minutes
|
|
270
|
+
is long enough to catch a loop running on a 60s sync cycle and short enough
|
|
271
|
+
that a week of ordinary edits never accumulates into it.
|
|
272
|
+
- `getVaultStats()` returns all three in one read: brake status, the last
|
|
273
|
+
rolling minute of requests by method, and the current write-loop suspects.
|
|
274
|
+
`configureVaultDiagnostics(options)` tunes the visibility-only thresholds and
|
|
275
|
+
the log sink; `resetVaultDiagnostics()` clears everything (for tests).
|
|
276
|
+
|
|
277
|
+
### Changed
|
|
278
|
+
|
|
279
|
+
- Every `createVaultClient` method now passes through the brake and the meter.
|
|
280
|
+
The only behaviour change a healthy caller can observe is the new
|
|
281
|
+
`RATE_LIMITED` rejection while a window is open — and only after the server
|
|
282
|
+
has already answered a real 429.
|
|
283
|
+
- A **429 arms the brake only when it is a rate-limiter hit**, never when it is
|
|
284
|
+
an over-quota rejection. The server uses 429 for both and they call for
|
|
285
|
+
opposite handling: a limiter hit means "stop asking for a while" (exactly
|
|
286
|
+
what the brake does), while a quota rejection means the account is full,
|
|
287
|
+
clears only when an operator acts, and already has its own self-resuming
|
|
288
|
+
window in the DB engine. Gating on it would hide `QUOTA_EXCEEDED` and its
|
|
289
|
+
descriptor behind `RATE_LIMITED` and leave apps unable to render "X of Y
|
|
290
|
+
used". The quota shape is recognised as narrowly as before, so anything that
|
|
291
|
+
is not unmistakably a quota body is treated as a limiter hit.
|
|
292
|
+
|
|
293
|
+
### Notes
|
|
294
|
+
|
|
295
|
+
- **Not gated**: `fetchVaultHealth` and `enrollVaultDevice`. Both are
|
|
296
|
+
pre-credential, user-initiated, one-shot calls — `/healthz` is the
|
|
297
|
+
unauthenticated probe you reach for *while* diagnosing a stuck client, and
|
|
298
|
+
refusing it during a braked window would take away the diagnostic just when
|
|
299
|
+
it is wanted.
|
|
300
|
+
- **For lastGLANCE / lifeGLANCE**: safe to take at leisure (semver-minor).
|
|
301
|
+
During a braked window, client calls fail fast with `VaultError { status:
|
|
302
|
+
429, code: 'RATE_LIMITED' }` instead of hitting the wire; any ladder that
|
|
303
|
+
already treats a real 429 as transient — they all do — handles it unchanged.
|
|
304
|
+
The meter and the write-loop warnings come along for free.
|
|
305
|
+
- An AES-GCM envelope carries a fresh random IV, so identical plaintext does
|
|
306
|
+
not produce an identical envelope: for encrypted payloads it is the polarity
|
|
307
|
+
flip that fires the detector, and the identical-content rule covers plaintext
|
|
308
|
+
and deterministic payloads.
|
|
309
|
+
|
|
10
310
|
## [1.10.0] - 2026-08-01
|
|
11
311
|
|
|
12
312
|
**Should you take this release?** Yes, for any deployment using the database
|
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 |
|
|
@@ -173,6 +238,8 @@ TypeScript declarations for all of the above are in `types/index.d.ts`.
|
|
|
173
238
|
|
|
174
239
|
This package follows [Semantic Versioning](https://semver.org/). Breaking changes to the public API or the sync envelope schema will result in a major version bump.
|
|
175
240
|
|
|
241
|
+
A **change to a behavioural default** counts as breaking and gets a major bump too, even when no API signature changes. The test is whether a dependent that changes none of its own code can get different behaviour by taking the release: if it can, the version number has to say so, because the changelog is the thing people skip. 2.0.0 flipped the pull-cursor default from per-page to end-of-pull on exactly this basis.
|
|
242
|
+
|
|
176
243
|
## License
|
|
177
244
|
|
|
178
245
|
MIT
|