/** * PushReceiver — inbound subscription seam for the hq-cloud watcher daemon * (project event-driven-sync-menubar US-009). * * Mirrors {@link PushTransport} (`./push-transport.ts`) but for the opposite * direction of travel: where the transport SHIPS local file changes out to the * cloud, the receiver SUBSCRIBES to the tenant fanout and triggers an * immediate, TARGETED local pull the moment a peer device of the same tenant * publishes a change. Together they form the event-driven primary path; the * existing `--poll-remote-ms` poll in `runRunnerWithLoop` is the safety net * behind both. * * Transport: SNS → per-client SQS (US-000 decision) * ───────────────────────────────────────────────── * Per the US-000 transport investigation (companies/indigo/projects/ * event-driven-sync-menubar/references.md): reuse PR #112's SNS publish + * DynamoDB catch-up log, and build the client RECEIVE side as a per-client * SQS queue subscribed to `sync-push-{tenantId}`. The receiver long-polls its * own queue, decodes each message body as a {@link PushEvent}, dedupes by * `sequenceNumber` per `relativePath`, and bridges into the existing sync * engine via an injected {@link SyncEngineFn} (→ targeted `runRunner` pull). * * The live queue is NOT provisioned yet (the server SQS-provisioning Lambda is * an unbuilt follow-up — see references.md "Open items handed to the plan"). * So this module ships: * - {@link SqsClientLike} — the narrow SQS surface the receiver depends on * (`receiveMessage` / `deleteMessage`). Production callers pass an * `@aws-sdk/client-sqs` `SQSClient` adapted to this shape; unit tests inject * a fake. NO real AWS is required to exercise the receiver. * - {@link SqsPushReceiver} — the real receiver. Long-polls the queue, * dispatches each event through the shared dedupe path, deletes the message * on successful handoff, and reconnects on transient `receiveMessage` * failures with exponential backoff. SQS's own 14-day retention buffers * messages while the device is offline → reconnect-replay is "free": on * reconnect the poll loop simply resumes and the retained messages are * redelivered, then dedupe skips anything already processed. * - {@link NoopPushReceiver} — the dormant default. Flips `connected` on * start, opens no subscription. Wired when the daemon runs without a real * queue (or when the feature flag is OFF). * - {@link createPushReceiver} — factory the daemon uses; returns the noop * unless an SQS client + queue URL are supplied. * * Lifecycle (mirrors PushTransport) * ───────────────────────────────── * - `start()` opens the subscription (begins the long-poll loop). Awaited * AFTER the watcher starts so a synthetic event can't race a half-built * daemon. When the feature flag is OFF, `start()` is a no-op and `connected` * stays false — NO queue is polled (dormant; AC#4). * - On each received message: validate with {@link decodePushEvent} (defense * in depth at the wire boundary), dedupe by `relativePath` against the * highest `sequenceNumber` seen for that path, then call the injected * {@link SyncEngineFn}. The sync engine is an injected seam — this story * does NOT re-implement download logic; it bridges to `runRunner` pull. * - `dispose()` aborts in-flight via AbortController, stops the poll loop, * awaits the in-flight sync up to a drain deadline, then disconnects. * * Dedupe (AC#3) * ───────────── * A per-`relativePath` map of the highest `sequenceNumber` already passed to * `syncFn`. An incoming event with `sequenceNumber <= seen` is skipped. SQS * at-least-once delivery + reconnect-replay means the SAME event can arrive * twice; dedupe makes that idempotent. * * Disconnect / reconnect with catch-up replay (AC#3/#4) * ───────────────────────────────────────────────────── * `receiveMessage` failures (network blip, throttling) are caught; the loop * backs off (exponential + jitter, capped) and resumes. Because the per-client * SQS queue retains undelivered messages for 14 days, anything published while * the device was offline/disconnected is redelivered when the poll resumes — * catch-up replay with no server round-trip. Redelivered-but-already-processed * events are absorbed by the dedupe path. The in-memory fake's * `simulateDisconnect()` / `simulateReconnect()` model exactly this buffering. * * Authority * ───────── * This receiver is created only after the event-sync wiring obtains * authenticated server inventory/credentials. It has no tenant, environment, * or queued-hint-derived positive gate. * * Cross-tenant isolation (US-010) * ─────────────────────────────── * Each receiver instance binds exactly ONE `tenantId` and polls exactly ONE * queue URL (its own tenant's per-client queue). Isolation is enforced at the * subscription boundary — the receiver never reads another tenant's queue, and * never filters cross-tenant data post-hoc. * * @see ./push-transport.ts (the outbound seam this mirrors) * @see ./feature-flags.ts (the per-tenant flag provider — US-008) * @see ../bin/sync-runner.ts (the wiring site — runRunnerWithLoop) * @see companies/indigo/projects/event-driven-sync-menubar/references.md (US-000) * * Adapted from indigoai-us/hq-pro PR #112 (src/sync/push-receiver.ts) into * @indigoai-us/hq-cloud (Path B). The hq-pro source shipped only Noop + * InMemory receivers (the production SQS path was deferred there); this module * builds the real SQS receiver behind the same lifecycle/dedupe/flag seam. */ import { type PushEvent } from "./push-event.js"; import { type SyncLatencyMetric } from "./metrics.js"; /** * How long `dispose()` awaits an in-flight `syncFn` after aborting its signal, * before abandoning it (the poll/cadence safety net re-pulls on the next tick). */ export declare const DEFAULT_RECEIVER_DISPOSE_DRAIN_MS = 5000; /** Default SQS long-poll wait (seconds). 20 is the SQS max — true long-poll. */ export declare const DEFAULT_WAIT_TIME_SECONDS = 20; /** Default max messages pulled per `receiveMessage` call (SQS max is 10). */ export declare const DEFAULT_MAX_MESSAGES = 10; /** * A receiver holds selected receipts until the targeted pull succeeds and all * coalesced receipts are acknowledged. Ten minutes covers the observed * 30–120 second pulls plus a bounded 1,000-receipt delete tail, without * changing the queue-owned default visibility timeout. */ export declare const DEFAULT_RECEIVER_VISIBILITY_TIMEOUT_SECONDS = 600; /** Reconnect backoff defaults. */ export declare const DEFAULT_RECONNECT_INITIAL_MS = 250; export declare const DEFAULT_RECONNECT_MAX_MS = 30000; /** Maximum distinct paths retained in receiver dedupe state. */ export declare const DEFAULT_RECEIVER_DEDUPE_MAX_PATHS = 50000; /** * One targeted pull may name at most this many distinct paths. This keeps argv * size and the scoped planner bounded while still amortizing normal edit bursts. */ export declare const DEFAULT_RECEIVER_BATCH_MAX_PATHS = 256; /** Bound raw receipt memory while draining a hot SQS queue into one pass. */ export declare const DEFAULT_RECEIVER_DRAIN_MAX_MESSAGES = 1000; /** * A second drain may keep receiving while a bulk targeted pull is running. * This is deliberately small: it prevents one slow scheduler pass from * silencing SQS intake without stockpiling enough leased receipts to outrun * the ten-minute visibility lease. */ export declare const DEFAULT_RECEIVER_MAX_IN_FLIGHT_DRAINS = 2; /** Consecutive one-second long-poll empties required to finish a drain. */ export declare const DEFAULT_RECEIVER_DRAIN_EMPTY_CONFIRMATIONS = 3; /** Maximum extra time spent filling a drain that already received work. */ export declare const DEFAULT_RECEIVER_DRAIN_FILL_MAX_MS = 5000; /** Short long-poll used to confirm that a non-blocking empty page is real. */ export declare const DEFAULT_RECEIVER_DRAIN_CONFIRMATION_WAIT_SECONDS = 1; /** * One SQS message as the receiver consumes it. A structural subset of the AWS * SDK `Message` so a real `SQSClient` response satisfies it without adaptation * and tests can build literals. */ export interface SqsMessageLike { /** The message payload — a JSON-encoded {@link PushEvent}. */ readonly Body?: string; /** Opaque handle used to delete the message after successful handoff. */ readonly ReceiptHandle?: string; /** Optional SQS message id (logged for diagnostics). */ readonly MessageId?: string; } /** * The narrow SQS client surface the receiver depends on. The AWS SDK * `SQSClient` does NOT match this shape directly (it exposes a single * `send(command)`); production callers adapt it with a thin wrapper (see * {@link sqsClientFromAwsSdk} in the wiring site / tests). Keeping the seam * this narrow means unit tests inject a hand-written fake with zero AWS deps. */ export interface SqsClientLike { /** * Long-poll the queue. Resolves with zero or more messages. MUST honor the * abort signal (resolve/reject promptly on abort) so `dispose()` doesn't * block on an in-flight 20s long-poll. */ receiveMessage(args: { queueUrl: string; maxMessages: number; waitTimeSeconds: number; visibilityTimeoutSeconds?: number; signal: AbortSignal; }): Promise<{ messages: SqsMessageLike[]; }>; /** Delete a successfully-handled message so it isn't redelivered. */ deleteMessage(args: { queueUrl: string; receiptHandle: string; }): Promise; /** Return an unselected receipt to the queue immediately for a later pass. */ changeMessageVisibility(args: { queueUrl: string; receiptHandle: string; visibilityTimeoutSeconds: number; }): Promise; } /** * Context handed to {@link SyncEngineFn} on every received event. * * - `event` — the validated, deduped PushEvent. `relativePath` is what the * sync engine pulls; `sequenceNumber` is for observability. * - `signal` — aborts when `dispose()` runs past its drain deadline. A * well-behaved sync fn checks `signal.aborted` between stages and returns * early. */ export interface PushReceiverContext { readonly event: PushEvent; readonly signal: AbortSignal; } /** * The injected sync function. The receiver does NOT perform the actual fetch — * it hands off the relativePath to whatever the deployment supplies. In * production this bridges to a targeted `runRunner` pull for the affected * company/path; in tests it's a fake recording invocations. * * Errors from `syncFn` are CAUGHT by the receiver — they log and the loop * continues. A failed sync is left unacknowledged in SQS so the queue can * redeliver it after the visibility timeout. */ export type SyncEngineFn = (ctx: PushReceiverContext) => Promise; /** One coalesced receiver pass. `events` contains the highest sequence per path. */ export interface PushReceiverBatchContext { readonly events: readonly PushEvent[]; readonly signal: AbortSignal; } export type SyncBatchEngineFn = { (ctx: PushReceiverBatchContext): Promise; /** * Optional runner-owned lane split. The receiver preserves receipt semantics * by invoking and acknowledging each returned sub-batch independently. */ partition?: (events: readonly PushEvent[]) => readonly (readonly PushEvent[])[]; }; /** * Best-effort CloudWatch metric publish seam (US-011). Invoked on the * receive-SUCCESS path with the measured save-on-A → visible-on-B latency. * Defaults to {@link publishSyncLatencyMetric} (the module singleton client); * tests inject a spy so no real AWS is touched. The receiver awaits it inside a * try/catch — a metric failure can never crash the loop (it's also best-effort * inside the default impl). */ export type PublishMetricFn = (metric: SyncLatencyMetric) => Promise; /** Minimal structured logger. Defaults to a no-op (quiet daemon). */ export interface ReceiverLogger { info(obj: Record, msg?: string): void; warn(obj: Record, msg?: string): void; error(obj: Record, msg?: string): void; debug(obj: Record, msg?: string): void; } /** * Lifecycle handle. Mirrors {@link PushTransport} so daemon wiring is * mechanically identical on both sides. */ export interface PushReceiver { /** Open the subscription / poll loop. No-op when the feature flag is OFF. */ start(): Promise; /** Idempotent teardown — stop polling, abort + drain in-flight, disconnect. */ dispose(): Promise; /** Advisory: is the subscription currently believed to be open? */ readonly connected: boolean; } /** * V2 delivery is deliberately not a `PushEvent`: paths, hashes, versions, and * device identifiers are not allowed in a wake. The opaque cursor is a hint * only; the fixed-high-water delta reader remains the correctness source. */ export interface RealtimeWake { readonly contractVersion: 2; readonly eventType: "vault.changed"; readonly scopeHandle: string; readonly cursor: string; } /** Strict wire decoder for the normative v2 delivery envelope. */ export declare function decodeRealtimeWake(raw: string): RealtimeWake; /** * Narrow delivery source for the v2 receiver. Server delivery owns the SQS * subscription and credentials; this client seam deliberately has no route * that can mint them. A source must stop delivering after `dispose`. */ export interface RealtimeWakeSource { start(onMessage: (raw: string) => void): Promise | void; dispose(): Promise | void; } export interface RealtimeWakeReceiverOptions { source: RealtimeWakeSource; onWake: (wake: RealtimeWake) => void; logger?: ReceiverLogger; } /** * Identity-bound v2 delivery receiver. It never compares wake cursors or * dispatches a path operation: every syntactically valid wake means exactly * the same thing, namely set the scope's drain bit. Invalid deliveries are * named diagnostics with no identifiers logged. */ export declare class RealtimeWakeReceiver implements PushReceiver { private readonly source; private readonly onWake; private readonly logger; private _connected; private disposed; private disposePromise; constructor(options: RealtimeWakeReceiverOptions); get connected(): boolean; start(): Promise; dispose(): Promise; } /** * Default `PushReceiver` used when no real queue is wired (or the flag is OFF * at the factory). `start()` flips `connected` true; `dispose()` flips it * false. No subscription work, no events. Mirrors {@link NoopPushTransport}. */ export declare class NoopPushReceiver implements PushReceiver { private _connected; get connected(): boolean; start(): Promise; dispose(): Promise; } /** * Configuration for {@link SqsPushReceiver}. */ export interface SqsPushReceiverOptions { /** * Tenant id this receiver subscribes to. Each instance binds exactly one * tenant — cross-tenant isolation is enforced by the subscription boundary * (this queue belongs to this tenant), not post-hoc filtering. (US-010) */ tenantId: string; /** * The caller's own per-tenant SQS queue URL (minted server-side by the * provisioning Lambda and subscribed to `sync-push-{tenantId}`). The * receiver polls ONLY this URL. */ queueUrl: string; /** The injected SQS client. Tests pass a fake; production an SDK adapter. */ sqs: SqsClientLike; /** * The sync engine that performs the actual targeted pull. The receiver only * invokes this; errors are logged + isolated from the loop. */ /** * Coalesced targeted-pull bridge. New production callers must supply this; * the compatibility `syncFn` is only retained for older embedders. */ syncBatchFn?: SyncBatchEngineFn; /** @deprecated Use `syncBatchFn` so one received batch becomes one pass. */ syncFn?: SyncEngineFn; /** Delete a message immediately after decode when it cannot need a pull. */ shouldProcess?: (event: PushEvent) => boolean; /** * Batch-level observation of decoded events rejected by `shouldProcess`. * Rejected messages have already followed the immediate-delete path; this * hook is diagnostics only and is never a retry/acknowledgement boundary. */ onFilteredEvents?: (events: readonly PushEvent[]) => void; /** * Reports whether the outer SQS receive observed queued work. This is a * scheduling signal only: a true value remains in force until a subsequent * outer long-poll returns empty, so a queued/slow batch cannot look idle * merely because its dispatch is waiting behind another pass. */ onReceiveActivity?: (hasMessages: boolean) => void; /** Structured logger. Default: a no-op (quiet). */ logger?: ReceiverLogger; /** SQS long-poll wait seconds. Default {@link DEFAULT_WAIT_TIME_SECONDS}. */ waitTimeSeconds?: number; /** Max messages per receive. Default {@link DEFAULT_MAX_MESSAGES}. */ maxMessages?: number; /** Per-receive SQS visibility lease. Default: ten minutes. */ visibilityTimeoutSeconds?: number; /** Max time `dispose()` waits for an in-flight syncFn after abort. */ disposeDrainMs?: number; /** Maximum distinct paths retained in dedupe state. */ dedupeMaxPaths?: number; /** Maximum distinct paths folded into one targeted pull. */ batchMaxPaths?: number; /** Maximum raw SQS receipts held while one batch is being formed. */ drainMaxMessages?: number; /** Maximum received drains which may be processing at once. */ maxInFlightDrains?: number; /** Reconnect backoff config. */ reconnect?: { initialMs?: number; maxMs?: number; jitter?: boolean; }; /** * Sleep seam for backoff (tests inject a fast/abortable sleep). Default: * host `setTimeout` that resolves early on abort. */ sleep?: (ms: number, signal: AbortSignal) => Promise; /** * Best-effort latency-metric publish (US-011). Called on the receive-success * path with the measured save→visible latency. Default: * {@link publishSyncLatencyMetric}; tests inject a spy. (AC#1/#3) */ publishMetric?: PublishMetricFn; /** * Clock for latency measurement + metric timestamps. Default * `() => Date.now()`. Tests inject a fake clock to assert the latency value. */ now?: () => number; } /** * Real client `PushReceiver` backed by a per-tenant SQS queue. * * Poll loop: long-poll `receiveMessage` → for each message, decode + dedupe + * dispatch through `syncFn`, then `deleteMessage` on successful handoff. A * `receiveMessage` rejection is treated as a transient disconnect: log, back * off, resume. SQS retention covers offline catch-up; dedupe covers redelivery. */ export declare class SqsPushReceiver implements PushReceiver { private readonly tenantId; private readonly queueUrl; private readonly sqs; private readonly syncBatchFn; /** Legacy per-event seam, retained to preserve the old embedding contract. */ private readonly syncFn; private readonly shouldProcess; private readonly onFilteredEvents; private readonly onReceiveActivity; private readonly logger; private readonly waitTimeSeconds; private readonly maxMessages; private readonly visibilityTimeoutSeconds; private readonly disposeDrainMs; private readonly reconnectInitialMs; private readonly reconnectMaxMs; private readonly reconnectJitter; private readonly sleep; private readonly publishMetric; private readonly now; private readonly batchMaxPaths; private readonly drainMaxMessages; private readonly maxInFlightDrains; private _connected; private disposed; private disposing; private disposePromise; /** Abort signal shared by the poll loop + in-flight sync; fired on dispose. */ private loopAbort; /** The running poll loop promise; awaited (best-effort) during dispose. */ private loopPromise; /** Every admitted drain owns one controller until it reaches a terminal outcome. */ private readonly inFlightAborts; private readonly inFlightDrains; /** Per-path highest sequence number already PROCESSED by syncFn. */ private readonly seenSequencePerPath; private _processedCount; private _dedupedCount; private _decodeFailureCount; private _receiveErrorCount; constructor(opts: SqsPushReceiverOptions); get connected(): boolean; start(): Promise; dispose(): Promise; /** Events that passed dedupe AND completed `syncFn` successfully. */ get processedCount(): number; /** Events skipped by dedupe. */ get dedupedCount(): number; /** Events dropped at the wire-boundary decode step. */ get decodeFailureCount(): number; /** `receiveMessage` failures (transient disconnects) the loop recovered from. */ get receiveErrorCount(): number; /** * The long-poll loop. Runs until the loop abort signal fires (dispose). A * `receiveMessage` rejection is a transient disconnect — log, back off, * resume. Because the SQS queue retains messages, resuming after a blip * replays the gap (catch-up). The loop never throws past this method; it * is fire-and-forgotten by `start()` and awaited best-effort by `dispose()`. */ private pollLoop; /** * Admit a received drain without coupling the next outer long-poll to its * slow lane. The set itself is the bounded lease budget; completion frees a * slot and wakes the poll loop through the raced promise below. */ private startDrain; /** Wait only when the explicit receipt budget is full, never for one pass. */ private waitForDrainSlot; /** * Drain immediately-available messages, then collapse them by relative path * before one sync pass. SQS caps an individual receive at ten messages, so a * hot queue needs several non-blocking receives to form a useful batch. * * `batchMaxPaths` bounds the targeted planner and argv; `drainMaxMessages` * bounds receipt memory. Coalescing happens while draining, so once the * path cap is full no further receive can lease an unservable backlog. */ private handleReceivedMessages; /** * Invoke one coalesced sync pass. The dedupe high-water marks advance only * after it completes, preserving redelivery after any pass failure. */ private dispatchBatch; /** Delete a message, swallowing transport errors (redelivery is harmless). */ private safeDelete; /** Explicitly release an over-cap receipt; failures are loud, never silent. */ private releaseUnselected; /** * Publish one best-effort latency datum (US-011). Awaits `publishMetric` and * swallows any rejection so a metric-backend outage can never reach the poll * loop. Called fire-and-forget (`void`) off the dispatch critical path. */ private emitLatencyMetric; /** Exponential backoff (capped) with optional full-jitter. */ private computeBackoff; /** Log + back off after any failed SQS receive, including a drain confirmation. */ private backoffAfterReceiveFailure; } /** * A tiny in-process fanout the {@link InMemoryPushReceiver} subscribes against. * Models SNS publish + the per-client SQS queue's disconnect buffering so unit * tests can drive the receive path without AWS. `publish` raw strings (the * wire form) so decode-failure paths are testable too. */ export declare class InMemoryFanout { private readonly subscribers; subscribe(handler: (raw: string) => void): () => void; /** Publish a raw (already-encoded) message body to all subscribers. */ publish(raw: string): void; } /** Options for {@link InMemoryPushReceiver}. */ export interface InMemoryPushReceiverOptions { tenantId: string; fanout: InMemoryFanout; syncFn: SyncEngineFn; logger?: ReceiverLogger; disposeDrainMs?: number; /** Maximum distinct paths retained in dedupe state. */ dedupeMaxPaths?: number; } /** * In-memory receiver paired with {@link InMemoryFanout}. Powers the unit * tests for dedupe, reconnect-replay, flag gating, and dispose-drain WITHOUT * any AWS SDK. The dedupe / dispatch / dispose semantics are identical to * {@link SqsPushReceiver} (shared design); the disconnect buffer is the * in-process analogue of the per-client SQS queue's 14-day retention. */ export declare class InMemoryPushReceiver implements PushReceiver { private readonly tenantId; private readonly fanout; private readonly syncFn; private readonly logger; private readonly disposeDrainMs; private _connected; private disposed; private disposing; private disposePromise; private unsubscribe; private disconnectedFlag; private readonly pendingDuringDisconnect; private readonly seenSequencePerPath; private inFlightAbort; private inFlightSync; private _processedCount; private _dedupedCount; private _decodeFailureCount; constructor(opts: InMemoryPushReceiverOptions); get connected(): boolean; start(): Promise; dispose(): Promise; get processedCount(): number; get dedupedCount(): number; get decodeFailureCount(): number; get bufferedCount(): number; /** Emulate a network blip — events buffer instead of dispatching. */ simulateDisconnect(): void; /** Emulate reconnect — drain the buffer through the same dedupe path. */ simulateReconnect(): void; private dispatch; } /** * Build a PushReceiver. The daemon defaults to the noop so wiring is * regression-safe — production deployments wire the SQS impl explicitly once * the server provisioning Lambda mints a queue URL. Mirrors PushTransport's * noop-default opt-in posture. */ export type CreatePushReceiverOptions = (SqsPushReceiverOptions & { kind?: "sqs"; }) | { kind: "noop"; }; export declare function createPushReceiver(opts: CreatePushReceiverOptions): PushReceiver; //# sourceMappingURL=push-receiver.d.ts.map