T-HUB/packages/scheduling/src/rematerialize.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

121 lines
5 KiB
TypeScript

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 };
}