@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/CHANGELOG.md +300 -0
- package/README.md +67 -0
- package/package.json +1 -1
- package/src/dbEngine.js +334 -52
- package/src/index.js +16 -1
- package/src/vaultClient.js +557 -20
- package/types/index.d.ts +182 -2
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
|
-
|
|
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
|