/** * When the daily digest is due, in the zone the operator reads it (#229). * * Pure by construction: every fact arrives as a timestamp and the policy, and * nothing here reads a clock or a file. The digest is "one per day" in the * IANA zone it is configured with (host zone when none) — a UTC key would roll * the digest over mid-evening for anyone west of Greenwich. */ import type { ReportingPolicy } from "./types.ts"; const MINUTE_MS = 60_000; const NEXT_DIGEST_HORIZON_MS = 3 * 24 * 60 * MINUTE_MS; export type DigestScheduleState = | { mode: "disabled" } | { mode: "per-tick" } | { mode: "due"; timezone: string } | { mode: "scheduled"; timezone: string; nextAt?: number }; const localMinuteFormatters = new Map(); interface CachedDigestSchedule { lastDigestDayKey: string | undefined; from: number; until: number; state: Extract; } const schedules = new WeakMap(); function localDigestMinute(at: number, timezone?: string): { day: string; clock: string } { const key = timezone ?? ""; let formatter = localMinuteFormatters.get(key); if (formatter === undefined) { formatter = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }); localMinuteFormatters.set(key, formatter); } const parts = formatter.formatToParts(new Date(at)); const get = (type: Intl.DateTimeFormatPartTypes): string => parts.find((part) => part.type === type)?.value ?? "00"; return { day: `${get("year")}-${get("month")}-${get("day")}`, clock: `${get("hour")}:${get("minute")}`, }; } /** `YYYY-MM-DD` in an IANA timezone (host zone when `timezone` is absent). */ export function localDayKey(at: number, timezone?: string): string { return localDigestMinute(at, timezone).day; } /** The durable day-marker key for the db-snapshot cadence (#289), mirroring * the `digest:` dedupe keys in the report outbox. */ export function dbSnapshotMarkerKey(dayKey: string): string { return `db-snapshot:${dayKey}`; } /** * Whether the conductor.db snapshot cadence is due now. * * The store is the ledger — the verb ledger, the decision rows and the run * history have no other copy — so it is snapshotted once per day, aligned to * the digest tick: a daily digest applies the exact {@link digestDue} * predicate (same day boundary, same `at`, same timezone), so the snapshot * lands on the same tick the digest goes out. A `none` or `per-tick` digest * still leaves the ledger on a daily schedule — durability does not follow * the reporting preference — bounded by the policy-zone day key (host zone * when the policy carries none), with no `at`. The zone must be the * policy's: the caller writes the `db-snapshot:` marker with * {@link localDayKey localDayKey(now, policy.digest.timezone)}, so a * host-zone read here would disagree with it whenever the two zones fall on * different days, and a `{cadence: "per-tick", timezone: "Europe/London"}` * policy on a UTC host would re-snapshot on every tick during the offset * window, pruning the retained set to nothing. * * Pure by construction, like {@link digestDue}: the last-run fact arrives as * the day key (`db-snapshot:` marker present for today ⟺ last run was * today), never a clock the caller read an hour ago. */ export function dbSnapshotDue( policy: Pick, lastSnapshotDayKey: string | undefined, now: number, ): boolean { if (policy.digest.cadence === "daily") return digestDue(policy, lastSnapshotDayKey, now); return localDayKey(now, policy.digest.timezone) !== lastSnapshotDayKey; } /** * Whether the digest is due now. * * - `none` → never. * - `per-tick` → always (the orchestrator sends it with every report). * - `daily` without `at` → once per day in the configured zone. * - `daily` with `at` → on a day it has not already run, once the local clock * has passed `at`. A restart after `at` still finds today unsent → one * catch-up; a fully missed day is skipped, never sent late. */ export function digestDue( policy: Pick, lastDigestDayKey: string | undefined, now: number, ): boolean { const { cadence, at, timezone } = policy.digest; if (cadence === "none") return false; if (cadence === "per-tick") return true; // daily const local = localDigestMinute(now, timezone); if (at === undefined) return lastDigestDayKey !== local.day; if (lastDigestDayKey === local.day) return false; return local.clock >= at; } /** Mechanical status for the next digest opportunity, using the same * predicate that gates report submission. Minute scanning deliberately keeps * skipped/repeated DST wall-clock times on the runtime's real timeline. */ export function digestScheduleState( policy: Pick, lastDigestDayKey: string | undefined, now: number, ): DigestScheduleState { if (policy.digest.cadence === "none") return { mode: "disabled" }; if (policy.digest.cadence === "per-tick") return { mode: "per-tick" }; const timezone = policy.digest.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"; if (digestDue(policy, lastDigestDayKey, now)) return { mode: "due", timezone }; const from = Math.floor(now / MINUTE_MS) * MINUTE_MS; const cached = schedules.get(policy.digest); if ( cached !== undefined && cached.lastDigestDayKey === lastDigestDayKey && from >= cached.from && from < cached.until ) { return cached.state; } const end = now + NEXT_DIGEST_HORIZON_MS; let cursor = from + MINUTE_MS; while (cursor <= end && !digestDue(policy, lastDigestDayKey, cursor)) cursor += MINUTE_MS; const state: Extract = { mode: "scheduled", timezone, ...(cursor > end ? {} : { nextAt: cursor }), }; schedules.set(policy.digest, { lastDigestDayKey, from, until: cursor, state, }); return state; }