Tide · guide

Dry run, manual run, retry

Preview what a reflex would do, run it by hand, and retry a failed task.

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

The engine

createTide makes the engine. It takes a store for its state (this one is in memory), Prism’s evaluate to fill in templates, and the effects: the functions a reflex may call by name.

The gateway is down for the first charge it is asked to make.

Tide takes the time as an argument and never reads the system clock, so this file prints the same on any day.

The engine
import { createTide, createMemoryStore } from '@niscorp/tide';
import { evaluate } from '@niscorp/prism';

let charges = 0;
const tide = createTide({
  store: createMemoryStore(),
  transform: evaluate,
  select: () => [{ id: 'ada', cents: 4600 }, { id: 'linus', cents: 2300 }],
  effects: {
    'card.charge': {
      writes: [],
      run: (input) => {
        if ((charges += 1) === 1) throw new Error('the gateway is down');
        return { charged: input };
      },
    },
  },
});

// an instant: a day of October 2026 and a local time in Vienna
const vienna = (day: number, time: string): number => Date.parse(`2026-10-${day}T${time}:00+02:00`);
Step 2 of 5

A disabled reflex

enabled: false means its schedule never runs it. retry.max: 0 means a charge that throws is not retried.

A disabled reflex
await tide.load(
  [
    {
      id: 'pass.renew',
      intent: 'Charge every season pass that is due.',
      on: { clock: { every: 'month', on: 1, at: '03:00', tz: 'Europe/Vienna' } },
      select: { query: 'passes due', mode: 'each', unitKey: 'id' },
      effect: { name: 'card.charge', input: { member: { $ref: '$.row.id' }, cents: { $ref: '$.row.cents' } } },
      policy: { retry: { max: 0 } },
      enabled: false,
    },
  ],
  { at: vienna(20, '09:00') },
);
Step 3 of 5

A dry run

preview runs the selection and fills in each input. The effect’s run is not called, and nothing is written.

A dry run
const preview = await tide.preview('pass.renew', { now: vienna(20, '10:00') });
console.log('would run:', preview.fired, '· for', preview.units.length, 'rows');
for (const unit of preview.units) console.log(' ', unit.unit, JSON.stringify(unit.input));
console.log('charges so far:', charges);
Step 4 of 5

Run it by hand

fire triggers a reflex now, whatever its own trigger and even when it is disabled, and the next advance runs it. Who did it is stored with the event, as by.

Run it by hand
await tide.fire('pass.renew', { now: vienna(20, '10:05'), by: 'box-office' });
await tide.advance({ now: vienna(20, '10:05') });

const taskStates = async (): Promise<string> => (await tide.ledger.tasks({})).map((task) => `${task.unit} ${task.state}`).join(', ');
console.log('after firing:', await taskStates());
Step 5 of 5

Retry the failed task

A failed task stays in the ledger as failed. retry reopens it, and the next advance runs it.

Retry the failed task
const [failed] = await tide.ledger.tasks({ state: 'failed' });
if (failed !== undefined) await tide.retry(failed.id, vienna(20, '10:30'));
await tide.advance({ now: vienna(20, '10:30') });
console.log('after retry:', await taskStates());
Then

Run it.

The preview lists two charges and makes none. Fired by hand, one charge fails and one goes through. Retried, both are done.

Run it
npx tsx try.mts
It prints
would run: true · for 2 rows
  ada {"member":"ada","cents":4600}
  linus {"member":"linus","cents":2300}
charges so far: 0
after firing: ada failed, linus done
after retry: ada done, linus done