@glance-apps/sync 1.11.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 +181 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/dbEngine.js +334 -52
- package/src/index.js +8 -1
- package/types/index.d.ts +74 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,187 @@ 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
|
+
|
|
10
191
|
## [1.11.0] - 2026-08-30
|
|
11
192
|
|
|
12
193
|
**Should you take this release?** Yes if you use the GLANCEvault transport, and
|
package/README.md
CHANGED
|
@@ -238,6 +238,8 @@ TypeScript declarations for all of the above are in `types/index.d.ts`.
|
|
|
238
238
|
|
|
239
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.
|
|
240
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
|
+
|
|
241
243
|
## License
|
|
242
244
|
|
|
243
245
|
MIT
|
package/package.json
CHANGED
package/src/dbEngine.js
CHANGED
|
@@ -60,6 +60,28 @@ const ts = (v) => {
|
|
|
60
60
|
// A window never becomes terminal: it only ever delays. Whatever the
|
|
61
61
|
// interval, the next ordinary cycle after it expires probes again, with no
|
|
62
62
|
// user action and no restart.
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
// The pull-cursor durability contract (Phase 4b).
|
|
65
|
+
//
|
|
66
|
+
// pullRemoteChanges persists a cursor recording what this device has consumed.
|
|
67
|
+
// Whether that is SAFE depends on something only the caller knows: whether
|
|
68
|
+
// applyRemoteEntity made the row durable by the time it returned.
|
|
69
|
+
//
|
|
70
|
+
// - lastGLANCE and lifeGLANCE write straight into Dexie, so it is durable
|
|
71
|
+
// immediately and the cursor may advance per page.
|
|
72
|
+
// - dayGLANCE applies into a per-cycle mirror that is DISCARDED on failure
|
|
73
|
+
// (commit-only-on-success). For it, a cursor advanced mid-pagination sits
|
|
74
|
+
// ahead of committed state: those rows are consumed, never committed,
|
|
75
|
+
// never re-listed by an incremental pull, and unreachable by its glitch
|
|
76
|
+
// heal — permanent row loss, demonstrated in the 1.10.0 survey.
|
|
77
|
+
//
|
|
78
|
+
// From 1.10.0 to 1.12.0 the per-page assumption was stated only in a source
|
|
79
|
+
// comment, and the config contract documenting applyRemoteEntity said nothing
|
|
80
|
+
// about durability at all. A caller cannot honour a contract it is never
|
|
81
|
+
// shown, so it is now a declared, validated mode.
|
|
82
|
+
const PULL_CURSOR_MODES = ['end-of-pull', 'per-page', 'caller'];
|
|
83
|
+
const DEFAULT_PULL_CURSOR_MODE = 'end-of-pull';
|
|
84
|
+
|
|
63
85
|
const BACKOFF_BASE_S = 30;
|
|
64
86
|
const MAX_PUSH_BACKOFF_S = 15 * 60; // writes, matching the file tier's upload cap
|
|
65
87
|
const MAX_PULL_BACKOFF_S = 5 * 60; // reads, matching the file tier's download cap
|
|
@@ -102,6 +124,27 @@ export const getOrCreateDeviceId = (storageKeyPrefix) => {
|
|
|
102
124
|
|
|
103
125
|
// Storage keys shared between this engine and the connect/recovery flow in
|
|
104
126
|
// vaultConnect.js. Defined once here so the two modules cannot drift.
|
|
127
|
+
/**
|
|
128
|
+
* True when an error is this engine's own gate refusing a call (Phase 4a),
|
|
129
|
+
* rather than anything the server said.
|
|
130
|
+
*
|
|
131
|
+
* THE POINT OF THIS EXPORT is that a caller with its own retry ladder or
|
|
132
|
+
* circuit breaker must be able to tell "the package paused me" from "the
|
|
133
|
+
* request failed" WITHOUT string matching and without hardcoding a code
|
|
134
|
+
* literal that could drift. Treating a suppression as a failure is how two
|
|
135
|
+
* independent brakes compound: the app escalates its own cooldown on a pause
|
|
136
|
+
* the package already scheduled, and the device waits far longer than either
|
|
137
|
+
* layer intends while each looks correct in isolation.
|
|
138
|
+
*
|
|
139
|
+
* A suppressed error carries `retryAt` (epoch ms), so a caller that keeps its
|
|
140
|
+
* own schedule can ALIGN to this window rather than add a second one to it.
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} err
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export const isSuppressedError = (err) =>
|
|
146
|
+
!!err && typeof err === 'object' && err.code === 'SYNC_SUPPRESSED';
|
|
147
|
+
|
|
105
148
|
export const vaultCredentialKey = (storageKeyPrefix) => `${storageKeyPrefix}-vault-credential`;
|
|
106
149
|
export const credentialHaltKey = (storageKeyPrefix) => `${storageKeyPrefix}-db-sync-credential-halt`;
|
|
107
150
|
|
|
@@ -123,7 +166,15 @@ export const credentialHaltKey = (storageKeyPrefix) => `${storageKeyPrefix}-db-s
|
|
|
123
166
|
*
|
|
124
167
|
* Data callbacks (the engine is data-shape agnostic):
|
|
125
168
|
* @param {Function} config.getLocalEntity - (entityId) => entity | null | Promise
|
|
126
|
-
* @param {Function} config.applyRemoteEntity - (entityId, entity) => void | Promise
|
|
169
|
+
* @param {Function} config.applyRemoteEntity - (entityId, entity) => void | Promise.
|
|
170
|
+
* DURABILITY MATTERS HERE, and what you promise decides pullCursorCommit
|
|
171
|
+
* below. If this function has made the row durable by the time it resolves
|
|
172
|
+
* (a direct store write), the pull cursor may safely advance page by page.
|
|
173
|
+
* If it writes somewhere your caller may still DISCARD — a per-cycle mirror
|
|
174
|
+
* committed only on success — then a cursor advanced mid-pagination sits
|
|
175
|
+
* ahead of your committed state and those rows are lost: consumed, never
|
|
176
|
+
* committed, never re-listed by an incremental pull. Declare the mode that
|
|
177
|
+
* matches; the default assumes nothing.
|
|
127
178
|
* @param {Function} config.applyRemoteDelete - (entityId) => void | Promise
|
|
128
179
|
* @param {Function} [config.isInsertOnly] - (entity, entityId) => boolean
|
|
129
180
|
* @param {Function} [config.getEntityLastModified] - (entity) => string|number (defaults to entity.lastModified)
|
|
@@ -133,6 +184,22 @@ export const credentialHaltKey = (storageKeyPrefix) => `${storageKeyPrefix}-db-s
|
|
|
133
184
|
* @param {Function} [config.onError] - (message, code, isHardStop) called with code 'KEY_MISMATCH' on a wrong key, 'VERIFIER_UNSUPPORTED' when the server can't host the verifier, or 'CREDENTIAL_INVALID' (isHardStop true) when the server rejects this device's credential — the cycle then halts until Phase 2.2 recovery exists
|
|
134
185
|
* @param {Function} [config.onRowsSkipped] - (count, entityIds) called when a cycle skipped undecryptable rows
|
|
135
186
|
* @param {boolean} [config.allowUnverified=false] - operator escape hatch: downgrade VERIFIER_UNSUPPORTED to a logged warning and proceed (unsafe; off by default)
|
|
187
|
+
* @param {'end-of-pull'|'per-page'|'caller'} [config.pullCursorCommit='end-of-pull']
|
|
188
|
+
* WHEN the pull cursor is persisted. Pick the one that matches what
|
|
189
|
+
* applyRemoteEntity promises above; an unrecognised value is REFUSED at
|
|
190
|
+
* construction rather than defaulted.
|
|
191
|
+
* - 'end-of-pull' (default): once, after a fully successful pagination loop.
|
|
192
|
+
* Safe for every caller, including one that has not thought about it. A
|
|
193
|
+
* mid-pagination failure re-pulls from where the run started, which is
|
|
194
|
+
* idempotent under entity-grain LWW.
|
|
195
|
+
* - 'per-page': after each completed page. Requires a durable apply. Lets a
|
|
196
|
+
* large backlog on a flaky connection resume from the last good page
|
|
197
|
+
* instead of restarting — the 1.10.0 convergence fix, now opt-in.
|
|
198
|
+
* - 'caller': the engine never persists the cursor. pullRemoteChanges
|
|
199
|
+
* returns maxSeq and the caller commits it with setHighWaterMark once its
|
|
200
|
+
* own state is durable. The only mode under which the cursor can never
|
|
201
|
+
* sit ahead of committed state, because the caller decides when.
|
|
202
|
+
* dbSyncCycle commits for itself on a fully successful cycle.
|
|
136
203
|
*/
|
|
137
204
|
export const createDbSyncEngine = (config) => {
|
|
138
205
|
const {
|
|
@@ -156,6 +223,7 @@ export const createDbSyncEngine = (config) => {
|
|
|
156
223
|
onError,
|
|
157
224
|
onRowsSkipped,
|
|
158
225
|
allowUnverified = false,
|
|
226
|
+
pullCursorCommit = DEFAULT_PULL_CURSOR_MODE,
|
|
159
227
|
} = config;
|
|
160
228
|
|
|
161
229
|
if (!storageKeyPrefix) throw new Error('createDbSyncEngine: storageKeyPrefix is required');
|
|
@@ -168,6 +236,17 @@ export const createDbSyncEngine = (config) => {
|
|
|
168
236
|
// verifier's single-row GET).
|
|
169
237
|
throw new Error('createDbSyncEngine: accountId is required');
|
|
170
238
|
}
|
|
239
|
+
// REFUSED, NOT DEFAULTED. A typo silently falling back to a default is the
|
|
240
|
+
// exact failure this contract exists to remove: 'perPage' or 'per_page'
|
|
241
|
+
// would quietly restore the unsafe behaviour on a caller that was trying to
|
|
242
|
+
// opt OUT of it, and nothing would say so until rows went missing. Matches
|
|
243
|
+
// the vault's strict auth-mode parsing.
|
|
244
|
+
if (!PULL_CURSOR_MODES.includes(pullCursorCommit)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`createDbSyncEngine: pullCursorCommit must be one of ${PULL_CURSOR_MODES.map((m) => `'${m}'`).join(', ')}` +
|
|
247
|
+
` (got ${JSON.stringify(pullCursorCommit)})`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
171
250
|
if (typeof getLocalEntity !== 'function') throw new Error('createDbSyncEngine: getLocalEntity is required');
|
|
172
251
|
if (typeof applyRemoteEntity !== 'function') throw new Error('createDbSyncEngine: applyRemoteEntity is required');
|
|
173
252
|
if (typeof applyRemoteDelete !== 'function') throw new Error('createDbSyncEngine: applyRemoteDelete is required');
|
|
@@ -354,6 +433,13 @@ export const createDbSyncEngine = (config) => {
|
|
|
354
433
|
const code = err && err.code;
|
|
355
434
|
if (code === 'PASSPHRASE_REQUIRED' || code === 'ACCOUNT_ID_REQUIRED') return null;
|
|
356
435
|
if (code === 'CREDENTIAL_INVALID') return null;
|
|
436
|
+
// SYNC_SUPPRESSED is this engine's OWN gate refusing a call (Phase 4a). It
|
|
437
|
+
// is not evidence about the server — no request was made — so letting it
|
|
438
|
+
// record a failure would let an open window feed itself: every suppressed
|
|
439
|
+
// call would extend the window that suppressed it, and a caller polling on
|
|
440
|
+
// a short interval would never see it close. It must never open, extend or
|
|
441
|
+
// escalate anything.
|
|
442
|
+
if (code === 'SYNC_SUPPRESSED') return null;
|
|
357
443
|
// QUOTA_EXCEEDED keeps its own reason so its descriptor keeps surfacing —
|
|
358
444
|
// but it is ONE window, not a second one stacked on a transport window.
|
|
359
445
|
if (code === 'QUOTA_EXCEEDED') return 'quota';
|
|
@@ -397,6 +483,69 @@ export const createDbSyncEngine = (config) => {
|
|
|
397
483
|
return reason;
|
|
398
484
|
};
|
|
399
485
|
|
|
486
|
+
// ── The gate (Phase 4a) ───────────────────────────────────────────────────
|
|
487
|
+
//
|
|
488
|
+
// A primitive called while its window is open fails FAST, before any network
|
|
489
|
+
// call, with a typed SYNC_SUPPRESSED error. The shape deliberately mirrors
|
|
490
|
+
// the vault client's RATE_LIMITED (1.11.0): a typed `code`, a boolean the
|
|
491
|
+
// caller can test without string matching, and a `retryAt` so a caller with
|
|
492
|
+
// its own scheduler can ALIGN to this window rather than stack a second one
|
|
493
|
+
// on top of it.
|
|
494
|
+
//
|
|
495
|
+
// WHY THROW RATHER THAN RETURN A SKIPPED SHAPE. A return would let a
|
|
496
|
+
// composed caller that ignores the value treat a suppressed cycle as a
|
|
497
|
+
// successful one — committing a mirror built from a pull that never ran and
|
|
498
|
+
// resetting its own breaker on a cycle that did nothing. Throwing converts
|
|
499
|
+
// the worst failure mode from "silently wrong" to "loudly refused". The
|
|
500
|
+
// argument the other way is real and was weighed: a return needs no catch,
|
|
501
|
+
// and a caller that forgets to special-case the code turns a deliberate
|
|
502
|
+
// pause into what looks like an error. That is the trade — see the PR.
|
|
503
|
+
const suppressedError = (w, direction) => {
|
|
504
|
+
const retryInMs = Math.max(0, w.until - Date.now());
|
|
505
|
+
const err = new Error(
|
|
506
|
+
`${direction} suppressed: backing off after ${w.code || 'a failure'}` +
|
|
507
|
+
` — retrying in ~${Math.ceil(retryInMs / 1000)}s`
|
|
508
|
+
);
|
|
509
|
+
err.code = 'SYNC_SUPPRESSED';
|
|
510
|
+
// Redundant on purpose. `code` is the discriminator; `suppressed` is a
|
|
511
|
+
// second, cheaper one for a caller whose error handling branches on a
|
|
512
|
+
// boolean, so nobody has to reach for a string comparison.
|
|
513
|
+
err.suppressed = true;
|
|
514
|
+
err.direction = direction; // 'push' | 'pull'
|
|
515
|
+
err.reason = w.reason; // 'quota' | 'auth' | 'transport'
|
|
516
|
+
err.cause = null;
|
|
517
|
+
err.causeCode = w.code; // the typed error that opened the window
|
|
518
|
+
err.retryAt = w.until; // epoch ms
|
|
519
|
+
err.retryInMs = retryInMs;
|
|
520
|
+
return err;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Throws when the direction's window is open. The single gate: every
|
|
524
|
+
// network-touching primitive calls it, and nothing else does.
|
|
525
|
+
const guardWindow = (w, direction) => {
|
|
526
|
+
if (isOpen(w)) throw suppressedError(w, direction);
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
// Throws when the device is under the credential halt. Enforcement of a
|
|
530
|
+
// halt has two halves, and a composed caller needs both: refuse to run
|
|
531
|
+
// while halted (here), and SET the halt when a rejection is seen (in each
|
|
532
|
+
// primitive's catch, via noteCredentialRejection). Only the pair works —
|
|
533
|
+
// a guard over a halt nothing sets never fires.
|
|
534
|
+
//
|
|
535
|
+
// This is a READ of durable state (localStorage), so unlike the windows it
|
|
536
|
+
// is safe to check in more than one place: the cycle keeps its own halt
|
|
537
|
+
// check for the reporting and result shape it owes the app, and this guard
|
|
538
|
+
// covers every other caller. Idempotent reads cannot compound; only
|
|
539
|
+
// mutating enforcement had to move to exactly one place.
|
|
540
|
+
const guardHalt = (context) => {
|
|
541
|
+
const halt = getCredentialHalt();
|
|
542
|
+
if (!halt) return;
|
|
543
|
+
const err = new Error(halt.message || `${context} refused: this device's credential was rejected`);
|
|
544
|
+
err.code = 'CREDENTIAL_INVALID';
|
|
545
|
+
err.halted = true;
|
|
546
|
+
throw err;
|
|
547
|
+
};
|
|
548
|
+
|
|
400
549
|
// Over-quota state, preserved exactly as 3.3 exposed it: non-null from the
|
|
401
550
|
// rejection until a write actually succeeds (not merely until the window
|
|
402
551
|
// expires), so an app can keep rendering "X of Y used" while the client
|
|
@@ -446,19 +595,37 @@ export const createDbSyncEngine = (config) => {
|
|
|
446
595
|
// when this instance went inert, { halted } when the device halted — so the
|
|
447
596
|
// cycle result matches what isSuperseded()/isCredentialHalted() report,
|
|
448
597
|
// whichever path the rejection arrived by.
|
|
449
|
-
|
|
598
|
+
// STATE ONLY (Phase 4a). Split out of applyCredentialRejection so a
|
|
599
|
+
// primitive can set the halt without also firing the cycle's reporting.
|
|
600
|
+
// The split is what lets a composed caller — one that never calls
|
|
601
|
+
// dbSyncCycle — still halt on a rejected credential. The identity rule and
|
|
602
|
+
// the superseded-inertness decision are untouched; this is only about which
|
|
603
|
+
// half of the work runs.
|
|
604
|
+
//
|
|
605
|
+
// IDEMPOTENT, and it has to be: a primitive notes the rejection on its way
|
|
606
|
+
// out, and the cycle then reports it through applyCredentialRejection,
|
|
607
|
+
// which notes it again. setCredentialHalt overwrites with the same message
|
|
608
|
+
// and isBearerSuperseded is a pure read, so the second note changes
|
|
609
|
+
// nothing.
|
|
610
|
+
const noteCredentialRejection = (err) => {
|
|
450
611
|
const message = err?.message || String(err);
|
|
451
|
-
let outcome;
|
|
452
612
|
if (isBearerSuperseded()) {
|
|
453
613
|
// Replaced by recovery and the app kept a stale reference: go inert in
|
|
454
614
|
// memory — surfaced once, then silent — leaving the shared halt key
|
|
455
615
|
// alone so the recovered engine keeps syncing.
|
|
456
616
|
supersededInert = true;
|
|
457
|
-
|
|
458
|
-
} else {
|
|
459
|
-
setCredentialHalt(message);
|
|
460
|
-
outcome = { halted: true };
|
|
617
|
+
return { superseded: true };
|
|
461
618
|
}
|
|
619
|
+
setCredentialHalt(message);
|
|
620
|
+
return { halted: true };
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// STATE + REPORTING. The cycle's path, unchanged in behaviour: it owes the
|
|
624
|
+
// app an onError/onStatusChange and a truthful result flag, and a primitive
|
|
625
|
+
// called directly owes none of that.
|
|
626
|
+
const applyCredentialRejection = (err) => {
|
|
627
|
+
const message = err?.message || String(err);
|
|
628
|
+
const outcome = noteCredentialRejection(err);
|
|
462
629
|
onError?.(message, 'CREDENTIAL_INVALID', true);
|
|
463
630
|
onStatusChange?.('error');
|
|
464
631
|
return outcome;
|
|
@@ -635,7 +802,7 @@ export const createDbSyncEngine = (config) => {
|
|
|
635
802
|
// verifyAccountKey, which throws KEY_MISMATCH (before any upsert) if the
|
|
636
803
|
// derived key doesn't match the account — so a device can never upload rows
|
|
637
804
|
// under a non-matching key.
|
|
638
|
-
const
|
|
805
|
+
const pushDirtyRowsUnguarded = async () => {
|
|
639
806
|
const dirty = getDirtySet();
|
|
640
807
|
if (dirty.length === 0) return { written: 0, deleted: 0, maxSeq: getPushAck() };
|
|
641
808
|
|
|
@@ -690,6 +857,49 @@ export const createDbSyncEngine = (config) => {
|
|
|
690
857
|
return { written: upserts.length, deleted: deletes.length, maxSeq };
|
|
691
858
|
};
|
|
692
859
|
|
|
860
|
+
/**
|
|
861
|
+
* The push, with its protections attached (Phase 4a).
|
|
862
|
+
*
|
|
863
|
+
* Every caller gets the halt, the window and the window bookkeeping —
|
|
864
|
+
* dbSyncCycle and a hand-composed cycle alike. The cycle no longer checks
|
|
865
|
+
* or records anything for the push; it reads the result of what happened
|
|
866
|
+
* here. See the module note on delegation.
|
|
867
|
+
*/
|
|
868
|
+
const pushDirtyRows = async () => {
|
|
869
|
+
// The guards sit INSIDE the try on purpose. A suppressed error then takes
|
|
870
|
+
// the same path out as any other failure and passes through
|
|
871
|
+
// recordFailure — where the SYNC_SUPPRESSED do-not-delay rule is what
|
|
872
|
+
// stops an open window from feeding itself. Guarding outside the try
|
|
873
|
+
// would make that rule unreachable defensive code, and a later change
|
|
874
|
+
// that produced a suppression deeper in the call could silently start
|
|
875
|
+
// extending the window that caused it. (The negative control at
|
|
876
|
+
// scripts/verify-phase-4a.mjs found exactly this: with the guards
|
|
877
|
+
// outside, removing the rule broke nothing.)
|
|
878
|
+
try {
|
|
879
|
+
guardHalt('push');
|
|
880
|
+
guardWindow(pushBackoff, 'push');
|
|
881
|
+
const result = await pushDirtyRowsUnguarded();
|
|
882
|
+
// Any success resets the window and its strike count, exactly as the
|
|
883
|
+
// file tier does. For a quota window this is also what clears the
|
|
884
|
+
// over-quota state: the operator acted, and no client action was
|
|
885
|
+
// needed or taken.
|
|
886
|
+
clearWindow(pushBackoff);
|
|
887
|
+
return result;
|
|
888
|
+
} catch (err) {
|
|
889
|
+
// A rejected credential is noted (not reported) here, so a caller that
|
|
890
|
+
// never runs the cycle still halts. recordFailure then classifies it as
|
|
891
|
+
// do-not-delay and opens nothing, exactly as the cycle's path did.
|
|
892
|
+
// `err.halted` marks an error raised by guardHalt from the STORED halt,
|
|
893
|
+
// as opposed to a fresh rejection from the server. Re-noting the former
|
|
894
|
+
// would keep rewriting the halt's timestamp on every refused call, so
|
|
895
|
+
// "when the credential was rejected" would drift into "when we last
|
|
896
|
+
// refused a call".
|
|
897
|
+
if (err && err.code === 'CREDENTIAL_INVALID' && !err.halted) noteCredentialRejection(err);
|
|
898
|
+
recordFailure(pushBackoff, err, MAX_PUSH_BACKOFF_S);
|
|
899
|
+
throw err;
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
693
903
|
// Decrypts and applies one row with entity-grain LWW. Mutates the supplied
|
|
694
904
|
// `dirty` Set in place (and persists it) when a remote write supersedes a
|
|
695
905
|
// dirty local copy, so we do not re-push a superseded version. Returns true if
|
|
@@ -788,7 +998,7 @@ export const createDbSyncEngine = (config) => {
|
|
|
788
998
|
// wrong key has aborted with KEY_MISMATCH before we get here; any decrypt
|
|
789
999
|
// failure now is an INDIVIDUAL bad row. Such a row is counted, quarantined,
|
|
790
1000
|
// and its cursor advanced past — it must never throw and wedge the cycle.
|
|
791
|
-
const
|
|
1001
|
+
const pullRemoteChangesUnguarded = async () => {
|
|
792
1002
|
await ensureRootKey();
|
|
793
1003
|
|
|
794
1004
|
let since = getHighWaterMark();
|
|
@@ -828,26 +1038,56 @@ export const createDbSyncEngine = (config) => {
|
|
|
828
1038
|
if (typeof R.seq === 'number') since = Math.max(since, R.seq);
|
|
829
1039
|
}
|
|
830
1040
|
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
// the whole page.
|
|
835
|
-
//
|
|
836
|
-
// re-
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
|
|
1041
|
+
// PER-PAGE COMMIT POINT ('per-page' only). Every row in a page reaches a
|
|
1042
|
+
// terminal outcome before we get here — applied, quarantined, or skipped
|
|
1043
|
+
// as engine-reserved — so a caller whose apply is durable may safely
|
|
1044
|
+
// advance past the whole page. That is what lets a large backlog on a
|
|
1045
|
+
// flaky connection resume from the last good page instead of
|
|
1046
|
+
// re-downloading from the start forever (the 1.10.0 convergence fix).
|
|
1047
|
+
//
|
|
1048
|
+
// It is NOT the default, because it is only correct for a caller that
|
|
1049
|
+
// has told us its apply is durable. See PULL_CURSOR_MODES.
|
|
1050
|
+
if (pullCursorCommit === 'per-page') {
|
|
1051
|
+
setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
|
|
1052
|
+
}
|
|
840
1053
|
|
|
841
1054
|
hasMore = !!more;
|
|
842
1055
|
}
|
|
843
1056
|
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
|
|
1057
|
+
// END-OF-PULL COMMIT POINT. Reached only when the whole pagination loop
|
|
1058
|
+
// completed — a page that throws exits above and leaves the cursor where
|
|
1059
|
+
// the run started, which is the 1.6.1 semantic and is safe for every
|
|
1060
|
+
// caller regardless of how it applies rows.
|
|
1061
|
+
//
|
|
1062
|
+
// In 'caller' mode the engine writes nothing at all: the returned maxSeq
|
|
1063
|
+
// is the caller's to commit (via setHighWaterMark) once its own state is
|
|
1064
|
+
// durable. That is the only mode under which the cursor can never sit
|
|
1065
|
+
// ahead of what the caller has committed, because the caller decides when.
|
|
1066
|
+
if (pullCursorCommit !== 'caller') {
|
|
1067
|
+
setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
|
|
1068
|
+
}
|
|
848
1069
|
return { maxSeq, appliedRemote, applied, skipped, skippedEntityIds };
|
|
849
1070
|
};
|
|
850
1071
|
|
|
1072
|
+
/**
|
|
1073
|
+
* The pull, with its protections attached (Phase 4a). Same contract as the
|
|
1074
|
+
* guarded push above.
|
|
1075
|
+
*/
|
|
1076
|
+
const pullRemoteChanges = async () => {
|
|
1077
|
+
// Guards inside the try — see pushDirtyRows for why.
|
|
1078
|
+
try {
|
|
1079
|
+
guardHalt('pull');
|
|
1080
|
+
guardWindow(pullBackoff, 'pull');
|
|
1081
|
+
const result = await pullRemoteChangesUnguarded();
|
|
1082
|
+
clearWindow(pullBackoff);
|
|
1083
|
+
return result;
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
if (err && err.code === 'CREDENTIAL_INVALID' && !err.halted) noteCredentialRejection(err);
|
|
1086
|
+
recordFailure(pullBackoff, err, MAX_PULL_BACKOFF_S);
|
|
1087
|
+
throw err;
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
|
|
851
1091
|
// ── Step 3: update device cursor ──────────────────────────────────────────
|
|
852
1092
|
// Best-effort: a failure here only affects tombstone GC timing, never sync
|
|
853
1093
|
// correctness, so it is swallowed. lastSeenSeq reports the pull cursor (what
|
|
@@ -855,6 +1095,36 @@ export const createDbSyncEngine = (config) => {
|
|
|
855
1095
|
// progress is the conservative value for server-side tombstone GC, since it
|
|
856
1096
|
// never lets the server reclaim a tombstone this device has not yet seen.
|
|
857
1097
|
const updateDeviceCursor = async () => {
|
|
1098
|
+
// THE THIRD PRIMITIVE IS DELIBERATELY DIFFERENT (Phase 4a): halt-gated,
|
|
1099
|
+
// NOT window-gated, never recording, and never throwing.
|
|
1100
|
+
//
|
|
1101
|
+
// It never throws because it never has. Callers await it mid-cycle inside
|
|
1102
|
+
// their own try blocks (dayGLANCE does, at dbEngine.js:839, before its
|
|
1103
|
+
// commit), so a best-effort call that suddenly threw would abort cycles
|
|
1104
|
+
// that today shrug this off — and in dayGLANCE's case trigger a cursor
|
|
1105
|
+
// rollback. Suppression is reported in the RETURN value instead.
|
|
1106
|
+
//
|
|
1107
|
+
// It never records because a failure here only affects tombstone GC
|
|
1108
|
+
// timing. It is not evidence that sync is failing, so it must not be able
|
|
1109
|
+
// to open or extend a window.
|
|
1110
|
+
//
|
|
1111
|
+
// IT IS NOT GATED ON THE PULL WINDOW, and this is the one place the Phase
|
|
1112
|
+
// 4a design review got it wrong before the tests corrected it. Once the
|
|
1113
|
+
// pull records its own failure, the pull window is ALREADY OPEN by the
|
|
1114
|
+
// time the cycle reaches this call — so a window gate here would suppress
|
|
1115
|
+
// the cursor report on the very cycle the pull was attempted, silently
|
|
1116
|
+
// undoing the 1.10.0 decoupling ("the cursor report runs whenever the
|
|
1117
|
+
// pull was ATTEMPTED, success or failure"). Whether to report on a given
|
|
1118
|
+
// cycle is a COMPOSITION decision about what just happened, which belongs
|
|
1119
|
+
// to the caller; the cycle makes it from its own pullSkipped, and a
|
|
1120
|
+
// composed caller makes its own. Delegation moved enforcement down, not
|
|
1121
|
+
// orchestration.
|
|
1122
|
+
//
|
|
1123
|
+
// What it does enforce is the terminal one: a halted device writes
|
|
1124
|
+
// nothing, from any caller. Rate-limit protection it gets for free from
|
|
1125
|
+
// the vault client's module-scope brake (1.11.0), which fails
|
|
1126
|
+
// vault.device() fast without any window logic here.
|
|
1127
|
+
if (getCredentialHalt()) return { updated: false, suppressed: true };
|
|
858
1128
|
try {
|
|
859
1129
|
return await vault.device(app, {
|
|
860
1130
|
accountId,
|
|
@@ -919,45 +1189,43 @@ export const createDbSyncEngine = (config) => {
|
|
|
919
1189
|
// report still happens. The dirty set is untouched by a failed push
|
|
920
1190
|
// (pushDirtyRows only clears it on full ack), so backoff changes WHEN
|
|
921
1191
|
// rows are retried, never WHETHER they are.
|
|
1192
|
+
// DELEGATED (Phase 4a). The cycle no longer gates and no longer
|
|
1193
|
+
// records: the primitives own both, for every caller. All the cycle
|
|
1194
|
+
// does here is separate "my own gate refused this" from "the server
|
|
1195
|
+
// refused this", because only the second is a failure to report.
|
|
1196
|
+
//
|
|
1197
|
+
// Doing both would DOUBLE the escalation: strikes would increment once
|
|
1198
|
+
// in the primitive and once here, turning the ladder into 30s, 120s,
|
|
1199
|
+
// 480s instead of 30, 60, 120.
|
|
922
1200
|
let pushError = null;
|
|
923
1201
|
let pushSkipped = false;
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
// Any success resets the window and its strike count, exactly as the
|
|
930
|
-
// file tier does. For a quota window this is also what clears the
|
|
931
|
-
// over-quota state: the operator acted, and no client action was
|
|
932
|
-
// needed or taken.
|
|
933
|
-
clearWindow(pushBackoff);
|
|
934
|
-
} catch (err) {
|
|
935
|
-
pushError = err;
|
|
936
|
-
recordFailure(pushBackoff, err, MAX_PUSH_BACKOFF_S);
|
|
937
|
-
}
|
|
1202
|
+
try {
|
|
1203
|
+
await pushDirtyRows();
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
if (err && err.code === 'SYNC_SUPPRESSED') pushSkipped = true;
|
|
1206
|
+
else pushError = err;
|
|
938
1207
|
}
|
|
939
1208
|
|
|
940
1209
|
onStatusChange?.('downloading');
|
|
941
1210
|
let pullError = null;
|
|
942
1211
|
let pullSkipped = false;
|
|
943
1212
|
let pull = { applied: 0, skipped: 0, skippedEntityIds: [] };
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
clearWindow(pullBackoff);
|
|
950
|
-
} catch (err) {
|
|
951
|
-
pullError = err;
|
|
952
|
-
recordFailure(pullBackoff, err, MAX_PULL_BACKOFF_S);
|
|
953
|
-
}
|
|
954
|
-
// The cursor report runs whenever the pull was ATTEMPTED, success or
|
|
955
|
-
// failure — that is the 3.3 decoupling applied to the pull. It is
|
|
956
|
-
// skipped only when the pull itself was skipped, because firing a
|
|
957
|
-
// write every cycle at a server we are backing off from is the exact
|
|
958
|
-
// hammering this fix removes.
|
|
959
|
-
await updateDeviceCursor();
|
|
1213
|
+
try {
|
|
1214
|
+
pull = await pullRemoteChanges();
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
if (err && err.code === 'SYNC_SUPPRESSED') pullSkipped = true;
|
|
1217
|
+
else pullError = err;
|
|
960
1218
|
}
|
|
1219
|
+
// The cursor report runs whenever the pull was ATTEMPTED, success or
|
|
1220
|
+
// failure — the 3.3 decoupling applied to the pull — and is skipped
|
|
1221
|
+
// only when the pull never ran, because firing a write every cycle at a
|
|
1222
|
+
// server we are backing off from is the hammering this removes.
|
|
1223
|
+
//
|
|
1224
|
+
// This is COMPOSITION, not enforcement: the cycle is deciding whether
|
|
1225
|
+
// this cycle has anything to report, which is what a cycle is for. It
|
|
1226
|
+
// reads pullSkipped, which the primitive told it. No window is checked
|
|
1227
|
+
// here and none is written.
|
|
1228
|
+
if (!pullSkipped) await updateDeviceCursor();
|
|
961
1229
|
|
|
962
1230
|
// A rejected credential can surface from the push or the pull once the
|
|
963
1231
|
// key is verified in-session (ensureRootKey then makes no network call,
|
|
@@ -1000,6 +1268,20 @@ export const createDbSyncEngine = (config) => {
|
|
|
1000
1268
|
};
|
|
1001
1269
|
}
|
|
1002
1270
|
|
|
1271
|
+
// THE CYCLE IS A CALLER TOO ('caller' mode). It commits the cursor here,
|
|
1272
|
+
// at the one point it knows everything landed — the same point it claims
|
|
1273
|
+
// lastSynced. Without this, an app that declared 'caller' and then used
|
|
1274
|
+
// dbSyncCycle would never advance the cursor and would re-pull its whole
|
|
1275
|
+
// backlog forever; the mode would be a footgun aimed at the caller that
|
|
1276
|
+
// read the docs most carefully.
|
|
1277
|
+
//
|
|
1278
|
+
// Committing only on a fully successful cycle is deliberate, and mirrors
|
|
1279
|
+
// what a commit-only-on-success composer does: if the push failed, that
|
|
1280
|
+
// caller discards its applied state, so the cursor must not move either.
|
|
1281
|
+
if (pullCursorCommit === 'caller' && typeof pull.maxSeq === 'number') {
|
|
1282
|
+
setHighWaterMark(Math.max(getHighWaterMark(), pull.maxSeq));
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1003
1285
|
const now = new Date().toISOString();
|
|
1004
1286
|
localStorage.setItem(KEY_LAST_SYNCED, now);
|
|
1005
1287
|
onStatusChange?.('success');
|
package/src/index.js
CHANGED
|
@@ -40,7 +40,14 @@ export {
|
|
|
40
40
|
} from './engine.js';
|
|
41
41
|
|
|
42
42
|
// Phase 3: database transport (selected via transportMode: 'database').
|
|
43
|
-
export {
|
|
43
|
+
export {
|
|
44
|
+
createDbSyncEngine,
|
|
45
|
+
getOrCreateDeviceId,
|
|
46
|
+
// Phase 4a: tells a caller's own retry ladder that the engine paused this
|
|
47
|
+
// call, rather than that the request failed. Branching on it is what keeps
|
|
48
|
+
// an app's breaker from stacking a second cooldown on the engine's window.
|
|
49
|
+
isSuppressedError,
|
|
50
|
+
} from './dbEngine.js';
|
|
44
51
|
export {
|
|
45
52
|
createVaultClient,
|
|
46
53
|
// Per-account auth substrate (vault Phase 1.4b): unauthenticated auth-mode
|
package/types/index.d.ts
CHANGED
|
@@ -46,7 +46,14 @@ export type SyncErrorCode =
|
|
|
46
46
|
// `status: 429` and `retryInMs`, so any ladder that already treats a real
|
|
47
47
|
// 429 as transient handles it unchanged. Never a hard stop: the window is
|
|
48
48
|
// self-expiring and a success both releases it and halves the escalation.
|
|
49
|
-
| 'RATE_LIMITED'
|
|
49
|
+
| 'RATE_LIMITED'
|
|
50
|
+
// DB transport (1.12.0): this engine's OWN gate refused the call, because
|
|
51
|
+
// the direction's backoff window is open. No request was made. Carries
|
|
52
|
+
// `suppressed: true`, `direction`, `retryAt` and `retryInMs`. Never a hard
|
|
53
|
+
// stop and never a failure — a caller with its own retry ladder should
|
|
54
|
+
// treat it as a scheduled pause and align to `retryAt` rather than
|
|
55
|
+
// escalating a second cooldown on top of it (see isSuppressedError).
|
|
56
|
+
| 'SYNC_SUPPRESSED';
|
|
50
57
|
|
|
51
58
|
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
52
59
|
|
|
@@ -628,6 +635,18 @@ export interface DbSyncEngineConfig {
|
|
|
628
635
|
// and proceed without verification (unsafe — a wrong-key device could push
|
|
629
636
|
// poison rows). Off by default so the safe behavior is the default.
|
|
630
637
|
allowUnverified?: boolean;
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* WHEN the pull cursor is persisted. Must match what `applyRemoteEntity`
|
|
641
|
+
* promises: if it has NOT made the row durable by the time it resolves —
|
|
642
|
+
* it writes into a per-cycle mirror the caller may discard — then anything
|
|
643
|
+
* but `'end-of-pull'` or `'caller'` loses rows on a mid-pagination failure.
|
|
644
|
+
*
|
|
645
|
+
* An unrecognised value is refused at construction, not defaulted.
|
|
646
|
+
*
|
|
647
|
+
* @default 'end-of-pull'
|
|
648
|
+
*/
|
|
649
|
+
pullCursorCommit?: PullCursorCommit;
|
|
631
650
|
}
|
|
632
651
|
|
|
633
652
|
/** Result of a DB sync cycle / pull: how many rows applied vs. quarantined. */
|
|
@@ -715,9 +734,22 @@ export interface DbSyncEngine {
|
|
|
715
734
|
transportMode: 'database';
|
|
716
735
|
sync(): Promise<DbSyncResult>;
|
|
717
736
|
dbSyncCycle(): Promise<DbSyncResult>;
|
|
737
|
+
/**
|
|
738
|
+
* Pushes the dirty set. Enforces the push backoff window and the credential
|
|
739
|
+
* halt for EVERY caller (1.12.0), not only dbSyncCycle: rejects with
|
|
740
|
+
* `SYNC_SUPPRESSED` while the window is open and with `CREDENTIAL_INVALID`
|
|
741
|
+
* while the device is halted, in both cases without touching the network.
|
|
742
|
+
*/
|
|
718
743
|
pushDirtyRows(): Promise<{ written: number; deleted: number; maxSeq: number }>;
|
|
744
|
+
/** As pushDirtyRows, against the pull window. */
|
|
719
745
|
pullRemoteChanges(): Promise<DbSyncResult & { maxSeq: number; appliedRemote: boolean }>;
|
|
720
|
-
|
|
746
|
+
/**
|
|
747
|
+
* Best-effort cursor report. Never throws and never opens a window. Halted
|
|
748
|
+
* devices report nothing and get `{ updated: false, suppressed: true }`;
|
|
749
|
+
* deliberately NOT gated on the pull window, because whether a given cycle
|
|
750
|
+
* has anything to report is the caller's composition decision.
|
|
751
|
+
*/
|
|
752
|
+
updateDeviceCursor(): Promise<{ updated: boolean; suppressed?: boolean }>;
|
|
721
753
|
ensureRootKey(): Promise<void>;
|
|
722
754
|
|
|
723
755
|
markDirty(entityId: string): void;
|
|
@@ -773,8 +805,48 @@ export interface DbSyncEngine {
|
|
|
773
805
|
vault: VaultClient;
|
|
774
806
|
}
|
|
775
807
|
|
|
808
|
+
/**
|
|
809
|
+
* When the pull cursor is persisted. Chosen to match what the caller's
|
|
810
|
+
* `applyRemoteEntity` promises: an unrecognised value is refused at
|
|
811
|
+
* construction rather than defaulted.
|
|
812
|
+
*
|
|
813
|
+
* - `'end-of-pull'` (default) — once, after a fully successful pagination
|
|
814
|
+
* loop. Safe for every caller, including one that has not thought about it.
|
|
815
|
+
* - `'per-page'` — after each completed page. Requires an apply that is
|
|
816
|
+
* durable on return. Lets a large backlog resume from the last good page.
|
|
817
|
+
* - `'caller'` — the engine never persists; `pullRemoteChanges` returns
|
|
818
|
+
* `maxSeq` and the caller commits it with `setHighWaterMark`.
|
|
819
|
+
*/
|
|
820
|
+
export type PullCursorCommit = 'end-of-pull' | 'per-page' | 'caller';
|
|
821
|
+
|
|
776
822
|
export function createDbSyncEngine(config: DbSyncEngineConfig): DbSyncEngine;
|
|
777
823
|
|
|
824
|
+
/**
|
|
825
|
+
* True when an error is the engine's own gate refusing a call (`SYNC_SUPPRESSED`)
|
|
826
|
+
* rather than anything the server said.
|
|
827
|
+
*
|
|
828
|
+
* For callers that keep their own retry ladder or circuit breaker: treating a
|
|
829
|
+
* suppression as a failure is how two independent brakes compound, each
|
|
830
|
+
* looking correct in isolation while the device waits far longer than either
|
|
831
|
+
* intends. Branch on this (or on `err.suppressed`) and align to `err.retryAt`.
|
|
832
|
+
*/
|
|
833
|
+
export function isSuppressedError(err: unknown): boolean;
|
|
834
|
+
|
|
835
|
+
/** The shape of a `SYNC_SUPPRESSED` rejection. */
|
|
836
|
+
export interface SyncSuppressedError extends Error {
|
|
837
|
+
code: 'SYNC_SUPPRESSED';
|
|
838
|
+
suppressed: true;
|
|
839
|
+
/** Which half was refused. */
|
|
840
|
+
direction: 'push' | 'pull';
|
|
841
|
+
/** Why the window is open. */
|
|
842
|
+
reason: 'quota' | 'auth' | 'transport' | null;
|
|
843
|
+
/** The typed error that opened the window. */
|
|
844
|
+
causeCode: string | null;
|
|
845
|
+
/** Epoch ms the window closes. */
|
|
846
|
+
retryAt: number;
|
|
847
|
+
retryInMs: number;
|
|
848
|
+
}
|
|
849
|
+
|
|
778
850
|
/**
|
|
779
851
|
* Package-owned stable device identifier: generated once (crypto.randomUUID)
|
|
780
852
|
* and persisted under `{prefix}-device-id`. Used for both the device cursor
|