From 6783967c194762751022a110a37c9fff5e083126 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:15:01 +0300 Subject: [PATCH] Compact --- app/features/scanner/README.md | 16 +- .../scanner/components/ExportMenu.tsx | 23 ++- app/features/scanner/components/LiveView.tsx | 7 +- .../scanner/components/PastSessionView.tsx | 28 ++- .../scanner/components/SessionView.tsx | 28 +-- app/features/scanner/components/VodView.tsx | 17 ++ .../scanner/components/events-feed.ts | 159 ++++++++++++++++-- .../scanner/components/live-session.ts | 15 +- .../scanner/components/sendou-ingest.ts | 79 ++++----- app/features/scanner/components/upload.ts | 122 +++++++++++--- app/features/scanner/core/sessions.ts | 88 +++++++++- .../store/compacted-matches.browser.test.ts | 135 +++++++++++++++ .../scanner/store/compacted-matches.ts | 122 ++++++++++++++ app/features/scanner/store/db.ts | 14 +- app/features/scanner/store/events.ts | 39 +---- .../scanner/tests/logic/sessions.test.ts | 76 +++++++++ 16 files changed, 797 insertions(+), 171 deletions(-) create mode 100644 app/features/scanner/store/compacted-matches.browser.test.ts create mode 100644 app/features/scanner/store/compacted-matches.ts diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 464db2959..afbc8d013 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -48,7 +48,21 @@ opens it, for anyone, through the same handoff Inspect uses. so events from different page loads share one timeline; the ring buffer stamps footage the same way. - **Retention** (`store/events.ts`, on a throttled pass at every save): - whole sessions older than 30 days or beyond the newest 20 go. Full-res + whole sessions older than 30 days, beyond the newest 20 or past the + `MAX_STORED_EVENTS` budget (~140 SZ games; the newest session is never + cut) go — never part of one, so a kept session's cards always expand to + their full timeline and scoreboards. The feed and live sends re-read only + the newest session (`refreshFeed`/`sendLive` take a session key to read + from), so the store's size costs nothing per saved event. +- **Compaction** (`events-feed.ts` on refresh, `store/compacted-matches.ts`): + 72 h after a session ends its games are frozen as built, one record each, + keeping every source event except the per-second Objective/PlayerStatus/ + StripWeapons reads (`compactSources`; ~85% of a game's events, ~6× its + bytes), and its raw events are deleted in the same transaction. Cards, + uploads (`sendCompacted`) and the debug tab read a compacted session like + a raw one, but it no longer picks up match builder fixes and its + `Raw detections` CSV option is disabled. Compacted sessions share the + 30-day / 20-session retention with the raw ones. Full-res frames (and the thumbnails made from them) are only captured in debug mode — the worker skips the PNG encode otherwise (`attachFrames`) — and are bounded by 72 h and `MAX_FRAMES`, the event staying with diff --git a/app/features/scanner/components/ExportMenu.tsx b/app/features/scanner/components/ExportMenu.tsx index 84e715321..3c6d623c8 100644 --- a/app/features/scanner/components/ExportMenu.tsx +++ b/app/features/scanner/components/ExportMenu.tsx @@ -1,7 +1,8 @@ /** * `⇩ CSV` in a session header: `Matches` (one row per game, the rows the * cards render) or `Raw detections` (one row per event), for this session - * or file. Settings has the same two shapes over everything. + * or file. A compacted session no longer has its raw detections, so that + * option is disabled rather than exporting the few events compaction kept. */ import { Download, FileText, ListTree } from "lucide-react"; import { SendouButton } from "~/components/elements/Button"; @@ -9,9 +10,12 @@ import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; import { eventsToCsv } from "../core/csv/events"; import { type MatchCsvSource, matchesToCsv } from "../core/csv/matches"; import type { BuiltMatch } from "../core/match-builder"; +import { SESSION_COMPACT_AFTER_MS } from "../core/sessions"; import { downloadCsv } from "./download"; import type { ScanEvent } from "./session-data"; +const RAW_KEPT_DAYS = SESSION_COMPACT_AFTER_MS / (24 * 60 * 60 * 1000); + export function ExportMenu({ built, events, @@ -21,7 +25,8 @@ export function ExportMenu({ }: { /** chronological */ built: readonly BuiltMatch[]; - events: readonly ScanEvent[]; + /** null once the session is compacted */ + events: readonly ScanEvent[] | null; source: MatchCsvSource; clipCounts: readonly number[]; /** `scanner-matches-2026-09-16` / `sws26-finals` — the download's stem */ @@ -34,7 +39,7 @@ export function ExportMenu({ variant="minimal" size="small" icon={} - isDisabled={events.length === 0} + isDisabled={built.length === 0 && !events?.length} > CSV @@ -58,17 +63,21 @@ export function ExportMenu({ } - onAction={() => + isDisabled={events === null} + onAction={() => { + if (!events) return; downloadCsv( `${fileBase}-events.csv`, eventsToCsv( events.toSorted((a, b) => a.t - b.t), source.originT, ), - ) - } + ); + }} > - Raw detections + {events === null + ? `Raw detections (removed ${RAW_KEPT_DAYS} days after the session)` + : "Raw detections"} ); diff --git a/app/features/scanner/components/LiveView.tsx b/app/features/scanner/components/LiveView.tsx index 0b05ca24f..1206f2d6e 100644 --- a/app/features/scanner/components/LiveView.tsx +++ b/app/features/scanner/components/LiveView.tsx @@ -14,7 +14,7 @@ import { loadEventFrame } from "../store/events"; import { useClips } from "./clips-feed"; import { EventFeed } from "./EventFeed"; import { ExportMenu } from "./ExportMenu"; -import { currentSession, useFeed } from "./events-feed"; +import { currentSession, newestSessionKey, useFeed } from "./events-feed"; import styles from "./LiveView.module.css"; import { saveCurrentFrameAsFixture, @@ -87,6 +87,7 @@ export function LiveView() { return ( { const id = built.sources[0]?.id; - if (id !== undefined) void sendLive(matchContaining(id)); + if (id !== undefined) { + void sendLive(matchContaining(id), newestSessionKey()); + } }} getFrame={frameLoader} emptyText="Play a game — it shows up here once its results screen is read." diff --git a/app/features/scanner/components/PastSessionView.tsx b/app/features/scanner/components/PastSessionView.tsx index f922d4096..a66efcad0 100644 --- a/app/features/scanner/components/PastSessionView.tsx +++ b/app/features/scanner/components/PastSessionView.tsx @@ -9,6 +9,7 @@ import { useSearchParamsTyped, } from "~/modules/search-params/hooks"; import { scannerSearchParams } from "../scanner-search-params"; +import { deleteCompactedSessions } from "../store/compacted-matches"; import { deleteEvents, loadEventFrame } from "../store/events"; import { useClips } from "./clips-feed"; import { ExportMenu } from "./ExportMenu"; @@ -20,7 +21,7 @@ import { SessionView } from "./SessionView"; import { sessionLabel } from "./SettingsPopover"; import { matchContaining } from "./sendou-ingest"; import type { ScanEvent } from "./session-data"; -import { sendLive } from "./upload"; +import { sendCompacted, sendLive } from "./upload"; export function PastSessionView() { const [id] = useSearchParam(scannerSearchParams, "id"); @@ -43,18 +44,23 @@ export function PastSessionView() { ); const remove = async () => { - await deleteEvents( - session.events - .map((event) => event.id) - .filter((eventId): eventId is number => eventId !== undefined), - ); - refreshFeed(); + if (session.compacted) { + await deleteCompactedSessions([session.key]); + } else { + await deleteEvents( + session.events + .map((event) => event.id) + .filter((eventId): eventId is number => eventId !== undefined), + ); + } + refreshFeed(session.key); setParams({ view: "home" }); }; return ( { const eventId = built.sources[0]?.id; - if (eventId !== undefined) void sendLive(matchContaining(eventId)); + if (eventId === undefined) return; + void (session.compacted ? sendCompacted : sendLive)( + matchContaining(eventId), + session.key, + ); }} getFrame={frameLoader} emptyText="No games were read in this session." @@ -73,7 +83,7 @@ export function PastSessionView() { <> = new Set(); -/** - * Builds keyed by the events array: views re-render for reasons other than new - * events (clips, upload state), and reusing the same `BuiltMatch` objects lets - * the unchanged cards skip rendering. - */ -const builtCache = new WeakMap[]>(); - type LobbyGroup = "private" | "x" | "other"; const LOBBY_GROUPS: LobbyGroup[] = ["private", "x", "other"]; @@ -70,6 +59,7 @@ export interface SessionInfo { export function SessionView({ kind, + built, events, originT, clips, @@ -83,6 +73,11 @@ export function SessionView({ children, }: { kind: SessionKind; + /** + * `events` built into matches, chronological. Reusing the same `BuiltMatch` + * objects across renders lets the unchanged cards skip rendering. + */ + built: BuiltMatch[]; /** chronological */ events: readonly ScanEvent[]; /** stream/file second positions count from */ @@ -104,7 +99,6 @@ export function SessionView({ const debug = useDebug(); const [playing, setPlaying] = useState(null); - const built = cachedBuild(events); const skipReasons = ingestSkipReasons(built); const clipsByMatch = built.map((b, index) => clipsOf(b.match, built[index + 1]?.match, clips), @@ -244,14 +238,6 @@ function MatchList({ ); } -function cachedBuild(events: readonly ScanEvent[]): BuiltMatch[] { - const cached = builtCache.get(events); - if (cached) return cached; - const built = buildScannerMatches(events); - builtCache.set(events, built); - return built; -} - function lobbyGroup(lobby: ScannerLobby | null): LobbyGroup { if (lobby === "PRIVATE") return "private"; if (lobby === "X") return "x"; diff --git a/app/features/scanner/components/VodView.tsx b/app/features/scanner/components/VodView.tsx index 1896ddcc0..326c698a4 100644 --- a/app/features/scanner/components/VodView.tsx +++ b/app/features/scanner/components/VodView.tsx @@ -18,6 +18,7 @@ import { } from "~/modules/search-params/hooks"; import type { ScanTelemetry } from "../core/detectors/telemetry"; import { formatTime } from "../core/format"; +import { type BuiltMatch, buildScannerMatches } from "../core/match-builder"; import { scannerSearchParams } from "../scanner-search-params"; import { deleteVodClips } from "../store/clips"; import { @@ -49,6 +50,13 @@ import { } from "./vod-scan"; import { refreshVods } from "./vods-feed"; +/** + * Builds keyed by the events array: views re-render for reasons other than new + * events (clips, upload state), and reusing the same `BuiltMatch` objects lets + * the unchanged cards skip rendering. + */ +const builtCache = new WeakMap[]>(); + export function VodView() { const [name] = useSearchParam(scannerSearchParams, "name"); const scan = useVodScan(); @@ -236,6 +244,7 @@ function VodSessionView({ return ( ); } + +function cachedBuild(events: readonly ScanEvent[]): BuiltMatch[] { + const cached = builtCache.get(events); + if (cached) return cached; + const built = buildScannerMatches(events); + builtCache.set(events, built); + return built; +} diff --git a/app/features/scanner/components/events-feed.ts b/app/features/scanner/components/events-feed.ts index e8b28d09c..310d1c4a7 100644 --- a/app/features/scanner/components/events-feed.ts +++ b/app/features/scanner/components/events-feed.ts @@ -3,26 +3,43 @@ * sessions (core/sessions.ts) with each session's matches built once per * refresh rather than per render. Every saved event asks for a refresh, ~2-3 * a second during a match; requests landing while one runs coalesce into a - * single trailing pass. + * single trailing pass. A refresh re-reads only the newest session unless + * told otherwise: the store holds weeks of sessions, and only a send status + * write or a delete changes an older one. Refreshes also compact the sessions + * that ended `SESSION_COMPACT_AFTER_MS` ago (store/compacted-matches.ts) and + * apply retention to the compacted ones. */ import { useSyncExternalStore } from "react"; +import * as R from "remeda"; import { type BuiltMatch, buildScannerMatches, invalidObjectiveEvents, } from "../core/match-builder"; import { + compactSources, + expiredCompactedSessionKeys, + SESSION_COMPACT_AFTER_MS, SESSION_GAP_MS, type SessionSummary, sessionKey, sessionSummary, splitSessions, } from "../core/sessions"; +import { + type CompactedMatch, + compactedBuilt, + compactSessions, + deleteCompactedSessions, + listCompactedMatches, +} from "../store/compacted-matches"; import { deleteEvents, listEvents, type StoredEvent } from "../store/events"; export interface LiveSession { /** the first event's detection time — the URL id */ key: number; + /** its games are frozen as built and `events` holds only what compaction kept */ + compacted: boolean; /** oldest first */ events: StoredEvent[]; /** chronological */ @@ -44,39 +61,73 @@ const EMPTY: FeedSnapshot = { loaded: false, sessions: [] }; let snapshot: FeedSnapshot = EMPTY; const listeners = new Set<() => void>(); -const state = { running: false, queued: false }; +let running = false; +/** the earliest `since` requested while a refresh was running */ +let pendingSince: number | null = null; /** an older session's events don't change, so its build is kept */ const buildCache = new Map(); +/** the raw events the snapshot's not yet compacted sessions were built from */ +let rawEvents: StoredEvent[] = []; +/** the compacted sessions, by key */ +const compactedSessions = new Map(); -export function refreshFeed(): void { - if (state.running) { - state.queued = true; - return; - } - state.running = true; +/** + * Re-reads the events detected at or after `since` (a session's key, 0 for + * everything) and keeps the older sessions as they are. Defaults to the newest + * session, the one a capture adds to — everything before the feed first loads. + */ +export function refreshFeed(since = newestSessionKey()): void { + pendingSince = Math.min(pendingSince ?? since, since); + if (running) return; + running = true; void (async () => { try { - do { - state.queued = false; - const events = await listEvents(); - snapshot = { loaded: true, sessions: await toSessions(events) }; + while (pendingSince !== null) { + const from = pendingSince; + pendingSince = null; + const [loaded, loadedCompacted] = await Promise.all([ + listEvents(from), + listCompactedMatches(from), + ]); + const loadedIds = new Set(loaded.map((event) => event.id)); + rawEvents = [ + ...rawEvents.filter( + (event) => event.detectedAt < from && !loadedIds.has(event.id), + ), + ...loaded, + ]; + for (const key of compactedSessions.keys()) { + if (key >= from) compactedSessions.delete(key); + } + addCompactedSessions(loadedCompacted); + snapshot = { loaded: true, sessions: await toSessions() }; for (const listener of listeners) listener(); - } while (state.queued); + } } catch { + pendingSince = null; snapshot = { loaded: true, sessions: snapshot.sessions }; for (const listener of listeners) listener(); } finally { - state.running = false; + running = false; } })(); } +/** + * The newest session's key, 0 before the feed loads. Reading from here always + * covers the session a capture is adding to, even one its latest event just + * started. + */ +export function newestSessionKey(): number { + return snapshot.loaded ? (snapshot.sessions.at(-1)?.key ?? 0) : 0; +} + export function useFeed(): FeedSnapshot { return useSyncExternalStore(subscribe, getFeed, () => EMPTY); } export function getFeed(): FeedSnapshot { - if (!snapshot.loaded && !state.running) refreshFeed(); + if (!snapshot.loaded && !running) refreshFeed(); return snapshot; } @@ -104,7 +155,82 @@ export function findSession( return feed.sessions.find((session) => session.key === key) ?? null; } -async function toSessions(events: StoredEvent[]): Promise { +/** + * Every session oldest first: the raw ones built from `rawEvents`, less those + * this pass compacts, then the compacted ones retention keeps. + */ +async function toSessions(): Promise { + const now = Date.now(); + const raw = await rawSessions(rawEvents); + const ripe = raw.filter( + (session) => now - session.endedAt > SESSION_COMPACT_AFTER_MS, + ); + if (ripe.length > 0) await compact(ripe); + const kept = raw.filter((session) => !ripe.includes(session)); + + const expired = expiredCompactedSessionKeys( + [...compactedSessions.values()], + kept.length, + now, + ); + if (expired.length > 0) { + await deleteCompactedSessions(expired); + for (const key of expired) compactedSessions.delete(key); + } + + return [...compactedSessions.values(), ...kept].sort((a, b) => a.key - b.key); +} + +async function compact(sessions: readonly LiveSession[]): Promise { + const matches: CompactedMatch[] = sessions.flatMap((session) => + session.built.map((built, index) => { + const sources = compactSources(built.sources).map((event) => ({ + ...event, + hasFrame: false, + })); + return { + id: sources[0]!.id!, + session: { + key: session.key, + endedAt: session.endedAt, + originT: session.originT, + }, + index, + match: built.match, + sources, + }; + }), + ); + const eventIds = new Set( + sessions.flatMap((session) => session.events.map((event) => event.id!)), + ); + await compactSessions(matches, [...eventIds]); + rawEvents = rawEvents.filter((event) => !eventIds.has(event.id!)); + addCompactedSessions(matches); +} + +function addCompactedSessions(matches: readonly CompactedMatch[]): void { + for (const games of Object.values( + R.groupBy(matches, (match) => match.session.key), + )) { + const { session } = games[0]; + const built = games + .toSorted((a, b) => a.index - b.index) + .map(compactedBuilt); + compactedSessions.set(session.key, { + key: session.key, + compacted: true, + events: built.flatMap((b) => b.sources).toSorted((a, b) => a.t - b.t), + built, + summary: sessionSummary(built.map((b) => b.match)), + startedAt: session.key, + endedAt: session.endedAt, + originT: session.originT, + }); + } +} + +async function rawSessions(events: StoredEvent[]): Promise { const sessions: LiveSession[] = []; const seen = new Set(); for (const sessionEvents of splitSessions(events)) { @@ -134,6 +260,7 @@ async function toSessions(events: StoredEvent[]): Promise { } const session: LiveSession = { key, + compacted: false, events: sorted, built, summary: sessionSummary(built.map((b) => b.match)), diff --git a/app/features/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index d1a6db9f4..19f21f8ea 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -49,6 +49,7 @@ import { describeError } from "./errors"; import { currentSession, getFeed, + newestSessionKey, refreshFeed, subscribeFeed, } from "./events-feed"; @@ -278,13 +279,18 @@ export async function startCapture({ void sendLive( (built) => retryableUnlinkedMatches(built) || unsentClosedMatches(built), + newestSessionKey(), ); } }, UNLINKED_RETRY_TICK_MS); clipTimer = setInterval(clipTick, CLIP_TICK_MS); audioTimer = setInterval(audioCheck, AUDIO_CHECK_MS); unsubscribeFeed = subscribeFeed(clipTick); - void trimEvents().catch(() => {}); + // a session is only worth coming back to if the browser keeps it + requestPersistentStorage(); + void trimEvents() + .then(() => refreshFeed(0)) + .catch(() => {}); set({ status: "running", since: Date.now(), @@ -309,11 +315,13 @@ export function stopCapture(): void { set({ ...IDLE }); // the scan ending is the last match boundary — flush what's unsent // (partials are safe: the server merges them into fuller resends) - if (uploadEnabled()) void sendLive(unsentMatches); + if (uploadEnabled()) void sendLive(unsentMatches, newestSessionKey()); void rollSessionClipsIntoHistory() .then(() => refreshClips()) .catch(() => {}); - void trimEvents().catch(() => {}); + void trimEvents() + .then(() => refreshFeed(0)) + .catch(() => {}); } /** Debug: the current capture frame as a PNG download. */ @@ -421,6 +429,7 @@ async function persist( refreshFeed(); await sendLive( (built) => matchContaining(id)(built) && unsentMatches(built), + newestSessionKey(), ); } } catch (error) { diff --git a/app/features/scanner/components/sendou-ingest.ts b/app/features/scanner/components/sendou-ingest.ts index fc5d11304..0e5e9b519 100644 --- a/app/features/scanner/components/sendou-ingest.ts +++ b/app/features/scanner/components/sendou-ingest.ts @@ -15,10 +15,9 @@ import type { IngestResponse } from "~/features/scanner-ingest/scanner-ingest-sc import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry"; import type { DetectedEvent } from "../core/detectors/types"; import type { BuiltMatch } from "../core/match-builder"; -import { buildScannerMatches, ingestSkipReasons } from "../core/match-builder"; +import { ingestSkipReasons } from "../core/match-builder"; import type { ScannerMatch } from "../core/scanner-match"; -import { EVENTS_STORE } from "../store/db"; -import { type SendStatus, updateEventsSend } from "../store/events"; +import type { SendStatus } from "../store/events"; import type { ScanEvent } from "./session-data"; const INGEST_URL = "/ingest"; @@ -39,9 +38,9 @@ export interface SendResult { } /** - * Builds the stored events into matches, POSTs the ingestable ones `include` - * selects, and records the outcome on every source event's `send` status - * (calling `onStatus` after each request's store writes). + * POSTs the ingestable `matches` (a whole session or file, which + * the skip rules look across) that `include` selects, and records the outcome + * through `writeSend` (calling `onStatus` after each request's store writes). * * Matches go out in as few requests as the server cap allows: sendou.ink * resolves a whole request at once, so several matches anchor on their @@ -51,30 +50,26 @@ export interface SendResult { * "unlinked"; the retry carries only those, which then resolve on their own. */ export async function sendMatches({ - events, + matches, include, onStatus, - store = EVENTS_STORE, + writeSend, }: { - events: readonly ScanEvent[]; + /** chronological */ + matches: readonly BuiltMatch[]; include: (built: BuiltMatch) => boolean; onStatus: () => void; - /** the IndexedDB store the events' send statuses are written to */ - store?: string; + /** stores a send status on the given matches */ + writeSend: ( + matches: readonly BuiltMatch[], + send: SendStatus, + ) => Promise; }): Promise { - const allBuilt = ingestableBuilt( - buildScannerMatches(events.filter((e) => e.id !== undefined)), - ); - const selected = allBuilt.filter(include); + const selected = ingestableBuilt(matches).filter(include); const result: SendResult = { sentMatches: 0, failedMatches: 0 }; for (const request of R.chunk(selected, MAX_MATCHES_PER_REQUEST)) { - const idsPerMatch = request.map((built) => built.sources.map((e) => e.id!)); - await updateEventsSend( - idsPerMatch.flat(), - { state: "sending", at: Date.now() }, - store, - ); + await writeSend(request, { state: "sending", at: Date.now() }); onStatus(); try { const response = await postIngestMatches( @@ -88,33 +83,25 @@ export async function sendMatches({ // match: the game is just not reported yet, so a later resend can still // land it. Without a context there is nothing to wait for. const unlinked = !link && response.contextResolved; - await updateEventsSend( - idsPerMatch[matchIndex]!, - { - state: unlinked ? "unlinked" : "sent", - at: Date.now(), - ...(link ? { link } : null), - ...(unlinked - ? { - attempts: - (aggregateSendStatus(built.sources)?.attempts ?? 0) + 1, - } - : null), - }, - store, - ); + await writeSend([built], { + state: unlinked ? "unlinked" : "sent", + at: Date.now(), + ...(link ? { link } : null), + ...(unlinked + ? { + attempts: + (aggregateSendStatus(built.sources)?.attempts ?? 0) + 1, + } + : null), + }); } result.sentMatches += request.length; } catch (err) { - await updateEventsSend( - idsPerMatch.flat(), - { - state: "failed", - at: Date.now(), - error: err instanceof Error ? err.message : String(err), - }, - store, - ); + await writeSend(request, { + state: "failed", + at: Date.now(), + error: err instanceof Error ? err.message : String(err), + }); result.failedMatches += request.length; } onStatus(); @@ -180,7 +167,7 @@ export function unsentClosedMatches(built: BuiltMatch): boolean { } function ingestableBuilt( - built: BuiltMatch[], + built: readonly BuiltMatch[], ): BuiltMatch[] { const skipped = ingestSkipReasons(built); return built.filter((match) => !skipped.has(match)); diff --git a/app/features/scanner/components/upload.ts b/app/features/scanner/components/upload.ts index f6629f30d..253810bad 100644 --- a/app/features/scanner/components/upload.ts +++ b/app/features/scanner/components/upload.ts @@ -4,9 +4,18 @@ * never overlap (a send requested mid-flight runs right after), and one * place that knows whether uploading is on at all (the setting, and a login). */ -import type { BuiltMatch } from "../core/match-builder"; -import { EVENTS_STORE, VOD_EVENTS_STORE } from "../store/db"; -import { listEvents } from "../store/events"; +import { type BuiltMatch, buildScannerMatches } from "../core/match-builder"; +import { + compactedBuilt, + listCompactedMatches, + updateCompactedMatchesSend, +} from "../store/compacted-matches"; +import { + COMPACTED_MATCHES_STORE, + EVENTS_STORE, + VOD_EVENTS_STORE, +} from "../store/db"; +import { listEvents, type SendStatus, updateEventsSend } from "../store/events"; import { loadVodEvents } from "../store/vods"; import { refreshFeed } from "./events-feed"; import { type SendResult, sendMatches } from "./sendou-ingest"; @@ -15,9 +24,24 @@ import { readSettings } from "./settings"; export type MatchSelector = (built: BuiltMatch) => boolean; +interface SendRequest { + include: MatchSelector; + /** live: the session key built matches are loaded from; VoD: unused */ + since: number; +} + +interface SendTarget { + load: (since: number) => Promise[]>; + writeSend: ( + matches: readonly BuiltMatch[], + send: SendStatus, + ) => Promise; + onStatus: (since: number) => void; +} + interface Sender { sending: boolean; - pending: MatchSelector[]; + pending: SendRequest[]; } let user: { id: number } | null = null; @@ -37,9 +61,45 @@ export function uploadEnabled(): boolean { return user !== null && readSettings().upload; } -/** Sends the live matches `include` selects; the feed refreshes as statuses change. */ -export function sendLive(include: MatchSelector): Promise { - return send(EVENTS_STORE, listEvents, include, refreshFeed); +/** + * Sends the matches `include` selects among the live events detected since + * `since` (the key of the session they belong to); the feed refreshes from + * there as statuses change. + */ +export function sendLive( + include: MatchSelector, + since: number, +): Promise { + return send( + EVENTS_STORE, + { include, since }, + { + load: async (from) => buildScannerMatches(await listEvents(from)), + writeSend: eventsSendWriter(EVENTS_STORE), + onStatus: refreshFeed, + }, + ); +} + +/** Sends the matches `include` selects among the compacted games of the session keyed `sessionKey`. */ +export function sendCompacted( + include: MatchSelector, + sessionKey: number, +): Promise { + return send( + COMPACTED_MATCHES_STORE, + { include, since: sessionKey }, + { + load: async (from) => + (await listCompactedMatches(from)).map(compactedBuilt), + writeSend: (matches, sendStatus) => + updateCompactedMatchesSend( + matches.map((built) => built.sources[0]!.id!), + sendStatus, + ), + onStatus: refreshFeed, + }, + ); } /** Sends the VoD's matches `include` selects; `onStatus` runs after each status write. */ @@ -50,19 +110,19 @@ export function sendVod( ): Promise { return send( `${VOD_EVENTS_STORE}:${name}`, - () => loadVodEvents(name), - include, - onStatus, - VOD_EVENTS_STORE, + { include, since: 0 }, + { + load: async () => buildScannerMatches(await loadVodEvents(name)), + writeSend: eventsSendWriter(VOD_EVENTS_STORE), + onStatus, + }, ); } async function send( key: string, - loadEvents: () => Promise, - include: MatchSelector, - onStatus: () => void, - store: string = EVENTS_STORE, + request: SendRequest, + target: SendTarget, ): Promise { if (!isLoggedIn()) return null; let sender = senders.get(key); @@ -71,20 +131,22 @@ async function send( senders.set(key, sender); } if (sender.sending) { - sender.pending.push(include); + sender.pending.push(request); return null; } sender.sending = true; const result: SendResult = { sentMatches: 0, failedMatches: 0 }; + let sentSince = request.since; try { - let next: MatchSelector | undefined = include; + let next: SendRequest | undefined = request; while (next) { - const events = await loadEvents(); + const { since } = next; + sentSince = Math.min(sentSince, since); const pass = await sendMatches({ - events, - include: next, - onStatus, - store, + matches: await target.load(since), + include: next.include, + onStatus: () => target.onStatus(since), + writeSend: target.writeSend, }); result.sentMatches += pass.sentMatches; result.failedMatches += pass.failedMatches; @@ -92,12 +154,24 @@ async function send( sender.pending = []; next = pending.length > 0 - ? (built) => pending.some((fn) => fn(built)) + ? { + include: (built) => pending.some((p) => p.include(built)), + since: Math.min(...pending.map((p) => p.since)), + } : undefined; } } finally { sender.sending = false; - onStatus(); + target.onStatus(sentSince); } return result; } + +function eventsSendWriter(store: string): SendTarget["writeSend"] { + return (matches, sendStatus) => + updateEventsSend( + matches.flatMap((built) => built.sources.map((event) => event.id!)), + sendStatus, + store, + ); +} diff --git a/app/features/scanner/core/sessions.ts b/app/features/scanner/core/sessions.ts index 40f7edd17..95ec6949a 100644 --- a/app/features/scanner/core/sessions.ts +++ b/app/features/scanner/core/sessions.ts @@ -2,13 +2,41 @@ * Sessions are client-only and derived at render: live detections ordered by * wall-clock time, split wherever two consecutive detections lie ≥ 2 h apart. * A session is keyed by its first event's `detectedAt` (stable across reloads, - * usable in a URL). Retention evicts whole sessions on the same split. + * usable in a URL). Retention evicts whole sessions on the same split, so a + * kept session's games always rebuild with their full details. A session + * `SESSION_COMPACT_AFTER_MS` past its end is compacted: its games are frozen + * as built and the per-second reads behind them dropped (`compactSources`). */ +import { OBJECTIVE_EVENT_TYPE } from "./detectors/objective/index"; +import { PLAYER_STATUS_EVENT_TYPE } from "./detectors/objective/player-status"; +import { STRIP_WEAPONS_EVENT_TYPE } from "./detectors/objective/strip-weapons"; import type { ScannerMatch } from "./scanner-match"; export const SESSION_GAP_MS = 2 * 60 * 60 * 1000; export const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; export const MAX_SESSIONS = 20; +/** + * Stored events across sessions. A Splat Zones game keeps ~350 (the counter + * and status reads behind its timeline), so this holds ~140 games; past it the + * oldest whole sessions go. + */ +export const MAX_STORED_EVENTS = 50_000; +/** + * Until then a session keeps its raw reads: debugging a misread and rebuilding + * after a match builder fix both need them, and frames are kept as long. + */ +export const SESSION_COMPACT_AFTER_MS = 72 * 60 * 60 * 1000; + +/** + * The per-second reads a compacted match drops: its frozen `objective`, + * `playerStatus` and team weapons already hold what they were read for, and + * they are ~85% of a game's events. + */ +const COMPACTED_AWAY_TYPES = [ + OBJECTIVE_EVENT_TYPE, + PLAYER_STATUS_EVENT_TYPE, + STRIP_WEAPONS_EVENT_TYPE, +]; interface Stamped { /** wall-clock ms of detection */ @@ -84,20 +112,62 @@ export function kdRatio(summary: SessionSummary): number | null { /** * Ids of the events retention evicts: every event of a session older than - * `SESSION_MAX_AGE_MS` or beyond the newest `MAX_SESSIONS`. A session's age is - * its last event's. + * `SESSION_MAX_AGE_MS`, beyond the newest `MAX_SESSIONS` or past the + * `MAX_STORED_EVENTS` budget, oldest first. A session's age is its last + * event's. The newest session is never cut for the budget: a session missing + * its first events would rebuild its games without their intro and timeline. */ export function expiredSessionEventIds( events: readonly E[], now: number, ): number[] { const sessions = splitSessions(events); - const kept = sessions.slice(-MAX_SESSIONS); + let keptCount = 0; + let keptEvents = 0; + for (const session of sessions.toReversed()) { + const tooOld = now - session.at(-1)!.detectedAt > SESSION_MAX_AGE_MS; + const overBudget = + keptCount > 0 && keptEvents + session.length > MAX_STORED_EVENTS; + if (tooOld || overBudget || keptCount === MAX_SESSIONS) break; + keptCount++; + keptEvents += session.length; + } return sessions - .filter( - (session) => - !kept.includes(session) || - now - session.at(-1)!.detectedAt > SESSION_MAX_AGE_MS, - ) + .slice(0, sessions.length - keptCount) .flatMap((session) => session.map((event) => event.id)); } + +/** + * The source events a compacted match keeps: all but the per-second reads, + * so its card still lists deaths, shows its scan time and upload state, and + * the debug view its detections. A match read off nothing else keeps its first + * source, which the card and uploads identify it by. + */ +export function compactSources( + sources: readonly E[], +): E[] { + const kept = sources.filter( + (event) => !COMPACTED_AWAY_TYPES.includes(event.type), + ); + return kept.length > 0 ? kept : sources.slice(0, 1); +} + +/** + * Keys of the compacted sessions retention evicts: older than + * `SESSION_MAX_AGE_MS`, or beyond the newest `MAX_SESSIONS` once the + * `rawSessionCount` sessions not yet compacted (always the newest) are counted. + */ +export function expiredCompactedSessionKeys( + compacted: readonly { key: number; endedAt: number }[], + rawSessionCount: number, + now: number, +): number[] { + const room = Math.max(0, MAX_SESSIONS - rawSessionCount); + return compacted + .toSorted((a, b) => b.key - a.key) + .filter( + (session, index) => + index >= room || now - session.endedAt > SESSION_MAX_AGE_MS, + ) + .map((session) => session.key); +} diff --git a/app/features/scanner/store/compacted-matches.browser.test.ts b/app/features/scanner/store/compacted-matches.browser.test.ts new file mode 100644 index 000000000..01eafdaf5 --- /dev/null +++ b/app/features/scanner/store/compacted-matches.browser.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import type { ScannerMatch } from "../core/scanner-match"; +import { + type CompactedMatch, + compactSessions, + deleteCompactedSessions, + listCompactedMatches, + updateCompactedMatchesSend, +} from "./compacted-matches"; +import { deleteEvents, listEvents, saveEvent } from "./events"; + +const MATCH = { mode: "SZ", stage: 0 } as ScannerMatch; + +function compacted( + id: number, + sessionKey: number, + index: number, +): CompactedMatch { + return { + id, + session: { key: sessionKey, endedAt: sessionKey + 1_000, originT: 0 }, + index, + match: MATCH, + sources: [ + { + id, + type: "MapStart", + t: 0, + detectedAt: sessionKey, + confidence: 1, + data: null, + }, + { + id: id + 1, + type: "Death", + t: 1, + detectedAt: sessionKey, + confidence: 1, + data: null, + }, + ], + }; +} + +async function clearAll() { + const matches = await listCompactedMatches(); + await deleteCompactedSessions(matches.map((match) => match.session.key)); + await deleteEvents((await listEvents()).map((event) => event.id!)); +} + +describe("compactSessions()", () => { + beforeEach(clearAll); + + test("stores the games and deletes the raw events they replace", async () => { + const id = await saveEvent({ + type: "Objective", + t: 0, + confidence: 1, + data: null, + }); + + await compactSessions([compacted(10, 1_000, 0)], [id]); + + expect(await listEvents()).toEqual([]); + expect((await listCompactedMatches()).map((match) => match.id)).toEqual([ + 10, + ]); + }); +}); + +describe("listCompactedMatches()", () => { + beforeEach(clearAll); + + test("lists the sessions keyed from `since` on, each session's games in order", async () => { + await compactSessions( + [ + compacted(30, 2_000, 1), + compacted(10, 1_000, 0), + compacted(20, 2_000, 0), + ], + [], + ); + + expect((await listCompactedMatches()).map((match) => match.id)).toEqual([ + 10, 20, 30, + ]); + expect( + (await listCompactedMatches(2_000)).map((match) => match.id), + ).toEqual([20, 30]); + }); +}); + +describe("updateCompactedMatchesSend()", () => { + beforeEach(clearAll); + + test("sets the status on every kept source of the given games", async () => { + await compactSessions( + [compacted(10, 1_000, 0), compacted(20, 1_000, 1)], + [], + ); + + await updateCompactedMatchesSend([10], { state: "sent", at: 5 }); + + const [first, second] = await listCompactedMatches(); + expect(first!.sources.map((source) => source.send?.state)).toEqual([ + "sent", + "sent", + ]); + expect(second!.sources.map((source) => source.send)).toEqual([ + undefined, + undefined, + ]); + }); +}); + +describe("deleteCompactedSessions()", () => { + beforeEach(clearAll); + + test("deletes every game of the given sessions only", async () => { + await compactSessions( + [ + compacted(10, 1_000, 0), + compacted(20, 1_000, 1), + compacted(30, 2_000, 0), + ], + [], + ); + + await deleteCompactedSessions([1_000]); + + expect((await listCompactedMatches()).map((match) => match.id)).toEqual([ + 30, + ]); + }); +}); diff --git a/app/features/scanner/store/compacted-matches.ts b/app/features/scanner/store/compacted-matches.ts new file mode 100644 index 000000000..c54d46379 --- /dev/null +++ b/app/features/scanner/store/compacted-matches.ts @@ -0,0 +1,122 @@ +/** + * Live sessions past `SESSION_COMPACT_AFTER_MS`, one record per game: the + * match frozen as it was built plus the source events `compactSources` keeps. + * A session's raw events (and frames) leave the `events` store in the same + * transaction its games arrive here, so it is always in exactly one of them. + */ +import type { BuiltMatch } from "../core/match-builder"; +import type { ScannerMatch } from "../core/scanner-match"; +import { + COMPACTED_MATCHES_STORE, + EVENTS_STORE, + FRAMES_STORE, + readwrite, + tx, +} from "./db"; +import type { SendStatus, StoredEvent } from "./events"; + +export interface CompactedMatch { + /** the first kept source's id — the card key and upload selectors' id */ + id: number; + /** the session the game was played in, as its raw events split it */ + session: { + /** the session key — its URL id and its clips' `sessionKey` */ + key: number; + endedAt: number; + /** stream second the session's positions count from */ + originT: number; + }; + /** the game's position in its session, 0-based */ + index: number; + match: ScannerMatch; + /** chronological; each carries the match's send status */ + sources: StoredEvent[]; +} + +/** A compacted game as the views and uploads take a built one. */ +export function compactedBuilt( + compacted: CompactedMatch, +): BuiltMatch { + return { match: compacted.match, sources: compacted.sources }; +} + +/** Stores the games and deletes the raw events (and frames) they were built from, in one transaction. */ +export function compactSessions( + matches: readonly CompactedMatch[], + eventIds: readonly number[], +): Promise { + return readwrite( + [COMPACTED_MATCHES_STORE, EVENTS_STORE, FRAMES_STORE], + (transaction) => { + const compacted = transaction.objectStore(COMPACTED_MATCHES_STORE); + for (const match of matches) { + compacted.put(match); + } + const events = transaction.objectStore(EVENTS_STORE); + const frames = transaction.objectStore(FRAMES_STORE); + for (const id of eventIds) { + events.delete(id); + frames.delete(id); + } + }, + ); +} + +/** Games of the sessions keyed at or after `since` (every compacted game by default), chronological. */ +export async function listCompactedMatches( + since = 0, +): Promise { + const matches = await tx( + COMPACTED_MATCHES_STORE, + "readonly", + (store) => + store + .index("sessionKey") + .getAll(IDBKeyRange.lowerBound(since)) as IDBRequest, + ); + return matches.sort( + (a, b) => a.session.key - b.session.key || a.index - b.index, + ); +} + +/** Sets (or clears) the send status of the given games, on every source they kept. */ +export function updateCompactedMatchesSend( + ids: readonly number[], + send: SendStatus | undefined, +): Promise { + return readwrite([COMPACTED_MATCHES_STORE], (transaction) => { + const store = transaction.objectStore(COMPACTED_MATCHES_STORE); + for (const id of ids) { + const get = store.get(id) as IDBRequest; + get.onsuccess = () => { + const record = get.result; + if (!record) return; // deleted meanwhile + for (const source of record.sources) { + if (send) source.send = send; + else delete source.send; + } + store.put(record); + }; + } + }); +} + +/** Deletes every game of the given sessions. */ +export function deleteCompactedSessions( + keys: readonly number[], +): Promise { + return readwrite([COMPACTED_MATCHES_STORE], (transaction) => { + const index = transaction + .objectStore(COMPACTED_MATCHES_STORE) + .index("sessionKey"); + for (const key of keys) { + const req = index.openCursor(IDBKeyRange.only(key)); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) return; + cursor.delete(); + cursor.continue(); + }; + } + }); +} diff --git a/app/features/scanner/store/db.ts b/app/features/scanner/store/db.ts index cd8782757..b15c6748b 100644 --- a/app/features/scanner/store/db.ts +++ b/app/features/scanner/store/db.ts @@ -2,6 +2,7 @@ * Shared IndexedDB handle. Stores: * - `events`: live detections, keyed by auto id (events.ts) * - `frames`: their full-res analyzed PNGs by event id, kept apart so listing the feed never deserializes them + * - `compacted-matches`: older live sessions' games, frozen as built, indexed by session (compacted-matches.ts) * - `vods`: one summary per scanned VoD, keyed by file name (vods.ts) * - `vod-events`: each saved VoD's detections, indexed by VoD name * - `vod-frames`: their PNGs, keyed by vod-event id @@ -10,10 +11,11 @@ * - `inspect-frames`: one-shot Inspect handoffs into a new debug tab (inspect.ts) */ const DB_NAME = "scanner"; -const DB_VERSION = 2; +const DB_VERSION = 3; export const EVENTS_STORE = "events"; export const FRAMES_STORE = "frames"; +export const COMPACTED_MATCHES_STORE = "compacted-matches"; export const VODS_STORE = "vods"; export const VOD_EVENTS_STORE = "vod-events"; export const VOD_FRAMES_STORE = "vod-frames"; @@ -25,7 +27,8 @@ export const INSPECT_FRAMES_STORE = "inspect-frames"; * Adds the stores a DB_VERSION bump introduced, keeping the existing ones and * their data. v2 added the clip stores and moved live event times onto the * wall clock, so a v1 database's live events (stamped on the page clock) - * are dropped. Changing an existing store's shape needs a real migration here. + * are dropped; v3 added the compacted matches. Changing an existing store's + * shape needs a real migration here. */ function upgrade(database: IDBDatabase, oldVersion: number): void { const has = (name: string) => database.objectStoreNames.contains(name); @@ -44,6 +47,13 @@ function upgrade(database: IDBDatabase, oldVersion: number): void { events.createIndex("detectedAt", "detectedAt"); } + if (!has(COMPACTED_MATCHES_STORE)) { + const compacted = database.createObjectStore(COMPACTED_MATCHES_STORE, { + keyPath: "id", + }); + compacted.createIndex("sessionKey", "session.key"); + } + if (!has(VODS_STORE)) { database.createObjectStore(VODS_STORE, { keyPath: "name" }); } diff --git a/app/features/scanner/store/events.ts b/app/features/scanner/store/events.ts index 30a964f7e..f5e5d73ca 100644 --- a/app/features/scanner/store/events.ts +++ b/app/features/scanner/store/events.ts @@ -4,22 +4,14 @@ * analyzed PNG lives in the separate `frames` store under the same id * (loadEventFrame) so listing the feed never deserializes megabytes of * blobs. Retention runs on save (throttled): whole sessions past - * `core/sessions.ts`'s age/count limits go, frames go after `FRAME_MAX_AGE_MS` - * or past `MAX_FRAMES` (the events stay, marked frameless), and `MAX_EVENTS` - * remains a hard floor. + * `core/sessions.ts`'s age/count/event limits go, and frames go after + * `FRAME_MAX_AGE_MS` or past `MAX_FRAMES` (the events stay, marked frameless). */ import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas"; import type { DetectedEvent } from "../core/detectors/types"; import { expiredSessionEventIds } from "../core/sessions"; import { EVENTS_STORE, FRAMES_STORE, readwrite, tx } from "./db"; -/** - * Counter/status reads land ~2.2 events a second of match time, so the cap must - * hold a whole session: at 1000 the store rolled over in ~8 minutes and evicted - * matches before they were sent (2026-08-23: Mahi-Mahi reached sendou.ink with no data). - */ -const MAX_EVENTS = 10_000; - /** * Full-res frame PNGs (~1-2MB each) are what makes a misread reportable; only * debug-mode captures save them, bounded by age and count, whichever bites first. @@ -91,7 +83,6 @@ export async function saveEvent( id = add.result; if (frame) frames.put(frame, id); else if (reuseId !== undefined) frames.delete(id); - evictBeyondCap(events, frames); const now = Date.now(); if (now - lastRetentionAt >= RETENTION_INTERVAL_MS) { lastRetentionAt = now; @@ -114,24 +105,6 @@ export function trimEvents(): Promise { ); } -/** Delete records (and frames) beyond MAX_EVENTS, oldest ids first. */ -function evictBeyondCap(events: IDBObjectStore, frames: IDBObjectStore): void { - const count = events.count(); - count.onsuccess = () => { - let excess = count.result - MAX_EVENTS; - if (excess <= 0) return; - const cursor = events.openKeyCursor(); // ascending id = oldest first - cursor.onsuccess = () => { - const c = cursor.result; - if (!c || excess <= 0) return; - frames.delete(c.primaryKey); - events.delete(c.primaryKey); - excess--; - if (excess > 0) c.continue(); - }; - }; -} - /** * Session and frame retention over key cursors only (no record is * deserialized): the `detectedAt` index yields every event's id and time, @@ -231,11 +204,15 @@ export async function deleteEvents(ids: number[]): Promise { }); } -export function listEvents(): Promise { +/** Events detected at or after `since` (wall-clock ms), every event by default. */ +export function listEvents(since = 0): Promise { return tx( EVENTS_STORE, "readonly", - (store) => store.getAll() as IDBRequest, + (store) => + store + .index("detectedAt") + .getAll(IDBKeyRange.lowerBound(since)) as IDBRequest, ); } diff --git a/app/features/scanner/tests/logic/sessions.test.ts b/app/features/scanner/tests/logic/sessions.test.ts index d1e50b5fe..d86984447 100644 --- a/app/features/scanner/tests/logic/sessions.test.ts +++ b/app/features/scanner/tests/logic/sessions.test.ts @@ -6,9 +6,12 @@ import assert from "node:assert/strict"; import type { ScannerMatch } from "../../core/scanner-match"; import { + compactSources, + expiredCompactedSessionKeys, expiredSessionEventIds, kdRatio, MAX_SESSIONS, + MAX_STORED_EVENTS, SESSION_GAP_MS, SESSION_MAX_AGE_MS, sessionKey, @@ -20,6 +23,14 @@ import { test } from "../node-test-compat"; const HOUR = 60 * 60 * 1000; const DAY = 24 * HOUR; +/** `count` events a second apart from `start`, ids continuing from `firstId` */ +function eventRun(start: number, count: number, firstId: number) { + return Array.from({ length: count }, (_, i) => ({ + id: firstId + i, + detectedAt: start + i * 1000, + })); +} + function stamped(...detectedAts: number[]) { return detectedAts.map((detectedAt, id) => ({ id, detectedAt })); } @@ -126,3 +137,68 @@ test("retention keeps the newest sessions only", () => { })); assert.deepEqual(expiredSessionEventIds(events, now), [0, 1]); }); + +test("retention drops the oldest whole sessions past the event budget", () => { + const now = 100 * DAY; + const half = MAX_STORED_EVENTS / 2; + const oldest = eventRun(now - 3 * DAY, half, 0); + const middle = eventRun(now - 2 * DAY, half, half); + const newest = eventRun(now - DAY, half, 2 * half); + const ids = expiredSessionEventIds([...oldest, ...middle, ...newest], now); + assert.deepEqual( + ids, + oldest.map((e) => e.id), + ); +}); + +test("retention keeps the newest session whole even past the event budget", () => { + const now = 100 * DAY; + const older = eventRun(now - 2 * DAY, 10, 0); + const newest = eventRun(now - DAY, MAX_STORED_EVENTS + 1, 10); + const ids = expiredSessionEventIds([...older, ...newest], now); + assert.deepEqual( + ids, + older.map((e) => e.id), + ); +}); + +test("compaction keeps every source but the per-second reads", () => { + const sources = [ + { type: "Objective" }, + { type: "MapStart" }, + { type: "PlayerStatus" }, + { type: "StripWeapons" }, + { type: "Death" }, + { type: "Scoreboard" }, + ]; + assert.deepEqual( + compactSources(sources).map((event) => event.type), + ["MapStart", "Death", "Scoreboard"], + ); +}); + +test("a match read only off per-second reads keeps its first source", () => { + const sources = [{ type: "Objective" }, { type: "PlayerStatus" }]; + assert.deepEqual(compactSources(sources), [sources[0]]); +}); + +test("compacted retention counts the raw sessions against the session cap", () => { + const now = 100 * DAY; + const compacted = Array.from({ length: MAX_SESSIONS }, (_, i) => ({ + key: now - (MAX_SESSIONS - i) * DAY, + endedAt: now - (MAX_SESSIONS - i) * DAY + HOUR, + })); + assert.deepEqual(expiredCompactedSessionKeys(compacted, 2, now), [ + compacted[1]!.key, + compacted[0]!.key, + ]); +}); + +test("compacted retention drops sessions older than 30 days", () => { + const now = 100 * DAY; + const old = { key: now - 40 * DAY, endedAt: now - 40 * DAY + HOUR }; + const recent = { key: now - 5 * DAY, endedAt: now - 5 * DAY + HOUR }; + assert.deepEqual(expiredCompactedSessionKeys([old, recent], 0, now), [ + old.key, + ]); +});