import { nextOccurrencesPrepared } from "./occurrences.js"; import { prepareSchedule, type Occurrence, type ScheduleSpec } from "./schedule.js"; import { currentTzdataVersion, toInstantMs } from "./timezone.js"; import type { NominalLocal } from "./timezone.js"; /** A stored occurrence row, as read from `schedule_occurrences`. */ export interface StoredOccurrence { /** Nominal local slot of this row (e.g. `2024-03-10T09:00`). */ readonly nominalLocal: NominalLocal; /** Materialized UTC fire instant, epoch ms. */ readonly fireAtUtc: number; /** Whether a claim has been taken on this row. */ readonly claimed: boolean; } export interface RematerializeOptions { /** * Instant the zone/tzdata change was applied (epoch ms). New occurrences * must fire strictly after max(this, last claimed fire instant). */ readonly changeInstant: Date | number | string; /** * `last_fired_nominal_local` recorded on the schedule — the local slot of * the last claimed occurrence. New occurrences must be in a strictly later * nominal slot, so a slot already fired under the old zone never refires. */ readonly lastFiredNominalLocal?: NominalLocal; /** How many future occurrences to generate after the change. */ readonly count?: number; /** Override the recorded tzdata version in tests. */ readonly tzdataVersion?: string; } export interface RematerializeResult { /** Occurrences to insert, ascending by fire instant. */ readonly toInsert: Occurrence[]; /** Fire instants (epoch ms) of unclaimed future rows to delete. */ readonly toDeleteFireAtUtc: number[]; /** The tzdata version backing this materialization, to record on the schedule. */ readonly tzdataVersion: string; } /** * Re-materialize a schedule after a tzdata or timezone change. * * Order of operations mirrors the spec: first, all *unclaimed* future * occurrences are invalidated (their fire instants are returned for deletion); * then new occurrences are generated — only those whose fire instant is * strictly after max(change instant, last claimed fire instant) AND whose * nominal local slot is strictly later than `last_fired_nominal_local`, so a * slot already fired under the old zone (daily 09:00 fired in New York, then * zone switched to Los Angeles) is never fired again. * * Claimed rows are never deleted and never regenerate: their fire instants * already happened or are owned by a claim. */ export function rematerialize( oldSpec: ScheduleSpec, newSpec: ScheduleSpec, stored: readonly StoredOccurrence[], options: RematerializeOptions, ): RematerializeResult { const changeMs = toInstantMs(options.changeInstant); const lastFiredNominal = options.lastFiredNominalLocal; const tzVersion = options.tzdataVersion ?? currentTzdataVersion(); // 1) Invalidate every unclaimed future occurrence: its fire instant is // returned for deletion. Claimed rows survive untouched. const toDeleteFireAtUtc: number[] = []; let lastClaimedFireMs: number | null = null; let maxClaimedNominal: NominalLocal | null = null; for (const row of stored) { if (row.claimed) { if (lastClaimedFireMs === null || row.fireAtUtc > lastClaimedFireMs) { lastClaimedFireMs = row.fireAtUtc; maxClaimedNominal = row.nominalLocal; } continue; } if (row.fireAtUtc > changeMs) { toDeleteFireAtUtc.push(row.fireAtUtc); } } // Effective refire gate: max(change instant, last claimed fire instant). const gateMs = Math.max(changeMs, lastClaimedFireMs ?? Number.NEGATIVE_INFINITY); // Effective nominal gate: the later of the recorded last-fired slot and the // latest claimed row's slot (rows are the runtime truth). let nominalGate: NominalLocal | null = lastFiredNominal ?? null; if (maxClaimedNominal !== null && (nominalGate === null || maxClaimedNominal > nominalGate)) { nominalGate = maxClaimedNominal; } // 2) Generate forward from the gate, skipping any slot not strictly later // than the nominal gate. const count = options.count ?? 32; const prepared = prepareSchedule(newSpec); const candidates = nextOccurrencesPrepared( prepared, gateMs, count * 4, { exclusive: true }, ); // Only rows that SURVIVE the change may block regeneration: claimed rows and // unclaimed rows already in the past. Rows being deleted are being replaced, // so their instants must not suppress the regenerated occurrences. const deleting = new Set(toDeleteFireAtUtc); const existing = new Set( stored.filter((row) => !deleting.has(row.fireAtUtc)).map((row) => row.fireAtUtc), ); const toInsert: Occurrence[] = []; for (const occurrence of candidates) { if (occurrence.fireAtUtc <= gateMs) continue; if (nominalGate !== null && occurrence.nominalLocal <= nominalGate) continue; if (existing.has(occurrence.fireAtUtc)) continue; toInsert.push(occurrence); if (toInsert.length >= count) break; } toInsert.sort((a, b) => a.fireAtUtc - b.fireAtUtc); return { toInsert, toDeleteFireAtUtc, tzdataVersion: tzVersion }; }