@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.11.0",
3
+ "version": "2.0.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- const applyCredentialRejection = (err) => {
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
- outcome = { superseded: true };
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 pushDirtyRows = async () => {
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 pullRemoteChanges = async () => {
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
- // Persist the cursor PER PAGE, not once at the end. Every row in a page
832
- // reaches a terminal outcome before we get here — applied, quarantined,
833
- // or skipped as engine-reserved — so the cursor may safely advance past
834
- // the whole page. Previously a failure on page 3 discarded the progress
835
- // of pages 1 and 2, so a large backlog on a flaky connection could
836
- // re-download the same pages forever and never converge; that is exactly
837
- // the situation backoff is meant to help, so the two changes belong
838
- // together.
839
- setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
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
- // Pull is the sole writer of the pull cursor. Advance it (monotonically)
845
- // from the highest seq we actually listed this run. Retained for the
846
- // zero-page case, and harmless after the per-page advance above.
847
- setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
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
- if (isOpen(pushBackoff)) {
925
- pushSkipped = true;
926
- } else {
927
- try {
928
- await pushDirtyRows();
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
- if (isOpen(pullBackoff)) {
945
- pullSkipped = true;
946
- } else {
947
- try {
948
- pull = await pullRemoteChanges();
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 { createDbSyncEngine, getOrCreateDeviceId } from './dbEngine.js';
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
- updateDeviceCursor(): Promise<{ updated: boolean }>;
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