Sanity Blueprints
authoring and generated runtime support for Workflows. This public package
declares the sanity.workflow manifest contract. It also provides two entry
points for the runtime that a deployment's definitions require:
| Entry point | What it holds |
|---|---|
@sanity/workflow-blueprint |
defineWorkflows, display descriptors, manifest types, validation, and config file names |
@sanity/workflow-blueprint/generate |
The needs analysis, the emission plan, names, trigger filters, and the file bodies |
@sanity/workflow-blueprint/runtime |
The wiring a generated function shell imports |
Publishing this package does not register a new resource type.
sanity.workflow becomes deployable after the Blueprints service enables its
backend provider. Until that service change ships, deploy definitions with
npx @sanity/workflow-cli deploy instead of adding this resource to a live
stack.
The public package owns the authoring contract:
defineWorkflows from this package.sanity blueprints deploy evaluates sanity.blueprint.ts and sends the
resulting sanity.workflow resource to the existing Blueprints API./runtime; generators and other tooling
import /generate.Evaluating the definer performs no network or Content Lake operations. The public package does not include the provider SDK or its lifecycle adapter.
describeWorkflowsResource returns ordered, labeled data for a Workflows
resource. A renderer can display these scalar, list, and nested rows without
knowing which Workflows fields to select. It does not read deployment state.
Use the prod deployment from Declaring the resource
to describe the planned resource:
import {defineWorkflows, describeWorkflowsResource} from '@sanity/workflow-blueprint'
import {prod} from './shared'
const resource = defineWorkflows(prod)
const plannedDetails = describeWorkflowsResource(resource)
Pass deployedDefinitions only when an operation or read supplies actual
definition names and versions. Without it, the descriptor omits the deployed
row. An empty array produces a deployed row with an empty list. The values
retain any unresolved resource reference in authored input. The helper does not
change the serialized defineWorkflows resource.
The restricted Workflows provider stores a workflowDisplay descriptor in
providerMetadata after a successful create or update. That value describes
the last successful operation, not the current state of the Content Lake.
Older or attached stack resources can lack this metadata. The existing
Blueprints CLI does not consume this descriptor, so this package alone does not
change local plan or remote info output.
defineWorkflows takes the same authoring shape as one deployments[]
entry of a sanity.workflow.ts config (WorkflowDeploymentInput — the
reader-floor acknowledgement is required). Share that authored object with
defineWorkflowConfig; do not pass a parsed config.deployments[n] (that
type leaves the floor unverified so commands that do not submit definitions
can load a missing entry):
Every deployment must also be exported by name from sanity.workflow.ts. The
generated modules import it from there, including the resources module a
deployment that needs no functions still gets, so the export is required
whatever a deployment emits. The generated tree names
which modules read it.
// shared.ts
import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const articleReview = defineWorkflow({
name: 'article-review',
title: 'Article review',
initialStage: 'drafting',
stages: [
{name: 'drafting', transitions: [{name: 'finish', to: 'published'}]},
{name: 'published'},
],
})
export const prod = {
expectedMinReaderModel: 4,
name: 'prod',
tag: 'prod',
workflowResource: {type: 'dataset', id: 'acme.workflows'},
definitions: [articleReview],
} satisfies WorkflowDeploymentInput
// sanity.workflow.ts
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
import {prod} from './shared'
export {prod}
export default defineWorkflowConfig({deployments: [prod]})
// sanity.blueprint.ts
import {defineBlueprint} from '@sanity/blueprints'
import {defineWorkflows} from '@sanity/workflow-blueprint'
import {prod} from './shared'
export default defineBlueprint({
resources: [defineWorkflows(prod)],
})
The definer validates eagerly. Definition structure, GROQ, and
@<handle>: alias bindings all fail at manifest evaluation, in the author's
terminal, instead of as a failed stack operation. The resulting resource is
pure data. The deployment rides the manifest verbatim with definitions in
authored form. Alias expansion happens during deployment.
Blueprints matches resources by name (default:
workflows-<deployment name>). Keep it stable after deployment: a retained
resource cannot be renamed because removing the old name is rejected.
Definitions are retain-only through Blueprints: the default lifecycle is
{deletionPolicy: 'retain'}. Destroying the stack detaches the resource and
leaves every definition version in place; removing the resource from a deployed
blueprint fails instead of deleting it. Removing one definition from the
resource's deployment.definitions list stops managing that name but leaves its
deployed versions in place. Policies that promise deletion (allow, replace)
are rejected. The lifecycle also supports coordinator-owned ownership actions:
{ownershipAction: {type: 'attach', id: RESOURCE_ID}} adopts an existing
resource by its provider ID.{ownershipAction: {type: 'detach'}} removes Stack ownership without deleting
definitions.Use dependsOn: '$.resources.RESOURCE_NAME' when this resource depends on
another resource in the same Stack. The coordinator resolves the reference
before it invokes the backend provider.
The engine is a library with no runtime of its own: an instance advances only
when something calls a verb. @sanity/workflow-blueprint/generate answers what
must call it for a given deployment, and plans the functions that will.
runtimeNeeds(deployment) performs no I/O, so it gives the same answer at
authoring, generation and deploy time.
| Declared in the definition | Inferred runtime need |
|---|---|
| Any effect | A drain must run its handler |
Any condition reading $now |
A clock must tick past the deadline |
| An action that spawns children | A tick must re-evaluate the parent once one settles |
start.kind: 'autonomous' on a startable |
A watcher must start instances from writes |
| Subject entries | Which document types are workflow-relevant |
| None of the above | No functions at all |
The last row is a feature. A purely interactive review workflow needs no functions, because the reactive Studio session is already its runtime.
A child settling writes the child, so nothing re-evaluates a parent gate
reading $subworkflows on its own, which is why spawning is a need of its own
rather than a consequence of the child's own runtime.
import {runtimeEmissionPlan, runtimeNeeds} from '@sanity/workflow-blueprint/generate'
const needs = runtimeNeeds(deployment)
needs.drain.effectNames // ['notify-reviewer']
needs.heartbeat.timeSites // the exact $now conditions, as one of its reasons
needs.heartbeat.spawnSites // the exact spawning actions, as another
for (const planned of runtimeEmissionPlan(config).deployments) {
planned.emission?.drains // one per effect with its own budget, plus one shared
planned.emission?.heartbeat?.reasons // why the heartbeat exists
planned.emission?.heartbeat?.definitions // whose instances it ticks
planned.hosting // every workflow and effect by resolved runtime kind
planned.selfHosted // what a self-hosted process must cover
planned.anyTypeSubjects // autonomous definitions whose watcher cannot narrow by type
planned.unnarrowedStartFilters // autonomous definitions whose watcher cannot rule out an update
planned.subjectlessDefinitions // non-self-hosted autonomous definitions no write can start
}
Datasets come from the deployment rather than the definitions — the workflow dataset plus every alias-bound one — because a definition names accepted document types and never says where those documents live.
Each definition that watches updates gets its own watcher per dataset, and the creates-only definitions share one: a fire on a path a definition does not read would start it again, and the engine dedupes no start, while a create fires once and admits nothing spurious. The plan never hands a watcher back for you to write by hand. Which definitions each watcher carries is one rule with one precedence:
'selfHosted' and which
declares a subject entry gets a watcher of its own in every watched dataset,
or shares the creates-only one when its filter reads nothing of the
document but its _type, and nothing of the dataset or the clock.'selfHosted' one is listed on none, whatever else it declares, and appears
in selfHosted.startInstances.'function' or 'durableFunction' one with no subject entry is listed on
none either, and appears in subjectlessDefinitions: it contributes no
trigger, and a deployment whose autonomous definitions are all subjectless
plans no watcher at all.When a listed definition's subject entry declares no types the type leg
excludes the engine's own documents and narrows nothing else, so the watcher
triggers on writes to every other document type in the dataset, system
documents included, and the runtime declines those. The definition is named in
anyTypeSubjects for a needs report to warn about: a subject that accepts any
document type is almost always a misconfigured entry rather than an intent.
A watcher fires on a write that changed a path its definition reads even when the filter was already true before the write, so an edit to a read path can restart a definition whose verdict did not change.
Start watchers supply the written document only to matching input-sourced subject fields. Literal and query subject initializers resolve through the engine when the instance starts.
Each document trigger is a prefilter: it removes invocations no workflow in the
deployment can need, and the function still re-checks what it was invoked for.
The heartbeat is a scheduled function and has no trigger filter at all; what it
works on is the runtime block's topic.
A drain fires on an instance write in its deployment's tag that touched
pendingEffects and left an entry unclaimed, and only for the effects that
drain runs, so a write leaving another drain's effect, a 'durableFunction'
one, or a 'selfHosted' one unclaimed does not invoke it. A comment or an
assignee change on the instance touches no queue entry and invokes nothing.
A start watcher fires on a create of a watched type, or on an update that
changed a field one of the listed definitions' start.filter reads. The paths
come from the filter's GROQ: watchedUpdatesOf(definition) walks it and reports
the document fields it reads, so status == "ready" && !defined(publishedAt)
narrows the trigger to (publishedAt, status), the paths deduped and sorted.
Each definition that watches updates gets a watcher of its own,
wf-<tag>-start-<dataset>-<definition>, carrying exactly the types and paths
it reads, so no write another definition's paths admitted can start it. The
definitions that watch creates alone share
wf-<tag>-start-<dataset>-creates: a create fires once, so their types are a
union there and the handler drops what is not applicable. A definition named
creates that watches updates therefore collides with that shared watcher, and
generation fails asking you to rename it.
Two filter shapes yield no paths, and they resolve in opposite directions. A
filter that reads nothing of the document but its _type, and nothing of the
dataset or the clock, watches creates only, because no update to that document
can change which definitions it is applicable for: the lake rejects a write that
changes _type, so a document cannot become a watched type after it exists, and
_type is never a watched path. Such a definition starts each
document once, at its creation. A document that existed before its watcher
deployed starts only when you start it explicitly, through
sanity-workflows start with the subject or your own script, or once the
definition gains a start filter reading the subject or the dataset, which makes
that document's later writes start candidates again. A filter whose truth can
change without a write to the subject keeps every update in and names its
definition in unnarrowedStartFilters, because narrowing there would drop a
write that does make the definition applicable. That covers the whole document
through @, a field named by an expression, a top-level field whose name needs
quoting, such as @["status.code"], a function handed the document implicitly,
such as references(), any read of another document through a dataset scan or
a dereference, and any read of $now. A document that becomes
eligible by time alone starts on its next write, because applicability is
evaluated on a write and nothing else. A quoted name deeper in a read still
narrows to the nearest field path above it, so status["weird name"] watches
status.
A trigger that narrows carries an explicit create leg, because
delta::changedAny compares a field against its previous value and a created
document has none. A watcher that narrows nothing has no delta clause at all, so
it already accepts both creates and updates.
workflowRuntimeNames, effectDrainName and startWatcherName derive every
emitted resource name from the deployment's tag, and drainEffectsFilter and
startWatcherFilter compile the triggers. They live here rather than in the
emitted text so one definition drives the blueprint, the handlers' execution
identities, and any tooling that finds a deployment's runtime by name. The four
that take a tag validate it first, and beyond that they differ, because what
reaches the output differs. effectDrainName and startWatcherName slugify
their effect, dataset and group name into the lowercase-dash form a resource
name takes, so a hostile character cannot survive; startWatcherName also
rejects a group outside EMITTED_NAME_PATTERN, because a definition name
reaches the emitted text as itself elsewhere. The filter compilers embed each
name verbatim in GROQ and therefore check it first: an effect name and a subject
type against EMITTED_NAME_PATTERN, and a delta selector's field path against
the engine's bare-attribute grammar through assertEmittablePath. A path holds
only field names a selector can spell unquoted, so a quoted name never becomes a
selector for some other field. The emission plan applies the name grammar to
every effect name, definition name and subject type the generator will embed.
A deployment's name is unique across a config but its tag may repeat, and
every emitted resource name derives from the tag. If two deployments share a
tag and both emit runtime resources, the plan rejects the colliding names and
names both deployments. A deployment that emits no runtime resources may share
a tag with another deployment.
Most projects never call this part directly:
@sanity/workflow-cli
wraps it as sanity-workflows blueprint generate, which reads
sanity.workflow.ts, prints the needs analysis, and writes the tree described
below. sanity-workflows blueprint generate --check is the CI drift gate. Reach
for the functions here when you are building your own generation step. When you
do, make the check the CLI makes: the config module must export each deployment
under the name deploymentExportIdentifier returns, because the generated
resources module and every function shell import it that way. These functions
cannot check it for you, and writing a plan for a config missing that export
produces modules that cannot compile while emissionDivergences still reports
the tree aligned.
CONFIG_FILE_NAMES is available from the package root and /generate. Custom
loaders can use it to discover supported config files in the same order as the
Workflows CLI.
emissionWritePlan turns an emission plan into the files a developer sees. It
does not read or write the generation root's files: the caller passes the paths
already on disk, the developer's sanity.blueprint.ts source, and the project's
declared dependency versions, then gets back every file body with the ownership
that decides whether writing it is allowed to replace what is there. When wiring
an existing blueprint without a supplied compiler, it loads the optional
installed TypeScript package.
sanity.workflow.ts yours: the definitions and the runtime block
sanity.blueprint.ts yours: spreads the generated resources
workflows.blueprint.ts generated shell (definitions entry commented out)
functions/
wf-prod-drain-effects/ generated shell + package.json
wf-prod-heartbeat/ generated shell + package.json
wf-prod-start-articles-creates/
generated shell + package.json
effect-handlers/
all.ts generated registry
notify-reviewer.ts yours, scaffolded once
| Ownership | Which files | What a writer may do |
|---|---|---|
generated |
the resources module, every function shell and manifest, the registry | replace it on every run |
scaffold |
one handler stub per registered effect name | write it only when absent |
wired |
the developer's sanity.blueprint.ts |
write the one-time insertion |
import {emissionWritePlan, runtimeEmissionPlan} from '@sanity/workflow-blueprint/generate'
const written = emissionWritePlan({
plan: runtimeEmissionPlan(config),
configFile, // the config file name found in the root, such as 'sanity.workflow.ts'
existingPaths, // every path already under the generation root
blueprintSource, // the developer's sanity.blueprint.ts, or undefined
declaredDependencies, // the project manifest's dependencies over devDependencies
})
written.writes // path, contents, and ownership
written.orphanedStubs // handler files for effect names the definitions dropped
written.orphanedRegistry // the registry, once no effect is registered at all
written.manualBlueprintWiring // the two lines to add when no single resources array is found
written.caseCollisions // scaffolds skipped because a file differs only in case
readGenerationRoot gathers those three inputs from a directory, and
applyEmissionWritePlan writes the plan back into it, reporting each file as
created, updated, or unchanged. It throws before writing anything rather
than replace a scaffold whose contents differ from the planned stub, so
neither an incomplete existingPaths nor a plan kept across a handler edit can
destroy an implemented handler. Rebuild the plan whenever the root may have
changed since readGenerationRoot read it.
import {
applyEmissionWritePlan,
emissionDivergences,
emissionWritePlan,
readGenerationRoot,
runtimeEmissionPlan,
} from '@sanity/workflow-blueprint/generate'
const plan = emissionWritePlan({
plan: runtimeEmissionPlan(config),
configFile: 'sanity.workflow.ts',
...readGenerationRoot(root),
})
applyEmissionWritePlan({root, plan}) // path, ownership, and what the write did
emissionDivergences({root, plan}) // planned files that are missing or differ; writes nothing
emissionDivergences is the read-only half: empty means the files on disk are
the files the plan would write. Because a scaffold is planned only when its path
is absent, a scaffold divergence reports a declared effect whose handler has
not been written yet.
A stub is planned only when its path is absent, so an implemented handler
survives every regeneration. The registry is regenerated from the registered
names, so removing an effect (or moving it to runtime: {kind: 'selfHosted'})
drops its import; the file it imported stays on disk and is reported in
orphanedStubs for the developer to delete. Leaving no registered effect at
all leaves nothing to regenerate, so the registry itself is reported in
orphanedRegistry rather than left behind importing files the same plan asks
the developer to delete. The generator deletes nothing in either case. The
registry types effectHandlers as Record<DeclaredEffectName, EffectHandler>, so a registered effect with no reachable handler is a compile
error rather than a runtime surprise.
Every identifier and path the generator derives from a declared effect name
comes from effectHandlerBindings, so a stub's file name and the registry's
import of it cannot disagree. It rejects the name all, whose handler file
would be the registry; a name whose camel-cased identifier JavaScript will not
accept as a binding, or that the registry module already binds
(effectHandlers, EffectHandler, DeclaredEffectName); two names that
collapse to one identifier, such as pay.out and pay-out; and two names whose
handler files differ only by case, because they are one file on a
case-insensitive filesystem. The registry's own module name is matched the same
way, so All is rejected alongside all.
Two kinds of generated module import the deployment, under the deployment's own
name, because defineWorkflows takes the authored deployment and not a parsed
config.deployments[n]: the resources module, from ./sanity.workflow beside
it, and every function shell, from ../../sanity.workflow two directories up.
The handler registry and the handler stubs import neither. A deployment name
that cannot name a binding, because it is not an identifier or because it is a
reserved word, fails the emission with a message naming it.
A function shell imports it under the fixed local alias deployment
(import {prod as deployment} from '../../sanity.workflow') and refers to it by
that name throughout. A shell binds nothing else called deployment, so a
deployment named after something a shell declares or imports — handler,
engine, createRuntimeEngine — cannot collide with it, however the templates
change. workflows.blueprint.ts names the deployment directly, and its own
bindings are a fixed set, so a deployment named workflowResources or after one
of the definers it imports is rejected at emission instead.
The defineWorkflows entry itself is emitted commented out, under the reason
and the command that deploys definitions meanwhile. The Blueprints service does
not register the sanity.workflow resource type yet, and one unknown resource
fails the whole deploy, the emitted functions included. The import stays, so
uncommenting is the only edit the entry takes once the resource type is enabled. A
single generator constant decides this, not deployment config, so one flip and
a regeneration turns it on everywhere.
Each function's package.json copies the dependency specifications the project
already declares, unchanged, so the generated functions and the project resolve
against the same ranges. A dependency the project declares nowhere fails the
plan instead of being stamped with a guessed range.
The developer's sanity.blueprint.ts is theirs. The generator inserts the
workflowResources import and one spread into its resources array, once, and
never touches the file again.
Where to insert is decided by parsing the file, not by matching text.
wireBlueprint reads it with the TypeScript parser and looks for exactly one
resources array literal among the object arguments of its defineBlueprint(...)
calls. Text that merely reads like the anchor is not a node and is never
matched, so a resources: [ inside a comment, a string, a template literal or a
regular expression cannot receive the spread. A resources array belonging to
some other object is not a candidate either. Finding none, or more than one, is
reported through manualBlueprintWiring with the two lines to add.
Wiring requires a value import of workflowResources from
./workflows.blueprint (with an optional .ts extension) and a
...workflowResources spread in that array. The generator inserts whichever is
missing. Other bindings named workflowResources, including unrelated or
type-only imports, require manual wiring; resolve the conflicting binding before
adding the generated import and spread.
Wiring an existing sanity.blueprint.ts uses the TypeScript compiler API. A
direct caller of @sanity/workflow-blueprint/generate can use an installed
TypeScript 6 compiler, or install @typescript/typescript6 and pass its default
export as compiler to emissionWritePlan or as the second argument to
wireBlueprint. This is required when the project uses TypeScript 7 or has no
typescript installation; otherwise wiring reports the missing API clearly.
Importing /generate does not itself require TypeScript. The Workflows CLI
supplies the compatibility compiler for its Blueprint commands. The optional
typescript peer accepts both 6 and 7, and the /runtime entry point and
generated functions do not install or import a compiler.
A scaffold is never written over a file whose path differs from it only in case.
Renaming an effect from notify to Notify plans effect-handlers/Notify.ts
while the developer's implementation sits in effect-handlers/notify.ts, and on
a case-insensitive filesystem that write would replace it. Planned paths are
matched against what is on disk without regard to case: neither path is written,
and the pair is reported in caseCollisions. Rename the file and its exported
handler to match the generated registry. For notify to Notify, change both
the filename to Notify.ts and the exported binding to Notify.
runtime blockWhere the generated runtime hosts a workflow or an effect is one runtime block
on three nodes: the deployment, the workflow in defineWorkflow, and the effect
node. Each level inherits from the one above, and omitting it everywhere hosts
everything on 'function'. kind is the whole block at the deployment and
workflow levels; an effect's 'function' block also carries the function
budget, timeout in seconds and memory in gigabytes. Each is a whole number
inside the range Sanity Functions accepts: timeout from 1 to 900 seconds and
memory from 1 to 10 gigabytes. A value outside its range fails at
defineWorkflow rather than at blueprint deploy.
| Kind | What the generator emits |
|---|---|
function |
A drain on instance writes, and the heartbeat a deadline, a spawn, or that drain needs |
durableFunction |
Nothing yet. The plan reports the workflow or effect, and the rest of the deployment emits |
selfHosted |
Nothing. The plan reports what the developer's own process must cover |
A start watcher is not in that table, because starting an instance does not
depend on where the workflow runs: 'function' and 'durableFunction' are
alike there, and only 'selfHosted' takes a definition off the watchers. The
rule and its precedence are under Deriving the runtime,
with the reports that name whatever the watchers leave out.
Budgeted effects recover one per beat: a beat registers one effect from
heartbeat.catchUp.budgetedEffectNames at a time, taken in turn, and drains a
single instance holding it. For each field some recoverable effect declares,
the beat carries the larger of the platform default and the largest declared
value, reported in heartbeat.catchUp.limits. The beat dispatches its
budgeted effect first: only the candidate query and that instance's claim sweep
run before the handler, and ticks and unbudgeted recovery follow it. A handler
therefore never runs in a budget smaller than the one its own drain gets,
whether that drain dispatched it or a beat caught it up. Effects without a
declared budget are not rationed and go out together.
Hosting decides which instances a heartbeat ticks and which it recovers. It
ticks the instances of the 'function'-hosted workflows in
heartbeat.definitions, and it is emitted when one of those workflows reads
$now or spawns children, or when any workflow in the deployment declares a
'function'-hosted effect. heartbeat.reasons lists the reasons it claims.
Recovery is listed separately in heartbeat.catchUp.recoverDefinitions,
because an effect that runs on a function has a drain whatever hosts its
workflow, and a drain that dies mid-dispatch leaves a claim only the sweep
releases: a durable or self-hosted workflow with a 'function'-hosted effect
gets a beat that recovers its queues with heartbeat.definitions empty. A
deployment with no 'function'-hosted workflow and no 'function'-hosted
effect emits no heartbeat: a durable workflow's instances belong to its
advancer and a self-hosted one's to the developer's process, which is why
selfHosted.tickDeadlines reports the self-hosted deadlines nothing is
guaranteed to tick.
The emitted heartbeat runs every minute, and a scheduled function runs at most as often as the organization's plan allows — every minute on Enterprise, hourly on Growth, daily on Free (Functions) — so a schedule below that threshold is not guaranteed to run at the rate it asks for, with no error anywhere.
The heartbeat is emitted as a plain defineScheduledFunction with no project.
A scheduled function is organization-scoped, the definer rejects a project on
one, and the two document functions beside it are project-scoped and do carry
theirs.
The block is authoring-only. The deploy strips it, so it never reaches the
Content Lake and changing a kind never mints a new definition version. It does
change the emitted tree, so regenerate after changing one. A function directory
the new tree no longer contains is inert: the blueprint lists each function by
its src, so nothing deploys it.
The block is a hint to the generator and to callers, never a rule the engine
enforces. The engine never sees it: any engine built anywhere with a handler for
an effect drains that effect when it calls drainEffects, whatever kind the node
declares, so a selfHosted effect is drained by whoever registers its handler,
the Studio included.
Nothing declared. articleReview and expenseApproval are definitions from
defineWorkflow, imported from wherever you keep them:
// sanity.workflow.ts
import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
export const prod = {
name: 'prod',
tag: 'prod',
expectedMinReaderModel: 4,
workflowResource: {type: 'dataset', id: 'acme.workflows'},
definitions: [articleReview, expenseApproval],
} satisfies WorkflowDeploymentInput
export default defineWorkflowConfig({deployments: [prod]})
Export the deployment under its own name and hand the same value to
defineWorkflowConfig; the generated modules import it from that named export.
Emitted: one shared drain, the heartbeat that catches up and sweeps after it,
and a watcher per autonomous definition that watches updates, plus one the
creates-only definitions share. A deployment with no effect, no $now deadline and no spawn
emits no drain and no heartbeat.
One workflow opts in:
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const expenseApproval = defineWorkflow({
name: 'expense-approval',
title: 'Expense approval',
runtime: {kind: 'durableFunction'},
initialStage: 'approval',
stages: [{name: 'approval', transitions: [{name: 'settle', to: 'paid'}]}, {name: 'paid'}],
})
article-review keeps its plain drain and heartbeat. expense-approval appears
under hosting.workflows.durableFunction and its effects under
hosting.effects.durableFunction, and its deadlines are no reason to emit a
heartbeat, nor are its instances ticked by the one article-review gets. The
durable advancer that runs it ships separately, so nothing is emitted for it
yet.
The deployment default flips, and one workflow stays on functions. Both definitions elide their stages:
// definitions.ts
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const erpSync = defineWorkflow({
/* … */
})
export const articleReview = defineWorkflow({
name: 'article-review',
title: 'Article review',
runtime: {kind: 'function'},
initialStage: 'drafting',
stages: [
/* … */
],
})
// sanity.workflow.ts
import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
import {articleReview, erpSync} from './definitions'
export const prod = {
name: 'prod',
tag: 'prod',
expectedMinReaderModel: 4,
workflowResource: {type: 'dataset', id: 'acme.workflows'},
runtime: {kind: 'selfHosted'},
definitions: [erpSync, articleReview],
} satisfies WorkflowDeploymentInput
export default defineWorkflowConfig({deployments: [prod]})
Emitted: article-review's drain and heartbeat only. erp-sync is reported in
selfHosted, which lists the deadlines the process must tick, the effects it
must drain, the parents it must re-evaluate when a child settles, and the
autonomous definitions it must start.
A durable workflow with one cheap effect on a plain function and one effect drained by an existing server:
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const expenseApproval = defineWorkflow({
name: 'expense-approval',
title: 'Expense approval',
runtime: {kind: 'durableFunction'},
initialStage: 'approval',
fields: [{type: 'boolean', name: 'approved', title: 'Approved'}],
stages: [
{
name: 'approval',
activities: [
{
name: 'pay',
actions: [
{
name: 'issue',
when: '$fields.approved',
status: 'done',
effects: [
{name: 'issue-payout'},
{name: 'stamp-approved', runtime: {kind: 'function', timeout: 30}},
{name: 'sync-erp', runtime: {kind: 'selfHosted'}},
],
},
],
},
],
transitions: [{name: 'settle', to: 'paid'}],
},
{name: 'paid'},
],
})
Emitted: a drain for stamp-approved alone, with a thirty-second timeout, and
its trigger names that one effect. issue-payout stays on the durable and
sync-erp on the developer's process, so no emitted drain claims either, and a
write leaving only those unclaimed invokes nothing. A heartbeat is emitted too,
and expense-approval is absent from heartbeat.definitions, so the beat never
ticks its instances. It recovers stamp-approved on them, because that drain
can die mid-dispatch and leave a claim nothing else releases. The handler
registry binds every effect except the self-hosted ones, whose handler lives in
that process.
@sanity/workflow-blueprint/runtime carries what a generated function shell
needs to configure and run the engine: the client and engine factory, the
beat's handler selection, and the beat itself.
These two examples are fragments of a function handler. They assume what
the generated handlers provide: deployment is the deployment imported from
sanity.workflow, effectHandlers is the registry exported from
effect-handlers/all.ts, and context and event are the arguments Sanity
Functions passes the handler.
createRuntimeEngine(deployment, {context}) turns a Sanity Function context into
a scoped engine plus the client it runs on. It builds a raw-perspective client
pinned to the engine's API version, derives cross-resource routing from the
deployment's resourceAliases, and declares a server execution identity named
after the deployment's robot token. It throws when the context carries no token,
because a bound robot token always delivers one.
import {createRuntimeEngine} from '@sanity/workflow-blueprint/runtime'
const {engine} = createRuntimeEngine(deployment, {
context,
effects: {handlers: effectHandlers, missingHandler: 'skip'},
})
await engine.drainEffects({instanceId: event.data._id})
runHeartbeat({engine, client, tag, definitions, execution}) is the
heartbeat's loop. It ticks every in-flight instance of definitions in the tag,
not only those with active activities, because an instance parked on a $now
deadline has no active activity and still needs the tick. It also releases effect
claims whose lease lapsed, since the drain trigger fires only on unclaimed
entries and a drain that died mid-dispatch leaves one no event would revisit. One
instance's failure is isolated and counted; an all-failed run throws, so the
invocation reports red instead of a green no-op.
import {
beatCatchUp,
createRuntimeEngine,
HEARTBEAT_BEAT_MS,
runHeartbeat,
} from '@sanity/workflow-blueprint/runtime'
const execution = {kind: 'server', id: 'wf-prod-heartbeat'} as const
const catchUp = beatCatchUp({
registry: effectHandlers,
effectNames: ['notify-reviewer', 'issue-payout'],
budgetedEffectNames: ['issue-payout'],
recoverDefinitions: ['article-review', 'expense-approval'],
afterMs: HEARTBEAT_BEAT_MS,
})
const {engine, client} = createRuntimeEngine(deployment, {
context,
execution,
effects: {handlers: catchUp.handlers, missingHandler: 'skip'},
})
const totals = await runHeartbeat({
engine,
client,
tag: deployment.tag,
definitions: ['article-review'],
execution,
catchUp: catchUp.args,
})
totals.caughtUp // pending effects this beat settled successfully
totals.caughtUpUnsettled // ones it settled as failed or could not settle, also logged
totals.deferred // instances whose budgeted dispatch waits for a later beat
catchUp picks the instances the beat drains: those of
catchUp.recoverDefinitions holding an entry one of effectNames names that
no drainer is working on, either unclaimed for longer than afterMs or
holding a claim whose lease lapsed. That list is separate from the
definitions the beat ticks, because an effect that runs on a function has a
drain to recover whatever hosts its workflow: a durable or self-hosted
workflow with a 'function' effect gets a beat that recovers its queues. A
completed instance holding such an entry is drained as well, and never ticked,
so the beat reaches it without another write to its queue. The drain that
follows is an ordinary one for the unbudgeted names, so it covers every entry
of those on that instance, a fresh one beside the overdue entry included; the
budgeted name is capped at one entry.
Budgeted effects recover one per beat. beatCatchUp registers every
unbudgeted handler plus one of budgetedEffectNames, taken in turn, and
runHeartbeat drains a single instance holding it before any other work: only
the candidate query and that instance's claim sweep run before the handler,
and ticks and unbudgeted recovery follow it. If another drainer took that entry
first, the next instance holding the effect gets the run after the first
instance's work. Deferral is of the budgeted dispatch alone: an instance the
beat passes over still has its unbudgeted entries drained, and
totals.deferred counts the instances whose budgeted run waits for a later
beat. The generator sizes the emitted beat to the widest effective budget among
those effects; a hand-written beat needs the same. Pass catchUp.handlers to
the engine with effects.missingHandler: 'skip' and catchUp.args to the
beat, and the registered handlers and the effects the beat looks for stay the
same set.
This is what lets you deploy definitions before the functions, on a plan whose schedule the platform actually runs: an effect queued in the gap, or while the drain was down, is dispatched by a beat rather than waiting for the next write to its instance. Below the plan's own cadence, the beat is not guaranteed to run at the rate it asks for, so recovery may wait for a write that reaches the drain. An effect with no declared budget goes out on the first beat that reaches its instance. A budgeted one waits for a beat whose turn holds it and which has not already spent that run on another instance, so a backlog of a budgeted effect clears one entry per beat whose turn it holds, and two budgeted effects share the turns in an order the clock picks.
One beat drains the instances it selected, in one invocation and in sequence,
so a backlog large enough to exhaust the function's budget ends the invocation
partway.
Nothing is lost: the entries it claimed keep their claims until the leases
lapse, and the beat that finds them next releases and redispatches them. The
recoverable instances are walked first, so the leg a short invocation is most
likely to drop is the tick pass. Because its order is stable, a deployment
whose in-flight set is larger than one invocation can walk ticks the same
prefix on every beat and never reaches its tail. The beat's budget is derived,
not configurable: a deployment whose effects declare no budget leaves the
platform's defaults in place, and the only way to raise it is to declare a
runtime budget on an effect, which also gives that effect its own drain and
its own turn.