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

fable-workflow

v1.0.0

Published

A configurable, data-model-agnostic workflow engine for Fable: an append-only event log, state machines defined entirely in config (states, guarded multi-exit transitions, role and data gates), and materialized projections (time metrics + eligibility) so

Readme

fable-workflow

A configurable, data-model-agnostic workflow engine for Fable.

Two things live here. The boring, solved part: an append-only event log that is the source of truth for a unit of work, with time metrics folded on every write. The interesting part: a state machine defined entirely in config, where roles gate who can move work forward and arbitrary data decides when a move is allowed. The engine never imports your schema. It reaches your data only through a resolver you supply, so one engine drives any domain.

Install

npm install fable-workflow

Peer concepts come from the Retold stack: it registers as a Fable service provider and resolves data addresses with Manyfest. It works standalone too (the core has no Fable or database dependency).

The idea in one minute

  • An event log per subject is the truth. Opening a subject, entering and leaving states, an actor starting and stopping work, a note, a prompt: all are appended, never mutated.
  • Two projections are folded onto that log on every event, so you never replay it to answer a question:
    • a metrics rollup (elapsed, active, stalled, effort, overlap, time in each state, time per actor), and
    • an eligibility set (the exits open right now, each with its readiness flag and its gate).
  • Because readiness is precomputed on write, the agency questions ("what can this person move forward", "who can act on this item") are flat reads, not a sweep of guards or a pile of SQL.
  • A workflow is config: states, transitions with multiple exits, role gates, and readiness guards. Guards are structured condition trees over addresses into your data. The engine resolves those addresses through a context resolver you provide, which is the one and only place it touches your model.

Quick start

const libFable = require('fable');
const libFableWorkflow = require('fable-workflow');

let tmpFable = new libFable();
tmpFable.serviceManager.addServiceType('Workflow', libFableWorkflow);
let tmpWorkflow = tmpFable.serviceManager.instantiateServiceProvider('Workflow');

// 1. Define the workflow. This is pure config; no code knows what an "article" is.
tmpWorkflow.defineWorkflow(
	{
		Key: 'editorial',
		Name: 'Editorial Review',
		States:
		[
			{ Key: 'draft',     Name: 'Draft',     IsInitial: true },
			{ Key: 'review',    Name: 'In Review' },
			{ Key: 'copyedit',  Name: 'Copy Edit' },
			{ Key: 'published', Name: 'Published', IsTerminal: true }
		],
		Transitions:
		[
			{ From: 'draft',    To: 'review',    RequiresEntitlement: 'author.submit',  Guard: { address: 'Article.WordCount', op: '>=', value: 500 } },
			{ From: 'review',   To: 'copyedit',  RequiresEntitlement: 'editor.approve', Guard: { address: 'Article.Score', op: '>=', value: 3 } },
			{ From: 'review',   To: 'draft',     RequiresEntitlement: 'editor.approve' },
			{ From: 'copyedit', To: 'published', RequiresEntitlement: 'editor.publish' }
		]
	});

// 2. Tell the engine how to reach your data. It calls this with a subject id.
let _Articles = { 'a-42': { WordCount: 320, Score: 0 } };
tmpWorkflow.setContextResolver((pSubjectId) => ({ Article: _Articles[pSubjectId] }));

// 3. Drive a subject. An actor is { ID, Entitlements: [] }.
let _Jan = { ID: 'jan', Entitlements: ['author.submit'] };
tmpWorkflow.open('a-42', 'editorial', _Jan);

tmpWorkflow.advance('a-42', 'review', _Jan);   // { ok: false, reason: 'the readiness guard for "review" is not satisfied' }

_Articles['a-42'].WordCount = 900;             // the author keeps writing
tmpWorkflow.reevaluate('a-42');                // re-fold eligibility against the new data
tmpWorkflow.advance('a-42', 'review', _Jan);   // { ok: true, state: { CurrentStates: ['review'], ... } }

The engine has no idea what an article is. Point the resolver at units, tickets, candidates, or loans and the same five methods run that domain with zero new code. The example/editorial-review.js script proves this by running a second, unrelated domain (a hardware return) on the same engine.

Concepts

States and transitions

A state has a Key, an optional Name and Category, and the flags IsInitial and IsTerminal. If you do not mark an initial state, the first one is used. Entering a terminal state closes the subject and stops its clocks.

A transition is a directed exit from one state to another. A state can have several exits (approve, send back, reject), each with its own gate and guard.

Guards: readiness from your data

A guard answers "is this move allowed by the data yet". It is a structured condition tree, not a string to be parsed:

// a leaf
{ address: 'Article.WordCount', op: '>=', value: 500 }

// branches: all / any / not
{ all: [ { address: 'Article.Score', op: '>=', value: 3 },
         { address: 'Article.Media[].Kind', op: 'includesAny', value: ['image', 'video'] } ] }

Addresses are resolved through your context resolver with Manyfest, so wildcards like Media[].Kind work. A missing guard means "no condition", which is always satisfied.

Operators: == === != > >= < <= in nin exists empty truthy falsy, plus the collection reductions includesAny includesAll countGte for wildcard addresses.

Guards can also read two engine-owned namespaces the engine layers onto the context: State.Current (the array of current state keys) and Metrics (the rollup below), so a guard can depend on elapsed time or the current state if you want it to.

Gates: roles and designated actors

A transition can require an entitlement (RequiresEntitlement: 'editor.approve'); an actor must carry that string in its Entitlements to make the move. A transition can also name a ActorAddress, an address into your data that resolves to a specific actor id (for example "only the assigned reviewer"); only the actor whose ID matches may move it.

A move succeeds only when the guard is satisfied and the actor clears the gate. advance returns { ok: true, state } or { ok: false, reason }.

The context resolver: the one seam to your model

setContextResolver((pSubjectId) => object) is the entire coupling between the engine and your data. Guards address into the object it returns. This is the backbone you build visualizations and reports on as well: one read, many consumers.

Projections: metrics and eligibility

Every event folds the metrics rollup:

| Field | Meaning | | --- | --- | | OpenedAt, ClosedAt | open and close timestamps | | ElapsedMS | wall-clock from open to now (or to close) | | ActiveMS | time at least one actor was working | | StalledMS | elapsed minus active | | EffortMS | sum of every actor's working time | | OverlapMS | effort minus active (concurrent work) | | StateTime | milliseconds spent in each state | | ActorTime | milliseconds worked by each actor |

Active, effort, and overlap come from actor.start and actor.stop events you emit when work begins and pauses. State time accrues automatically as states are entered and left.

The eligibility set is the list of exits open from the current state, each with ToState, GuardSatisfied, RequiredEntitlement, and ResolvedActor. When a guard flips from unsatisfied to satisfied during a reevaluate, the engine appends an exit.became-available event so you can react to work becoming ready.

Agency queries

Because eligibility is materialized, these are indexed reads:

  • whatCanAdvance(actor) returns the subject ids this actor can move forward right now.
  • whoCanActOn(subjectId) returns the open exits and what each requires.

Parallel states

Set AllowParallelStates: true on a workflow and a subject can occupy more than one state at once (a design phase and a documentation phase running together). By default a subject holds a single state and entering a new one exits the prior automatically.

API

All methods are on the service (fable.Workflow) and delegate to the pure engine.

| Method | Purpose | | --- | --- | | defineWorkflow(definition) | register a workflow from config | | getWorkflow(key) | the normalized definition | | setContextResolver(fn) | supply (subjectId) => data | | open(subjectId, workflowKey, actor, at?) | start a subject in its initial state | | advance(subjectId, toState, actor, at?) | attempt a governed move; returns { ok, reason?, state? } | | emit(subjectId, event, at?) | append an event (actor.start / actor.stop / a note / a prompt / anything) | | reevaluate(subjectId, at?) | re-fold eligibility after the subject's data changes | | hydrate(subjectId, workflowKey, events) | rebuild a subject's state and projections by replaying a stored event log (for a server that persists the log instead of holding the engine in memory) | | getState(subjectId) | current states and closed flag | | getTimeline(subjectId) | the full event log | | getMetrics(subjectId) | the metrics rollup | | getAvailableExits(subjectId) | the eligibility set | | whatCanAdvance(actor) | subject ids this actor can move forward | | whoCanActOn(subjectId) | who has agency on this subject now |

The at argument is an optional millisecond timestamp; omit it to use the clock. An actor is { ID, Entitlements: [] }.

Event types

opened, state.enter, state.exit, exit.became-available, actor.start, actor.stop, closed, plus any custom event you emit. Every event carries an ID, an At, a Type, and usually an Actor.

Design

The code is split so the interesting part is testable without any framework:

  • source/Workflow-Engine.js is the pure core. No Fable, no database, no DOM. The clock and the address resolver are injected.
  • source/Workflow-Guards.js is the guard tree evaluator, with dependency extraction and structural validation.
  • source/Fable-Workflow.js is the thin Fable service provider that wires Manyfest in as the resolver and exposes the API.

Both the engine and the guards are exported for direct use:

const libFableWorkflow = require('fable-workflow');
libFableWorkflow.WorkflowEngine;   // the pure engine class
libFableWorkflow.WorkflowGuards;   // the guard evaluator class

Test and example

npm test        # Mocha TDD
npm run example # the narrated editorial + hardware-return walkthrough

License

MIT