import type { LiveQueryEvent, QueryRow } from "@/lib/types"; /** A live stream row; `key` is a client-side monotonic counter (SSE frames carry no id). */ export interface LiveRow extends LiveQueryEvent { key: number; } export const RING_CAPACITY = 500; /** Prepend `row` (rows are newest-first) and drop the oldest beyond `capacity`. */ export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_CAPACITY): LiveRow[] { const next = [row, ...rows]; return next.length > capacity ? next.slice(0, capacity) : next; } // `since` on GET /api/queries is inclusive, so the re-sync fetch returns the // last-seen row(s) again; live rows have no id, so identity is this tuple. function signature(row: LiveQueryEvent): string { return `${row.ts}|${row.domain}|${row.client_ip}|${row.qtype ?? -1}|${row.blocked}|${row.upstream}`; } /** * Merge rows fetched for a reconnect gap (newest-first, from GET /api/queries) * into the buffer. Rows already present are skipped; `missed` counts what was * actually added. The result stays newest-first (stable sort by ts) and capped. */ export function mergeGap( rows: LiveRow[], fetched: QueryRow[], nextKey: () => number, capacity: number = RING_CAPACITY, ): { rows: LiveRow[]; missed: number } { const seen = new Set(rows.map(signature)); const added: LiveRow[] = []; for (const row of fetched) { const event: LiveQueryEvent = { ts: row.ts, domain: row.domain, client_ip: row.client_ip, qtype: row.qtype, blocked: row.blocked, block_reason: row.block_reason, response_time_us: row.response_time_us, cache_hit: row.cache_hit, upstream: row.upstream, }; if (seen.has(signature(event))) continue; added.push({ ...event, key: nextKey() }); } if (added.length === 0) return { rows, missed: 0 }; const merged = [...added, ...rows].sort((a, b) => b.ts - a.ts).slice(0, capacity); return { rows: merged, missed: added.length }; }