/** * Pure retention policy (spec: audit hot 90 days, chat hot 365 days, * move exactly once into versioned zstd JSONL archives, retained forever). */ export interface RetentionRow { id: string ts: string } export interface RetentionPolicy { auditHotDays: number chatHotDays: number } export const DEFAULT_RETENTION: RetentionPolicy = { auditHotDays: 90, chatHotDays: 365, } export function cutoffIso(now: Date, hotDays: number): string { return new Date(now.getTime() - hotDays * 86_400_000).toISOString() } /** Rows whose ts is strictly older than the cutoff must be archived. */ export function rowsToArchive(rows: RetentionRow[], cutoff: string): RetentionRow[] { return rows.filter((r) => r.ts < cutoff) } /** Month partition key `YYYY-MM` of an ISO timestamp. */ export function monthOf(ts: string): string { return ts.slice(0, 7) } /** * Partition rows to archive into one group per month. * Each group becomes one immutable segment (multiple segments per month allowed). */ export function groupByMonth(rows: RetentionRow[]): Map { const groups = new Map() for (const row of rows) { const month = monthOf(row.ts) const bucket = groups.get(month) if (bucket) bucket.push(row) else groups.set(month, [row]) } return groups } export const ARCHIVE_FORMAT_VERSION = 1 export interface SegmentSidecar { version: number kind: 'audit' | 'chat' month: string segment: string count: number firstId: string lastId: string firstTs: string lastTs: string sha256: string bytes: number } export interface ArchiveQuery { kind: 'audit' | 'chat' since?: string until?: string q?: string limit: number } export interface ArchivedRecord { id: string ts: string record: unknown } /** Sidecars overlapping a time range can be selected without decompression. */ export function segmentsOverlapping( sidecars: T[], query: { since?: string; until?: string }, ): T[] { return sidecars.filter((s) => { if (query.since && s.lastTs < query.since) return false if (query.until && s.firstTs > query.until) return false return true }) }