@pgfsm/compiler
v0.3.0
Published
FSM JSON compiler for PostgreSQL-backed state machines
Readme
@pgfsm/compiler
FSM JSON compiler for PostgreSQL-backed state machines: generates fsm.json
from a folder of state machine definitions, scaffolds the action/guard/delay/
actor stub code each language needs to implement, validates that every reference
in the JSON has a matching implementation, and loads the compiled result into
the database.
Install
npx @pgfsm/compiler --helpor install it as a dependency / global CLI:
npm install @pgfsm/compiler
npm install -g @pgfsm/compiler # for a global `fsm-compiler` commandUsage
Run npx @pgfsm/compiler --help for the full flag reference. Every command
below that takes -f/--folder for a directory applies the same rule to that
path: it must not start with . (use a bare relative path like fsm, or an
absolute path — not ./fsm) and must not end with /.
generate-fsm-json — compile fsm.json from a state machine definition
Input — -f/--folder accepts either:
- A plugin-root directory containing one subfolder per FSM name, each with
version subfolders (
v01,v02, …), each containing amachine.tswhose default export is an XState machine (fromcreateMachine(...)). Version folders without amachine.tsare skipped, not an error. - A single
.tsfile path — only its containing directory is read from; that directory must contain a file literally namedmachine.ts(the filename you pass is only used to locate the directory). Requires-N/--fsm-nameand-V/--fsm-version; neither is guessed from the file's folders. Themachine.tsis compiled from where it is (so its imports still resolve), and only if compilation succeeds arefsm.json/xstate-fsm.jsonwritten to{cwd}/fsm/<fsmName>/<fsmVersion>/.machine.tsitself is not copied, so that folder holds build output unless themachine.tsalready lives there (then it's an in-place compile).-Valso fills in missingasyncOperationVersionon invoke actors. If that folder's existingfsm.jsonbelongs to a different machineid, the command refuses unless--forceis passed (only as strong as the ids: machines left at xstate's default(machine)id all match). There is no--output.
Other flags: -s/--skip-dirs (comma-separated FSM names to skip, directory
mode only), -r/--show-recommendation (also validates the generated
fsm.json against the FSM JSON schema and logs any errors — doesn't change
what's written).
Output — per version folder (in place in directory mode;
{cwd}/fsm/<fsmName>/<fsmVersion>/ for a single .ts file):
xstate-fsm.json— the machine's raw XState-exported JSONfsm.json— that JSON normalized (actions coerced to{ type }objects, raise/cancel delay names filled in, invoke actors'asyncOperationType/asyncOperationVersionresolved). This is what every other command below reads.
npx @pgfsm/compiler -c generate-fsm-json -f fsm
npx @pgfsm/compiler -c generate-fsm-json -f fsm --skip-dirs carVitals
cd my-app && npx @pgfsm/compiler -c generate-fsm-json -f ~/designs/checkout/machine.ts -N checkout -V v01 # → my-app/fsm/checkout/v01/The full fsm.json spec (states, transitions, guards, actions, actors, delays)
is documented in
docs/reference/fsm-definition-format.md.
generate-sync-logic — scaffold action/guard/delay stubs
Reads a version folder's fsm.json, so generate-fsm-json must have already
run. Output is never written relative to --folder — it's always anchored at
Deno.cwd() (wherever the CLI is invoked from), so cd into the directory you
want sync-worker/ to land in before running it.
Input — -f/--folder accepts either:
- A plugin-root directory — every version folder under it is scaffolded.
- A single
fsm.jsonfile path — only that one version's stubs are scaffolded. Requires-N/--fsm-nameand-V/--fsm-version— identity is never inferred from the file's folders (mirrorsvalidate-sync-operation's own single-file-mode flags).
-l/--lang: comma-separated typescript,python,rust,go (default
typescript). -s/--skip-dirs: directory mode only.
Output — always under the reserved sync-worker/ subfolder at the current
working directory, nested <lang>/<fsmName>/<fsmVersion>/ deep (folder mode
derives <fsmName>/<fsmVersion> per FSM while walking; single-file mode uses
--fsm-name/--fsm-version directly) — so multiple FSMs/versions scaffolded
from the same working directory don't collide:
sync-worker/<lang>/<fsmName>/<fsmVersion>/actions/<name>/<name>.{ts,py,rs,go}— one stub file per action name infsm.json(built-inxstate.raise/xstate.cancelexcluded)sync-worker/<lang>/<fsmName>/<fsmVersion>/guards/<name>/<name>.*— one per guardsync-worker/<lang>/<fsmName>/<fsmVersion>/delays/<name>/<name>.*— one per delay
Two names of one kind that differ only by case are rejected, since their folders
would collide on a case-insensitive file system. Code shared by several
operations can live in a sibling module such as guards/_shared.ts; the
compiler only reads the <name>/<name>.* files.
Every stub has a // TODO: implement body.
For typescript (the only language this is currently written for), also, at
that same <fsmName>/<fsmVersion> level:
sync-operation-registry.generated.ts— imports every action/guard/delay stub written for that version and combines them into oneSyncOperationRegistration[](fsmName,fsmVersion,syncOperationType—"action"/"guard"/"delay",syncOperationName,syncOperationLanguage,handler), so a worker can register/dispatch without importing each kind's module separately.fsm.json— a copy of that version'sfsm.json, so this directory is self-contained rather than requiring a reader to also reach back to the source FSM tree for the FSM definition.
npx @pgfsm/compiler -c generate-sync-logic -f fsm --lang typescript,python
npx @pgfsm/compiler -c generate-sync-logic -f fsm/creditCheck/v01/fsm.json --fsm-name creditCheck --fsm-version v01generate-async-logic — scaffold actor stubs
Reads a version folder's fsm.json (every invoke object), so
generate-fsm-json must have already run. Like generate-sync-logic, output is
never written relative to --folder — it's always anchored at Deno.cwd()
(wherever the CLI is invoked from), so cd into the directory you want
async-worker/ to land in before running it.
Input — -f/--folder accepts either:
- A plugin-root directory — every version folder under it is scaffolded.
- A single
fsm.jsonfile path — only that one version's actor files/manifest/barrel/registry are scaffolded. Requires-N/--fsm-nameand-V/--fsm-version— identity is never inferred from the file's folders; mirrorsgenerate-sync-logic/validate-sync-operation's own single-file-mode flags).
-s/--skip-dirs: directory mode only.
Output — always under the reserved async-worker/ subfolder at the current
working directory. Per <lang>/<fsmName>/<fsmVersion>/ (folder mode derives
<fsmName>/<fsmVersion> per FSM while walking; single-file mode uses
--fsm-name/--fsm-version directly):
- One file per distinct actor:
async-worker/<lang>/<fsmName>/<fsmVersion>/actors/<name>/<name>.<ext>, where<lang>is that invoke object's ownasyncOperationLanguage(defaulttypescript) actors-manifest.json— that language's actors, written only for languages this version actually used- A barrel re-exporting each actor:
actors/index.ts(TS),actors/__init__.py(Python),actors/mod.rs(Rust) — Go has no barrel async-worker/<lang>/<fsmName>/<fsmVersion>/actor-registry.generated.*(TS/Python/ Rust) — one level aboveactors/, not inside it — written only when that language has at least one actor
Both modes also refresh the aggregate registry plus worker SDK — one per
language, combining every FSM version's actors — since a worker process serves
its language's actors across every FSM, not just one, at async-worker/<lang>/
directly (for TypeScript, run-async-worker.ts plus a deno.json pinning the
published @pgfsm/async-worker-sdk package; for Python, run_async_worker.py
plus a uv pyproject.toml pinning the published pgfsm-async-worker-sdk; for
Rust, src/main.rs plus a Cargo.toml depending on the published
pgfsm-async-worker-sdk crate; for Go, main.go plus a go.mod requiring the
published github.com/pgfsm/fsm/packages/fsm-async-worker-sdk-go module;
actor-registry-aggregate.generated.ts, etc. — alongside every
<fsmName>/<fsmVersion>/ this run wrote for that language). Regenerating also
removes a cli.ts/sdk.ts (TypeScript), cli.py/sdk.py/requirements.txt
(Python), src/sdk.rs (Rust) or sdk.go (Go) left by an older compiler
version, as long as it still has the auto-generated header. The actor set
aggregated always comes from the real FSM tree, regardless: --folder's own
walk in directory mode, or the target fsm.json's own location (found by
walking three directories up) in single-file mode.
npx @pgfsm/compiler -c generate-async-logic -f apps/fsm-core-example/fsm
npx @pgfsm/compiler -c generate-async-logic -f apps/fsm-core-example/fsm/creditCheck/v01/fsm.json --fsm-name creditCheck --fsm-version v01Per-actor concurrency. Every new actor stub declares how many invokes of
that actor one worker runs at once, next to the handler:
export const
maxConcurrency = 1; (TypeScript), MAX_CONCURRENCY = 1 (Python),
pub const MAX_CONCURRENCY: u32 = 1; (Rust), const MaxConcurrency = 1 (Go).
The generated registries pass it to the worker SDK, which sends it to the
gateway; it wins over the worker's --max-concurrency. Raise it only when the
handler is safe to run concurrently, and keep handlers idempotent (delivery is
at-least-once). A stub from an older compiler without the setting still builds:
its actor uses the worker's --max-concurrency. Add the line to opt in. A kept
Rust src/main.rs from before this needs
.with_max_concurrency(reg.max_concurrency) on each registration;
--overwrite generated-only reports it.
generate-all — run all three generate steps in sequence
Runs generate-fsm-json, then generate-async-logic, then
generate-sync-logic — for a fresh FSM (or a whole plugin-root tree), one
invocation instead of three. Accepts three input shapes:
- Directory — runs all three steps across every versioned FSM under the folder. A step's own best-effort walk collects failures per FSM without stopping (same as running the three commands separately would); one FSM's failure in an earlier step doesn't block the next step from still running for whichever FSMs did succeed. The command still exits non-zero if anything failed anywhere.
- Single
.tsfile — chains all three steps for just that one FSM version.fsm.json/xstate-fsm.jsongo to{cwd}/fsm/<fsmName>/<fsmVersion>/, exactly asgenerate-fsm-jsondoes for a single.tsfile. - Single
fsm.jsonfile — thefsm.jsonalready exists, sogenerate-fsm-jsonis skipped; the file is copied to{cwd}/fsm/<fsmName>/<fsmVersion>/fsm.json(same machine-idguard and--force) andgenerate-async-logic/generate-sync-logicrun against it.
Either way the current directory ends up with fsm/, sync-worker/ and
async-worker/, and a later generate-all -f fsm from there rebuilds it all
without pointing back at the original file.
In every mode, async-worker/ and sync-worker/ land under the current working
directory — the same anchor as generate-sync-logic/generate-async-logic — so
run it from your app root. There is no --output. Both single-file modes
require -N/--fsm-name and -V/--fsm-version, like the standalone
commands' single-fsm.json mode. They are never guessed from the file's parent
folders: -f a/fsm.json would otherwise silently become <cwd's name>/a.
-s/--skip-dirs, -r/--show-recommendation (step 1), and -l/--lang
(step 3) all apply, same as the individual commands.
cd apps/fsm-core-example
npx @pgfsm/compiler -c generate-all -f fsm
npx @pgfsm/compiler -c generate-all -f fsm/creditCheck/v01/machine.ts -N creditCheck -V v01
npx @pgfsm/compiler -c generate-all -f fsm/creditCheck/v01/fsm.json -N creditCheck -V v01
npx @pgfsm/compiler -c generate-all -f ~/Downloads/checkout.json -N checkout -V v01Re-running without losing your code — --overwrite generated-only
By default (--overwrite all) every command rewrites every file it produces.
--overwrite generated-only (on generate-sync-logic, generate-async-logic,
generate-all and create-async-logic) instead keeps files that are yours once
created, and rewrites only compiler-owned ones:
| Class | Files | With generated-only |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| Generated | fsm.json/xstate-fsm.json, every registry and aggregate registry, actors-manifest.json, actor barrels, actor-registry-aggregate-generated/, and the Go worker's go.mod + main.go | Always rewritten |
| Scaffolded | each action/guard/delay stub, each actor stub, each Go actor's own go.mod, run-sync-worker.ts, run-async-worker.ts, run_async_worker.py, src/main.rs, their deno.json/pyproject.toml/Cargo.toml, and .gitignores | Written only if missing |
The Go worker module is generated because its go.mod lists every actor module
and must match main.go's SDK pin; add a Go actor's own dependencies to that
actor's go.mod. A new action, guard, delay or actor gets its own new stub
file. Kept files are never reformatted, and the run ends with a
created / regenerated / kept count. This is the mode @pgfsm/cli uses.
npx @pgfsm/compiler -c generate-all -f fsm --overwrite generated-onlycreate-async-logic — scaffold one actor outside any FSM's invoke list
For actors in the shared, non-FSM-scoped async-operation pool. For actors that
belong to an FSM's invoke list, use generate-async-logic instead.
Unlike every other command here, this one takes no -f/--folder at all —
output is always anchored at Deno.cwd() (wherever the CLI is invoked from), so
cd into the app root you want async-worker/ to land in before running it
(e.g. apps/fsm-core-example).
Input — -l/--lang: exactly one language, required.
-n/--function-name: function name, required. -F/--function-version:
version name matching v\d{2} (e.g. v01), required. Unrelated to
-N/--fsm-name/-V/--fsm-version — these actors have no owning FSM.
Output — always under the reserved async-worker/ subfolder at the current
working directory:
async-worker/<lang>/sharedAsyncOperation/<functionVersion>/actors/<functionName>/<functionName>.<ext>async-worker/<lang>/sharedAsyncOperation/<functionVersion>/actors-manifest.json, rewritten from every shared-async-op actor currently on disk for that language at that onefunctionVersion(every language, including Go).- For
typescript/python/rust, an actors barrel re-exporting every shared-async-op actor currently on disk for that language at that onefunctionVersion, atasync-worker/<lang>/sharedAsyncOperation/<functionVersion>/actors/<barrel file>(index.ts/__init__.py/mod.rs). - For
typescript/python/rust, that language's registry file atasync-worker/<lang>/sharedAsyncOperation/<functionVersion>/actor-registry.generated.<ext>(actor_registry_generated.pyfor Python specifically — its dottedimportsyntax can't reference a hyphenated module name), rewritten from every shared-async-op actor currently on disk for that language at that onefunctionVersion(not a global file across every version). - For
go, its own aggregate atasync-worker/go/sharedAsyncOperation/actor-registry-aggregate-generated/(go.mod+registry.go, onerequire+replaceper actor's own standalone Go module — Go actors can't share a flat registry file the way TS/Python/Rust do).
Neither ever touches the FSM-scoped aggregate
(actor-registry-aggregate.generated.ts) — this pool is fully separate.
cd apps/fsm-core-example
npx @pgfsm/compiler -c create-async-logic --lang typescript --function-name checkCreditScore --function-version v01delete — remove generated files
Input — -f/--folder: plugin-root directory. -s/--skip-dirs.
Output/side effect — per version folder, removes fsm.json and
xstate-fsm.json. Version folders with no machine.ts are left alone, since
their fsm.json can't be regenerated from there. Missing files are skipped
silently, not an error.
The worker folders {cwd}/sync-worker/typescript/<fsmName>/<fsmVersion>/ and
{cwd}/async-worker/<lang>/<fsmName>/<fsmVersion>/ are kept by default,
because they hold the stubs you implement; the command logs which ones it kept.
Pass --include-workers to remove them too, scoped to the FSM versions being
deleted. The aggregate registries under sync-worker/typescript/ and
async-worker/<lang>/ are not rewritten, so re-run generate-sync-logic/
generate-async-logic afterwards.
npx @pgfsm/compiler -c delete -f fsm
npx @pgfsm/compiler -c delete -f fsm --include-workers # also removes implemented stubsvalidate-sync-operation — check action/guard/delay stubs are implemented
Input — -f/--folder: plugin-root directory. -s/--skip-dirs.
Output — writes nothing; validates that every action/guard/delay in
fsm.json has a matching export in
{cwd}/sync-worker/<lang>/<fsmName>/<fsmVersion>/actions|guards|delays/
(<name>/<name>.ts, or the folder's index.ts when it has one — the same
layout generate-sync-logic writes) and logs a pass/fail result per method.
npx @pgfsm/compiler -c validate-sync-operation -f fsmload — load a compiled fsm.json into the database (deprecated)
Deprecated: use
pgfsmctl fsm load <folder>from@pgfsm/ctlinstead (SPEC-006).loadstill works for now, with the same behaviour, and prints a warning; it will be removed in a later release.
Input — -f/--folder: plugin-root directory (each version folder's
fsm.json must already exist). -d/--db-url (or the DATABASE_URL env var).
-s/--skip-dirs.
Output/side effect — inserts each FSM's states/transitions into the
fsm_core PostgreSQL schema, resolving dependent_children from any invoke
actors whose asyncOperationType is "fsm", as one transaction with child FSMs
loaded before their parents. Identical re-loads are no-ops; any failure loads
nothing and exits non-zero. No local files are written.
npx @pgfsm/compiler -c load -f fsm -d "$DATABASE_URL"Programmatic usage
import {
generateAsyncOperationLogicFromFolders, // scaffold actor stubs
generateFsmJSONFromFolders, // generate fsm.json for every FSM under a folder tree
generateSyncOperationLogicFromFolders, // scaffold action/guard/delay stubs
loadFsmJSONFromFolders, // deprecated: load fsm.json into the database (use @pgfsm/db's loadFsmDefinitions)
validateSyncOperationFromFolders, // check action/guard/delay stubs are implemented
} from "@pgfsm/compiler";
import type { OperationLang, WorkflowType } from "@pgfsm/compiler";
// WorkflowType = "fsm" | "sharedAsyncOperation" | "internalAsyncOperation"
// OperationLang = "typescript" | "python" | "rust" | "go"generateAsyncOperationLogicFromFolders's 3rd parameter, writeRootAbsPath, is
required — a pure write destination for the aggregate registry/worker SDK (see
the CLI section above for what it does and doesn't control). The CLI itself has
no dedicated flag for it: it always passes Deno.cwd(). generateAll takes the
same value as its writeRootAbsPath option.
The REST API and workers use these at startup to discover and validate FSM plugins before accepting requests.
License
Apache-2.0
