Releases: ahmedrowaihi/iterativeflow
v4.1.0
Minor Changes
-
ccda5c6: Add
engine.retry(runId)— replay afailedrun from the step that failed. Memoizedokstep results are preserved; thefailed_terminalstep row is deleted, the run is reset topendingwithattempts=0, aresumedevent is recorded, and the run is re-enqueued atomically. Returns aRetryResultdiscriminated bykind:"queued","missing", or"not_failed"(with the current status).This is replay, not restart: a fresh
handle.start(input)is a brand-new run and re-executes every step.engine.retry(runId)resumes the samerunIdand skips work already done.
Patch Changes
-
d0fcf0d: Fix
engine.status()andengine.listRuns()rows showing every column asunknownon the consumer side. The root cause:RunRow(and friends) weretypeof runs.$inferSelect, which carries drizzle's column brand into the bundled.d.ts. The bundle re-renders drizzle under a vendored namespace, so a consumer's drizzle copy can't dereference the brand — every per-column inference collapses tounknown.RunRow,StepRow,TimerRow,SignalRow,EventRoware now hand-written interfaces with concrete field types (id: string,status: RunStatus,createdAt: Date,error: FlowError | null,tags: string[] | null, jsonb columns asunknown, etc.). A compile-time equivalence check pins each interface to drizzle's$inferSelectof the runtime table, so a column rename or type change here fails the build instead of drifting.No runtime change; structurally identical shapes — consumers just get usable types in
engine.status().run.nameetc. without casts.
v4.0.0
Major Changes
-
e0c14ad: Stop hiding consequential behavior behind defaults.
Two defaults silently took actions the developer didn't ask for. Both now hand the decision back:
StepOpts.retriesdefaults to0(was3). A step runs once and its failure is terminal unless you opt in withretries: N. Previously every step silently retried up to 4× with exponential backoff; you had to writeretries: 0to get a single run. (Steps re-run on crash recovery regardless, so side-effecting bodies should already be idempotent.)engine.listRuns({ limit })throws whenlimit > 500instead of silently clamping to 500. Asking for more than the max now surfaces an error rather than truncating the page without a signal.
Migration: if you relied on automatic step retries, add
retries: 3(or your preferred count) to thosectx.step(...)/.step(...)calls. If you passedlistRuns({ limit })above 500, lower it to ≤ 500. -
e0c14ad: Group
EngineOptsinto descriptive config blocks.The flat options bag is replaced with four nested groups so related settings live together and each group's defaults are documented on the hover. Switchable subsystems (
reconciler,retention) takefalse | { … }; always-on tuning (worker,limits) takes{ … }. New:reconciler.schedulelets you change the sweep cadence (previously hardcoded to every minute).Migration:
Before (v3) After (v4) workerSchemaworker.schemaconcurrencyworker.concurrencypollIntervalworker.pollIntervalenqueueworker.enqueuedisableReconciler: truereconciler: falsereconcilerGraceMsreconciler.graceMsrunningStuckMsreconciler.runningStuckMsmaxRunAttemptslimits.maxRunAttemptsdefaultStepTimeoutMslimits.defaultStepTimeoutMsretentionandlimits(size caps) keep their fields;limitsnow also holdsmaxRunAttemptsanddefaultStepTimeoutMs.// before createEngine({ db, pool, workerSchema: "gw", concurrency: 10, disableReconciler: true, maxRunAttempts: 50, }); // after createEngine({ db, pool, worker: { schema: "gw", concurrency: 10 }, reconciler: false, limits: { maxRunAttempts: 50 }, });
Minor Changes
-
e0c14ad: Export
isSuspendandFlowSuspendfrom the public API.ctx.sleep/ctx.signal/ctx.invokepark a run by throwingFlowSuspend. Because it extendsError, atry/catcharound actx.*call silently swallows the suspend and the run never parks. These were@internal, so consumers had no way to guard. The correct pattern is now expressible:try { await ctx.signal("approval", { timeout: "24h" }); } catch (err) { if (isSuspend(err)) throw err; // let the run park // ...handle real errors }
Patch Changes
-
e0c14ad: Reap orphaned
cron:*jobs on worker startup.When a cron is removed from code, graphile-worker stops scheduling it but already-enqueued
cron:<name>jobs linger with no task handler — they sit forever, erroring across deploy cutovers.startGraphileWorkernow runs a best-effort purge afterrun(), completing anycron:*job whose task is no longer registered. It never throws, so a reap failure can't block worker startup.The cron policy (jitter, overlap, reaping) now lives in its own
cronmodule that the graphile adapter drives. -
e0c14ad: Recover runs whose worker crashed mid-execution.
A run that died while
status = runningcould never resume: the reconciler re-enqueued it but left the statusrunning, andclaimRunrejectsrunningas "lost" — so the re-enqueued job was skipped forever and the run hung permanently. The reconciler now resets a stuckrunningrun toretryingbefore re-enqueuing, so the next claim succeeds. Guarded by the existingreconciler.runningStuckMsthreshold (default 10 min).
v3.1.0
Minor Changes
-
f808fa1:
engine.status()andengine.listRuns()now return rows with your drizzle-inferred types instead ofunknown.Engine,EngineOpts,RunDetail, andListRunsPageare generic overT extends FlowTableswith a sensible default. Passtablesfrom your generated schema and any custom columns you added flow through end-to-end — withouttables, the rows reflect the engine's internal table shape.Also exports the row types (
RunRow,StepRow,TimerRow,SignalRow,EventRow),DefaultFlowTables, and theRow<T>helper.
v3.0.2
Patch Changes
-
77b0f22: Fix JS
Datebinding in rawsql\`fragments — caused runtime failures onpostgres-js/neon-serverlessdrivers, which (unlikenode-postgres) don't natively encodeDate` in positional params when drizzle hasn't propagated column type info.Three sites affected:
reconcile.ts—${runs.updatedAt} < ${olderThan}rewritten via drizzle's typedlt(col, date)so the column'stimestamptzencoder runs. TheEXISTSsubqueries (no JS values, onlyNOW()) stay raw.queries.ts— cursor tuple compare(createdAt, id) < (...)casts the JS-Date param to::timestamptzin SQL. Tuple compare can't go throughlt, so the cast is the cheapest correct fix.adapters/graphile/index.ts—add_job(... run_at => ${opts.runAt} ...)cast to::timestamptzfor the same reason. Affected every delayed enqueue (sleeps, retries,delaystart opt).
Consumers using postgres-js or neon-serverless no longer need to spin up a separate
node-postgreshandle for the engine's pool.A single
ts(date)helper insrc/util/sql-params.tscentralizes the cast — every Date param in a rawsql\`` fragment goes through it. Easier to grep for, easier to extend (uuid/bigint/etc.) if the next driver-portability footgun shows up. -
fcc8f99: Three more boot-time footgun warnings + structural cleanup.
Warnings (operator-tunable defaults that silently bite under load):
flow.config.unbounded_step_timeout— nodefaultStepTimeoutMsset; a hung step pins a worker slot indefinitely. SetdefaultStepTimeoutMs(or passStepOpts.timeoutMson every step).flow.config.no_retention— noretentionconfigured;workflow.eventsand terminalworkflow.runsgrow forever. SetEngineOpts.retentionor run your own prune cron.- (already shipped last patch)
flow.config.stuck_shorter_than_step_timeout— reconciler would resurrect a still-running step.
Stderr fallback for warnings. When
EngineOpts.loggerisn't provided, the engine now uses a logger that pipeswarn/errortoprocess.stderr(debug/info stay silent). Previously the default was a full noop — boot validators warned into the void. Consumers who genuinely want silence still get it by passing their own no-op logger.Internal restructure. Extracted
src/engine/internal-crons.ts(reconciler + retention cron builders) andsrc/engine/loggers.ts(fallback + console presets).engine.ts464 → 413 lines;createEnginereads more linearly. Default magic numbers consolidated into named constants, using the existingtoMs("1m")/toMs("10m")duration helpers for self-documenting time values. -
645bc2a: Boot validator + docs for restart behavior.
Validator — warns at engine boot when
runningStuckMs < defaultStepTimeoutMs. The mismatch produces a real bug class: a step running between the two bounds is indistinguishable from a crashed process, so the reconciler resurrects it and you get two concurrent attempts of the same run.createEngine({ runningStuckMs: 60_000, // 1 min defaultStepTimeoutMs: 30 * 60_000, // 30 min ← BAD: step can outlive stuck threshold }); // warns: flow.config.stuck_shorter_than_step_timeout
Docs — new "Restart behavior" section in
docs/guide.mdcovers:- What survives a restart (
runningruns → reconciler;sleepingruns → graphile;awaiting_signalruns → DB rows + NOTIFY; idempotency keys; cron advisory locks). - What doesn't (
handle.result/handle.waitin-process Promise waiters die on crash; caller must retry). - At-least-once step semantics — make external calls idempotent.
- Crash-recovery latency =
runningStuckMs(default 10 min); tune lower for tighter recovery, but respect the new validator. - Multi-instance / rolling deploy safety (FOR UPDATE SKIP LOCKED + cross-instance NOTIFY).
- What survives a restart (
v3.0.1
Patch Changes
-
c048de0: Fix
db.execute()result-shape assumption that broke on drivers other thandrizzle-orm/node-postgres.invoke-budgetandschema-versionprobes were readingresult.rows[0], butpostgres-js(and some drizzle 1.x driver builds) return the rows array directly — those consumers were gettingundefined.rowsand patching the dist by hand.Added a
rowsOf()helper that handles both shapes and used it at both call sites.pg_notifyand other fire-and-forget executes are unaffected.
v3.0.0
Major Changes
-
30feb15: v3 — codegen + customizable tables (cross-version drizzle safety + naming flexibility).
Why
Two pain points v3 solves:
-
Drizzle cross-version type breakage. v2 exported table objects typed against drizzle-orm 0.45. Consumers on drizzle-orm 1.0-rc hit TS errors at
db.select().from(runs)because the embeddedPgTableshape didn't match their drizzle. v3 ships no drizzle-typed values; consumers generate their own via the CLI and use their drizzle's types throughout. -
No naming flexibility. v2 hardcoded
workflow.runs,workflow.steps, etc. Consumers couldn't rename tables, change thepgSchemaname, or add custom columns. v3 lets you customize all of it.
What changed (breaking)
iterativeflow/schemasubpath export removed. Previouslyimport { runs } from "iterativeflow/schema"worked; now runnpx iterativeflow generate-schemaand import from your own project file.iterativeflow/relationssubpath removed (same reason).flowSchema,runs,steps,signals,timers,eventsare no longer exported anywhere on the public surface. They moved entirely into a generated file the consumer owns.
What's new
npx iterativeflow generate-schema— emits./iterativeflow-schema.tsat the project root (override with--out). Typed against yourdrizzle-orm, sodb.select().from(flowTables.runs)works on any drizzle version.createEngine({ tables })— optional. PassflowTablesfrom your generated file only if you customize (renamed tables, custompgSchemaname, added columns the engine should see). The defaultcreateEngine({ db, pool })works against the unmodified generated file.applyFlowSchema(db)/dropFlowSchema(db)are now re-exported from the mainiterativeflowentry. UseapplyFlowSchemato install the workflow tables programmatically without drizzle-kit; it reads the bundledmigrations/0000_init.sqldirectly (nodrizzle-kit/apiruntime dependency).
What stayed the same
- The SQL —
migrations/0000_init.sqlis unchanged.psql -f node_modules/iterativeflow/migrations/0000_init.sqlstill works. - Wire-level contracts —
pg_notifychannel names (flow_terminal,flow_progress), cursor key scheme, replay semantics — all unchanged. - Constants and error vocabulary —
RUN_STATUSES,STEP_STATUSES,EVENT_TYPES,FLOW_ERROR_CODES, the derivedRunStatus/StepStatus/EventType/FlowErrorCodetypes, and theFlowErrorinterface stay on the mainiterativeflowentry.
Migration
# 1. Generate the consumer-side schema file npx iterativeflow generate-schema # → wrote ./iterativeflow-schema.ts # 2. Update your drizzle.config.ts # BEFORE: schema: [require.resolve("iterativeflow/schema")] # AFTER: schema: ["./iterativeflow-schema.ts"] # 3. Replace any imports from "iterativeflow/schema" # BEFORE: import { runs } from "iterativeflow/schema" # AFTER: import { flowTables } from "./iterativeflow-schema" # // then: flowTables.runs # 4. If you customize (renamed tables, custom pgSchema, added columns): # pass tables to createEngine: # createEngine({ db, pool, tables: flowTables }) # Otherwise nothing to do — `createEngine({ db, pool })` works as-is.
Same SQL, same column names by default, same wire shape.
Stability discipline
etc/iterativeflow.api.md(tracked vianpm run api:check) gates every change to the public surface. The library's public TS is now ORM-type-free — constants, error types, plain interfaces, and the engine's runtime API only. -
v2.0.1
Patch Changes
- adfaf27: Docs and internal hardening:
- Rewrote
docs/guide.mdto v2 vocabulary (signalnothook,FlowHandlenotWorkflowHandle,engine.listen()notengine.start(), etc.). Multiple v1 names were sitting in published docs after the v2 rename — they're gone now. - Fixed a stray
// hook's timeout firedcomment inREADME.md. - Added a compiled-docs gate:
scripts/extract-doc-examples.mjspulls every```tsblock fromREADME.md+docs/*.mdintotests/docs-examples/, andnpm run docs:checktypechecks them against the local source. Wired into the pre-push hook and CI. Blocks that aren't standalone (signature listings, partial chains) are marked with<!-- doc-check: skip -->. - Added a replay corpus:
tests/replay-corpus/*.jsonare captured suspension-state snapshots (sleep-suspended,signal-suspended,completed-run), andtests/replay-corpus/corpus.test.tsre-inserts each one against a fresh pglite and replays viaplayRunAttemptto verify the run reaches the documented terminal state. Regenerate the corpus deliberately vianpm run corpus:capturewhen the storage shape changes.
- Rewrote
v2.0.0
Major Changes
-
a248675: Major release — unified vocabulary, child flows, blocking
handle.result(),AbortSignalin steps, paginatedlistRuns, retention auto-pruning, payload caps, metrics, and a richer schema. No backwards-compatible aliases — see migration below.Breaking changes
Schema
A
drizzle-kit generate && drizzle-kit migrateis required.- Column rename
step_key/hook_key→cursor_keyacrosssteps,timers,events,signals. - Table rename
workflow.hooks→workflow.signals. - New columns on
runs:parent_run_id,parent_cursor_key,tags text[](GIN-indexed). - Run statuses:
waiting→awaiting_signal; newretryingstatus (split out fromsleeping). - Event types:
hook_armed/hook_resolved/hook_timeout→signal_armed/signal_delivered/signal_timeout. - Error codes:
WORKFLOW_HOOK_TIMEOUT→SIGNAL_TIMEOUT;HOOK_PAYLOAD_INVALID→SIGNAL_PAYLOAD_INVALID;WORKFLOW_SUSPEND_IN_STEP→STEP_INVALID_AWAIT;UNKNOWN_WORKFLOW→FLOW_UNKNOWN;CANCELED→RUN_CANCELED;NON_DETERMINISTIC→REPLAY_NON_DETERMINISTIC;INCOMPATIBLE_VERSION→REPLAY_INCOMPATIBLE_VERSION. New:INVOKE_DEPTH_EXCEEDED,INVOKE_FANOUT_EXCEEDED,SCHEMA_MISMATCH.
The Postgres schema name
workflowis unchanged.API
-
ctx.hook(name)→ctx.signal(name)(and builder.hook()→.signal()). -
engine.start()→engine.listen(). -
engine.defineWorkflow({ run })→engine.register({ ..., body })(or use the builder; both go throughengine.register). -
Step functions now receive a structured argument:
// before await ctx.step("fetch", () => httpGet(url)); // after await ctx.step("fetch", ({ input, signal, attempt }) => httpGet(url, { signal }));
-
engine.signal(runId, name, payload)now returnsSignalDeliveryResultinstead ofvoid:const result = await engine.signal(runId, "approve", { ok: true }); switch (result.kind) { case "delivered": break; // the run was awaiting; now resumes case "buffered": break; // signal arrived first; consumed on arm case "duplicate": break; // already accepted; idempotent case "expired": break; // timeout already fired — reject the webhook }
-
Type renames:
WorkflowContext→FlowContext,WorkflowHandle→FlowHandle,WorkflowError→FlowError,WorkflowErrorCode→FlowErrorCode,WORKFLOW_ERROR_CODES→FLOW_ERROR_CODES,WorkflowRuntimeError→FlowRuntimeError,workflowError→flowError,toWorkflowError→toFlowError,workflowSchema→flowSchema,applyWorkflowSchema→applyFlowSchema,dropWorkflowSchema→dropFlowSchema,HookOpts→SignalOpts,HookNode→SignalNode,WorkflowSuspend→FlowSuspend,RuntimeWorkflowContext→RuntimeFlowContext,DefineWorkflowOpts(runfield) →DefineFlowOpts(bodyfield),SignalResult→SignalDeliveryResult. -
Source layout:
runtime/graphile.ts→adapters/graphile/;tracing.ts→util/tracing.ts. Internal task identifierworkflow:run→flow:run.
New features
Child flows —
ctx.invokeconst order = engine.register(flow("order").step(...).build()); const ship = engine.register(flow("ship").step(...).build()); const fulfill = flow("fulfill") .step("validate", ({ input, signal }) => validate(input, { signal })) .step("place", async ({ input, ctx }) => { const placedOrder = await ctx.invoke(order, input); return ctx.invoke(ship, placedOrder); }) .build();
Child flows have their own
runId, attempts, and snapshot. The parent suspends until the child terminates. Cursor-keyed so resumes don't re-spawn the child.Blocking
handle.result()const { runId } = await handle.start({ userId: "u_1" }); const output = await handle.result(runId, { timeoutMs: 60_000 });
Backed by Postgres
LISTEN flow_terminalwith a row-poll fallback. No more pollinghandle.output()in your code.AbortSignalin step functionsWires the configured
timeoutMsANDengine.cancel(runId)to a singleAbortSignal. Pass it tofetch,pg,undici, OpenAI SDKs..step("call-llm", async ({ input, signal }) => { const res = await fetch(url, { signal, body: input }); return res.json(); }, { timeoutMs: 30_000 })
engine.cancel(runId)now aborts the in-flight controller AND guardsmarkCompleted/markFailedfrom overwriting the canceled tombstone.Run listing —
engine.listRunsconst page = await engine.listRuns({ name: "onboard", status: ["failed", "awaiting_signal"], tag: "tenant:acme", since: new Date(Date.now() - 24 * 60 * 60_000), limit: 50, });
Keyset pagination on
(createdAt, id). Composes with the newtagscolumn (GIN-indexed).await handle.start(input, { tags: [`tenant:${tenantId}`, "priority:high"] });
Retention auto-pruning
createEngine({ db, pool, retention: { eventsOlderThan: "30d", runsOlderThan: "90d", schedule: "0 * * * *", // default hourly }, });
Payload size caps
createEngine({ db, pool, limits: { maxInputBytes: 256 * 1024, maxStepResultBytes: 256 * 1024, maxSignalPayloadBytes: 64 * 1024, }, });
Oversized values throw before they hit the database.
Metrics
createEngine({ db, pool, metrics: { runStarted: ({ name }) => counters.runs_started.inc({ name }), stepFinished: ({ status, durationMs }) => histograms.step.observe({ status }, durationMs), signalDelivered: ({ kind }) => counters.signals.inc({ kind }), }, });
All methods are optional; methods you don't supply are no-ops. Available:
runStarted,runCompleted,runFailed,runSuspended,stepFinished,signalDelivered,reconcilerSweep.Operational helpers
const engine = createEngine({ db, pool, logger: consoleLogger() }); engine.attachShutdownSignals(); // SIGTERM/SIGINT → engine.stop() await engine.listen(); const health = await engine.health(); // { ok, db, worker, startedAt }
loggeris now optional (defaults to a noop logger).Cron — timezone, overlap, jitter
engine.defineCron({ name: "nightly-report", schedule: "0 2 * * *", timezone: "America/Los_Angeles", overlap: "skip", // default — prevents concurrent runs via PG advisory lock jitterMs: 60_000, run: async () => generateReport(), });
Hard ceilings
createEngine({ db, pool, maxRunAttempts: 100, // hard ceiling — stops poison-pill loops defaultStepTimeoutMs: 30 * 60_000, // fallback when StepOpts.timeoutMs is not set });
Exhausted runs fail with
RUN_ATTEMPTS_EXHAUSTED.Schema fingerprint at boot
The engine reads
information_schemafor marker columns on firstlisten()/ firsthandle.start()and throwsSCHEMA_MISMATCHif the schema is at the wrong version. The error message tells you exactly which migration to run.// If the schema is at v1 (or not applied): // Error: SCHEMA_MISMATCH: schema is at v1, engine expects v2 — run `drizzle-kit generate && drizzle-kit migrate`
Eliminates the rolling-deploy class of "engine code expects v2 schema, DB is still v1, runs silently fail" failures.
Hard caps on
ctx.invokelimits.maxInvokeDepth(default10) andlimits.maxChildrenPerRun(default1000) stop accidental infinite recursion and runaway fan-out:createEngine({ db, pool, limits: { maxInvokeDepth: 10, // root = 1; throws INVOKE_DEPTH_EXCEEDED if exceeded maxChildrenPerRun: 1000, // throws INVOKE_FANOUT_EXCEEDED if exceeded }, });
Boot-time validators
createEnginenow fails fast on operator misconfiguration:logger— missingdebug/info/warn/errorthrows on construction.retention.runsOlderThan/eventsOlderThan— invalid durations throw on construction instead of failing at the first cron tick.pool.options.maxvsconcurrency— whenconcurrency > pool.max, the engine emitslogger.warn("flow.config.pool_too_small", { concurrency, poolMax }).defineCron({ schedule })— invalid cron patterns throw at registration time, not atlisten().
Bundle size budget
npm run size:checksums the gzipped sizes ofdist/*.jsand fails CI if the total exceeds the configured budget (default320 kB, override viaSIZE_BUDGET_KB). Current footprint is ~22 kB gzipped, so the budget is roomy on purpose — it's a regression guard, not a limit.Resilient LISTEN reconnect
The Postgres
LISTENsubscription that powershandle.result()/handle.wait()now reconnects on its own. Previously a single connection error would permanently degradehandle.result()to a row-poll fallback until the engine was restarted.- State machine:
idle → connecting → listening → reconnecting → stopped. - Multi-channel: subscribes to
flow_terminalANDflow_progressover a single connection. - Exponential backoff
1s → 30s(capped), with jitter. - Single in-flight loop guarded by an
AbortController; cancelled cleanly onengine.stop(). engine.health()reportslisten: booleanso probes can distinguish "engine up, LISTEN down" from "engine fully healthy".- Verified by an integration test that calls
pg_terminate_backend()on the LISTEN backend and checks that a freshpg_notifyround-trip still wakes its waiter. - Multi-instance coverage: a dedicated test suite spins up two engines against the same Postgres and verifies cross-instance
handle.result(),handle.wait(),engine.signal(), and `engine.can...
- Column rename
v1.0.0
Major Changes
-
973cd2b: ## v1.0 — durable, iterative workflows on your own Postgres
const onboard = flow("onboard") .input(z.object({ userId: z.string() })) .step("create-account", ({ input }) => createAccount(input.userId)) .sleep("3d") .hook("survey", { schema: z.object({ score: z.number() }) }) .output(({ input }) => ({ score: input.score })) .build(); const handle = engine.register(onboard); const { runId } = await handle.start({ userId: "u_1" }); // 3 days later, from a webhook: await engine.signal(runId, "survey", { score: 9 });
The run lives in Postgres for three days. Workers can crash, deploys can roll, the process can be killed and restarted — when the timer fires, the workflow resumes from where it left off.
Inspired by Trigger.dev's workflow SDK and Temporal. Runs inside your Node app on top of graphile-worker and drizzle-orm. No extra service to host.
Engine
- Builder —
flow().step().sleep().hook().loop().output().build()with a single value channel and typed I/O engine.defineWorkflow— raw escape hatch for dynamic graphs and infinite loops- Versioned flows —
INCOMPATIBLE_VERSION/NON_DETERMINISTICon graph drift; never silent corruption - Transactional outbox — state writes + queue insert commit atomically; reconciler re-enqueues orphans
- Lock-order rule —
runs FOR UPDATEfirst everywhere; no deadlock by construction - Per-step
timeoutMsso a hung function can't pin a worker forever - Retention —
engine.pruneEvents/engine.pruneRuns
Compatibility
- Standard Schema for validation — bring your own (zod, valibot, arktype, …)
- Zero runtime dependency on zod or ms
- Works with stable drizzle 0.45+ and graphile-worker 0.16+ (also the v1-rc lines)
Dev + release pipeline
- Pre-commit / pre-push hooks via lefthook — lint, format, typecheck, tests
- Oxc tooling — oxlint + oxfmt for fast lint + format
- PR previews via pkg.pr.new — every PR gets an installable build
- OIDC trusted publishing to npm — no
NPM_TOKEN, no token rotation; releases driven entirely by merging the changesets PR
Full guide:
docs/guide.md. Worked examples (checkout, onboarding, multi-agent + human-in-loop, multi-signer, saga, account deletion):docs/examples/. - Builder —