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/.
121 lines
5 KiB
TypeScript
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 };
|
|
}
|