Skip to content
Mercury
English
Design Book
Menu

Observing: watch, inspect, describe

Two ways to see a model. watch calls a function for a unit's updates; inspect, on its own subpath, is the one surface a tool reads, with every update, skip, failure, declaration, effect run and reset as an observation.

A model is observed in two ways: application code watches a unit, and a tool, such as DevTools, a log or a test, inspects the whole model.

watch

watch(unit, fn, {scope?}) calls fn with each update of unit and returns a Subscription: a function that ends the watch, with .unsubscribe() as the same call.

  • With scope, the watch sees that scope's updates and nothing outside it.
  • Without one, it sees the work outside any scope.
  • The watch is also owned by the generation of the code that made it. When a hot swap retires that generation, the watch ends with it, even if its scope lives on.
import { allSettled, event, fork, store, watch } from "@fiberfx/hypergraph";

const increment = event();
const $count = store(0).on(increment, (n) => n + 1);

export async function seen() {
  const scope = fork();
  const values: number[] = [];
  const stop = watch($count, (n) => values.push(n), { scope });
  await allSettled(increment, { scope });
  increment(); // outside the scope: not seen
  stop();
  return values; // [1]
}

The vector watch.scoped-store-not-global states the line marked not seen: a watch in a scope records nothing for a call outside it.

watch and unit.watch

The two differ on a store, on purpose:

Called with the state now?
watch($store, fn, {scope?}) no, only with updates
$store.watch(fn) yes, then with every update

Use watch for a reaction in a scope. A conformance vector's watch records the state now as well, which is why a trace such as the diamond's in wiring starts with each store's state.

inspect

inspect is imported from its own subpath, @fiberfx/hypergraph/inspect, so an app that never inspects does not carry it.

inspect({scope?, kinds?, fn}) calls fn with each observation, and returns a Subscription.

  • With scope, it sees that scope only. Without one, it sees the work outside any scope, and no scoped work.
  • With kinds, it sees only the kinds listed.
  • When the last subscriber unsubscribes, the kernel stops recording, so inspection costs nothing after.

An observation is {type, unit, scope, gen, value, cause}. unit is the unit's sid, or a run-time identity for a unit no compiler named yet; cause is the nearest unit upstream in the launch, or null for a call from outside.

import { allSettled, event, fork, store } from "@fiberfx/hypergraph";
import { inspect, type Observation } from "@fiberfx/hypergraph/inspect";

const increment = event();
const $count = store(0, { sid: "count" }).on(increment, (n) => n + 1);

export async function updates() {
  const scope = fork();
  const seen: Observation[] = [];
  const subscription = inspect({ scope, kinds: ["update"], fn: (o) => seen.push(o) });
  await allSettled(increment, { scope });
  subscription.unsubscribe();
  return seen.filter((o) => o.unit === "count").map((o) => o.value);
}

The kinds of observation

type When Extra fields
update a unit took a value
skip a step was stopped: a filter, an equal or undefined store update, an updateFilter refusal
fail a user function threw; value is the error
declare a unit was made; value is its Declaration fresh when it is new in its generation
perform an effect started at, ms since the epoch
settle an effect finished at
reset a value was not carried across a swap or a frame reason

skip is information, which kinds can leave out; it answers "why did this sample not fire". declare goes to unscoped inspectors. A Declaration is {unit, kind, sid?, name?, derived?, owner?, gen}; owner is not given yet, until the compiler names it.

A declare marked fresh is a store that only the new generation has: it starts from its initial value and nothing was lost, so it is information, not a reset (the vector hmr.new-is-information-not-a-reset).

describe

describe(reason), from the same subpath, words a reset's reason the way a developer reads it, for a console, an overlay or DevTools:

reason describe(reason)
{kind: "removed"} removed
{kind: "shape", from, to} shape changed from <from> to <to>
{kind: "renamed", to} renamed (a hint, not applied: it may now be <to>)
{kind: "positional"} positional
{kind: "new"} new
import { describe } from "@fiberfx/hypergraph/inspect";

export const line = describe({ kind: "shape", from: "runtime:number", to: "runtime:string" });
// "shape changed from runtime:number to runtime:string"

The types Observation, Declaration, Kind and ResetReason are exported from @fiberfx/hypergraph/inspect too.