@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/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