Missed runs and daylight saving
What a daily schedule does on the night the clocks go back, and what it does about runs it missed while nothing called it.
pnpm add @niscorp/tide @niscorp/prism @niscorp/strata zodAn engine for each try
Each part of this file starts from an empty store, so engine makes a new one. The effect prints the night it was called for.
import { createTide, createMemoryStore } from '@niscorp/tide';
import type { ReflexInput } from '@niscorp/tide';
import { evaluate } from '@niscorp/prism';
const engine = (label: string) =>
createTide({
store: createMemoryStore(),
transform: evaluate,
effects: { 'safe.lock': { writes: [], run: (input) => console.log(label, '· ran for', (input as { night: string }).night) } },
});A reflex at 02:30
A schedule is a local time in a named time zone. policy says what to do about missed runs.
const nightly = (policy: ReflexInput['policy']): ReflexInput => ({
id: 'safe.lock',
intent: 'Lock the box office safe at 02:30.',
on: { clock: { every: 'day', at: '02:30', tz: 'Europe/Vienna' } },
effect: { name: 'safe.lock', input: { night: { $ref: '$.occurrence.key' } } },
policy,
});The night the clocks go back
On 25 October 2026 the time 02:30 comes twice in Vienna. A day has one occurrence, named by its date, so the reflex runs once: at the first 02:30.
const longNight = engine('clock change');
await longNight.load([nightly({})], { at: Date.parse('2026-10-24T12:00:00+02:00') });
await longNight.advance({ now: Date.parse('2026-10-25T12:00:00+01:00') });
const [run] = await longNight.ledger.runs({});
console.log('clock change · due at', new Date(run?.dueAt ?? 0).toISOString());Three missed nights
advance is called on the 20th at noon, and not again until the 23rd at noon. Three nights were missed.
By default the oldest missed night runs and the others are recorded as skipped, because one run of a reflex is not started while another is unsettled. overlap: 'allow' runs them all. catchUp: 'latest' runs only the newest. catchUp: 'skip' runs none that is more than an hour late, which is all three.
The engine here is the one that was loaded before the nights were missed. A host that was down itself loads its reflexes without at when it comes back: at says from when a reflex counts, and nights before it are passed over.
const policies: [string, ReflexInput['policy']][] = [
['default', {}],
['allow', { overlap: 'allow' }],
['latest', { catchUp: 'latest' }],
['skip', { catchUp: 'skip' }],
];
for (const [label, policy] of policies) {
const tide = engine(label);
await tide.load([nightly(policy)], { at: Date.parse('2026-10-20T12:00:00+02:00') });
for (let pass = 0; pass < 4; pass += 1) await tide.advance({ now: Date.parse('2026-10-23T12:00:00+02:00') });
const runs = await tide.ledger.runs({});
console.log(label, '· skipped:', runs.filter((night) => night.state === 'skipped').length, 'of', runs.length);
}Run it.
On the long night it ran once, due at 00:30 in ISO time: the first 02:30. Then the three missed nights four ways. Each night is recorded as run or skipped, so the ledger shows what was not done.
npx tsx nights.mtsclock change · ran for 2026-10-25 clock change · due at 2026-10-25T00:30:00.000Z default · ran for 2026-10-21 default · skipped: 2 of 3 allow · ran for 2026-10-21 allow · ran for 2026-10-22 allow · ran for 2026-10-23 allow · skipped: 0 of 3 latest · ran for 2026-10-23 latest · skipped: 2 of 3 skip · skipped: 3 of 3