Tide · guide

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.

Install it
pnpm add @niscorp/tide @niscorp/prism @niscorp/strata zod
Step 1 of 4

An 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.

An engine for each try
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) } },
  });
Step 2 of 4

A reflex at 02:30

A schedule is a local time in a named time zone. policy says what to do about missed runs.

A reflex at 02:30
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,
});
Step 3 of 4

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.

The night the clocks go back
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());
Step 4 of 4

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.

Three missed nights
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);
}
Then

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.

Run it
npx tsx nights.mts
It prints
clock 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