npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sanity-labs/workflow-engine-explore

v0.4.0

Published

A workflow / BPM engine for Sanity content. Defines workflows as data, runs them as instances against a Sanity client, gates actions on guards, queues effects for runtimes to drain.

Readme

@sanity-labs/workflow-engine-explore

A workflow / BPM engine for Sanity content. Defines workflows as data, runs them as instances against a Sanity client, gates actions on guards, queues effects for runtimes to drain.

Status: 0.x POC. Sharp edges. Breaking changes between minor versions. Use it to learn, not to bet a product on.

npm install @sanity-labs/workflow-engine-explore

Companion packages, shipped separately so the engine's runtime install graph stays minimal (groq-js + zod only — no React, no SDK, no test deps):

| Package | Purpose | |---|---| | @sanity-labs/workflow-engine-explore-react | Sanity App SDK hooks for React consumers | | @sanity-labs/workflow-engine-explore-test | In-memory bench (createBench) for testing workflows | | @sanity-labs/workflow-engine-explore-test-client | Standalone in-memory @sanity/client for tests |


Quickstart

import { workflow } from "@sanity-labs/workflow-engine-explore";
import { defineWorkflow } from "@sanity-labs/workflow-engine-explore/define";
import { createClient } from "@sanity/client";

const client = createClient({ projectId: "…", dataset: "…", token: "…", useCdn: false });
const tags = ["acme-prod"]; // engine-scope; see Tags below

// 1. Define a workflow.
const articleReview = defineWorkflow({
  workflowId: "article-review",
  version: 1,
  name: "Article review",
  initialStageId: "draft",
  stages: [
    {
      id: "draft",
      tasks: [{ id: "write", actions: [{ name: "submit", setStatus: "done" }] }],
      transitions: [{ to: "in-review", on: "auto", guard: "allTasksDone" }],
    },
    {
      id: "in-review",
      tasks: [{ id: "approve", actions: [{ name: "approve", setStatus: "done" }] }],
      transitions: [{ to: "published", on: "auto", guard: "allTasksDone" }],
    },
    { id: "published", kind: "terminal" },
  ],
  predicates: [
    {
      id: "allTasksDone",
      name: "All tasks done",
      groq: "count(*[_id == $self][0].taskStatus[status != 'done' && status != 'skipped']) == 0",
    },
  ],
});

// 2. Deploy.
await workflow.deployDefinitions({ client, tags, definitions: [articleReview] });

// 3. Start an instance, fire actions.
const actor = { kind: "user" as const, id: "alice" };
const inst = await workflow.startInstance({
  client, tags,
  workflowId: "article-review",
  subject: { kind: "document", ref: "article-123" },
  actor,
});

await workflow.fireAction({
  client, tags,
  instanceId: inst._id,
  taskId: "write",
  action: "submit",
  actor,
});
// Auto-transition fires: draft → in-review

await workflow.fireAction({
  client, tags,
  instanceId: inst._id,
  taskId: "approve",
  action: "approve",
  actor,
});
// Auto-transition fires: in-review → published

// 4. Read current state.
const evaluation = await workflow.evaluate({ client, tags, instanceId: inst._id, actor });
console.log(evaluation.currentStage.stage.id); // "published"

Concepts

A workflow is stages connected by transitions. Each stage has tasks, each task exposes actions. Firing an action mutates task status; guards decide which transitions become legal. The engine commits one transition at a time and cascades auto-transitions until stable.

Stages

A stage is one named phase of a workflow. The active stage of an instance is its currentStageId. Stages declare:

  • id (required) and optional name / description
  • kind: "normal" (default) or "terminal" (workflow stops here, no transitions allowed)
  • tasks: work assigned to people, services, or both
  • transitions: outgoing moves to other stages, with optional guard and on trigger
  • effects: side-effect descriptors fired when the stage is entered

Tasks

A task is a unit of stage-local work. It has a lifecycle (pendingactivedone/skipped/failed) and a set of named actions that move it through that lifecycle.

{
  id: "review",
  name: "Editor review",
  assignees: [{ kind: "role", role: "editor" }],
  actions: [
    { name: "approve", setStatus: "done", roles: ["editor", "admin"] },
    { name: "reject",  setStatus: "failed" },
  ],
}

assignees is a soft hint — the engine attaches it to the task but doesn't enforce identity gates by itself. Use actions[].roles for action-level gating (still soft; combine with workflow.permissions for real ACL).

Transitions

A transition moves the instance from one stage to another.

{ to: "published", on: "auto", guard: "allTasksDone" }
  • on: "auto" (default) — the engine considers this transition every time it cascades. Fires the first one whose guard passes.
  • on: "manual" — only fires when explicitly requested (rare; the common case is auto + guard).
  • guard — either an inline GROQ string, or a { ref: "predicateId", args: { … } } reference to a named predicate on the workflow.

A transition guard is evaluated against client.fetch with reserved params:

| Param | Resolves to | |---|---| | $self | The instance _id | | $subject | instance.subject.ref (or null) | | $parent | The immediate parent ref (ancestors.at(-1)._ref), or null at root | | $ancestors | Full ancestor chain, root-first |

Plus any args supplied on a predicate ref.

Actions

An action is an event you fire against a task. Firing an action:

  1. Mutates the task's status (setStatus) and optionally the instance's effectsContext (via outputs).
  2. Cascades auto-transitions until stable.
  3. Propagates to ancestor instances (so a child completing can roll up to its parent).
await workflow.fireAction({
  client, tags,
  instanceId, taskId: "review", action: "approve",
  actor,
});

Predicates

A named, reusable GROQ guard. Declared on the workflow definition; referenced by transitions and action availableWhen clauses:

predicates: [
  {
    id: "subjectApproved",
    name: "Subject is approved",
    groq: "*[_id == $subject][0].state == \"approved\"",
  },
],
// Used:
transitions: [{ to: "next", on: "auto", guard: { ref: "subjectApproved" } }],

Predicates can take typed params (string, number, boolean, enum):

{
  id: "subjectInState",
  name: "Subject in state",
  groq: "*[_id == $subject][0].state == $state",
  params: [{ name: "state", type: "string", enum: ["draft", "approved", "rejected"] }],
}
// Used: guard: { ref: "subjectInState", args: { state: "approved" } }

Effects

Side effects (publish a release, notify Slack, write a log, …) are declared as descriptors on stages, tasks, transitions, or actions:

effects: [{ name: "sanity.release.publish", input: { releaseId: "rls-2026-q2" } }]

The engine does not run effects. It appends a PendingEffect to the instance's queue, snapshots its inputs (resolved against effectsContext bindings), and trusts a runtime to drain the queue. After running an effect, the runtime calls workflow.completeEffect to report the outcome (and optionally seed effectsContext with outputs).

This separation is intentional:

  • Effects can be any side effect — publishing, sending an email, calling an API — without the engine needing to know
  • Multiple runtimes can split responsibility (e.g. one Sanity Function per effect name)
  • Idempotency lives at the runtime layer

See effectsContext for the binding model.

effectsContext

A stable, named bag of params on the instance. Effect handlers read from it; effect outputs upsert into it. Seeded at startInstance and updated by completeEffect({ outputs }):

const inst = await workflow.startInstance({
  client, tags,
  workflowId: "release",
  effectsContext: { releaseId: "rls-2026-q2", channel: "#editorial" },
  actor,
});
// Inside an effect declaration:
effects: [{ name: "slack.notify", input: { channel: "$channel", text: "Published" } }]
// The runtime substitutes "$channel" from effectsContext at queue time.

Instances

A workflow instance is one running execution of a definition. It carries:

  • _id, _type: "workflow.instance", tags: string[]
  • workflowId, pinnedVersion, definitionSnapshot (frozen JSON)
  • currentStageId, taskStatus[]
  • pendingEffects[], effectHistory[]
  • history[] (ordered, polymorphic — stage transitions, action fires, effect outcomes, spawns, etc.)
  • subject (optional { kind, ref } — the document the workflow is about)
  • ancestors[] (parent → grandparent → … for spawned children)
  • startedAt, lastChangedAt, optional completedAt

Tags — engine-scope multi-tenant isolation

The engine takes a required tags: string[] config on every call. Tags must be Sanity-ID-compatible:

^[a-z0-9][a-z0-9-]*$

ASCII lowercase + digits + dashes, no leading dash, no dots (dots are reserved as the _id prefix separator).

When the engine writes a definition or instance:

  • _id is prefixed with tags[0] joined by . — e.g. acme-prod.article-review.v1, acme-prod.wf-instance.7f3b…
  • The full tags array is stamped on the document as a tags: string[] field

When the engine reads:

  • Every GROQ query for workflow.definition / workflow.instance filters by count(tags[@ in $engineTags]) > 0
  • Direct getDocument(id) lookups check tag intersection on the returned doc

Two engines with disjoint tag sets share a workflow resource without seeing each other's data. Engines sharing a tag have visibility through that tag. Spawn-children inherit the parent's tags so they stay visible to whichever engine spawned the chain.

This is the answer to test/prod isolation, multi-app coexistence, per-team scopes, and the general "many tenants in one workflow resource" question — same mechanism handles all of them.

import { validateTags, canonicalTag } from "@sanity-labs/workflow-engine-explore";

validateTags(["acme-prod"]);          // ok
validateTags(["acme-prod", "shared"]); // ok — multiple tags
validateTags([]);                      // throws
validateTags(["bad.dot"]);             // throws
validateTags(["BadCase"]);             // throws

canonicalTag(["acme-prod", "shared"]); // → "acme-prod"

API

All entrypoints take { client, tags, actor, … }. Actor must always be explicit — the engine never fabricates an identity for production callers.

workflow.deployDefinitions({ client, tags, definitions })

Bulk, idempotent deploy. Topologically sorts so children deploy before parents that spawn them. Skips unchanged definitions (deep-compare). Per-definition status:

const { results } = await workflow.deployDefinitions({ client, tags, definitions: [a, b, c] });
// results: [{ workflowId, version, status: "created" | "updated" | "unchanged" }, …]

Errors:

  • Unresolved spawn.definitionRef._ref (neither in batch nor deployed) → before any write
  • Dependency cycle in the batch → before any write
  • Invalid Zod-validated definition shape → before any write, with path-prefixed error message

workflow.startInstance({ client, tags, workflowId, version?, subject?, ancestors?, effectsContext?, instanceId?, actor })

Spawn a new instance from a deployed definition. Pins the version (frozen snapshot), seeds effectsContext, enters the initial stage, primes onEnter effects, cascades auto-transitions until stable. Returns the resulting instance.

If version is omitted, picks the highest-version deployed definition for workflowId. If instanceId is omitted, the engine generates one as ${tags[0]}.wf-instance.${random}.

workflow.fireAction({ client, tags, instanceId, taskId, action, actor, idempotent?, grants?, grantsFromPath? })

The universal "something happened" call. Editors fire it; runtimes fire it in response to webhooks, effect completions, timer firings. Auto-invokes pending tasks (pending → active) so callers don't need to know task lifecycle.

  • idempotent: true — no-op (don't throw) if the task is missing from the current stage (e.g. workflow already advanced past it). Returns { instance, cascaded: 0, fired: false }.
  • grants / grantsFromPath — see Permissions below.

Returns { instance, cascaded, fired }.

workflow.tick({ client, tags, instanceId, actor })

Re-evaluates auto-transitions and resolves due waits until stable. Use this when something changed outside the instance that might affect it (a subject doc was patched, a sibling workflow completed, a waitUntil came due). The runtime doesn't need to know what changed — tick re-evaluates.

workflow.completeEffect({ client, tags, instanceId, effectKey, status, outputs?, detail?, error?, durationMs?, actor })

Drains a pending effect from the queue, appends to effectHistory, optionally seeds effectsContext with outputs (for downstream bindings), cascades.

await workflow.completeEffect({
  client, tags,
  instanceId, effectKey: "ef-publish-abc",
  status: "done",
  outputs: { publishedAt: new Date().toISOString() },
  actor: { kind: "system", id: "drain-effects-fn" },
});

workflow.evaluate({ client, tags, instanceId, actor, grants?, grantsFromPath? })

Read-only projection of the instance's current state from an actor's perspective. Returns per-action verdicts with structured disabledReason for UI rendering. Same logic fireAction uses for its soft gate.

workflow.permissions

Sanity ACL helpers — GROQ-evaluated grants, mirror of canvas's permission store. Used by workflow.evaluate to soft-gate actions when grants are supplied.

const allowed = workflow.permissions.grantsPermissionOn({
  grants, document: instanceDoc, permission: "update",
});

Migration / versioning

Each instance carries a definitionSnapshot — a frozen JSON copy of the definition at start time. New versions of a workflow deploy alongside earlier ones; in-flight instances stay on their pinned version indefinitely. New instances pick up the latest.

This is the simplest viable strategy:

  • ✅ No mid-flight migration needed
  • ✅ Definition authors can iterate without breaking running work
  • ❌ Long-running instances drift from current definition state (audit history is honest about which version they ran on)
  • ❌ No automatic backport of fixes to in-flight instances (deliberate — automatic structural migration is a rabbit hole)

If a 0.x project wants long-running instances to pick up changes, the path today is: drive them to a terminal stage, kick off new instances on the new version.


Permissions / ACL

The engine has a soft gate on actions:

  1. If grants (or grantsFromPath) is supplied → action evaluation checks permissions.grantsPermissionOn against the instance doc + the permission field on the action.
  2. If the actor's role intersects action.roles[] (or ["*"] wildcard).

Both gates emit structured disabledReason so UIs can render disabled-with-tooltip without re-implementing the logic. The gate is informational by default — Sanity's storage-layer ACL is the actual write boundary. The engine's gate exists so callers can render the same UI verdict the write will produce.

grantsFromPath is the recommended pattern in production — pass a URL path on the client (e.g. /canvases/<id>/acl for canvas-resource workflows or /projects/<id>/datasets/<ds>/acl for project workflows) and the engine fetches + caches per (client, path) for the process lifetime.


Testing

npm install -D @sanity-labs/workflow-engine-explore-test
import { createBench } from "@sanity-labs/workflow-engine-explore-test";

const bench = createBench();
await bench.deployDefinitions({ definitions: [articleReview] });
const inst = await bench.startInstance({ workflowId: "article-review" });
await bench.fireAction({ instanceId: inst._id, taskId: "write", action: "submit" });
expect(await bench.currentStage(inst._id)).toBe("in-review");

See @sanity-labs/workflow-engine-explore-test for the full bench API.


React

npm install @sanity-labs/workflow-engine-explore-react
import { useFireAction, useWorkflowInstance } from "@sanity-labs/workflow-engine-explore-react";

function ApproveButton({ instanceId, taskId }: { instanceId: string; taskId: string }) {
  const { fire, pending } = useFireAction({ tags: ["acme-prod"] });
  return (
    <button disabled={pending} onClick={() => fire({ instanceId, taskId, action: "approve" })}>
      Approve
    </button>
  );
}

See @sanity-labs/workflow-engine-explore-react for the full hook API.


License

UNLICENSED — internal Sanity labs exploration. Restricted-access npm scope (@sanity-labs).