@glance-apps/sync 1.11.0 → 2.1.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 +211 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/dbEngine.js +334 -52
- package/src/index.js +33 -1
- package/src/snapshotFileSync.js +480 -0
- package/src/snapshotMergeExplain.js +254 -0
- package/types/index.d.ts +207 -2
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
|
|
@@ -63,6 +70,31 @@ export {
|
|
|
63
70
|
// from a rejected credential: re-enrollment that rotates the dead credential
|
|
64
71
|
// away server-side.
|
|
65
72
|
export { connectVaultSyncEngine, recoverVaultSyncEngine } from './vaultConnect.js';
|
|
73
|
+
// 2.1.0: the snapshot-file cycle (iCloud, Direct Access) and the slice
|
|
74
|
+
// comparison it decides writes and applies with. Pure over an injected
|
|
75
|
+
// transport; see src/snapshotFileSync.js.
|
|
76
|
+
export {
|
|
77
|
+
runSnapshotFileCycle,
|
|
78
|
+
classifySnapshotText,
|
|
79
|
+
evaluateMissingSnapshot,
|
|
80
|
+
shouldPromptFirstRun,
|
|
81
|
+
payloadHasData,
|
|
82
|
+
relayDecision,
|
|
83
|
+
relayWaitMs,
|
|
84
|
+
noteWriter,
|
|
85
|
+
readWriters,
|
|
86
|
+
RELAY_CONFIRM_MS,
|
|
87
|
+
RELAY_STAGGER_MS,
|
|
88
|
+
MISSING_GRACE_MS,
|
|
89
|
+
} from './snapshotFileSync.js';
|
|
90
|
+
export {
|
|
91
|
+
canonicalJson,
|
|
92
|
+
describeSliceDiff,
|
|
93
|
+
sliceDiffs,
|
|
94
|
+
writeWorthy,
|
|
95
|
+
explainSnapshotMerge,
|
|
96
|
+
PER_MERGE_STAMPS,
|
|
97
|
+
} from './snapshotMergeExplain.js';
|
|
66
98
|
export {
|
|
67
99
|
setupDbRootKey,
|
|
68
100
|
initDbRootKey,
|