Tide · guide

Trigger on a database write

A reflex triggered by a row being inserted, and a second reflex triggered when the first one finishes: a workflow of two steps.

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

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.

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

const tide = createTide({
  store: createMemoryStore(),
  transform: evaluate,
  effects: {
    'mail.send': { writes: [], run: (input) => console.log('mail.send', input) },
    'board.update': { writes: [], run: (input) => console.log('board.update', 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 4

The host’s loop

One advance processes what was passed in and runs what is due. A reflex triggered by one of those runs, such as the next one in a chain, runs on the next advance. So a host calls it until a pass does nothing.

The host’s loop
// what a host does: advance until a pass does nothing
const settle = async (now: number): Promise<void> => {
  for (;;) {
    const pass = await tide.advance({ now });
    const did = pass.factsMatched + pass.runsCreated + pass.tasksCreated + pass.executed + pass.runsSettled;
    if (did === 0) return;
  }
};
Step 3 of 4

Two reflexes

The first is triggered by a write: a row inserted into bookings. The row is $.row in its input.

The second is triggered when a run of the first has finished. Its on names the first reflex by id.

Two reflexes
await tide.load(
  [
    {
      id: 'booking.confirm',
      intent: 'Confirm a booking to the member who made it.',
      on: { fact: { entity: 'bookings', op: 'insert' } },
      effect: { name: 'mail.send', input: { to: { $ref: '$.row.member' }, seats: { $ref: '$.row.seats' } } },
    },
    {
      id: 'booking.board',
      intent: 'Update the foyer board when a confirmation has gone out.',
      on: { fact: { run: 'booking.confirm' } },
      effect: { name: 'board.update', input: { confirmed: { $ref: '$.fact.stats.done' } } },
    },
  ],
  { at: vienna(23, '09:00') },
);
Step 4 of 4

A booking is written

ingest passes Tide an event, here a write. It only stores it, and advance does the work.

In a Moss app with facts configured, nobody calls ingest for a write: each row written through Vex is passed to Tide.

A booking is written
await tide.ingest({ kind: 'write', entity: 'bookings', op: 'insert', row: { member: 'mia', seats: 2 }, at: vienna(23, '10:15') });
await settle(vienna(23, '10:15'));

for (const run of await tide.ledger.runs({})) console.log(run.reflexId, run.state, 'because of', run.cause);
Then

Run it.

The mail goes out, then the board is updated with one confirmation. Each run records what triggered it: the write, then the first run finishing.

Run it
npx tsx write.mts
It prints
mail.send { to: 'mia', seats: 2 }
board.update { confirmed: 1 }
booking.confirm settled because of fact:fact_3
booking.board settled because of fact:fact_6