@glance-apps/sync 1.8.0 → 1.11.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 +329 -0
- package/README.md +65 -0
- package/package.json +1 -1
- package/src/dbEngine.js +319 -28
- package/src/index.js +8 -0
- package/src/vaultClient.js +606 -19
- package/types/index.d.ts +204 -1
package/types/index.d.ts
CHANGED
|
@@ -32,7 +32,21 @@ export type SyncErrorCode =
|
|
|
32
32
|
// credential (401 "invalid credential"). Surfaced with isHardStop === true;
|
|
33
33
|
// the engine persists a halt and stops retrying. Recovery (re-enrollment)
|
|
34
34
|
// is Phase 2.2 — nothing in this version clears the halt.
|
|
35
|
-
| 'CREDENTIAL_INVALID'
|
|
35
|
+
| 'CREDENTIAL_INVALID'
|
|
36
|
+
// DB transport: the server rejected a write because the account is over a
|
|
37
|
+
// configured quota (413 storage-shaped / 429 volume-shaped, Phase 3.2).
|
|
38
|
+
// ALWAYS isHardStop === false — a quota condition clears when the operator
|
|
39
|
+
// raises the limit or reclaim runs, with NO client action, so the engine
|
|
40
|
+
// never halts on it. The parsed descriptor rides on the engine's
|
|
41
|
+
// getQuotaState(); the engine suppresses further writes for a bounded,
|
|
42
|
+
// self-resuming window while the pull keeps running.
|
|
43
|
+
| 'QUOTA_EXCEEDED'
|
|
44
|
+
// DB transport (1.11.0): the module-scope brake is engaged after a real 429,
|
|
45
|
+
// so this call failed FAST without touching the network. Carries
|
|
46
|
+
// `status: 429` and `retryInMs`, so any ladder that already treats a real
|
|
47
|
+
// 429 as transient handles it unchanged. Never a hard stop: the window is
|
|
48
|
+
// self-expiring and a success both releases it and halves the escalation.
|
|
49
|
+
| 'RATE_LIMITED';
|
|
36
50
|
|
|
37
51
|
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
38
52
|
|
|
@@ -414,6 +428,23 @@ export interface VaultPulledRow extends Partial<VaultRow> {
|
|
|
414
428
|
envelope?: string;
|
|
415
429
|
}
|
|
416
430
|
|
|
431
|
+
/**
|
|
432
|
+
* One intent event on the wire. `envelope` is OPAQUE to this package — it is
|
|
433
|
+
* never decoded or inspected; the codec lives in `@glance-apps/intents`.
|
|
434
|
+
*/
|
|
435
|
+
export interface VaultIntentEvent {
|
|
436
|
+
eventId: string;
|
|
437
|
+
/** Opaque base64 payload. */
|
|
438
|
+
envelope: string;
|
|
439
|
+
/** ISO 8601. */
|
|
440
|
+
expiresAt: string;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface VaultIntentRow extends VaultIntentEvent {
|
|
444
|
+
seq: number;
|
|
445
|
+
serverMtime: string;
|
|
446
|
+
}
|
|
447
|
+
|
|
417
448
|
export interface VaultClient {
|
|
418
449
|
batch(app: string, args: { accountId: string; rows: VaultRow[] }): Promise<{ written: number; maxSeq: number }>;
|
|
419
450
|
list(app: string, args: { accountId: string; since: number }): Promise<{ rows: VaultPulledRow[]; hasMore: boolean }>;
|
|
@@ -422,6 +453,10 @@ export interface VaultClient {
|
|
|
422
453
|
device(app: string, args: { accountId: string; deviceId: string; lastSeenSeq: number }): Promise<{ updated: boolean }>;
|
|
423
454
|
getSalt(accountId: string): Promise<Uint8Array | null>;
|
|
424
455
|
putSalt(accountId: string, salt: Uint8Array): Promise<Uint8Array>;
|
|
456
|
+
/** Appends intent events. Insert-only: a re-sent eventId is a server no-op. */
|
|
457
|
+
intentsBatch(accountId: string, events: VaultIntentEvent[]): Promise<{ written: number; maxSeq: number }>;
|
|
458
|
+
/** Fetches non-expired intent events with seq > since, ascending. One page per call. */
|
|
459
|
+
intentsList(accountId: string, opts?: { since?: number; limit?: number }): Promise<{ rows: VaultIntentRow[]; hasMore: boolean }>;
|
|
425
460
|
}
|
|
426
461
|
|
|
427
462
|
export function createVaultClient(config: {
|
|
@@ -429,8 +464,89 @@ export function createVaultClient(config: {
|
|
|
429
464
|
/** Shared device token, or the device's enrolled credential in per-account mode. */
|
|
430
465
|
vaultToken: string;
|
|
431
466
|
fetchImpl?: typeof fetch;
|
|
467
|
+
/**
|
|
468
|
+
* Set false to opt this client out of the module-scope brake — it then
|
|
469
|
+
* neither gates on nor arms it. An escape hatch for tests that need the
|
|
470
|
+
* wire on every call; production callers should leave it on.
|
|
471
|
+
* @default true
|
|
472
|
+
*/
|
|
473
|
+
brake?: boolean;
|
|
432
474
|
}): VaultClient;
|
|
433
475
|
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// Module-scope request diagnostics (1.11.0)
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
//
|
|
480
|
+
// One brake, one budget meter and one write-loop history per BUNDLE REALM,
|
|
481
|
+
// not per client instance: they model a resource every client in the process
|
|
482
|
+
// shares — the server's per-IP request budget. A separately bundled copy
|
|
483
|
+
// (e.g. an Obsidian plugin) is its own realm, which is correct: separate
|
|
484
|
+
// process, separate traffic.
|
|
485
|
+
|
|
486
|
+
export interface VaultBrakeStatus {
|
|
487
|
+
/** Is the brake engaged right now? While engaged, every client call fails fast with RATE_LIMITED. */
|
|
488
|
+
braked: boolean;
|
|
489
|
+
/** Epoch ms the current window expires, or null when not braked. */
|
|
490
|
+
until: number | null;
|
|
491
|
+
/** Escalation memory. Doubles per 429 burst to a 10-minute ceiling; a success halves it. */
|
|
492
|
+
memoryMs: number;
|
|
493
|
+
/** Milliseconds left in the window, 0 when not braked. */
|
|
494
|
+
retryInMs: number;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export interface VaultWriteLoopSuspect {
|
|
498
|
+
entityId: string;
|
|
499
|
+
/**
|
|
500
|
+
* Redundant writes counted inside the window: polarity flips, repeat
|
|
501
|
+
* deletes of an already-deleted row, or identical-content rewrites.
|
|
502
|
+
*/
|
|
503
|
+
transitions: number;
|
|
504
|
+
/** Whether the loud one-per-window warning has fired for this id. */
|
|
505
|
+
warned: boolean;
|
|
506
|
+
history: Array<{ polarity: 'upsert' | 'delete'; at: number; contentHash: string | null }>;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export interface VaultStats {
|
|
510
|
+
brake: VaultBrakeStatus;
|
|
511
|
+
requests: {
|
|
512
|
+
/** Requests that reached the wire in the last rolling minute. */
|
|
513
|
+
lastMinute: number;
|
|
514
|
+
softLimitPerMinute: number;
|
|
515
|
+
/** Attribution by client method name, e.g. { intentsList: 210, batch: 130 }. */
|
|
516
|
+
byMethod: Record<string, number>;
|
|
517
|
+
/** Samples dropped by the buffer's hard cap (only ever under-counts a storm). */
|
|
518
|
+
droppedSamples: number;
|
|
519
|
+
};
|
|
520
|
+
writeLoopSuspects: VaultWriteLoopSuspect[];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Is the shared brake engaged? For callers that prefer to sit a cycle out pre-flight. */
|
|
524
|
+
export function isVaultRateLimited(): boolean;
|
|
525
|
+
|
|
526
|
+
/** The brake's full state. */
|
|
527
|
+
export function vaultBrakeStatus(): VaultBrakeStatus;
|
|
528
|
+
|
|
529
|
+
/** Brake status, per-minute request counts by method, and write-loop suspects in one read. */
|
|
530
|
+
export function getVaultStats(): VaultStats;
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Tunes the visibility-only thresholds. The brake's curve is deliberately not
|
|
534
|
+
* configurable — it protects someone else's server.
|
|
535
|
+
*/
|
|
536
|
+
export function configureVaultDiagnostics(options?: {
|
|
537
|
+
/** Budget-meter warn threshold. @default 300 */
|
|
538
|
+
softLimitPerMinute?: number;
|
|
539
|
+
/** Write-loop K: redundant writes to one entityId before warning. @default 4 */
|
|
540
|
+
loopTransitions?: number;
|
|
541
|
+
/** Write-loop M. @default 600000 (10 minutes) */
|
|
542
|
+
loopWindowMs?: number;
|
|
543
|
+
/** Console-shaped sink for the diagnostic lines. Defaults to `console`. */
|
|
544
|
+
logger?: { warn?: (m: string) => void; info?: (m: string) => void; log?: (m: string) => void } | null;
|
|
545
|
+
}): { softLimitPerMinute: number; loopTransitions: number; loopWindowMs: number; logger: unknown };
|
|
546
|
+
|
|
547
|
+
/** Clears the brake, the meter and the write history, and restores the defaults. For tests. */
|
|
548
|
+
export function resetVaultDiagnostics(): void;
|
|
549
|
+
|
|
434
550
|
// Per-account auth (vault Phase 1.4b).
|
|
435
551
|
|
|
436
552
|
/** Which trust model a GLANCEvault server runs. Discoverable via fetchVaultHealth. */
|
|
@@ -519,6 +635,74 @@ export interface DbSyncResult {
|
|
|
519
635
|
applied: number;
|
|
520
636
|
skipped: number;
|
|
521
637
|
skippedEntityIds: string[];
|
|
638
|
+
/** True when the push threw but the pull still ran (Phase 3.3 partial function). */
|
|
639
|
+
pushFailed?: boolean;
|
|
640
|
+
/** True when the push was skipped because its backoff window is open. */
|
|
641
|
+
pushSkipped?: boolean;
|
|
642
|
+
/** The push error's code when pushFailed/pushSkipped — the pull's own result is still reported above. */
|
|
643
|
+
pushErrorCode?: SyncErrorCode | string;
|
|
644
|
+
/** True when the pull threw but the cursor report still ran. */
|
|
645
|
+
pullFailed?: boolean;
|
|
646
|
+
/** True when the pull was skipped because its backoff window is open. */
|
|
647
|
+
pullSkipped?: boolean;
|
|
648
|
+
/** The pull error's code when pullFailed/pullSkipped. */
|
|
649
|
+
pullErrorCode?: SyncErrorCode | string;
|
|
650
|
+
/** The standing over-quota descriptor, when one applies. */
|
|
651
|
+
quota?: QuotaState | null;
|
|
652
|
+
/** True when the cycle no-opped because the credential halt is set. */
|
|
653
|
+
halted?: boolean;
|
|
654
|
+
/** True when the cycle no-opped because this instance's credential was superseded. */
|
|
655
|
+
superseded?: boolean;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Over-quota state (Phase 3.3). IN MEMORY ONLY — this describes server-side
|
|
660
|
+
* state that changes without the client (an operator raises a limit, reclaim
|
|
661
|
+
* runs, intents expire), so it is never persisted and there is nothing to
|
|
662
|
+
* clear: it lifts by itself when a write succeeds again.
|
|
663
|
+
*
|
|
664
|
+
* `quota` is the server's dimension string, passed through verbatim — the
|
|
665
|
+
* known values are 'storage' | 'rows' | 'intents' | 'concurrent-uploads', but
|
|
666
|
+
* a newer server may add dimensions, so treat an unrecognised one generically
|
|
667
|
+
* rather than assuming it cannot happen. The numbers are bytes for 'storage'
|
|
668
|
+
* and counts otherwise, and render "X of Y used" directly; they are null only
|
|
669
|
+
* in the defensive case where a rejection was typed without a full body.
|
|
670
|
+
*/
|
|
671
|
+
/**
|
|
672
|
+
* One backoff window. The DB engine keeps two (push and pull) and enforces
|
|
673
|
+
* them itself; both are IN MEMORY ONLY, like the file tier's, so a restart
|
|
674
|
+
* clears them — a relaunch is a user saying "try now", and a persisting
|
|
675
|
+
* failure re-opens the window immediately.
|
|
676
|
+
*
|
|
677
|
+
* A window only ever DELAYS: the next cycle after `until` probes again, with
|
|
678
|
+
* no user action and no restart. Failures that must not be delayed at all
|
|
679
|
+
* (PASSPHRASE_REQUIRED / ACCOUNT_ID_REQUIRED, which clear the moment the app
|
|
680
|
+
* supplies the value) and failures owned by another state (CREDENTIAL_INVALID,
|
|
681
|
+
* which halts) never open one.
|
|
682
|
+
*/
|
|
683
|
+
export interface BackoffWindow {
|
|
684
|
+
/** Epoch ms; the direction is skipped while this is in the future. 0 when clear. */
|
|
685
|
+
until: number;
|
|
686
|
+
/** Consecutive failures driving the escalation (30s doubling; 15 min push cap, 5 min pull cap). */
|
|
687
|
+
strikes: number;
|
|
688
|
+
/** 'quota' (keeps surfacing its descriptor), 'auth' (flat hour on a 401), 'transport' (quiet after one signal). */
|
|
689
|
+
reason: 'quota' | 'auth' | 'transport' | null;
|
|
690
|
+
/** The typed error code that opened the window. */
|
|
691
|
+
code: SyncErrorCode | string | null;
|
|
692
|
+
/** ISO timestamp of the most recent failure. */
|
|
693
|
+
since: string | null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export interface QuotaState {
|
|
697
|
+
quota: string;
|
|
698
|
+
limit: number | null;
|
|
699
|
+
used: number | null;
|
|
700
|
+
requested: number | null;
|
|
701
|
+
message: string;
|
|
702
|
+
/** ISO timestamp of the most recent rejection. */
|
|
703
|
+
since: string;
|
|
704
|
+
/** ISO timestamp after which the engine probes again, on its own. */
|
|
705
|
+
retryAt: string;
|
|
522
706
|
}
|
|
523
707
|
|
|
524
708
|
/** A row quarantined after failing to decrypt under the (verified) account key. */
|
|
@@ -564,6 +748,25 @@ export interface DbSyncEngine {
|
|
|
564
748
|
getCredentialHalt(): { message: string; at: string } | null;
|
|
565
749
|
/** True once this instance proved its bearer was superseded by recovery and went inert (in-memory only). */
|
|
566
750
|
isSuperseded(): boolean;
|
|
751
|
+
/** The standing over-quota descriptor, or null. In-memory only; never a hard stop; nothing to clear. */
|
|
752
|
+
getQuotaState(): QuotaState | null;
|
|
753
|
+
/** True while the bounded over-quota window is open (writes skipped, pulls continue). */
|
|
754
|
+
isQuotaSuppressed(): boolean;
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Epoch ms before which the push/pull will not be attempted, or 0. Same
|
|
758
|
+
* getter names and meaning as the file engine's, so one scheduler can treat
|
|
759
|
+
* both tiers identically — but unlike the file tier, the DB engine also
|
|
760
|
+
* ENFORCES these itself, so honouring them is an optimisation, not a duty.
|
|
761
|
+
*/
|
|
762
|
+
getUploadBackoffUntil(): number;
|
|
763
|
+
getDownloadBackoffUntil(): number;
|
|
764
|
+
/**
|
|
765
|
+
* The full backoff picture. `reason` distinguishes a one-hour auth window
|
|
766
|
+
* from a thirty-second transport one without inferring from the timestamp's
|
|
767
|
+
* magnitude, which is what an app needs to tell a user why and for how long.
|
|
768
|
+
*/
|
|
769
|
+
getBackoffState(): { push: BackoffWindow; pull: BackoffWindow };
|
|
567
770
|
/** The resolved stable device identity this engine runs as. */
|
|
568
771
|
deviceId: string;
|
|
569
772
|
|