@glance-apps/sync 1.6.1 → 1.10.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 +343 -0
- package/package.json +1 -1
- package/src/dbEngine.js +450 -11
- package/src/index.js +15 -2
- package/src/vaultClient.js +208 -9
- package/src/vaultConnect.js +391 -0
- package/types/index.d.ts +211 -1
package/src/dbEngine.js
CHANGED
|
@@ -36,6 +36,75 @@ const ts = (v) => {
|
|
|
36
36
|
return Number.isNaN(t) ? 0 : t;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
// ── The backoff ladder ──────────────────────────────────────────────────────
|
|
40
|
+
//
|
|
41
|
+
// ONE windowing mechanism for every recoverable failure, carrying a `reason`.
|
|
42
|
+
// It replaces the separate quota-suppression window added in 3.3, which was
|
|
43
|
+
// already built to this shape — this consolidates two parallel things rather
|
|
44
|
+
// than forcing two different ones together.
|
|
45
|
+
//
|
|
46
|
+
// The numbers, the escalation curve, the reset rule and the in-memory
|
|
47
|
+
// lifetime are the FILE TIER'S (src/engine.js): 30s doubling to a 15-minute
|
|
48
|
+
// cap for writes and a 5-minute cap for reads, a flat hour on an auth
|
|
49
|
+
// failure, and any success resets both the counter and the window. That file
|
|
50
|
+
// was read and deliberately not refactored.
|
|
51
|
+
//
|
|
52
|
+
// The ONE thing that differs, and the reason this fix exists: the file tier's
|
|
53
|
+
// backoff is ADVISORY — nothing reads uploadBackoffUntil except its getter,
|
|
54
|
+
// so honouring it is the calling app's job. That is why "the DB engine
|
|
55
|
+
// hammers" is live in every published version. Here the engine ENFORCES its
|
|
56
|
+
// own window (it skips the work when called too soon, exactly as 3.3's quota
|
|
57
|
+
// suppression did) AND exposes the same getters, so a scheduler can still be
|
|
58
|
+
// smart without being obliged to be.
|
|
59
|
+
//
|
|
60
|
+
// A window never becomes terminal: it only ever delays. Whatever the
|
|
61
|
+
// interval, the next ordinary cycle after it expires probes again, with no
|
|
62
|
+
// user action and no restart.
|
|
63
|
+
const BACKOFF_BASE_S = 30;
|
|
64
|
+
const MAX_PUSH_BACKOFF_S = 15 * 60; // writes, matching the file tier's upload cap
|
|
65
|
+
const MAX_PULL_BACKOFF_S = 5 * 60; // reads, matching the file tier's download cap
|
|
66
|
+
const AUTH_BACKOFF_MS = 60 * 60 * 1000; // flat hour, matching AUTH_FAILURE_BACKOFF_MS
|
|
67
|
+
|
|
68
|
+
// randomUUID is missing in some non-secure-context WebViews; getRandomValues
|
|
69
|
+
// is everywhere the rest of this package's crypto already runs.
|
|
70
|
+
const randomUUID = () => {
|
|
71
|
+
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
|
|
72
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
73
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
74
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
75
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
76
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The package-owned stable device identifier (vault Phase 1.4b). Generated
|
|
81
|
+
* once per storageKeyPrefix and persisted under `{prefix}-device-id`; used
|
|
82
|
+
* for both the device cursor and per-account enrollment, so the identity a
|
|
83
|
+
* credential is bound to is the same one the server's tombstone-GC cursor
|
|
84
|
+
* tracks. An explicit config.deviceId still wins for callers that already
|
|
85
|
+
* manage their own.
|
|
86
|
+
*
|
|
87
|
+
* Existing installs that never passed a deviceId get one generated on first
|
|
88
|
+
* launch of this version — which also means their device cursor starts
|
|
89
|
+
* updating for the first time (updateDeviceCursor previously no-oped
|
|
90
|
+
* silently without a deviceId).
|
|
91
|
+
*/
|
|
92
|
+
export const getOrCreateDeviceId = (storageKeyPrefix) => {
|
|
93
|
+
if (!storageKeyPrefix) throw new Error('getOrCreateDeviceId: storageKeyPrefix is required');
|
|
94
|
+
const key = `${storageKeyPrefix}-device-id`;
|
|
95
|
+
let id = localStorage.getItem(key);
|
|
96
|
+
if (!id) {
|
|
97
|
+
id = randomUUID();
|
|
98
|
+
localStorage.setItem(key, id);
|
|
99
|
+
}
|
|
100
|
+
return id;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Storage keys shared between this engine and the connect/recovery flow in
|
|
104
|
+
// vaultConnect.js. Defined once here so the two modules cannot drift.
|
|
105
|
+
export const vaultCredentialKey = (storageKeyPrefix) => `${storageKeyPrefix}-vault-credential`;
|
|
106
|
+
export const credentialHaltKey = (storageKeyPrefix) => `${storageKeyPrefix}-db-sync-credential-halt`;
|
|
107
|
+
|
|
39
108
|
/**
|
|
40
109
|
* Creates the database sync engine.
|
|
41
110
|
*
|
|
@@ -46,7 +115,7 @@ const ts = (v) => {
|
|
|
46
115
|
* @param {string} config.vaultUrl
|
|
47
116
|
* @param {string} config.vaultToken
|
|
48
117
|
* @param {string} config.accountId
|
|
49
|
-
* @param {string} [config.deviceId]
|
|
118
|
+
* @param {string} [config.deviceId] - stable device identity; defaults to the persisted package-owned id (getOrCreateDeviceId)
|
|
50
119
|
* @param {string} config.cryptoDBName - device storage name for the root key
|
|
51
120
|
* @param {Function|null} [config.nativeGetSyncKey]
|
|
52
121
|
* @param {Function|null} [config.nativeStoreSyncKey]
|
|
@@ -61,7 +130,7 @@ const ts = (v) => {
|
|
|
61
130
|
*
|
|
62
131
|
* Event callbacks (optional):
|
|
63
132
|
* @param {Function} [config.onStatusChange]
|
|
64
|
-
* @param {Function} [config.onError] - (message, code, isHardStop) called with code 'KEY_MISMATCH' on a wrong key,
|
|
133
|
+
* @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
|
|
65
134
|
* @param {Function} [config.onRowsSkipped] - (count, entityIds) called when a cycle skipped undecryptable rows
|
|
66
135
|
* @param {boolean} [config.allowUnverified=false] - operator escape hatch: downgrade VERIFIER_UNSUPPORTED to a logged warning and proceed (unsafe; off by default)
|
|
67
136
|
*/
|
|
@@ -104,6 +173,9 @@ export const createDbSyncEngine = (config) => {
|
|
|
104
173
|
if (typeof applyRemoteDelete !== 'function') throw new Error('createDbSyncEngine: applyRemoteDelete is required');
|
|
105
174
|
|
|
106
175
|
const app = vaultApp || appId;
|
|
176
|
+
// Package-owned device identity: an explicit config.deviceId wins, else the
|
|
177
|
+
// persisted per-prefix id (generated on first use).
|
|
178
|
+
const resolvedDeviceId = deviceId || getOrCreateDeviceId(storageKeyPrefix);
|
|
107
179
|
const lastModifiedOf = typeof getEntityLastModified === 'function'
|
|
108
180
|
? getEntityLastModified
|
|
109
181
|
: (entity) => entity && entity.lastModified;
|
|
@@ -135,9 +207,41 @@ export const createDbSyncEngine = (config) => {
|
|
|
135
207
|
// on pull so one bad row cannot wedge the cycle, and re-attempted each cycle
|
|
136
208
|
// so a row recovers if the correct key later becomes available.
|
|
137
209
|
const KEY_QUARANTINE = `${storageKeyPrefix}-db-sync-quarantine`;
|
|
210
|
+
// KEY_CRED_HALT persists the credential-rejected stop state (vault Phase
|
|
211
|
+
// 1.4b), as { message, at }. Once the server has said 401 "invalid
|
|
212
|
+
// credential", retrying is pure hammering — the only way out is
|
|
213
|
+
// user-initiated re-enrollment (recoverVaultSyncEngine, Phase 2.2), which
|
|
214
|
+
// is the ONLY code that clears this key, and only after a verified
|
|
215
|
+
// successful enrollment. Shared-mode auth failures (401 "invalid device
|
|
216
|
+
// token") do NOT set it — those are operator config problems that a config
|
|
217
|
+
// fix resolves, so they keep the ordinary retry behavior.
|
|
218
|
+
const KEY_CRED_HALT = credentialHaltKey(storageKeyPrefix);
|
|
219
|
+
const KEY_CREDENTIAL = vaultCredentialKey(storageKeyPrefix);
|
|
138
220
|
|
|
139
221
|
// ── In-memory guards ──────────────────────────────────────────────────────
|
|
140
222
|
let syncing = false;
|
|
223
|
+
// Set when this instance's bearer is proven superseded (see
|
|
224
|
+
// isBearerSuperseded): the instance goes inert IN MEMORY ONLY — it never
|
|
225
|
+
// touches the shared halt key, and it stops generating network traffic. A
|
|
226
|
+
// stale reference the app forgot after recovery dies quietly instead of
|
|
227
|
+
// hammering the server with a dead credential.
|
|
228
|
+
let supersededInert = false;
|
|
229
|
+
// The two backoff windows. IN MEMORY ONLY, like the file tier's: a restart
|
|
230
|
+
// clears them, which is the right default — a relaunch is a user signalling
|
|
231
|
+
// "try now", and the failure re-opens the window immediately if it persists.
|
|
232
|
+
// Nothing here is persisted, so nothing can outlive the condition it
|
|
233
|
+
// describes or need a clearing path.
|
|
234
|
+
const newWindow = () => ({
|
|
235
|
+
until: 0, // epoch ms; the work is skipped while this is in the future
|
|
236
|
+
strikes: 0, // consecutive failures, driving the escalation
|
|
237
|
+
reason: null, // 'quota' | 'auth' | 'transport'
|
|
238
|
+
code: null, // the typed error code that opened it
|
|
239
|
+
message: null,
|
|
240
|
+
quota: null, // the parsed descriptor, when reason === 'quota'
|
|
241
|
+
since: null, // ISO of the most recent failure
|
|
242
|
+
});
|
|
243
|
+
const pushBackoff = newWindow();
|
|
244
|
+
const pullBackoff = newWindow();
|
|
141
245
|
// Per-session cache: once the derived root key is proven against the account's
|
|
142
246
|
// existing data (Part A), we never re-verify for the life of this engine.
|
|
143
247
|
let verified = false;
|
|
@@ -219,6 +323,172 @@ export const createDbSyncEngine = (config) => {
|
|
|
219
323
|
writeQuarantine(getQuarantine().filter((q) => q.entityId !== entityId));
|
|
220
324
|
};
|
|
221
325
|
|
|
326
|
+
// Credential-rejected stop state. Read/written only here; deliberately no
|
|
327
|
+
// clear function on the engine (recovery is Phase 2.2).
|
|
328
|
+
const getCredentialHalt = () => {
|
|
329
|
+
const raw = localStorage.getItem(KEY_CRED_HALT);
|
|
330
|
+
if (!raw) return null;
|
|
331
|
+
try {
|
|
332
|
+
return JSON.parse(raw);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const setCredentialHalt = (message) => {
|
|
338
|
+
localStorage.setItem(KEY_CRED_HALT, JSON.stringify({ message, at: new Date().toISOString() }));
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// ── The backoff ladder: classify, open, clear ─────────────────────────────
|
|
342
|
+
|
|
343
|
+
// WHICH FAILURES OPEN A WINDOW. Returns null for the classes that must NOT
|
|
344
|
+
// be delayed:
|
|
345
|
+
// - Readiness (PASSPHRASE_REQUIRED / ACCOUNT_ID_REQUIRED) fires BEFORE any
|
|
346
|
+
// network call and clears the instant the app supplies the value. A
|
|
347
|
+
// 30-second delay after a user types their passphrase would read as
|
|
348
|
+
// broken. The file tier has no equivalent class, so copying it faithfully
|
|
349
|
+
// would have missed this.
|
|
350
|
+
// - CREDENTIAL_INVALID is owned by the credential halt, which is terminal.
|
|
351
|
+
// Stacking a window on top would be meaningless at best and could soften
|
|
352
|
+
// a terminal state at worst.
|
|
353
|
+
const backoffReasonFor = (err) => {
|
|
354
|
+
const code = err && err.code;
|
|
355
|
+
if (code === 'PASSPHRASE_REQUIRED' || code === 'ACCOUNT_ID_REQUIRED') return null;
|
|
356
|
+
if (code === 'CREDENTIAL_INVALID') return null;
|
|
357
|
+
// QUOTA_EXCEEDED keeps its own reason so its descriptor keeps surfacing —
|
|
358
|
+
// but it is ONE window, not a second one stacked on a transport window.
|
|
359
|
+
if (code === 'QUOTA_EXCEEDED') return 'quota';
|
|
360
|
+
// A shared-mode 401 ("invalid device token") is a wrong/rotated token: it
|
|
361
|
+
// will not fix itself in 30 seconds and hammering an auth endpoint is the
|
|
362
|
+
// worst kind of noise, so it gets the file tier's flat hour. This is NOT
|
|
363
|
+
// the credential halt — it stays retryable and self-resuming.
|
|
364
|
+
//
|
|
365
|
+
// `cause` is consulted because the key verifier re-wraps whatever it hits
|
|
366
|
+
// as VERIFIER_UNSUPPORTED (keeping the original on err.cause), which would
|
|
367
|
+
// otherwise hide a 401 behind a fresh error carrying no status.
|
|
368
|
+
if (err && (err.status === 401 || (err.cause && err.cause.status === 401))) return 'auth';
|
|
369
|
+
return 'transport';
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const openWindow = (w, err, reason, maxSeconds) => {
|
|
373
|
+
w.strikes += 1;
|
|
374
|
+
const ms = reason === 'auth'
|
|
375
|
+
? AUTH_BACKOFF_MS
|
|
376
|
+
: Math.min(BACKOFF_BASE_S * Math.pow(2, w.strikes - 1), maxSeconds) * 1000;
|
|
377
|
+
w.until = Date.now() + ms;
|
|
378
|
+
w.reason = reason;
|
|
379
|
+
w.code = (err && err.code) || 'NETWORK_ERROR';
|
|
380
|
+
w.message = err?.message || String(err);
|
|
381
|
+
w.quota = reason === 'quota' && err && err.quota ? err.quota : null;
|
|
382
|
+
w.since = new Date().toISOString();
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const clearWindow = (w) => {
|
|
386
|
+
w.until = 0; w.strikes = 0; w.reason = null;
|
|
387
|
+
w.code = null; w.message = null; w.quota = null; w.since = null;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const isOpen = (w) => w.until > Date.now();
|
|
391
|
+
|
|
392
|
+
// Record a failure against a window, honouring the do-not-delay classes.
|
|
393
|
+
// Returns the reason, or null when the failure opened no window.
|
|
394
|
+
const recordFailure = (w, err, maxSeconds) => {
|
|
395
|
+
const reason = backoffReasonFor(err);
|
|
396
|
+
if (reason) openWindow(w, err, reason, maxSeconds);
|
|
397
|
+
return reason;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Over-quota state, preserved exactly as 3.3 exposed it: non-null from the
|
|
401
|
+
// rejection until a write actually succeeds (not merely until the window
|
|
402
|
+
// expires), so an app can keep rendering "X of Y used" while the client
|
|
403
|
+
// probes. Now derived from the push window rather than a parallel variable.
|
|
404
|
+
const getQuotaState = () => {
|
|
405
|
+
if (pushBackoff.reason !== 'quota') return null;
|
|
406
|
+
const d = pushBackoff.quota;
|
|
407
|
+
return {
|
|
408
|
+
quota: d ? d.quota : 'unknown',
|
|
409
|
+
limit: d ? d.limit : null,
|
|
410
|
+
used: d ? d.used : null,
|
|
411
|
+
requested: d ? d.requested : null,
|
|
412
|
+
message: pushBackoff.message,
|
|
413
|
+
since: pushBackoff.since,
|
|
414
|
+
retryAt: new Date(pushBackoff.until).toISOString(),
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
const isQuotaSuppressed = () => pushBackoff.reason === 'quota' && isOpen(pushBackoff);
|
|
418
|
+
|
|
419
|
+
// PER-REASON SURFACING on a cycle whose work was SKIPPED because a window is
|
|
420
|
+
// open. A quota window keeps surfacing QUOTA_EXCEEDED with its descriptor
|
|
421
|
+
// every cycle, because apps render "X of Y used" from it and a UI built
|
|
422
|
+
// after the rejection must still learn about it. A transport or auth window
|
|
423
|
+
// stays quiet after its first signal — the failure was already reported and
|
|
424
|
+
// repeating it every cycle is the noise this fix exists to remove. The
|
|
425
|
+
// status still reads 'error' in both cases: sync genuinely is not working.
|
|
426
|
+
//
|
|
427
|
+
// APP-INTEGRATION CONSEQUENCE (deliberate, do not "fix" by re-surfacing):
|
|
428
|
+
// because the cycle's onError(null) reset still fires on suppressed cycles,
|
|
429
|
+
// a quiet window means the app's LAST onError MESSAGE is cleared while
|
|
430
|
+
// onStatusChange reads 'error'. An app must therefore render standing
|
|
431
|
+
// backoff from getBackoffState() (reason + until — "retrying in Xs"), not
|
|
432
|
+
// from a remembered onError string. Surface this when integrating the
|
|
433
|
+
// three apps.
|
|
434
|
+
const surfaceStandingWindow = (w) => {
|
|
435
|
+
if (w.reason === 'quota') onError?.(w.message, 'QUOTA_EXCEEDED', false);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// The credential-rejection path, extracted UNCHANGED from the cycle's catch
|
|
439
|
+
// so a 401 surfacing from the push or the pull reaches exactly the same
|
|
440
|
+
// logic it always reached from ensureRootKey. Nothing about the halt, the
|
|
441
|
+
// identity rule, or the superseded-inertness decision is altered — this is
|
|
442
|
+
// only about where the error is allowed to arrive from. Under NO
|
|
443
|
+
// circumstances does the engine re-enroll here; recovery is user-initiated
|
|
444
|
+
// (recoverVaultSyncEngine).
|
|
445
|
+
// Returns the truthful result flag for the cycle to spread — { superseded }
|
|
446
|
+
// when this instance went inert, { halted } when the device halted — so the
|
|
447
|
+
// cycle result matches what isSuperseded()/isCredentialHalted() report,
|
|
448
|
+
// whichever path the rejection arrived by.
|
|
449
|
+
const applyCredentialRejection = (err) => {
|
|
450
|
+
const message = err?.message || String(err);
|
|
451
|
+
let outcome;
|
|
452
|
+
if (isBearerSuperseded()) {
|
|
453
|
+
// Replaced by recovery and the app kept a stale reference: go inert in
|
|
454
|
+
// memory — surfaced once, then silent — leaving the shared halt key
|
|
455
|
+
// alone so the recovered engine keeps syncing.
|
|
456
|
+
supersededInert = true;
|
|
457
|
+
outcome = { superseded: true };
|
|
458
|
+
} else {
|
|
459
|
+
setCredentialHalt(message);
|
|
460
|
+
outcome = { halted: true };
|
|
461
|
+
}
|
|
462
|
+
onError?.(message, 'CREDENTIAL_INVALID', true);
|
|
463
|
+
onStatusChange?.('error');
|
|
464
|
+
return outcome;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// The halt-set identity rule (Phase 2.2). The halt key is shared by every
|
|
468
|
+
// engine instance on this device, so a STALE instance — one still holding a
|
|
469
|
+
// credential that recovery has since replaced — must not be able to re-set
|
|
470
|
+
// the halt and brick the recovered engine. A superseded bearer is provable:
|
|
471
|
+
// the stored credential record exists, is readable, and holds a DIFFERENT
|
|
472
|
+
// credential than this engine speaks.
|
|
473
|
+
//
|
|
474
|
+
// THE RULE FAILS TOWARD HALTING, deliberately: halt when the record is
|
|
475
|
+
// missing or unreadable, or when it matches this engine's bearer. Skip the
|
|
476
|
+
// halt ONLY on that provable-superseded case. The naive simplification
|
|
477
|
+
// ("no record -> no halt") would leave an engine with a dead credential and
|
|
478
|
+
// a wiped record retrying forever — exactly what the halt exists to
|
|
479
|
+
// prevent. Do not simplify this condition.
|
|
480
|
+
const isBearerSuperseded = () => {
|
|
481
|
+
if (typeof vaultToken !== 'string' || vaultToken === '') return false;
|
|
482
|
+
const raw = localStorage.getItem(KEY_CREDENTIAL);
|
|
483
|
+
if (!raw) return false;
|
|
484
|
+
try {
|
|
485
|
+
const rec = JSON.parse(raw);
|
|
486
|
+
return !!(rec && typeof rec.credential === 'string' && rec.credential !== vaultToken);
|
|
487
|
+
} catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
222
492
|
// markDirty: called by the app on every local write (and at creation time for
|
|
223
493
|
// insert-only types). Idempotent and synchronous so it can run inside the
|
|
224
494
|
// app's own write path.
|
|
@@ -253,9 +523,19 @@ export const createDbSyncEngine = (config) => {
|
|
|
253
523
|
// typed, retryable code instead of being mislabeled "update your server".
|
|
254
524
|
const isClientNotReady = (err) =>
|
|
255
525
|
err && (err.code === 'ACCOUNT_ID_REQUIRED' || err.code === 'PASSPHRASE_REQUIRED');
|
|
526
|
+
// Likewise a rejected credential (401 "invalid credential") is an auth
|
|
527
|
+
// outcome, not a capability one: pass it through so the cycle's halt
|
|
528
|
+
// handling sees it — and so allowUnverified can never wave a revoked
|
|
529
|
+
// device through. A quota rejection (Phase 3.3) is the same kind of
|
|
530
|
+
// mistake to make: the verifier's establishing write is a net-new entity,
|
|
531
|
+
// so an account at the row cap gets a 413 here, and relabelling it
|
|
532
|
+
// VERIFIER_UNSUPPORTED tells the user to update a server that is working
|
|
533
|
+
// correctly. Pass it through so the cycle reports the truth.
|
|
534
|
+
const isPassThrough = (err) =>
|
|
535
|
+
isClientNotReady(err) || (err && (err.code === 'CREDENTIAL_INVALID' || err.code === 'QUOTA_EXCEEDED'));
|
|
256
536
|
|
|
257
537
|
const verifierUnsupported = (cause) => {
|
|
258
|
-
if (
|
|
538
|
+
if (isPassThrough(cause)) throw cause;
|
|
259
539
|
if (allowUnverified) {
|
|
260
540
|
// eslint-disable-next-line no-console
|
|
261
541
|
console.warn(`[${appId}] db sync proceeding UNVERIFIED: the server cannot host the key verifier (${KEYCHECK_ENTITY_ID}).`, cause);
|
|
@@ -548,11 +828,22 @@ export const createDbSyncEngine = (config) => {
|
|
|
548
828
|
if (typeof R.seq === 'number') since = Math.max(since, R.seq);
|
|
549
829
|
}
|
|
550
830
|
|
|
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));
|
|
840
|
+
|
|
551
841
|
hasMore = !!more;
|
|
552
842
|
}
|
|
553
843
|
|
|
554
844
|
// Pull is the sole writer of the pull cursor. Advance it (monotonically)
|
|
555
|
-
// from the highest seq we actually listed this run.
|
|
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.
|
|
556
847
|
setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
|
|
557
848
|
return { maxSeq, appliedRemote, applied, skipped, skippedEntityIds };
|
|
558
849
|
};
|
|
@@ -564,11 +855,10 @@ export const createDbSyncEngine = (config) => {
|
|
|
564
855
|
// progress is the conservative value for server-side tombstone GC, since it
|
|
565
856
|
// never lets the server reclaim a tombstone this device has not yet seen.
|
|
566
857
|
const updateDeviceCursor = async () => {
|
|
567
|
-
if (!deviceId) return { updated: false };
|
|
568
858
|
try {
|
|
569
859
|
return await vault.device(app, {
|
|
570
860
|
accountId,
|
|
571
|
-
deviceId,
|
|
861
|
+
deviceId: resolvedDeviceId,
|
|
572
862
|
lastSeenSeq: getHighWaterMark(),
|
|
573
863
|
});
|
|
574
864
|
} catch (err) {
|
|
@@ -582,31 +872,151 @@ export const createDbSyncEngine = (config) => {
|
|
|
582
872
|
const dbSyncCycle = async () => {
|
|
583
873
|
const cfg = getConfig();
|
|
584
874
|
if (cfg && cfg.enabled === false) return;
|
|
875
|
+
// A superseded stale instance is dead silently: no network, and no
|
|
876
|
+
// onError either — its failure was surfaced once when detected, and
|
|
877
|
+
// re-surfacing would fight the recovered engine's own status/error
|
|
878
|
+
// signals through the app's shared callbacks.
|
|
879
|
+
if (supersededInert) {
|
|
880
|
+
return { applied: 0, skipped: 0, skippedEntityIds: [], superseded: true };
|
|
881
|
+
}
|
|
882
|
+
// Standing credential-rejected stop: no network calls at all. The state is
|
|
883
|
+
// re-surfaced on every attempted cycle so a UI constructed after the halt
|
|
884
|
+
// (e.g. next app launch) still learns about it through its own onError.
|
|
885
|
+
const halt = getCredentialHalt();
|
|
886
|
+
if (halt) {
|
|
887
|
+
onError?.(halt.message, 'CREDENTIAL_INVALID', true);
|
|
888
|
+
onStatusChange?.('error');
|
|
889
|
+
return { applied: 0, skipped: 0, skippedEntityIds: [], halted: true };
|
|
890
|
+
}
|
|
585
891
|
if (syncing) return;
|
|
892
|
+
|
|
893
|
+
// Nothing at all can proceed while the key is unverified AND both windows
|
|
894
|
+
// are open: proving the key needs the network, and both directions are
|
|
895
|
+
// backed off. (Once verified in-session, ensureRootKey is a local check,
|
|
896
|
+
// so a single open window only skips its own half of the cycle.)
|
|
897
|
+
if (!verified && isOpen(pushBackoff) && isOpen(pullBackoff)) {
|
|
898
|
+
surfaceStandingWindow(pushBackoff);
|
|
899
|
+
onStatusChange?.('error');
|
|
900
|
+
return {
|
|
901
|
+
applied: 0, skipped: 0, skippedEntityIds: [],
|
|
902
|
+
pushSkipped: true, pullSkipped: true, quota: getQuotaState(),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
586
906
|
syncing = true;
|
|
587
907
|
onStatusChange?.('uploading');
|
|
588
908
|
onError?.(null, null, false);
|
|
589
909
|
try {
|
|
590
910
|
// Part A first: prove the key before we push anything. A globally wrong
|
|
591
911
|
// key aborts here with KEY_MISMATCH (push gated, nothing uploaded) rather
|
|
592
|
-
// than quarantining thousands of rows one by one on the pull.
|
|
912
|
+
// than quarantining thousands of rows one by one on the pull. A failure
|
|
913
|
+
// here blocks BOTH directions, so it opens both windows (see the catch).
|
|
593
914
|
await ensureRootKey();
|
|
594
915
|
|
|
595
|
-
|
|
916
|
+
// PARTIAL FUNCTION (3.3, extended here to the pull). A failure in one
|
|
917
|
+
// direction must never cost the other: the push error is captured, the
|
|
918
|
+
// pull runs, and the pull's own error is captured too so the cursor
|
|
919
|
+
// report still happens. The dirty set is untouched by a failed push
|
|
920
|
+
// (pushDirtyRows only clears it on full ack), so backoff changes WHEN
|
|
921
|
+
// rows are retried, never WHETHER they are.
|
|
922
|
+
let pushError = null;
|
|
923
|
+
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
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
596
940
|
onStatusChange?.('downloading');
|
|
597
|
-
|
|
598
|
-
|
|
941
|
+
let pullError = null;
|
|
942
|
+
let pullSkipped = false;
|
|
943
|
+
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();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// A rejected credential can surface from the push or the pull once the
|
|
963
|
+
// key is verified in-session (ensureRootKey then makes no network call,
|
|
964
|
+
// so it is no longer the first thing to see a 401). Route it to the
|
|
965
|
+
// halt path from wherever it surfaced — the halt logic itself is
|
|
966
|
+
// untouched.
|
|
967
|
+
const credentialRejection =
|
|
968
|
+
(pushError && pushError.code === 'CREDENTIAL_INVALID' && pushError) ||
|
|
969
|
+
(pullError && pullError.code === 'CREDENTIAL_INVALID' && pullError) || null;
|
|
970
|
+
if (credentialRejection) {
|
|
971
|
+
const outcome = applyCredentialRejection(credentialRejection);
|
|
972
|
+
return { applied: pull.applied, skipped: pull.skipped, skippedEntityIds: pull.skippedEntityIds, ...outcome };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (pull.skipped > 0) onRowsSkipped?.(pull.skipped, pull.skippedEntityIds);
|
|
976
|
+
|
|
977
|
+
// lastSynced means "this device is fully in step with the server", so a
|
|
978
|
+
// cycle with any failed or skipped half does not claim it.
|
|
979
|
+
if (pushError || pushSkipped || pullError || pullSkipped) {
|
|
980
|
+
// Report the fresh failure if there was one; otherwise this is a
|
|
981
|
+
// suppressed cycle, and only a quota window keeps speaking.
|
|
982
|
+
if (pushError || pullError) {
|
|
983
|
+
const err = pushError || pullError;
|
|
984
|
+
onError?.(err?.message || String(err), (err && err.code) || 'NETWORK_ERROR', false);
|
|
985
|
+
} else {
|
|
986
|
+
surfaceStandingWindow(pushSkipped ? pushBackoff : pullBackoff);
|
|
987
|
+
}
|
|
988
|
+
onStatusChange?.('error');
|
|
989
|
+
return {
|
|
990
|
+
applied: pull.applied,
|
|
991
|
+
skipped: pull.skipped,
|
|
992
|
+
skippedEntityIds: pull.skippedEntityIds,
|
|
993
|
+
pushFailed: !!pushError,
|
|
994
|
+
pushSkipped,
|
|
995
|
+
pushErrorCode: pushError ? ((pushError.code) || 'NETWORK_ERROR') : (pushSkipped ? pushBackoff.code : undefined),
|
|
996
|
+
pullFailed: !!pullError,
|
|
997
|
+
pullSkipped,
|
|
998
|
+
pullErrorCode: pullError ? ((pullError.code) || 'NETWORK_ERROR') : (pullSkipped ? pullBackoff.code : undefined),
|
|
999
|
+
quota: getQuotaState(),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
599
1002
|
|
|
600
1003
|
const now = new Date().toISOString();
|
|
601
1004
|
localStorage.setItem(KEY_LAST_SYNCED, now);
|
|
602
1005
|
onStatusChange?.('success');
|
|
603
1006
|
|
|
604
|
-
if (pull.skipped > 0) onRowsSkipped?.(pull.skipped, pull.skippedEntityIds);
|
|
605
1007
|
return { applied: pull.applied, skipped: pull.skipped, skippedEntityIds: pull.skippedEntityIds };
|
|
606
1008
|
} catch (err) {
|
|
1009
|
+
// Only ensureRootKey reaches here now (push and pull are captured
|
|
1010
|
+
// above). It gates both directions, so its failure opens both windows.
|
|
607
1011
|
// eslint-disable-next-line no-console
|
|
608
1012
|
console.error(`[${appId}] db sync cycle error:`, err);
|
|
609
1013
|
const code = err && err.code ? err.code : 'NETWORK_ERROR';
|
|
1014
|
+
if (code === 'CREDENTIAL_INVALID') {
|
|
1015
|
+
const outcome = applyCredentialRejection(err);
|
|
1016
|
+
return { applied: 0, skipped: 0, skippedEntityIds: [], ...outcome };
|
|
1017
|
+
}
|
|
1018
|
+
const reason = recordFailure(pushBackoff, err, MAX_PUSH_BACKOFF_S);
|
|
1019
|
+
if (reason) recordFailure(pullBackoff, err, MAX_PULL_BACKOFF_S);
|
|
610
1020
|
onError?.(err?.message || String(err), code, false);
|
|
611
1021
|
onStatusChange?.('error');
|
|
612
1022
|
return { applied: 0, skipped: 0, skippedEntityIds: [] };
|
|
@@ -650,6 +1060,35 @@ export const createDbSyncEngine = (config) => {
|
|
|
650
1060
|
isSyncing: () => syncing,
|
|
651
1061
|
isKeyVerified: () => verified,
|
|
652
1062
|
hasEncryptionReady: hasDbRootKey,
|
|
1063
|
+
// Credential-rejected stop state. Cleared only by recoverVaultSyncEngine
|
|
1064
|
+
// after a verified successful re-enrollment — never by the engine.
|
|
1065
|
+
isCredentialHalted: () => getCredentialHalt() !== null,
|
|
1066
|
+
getCredentialHalt,
|
|
1067
|
+
// True once this instance proved its bearer was superseded by recovery
|
|
1068
|
+
// and went inert (in-memory only; a fresh instance is unaffected).
|
|
1069
|
+
isSuperseded: () => supersededInert,
|
|
1070
|
+
// Over-quota state (Phase 3.3): null when clear, else the parsed
|
|
1071
|
+
// descriptor plus when the next probe is due. In-memory only, never a
|
|
1072
|
+
// hard stop, and there is nothing to clear — it lifts by itself once the
|
|
1073
|
+
// server accepts a write again. Now derived from the push backoff window.
|
|
1074
|
+
getQuotaState,
|
|
1075
|
+
isQuotaSuppressed,
|
|
1076
|
+
|
|
1077
|
+
// Backoff. The timestamps use the file tier's getter names and meaning
|
|
1078
|
+
// (epoch ms before which that direction will not be attempted), so a
|
|
1079
|
+
// scheduler can treat both tiers identically. Unlike the file tier the
|
|
1080
|
+
// engine ALSO enforces them itself, so honouring them is optional.
|
|
1081
|
+
getUploadBackoffUntil: () => pushBackoff.until,
|
|
1082
|
+
getDownloadBackoffUntil: () => pullBackoff.until,
|
|
1083
|
+
// The full picture, because the timestamp alone cannot distinguish a
|
|
1084
|
+
// one-hour auth backoff from a thirty-second transport one without
|
|
1085
|
+
// inferring from its magnitude. `reason` is 'quota' | 'auth' |
|
|
1086
|
+
// 'transport'; `code` is the typed error that opened the window.
|
|
1087
|
+
getBackoffState: () => ({
|
|
1088
|
+
push: { until: pushBackoff.until, reason: pushBackoff.reason, code: pushBackoff.code, strikes: pushBackoff.strikes, since: pushBackoff.since },
|
|
1089
|
+
pull: { until: pullBackoff.until, reason: pullBackoff.reason, code: pullBackoff.code, strikes: pullBackoff.strikes, since: pullBackoff.since },
|
|
1090
|
+
}),
|
|
1091
|
+
deviceId: resolvedDeviceId,
|
|
653
1092
|
|
|
654
1093
|
// Sub-modules
|
|
655
1094
|
vault,
|
package/src/index.js
CHANGED
|
@@ -40,8 +40,21 @@ export {
|
|
|
40
40
|
} from './engine.js';
|
|
41
41
|
|
|
42
42
|
// Phase 3: database transport (selected via transportMode: 'database').
|
|
43
|
-
export { createDbSyncEngine } from './dbEngine.js';
|
|
44
|
-
export {
|
|
43
|
+
export { createDbSyncEngine, getOrCreateDeviceId } from './dbEngine.js';
|
|
44
|
+
export {
|
|
45
|
+
createVaultClient,
|
|
46
|
+
// Per-account auth substrate (vault Phase 1.4b): unauthenticated auth-mode
|
|
47
|
+
// discovery and the bootstrap-secret -> per-device-credential exchange.
|
|
48
|
+
fetchVaultHealth,
|
|
49
|
+
enrollVaultDevice,
|
|
50
|
+
} from './vaultClient.js';
|
|
51
|
+
// The packaged connect flow: discover -> branch -> enroll if needed ->
|
|
52
|
+
// persist credential -> build engine. Apps hand it a secret or token and get
|
|
53
|
+
// a working engine back; the bootstrap secret never touches storage.
|
|
54
|
+
// recoverVaultSyncEngine (Phase 2.2) is the halt-gated, user-initiated exit
|
|
55
|
+
// from a rejected credential: re-enrollment that rotates the dead credential
|
|
56
|
+
// away server-side.
|
|
57
|
+
export { connectVaultSyncEngine, recoverVaultSyncEngine } from './vaultConnect.js';
|
|
45
58
|
export {
|
|
46
59
|
setupDbRootKey,
|
|
47
60
|
initDbRootKey,
|