@glance-apps/sync 1.10.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,13 +40,28 @@ 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
47
54
  // discovery and the bootstrap-secret -> per-device-credential exchange.
48
55
  fetchVaultHealth,
49
56
  enrollVaultDevice,
57
+ // Module-scope request diagnostics (1.11.0). The brake gates every client
58
+ // call after a real 429; the budget meter and the write-loop detector are
59
+ // visibility only. All three are per bundle realm, not per client.
60
+ isVaultRateLimited,
61
+ vaultBrakeStatus,
62
+ getVaultStats,
63
+ configureVaultDiagnostics,
64
+ resetVaultDiagnostics,
50
65
  } from './vaultClient.js';
51
66
  // The packaged connect flow: discover -> branch -> enroll if needed ->
52
67
  // persist credential -> build engine. Apps hand it a secret or token and get