/** * push/subscription-store.ts * * The on-disk record of which devices an operator has registered for browser * push. Persisted with the same atomic-JSON `PersistentStore` the approval and * session stores use, the capability URLs and key material stay on disk in the * daemon's own state directory, never on the wire. * * Delete means delete: `remove()` drops the record entirely (it does not flag a * tombstone), and `list()` cannot return it afterward. Pruning a dead endpoint * uses the same `remove()` path. * * Housekeeping (see subscription-housekeeping.ts for the full rationale): * - Content is validated at REGISTRATION, so a record that could never receive * a push is refused with a plain reason instead of being stored and failing * weeks later at delivery time. * - `sweep()` removes only records that are PROVABLY dead, unusable key * material, a torn record, or a push service that has refused it past the * bounded-failure threshold. There is no age TTL and no eviction to make * room: a quiet device that still works is never removed, so nobody ever has * to resubscribe. * - Every sweep discloses what it removed and the evidence, and runs on * recovery AND on a timer. * * Concurrency: there is no process-local cache. Every read goes to disk, so a * revocation takes effect on the very next lookup rather than after a restart. * Every read-modify-write runs under BOTH an in-process queue and the advisory * lock file the checkpoint store uses (`.json.lock`), so a second * daemon's registration cannot be clobbered by this one's sweep, and two sweeps * over the same file cannot interleave. */ import { type PushSubscriptionPolicy, type PushSubscriptionSweepReport } from './subscription-housekeeping.js'; import type { PublicPushSubscription, PushReconcileDrift, StoredPushSubscription, SubscriptionKeyMaterial } from './types.js'; export interface RegisterSubscriptionInput { readonly principalId: string; /** Stable device identity; when present the record reconciles on it, not the endpoint. */ readonly deviceId?: string | undefined; readonly endpoint: string; readonly keys: SubscriptionKeyMaterial; } /** The outcome of a reconcile-on-open: the healed record plus what drifted. */ export interface ReconcileResult { readonly record: StoredPushSubscription; readonly drift: PushReconcileDrift; } /** Construction knobs; all optional so the stock store is one path argument. */ export interface PushSubscriptionStoreOptions { /** Housekeeping policy; absent ⇒ {@link DEFAULT_PUSH_SUBSCRIPTION_POLICY}. */ readonly policy?: (() => PushSubscriptionPolicy) | PushSubscriptionPolicy | undefined; /** Where sweep disclosure is written; absent ⇒ `-housekeeping.json`. */ readonly disclosurePath?: string | undefined; /** Clock seam so disclosure timestamps are deterministic under test. */ readonly now?: (() => number) | undefined; } /** The redacted, wire-safe projection of a stored subscription. */ export declare function toPublicSubscription(record: StoredPushSubscription): PublicPushSubscription; /** The short, stable hash a client compares its own endpoint against to detect drift. */ export declare function endpointHashFor(endpoint: string): string; export declare class PushSubscriptionStore { private readonly store; /** `.json.lock`, the cross-process mutex, or null for an in-memory store. */ private readonly lockPath; private readonly disclosure; private readonly policy; private readonly now; /** Serializes in-process mutations so a sweep and a register cannot interleave. */ private queue; private timer; private lastReport; constructor(filePath: string, options?: PushSubscriptionStoreOptions); /** Raw records straight from disk, unfiltered. */ private loadRaw; /** * Records from disk with the provably-dead ones filtered out of the RESULT * (they are removed from the file by `sweep()`, not by a read). A read never * serves a record that could not receive a push. */ private loadUsable; /** * Run `fn` as the only read-modify-write against this file, in this process * AND across processes. The in-process chain orders callers here; the * advisory lock file (the same one the checkpoint store uses) keeps a second * daemon's registration from being clobbered by this one's sweep. An * in-memory store has no file to contend on and takes the chain only. */ private run; private persist; /** * Find the record this input reconciles onto: by device identity when the * input carries a deviceId (so a rotated endpoint heals in place), otherwise * by raw endpoint (the legacy, device-id-less path). Returns the index or -1. */ private matchIndex; /** * Register (or refresh) a subscription. A record is reconciled on device * identity when the input carries a deviceId (a browser whose endpoint * rotated presents the same deviceId with a new endpoint, healing the one * record), otherwise on the raw endpoint (legacy). Either way a re-register * clears the failure counter, the client just proved the device is live. * * Throws `PushSubscriptionValidationError` when the endpoint or key material * could never receive a push. */ register(input: RegisterSubscriptionInput): Promise; /** * Reconcile-on-open: store the client's CURRENT endpoint/keys for its device * identity, healing a stale record in place, and report what drifted so the * client learns whether the daemon had been holding an out-of-date endpoint. * * A registration is NEVER refused for crowding. When a principal is already * above the warning threshold, housekeeping first removes anything provably * dead; if that frees nothing, the new device is accepted anyway and the * crowding is logged and disclosed. Trading a working device for a new one * would silently stop notifications on a device nobody unsubscribed. */ reconcile(input: RegisterSubscriptionInput): Promise; /** Disclose whatever a registration-time pass reaped or flagged. */ private discloseRegistrationPass; /** All subscriptions for a principal, redacted for the wire. */ listPublic(principalId: string): Promise; /** The full record (endpoint + keys) for a delivery. Not for the wire. */ get(id: string): Promise; /** Every stored subscription, the delivery fan-out reads this. Not for the wire. */ all(): Promise; /** * Delete a subscription. Returns true if a record was actually removed, false * if the id was already absent, the caller reports an honest 404 rather than * a 200-noop. An optional `principalId` scopes the delete so one operator * cannot remove another's device. */ remove(id: string, principalId?: string): Promise; /** * Record the outcome of the last delivery attempt against a subscription. A * `delivered` outcome resets the consecutive-failure counter; a `failed` * outcome increments it (the bounded-retry counter the delivery path prunes * on). Returns the resulting consecutive-failure count so the delivery path * can decide whether the bounded retries are exhausted. */ recordOutcome(id: string, outcome: StoredPushSubscription['lastOutcome']): Promise; /** The most recent report this process produced, or null before the first pass. */ getLastReport(): PushSubscriptionSweepReport | null; /** Disclosure history from disk, newest last. */ listDisclosures(): Promise; /** * One housekeeping pass: remove every record that is provably dead, keep * everything else, and disclose the result. Idempotent, a second pass over * the same file removes nothing. Safe to run concurrently with another * process: removals are computed by id and applied to a fresh read, so a * record registered in between survives. */ sweep(trigger?: PushSubscriptionSweepReport['trigger']): Promise; /** * The recovery pass. Runs before any push verb is served, so a record left * torn by a crash, or one the delivery path had already proved dead when the * process died mid-prune, is removed rather than served. */ runRecoverySweep(): Promise; /** * Keep sweeping on an interval. A long-lived daemon that only swept at boot * would never sweep at all, so this is not optional wiring. The timer is * unref'd, a pending sweep never holds the process open. */ startPeriodicSweep(intervalMs: number): void; stopPeriodicSweep(): void; } //# sourceMappingURL=subscription-store.d.ts.map