@glance-apps/sync 1.10.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/types/index.d.ts CHANGED
@@ -40,7 +40,20 @@ export type SyncErrorCode =
40
40
  // never halts on it. The parsed descriptor rides on the engine's
41
41
  // getQuotaState(); the engine suppresses further writes for a bounded,
42
42
  // self-resuming window while the pull keeps running.
43
- | 'QUOTA_EXCEEDED';
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'
50
+ // DB transport (1.12.0): this engine's OWN gate refused the call, because
51
+ // the direction's backoff window is open. No request was made. Carries
52
+ // `suppressed: true`, `direction`, `retryAt` and `retryInMs`. Never a hard
53
+ // stop and never a failure — a caller with its own retry ladder should
54
+ // treat it as a scheduled pause and align to `retryAt` rather than
55
+ // escalating a second cooldown on top of it (see isSuppressedError).
56
+ | 'SYNC_SUPPRESSED';
44
57
 
45
58
  export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
46
59
 
@@ -422,6 +435,23 @@ export interface VaultPulledRow extends Partial<VaultRow> {
422
435
  envelope?: string;
423
436
  }
424
437
 
438
+ /**
439
+ * One intent event on the wire. `envelope` is OPAQUE to this package — it is
440
+ * never decoded or inspected; the codec lives in `@glance-apps/intents`.
441
+ */
442
+ export interface VaultIntentEvent {
443
+ eventId: string;
444
+ /** Opaque base64 payload. */
445
+ envelope: string;
446
+ /** ISO 8601. */
447
+ expiresAt: string;
448
+ }
449
+
450
+ export interface VaultIntentRow extends VaultIntentEvent {
451
+ seq: number;
452
+ serverMtime: string;
453
+ }
454
+
425
455
  export interface VaultClient {
426
456
  batch(app: string, args: { accountId: string; rows: VaultRow[] }): Promise<{ written: number; maxSeq: number }>;
427
457
  list(app: string, args: { accountId: string; since: number }): Promise<{ rows: VaultPulledRow[]; hasMore: boolean }>;
@@ -430,6 +460,10 @@ export interface VaultClient {
430
460
  device(app: string, args: { accountId: string; deviceId: string; lastSeenSeq: number }): Promise<{ updated: boolean }>;
431
461
  getSalt(accountId: string): Promise<Uint8Array | null>;
432
462
  putSalt(accountId: string, salt: Uint8Array): Promise<Uint8Array>;
463
+ /** Appends intent events. Insert-only: a re-sent eventId is a server no-op. */
464
+ intentsBatch(accountId: string, events: VaultIntentEvent[]): Promise<{ written: number; maxSeq: number }>;
465
+ /** Fetches non-expired intent events with seq > since, ascending. One page per call. */
466
+ intentsList(accountId: string, opts?: { since?: number; limit?: number }): Promise<{ rows: VaultIntentRow[]; hasMore: boolean }>;
433
467
  }
434
468
 
435
469
  export function createVaultClient(config: {
@@ -437,8 +471,89 @@ export function createVaultClient(config: {
437
471
  /** Shared device token, or the device's enrolled credential in per-account mode. */
438
472
  vaultToken: string;
439
473
  fetchImpl?: typeof fetch;
474
+ /**
475
+ * Set false to opt this client out of the module-scope brake — it then
476
+ * neither gates on nor arms it. An escape hatch for tests that need the
477
+ * wire on every call; production callers should leave it on.
478
+ * @default true
479
+ */
480
+ brake?: boolean;
440
481
  }): VaultClient;
441
482
 
483
+ // ---------------------------------------------------------------------------
484
+ // Module-scope request diagnostics (1.11.0)
485
+ // ---------------------------------------------------------------------------
486
+ //
487
+ // One brake, one budget meter and one write-loop history per BUNDLE REALM,
488
+ // not per client instance: they model a resource every client in the process
489
+ // shares — the server's per-IP request budget. A separately bundled copy
490
+ // (e.g. an Obsidian plugin) is its own realm, which is correct: separate
491
+ // process, separate traffic.
492
+
493
+ export interface VaultBrakeStatus {
494
+ /** Is the brake engaged right now? While engaged, every client call fails fast with RATE_LIMITED. */
495
+ braked: boolean;
496
+ /** Epoch ms the current window expires, or null when not braked. */
497
+ until: number | null;
498
+ /** Escalation memory. Doubles per 429 burst to a 10-minute ceiling; a success halves it. */
499
+ memoryMs: number;
500
+ /** Milliseconds left in the window, 0 when not braked. */
501
+ retryInMs: number;
502
+ }
503
+
504
+ export interface VaultWriteLoopSuspect {
505
+ entityId: string;
506
+ /**
507
+ * Redundant writes counted inside the window: polarity flips, repeat
508
+ * deletes of an already-deleted row, or identical-content rewrites.
509
+ */
510
+ transitions: number;
511
+ /** Whether the loud one-per-window warning has fired for this id. */
512
+ warned: boolean;
513
+ history: Array<{ polarity: 'upsert' | 'delete'; at: number; contentHash: string | null }>;
514
+ }
515
+
516
+ export interface VaultStats {
517
+ brake: VaultBrakeStatus;
518
+ requests: {
519
+ /** Requests that reached the wire in the last rolling minute. */
520
+ lastMinute: number;
521
+ softLimitPerMinute: number;
522
+ /** Attribution by client method name, e.g. { intentsList: 210, batch: 130 }. */
523
+ byMethod: Record<string, number>;
524
+ /** Samples dropped by the buffer's hard cap (only ever under-counts a storm). */
525
+ droppedSamples: number;
526
+ };
527
+ writeLoopSuspects: VaultWriteLoopSuspect[];
528
+ }
529
+
530
+ /** Is the shared brake engaged? For callers that prefer to sit a cycle out pre-flight. */
531
+ export function isVaultRateLimited(): boolean;
532
+
533
+ /** The brake's full state. */
534
+ export function vaultBrakeStatus(): VaultBrakeStatus;
535
+
536
+ /** Brake status, per-minute request counts by method, and write-loop suspects in one read. */
537
+ export function getVaultStats(): VaultStats;
538
+
539
+ /**
540
+ * Tunes the visibility-only thresholds. The brake's curve is deliberately not
541
+ * configurable — it protects someone else's server.
542
+ */
543
+ export function configureVaultDiagnostics(options?: {
544
+ /** Budget-meter warn threshold. @default 300 */
545
+ softLimitPerMinute?: number;
546
+ /** Write-loop K: redundant writes to one entityId before warning. @default 4 */
547
+ loopTransitions?: number;
548
+ /** Write-loop M. @default 600000 (10 minutes) */
549
+ loopWindowMs?: number;
550
+ /** Console-shaped sink for the diagnostic lines. Defaults to `console`. */
551
+ logger?: { warn?: (m: string) => void; info?: (m: string) => void; log?: (m: string) => void } | null;
552
+ }): { softLimitPerMinute: number; loopTransitions: number; loopWindowMs: number; logger: unknown };
553
+
554
+ /** Clears the brake, the meter and the write history, and restores the defaults. For tests. */
555
+ export function resetVaultDiagnostics(): void;
556
+
442
557
  // Per-account auth (vault Phase 1.4b).
443
558
 
444
559
  /** Which trust model a GLANCEvault server runs. Discoverable via fetchVaultHealth. */
@@ -520,6 +635,18 @@ export interface DbSyncEngineConfig {
520
635
  // and proceed without verification (unsafe — a wrong-key device could push
521
636
  // poison rows). Off by default so the safe behavior is the default.
522
637
  allowUnverified?: boolean;
638
+
639
+ /**
640
+ * WHEN the pull cursor is persisted. Must match what `applyRemoteEntity`
641
+ * promises: if it has NOT made the row durable by the time it resolves —
642
+ * it writes into a per-cycle mirror the caller may discard — then anything
643
+ * but `'end-of-pull'` or `'caller'` loses rows on a mid-pagination failure.
644
+ *
645
+ * An unrecognised value is refused at construction, not defaulted.
646
+ *
647
+ * @default 'end-of-pull'
648
+ */
649
+ pullCursorCommit?: PullCursorCommit;
523
650
  }
524
651
 
525
652
  /** Result of a DB sync cycle / pull: how many rows applied vs. quarantined. */
@@ -607,9 +734,22 @@ export interface DbSyncEngine {
607
734
  transportMode: 'database';
608
735
  sync(): Promise<DbSyncResult>;
609
736
  dbSyncCycle(): Promise<DbSyncResult>;
737
+ /**
738
+ * Pushes the dirty set. Enforces the push backoff window and the credential
739
+ * halt for EVERY caller (1.12.0), not only dbSyncCycle: rejects with
740
+ * `SYNC_SUPPRESSED` while the window is open and with `CREDENTIAL_INVALID`
741
+ * while the device is halted, in both cases without touching the network.
742
+ */
610
743
  pushDirtyRows(): Promise<{ written: number; deleted: number; maxSeq: number }>;
744
+ /** As pushDirtyRows, against the pull window. */
611
745
  pullRemoteChanges(): Promise<DbSyncResult & { maxSeq: number; appliedRemote: boolean }>;
612
- updateDeviceCursor(): Promise<{ updated: boolean }>;
746
+ /**
747
+ * Best-effort cursor report. Never throws and never opens a window. Halted
748
+ * devices report nothing and get `{ updated: false, suppressed: true }`;
749
+ * deliberately NOT gated on the pull window, because whether a given cycle
750
+ * has anything to report is the caller's composition decision.
751
+ */
752
+ updateDeviceCursor(): Promise<{ updated: boolean; suppressed?: boolean }>;
613
753
  ensureRootKey(): Promise<void>;
614
754
 
615
755
  markDirty(entityId: string): void;
@@ -665,8 +805,48 @@ export interface DbSyncEngine {
665
805
  vault: VaultClient;
666
806
  }
667
807
 
808
+ /**
809
+ * When the pull cursor is persisted. Chosen to match what the caller's
810
+ * `applyRemoteEntity` promises: an unrecognised value is refused at
811
+ * construction rather than defaulted.
812
+ *
813
+ * - `'end-of-pull'` (default) — once, after a fully successful pagination
814
+ * loop. Safe for every caller, including one that has not thought about it.
815
+ * - `'per-page'` — after each completed page. Requires an apply that is
816
+ * durable on return. Lets a large backlog resume from the last good page.
817
+ * - `'caller'` — the engine never persists; `pullRemoteChanges` returns
818
+ * `maxSeq` and the caller commits it with `setHighWaterMark`.
819
+ */
820
+ export type PullCursorCommit = 'end-of-pull' | 'per-page' | 'caller';
821
+
668
822
  export function createDbSyncEngine(config: DbSyncEngineConfig): DbSyncEngine;
669
823
 
824
+ /**
825
+ * True when an error is the engine's own gate refusing a call (`SYNC_SUPPRESSED`)
826
+ * rather than anything the server said.
827
+ *
828
+ * For callers that keep their own retry ladder or circuit breaker: treating a
829
+ * suppression as a failure is how two independent brakes compound, each
830
+ * looking correct in isolation while the device waits far longer than either
831
+ * intends. Branch on this (or on `err.suppressed`) and align to `err.retryAt`.
832
+ */
833
+ export function isSuppressedError(err: unknown): boolean;
834
+
835
+ /** The shape of a `SYNC_SUPPRESSED` rejection. */
836
+ export interface SyncSuppressedError extends Error {
837
+ code: 'SYNC_SUPPRESSED';
838
+ suppressed: true;
839
+ /** Which half was refused. */
840
+ direction: 'push' | 'pull';
841
+ /** Why the window is open. */
842
+ reason: 'quota' | 'auth' | 'transport' | null;
843
+ /** The typed error that opened the window. */
844
+ causeCode: string | null;
845
+ /** Epoch ms the window closes. */
846
+ retryAt: number;
847
+ retryInMs: number;
848
+ }
849
+
670
850
  /**
671
851
  * Package-owned stable device identifier: generated once (crypto.randomUUID)
672
852
  * and persisted under `{prefix}-device-id`. Used for both the device cursor