Skip to Content

Workflow

Use workflow() to verify business lifecycles across multiple contract cases — register then login, create then read then delete, request an export then poll until it is ready.

workflow() is the current lifecycle API. The old contract.flow() API has been removed — use workflow() instead.

When to use

  • Cross-endpoint lifecycle verification: one case’s output feeds the next
  • Data passed between contract cases
  • Bounded polling of an async contract case
  • Branching business paths that should be visible in projection
  • Reusable lifecycle fragments or data-driven workflow matrices

For single-endpoint behavior, use Contract HTTP. For opaque exploratory runtime checks, use test() API.

Basic workflow

A workflow composes existing contract cases. It does not redeclare endpoints — you reference cases already defined in your contract.http.with() specs and wire them together with pure mapping functions.

An in lens returns the case’s logical input — the shape declared by the case’s needs schema — not HTTP fields. The case’s own function-valued action fields (body, pathParams, query, headers) map that input onto the wire. A case consumed via in must declare needs:

// users.contract.ts — cases consumed by the workflow declare `needs` // and map it with function-valued action fields: export const getUser = userApi("get-user", { endpoint: "GET /users/:id", cases: { success: defineHttpCase<{ id: string }>({ description: "Existing user returns full profile", needs: z.object({ id: z.string() }), pathParams: ({ id }) => ({ id }), expect: { status: 200, schema: UserSchema }, }), }, });
import { workflow } from "@glubean/sdk"; import { createUser, getUser, deleteUser } from "./users.contract.ts"; export const userLifecycle = workflow({ id: "user-lifecycle", name: "User lifecycle", tags: ["users", "e2e"], }) .call("create-user", createUser.case("success"), { out: (_state, res) => ({ userId: res.body.id as string }), }) .call("read-user", getUser.case("success"), { in: (state) => ({ id: state.userId }), }) .call("delete-user", deleteUser.case("success"), { in: (state) => ({ id: state.userId }), });

The trailing .build() call is optional. Exported workflow builders auto-build and are discovered by the scanner and runner.

Mental model

LayerAPIPurpose
Endpoint promisecontract.http.with()Defines what one endpoint must do
Lifecycle promiseworkflow()Defines how endpoint promises work together
Runtime evidencetest()Captures imperative checks that should not be projected

Workflows execute, but they also project. Every node gets a projection grade:

GradeMeaning
fullDeclarative and statically projectable
partialOpaque logic with declared reads/writes/assertion hints
traceRuntime evidence promotes an otherwise opaque node
opaqueOnly node identity/result are known

Prefer full or partial nodes for workflows that Cloud, reviewers, or agents should understand.

Node APIs

setup()

Runs once before workflow nodes and returns the initial state. Use it for lifecycle setup that belongs to the whole journey.

export const checkout = workflow("checkout") .setup(async (ctx) => ({ tenantId: ctx.vars.require("TENANT_ID"), })) .call("create-cart", createCart.case("success"), { // logical input — the case's `body: ({ tenantId }) => ...` builds the request in: (s) => ({ tenantId: s.tenantId }), out: (s, res) => ({ ...s, cartId: res.body.id as string }), });

call()

Calls an existing contract case. Use in to map workflow state to case input and out to fold the response back into state.

.call("pay-order", payOrder.case("success"), { in: (s) => ({ id: s.orderId }), out: (s, res) => ({ ...s, paymentId: res.body.paymentId as string }), })

in returns the case’s needs-shaped logical input (see Basic workflow). in and out must be pure synchronous mapping functions — field lookup and repacking only. Do not do I/O or return a Promise from them. Put arbitrary async work in action().

compute()

Pure synchronous state transform. Use it for reshaping, derived fields, or values that would make an in / out mapping hard to read, such as template literals, .map(), or method calls.

.compute("auth-header", (s) => ({ ...s, authHeader: `Bearer ${s.token}`, }))

action()

Arbitrary async work. Add project hints when the action matters to reviewers.

.action( "seed-inventory", async (ctx, s) => { const item = await seedInventory(ctx, s.tenantId); return { ...s, itemId: item.id }; }, { project: { writes: ["itemId"], note: "seed inventory for checkout" } }, )

action() supports explicit retry metadata on safe, idempotent actions:

.action("warm-cache", warmCache, { retry: { attempts: 3, delay: 500, reason: "cache warmup is idempotent" }, })

check()

Use check() for workflow-state assertions. Prefer the declarative form when projection matters.

.check("cart-total", { expect: (w) => [ w.when((s: { total: number }) => s.total).gt(0), ], })

Use the callback form when the assertion is necessarily imperative. Add a projection hint.

.check( "discount-applied", async (ctx, s) => { ctx.assert(s.discount === "SUMMER25", "Checkout applies the selected promo code", { actual: s.discount, expected: "SUMMER25", }); }, { project: { asserts: "selected promo code is applied to checkout total" } }, )

branch()

Two-way branch. A declarative when projects as full; a runtime whenRuntime is opaque and must include a message.

.branch("inventory-path", { when: (w) => w.when((s: { inStock: boolean }) => s.inStock).eq(true), then: (b) => b.call("reserve", reserveItem.case("success")), else: (b) => b.call("backorder", createBackorder.case("success")), })

switch() and route()

Use switch() when the cases converge and the workflow continues. Use route() when each path owns the rest of the workflow and no trunk continues.

route() here names a terminal branch in the builder API. It is not an HTTP route; the durable product artifact is still the workflow.

.switch("fulfillment-method", { on: (s: { method: string }) => s.method, cases: [ { value: "ship", then: (b) => b.call("ship", shipOrder.case("success")) }, { value: "pickup", then: (b) => b.call("pickup", createPickup.case("success")) }, ], default: (b) => b.check("unsupported-method", async (ctx) => ctx.fail("Unsupported fulfillment method")), })

For a terminal tree, use route(). A default branch is required, and only .teardown() or .build() may follow it:

.route("fulfillment-terminal", { on: (s: { method: string }) => s.method, cases: [ { value: "ship", then: (b) => b.call("ship", shipOrder.case("success")) }, { value: "pickup", then: (b) => b.call("pickup", createPickup.case("success")) }, ], default: (b) => b.check("unsupported-method", async (ctx) => ctx.fail("Unsupported fulfillment method")), })

poll()

Use poll() to repeat a contract case until a declarative or runtime exit predicate holds. Bounds are required: every poll must have a total stop condition (timeout or maxAttempts) and a finite per-attempt budget (timeout can serve as both; otherwise set perAttemptTimeout).

.poll("wait-for-export", getExport.case("ready"), { in: (s) => ({ id: s.exportId }), until: (w) => w.when((res: { body: { state: string } }) => res.body.state).eq("ready"), timeout: 60_000, every: 2_000, out: (s, res) => ({ ...s, downloadUrl: res.body.downloadUrl as string }), })

Inbound cases are also awaited with poll(), using a receiver supplied by via.

.poll("wait-for-stripe-webhook", stripeWebhook.case("paymentIntentCreated"), { via: (s) => s.receiver, correlate: { event: (event) => event.data.object.metadata.orderId, state: (s) => s.orderId, }, timeout: 60_000, out: (s, event) => ({ ...s, stripeEventId: event.id }), })

Use pollAction() only when the repeated probe is not modeled as a contract case yet.

use() and group()

Reusable fragments are plain functions over the builder. Use .use() to apply a fragment and .group() to visually group nodes in reports.

import type { WorkflowBuilder } from "@glubean/sdk"; const withLogin = (b: WorkflowBuilder<undefined>) => b .call("login", login.case("success"), { out: (_s, res) => ({ token: res.body.token as string }), }); export const profileJourney = workflow("profile-journey") .use(withLogin) .group("profile", (b) => b .call("read-profile", getProfile.case("authorized"), { // the case declares needs<{ token }> and maps it: // headers: ({ token }) => ({ Authorization: `Bearer ${token}` }) in: (s) => ({ token: s.token }), }) );

Data-driven workflows

Use workflow.each() for deterministic workflow matrices.

export const checkoutByRegion = workflow.each([ { region: "us", currency: "USD" }, { region: "eu", currency: "EUR" }, ])( { id: "checkout-$region", tags: ["checkout"], tagFields: ["region"] }, (wf, row) => wf .setup(async () => ({ region: row.region, currency: row.currency })) .call("quote", quote.case("success"), { in: (s) => ({ region: s.region }), }), );

Every row must project the same workflow structure. Row data should enter through setup() or closed-over literals, not by conditionally adding different nodes per row.

Setup, teardown, and cleanup

Use workflow().setup() for journey-level initial state and workflow().teardown() for journey-level cleanup. Contract cases stay pure — they do not own setup/teardown.

import { configure, workflow } from "@glubean/sdk"; const { http: api } = configure({ http: { prefixUrl: "{{BASE_URL}}" }, }); export const orderLifecycle = workflow("order-lifecycle") .setup(async (ctx) => ({ tenantId: ctx.vars.require("TENANT_ID") })) .call("create-order", createOrder.case("success"), { in: (s) => ({ tenantId: s.tenantId }), out: (s, res) => ({ ...s, orderId: res.body.id as string }), }) .teardown(async (ctx, s) => { await api.delete(`orders/${s.orderId}`); });

workflow() vs test()

Use workflow() when the lifecycle itself is a product promise worth projecting. Use test() when the value is runtime evidence and the logic is intentionally imperative.

Good workflow() candidates:

  • checkout lifecycle
  • async export lifecycle
  • webhook delivery lifecycle
  • cross-protocol provisioning lifecycle

Good test() candidates:

  • exploratory endpoint probes
  • browser-heavy automation
  • one-off diagnostics
  • complex imperative checks that would become opaque workflow nodes anyway

Next

Last updated on