@async/flow

Compose And Status Helpers

compose(...) creates a handler from ordered steps.

import {
  after,
  bool,
  branch,
  can,
  compose,
  dispatch,
  every,
  flow,
  inspect,
  matches,
  not,
  parallel,
  remember,
  set,
  some,
  status,
  transition,
  when
} from "@async/flow";

const checkout = flow({
  store: {
    step: status("shipping", ["shipping", "payment", "review"]),
    previousStep: null,
    canSubmit: true,
    loading: false,
    orderId: null
  },

  on: {
    next: remember(["step", "previousStep"], [
      transition("step", {
        shipping: "payment",
        payment: "review"
      })
    ]),

    submit: compose([
      when((store) => store.step === "review" && store.canSubmit),
      set("loading", true),
      parallel({
        inventory(_store, input) {
          return reserveInventory(input.form);
        },
        tax(_store, input) {
          return calculateTax(input.form);
        }
      }),
      async (_store, input) => {
        const order = await submitOrder(input.form);
        return order.id;
      },
      (store, _input, orderId) => {
        store.orderId = orderId;
      },
      set("loading", false)
    ])
  }
});

Step Contract

Each step receives:

step(store, input, previous);

store is the Flow store proxy. input is the stable dispatch input.

previous starts as undefined and becomes the last non-undefined value

returned by an earlier step.

compose preserves the receiver for every step, so method-style helpers can use

this.dispatch(...), this.store._user, and other Flow receiver capabilities.

const handler = compose([
  function load(store) {
    return this.store._user.load(store.userId);
  },
  function cache(store, _input, user) {
    store.currentUser = user;
  }
]);

Async Boundaries

If every step is synchronous, the composed handler returns synchronously. It

does not create a promise.

When a step returns a promise-like value, Flow flushes the current synchronous

batch and resumes the remaining steps in a fresh batch after the promise

settles. This makes loading states observable before async work completes.

sync segment:
  loading = true
flush

async work resolves

continuation segment:
  orderId = order.id
  loading = false
flush

Helper Steps

set(key, value) writes store[key] = value.

set("loading", true);
set({ loading: false, error: null });

Values may also be derived from the current store, dispatch input, or previous

compose result:

set("orderId", (_store, _input, order) => order.id);

update(key, fn) writes a value derived from the current store value:

update("count", (count) => count + 1);

when(predicate) stops the composed handler when the predicate returns false:

compose([
  when((store) => store.canSubmit),
  set("loading", true)
]);

Use availability: true when a leading when(...) gate should participate in

pre-dispatch event inspection:

compose([
  when((store) => store.canSubmit, {
    availability: true,
    reason: "cannot_submit",
    label: "Submit order"
  }),
  set("loading", true)
]);

Only leading availability gates are lifted into can(...) and explain(...).

Later gates run at dispatch time only, because earlier steps may mutate the

store before they are evaluated.

dispatch(eventName, payload?) creates a reusable deferred sender. When the

sender is invoked, it dispatches to the invocation target. In composed Flow

handlers, that target is the current Flow receiver. Function payloads receive

(store, input, previous) when the sender runs as a Flow step.

dispatch("finish", (_store, input) => ({ source: input.source }));

Deferred senders can also be reused with other event sinks:

const ready = dispatch("ready", { id: 1 });

ready.call(checkout);
ready.call(element);
ready.emit(emitter);
ready.send(sender);

Use target-first dispatch for immediate sends:

dispatch(checkout, "ready", { id: 1 });
dispatch(this, "ready", { id: 1 });
dispatch(element, "ready", { id: 1 });
dispatch(emitter, "ready", { id: 1 });
dispatch(sender, "ready", { id: 1 });

after(ms, eventName, input?) schedules another Flow event through the current

Flow receiver.

after(5000, "checkJobStatus", (store) => ({ id: store.jobId }));

after(ms, callback, input?) creates a standalone cancellable timer helper.

The callback receives either the input passed when the helper is called or the

fixed input passed when the helper was created.

const markReady = after(100, (next) => {
  phase.set(next);
}, "ready");

const cancel = markReady();
cancel();

branch(cases) runs the first matching case. Tuple cases are

[condition, handler]; a bare handler is the default case. Conditions may be

predicate functions or computed boolean helpers.

branch([
  [(store) => store.jobStatus === "SUCCEEDED", dispatch("reportJobSucceeded")],
  [(store) => store.jobStatus === "ERROR", dispatch("reportJobError")],
  compose([
    set("step", "WaitForCompletion"),
    after(5000, "checkJobStatus")
  ])
]);

onError(handle, handler) maps sync throws and async rejections:

onError(
  (error) => ({ error: error.message }),
  async () => {
    throw new Error("failed");
  }
);

Plain object handler results are applied as store updates by Flow dispatch.

When a composed step returns a plain object as service data instead of store

updates, map it to a primitive or write it into the store before the composed

handler finishes.

parallel(branches) runs independent branch steps at the same point in a

composed handler, waits for every async branch, and returns undefined.

compose([
  set({ syncing: true, error: null }),
  parallel({
    user() {
      return this.store._user.reload();
    },
    cart() {
      return this.store._cart.reload();
    }
  }),
  set("syncing", false)
]);

Use parallel(...) for effect fan-out/fan-in. It does not create parallel

state regions and it does not collect branch results by default.

remember(mapping, steps) captures source store values before scoped work and

writes those captured values to explicit target fields after the scoped work

succeeds, but only when a source changed.

remember(["step", "previousStep"], [
  transition("step", {
    shipping: "payment",
    payment: "review"
  })
]);

Multiple mappings are supported:

remember([
  ["step", "previousStep"],
  ["mode", "previousMode"]
], [
  transition("step", { shipping: "payment" }),
  set("mode", "editing")
]);

remember(...) stores previous values in author-chosen fields. It does not add

hidden history, rollback, or transaction behavior.

Status Helpers

status(initial, allowed?) creates a writable finite status signal. It works

standalone or as a Flow store declaration.

const order = flow({
  store: {
    step: status("shipping", ["shipping", "payment", "review"]),
    canSubmit: true,
    canGoNext: can("step", "next"),
    inReview: matches("step", "review"),
    readyToSubmit: every(matches("step", "review"), (store) => store.canSubmit),
    submitBlocked: not((store) => store.readyToSubmit)
  },

  on: {
    next: transition("step", {
      shipping: "payment",
      payment: "review"
    }),

    submit: guard(
      (store) => store.readyToSubmit,
      set("submitted", true)
    )
  }
});

transition(statusName, rules) writes the next status when a rule matches.

Missing matches are no-ops. Rule when fields accept the same boolean

conditions as guard(...) and when(...).

transition("step", {
  from: "review",
  to: "submitted",
  when: every(matches("step", "review"), (store) => store.canSubmit)
});

Use a status name when the transition belongs to a Flow store; that keeps

can(...), explain(...), and inspect(...) tied to public store metadata.

Use live status refs when the helpers are standing alone:

const phase = status("idle", ["idle", "dragging", "dropped"]);

const startDragging = transition(phase, {
  idle: "dragging"
});

const drop = transition(phase, {
  dragging: "dropped"
});

const canStartDragging = can(startDragging);
const canDrop = can(drop);
const isDragging = matches(phase, "dragging");
const dropReady = every(isDragging, canDrop);

canStartDragging.get(); // true
canDrop.get(); // false
isDragging.get(); // false
dropReady.get(); // false

startDragging();

phase.get(); // "dragging"
canStartDragging.get(); // false
canDrop.get(); // true
isDragging.get(); // true
dropReady.get(); // true

drop();

phase.get(); // "dropped"
canDrop.get(); // false

set(statusRef, value), update(statusRef, fn), bool(ref),

every(ref, ...), some(ref, ...), and not(ref) also work directly with live

refs. Use store names when a helper should integrate with Flow inspection; use

refs when the helper should stand alone.

Use inspect(...) for standalone helper metadata and Flow inspection:

inspect(phase);
// { type: "status", value: "dropped", allowed: ["idle", "dragging", "dropped"] }

inspect(startDragging);
// {
//   type: "transition",
//   target: {
//     type: "status",
//     value: "dropped",
//     allowed: ["idle", "dragging", "dropped"]
//   },
//   rules: [{ conditional: false, from: "idle", to: "dragging" }]
// }

can(statusName, eventName) computes whether a transition handler can move

from the current status.

can(eventName) computes whether an event is available now. It infers the

status metadata from the event instead of repeating the status name.

can(flow, eventName, input?) returns a standalone computed ref that follows a

Flow instance's event availability.

can(transitionStep, input?) returns a standalone computed ref for a

transition(statusRef, rules) helper.

const checkout = flow({
  store: {
    step: status("shipping", ["shipping", "payment", "review"]),
    canAdvance: can("next")
  },
  on: {
    next: transition("step", {
      shipping: "payment",
      payment: "review"
    })
  }
});

matches(statusName, value) computes whether the current status matches a

value. matches(statusRef, value) returns a standalone computed ref for a live

status signal. The value may also be an array.

const dragging = matches("phase", ["dragging", "overTarget"]);

const phase = status("idle", ["idle", "dragging"]);
const isDragging = matches(phase, "dragging");

phase.set("dragging");
isDragging.get(); // true

bool(condition) coerces one condition to a computed boolean. every(...),

some(...), and not(...) compose boolean conditions without inline &&,

||, or !.

const cardDrag = flow({
  store: {
    phase: status("idle", ["idle", "dragging", "overTarget"]),
    cardId: null,
    overColumnId: null,
    dragging: matches("phase", ["dragging", "overTarget"]),
    dropReady: every(
      matches("phase", "overTarget"),
      (store) => store.cardId,
      (store) => store.overColumnId
    ),
    blocked: not(can("drop"))
  },

  on: {
    drop: guard(
      every(
        matches("phase", "overTarget"),
        (store) => store.cardId,
        (store) => store.overColumnId
      ),
      set("dropped", true)
    )
  }
});

Conditions can be predicate functions, computed definitions from helpers such

as matches(...) or can(...), or boolean helpers. Predicate conditions

receive (store, input, previous), matching composed handler steps.

guard(predicate, handler) skips the handler when the predicate is false.

Imported can(flow, eventName, input?) returns a computed event availability

ref without dispatching the event. Availability uses Flow-visible transition

metadata, guard metadata, and explicit leading when(..., { availability: true })

gates. Plain composed handlers remain callable unless they publish availability

metadata.

can(checkout, "next").get(); // true
can(checkout, "submit", { confirm: true }).get();

flow.explain(eventName, input?) and receiver

this.explain(eventName, input?) return stable reason data for allowed and

blocked events. Applications should map reason codes to user-facing text.

checkout.explain("submit");
// {
//   event: "submit",
//   allowed: false,
//   reason: "guard_failed",
//   source: "guard"
// }

Built-in reason codes are:

unknown_event
allowed
plain_handler
no_matching_transition
transition_condition_failed
guard_failed

Availability gates can return custom reason values through their metadata.

Transition rules and guards may carry reason and label metadata:

guard(
  (store) => store.step === "review" && store.canSubmit,
  transition("step", { review: "submitted" }),
  {
    reason: "cannot_submit",
    label: "Submit order"
  }
);

Imported inspect(flow) returns public inspection data for store entries,

handlers, transitions, and guards. Availability gates lifted from composed

handlers appear as guard metadata.

const description = inspect(checkout);

description.handlers; // ["next", "submit"]
description.store.step.type; // "status"
description.transitions.next.status; // "step"

Inspections are fresh snapshots. They do not expose raw handler

functions, guard predicates, availability predicates, or transition condition

functions.

Transition, standalone transition, standalone dispatch, standalone after, guard,

and availability metadata use public symbols:

import {
  AVAILABILITY,
  GUARD,
  STANDALONE_AFTER,
  STANDALONE_DISPATCH,
  STANDALONE_TRANSITION,
  TRANSITION
} from "@async/flow";