import type { Pool } from 'pg'; import type { Logger } from 'pino'; /** * The port the collab hooks use to advertise which pages have a live editing * session (issue #40). The api's compaction job reads the backing table to * skip pages that are being edited, so it never fights the collab writer. */ export interface SessionRegistry { /** Record that a page now has an open session (called when its doc loads). */ markOpen(pageId: string): void; /** Record that a page's session ended (called when its doc unloads). */ markClosed(pageId: string): void; } export interface SessionRegistryDeps { pool: Pool; logger: Logger; /** * How often the heartbeat refreshes the `heartbeat_at` of every open page, * so the api can tell a still-open session from one whose collab process * crashed without unloading (its row simply ages out). Overridable for tests. */ heartbeatMs?: number; } export interface StartableSessionRegistry extends SessionRegistry { /** * Begin the periodic heartbeat. `openDocumentNames` returns the page ids the * collab server currently holds open; taken here rather than in the * constructor so the registry can be created before the server it reads from. */ start(openDocumentNames: () => string[]): void; /** Stop the heartbeat. Idempotent. */ stop(): void; } const DEFAULT_HEARTBEAT_MS = 30_000; /** * PostgreSQL-backed {@link SessionRegistry} (issue #40, ADR 0013 §compaction). * * Each open page keeps a row in `collab_open_sessions` whose `heartbeat_at` is * refreshed on a timer. The compaction job treats a page as actively edited * while its heartbeat is fresh and ignores it otherwise, so a crashed collab * process cannot block compaction forever — the stale rows just age out of the * freshness window. Writes are best-effort: a failed heartbeat only risks a * page being compacted a run later, never data loss. */ export class PostgresSessionRegistry implements StartableSessionRegistry { private readonly heartbeatMs: number; private timer: NodeJS.Timeout | undefined; private openDocumentNames: () => string[] = () => []; constructor(private readonly deps: SessionRegistryDeps) { this.heartbeatMs = deps.heartbeatMs ?? DEFAULT_HEARTBEAT_MS; } markOpen(pageId: string): void { void this.deps.pool .query( `INSERT INTO collab_open_sessions (page_id, heartbeat_at) VALUES ($1, now()) ON CONFLICT (page_id) DO UPDATE SET heartbeat_at = now()`, [pageId], ) .catch((error: unknown) => { this.deps.logger.warn( { event: 'session.mark_open.failed', pageId, err: (error as Error).message }, 'could not record open collab session', ); }); } markClosed(pageId: string): void { void this.deps.pool .query('DELETE FROM collab_open_sessions WHERE page_id = $1', [pageId]) .catch((error: unknown) => { this.deps.logger.warn( { event: 'session.mark_closed.failed', pageId, err: (error as Error).message }, 'could not clear open collab session', ); }); } start(openDocumentNames: () => string[]): void { this.openDocumentNames = openDocumentNames; if (this.timer) return; this.timer = setInterval(() => this.beat(), this.heartbeatMs); // Don't keep the process alive just for the heartbeat. this.timer.unref?.(); } stop(): void { if (this.timer) { clearInterval(this.timer); this.timer = undefined; } } private beat(): void { const open = this.openDocumentNames(); if (open.length === 0) return; void this.deps.pool .query( `UPDATE collab_open_sessions SET heartbeat_at = now() WHERE page_id = ANY($1::text[])`, [open], ) .catch((error: unknown) => { this.deps.logger.warn( { event: 'session.heartbeat.failed', err: (error as Error).message }, 'collab session heartbeat failed', ); }); } }