/** * Ordered, replayable per-channel message history. * * Broadcast on its own is fire-and-forget to whoever is connected at the * instant it is sent: fine for presence and for "someone saved" notifications, * not enough for op-based collaborative editing, where a client that blinks * out for two seconds has to resync a whole document rather than catch up on * the four operations it missed. This adds the missing half — every retained * broadcast gets a per-channel sequence number, and a client can ask for * everything after the last one it saw. * * Three decisions worth stating, because each rules out a simpler-looking one: * * - **Retention is server-side and opt-in.** A channel is created by whoever * names it, so a client-supplied history depth would let any visitor commit * the backend to unbounded storage. And presence channels — the common case * — must not pay for this: with no rules configured nothing is written, no * table is created, and `broadcast` runs exactly the code it ran before. * * - **Sequence numbers come from the database, not from a counter in this * process.** They have to survive a restart and be shared across instances; * an in-memory counter would restart at 1 after a deploy and hand a * reconnecting client a replay from the wrong era, silently. * * - **The cursor row outlives the messages it numbered.** Pruning is what * makes retention affordable, but pruning the cursor along with the messages * would restart the sequence and make `sinceSeq` mean something different * before and after — the worst kind of bug, because replay would still * return rows and they would look plausible. Cursors are tiny and are kept * forever; see {@link prune}, which touches only `channel_messages`. */ import { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types"; /** * Parse a retention TTL into milliseconds. * * Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`, * `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the * caller treats as "no TTL" — a misspelt duration must not silently become an * aggressive one. */ export declare function parseTtlMs(ttl: number | string | undefined): number | undefined; /** * Whether `channel` is covered by `rule`. * * Exact match, or a trailing `*` acting as a prefix. Not a general glob: this * decides what reaches disk, and a pattern language whose reach is not obvious * at a glance is the wrong tool for that job. */ export declare function channelMatchesRule(channel: string, rule: ChannelRetentionRule): boolean; /** A rule with its TTL already resolved to milliseconds. */ export interface ResolvedRetention { limit?: number; ttlMs?: number; } /** * Persistence and replay for retained channels. * * Inert unless constructed with at least one rule: {@link enabled} is false, * {@link ensureTables} does nothing, and {@link retentionFor} answers undefined * for every channel, so the realtime service never reaches the SQL below. */ export declare class ChannelHistoryStore { private db; private rules; /** Resolved rule per channel name, so the match runs once per channel. */ private resolved; /** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */ private lastPruned; private tablesReady; constructor(db: NodePgDatabase>, rules?: ChannelRetentionRule[]); /** Whether any channel retains anything at all. */ get enabled(): boolean; /** * The retention that applies to `channel`, or undefined when none does. * * First matching rule wins, so callers order them most-specific first. */ retentionFor(channel: string): ResolvedRetention | undefined; /** * Create the history tables. Idempotent, and a no-op when no rule is set — * a deployment that never retains anything gets no schema for it. */ ensureTables(): Promise; /** * Append a broadcast and return the sequence number it was given. * * The sequence is allocated by the same statement that stores the message, * so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE` * takes a row lock on the channel's cursor, which is what makes concurrent * broadcasts to one channel line up in a single order — and what keeps * different channels from contending with each other at all. */ append(channel: string, event: string, payload: unknown, senderId?: string): Promise<{ seq: number; at: string; }>; /** * Everything retained for `channel` after `sinceSeq`, oldest first. * * `latestSeq` is reported whether or not the messages were capped, so a * client that is further behind than one page can tell. */ replay(channel: string, sinceSeq?: number, limit?: number): Promise<{ messages: ChannelHistoryEntry[]; latestSeq: number; }>; /** * One retained message by its address. * * This is what makes the cross-instance pointer path work: a broadcast too * large to travel inside a `pg_notify` payload is already stored here, so * the notification carries `(channel, seq)` and each receiving instance * reads the body back. Returns null when the message has since been pruned * — a receiver that is that far behind has nothing useful to deliver, and * the client's own `channel_history` replay is the repair path. */ getBySeq(channel: string, seq: number): Promise; /** * Enforce a channel's retention bounds. * * Throttled per channel, so a burst of operations prunes once rather than * once per message — the cost then tracks elapsed time instead of write * volume, which is what makes retention affordable on a hot channel. */ prune(channel: string, retention: ResolvedRetention): Promise; /** Forget throttle and match caches. Called on shutdown. */ clear(): void; }