Luigit
repositories / will

will

owned by admin

src/shared/retention.ts

Raw
/**
 * 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<string, RetentionRow[]> {
  const groups = new Map<string, RetentionRow[]>()
  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<T extends { firstTs: string; lastTs: string }>(
  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
  })
}