Critical fixes: - C1: Create standalone ADR-006 file (edge type consistency), extract from open-questions.md inline content - C2: Convert CallResult from plain interface to TypeBox schema, aligning with 'TypeBox as single source of truth' constraint - C3: Add fromJSON() cycle detection specification - enforce ADR-002 DAG invariant even on deserialized input - C4: Rewrite consumer-integration.md Phase 4 to use ADR-005 event-append pattern instead of direct signal mutation - C5: Fix operator precedence bug in consumer-integration.md (missing parentheses around OR condition) Warnings addressed: - W1: Fix immutability claim - operation graph is 'conventionally immutable', not prevented by API - W2: Add EventLogProjection to reactive exports map - W3: Add CallResult/CallResultSchema to schema exports map - W4: Fix reactive-execution.md Level 1 error handling to use event-append pattern instead of direct signal mutation - W5: Remove duplicate dataFlow inference description in schema.md - W6: Clarify ADR-006 project context (flowgraph vs taskgraph) Suggestions implemented: - S1: Add 'reviewed' document lifecycle status between draft/stable, update all docs to reviewed status - S2: Add carve-out note for analysis result types in schema.md constraints (they are ephemeral, not serialized) - S3: Add isComplete() and getAggregateStatus() convenience methods to WorkflowReactiveRoot specification
19 KiB
status, last_updated
| status | last_updated |
|---|---|
| reviewed | 2026-05-20 |
Build & Distribution
Package structure, exports map, dependencies, and platform targets.
Package Structure
@alkdev/flowgraph/
├── src/
│ ├── component/ # ujsx workflow components
│ │ ├── operation.ts # <Operation> component
│ │ ├── sequential.ts # <Sequential> component
│ │ ├── parallel.ts # <Parallel> component
│ │ ├── conditional.ts # <Conditional> component
│ │ ├── map.ts # <Map> component
│ │ └── index.ts
│ ├── host/
│ │ ├── graphology.ts # GraphologyHostConfig
│ │ ├── reactive.ts # ReactiveHostConfig
│ │ └── index.ts
│ ├── schema/
│ │ ├── enums.ts # CallStatus, EdgeType, NodeCategory, NodeStatus
│ │ ├── node.ts # OperationNodeAttrs, CallNodeAttrs
│ │ ├── edge.ts # OperationEdgeAttrs, CallEdgeAttrs, TemplateEdgeAttrs
│ │ ├── graph.ts # SerializedGraph, FlowGraphSerialized
│ │ └── index.ts
│ ├── graph/
│ │ ├── construction.ts # FlowGraph class (fromSpecs, fromCallEvents, fromJSON, etc.)
│ │ ├── validation.ts # validateSchema, validateGraph, validate
│ │ ├── queries.ts # topologicalOrder, hasCycles, ancestors, descendants, etc.
│ │ ├── mutation.ts # addNode, addEdge, updateNodeStatus, removeNode, etc.
│ │ └── index.ts
│ ├── reactive/
│ │ ├── workflow.ts # WorkflowReactiveRoot (signal-backed execution)
│ │ ├── node-status.ts # Signal<NodeStatus>, computed preconditions, computed blockedByFailure
│ │ └── index.ts
│ ├── analysis/
│ │ ├── type-compat.ts # typeCompat, buildTypeEdges, analyzeTypeCompat
│ │ ├── workflow.ts # validateTemplate, validatePreconditions
│ │ ├── defaults.ts # resolveDefaults for CallStatus, EdgeType, etc.
│ │ └── index.ts
│ ├── error/
│ │ └── index.ts # FlowgraphError hierarchy
│ └── index.ts # Barrel export
├── test/
│ ├── graph/
│ │ ├── construction.test.ts
│ │ ├── validation.test.ts
│ │ ├── queries.test.ts
│ │ └── mutation.test.ts
│ ├── schema/
│ │ └── enums.test.ts
│ ├── analysis/
│ │ ├── type-compat.test.ts
│ │ └── workflow.test.ts
│ ├── component/
│ │ └── components.test.ts
│ ├── host/
│ │ ├── graphology.test.ts
│ │ └── reactive.test.ts
│ └── error/
│ └── errors.test.ts
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── vitest.config.ts
└── AGENTS.md
Package JSON
Following the same pattern as @alkdev/ujsx and @alkdev/operations — conditional exports with explicit types and default for both import and require:
{
"name": "@alkdev/flowgraph",
"version": "0.1.0",
"description": "Workflow graph library — DAG-based operation orchestration over graphology, with ujsx template composition and reactive execution",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"/component": {
"import": {
"types": "./dist/component/index.d.ts",
"default": "./dist/component/index.js"
},
"require": {
"types": "./dist/component/index.d.cts",
"default": "./dist/component/index.cjs"
}
},
"/host": {
"import": {
"types": "./dist/host/index.d.ts",
"default": "./dist/host/index.js"
},
"require": {
"types": "./dist/host/index.d.cts",
"default": "./dist/host/index.cjs"
}
},
"/schema": {
"import": {
"types": "./dist/schema/index.d.ts",
"default": "./dist/schema/index.js"
},
"require": {
"types": "./dist/schema/index.d.cts",
"default": "./dist/schema/index.cjs"
}
},
"/graph": {
"import": {
"types": "./dist/graph/index.d.ts",
"default": "./dist/graph/index.js"
},
"require": {
"types": "./dist/graph/index.d.cts",
"default": "./dist/graph/index.cjs"
}
},
"/reactive": {
"import": {
"types": "./dist/reactive/index.d.ts",
"default": "./dist/reactive/index.js"
},
"require": {
"types": "./dist/reactive/index.d.cts",
"default": "./dist/reactive/index.cjs"
}
},
"/analysis": {
"import": {
"types": "./dist/analysis/index.d.ts",
"default": "./dist/analysis/index.js"
},
"require": {
"types": "./dist/analysis/index.d.cts",
"default": "./dist/analysis/index.cjs"
}
},
"/error": {
"import": {
"types": "./dist/error/index.d.ts",
"default": "./dist/error/index.js"
},
"require": {
"types": "./dist/error/index.d.cts",
"default": "./dist/error/index.cjs"
}
}
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:tsc": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"flowgraph",
"dag",
"workflow",
"graphology",
"ujsx",
"operations"
],
"license": "MIT OR Apache-2.0",
"dependencies": {
"@alkdev/typebox": "^0.34.49",
"@alkdev/ujsx": "^0.1.0",
"@preact/signals-core": "^1.14.1",
"graphology": "^0.26.0",
"graphology-dag": "^0.4.1"
},
"peerDependencies": {
"@alkdev/operations": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},
"engines": {
"node": ">=18.0.0"
}
}
Exports Map: Sub-path Imports
The exports map uses the "/subpath" format (with leading slash) to match the pattern used by @alkdev/ujsx. This is the Node.js subpath exports convention — consumers import as:
import { FlowGraph } from "@alkdev/flowgraph/graph";
import { typeCompat } from "@alkdev/flowgraph/analysis";
import { h } from "@alkdev/ujsx";
import { createRoot } from "@alkdev/ujsx/reactive";
Each sub-path has conditional exports for both import (ESM) and require (CJS), with separate types files (.d.ts and .d.cts). The typesVersions field is NOT used — conditional exports with types entries replace it for modern Node.js resolution.
Exports Map
Following the taskgraph pattern, each module has a sub-path export:
| Sub-path | Content | Use case |
|---|---|---|
@alkdev/flowgraph |
Barrel export (everything) | Full import |
@alkdev/flowgraph/component |
<Operation>, <Sequential>, <Parallel>, <Conditional>, <Map> |
Template authoring |
@alkdev/flowgraph/host |
GraphologyHostConfig, ReactiveHostConfig |
ujsx HostConfig implementations |
@alkdev/flowgraph/schema |
TypeBox schemas, enums, types (including CallResultSchema, CallResult) |
Schema-only import (no graph dependency) |
@alkdev/flowgraph/graph |
FlowGraph class, construction, mutation, queries |
Core graph operations |
@alkdev/flowgraph/reactive |
WorkflowReactiveRoot, EventLogProjection, signal-based execution |
Runtime execution |
@alkdev/flowgraph/analysis |
typeCompat, validateTemplate, ordering functions |
Analysis and validation |
@alkdev/flowgraph/error |
Error classes | Error handling |
Dependencies
Production Dependencies
{
"dependencies": {
"@alkdev/typebox": "^0.34.49",
"@alkdev/ujsx": "^0.1.0",
"@preact/signals-core": "^1.14.1",
"graphology": "^0.26.0",
"graphology-dag": "^0.4.1"
},
"peerDependencies": {
"@alkdev/operations": "^0.1.0"
}
}
| Package | Role | Why |
|---|---|---|
@alkdev/typebox |
Schema definitions, validation, Value.Check, Value.Errors |
Direct dependency — all schemas are TypeBox |
@alkdev/ujsx |
UNode, HostConfig, createRoot, h(), ReactiveRoot | Direct dependency — workflow templates are ujsx trees |
@preact/signals-core |
signal, computed, effect, batch |
Direct dependency — flowgraph's reactive layer uses signals directly for WorkflowReactiveRoot |
graphology |
DirectedGraph data structure |
Core graph engine — same as taskgraph |
graphology-dag |
topologicalSort, hasCycle, parallelGroups |
DAG-specific algorithms |
@alkdev/operations |
OperationSpec, CallEventMap, CallStatus |
Peer dependency — type imports only, no runtime dependency |
Why @alkdev/operations is a Peer Dependency
Flowgraph imports OperationSpec, CallEventMap, and CallStatus types from @alkdev/operations, but does not depend on the runtime (registry, call handler, pending request map). Making it a peer dependency:
- Avoids circular dependency concerns (operations doesn't depend on flowgraph)
- Allows flowgraph to work with any version of operations that provides the right types
- Reduces bundle size for consumers that don't use operations
Why @preact/signals-core is a Direct Dependency
While @preact/signals-core is also a dependency of @alkdev/ujsx, flowgraph lists it as a direct dependency because WorkflowReactiveRoot creates signal() and computed() instances directly. This makes the dependency explicit and ensures version alignment. Consumers import signals from flowgraph's reactive exports, not directly from Preact — the @alkdev/ujsx/reactive module re-exports the same signal primitives for consumers who need them, but flowgraph's own reactive module depends on @preact/signals-core directly for its internal signal graph construction.
No workspace:* — Published npm Packages
All @alkdev packages are independently published to npm. The dependency versions use semver ranges (^0.34.49, ^0.1.0) matching the pattern used by all sibling packages. During local development, the packages exist as local repos on disk, but they are not linked via a monorepo workspace protocol. Each package is built and published independently.
Build Configuration
tsup.config.ts
Following the same pattern as @alkdev/taskgraph and @alkdev/ujsx — named entry points matching source file paths, no splitting flag (ujsx doesn't use it either):
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
'component/index': 'src/component/index.ts',
'host/index': 'src/host/index.ts',
'schema/index': 'src/schema/index.ts',
'graph/index': 'src/graph/index.ts',
'reactive/index': 'src/reactive/index.ts',
'analysis/index': 'src/analysis/index.ts',
'error/index': 'src/error/index.ts',
},
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
target: 'es2022',
});
- ESM + CJS dual output — matches all sibling packages
- Source maps — for debugging
- Type declarations —
.d.tsfiles for all exports target: 'es2022'— matches tsconfig target, ensures consistent output- Named entry points with path prefixes — matches ujsx pattern (e.g.,
'core/schema': 'src/core/schema.ts'), producing output likedist/component/index.jsconsistent with the exports map
Note: The splitting: true option was in an earlier draft but has been removed. The sibling projects (taskgraph, ujsx, operations) don't use splitting, and the named entry points with sub-paths already provide natural code boundaries for tree-shaking.
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"erasableSyntaxOnly": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}
Matches the tsconfig patterns of all @alkdev packages: ES2022 target, Node16 module resolution, strict mode with additional safety checks (noUncheckedIndexedAccess, noUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch, erasableSyntaxOnly).
vitest.config.ts
Following the same pattern as @alkdev/taskgraph and @alkdev/ujsx:
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
test: {
globals: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/index.ts'],
},
},
});
Platform Targets
Following taskgraph's philosophy: pure JavaScript, no native addons.
| Platform | Support Level | Notes |
|---|---|---|
| Node.js | Primary | All dependencies are pure JS |
| Deno | Compatible | ESM-first, no Node-specific APIs used |
| Bun | Compatible | All dependencies are Bun-compatible |
| Browser | Compatible | graphology and signals-core work in browsers |
The library has no native dependencies, no filesystem access, and no Node-specific APIs (no fs, path, child_process, etc.). This makes it platform-agnostic.
Tree-Shaking
The sub-path export structure enables effective tree-shaking:
- Consumers using only
@alkdev/flowgraph/schemadon't pull in the graph engine or ujsx - Consumers using only
@alkdev/flowgraph/analysisdon't pull in the reactive layer - Consumers using
@alkdev/flowgraph/componentget ujsx but not graphology (templates can be defined without importing the graph engine)
The barrel export (@alkdev/flowgraph) re-exports everything for convenience, but consumers concerned about bundle size should use sub-path imports.
Testing Strategy
Unit Test Categories
| Category | What to test | How |
|---|---|---|
| Schema validation | TypeBox schemas validate/correct shapes | Value.Check() / Value.Errors() |
| Graph construction | fromSpecs, fromCallEvents, fromJSON |
Build graphs, assert node/edge counts |
| Graph mutations | addNode, addEdge, updateStatus |
Assert success, assert throws on violations |
| Graph queries | topologicalOrder, ancestors, descendants |
Known graphs, expected results |
| Type compatibility | typeCompat for known schema pairs |
Compatible/incompatible/unknown |
| Template validation | validateTemplate against known graphs |
Known valid/invalid templates |
| Error hierarchy | CycleError, InvalidTransitionError, etc. |
Assert throw types, assert message format |
| Reactive execution | Signal propagation, preconditions, abort cascade | Set up mini reactive graph, assert state transitions |
Testing Reactive Graphs
Testing signal-based state propagation requires specific patterns:
-
Setup: Create a
WorkflowReactiveRootwith a known DAG. Assert initial state (all nodesidle). -
Transition: Set a node's status signal to a known value. Assert that dependents'
preconditionsandblockedByFailurecomputeds update correctly. -
Assertion: Check
node.status.value,node.preconditions.value,node.blockedByFailure.valueat each step.
// Example test pattern
const root = new WorkflowReactiveRoot(dag);
const nodeA = root.statusMap.get("A")!;
const nodeB = root.statusMap.get("B")!;
// Initially: both idle
expect(nodeA.value).toBe("idle");
expect(nodeB.preconditions.value).toBe(false); // A not completed yet
// Complete A → B's preconditions met
nodeA.value = "completed";
expect(nodeB.preconditions.value).toBe(true);
- Cleanup: Call
root.dispose()after each test to prevent signal leaks.
Testing Template Rendering
Template rendering tests follow the same pattern for both HostConfigs:
- Define a template
- Render to the target (graphology or reactive)
- Assert the output (graph structure or signal state)
// GraphologyHostConfig test
const host = new GraphologyHostConfig();
const root = createRoot(host, new DirectedGraph());
root.render(template);
const graph = root.ctx.graph;
expect(graph.nodes()).toEqual(["A", "B", "C"]);
expect(graph.edges()).toEqual(["A->B", "B->C"]);
// ReactiveHostConfig test
const reactiveHost = new ReactiveHostConfig(registry, workflowRoot);
const reactiveRoot = createRoot(reactiveHost, {});
reactiveRoot.render(template);
expect(workflowRoot.statusMap.size).toBe(3);
Testing Error Paths
All error paths should be tested:
- Cycle detection: adding a cycle-creating edge throws
CycleError - Duplicate node/edge: adding duplicates throws
ConstructionError - Invalid status transition:
updateStatus(completed → running)throwsInvalidTransitionError - Validation errors:
validateGraph()returns arrays, never throws
Constraints
- No filesystem access — flowgraph is a pure computation library. Persistence is the hub's concern.
- No network access — no HTTP clients, WebSocket connections, or Redis clients. All data comes in through constructor arguments.
@alkdev/operationsis a peer dependency — type imports only, no runtime dependency on the operations registry or call protocol.- ESM-first — the package is authored in ESM with CJS output generated by tsup. All internal imports use
.jsextensions for Node16 module resolution. - Sub-path exports provide natural code boundaries — named entry points in tsup match source file paths, enabling effective tree-shaking without
splitting: true. - Vitest for testing — following the convention of all
@alkdevpackages.
References
- Taskgraph build configuration:
@alkdev/taskgraph_ts/tsup.config.ts,@alkdev/taskgraph_ts/tsconfig.json - ujsx build configuration:
@alkdev/ujsx/tsup.config.ts - graphology: https://github.com/graphology/graphology
- graphology-dag: https://github.com/graphology/graphology-dag