/** * Polling-based change notification. * * Replaces SSE with a simple version counter. Each DB mutation (app-state, * settings, resources) increments the version. Clients poll `/_agent-native/poll?since=N` * and receive any events that occurred after version N. * * Works in all deployment environments (serverless, edge, long-lived). * * Also detects cross-process DB writes by periodically checking the * application_state and settings tables' updated_at timestamps. This ensures * that changes made by external processes (e.g., CLI actions, cron jobs) * are picked up even though they don't call recordChange() in this process. * * All change-tracking state lives on an {@link AppSyncState} instance rather * than in module-level singletons, so a single process can hold one isolated * instance per app (the hosted Realtime Gateway serves many apps at once). The * module-level exports below delegate to a lazily-created default instance * bound to the process-global DB, so self-hosted apps run exactly one code * path with no behavioral change. */ import { EventEmitter } from "node:events"; import { type DbExec } from "../db/client.js"; export interface ChangeEvent { version: number; source: string; type: string; key?: string; /** * Owner email for tenant-scoped events. When absent, the event is treated * as deployment-global (e.g. table-level "something changed" pings) and * delivered to every authenticated poller. Specific events that should * only fan out to one user MUST set this — otherwise polling clients * across tenants see each other's signals. */ owner?: string; /** Optional org ID for org-scoped events. */ orgId?: string; /** * Shareable resource type this event belongs to (e.g. "document"). When * present together with `resourceId`, the per-user delivery filter * (`canSeeChangeForUser`) can run an access-aware check so non-owner sharees * with explicit viewer+ access receive the push instead of only the poll * fallback. See the SYNC-CACHE note above `canSeeChangeForUser`. */ resourceType?: string; /** * Shareable resource id this event belongs to. Paired with `resourceType` * to drive the access-aware delivery check in `canSeeChangeForUser`. */ resourceId?: string; [k: string]: unknown; } export declare const DURABLE_LEGACY_DB_CHECK_INTERVAL_MS = 30000; export declare const POLL_CHANGE_EVENT = "poll-change"; export type ChangeReadResult = { version: number; events: ChangeEvent[]; /** * True when the returned version is an intentional cursor stop, not the * source high-water mark. This happens when access is still pending or when a * durable page hit the read limit and more rows may remain unread. */ cursorLimited?: boolean; }; /** * Resolve whether `userEmail`/`orgId` may access a shareable resource. Injected * so the hosted gateway can bind the check to a specific app's resource * registry + DB; the default resolves the framework's process-global registry * lazily to avoid a load-order/circular-import hazard (poll.ts is imported very * widely and the sharing module pulls in the resource registry). */ export type AccessResolver = (resourceType: string, resourceId: string, ctx: { userEmail: string; orgId: string | undefined; }) => Promise; export interface AppSyncStateOptions { /** * Per-app DB accessor. Called per query (not memoized here) so a mocked or * hot-swapped exec is always honored. Defaults to the process-global * `getDbExec`. */ getDb?: () => DbExec; /** Whether this app's DB is Postgres. Defaults to the process-global check. */ isPostgres?: () => boolean; /** Access-aware delivery resolver. Defaults to the framework registry. */ resolveAccess?: AccessResolver; /** * Derive durable-event ids deterministically from the event's logical * identity plus a caller-supplied dedupe signal (the source row's * `updated_at`), instead of the default `version-` id. * * Off by default: a single-process app has one writer, so random ids are * fine and `ON CONFLICT (id) DO NOTHING` never needs to fire. The hosted * gateway sets this so two instances that independently detect the same * out-of-band write persist the SAME id and dedupe to one row. The signal * intentionally excludes `version` (which is per-instance) so it collides * across instances. */ deterministicEventIds?: boolean; /** * Allocate durable-event versions from the app's Postgres DB (a one-row * allocator advanced with `GREATEST(v + 1, epoch_ms_now)` inside the insert * statement) instead of the per-writer in-memory clock counter. * * Off by default: a single-writer app's clock counter is already monotonic. * Hosted realtime has MULTIPLE writers per app DB (the app's serverless * instances plus gateway instances), where clock skew can assign a LOWER * version to a LATER event — a client whose cursor passed the higher value * then filters the later event out forever. DB allocation serializes on the * allocator row's lock, so version order equals commit order across all * writers. Versions stay on the epoch-ms scale (existing cursors, the * detector's timestamp-mixed seed, and lag metrics all assume it). * Postgres only; ignored on SQLite. */ dbAssignedVersions?: boolean; } /** * Per-app change-tracking state and read path. One instance per deployed app. * The framework runs a single default instance ({@link getDefaultAppSyncState}) * bound to the process DB; the hosted Realtime Gateway constructs one instance * per app with that app's pooled Neon connection injected. */ export declare class AppSyncState { private readonly getDb; private readonly isPg; private readonly resolveAccessFn; private readonly deterministicEventIds; private readonly dbAssignedVersions; /** Serializes gated recordChange calls so buffer/emit order matches * allocation order (interleaved allocations could buffer out of order). */ private recordChain; /** Count of DB-allocation failures that fell back to clock versions. */ private dbVersionFallbacks; private warnedListenerThrow; private version; private readonly buffer; private readonly pollEmitter; private syncEventsInitPromise; private lastDurablePrune; /** * Whether we've seeded `version` from the DB. In serverless (Netlify, * Vercel, etc.) each invocation starts fresh — without seeding, `version` * resets to 0 and polling clients see the version jump backwards, causing * duplicate events and stuck UI. */ private versionSeeded; private lastDbCheck; private checkPromise; private lastAppStateTs; private lastSettingsTs; private lastExtensionsTs; private lastExtensionsUpdatedAt; private lastExtensionMarkerTs; private lastActionMarkerTs; /** * Tracks the latest updated_at seen on the `__screen_refresh__` key. * `screenRefreshInitialized` guards against spurious emits on the first poll * after a restart (where an existing row would look like a fresh bump). */ private lastScreenRefreshTs; private screenRefreshInitialized; private readonly lastScreenRefreshTsBySession; private localEmittersWired; /** * TTL'd access cache for the access-aware branch of `canSeeChangeForUser`, * keyed `${userEmail}|${resourceType}|${resourceId}`. Insertion order doubles * as FIFO for eviction (JS Maps preserve insertion order). Held per-instance * so one gateway process serving many apps never leaks one app's cached * access decision into another app's delivery filter. */ private readonly accessCache; /** In-flight background access checks, keyed identically, to dedupe bursts. */ private readonly accessInFlight; /** Per-resource generation bumped when shares/visibility change. */ private readonly accessInvalidationEpoch; constructor(options?: AppSyncStateOptions); /** * Durable-event id. Deterministic (hash of logical identity + `dedupeKey`, * excluding the per-instance `version`) when the instance opts in AND a * dedupe signal is supplied — that combination lets `ON CONFLICT (id) DO * NOTHING` collapse the same out-of-band write detected by multiple gateway * instances into one row. Otherwise the historical `version-` id, * which is unique-per-write for a single-writer app. */ private durableEventId; /** Get the current version counter. */ getVersion(): number; getPollEmitter(): EventEmitter; /** * Wire the in-process app-state/settings emitters into `recordChange` so * same-process writes surface on the poll/SSE fast path. Idempotent. Only the * default (in-process) instance calls this — the gateway learns of changes by * tailing the DB, not from these process-global emitters. */ wireLocalEmitters(): void; ensureSyncEventsTable(): Promise; private pruneDurableEvents; persistSyncEvent(event: ChangeEvent, dedupeKey?: string, presetId?: string): Promise; /** * Persist with a DB-allocated version (see ALLOCATING_INSERT_SQL). Returns * the allocated version — for a deterministic-id dedupe loser, the winner's * existing version — or null when persistence is unavailable (caller falls * back to clock allocation). Unlike `persistSyncEvent`, errors PROPAGATE to * the caller: silence here would silently drop the event entirely, since the * buffer/emit are deferred behind this call. */ private persistWithDbAssignedVersion; /** Read back the version of an already-committed row (a failed allocating * call whose statement actually committed). Null when absent/unreachable. */ private recoverCommittedVersion; /** * Lift the allocator to at least `floor`. Client cursors are max-only, so * any value that can become a cursor (notably the seed's app-clock * `updated_at` domain) must never exceed the allocator — a cursor above it * would filter later, lower-allocated events out permanently. Failure is * swallowed: alignment degrades to the same soft guarantee as the clock * fallback. */ private alignVersionAllocator; readMaxSyncEventVersion(): Promise; /** * Oldest retained durable version. Used to detect a reconnect cursor that * predates the 24h retention window so the gateway can signal a full * resync instead of silently dropping events. One indexed aggregate over * `sync_events_version_idx`; no schema change. */ readMinSyncEventVersion(): Promise; invalidateCollabAccessCache(resourceType: string, resourceId: string): void; private setAccessCache; /** * Fire a background access check for a cache-miss key. Never awaited by the * caller — the current event is NOT delivered (we returned false), but the * result is cached so the user's NEXT event within the TTL is pushed. Dedupes * concurrent checks for the same key via `accessInFlight`. */ private scheduleAccessCheck; /** * Test-only: clear the access cache and in-flight set so cases don't bleed * into each other. Intentionally NOT part of the public API. */ __resetAccessCacheForTests(): void; /** * Decide whether a poll/SSE change event should be delivered to a user. * * SYNC-CACHE VARIANT — WHY THIS IS SYNCHRONOUS: * This function is called on hot, synchronous paths: the SSE emitter callback * `push(change)` in poll-events.ts (fires per event) and the * `getChangesSinceForUser` loop in this file. Making it async would be * invasive. Instead, for the access-aware branch we consult an in-memory * cache and, on a miss, fire a NON-BLOCKING background access check and * return `false` for the current event. Because the poll fallback re-evaluates * with the now-populated cache, delivery is eventually guaranteed — the only * cost is that the very first event for a fresh (user, resource) pair goes * over poll instead of push, and every subsequent event within the TTL is * pushed. * * Security: a cache MISS returns `false`, so we NEVER deliver to a user before * their access has been affirmatively confirmed by the resolver — the same * authority that gates the HTTP routes. Errors fail closed (cached deny). The * owner/org fast paths below are unchanged and evaluated first. */ canSeeChangeForUser(event: Pick, userEmail: string, orgId: string | undefined): boolean; private getChangeVisibilityForUser; /** * Record a change event. Called by emitter listeners and the tail detector. * `dedupeKey` is a stable cross-instance signal (a source row's `updated_at`) * used only when the instance derives deterministic ids; it is a persistence * concern and never stored on the event object. */ recordChange(event: { source: string; type: string; key?: string; [k: string]: unknown; }, opts?: { dedupeKey?: string; }): void; /** Buffer + emit. Shared by both version-allocation modes. */ private commitEntry; private recordWithDbVersion; /** * `commitEntry` for the deferred (chained) path: the buffer push must land * and a throwing pollEmitter listener must not reject the chain — the * synchronous path propagates such throws to the recordChange caller, but * here there is no caller to propagate to, only the chain to poison. */ private commitEntryForChain; private recordExtensionChanges; private recordActionChanges; /** Get all changes after a given version. */ getChangesSince(since: number): { version: number; events: ChangeEvent[]; }; /** * Get changes after a given version, filtered to events the caller is * allowed to see. */ getChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined): ChangeReadResult; getDurableChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined): Promise; getCombinedChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined, useDurableEvents: boolean): Promise<{ version: number; events: ChangeEvent[]; }>; /** * Seed `version` from DB timestamps on the first call so serverless cold * starts don't return version 0 and confuse polling clients. */ seedVersionFromDb(): Promise; /** * Check for cross-process DB writes by comparing updated_at timestamps. * Throttled per instance. */ checkExternalDbChanges(options: { durableEvents: boolean; }): Promise; private doCheckExternalDbChanges; } /** * The process-wide default instance, bound to the global DB. All module-level * exports delegate here so self-hosted apps run exactly one code path. */ export declare function getDefaultAppSyncState(): AppSyncState; /** Get the current global version counter. */ export declare function getVersion(): number; export declare function getPollEmitter(): EventEmitter; export declare function invalidateCollabAccessCache(resourceType: string, resourceId: string): void; /** * Test-only: clear the default instance's access cache. Underscore-prefixed and * intentionally NOT part of the public API — do not rely on it outside tests. */ export declare function __resetCollabAccessCacheForTests(): void; export declare function canSeeChangeForUser(event: Pick, userEmail: string, orgId: string | undefined): boolean; /** Record a change event. Called by emitter listeners. */ export declare function recordChange(event: { source: string; type: string; key?: string; [k: string]: unknown; }): void; /** Get all changes after a given version. */ export declare function getChangesSince(since: number): { version: number; events: ChangeEvent[]; }; /** * Get changes after a given version, filtered to events the caller is * allowed to see. */ export declare function getChangesSinceForUser(since: number, userEmail: string, orgId: string | undefined): ChangeReadResult; /** * Create an H3 handler for the poll endpoint. * * GET /_agent-native/poll?since=N → { version, events[] } * * Requires an authenticated session. Events are filtered to the caller's * tenant — global events (owner-less, table-level pings) reach every * authenticated caller; owned events reach only the matching user/org. * Without auth + filtering, an anonymous attacker could poll the deployment * and infer cross-tenant activity from the global event stream. */ export declare function createPollHandler(state?: AppSyncState): import("h3").EventHandlerWithFetch>; //# sourceMappingURL=poll.d.ts.map