Store-agnostic React orchestration for the @sanity/workflow-engine reactive
session. You supply a WorkflowObserver (how to watch documents in your
store); the hooks do the rest:
useWorkflowSession — one instance, live: feeds the engine session
ready-gated and returns {evaluation, ready, invalid, error, guards, tick, fireAction, editField, previewField, discardFieldPreview}.
A settled commit re-evaluates immediately — no store echo needed.useWorkflowInstances — a live, filterable instance list (definition /
stage / document / completed) for list, board, and inbox surfaces. Returns
{instances, loading, unreadable, error} — rows the read discipline
refuses are skipped from instances and flagged in unreadable, so one
bad row can't sink the listing and a silently short list stays impossible.
Rows are committed instance docs — feed one to useWorkflowSession for
its evaluation and verdicts.useInstancePreviews — every run in the tag partition as the engine's
light preview projection, drained page by page until the whole filtered set
is held (drained: true means counts are totals) and then kept fresh from
the change feed: an event refetches the touched previews alone, and a feed
reset re-drains the whole partition. Returns
{previews, loading, drained, unreadable, error}.useDocumentWorkflows — which in-flight instances reference this document
(GDR URI), matched by the engine's exact watch-set semantics
(instanceWatchesDocument). Returns {instances, loading, unreadable, error}.useWorkflowTelemetry — the host's @sanity/telemetry logger, for
createEngine({telemetry}). The core also logs one adoption event,
Workflows Session Started, through the host's provider when a session is
created; it creates no store and resolves no consent — outside a provider
everything no-ops.Use @sanity/workflow-sdk (App SDK) or @sanity/workflow-studio (Studio)
instead of this package unless you're building a new adapter.
Two entries: the root exports the hooks, their result types, and
isInvalidDoc/InvalidDoc; the ./observer entry is the adapter SPI —
the observer contract plus the store-building toolkit (the validated-store
wrappers validatedInstanceStore / validatedGuardsStore /
validatedInstancesStore / validatedPreviewPage, combineDocStores,
combineGuardStores, mapSnapshot, the ref-routing helpers, NO_GUARDS,
useKeyed). App code never imports from ./observer; adapters build against
it.
editField({field, mode, value}) edits an EditableFieldEvaluation returned by
the same session's evaluation.editableFields; callers pass that field object
directly and the session derives its engine address. The paired previewField
and discardFieldPreview verbs accept the same evaluated field, so inline
controls do not need to derive an edit target. Low-level target overloads serve
adapter-owned controls that do not start from an evaluated field. Each call is a discrete
COMMIT (a history entry, a guard refresh, a cascade that can move the
instance, an ifRevisionId write) — not a draft patch. Commits ride
deliberate boundaries — blur, Enter, a single-gesture control (switch,
picker), an explicit Save — never a timer and never an onChange per
keystroke: every keystroke would write history and race the revision check.
The per-keystroke feel comes from previewField — staged, never-persisted
optimistic previews the next evaluation projects instantly (tolerant at
staging; dropped when the target's commit settles) — and
discardFieldPreview drops them without committing, reverting the
projection when an editing episode ends with nothing to commit.
interface WorkflowObserver {
observeInstance(instanceId): DocStore<ObservedInstance>
observeDocs(documents, perspective): DocStore<ReadonlyMap<string, ObservedDoc>>
observeGuards({instanceId, resources, sharedIds?}): DocStore<ObservedGuards>
observeInstances(query): DocStore<ObservedInstances>
fetchInstancePreviews(query): Promise<readonly unknown[]>
listenInstanceChanges({query, onEvent}): () => void
ensureDocumentExists(args): Promise<void>
}
type ObserverState<T> =
| {status: 'loading'}
| {status: 'ready'; value: T}
| {status: 'invalid'; invalid: InvalidDoc}
| {status: 'error'; error: unknown}
DocStore snapshot is reactive state. Adapters translate their native
value and error channels into loading, ready, invalid, or error; a
later successful emission clears either failure state. A ready doc value is
null when the document does not exist.ObservedInstance is a parsed
WorkflowInstance | null, the guard ready value is a validated array, and
the instance-list ready value is ObservedInstances —
{instances, unreadable}, validated PER ROW so one refused row flags
itself instead of failing the list. Wrap your raw stores with the SPI's
validatedInstanceStore / validatedGuardsStore /
validatedInstancesStore — they apply the engine's read discipline per
doc type (model gate + shape parse for instances; parse only for guards,
which carry no stamp by design), memoize per snapshot for
useSyncExternalStore, and surface validation failures as the invalid
state (single doc, guards) or per-row unreadable entries (the list)
instead of throwing into render. Content docs stay unvalidated (foreign
content).ensureDocumentExists is the seam's one WRITE primitive: make the document
exist in the representations the engine's field hydration reads
(drafts-over-published, or the release's version doc when releaseId is
set), writing nothing when a visible representation already exists. Start
flows call it before startInstance so $fields.subject hydrates.{query, params} (the
engine's instancesQuery) against the engine's own dataset, and reports
loading until the first read resolves — "still discovering" and
"confirmed none" stay distinguishable.fetchInstancePreviews runs one
page of the engine's instancePreviewsQuery and returns the raw rows (the
react layer parses per row through validatedPreviewPage), and
listenInstanceChanges subscribes the engine's instanceChangesQuery
change feed. The adapter owns feed health: one upstream listener per feed
however many stores subscribe, reconnect with backoff on error, then a
reset event on which consumers resynchronize whatever the gap missed.listenQuery throttles to ~1/s built-in; the App SDK query store
owns its refetch cadence); this layer's per-emission work is a free snapshot
swap; and per-row evaluation (a session per list row) rides
useWorkflowSession's burst coalescer. Contrast the session path, which
coalesces because each emission triggers a real re-evaluation.session.guards) and feeds them to the session, so action verdicts
flip to mutation-guard-denied while a matching guard denies — and back when
it is removed. A guard co-locates with the subject it locks, so an instance's guards
span the datasets its GDRs name; observeGuards receives that resource set
(guardResourcesFor: the engine dataset ∪ each currently-observed subject
dataset) and the adapter fans one guard stream per resource, merged by
combineGuardStores. Build each per-resource query with the engine's
set-shaped instancesGuardQuery. Report loading until the first read
resolves and keep resolved snapshots referentially
stable between emissions (useSyncExternalStore compares by identity;
a resolved-empty read should be the shared NO_GUARDS array).sharedIds is the guard-consolidation seam: the id-set of every co-mounted
session, handed identically to each one (the caller passes it as
useWorkflowSession's guardScope), so an adapter can subscribe ONE
set-shaped query per resource — canonicalize the set (sort + dedupe) so
identical scopes produce identical query state — and slice each session's
rows back out by sourceInstanceId BEFORE validation, so a sibling's
malformed guard can't invalidate this session. The returned stream carries
ONLY this instance's guards, and a set that omits instanceId must be
refused loudly (its rows could never arrive — the stream would resolve
vacuously empty). Omitted, subscribe for the one instance.session.ready includes the guard stream: the first evaluation only runs
once the docs and the guards are in hand, so an action never renders
allowed and then flips to mutation-guard-denied merely because guards
loaded late — and ready is never true while session.invalid is set.
invalid (InvalidDoc {reason, documentId, message}) is the explicit
validation state: 'model-ahead' means an engine-owned doc requires a
newer @sanity/workflow-engine; 'shape-violation' means a stored tree is
corrupt. Render the remediation; the state self-clears when a later
emission validates. A read failure instead surfaces on session.error; both
failure states make ready false and clear after recovery. session.guards
is undefined while loading, then the live list — except through a
scope-only resubscription (a co-mounted session joined or left
guardScope), where the hook holds the last-resolved list so consolidation
churn can't blink ready off. Retracted guards disappear from the stream.update write (the write every
action/tick commit performs), against guards in the instance's own
datasource — a guard only applies inside the datasource it lives in.before = after (the projection can't know the
post-commit shape): freezes, deny-alls, and metadata/identity predicates
surface; predicates that require a delta to allow will read as denied.observeGuards spans the datasets the instance's currently-observed GDRs
name (workflow-scope + open-stage subjects + the engine dataset) — where any
guard that can deny right now lives. Guards on a subject named only by a
closed prior stage in a foreign dataset (already deleted) are not
surfaced; the engine's all-stages guardsForInstance is the path that walks
every stage.update write against
same-datasource guards; the full cross-resource write-set forecast is
follow-up work.See each adapter's README for its store-specific limitations.