Tide · DOCS.md

This is DOCS.md as @niscorp/tide 0.1.7 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/tide/DOCS.md

Tide — API Reference

Every symbol the package exports, and what it promises. See DESIGN.md for why the shapes are these shapes.

Contents

Four entry points: @niscorp/tide (everything below unless it says otherwise), @niscorp/tide/agent, @niscorp/tide/testing and @niscorp/tide/postgres. zod is the one required peer. Two peers are optional, each imported by one entry only: @niscorp/cortex by /agent, @niscorp/strata by /postgres.


createTide

const tide: Tide = createTide(config: TideConfig);
type TideConfig = {
  store: TideStore;                      // required
  transform: TransformFn;                // required — (config, source) => value
  select?: SelectFn;                     // required only if a reflex has a selection
  effects?: EffectRegistry | ((as: string | undefined) => EffectRegistry);
  actor?: (as: string | undefined) => unknown;   // default: the `as` string itself
  maxChainDepth?: number;                // default 24 — a fact deeper than this is parked
  maxChainFacts?: number;                // default 10 000 — facts one chain may mint beneath its root; the rest are parked
  storeUnwatchedWrites?: boolean;        // default true — false drops a write fact no loaded reflex could watch
  maxFanOut?: number;                    // default 10 000 — rows one selection may return; a run whose selection returns more is skipped
  leaseMs?: number;                      // default 300 000 — how long a claim is good for
  onEvent?: (event: TideEvent) => void;
};

effects may be a function of the reflex's as, which is how a host builds a per-identity registry. Load-time verification sees the union across every declared identity, so an unregistered effect is still caught before it runs.

onEvent hears every fact the ledger admits — fact.ingested whether the host ingested it, a handler emitted it or a run settled — and every one it refuses on its dedupeKey, as fact.deduped. All three paths go through one door in the engine, and a fact written inside a transaction is announced only once that transaction commits, so an event always describes a row that exists.

storeUnwatchedWrites: false is for a host whose ledger is not its audit log. ingest then returns undefined, stores nothing and announces nothing for a write fact whose entity (and op) no loaded reflex's trigger names. It asks only that — it ignores enabled, arming and as — and it drops nothing while no reflexes are loaded. Signal and manual facts are always stored.

leaseMs is the recovery story for a process that dies between the effect and the record. A task still claimed past its lease is claimable again; the fencing token is what makes that safe.


The reflex

type Reflex = {
  id: string;                 // unique; a tenant's reflex is the tenant's own row
  intent: string;             // one factual sentence, in the operator's language
  on: Trigger;
  as?: string;                // opaque identity; the host resolves it
  params?: Record<string, unknown>;      // authored knobs, visible as $.params
  select?: {
    query: unknown;           // opaque — a template: evaluated by `transform`, the result handed to the `select` seam
    mode: 'each' | 'batch';   // default 'each'
    unitKey?: string;         // required for 'each' — the idempotency grain
  };
  when?: unknown;             // predicate template; FACT triggers only
  effect: { name: string; input?: unknown };
  policy: Policy;
  enabled: boolean;           // default true — a switch, not part of the version
};

ReflexSchema parses it; ReflexInput is the input type (defaults not yet applied — policy, enabled and select.mode may be omitted). The parts are exported too: SelectionSchema, EffectRefSchema, TriggerSchema and its members (ClockTriggerSchema, ClockRecurringSchema, ClockOnceSchema, WriteTriggerSchema, SignalTriggerSchema, RunTriggerSchema, ManualTriggerSchema), ClockUnitSchema, OpSchema, WEEKDAYS, PolicySchema, RetrySchema, FactInputSchema and FactKindSchema, with the types Selection, EffectRef, Trigger, Clock, ClockRecurring, ClockOnce, Op, Weekday, Policy, Retry, FactInput and FactKind.

Load refuses, in one pass: a reflex that does not parse, a duplicate id, an unregistered effect, when on a non-fact trigger, each mode with no unitKey, a run subscription to a reflex that is not loaded, and an unguarded cycle — a loop on which no reflex has a select or a when.

enabled is the host's. Tide reads it off the reflex it is handed and holds no copy. To pause an automation, write your own row and load again.


Triggers

// the clock — recurring
{ "clock": { "every": "day" | "week" | "month" | "year",
             "on": 1..31 | "mon".."sun" | "MM-DD",   // omitted for `day`
             "at": "HH:MM", "tz": "Europe/Vienna" } }

// the clock — one shot; seconds optional
{ "clock": { "at": "2026-09-14T09:00", "tz": "Europe/Vienna" } }
{ "clock": { "at": "2026-09-14T09:00:30", "tz": "Europe/Vienna" } }

// a timer — in a DRAFT only (ReflexDraftSchema), never stored: anchorDraft
// fixes it to a one-shot clock when the draft is saved
{ "timer": { "hours": 0, "minutes": 5, "seconds": 0 } }        // each optional, > 0 in total

// a write somebody made (pushed by the host)
{ "fact": { "entity": "invoices", "op": "insert" } }   // `op` optional

// an external event, on a named intake
{ "fact": { "signal": "stripe" } }

// another reflex's run settled — fan-in and dependency
{ "fact": { "run": "billing.charge-due" } }

// only by hand
{ "manual": {} }

Clock semantics. Occurrence identity is local calendar fields — 2026-03 for monthly, 2026-03-07 for daily/weekly, 2026 for yearly. A DST transition can move the instant a key fires at but cannot mint a second key or lose one. Day-31 clamps to month end (February bills on the 28th). An erased spring-forward local time shifts past the gap; an ambiguous fall-back time takes the first occurrence. Both match Temporal's disambiguation: 'compatible'.

A clock reflex with no arming time establishes its baseline on the first advance and mints nothing — materializing from the epoch would backfill decades. Pass load(reflexes, { at }) to arm it at a known moment.

A paused clock still moves. A disarmed reflex materializes nothing, but its watermark keeps up with each advance — so eight days paused is eight days of nothing, not eight occurrences waiting to be minted the moment it comes back. A reflex that enters the loaded set re-baselines to the at passed to load, for the same reason: coming back is not the same as never having left.

There is no poll. A data change is a write fact the host pushes when it sees its own write; an external source with no write choke point enters through an importer that ingests write facts at the door. A poll could only re-discover what was already pushed, one interval late — see DESIGN.md.


Drafts

A draft is a reflex as it is written, before it is saved: id, intent, on and effect — nothing that is the host's (as, policy, enabled). It is the shape the reflex agent answers with.

draftSchemaOf(choice?: DraftChoice): z.ZodType<ReflexDraft>
type DraftChoice = {
  triggers?: readonly DraftTrigger[];   // 'clock' | 'timer' | 'write' | 'signal' | 'run' | 'manual' — default: all six
  fields?: readonly DraftField[];       // 'params' | 'select' | 'when' — default: none
};
ReflexDraftSchema                       // draftSchemaOf() — every trigger kind, no extra fields
DRAFT_TRIGGERS                          // trigger kind → its schema
TimerSchema, TimerTriggerSchema         // { hours?, minutes?, seconds? } — non-negative integers, > 0 in total

draftSchemaOf throws a plain Error on an empty triggers list. The draft schemas keep the reflex's own rules: when on a non-fact trigger and each with no unitKey are refused.

anchorDraft(draft: ReflexDraft, anchor: { at: number; tz: string }): Reflex
timerMs(timer: Timer): number

anchorDraft turns a draft into a stored reflex at the moment it is saved. A timer becomes a one-shot clock at anchor.at plus its length, rounded up to the whole second, as a local date-time in anchor.tz; any other trigger passes through. The result is parsed by ReflexSchema, so defaults are applied and a bad draft throws. The host adds as afterwards.


Policy

type Policy = {
  retry?: { max: number; backoff: 'fixed' | 'exponential'; baseMs: number };  // default max 3 / exponential / 60s
  timeoutMs: number;        // default 30 000
  overlap: 'skip' | 'allow';   // default 'skip'
  catchUp: 'run' | 'skip' | 'latest';   // default 'run'
  lateMs: number;           // default 3 600 000 — only `catchUp: 'skip'` reads it
  order: 'any' | 'serial';  // default 'any'
};
  • retry — bounded. max is retries after the first attempt; exhausting them parks the task failed, terminal and visible, exitable only by retry().

  • overlap: 'skip' — refuses to start a run while the previous one is unsettled. The skip is a recorded run row with a note, never an absence. It governs repeats: a clock occurrence and a manual fact are the same intent coming round again. A write, signal or run fact is a distinct event that exists exactly once, so it always opens a run whatever this says — discarding it would be data loss, and the tool for bounding concurrency without discarding is order: 'serial', which queues.

  • catchUp — after downtime: 'run' opens a run for every missed occurrence, 'latest' fires only the most recent, 'skip' fires only what is inside lateMs. Every decision leaves a run row saying which happened.

    overlap is then asked of each run catchUp opens, and the two defaults meet here. Occurrences missed together come due in one advance and are repeats of one another, so under overlap: 'skip' the oldest runs and every later one, the one that is on time included, is recorded skipped with an overlap note and a run.skipped event. The report's skippedOccurrences counts catch-up decisions and does not count those. To run each missed occurrence, say overlap: 'allow' (with order: 'serial' they run one task at a time); to run only the newest, say catchUp: 'latest'. catchUp: 'skip' meets the same rule when lateMs spans more than one occurrence.

  • order: 'serial' — one in-flight task at a time for that reflex. Enforced inside the claim, not by a filter applied around it.

There is no coalesce. It cost two port methods, a table, an exactly-once promise and a DELETE … RETURNING on every advance, and nothing ever set it. A digest that genuinely needs batching is a delayed run: the first fact of a group mints one at now + window under cause: 'coalesce:<key>:<start>', the rest collide on UNIQUE(reflexId, cause) and are refused, and when it comes due the reflex selects what changed. Mechanisms that already exist, and no table.


Templates and $

A template is a transform-config slot inside a reflex — effect.input, when and select.query. Tide stores, diffs and hashes them but never interprets them: they go to the transform seam verbatim (under moss, Prism — evaluate from @niscorp/prism fits the seam as it is).

What tide puts in $ itself is plain JSON: a fact carries no key for a value it was never given, in the memory store as in a database. What the host handed over is the host's — a row its select answered, a write fact's row, a signal's payload.

$ = { params,        // the reflex's knobs
      occurrence?,   // clock runs: { key, at }
      fact?,         // fact runs: the fact
      facts?,        // runs carrying more than one fact, in `at` order
      row?,          // per-unit in `each` mode
      rows?,         // in `batch` mode
      now }          // the advance's LOGICAL now — never a wall-clock read

Truthiness is one predicate, shared by the matcher and by preview: false, null, undefined, 0, '' and [] are all falsy. They used to disagree, and a when returning an empty list previewed as "this will fire" and then didn't — the exact surprise preview exists to eliminate.

The honest cost of opacity: a typo'd $ref cannot be caught at load. It surfaces at preview() — which is why preview is the authoring loop's inner verb — and, if it slips through, on the task's error.


The fact

The public intake contract. Anything that can produce this shape can drive tide.

type FactInput = {
  kind: 'write' | 'signal' | 'manual' | 'run';
  entity?: string;  op?: 'insert' | 'update' | 'delete';  row?: Record<string, unknown>;
  name?: string;    payload?: unknown;                    // signal
  reflex?: string;  runId?: string;  occurrence?: string;
  stats?: { total: number; done: number; failed: number };  // run
  target?: string;  by?: string;                          // manual (via `fire`)
  at: number;                 // supplied by the caller — tide reads no clocks
  notBefore?: number;         // a delayed fact: timers as data
  dedupeKey?: string;         // a repeat is refused — no row, a `fact.deduped` event; not an error
  cause?: string;             // set by tide when an effect emits
};

The union is enforced end to end. A write needs an entity, a signal a name, a manual a target, a run both reflex and runId — and each refuses the fields belonging to the others. { kind: 'write' } with no entity used to parse, store, match nothing, get marked delivered and vanish, with no way for the producer to learn its fact was never going to wake anything.

Ops are distinct stimuli. "Only on create" is op: 'insert'. Things that happen exactly once ride inserts naturally when the domain models intent as rows: a receipt follows a payment (inserted once), not "invoices where status = paid" (a state every later edit still satisfies). Where a domain does hang state on a wide row, the guard is the selection — ask "is there work left to do", not "is the row in state X".

notBefore needs no cancel API. The reflex it wakes re-checks reality through its selection, and zero rows is an ordinary outcome. Reality is the cancellation token.

Dedupe is keyed on (kind, entity, name, dedupeKey). entity is in the key because two sources over different tables whose event ids happen to agree are not duplicates of each other, and without it one of them loses its rows.


Verbs

load(reflexes, options?: { at?: number }): Promise<LoadReport>

Validates, hashes versions, derives and verifies the graph, and arms. at is the host's boot time; arming persists, so a restart does not reopen the past. LoadReport carries { loaded, cycles, blind, warnings } — guarded cycles are legal and reported; effects with no writes are reported as blind edges. Calling it again is how a host changes what is running, including turning one off.

ingest(fact: FactInput, options?: { as?: string; cause?: string; depth?: number }): Promise<Fact | undefined>

One write. Undefined means a dedupeKey collision — a refusal, not an error, announced as fact.deduped so a repeat is never mistaken for silence — or, under storeUnwatchedWrites: false, a write nothing watches, dropped silently. A fact that does not parse throws invalid_fact. Matching happens in advance, against whatever is loaded when the fact comes due, which is what lets a delayed fact meet the reflexes of the day it fires.

as is whose fact this is, and it is the tenant boundary at the intake. A fact is only ever offered to reflexes running under the SAME identity, so a webhook ingested for one tenant cannot wake another tenant's reflex on the same signal. It is a second argument rather than a field on the fact because the fact shape is what a webhook body parses into: an identity a caller can choose is not an identity.

The rule is strict — a fact with no identity reaches only reflexes with no identity. A single-tenant host names no as anywhere and is unaffected; a multi-tenant host that forgets one gets silence rather than a leak. An event that genuinely concerns every tenant is ingested once per tenant, by the only party that can know which those are.

cause and depth are the chain thread, and they exist for one producer: a bridge that mints an effect handler's own writes back in as facts. It forwards the task's ctx.depth (and a cause) with the write, so the chain ceiling survives the trip through the host's database instead of resetting at every hop. Like as, they are the host's word at the door — a cause that arrived inside the fact is discarded. Anything else ingests with neither: depth defaults to 0.

advance(options: { now: number; limit?: number }): Promise<AdvanceReport>

Materialize → match → fan out → claim → execute → settle. Idempotent and safe to run concurrently. limit (default 100) bounds facts, fan-outs and claims per pass. A chain advances one hop per call, so the driver drains to quiescence and then sleeps until nextDue.

type AdvanceReport = {
  now: number;
  materialized: number; skippedOccurrences: number;
  factsMatched: number; runsCreated: number; tasksCreated: number;
  executed: number; succeeded: number; failed: number; retrying: number; reclaimed: number;
  runsSettled: number; parked: number;
};
nextDue(now: number): Promise<number | undefined>

The next instant at which advance can make progress: an undelivered fact's notBefore ?? at, a pending run's dueAt, a waiting task's backoff, a claimed task's lease lapse, a loaded clock's next occurrence. undefined means nothing is scheduled — sleep until an ingest. The timer is the driver's; tide still reads no clock.

reflexes(): readonly Reflex[]

What is loaded now, as parsed.

fire(reflexId, { now, input?, by? }): Promise<Fact | undefined>

Sugar over ingest: mints a manual fact aimed at one reflex, with input as its payload and by (default 'operator') recorded. Works on a disarmed reflex — arming gates triggers, not people. Throws unknown_reflex when the reflex is not loaded.

retry(taskId, now): Promise<boolean>

Reopens a failed task and rewinds its run from settled back to fanned, in one transaction, so the next advance actually claims it. The task comes off the run's failed count whether or not the run had settled, so retrying several tasks at once, or one while the run is still going, leaves the counts true. false when the task does not exist or is not failed. The run keeps drained, so re-settling does not announce a second time: the digest that already went out saying twelve failed is not sent again.

preview(reflexId, { now, fact? }): Promise<PreviewReport>

Runs the real pipeline against real data and stubs exactly one function, the effect executor. Writes nothing. Returns the occurrence or fact, the selected rows by name, each unit's resolved input, and whatever the handler's preview hook renders. For a clock reflex the occurrence is the most recent one at or before now (for a one-shot, its one date, even when that is still ahead), whether or not the reflex was armed then or is enabled now, so an advance at the same now may create no run. fired is false, with a reason, when when does not match the fact passed in or the fan-out fails (the selection throws, a unit key repeats); true does not say a run is due.

graph(): GraphReport      // { edges: { from, to, via }[], cycles, blind, errors, warnings } over what is loaded
sweep(now, retention): Promise<number>   // { facts?, runs?, tasks? } horizons in ms

The ledger

Three readable row kinds, all reachable through tide.ledger and — under a host that exposes the tables — through ordinary queries.

tide.ledger.runs(filter?)      // { reflexId?, limit? } — newest first
tide.ledger.run(id)
tide.ledger.tasks(filter?)     // { runId?, reflexId?, state?, limit? }
tide.ledger.task(id)
tide.ledger.facts(filter?)     // { reflexId?, limit? } — newest first; `reflexId` keeps run and manual facts naming that reflex
tide.ledger.fact(id)
tide.ledger.causeChain(factId)     // walks `cause` upward — the fact first, then its ancestors
tide.ledger.releaseParked(factId)  // let a parked fact through, once; false if it is not parked
  • fact — everything that arrived: the FactInput plus id, the as it was minted under, depth, deliveredAt, any parked reason and released override, the root of its chain and (on a root) its descendants count. depth counts the hops from the start of a chain: a fact a handler emits or a settled run mints is one deeper than the task or run it came from, and ingest stores the depth it is handed. deliveredAt is the now of the advance that offered the fact to every loaded reflex, set whether or not one matched. root is the id of the fact that started the chain, and that fact itself has none.
  • run — one activation: cause, version hash, the identity it ran as, what the selection returned, the counts, the outcome. A run's cause is occurrence:<key> or fact:<id>; a fact's is task:<id> when a handler emitted it and run:<id> when a run's settlement minted it.
  • task — one unit of effect work: unit key, pinned env, attempt count, lease, last error, output. unit is the row's unitKey value as a string in each mode, and '' for a batch run and for a run with no selection. claimedUntil is when the lease lapses, and 0 while the task is not claimed.

States: a task moves pending → claimed → done | retrying | failed; a run moves pending → fanned → settled, or lands on skipped (overlap, catch-up, a refused fan-out, or a reflex no longer loaded — always with a note).

limit means the most recent N, in every store. The two stores used to answer opposite ends of the same list under the same filter.

There is no attempt table. Correctness needs the count and the last error, and both are on the task; per-attempt history is the host's log, which is where it was being read from anyway.

Retention is a policy, not an accident. The default is keep-forever, which is the only honest default when the rows are billing history:

await tide.sweep(now, { facts: THIRTY_DAYS, runs: NINETY_DAYS });

Removing a run removes its tasks with it. Keeping the tasks would destroy the UNIQUE(runId, unit) row that is the "this unit already ran" record, and a restore would then re-charge the invoice.


Events

onEvent receives a TideEvent. Events are not rows — they are what a host logs at whatever grain it wants.

type Carries When
run.created run a run opened
run.settled run its last task settled, or it fanned out to no tasks — once per run, not again after a retry
run.skipped reflexId, reason overlap, or a fan-out tide refused
run.deferred reflexId, runId, reason a fan-out failed in a host seam; retried next advance
task.done / task.failed task an attempt recorded a terminal outcome
task.retrying task, nextAt an attempt threw or timed out with retries left
task.reclaimed task a lapsed lease was taken back
fact.ingested fact a fact was stored — ingested, emitted, or a settled run's
fact.deduped fact a fact was refused on its dedupeKey
fact.parked fact, reason the matcher parked it at maxChainDepth. A fact parked on arrival by maxChainFacts is announced as fact.ingested, with parked set
fact.unmatched fact, reflexId, reason a when said no, or threw

The seams

Seam Contract
store TideStore — six capabilities; see below
transform (config: unknown, source: Row) => unknown
select (query: unknown, ctx: SelectCtx) => AsyncIterable<Row> | Iterable<Row> | Promise<Iterable<Row>>
effects name → { run(input, ctx), writes?, preview? }
actor (as: string | undefined) => unknown — threaded into select and run, opaque throughout
type TideCtx = {
  reflexId: string; runId: string; taskId: string;
  taskKey: string;   // the downstream idempotency key — pass it to the provider
  attempt: number;
  depth: number;     // how deep in a cause chain this task sits — a bridge forwards it to `ingest`
  now: number; actor: unknown;
  emit: (fact: Omit<FactInput, 'cause'>) => void;   // buffered; commits with a SUCCESSFUL attempt only
};

select receives SelectCtx — { reflexId, now, actor, env }, where env is $. An effect's optional preview(input, ctx) receives PreviewCtx — { reflexId, unit, now, actor }.

The calling convention is the retry classification. Return = done, however unhappy the outcome (a card decline is a domain outcome: record it as a row and let another reflex branch on it). Throw = transient, retried on bounded backoff.

emit accepts write and signal only, and validates. A handler that could emit manual would reach past the arming switch, because manual facts are checked before enablement on purpose — arming gates triggers, not people, and a handler is not a person. run is tide's own bookkeeping. A malformed emit throws inside the handler, so the attempt fails visibly rather than minting a fact that matches nothing and vanishes.


The store contract

type TideStore = {
  transact:       <T>(fn: (tx: TideStore) => Promise<T>) => Promise<T>;
  appendIfAbsent: (table, row) => Promise<Row | undefined>;
  claim:          (spec: ClaimSpec) => Promise<readonly Row[]>;
  cas:            (table, id, expect, set) => Promise<boolean>;
  query:          (spec: QuerySpec) => Promise<readonly Row[]>;
  remove:         (spec: RemoveSpec) => Promise<number>;
};

Six capabilities over four tables — fact, run, task, state. The old port had a method per noun per verb, twenty-seven of them, so claimTasks, drainSettled and claimClosedWindows were three hand-written implementations of one operation and diverged three ways.

  • appendIfAbsent is idempotent on the table's unique key, declared once in the exported UNIQUE_BY constant. Those four constraints are the engine's exactly-once promises; a store that does not enforce all four is not a tide store. It returns the stored row, or undefined on a collision: a refusal, not an error. The store mints the id of a fact, run or task row that carries none. A key with a when column applies only to rows that have that column set, so a fact with no dedupeKey is always appended.
  • claim selects and mutates in one step. set accepts { inc: n } for a counter, because attempt has to rise at claim time or a handler that reliably kills the process retries forever. onePer is order: 'serial', and it lives inside the claim because a caller that looks first and claims second loses the race. onePer: { column, held, only } lets at most one row per value of column be held at a time, counting rows that match held from an earlier claim; it applies to the values listed in only, and every other value claims freely.
  • cas is the fence: a timed-out attempt finishing late finds its token superseded and is discarded. Setting a field to undefined clears it.
  • transact is required. Fan-out committing with its run's transition, and an attempt recording with the facts it emitted, are transactions rather than conventions.
  • query and remove take { table, where?, order?, limit? } and { table, where? }. A where value is a literal (equality) or a Comparison — eq, ne, lt, lte, gt, gte, in, notIn, isNull, listed in the exported COMPARISON_OPS. There is no OR. Removing a run removes its tasks.

PRIMARY_KEY is exported beside UNIQUE_BY: the column cas addresses a row by — id for fact, run and task, reflexId for state. The spec types (ClaimSpec, QuerySpec, RemoveSpec, Where, Comparison, Order, Mutation, UniqueKey, TableName, TideTables) are exported with them.

import { createMemoryStore } from '@niscorp/tide';
import { STORE_CONTRACT } from '@niscorp/tide/testing';

createMemoryStore() is the reference implementation and what headless checks run against. It also exposes snapshot() for assertions — deliberately outside TideStore, so the engine cannot reach around its own contract.

STORE_CONTRACT is the executable definition of everything above: a readonly StoreCheck[] — { name: string; run: (store: TideStore) => Promise<void> } — of checks that throw, with no test framework, so any runner can drive them. Each check wants a fresh, empty store.

for (const check of STORE_CONTRACT) it(check.name, () => check.run(makeStore()));

It is exported rather than kept in test/ because the contract is not tide's private business. The last SQL store in this package said in its own header that it was "held to the same tests" and had none; it diverged in eleven ways and every one of them was invisible. Tide's own Postgres store (below) runs this same list against a real database, in two pool shapes.


The Postgres store

import { createTideStore, TIDE_SEQUENCE, TIDE_TABLES, TIDE_DDL } from '@niscorp/tide/postgres';
import type { TidePool, TidePoolQuery, TideStoreOptions } from '@niscorp/tide/postgres';

An entry point of its own, so that the engine's entry imports nothing but zod. It imports @niscorp/strata, an optional peer: install it to use this entry.

createTideStore(pool: TidePool, options?: TideStoreOptions): TideStore & { ready: Promise<void> }

A TideStore over four tables: tide_fact, tide_run, tide_work, tide_reflex_state (TIDE_TABLES). appendIfAbsent is ON CONFLICT DO NOTHING on the key UNIQUE_BY names; claim is one UPDATE … FOR UPDATE SKIP LOCKED; the state columns carry CHECK constraints; deleting a run cascades to its work.

  • pool is a TidePool: { query(text, values?), transaction?(fn) }. It is a type of the store's own, and structural: vex's PgPool, a PGlite behind a shim, a pg pool that checks a client out for each transaction. query and transaction are called on the object that has them. A pool with no transaction cannot run tide: fan-out, attempt recording and a serial claim are transactions, and each refuses with a sentence.
  • ready resolves when the tables are there. Every method awaits it. The store migrates them itself, through a strata ledger, unless options.migrate is false: then whoever owns the database's migrations has run TIDE_SEQUENCE in a run of its own (under moss, runtime.tables).
  • TIDE_SEQUENCE is the tables as a strata sequence. Its id is nisc.moss.tide: the store was in @niscorp/moss when the sequence was named, and every ledger that has run it holds it under that name. TIDE_DDL is the text of its first migration. Both are history: the tables change by appending a migration, never by editing one.
  • A row is read by a schema. What comes back from the database is parsed, one schema for each table: a row that does not fit throws where it is read, naming the table and the field.
  • A serial group is claimed by one instance at a time. Asking whether a group is held and claiming in it run in one transaction, behind an advisory lock on the group (pg_try_advisory_xact_lock). An instance that does not get the lock leaves the group to the one that has it.

The engine's own writes go through the pool, not through a host's write path: an engine claiming its own task has no principal to run a policy for. The ledger READS are the host's, as ordinary queries over these tables.


The driver

import { createTideDriver } from '@niscorp/tide';
import type { TideDriver, TideDriverConfig } from '@niscorp/tide';

createTideDriver({ tide, now?, janitorMs?, retention? }): TideDriver

The engine reads no clock and paces nothing; this is the thing that does, for a host that does not write its own.

  • Wake — ingest and fire advance the engine immediately, to quiescence: a chain advances one committed hop per step, so the driver loops until a step reports nothing moved. wake() returns the promise of that drain, so a "run it now" button can await a settled world.
  • Sleep — after quiescence, tide.nextDue(now) names the next instant worth waking for and one timer sleeps until then, preempted by any ingest. No beat, no polling for work. A wait longer than a timer can hold (2^31 − 1 ms, 24.8 days) is cut to that: the driver wakes, finds nothing, and waits out the rest.
  • Janitor — a slow fallback wake plus the retention sweep. It finds nothing when nothing is broken; it exists because a durable multi-worker design needs the scan that notices work committed by a process that died before its own wake ran. It is how work is recovered, never how it moves.

A TideDriver is { ingest, fire, wake, stop }. Hand a host's intake the driver, not the bare engine, so a fact wakes it instead of waiting for somebody's beat. stop() resolves once nothing the driver started still touches the store: await it before closing the database. now replaces the clock (Date.now); janitorMs defaults to 5 minutes; absent retention, nothing is swept. What it logs, it logs as [tide:driver].


Occurrence math

Exported because a host that renders a schedule needs the same answers the engine uses:

occurrencesBetween(clock, after, through, cap): Occurrence[]   // (after, through]
occurrenceKey(clock, day): string
zonedToUtc(day, hour, minute, tz): number
zonedParts(instant, tz): LocalParts
daysInMonth(year, month): number
versionOf(reflex): string      // the content hash, excluding `enabled`

occurrenceKey takes a recurring clock. anchorDraft and timerMs are under Drafts.


The reflex agent

@niscorp/tide/agent — needs @niscorp/cortex, the optional peer. Given what somebody wants automated and the effects the host offers, it answers with one of three things: a reflex draft, a question back, or a refusal. It schedules nothing; the host shows the answer, and saves a draft with anchorDraft and load.

createReflexAgent(config: { effects: readonly OfferedEffect[] } & DraftChoice): AgentDefinition<ReflexAnswer>

type OfferedEffect = { name: string; description: string; input: ZodType };
type ReflexAgentInput = { intent: string; now: string; tz: string };   // now: local "YYYY-MM-DDTHH:MM" in tz
type ReflexAnswer = ReflexDraft | ReflexQuestion | ReflexRefusal;
type ReflexQuestion = { question: string };
type ReflexRefusal = { refused: string };

triggers and fields narrow what a draft may use, as in draftSchemaOf. A draft naming an effect that is not offered, or an input that effect's schema refuses, is sent back to the model to correct within the same run.

const agent = createReflexAgent({ effects, triggers: ['clock', 'timer'] });
const result = await agent.run({ intent, now, tz }, { llm }).result;
if (result.ok && isDraft(result.output.data)) {
  // …shown to the person; when they save it:
  await tide.load([{ ...anchorDraft(result.output.data, { at: Date.now(), tz }), as: owner }], { at: Date.now() });
}
  • isDraft(answer) — narrows a ReflexAnswer to a draft.
  • answerSchemaOf(choice?) — the answer schema for what the caller offers; ReflexAnswerSchema is answerSchemaOf().
  • ReflexAgentInputSchema, ReflexQuestionSchema, ReflexRefusalSchema — the input and the two non-draft answers.
  • effectProblem(effects, draft): string | undefined — what is wrong with a draft's effect, as a sentence, or nothing. The check the agent runs.
  • reflexConversation({ now, tz, earlier, latest }): Message[] — the conversation so far as chat turns: each earlier { request, answer, reasoning? } and the person's latest words. Pass it to run in place of a single input when a question is answered or a draft corrected.

Errors

TideError carries a code and optional details; isTideError(value) narrows it.

Code Raised when
invalid_reflex a reflex failed ReflexSchema — including when on a non-fact trigger and each with no unitKey
invalid_fact a fact failed FactInputSchema, or a handler emitted one that did, or emitted a kind other than write / signal
duplicate_reflex two reflexes share an id
unknown_reflex fire/preview named a reflex that is not loaded, or load found a reflex watching the run of one that is not there
unguarded_cycle load found a loop on which no reflex has a select or a when
unknown_effect load found a reflex naming an effect that is not registered
no_transform in TideErrorCode; not raised — transform is a required config field
duplicate_unit a selection's unitKey was not unique, or maxFanOut was exceeded
store a selection exists but no select seam is wired

load checks the whole graph in one pass. When it refuses, details.errors lists every reason and details.refusals gives each with its own code; the error's code is the first one's.

Runtime failures are not exceptions: a handler that throws becomes a recorded attempt, a when that throws becomes a recorded non-match, and an advance does not crash because one reflex is broken.

A fan-out failure is two different things and the engine tells them apart. A TideError is tide's own refusal — a duplicate unit key, a missing select seam — which means the reflex is wrong and will be wrong again next advance, so the run is skipped with a note. Anything else came out of a host seam: a selection, a transform, a database. That is a bad minute, not a bad reflex, so the run stays pending and is retried — a row past its due time that a query can see. Every throw used to skip, and because runs are idempotent on (reflexId, cause), one database hiccup destroyed that night's billing run permanently.