flowcraft 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.editorconfig +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/config/tsconfig.json +21 -0
  5. package/config/tsup.config.ts +11 -0
  6. package/config/vitest.config.ts +11 -0
  7. package/docs/.vitepress/config.ts +105 -0
  8. package/docs/api-reference/builder.md +158 -0
  9. package/docs/api-reference/fn.md +142 -0
  10. package/docs/api-reference/index.md +38 -0
  11. package/docs/api-reference/workflow.md +126 -0
  12. package/docs/guide/advanced-guides/cancellation.md +117 -0
  13. package/docs/guide/advanced-guides/composition.md +68 -0
  14. package/docs/guide/advanced-guides/custom-executor.md +180 -0
  15. package/docs/guide/advanced-guides/error-handling.md +135 -0
  16. package/docs/guide/advanced-guides/logging.md +106 -0
  17. package/docs/guide/advanced-guides/middleware.md +106 -0
  18. package/docs/guide/advanced-guides/observability.md +175 -0
  19. package/docs/guide/best-practices/debugging.md +182 -0
  20. package/docs/guide/best-practices/state-management.md +120 -0
  21. package/docs/guide/best-practices/sub-workflow-data.md +95 -0
  22. package/docs/guide/best-practices/testing.md +187 -0
  23. package/docs/guide/builders.md +157 -0
  24. package/docs/guide/functional-api.md +133 -0
  25. package/docs/guide/index.md +178 -0
  26. package/docs/guide/recipes/creating-a-loop.md +113 -0
  27. package/docs/guide/recipes/data-processing-pipeline.md +123 -0
  28. package/docs/guide/recipes/fan-out-fan-in.md +112 -0
  29. package/docs/guide/recipes/index.md +15 -0
  30. package/docs/guide/recipes/resilient-api-call.md +110 -0
  31. package/docs/guide/tooling/graph-validation.md +160 -0
  32. package/docs/guide/tooling/mermaid.md +156 -0
  33. package/docs/index.md +56 -0
  34. package/eslint.config.js +16 -0
  35. package/package.json +40 -0
  36. package/pnpm-workspace.yaml +2 -0
  37. package/sandbox/1.basic/README.md +45 -0
  38. package/sandbox/1.basic/package.json +16 -0
  39. package/sandbox/1.basic/src/flow.ts +17 -0
  40. package/sandbox/1.basic/src/main.ts +22 -0
  41. package/sandbox/1.basic/src/nodes.ts +112 -0
  42. package/sandbox/1.basic/src/utils.ts +35 -0
  43. package/sandbox/1.basic/tsconfig.json +3 -0
  44. package/sandbox/2.research/README.md +46 -0
  45. package/sandbox/2.research/package.json +16 -0
  46. package/sandbox/2.research/src/flow.ts +14 -0
  47. package/sandbox/2.research/src/main.ts +31 -0
  48. package/sandbox/2.research/src/nodes.ts +108 -0
  49. package/sandbox/2.research/src/utils.ts +45 -0
  50. package/sandbox/2.research/src/visualize.ts +29 -0
  51. package/sandbox/2.research/tsconfig.json +3 -0
  52. package/sandbox/3.parallel/README.md +65 -0
  53. package/sandbox/3.parallel/package.json +16 -0
  54. package/sandbox/3.parallel/src/main.ts +45 -0
  55. package/sandbox/3.parallel/src/nodes.ts +43 -0
  56. package/sandbox/3.parallel/src/utils.ts +25 -0
  57. package/sandbox/3.parallel/tsconfig.json +3 -0
  58. package/sandbox/4.dag/README.md +179 -0
  59. package/sandbox/4.dag/data/1.blog-post/100.json +60 -0
  60. package/sandbox/4.dag/data/1.blog-post/README.md +25 -0
  61. package/sandbox/4.dag/data/2.job-application/200.json +103 -0
  62. package/sandbox/4.dag/data/2.job-application/201.json +31 -0
  63. package/sandbox/4.dag/data/2.job-application/202.json +31 -0
  64. package/sandbox/4.dag/data/2.job-application/README.md +58 -0
  65. package/sandbox/4.dag/data/3.customer-review/300.json +141 -0
  66. package/sandbox/4.dag/data/3.customer-review/301.json +31 -0
  67. package/sandbox/4.dag/data/3.customer-review/302.json +28 -0
  68. package/sandbox/4.dag/data/3.customer-review/README.md +71 -0
  69. package/sandbox/4.dag/data/4.content-moderation/400.json +161 -0
  70. package/sandbox/4.dag/data/4.content-moderation/401.json +47 -0
  71. package/sandbox/4.dag/data/4.content-moderation/402.json +46 -0
  72. package/sandbox/4.dag/data/4.content-moderation/403.json +31 -0
  73. package/sandbox/4.dag/data/4.content-moderation/README.md +83 -0
  74. package/sandbox/4.dag/package.json +19 -0
  75. package/sandbox/4.dag/src/main.ts +73 -0
  76. package/sandbox/4.dag/src/nodes.ts +134 -0
  77. package/sandbox/4.dag/src/registry.ts +87 -0
  78. package/sandbox/4.dag/src/types.ts +25 -0
  79. package/sandbox/4.dag/src/utils.ts +42 -0
  80. package/sandbox/4.dag/tsconfig.json +3 -0
  81. package/sandbox/5.distributed/.env.example +1 -0
  82. package/sandbox/5.distributed/README.md +88 -0
  83. package/sandbox/5.distributed/data/1.blog-post/100.json +59 -0
  84. package/sandbox/5.distributed/data/1.blog-post/README.md +25 -0
  85. package/sandbox/5.distributed/data/2.job-application/200.json +103 -0
  86. package/sandbox/5.distributed/data/2.job-application/201.json +30 -0
  87. package/sandbox/5.distributed/data/2.job-application/202.json +30 -0
  88. package/sandbox/5.distributed/data/2.job-application/README.md +58 -0
  89. package/sandbox/5.distributed/data/3.customer-review/300.json +141 -0
  90. package/sandbox/5.distributed/data/3.customer-review/301.json +31 -0
  91. package/sandbox/5.distributed/data/3.customer-review/302.json +57 -0
  92. package/sandbox/5.distributed/data/3.customer-review/README.md +71 -0
  93. package/sandbox/5.distributed/data/4.content-moderation/400.json +173 -0
  94. package/sandbox/5.distributed/data/4.content-moderation/401.json +47 -0
  95. package/sandbox/5.distributed/data/4.content-moderation/402.json +46 -0
  96. package/sandbox/5.distributed/data/4.content-moderation/403.json +31 -0
  97. package/sandbox/5.distributed/data/4.content-moderation/README.md +83 -0
  98. package/sandbox/5.distributed/package.json +20 -0
  99. package/sandbox/5.distributed/src/client.ts +124 -0
  100. package/sandbox/5.distributed/src/executor.ts +69 -0
  101. package/sandbox/5.distributed/src/nodes.ts +136 -0
  102. package/sandbox/5.distributed/src/registry.ts +101 -0
  103. package/sandbox/5.distributed/src/types.ts +45 -0
  104. package/sandbox/5.distributed/src/utils.ts +69 -0
  105. package/sandbox/5.distributed/src/worker.ts +217 -0
  106. package/sandbox/5.distributed/tsconfig.json +3 -0
  107. package/sandbox/6.rag/.env.example +1 -0
  108. package/sandbox/6.rag/README.md +60 -0
  109. package/sandbox/6.rag/data/README.md +31 -0
  110. package/sandbox/6.rag/data/rag.json +58 -0
  111. package/sandbox/6.rag/documents/sample-cascade.txt +11 -0
  112. package/sandbox/6.rag/package.json +18 -0
  113. package/sandbox/6.rag/src/main.ts +52 -0
  114. package/sandbox/6.rag/src/nodes/GenerateEmbeddingsNode.ts +54 -0
  115. package/sandbox/6.rag/src/nodes/LLMProcessNode.ts +48 -0
  116. package/sandbox/6.rag/src/nodes/LoadAndChunkNode.ts +40 -0
  117. package/sandbox/6.rag/src/nodes/StoreInVectorDBNode.ts +36 -0
  118. package/sandbox/6.rag/src/nodes/VectorSearchNode.ts +53 -0
  119. package/sandbox/6.rag/src/nodes/index.ts +28 -0
  120. package/sandbox/6.rag/src/registry.ts +23 -0
  121. package/sandbox/6.rag/src/types.ts +44 -0
  122. package/sandbox/6.rag/src/utils.ts +77 -0
  123. package/sandbox/6.rag/tsconfig.json +3 -0
  124. package/sandbox/tsconfig.json +13 -0
  125. package/src/builder/collection.test.ts +287 -0
  126. package/src/builder/collection.ts +269 -0
  127. package/src/builder/graph.test.ts +406 -0
  128. package/src/builder/graph.ts +336 -0
  129. package/src/builder/graph.types.ts +104 -0
  130. package/src/builder/index.ts +3 -0
  131. package/src/context.ts +111 -0
  132. package/src/errors.ts +34 -0
  133. package/src/executor.ts +29 -0
  134. package/src/executors/in-memory.test.ts +93 -0
  135. package/src/executors/in-memory.ts +140 -0
  136. package/src/functions.test.ts +191 -0
  137. package/src/functions.ts +117 -0
  138. package/src/index.ts +5 -0
  139. package/src/logger.ts +41 -0
  140. package/src/types.ts +75 -0
  141. package/src/utils/graph.test.ts +144 -0
  142. package/src/utils/graph.ts +182 -0
  143. package/src/utils/index.ts +3 -0
  144. package/src/utils/mermaid.test.ts +239 -0
  145. package/src/utils/mermaid.ts +133 -0
  146. package/src/utils/sleep.ts +20 -0
  147. package/src/workflow.test.ts +622 -0
  148. package/src/workflow.ts +561 -0
@@ -0,0 +1,93 @@
1
+ import type { MiddlewareNext, NodeArgs } from '../workflow'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { contextKey, Flow, Node, TypedContext } from '../workflow'
4
+ import { InMemoryExecutor } from './in-memory'
5
+
6
+ const VALUE = contextKey<number>('value')
7
+ const PATH = contextKey<string[]>('path')
8
+ const MIDDLEWARE_PATH = contextKey<string[]>('middleware_path')
9
+
10
+ // Helper Nodes for testing
11
+ class AddNode extends Node {
12
+ constructor(private num: number, public id: string) { super() }
13
+ async exec({ ctx }: any) {
14
+ const current = ctx.get(VALUE) ?? 0
15
+ ctx.set(VALUE, current + this.num)
16
+ const path = ctx.get(PATH) ?? []
17
+ ctx.set(PATH, [...path, this.id])
18
+ }
19
+ }
20
+
21
+ class BranchNode extends Node {
22
+ async post({ ctx }: any) {
23
+ const path = ctx.get(PATH) ?? []
24
+ ctx.set(PATH, [...path, 'branch'])
25
+ return ctx.get(VALUE) > 10 ? 'over' : 'under'
26
+ }
27
+ }
28
+
29
+ describe('testInMemoryExecutor', () => {
30
+ it('should execute a simple linear flow', async () => {
31
+ const ctx = new TypedContext([[VALUE, 1]])
32
+ const startNode = new AddNode(5, 'start')
33
+ startNode.next(new AddNode(3, 'next'))
34
+ const flow = new Flow(startNode)
35
+ const executor = new InMemoryExecutor()
36
+
37
+ await executor.run(flow, ctx)
38
+
39
+ expect(ctx.get(VALUE)).toBe(9)
40
+ expect(ctx.get(PATH)).toEqual(['start', 'next'])
41
+ })
42
+
43
+ it('should handle conditional branching', async () => {
44
+ const ctx = new TypedContext([[VALUE, 5]])
45
+ const start = new AddNode(6, 'start') // value becomes 11
46
+ const branch = new BranchNode()
47
+ const overNode = new AddNode(100, 'over_node')
48
+ const underNode = new AddNode(-1, 'under_node')
49
+ start.next(branch)
50
+ branch.next(overNode, 'over')
51
+ branch.next(underNode, 'under')
52
+
53
+ const flow = new Flow(start)
54
+ const executor = new InMemoryExecutor()
55
+
56
+ await executor.run(flow, ctx)
57
+
58
+ expect(ctx.get(VALUE)).toBe(111) // 5 + 6 + 100
59
+ expect(ctx.get(PATH)).toEqual(['start', 'branch', 'over_node'])
60
+ })
61
+
62
+ it('should correctly execute a composed flow (sub-flow)', async () => {
63
+ const ctx = new TypedContext([[VALUE, 1]])
64
+ const innerStart = new AddNode(5, 'inner_start') // 1 + 5 = 6
65
+ innerStart.next(new AddNode(10, 'inner_end')) // 6 + 10 = 16
66
+ const innerFlow = new Flow(innerStart)
67
+ const outerStart = new AddNode(100, 'outer_start') // 16 + 100 = 116
68
+ const outerFlow = new Flow(innerFlow)
69
+ innerFlow.next(outerStart)
70
+ const executor = new InMemoryExecutor()
71
+ await executor.run(outerFlow, ctx)
72
+ expect(ctx.get(VALUE)).toBe(116)
73
+ expect(ctx.get(PATH)).toEqual(['inner_start', 'inner_end', 'outer_start'])
74
+ })
75
+
76
+ it('should apply middleware from the orchestrating flow to its nodes', async () => {
77
+ const ctx = new TypedContext()
78
+ const flow = new Flow(new AddNode(10, 'node1'))
79
+ const testMiddleware = async (args: NodeArgs, next: MiddlewareNext) => {
80
+ const path = args.ctx.get(MIDDLEWARE_PATH) ?? []
81
+ args.ctx.set(MIDDLEWARE_PATH, [...path, `enter_${args.name}`])
82
+ const result = await next(args)
83
+ const finalPath = args.ctx.get(MIDDLEWARE_PATH) ?? []
84
+ args.ctx.set(MIDDLEWARE_PATH, [...finalPath, `exit_${args.name}`])
85
+ return result
86
+ }
87
+ flow.use(testMiddleware)
88
+ const executor = new InMemoryExecutor()
89
+ await executor.run(flow, ctx)
90
+ expect(ctx.get(VALUE)).toBe(10)
91
+ expect(ctx.get(MIDDLEWARE_PATH)).toEqual(['enter_AddNode', 'exit_AddNode'])
92
+ })
93
+ })
@@ -0,0 +1,140 @@
1
+ import type { IExecutor, InternalRunOptions } from '../executor'
2
+ import type { AbstractNode, Context, Flow, Logger, Middleware, MiddlewareNext, NodeArgs, RunOptions } from '../workflow'
3
+ import { AbortError, NullLogger } from '../workflow'
4
+
5
+ /**
6
+ * The default executor that runs a workflow within a single, in-memory process.
7
+ * This class contains the core logic for traversing a workflow graph, applying middleware,
8
+ * and handling node execution.
9
+ */
10
+ export class InMemoryExecutor implements IExecutor {
11
+ /**
12
+ * A stateless, reusable method that orchestrates the traversal of a graph.
13
+ * It is called by `run()` for top-level flows and by `Flow.exec()` for sub-flows.
14
+ * @param startNode The node where the graph traversal begins.
15
+ * @param flowMiddleware The middleware array from the containing flow.
16
+ * @param context The shared workflow context.
17
+ * @param options The internal, normalized run options.
18
+ * @returns The final action from the last executed node in the graph.
19
+ * @internal
20
+ */
21
+ public async _orch(
22
+ startNode: AbstractNode,
23
+ flowMiddleware: Middleware[],
24
+ context: Context,
25
+ options: InternalRunOptions,
26
+ ): Promise<any> {
27
+ let currentNode: AbstractNode | undefined = startNode
28
+ let lastAction: any
29
+
30
+ const { logger, signal, params, executor } = options
31
+
32
+ while (currentNode) {
33
+ if (signal?.aborted)
34
+ throw new AbortError()
35
+
36
+ const chain = this.applyMiddleware(flowMiddleware, currentNode)
37
+
38
+ const nodeArgs: NodeArgs = {
39
+ ctx: context,
40
+ params,
41
+ signal,
42
+ logger,
43
+ prepRes: undefined,
44
+ execRes: undefined,
45
+ name: currentNode.constructor.name,
46
+ executor,
47
+ }
48
+
49
+ lastAction = await chain(nodeArgs)
50
+
51
+ const previousNode = currentNode
52
+ currentNode = this.getNextNode(previousNode, lastAction, logger)
53
+ }
54
+
55
+ return lastAction
56
+ }
57
+
58
+ /**
59
+ * Executes a given flow with a specific context and options.
60
+ * This is the main entry point for the in-memory execution engine.
61
+ * @param flow The Flow instance to execute.
62
+ * @param context The shared context for the workflow.
63
+ * @param options Runtime options, including a logger, abort controller, or initial params.
64
+ * @returns A promise that resolves with the final action of the workflow.
65
+ */
66
+ public async run(flow: Flow, context: Context, options?: RunOptions): Promise<any> {
67
+ const logger = options?.logger ?? new NullLogger()
68
+ const controller = options?.controller
69
+ const combinedParams = { ...flow.params, ...options?.params }
70
+
71
+ const internalOptions: InternalRunOptions = {
72
+ logger,
73
+ signal: controller?.signal,
74
+ params: combinedParams,
75
+ executor: this,
76
+ }
77
+
78
+ // Handle "logic-bearing" flows (e.g., BatchFlow) that don't have a graph.
79
+ // Their logic is self-contained in their `exec` method.
80
+ if (!flow.startNode) {
81
+ logger.info(`Executor is running a logic-bearing flow: ${flow.constructor.name}`)
82
+ const chain = this.applyMiddleware(flow.middleware, flow)
83
+ return await chain({
84
+ ...internalOptions,
85
+ ctx: context,
86
+ prepRes: undefined,
87
+ execRes: undefined,
88
+ name: flow.constructor.name,
89
+ })
90
+ }
91
+
92
+ logger.info(`Executor is running flow graph: ${flow.constructor.name}`)
93
+ // Delegate the graph traversal to our new stateless helper.
94
+ // Pass the flow's own middleware to be applied to its nodes.
95
+ return this._orch(flow.startNode, flow.middleware, context, internalOptions)
96
+ }
97
+
98
+ /**
99
+ * Determines the next node to execute based on the action returned by the current node.
100
+ * @internal
101
+ */
102
+ public getNextNode(curr: AbstractNode, action: any, logger: Logger): AbstractNode | undefined {
103
+ const nextNode = curr.successors.get(action)
104
+ const actionDisplay = typeof action === 'symbol' ? action.toString() : action
105
+
106
+ if (nextNode) {
107
+ logger.debug(`Action '${actionDisplay}' from ${curr.constructor.name} leads to ${nextNode.constructor.name}`, { action })
108
+ }
109
+ else if (curr.successors.size > 0 && action !== undefined && action !== null) {
110
+ logger.info(`Flow ends: Action '${actionDisplay}' from ${curr.constructor.name} has no configured successor.`)
111
+ }
112
+ return nextNode
113
+ }
114
+
115
+ /**
116
+ * Composes a chain of middleware functions around a node's execution.
117
+ * @internal
118
+ */
119
+ public applyMiddleware(middleware: Middleware[], nodeToRun: AbstractNode): MiddlewareNext {
120
+ // The final function in the chain is the actual execution of the node.
121
+ const runNode: MiddlewareNext = (args: NodeArgs) => {
122
+ return nodeToRun._run({
123
+ ctx: args.ctx,
124
+ params: { ...args.params, ...nodeToRun.params },
125
+ signal: args.signal,
126
+ logger: args.logger,
127
+ executor: args.executor,
128
+ })
129
+ }
130
+
131
+ if (!middleware || middleware.length === 0)
132
+ return runNode
133
+
134
+ // Build the chain backwards, so the first middleware in the array is the outermost.
135
+ return middleware.reduceRight<MiddlewareNext>(
136
+ (next, mw) => (args: NodeArgs) => mw(args, next),
137
+ runNode,
138
+ )
139
+ }
140
+ }
@@ -0,0 +1,191 @@
1
+ import type { Logger, RunOptions } from './workflow'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import {
4
+ compose,
5
+ contextNode,
6
+ mapNode,
7
+ pipeline,
8
+ transformNode,
9
+ } from './functions'
10
+ import { contextKey, DEFAULT_ACTION, lens, TypedContext } from './workflow'
11
+
12
+ // Mock logger for testing
13
+ function createMockLogger(): Logger {
14
+ return {
15
+ debug: vi.fn(),
16
+ info: vi.fn(),
17
+ warn: vi.fn(),
18
+ error: vi.fn(),
19
+ }
20
+ }
21
+
22
+ let mockLogger = createMockLogger()
23
+ let runOptions: RunOptions = { logger: mockLogger }
24
+
25
+ afterEach(() => {
26
+ mockLogger = createMockLogger()
27
+ runOptions = { logger: mockLogger }
28
+ })
29
+
30
+ // Context Keys for testing
31
+ const NAME = contextKey<string>('name')
32
+ const COUNTER = contextKey<number>('counter')
33
+ const RESULT = contextKey<any>('result')
34
+ const PREFIX = contextKey<string>('prefix')
35
+
36
+ describe('mapNode', () => {
37
+ it('should create a node from a synchronous function', async () => {
38
+ const ctx = new TypedContext()
39
+ const doubleNode = mapNode<{ value: number }, number>(params => params.value * 2)
40
+ .toContext(RESULT)
41
+ await doubleNode.withParams({ value: 10 }).run(ctx, runOptions)
42
+ expect(ctx.get(RESULT)).toBe(20)
43
+ })
44
+
45
+ it('should create a node from an asynchronous function', async () => {
46
+ const ctx = new TypedContext()
47
+ const upperNode = mapNode<{ value: string }, string>(async params => params.value.toUpperCase())
48
+ .toContext(RESULT)
49
+ await upperNode.withParams({ value: 'hello' }).run(ctx, runOptions)
50
+ expect(ctx.get(RESULT)).toBe('HELLO')
51
+ })
52
+
53
+ it('should handle nodes that produce no output', async () => {
54
+ const ctx = new TypedContext()
55
+ const sideEffectFn = vi.fn()
56
+ const tapNode = mapNode<any, void>(params => sideEffectFn(params.value))
57
+ await tapNode.withParams({ value: 123 }).run(ctx, runOptions)
58
+ expect(sideEffectFn).toHaveBeenCalledWith(123)
59
+ const action = await tapNode.withParams({ value: 456 }).run(ctx, runOptions)
60
+ expect(Array.from(ctx.entries())).toHaveLength(0)
61
+ expect(action).toBe(DEFAULT_ACTION)
62
+ })
63
+ })
64
+
65
+ describe('contextNode', () => {
66
+ it('should access context and params in a synchronous function', async () => {
67
+ const ctx = new TypedContext([[PREFIX, 'Hello']])
68
+ const greeterNode = contextNode<{ name: string }, string>((ctx, params) => {
69
+ const prefix = ctx.get(PREFIX) ?? 'Default'
70
+ return `${prefix}, ${params.name}!`
71
+ }).toContext(RESULT)
72
+ await greeterNode.withParams({ name: 'World' }).run(ctx, runOptions)
73
+ expect(ctx.get(RESULT)).toBe('Hello, World!')
74
+ })
75
+
76
+ it('should access context and params in an asynchronous function', async () => {
77
+ const ctx = new TypedContext([[PREFIX, 'Hola']])
78
+ const greeterNode = contextNode<{ name: string }, string>(async (ctx, params) => {
79
+ const prefix = ctx.get(PREFIX)
80
+ await new Promise(resolve => setTimeout(resolve, 1))
81
+ return `${prefix}, ${params.name}!`
82
+ }).toContext(RESULT)
83
+ await greeterNode.withParams({ name: 'Mundo' }).run(ctx, runOptions)
84
+ expect(ctx.get(RESULT)).toBe('Hola, Mundo!')
85
+ })
86
+ })
87
+
88
+ describe('transformNode', () => {
89
+ it('should apply a single context transform', async () => {
90
+ const ctx = new TypedContext()
91
+ const nameLens = lens(NAME)
92
+ const setNode = transformNode(nameLens.set('Alice'))
93
+ await setNode.run(ctx, runOptions)
94
+ expect(ctx.get(NAME)).toBe('Alice')
95
+ })
96
+
97
+ it('should apply multiple context transforms in order', async () => {
98
+ const ctx = new TypedContext([[COUNTER, 10]])
99
+ const nameLens = lens(NAME)
100
+ const counterLens = lens(COUNTER)
101
+ const setupNode = transformNode(
102
+ nameLens.set('Bob'),
103
+ counterLens.update(c => (c ?? 0) + 5),
104
+ )
105
+ await setupNode.run(ctx, runOptions)
106
+ expect(ctx.get(NAME)).toBe('Bob')
107
+ expect(ctx.get(COUNTER)).toBe(15)
108
+ })
109
+
110
+ it('should be chainable within a pipeline', async () => {
111
+ const ctx = new TypedContext([[COUNTER, 0]])
112
+ const nameLens = lens(NAME)
113
+ const counterLens = lens(COUNTER)
114
+ // A node that reads from context
115
+ const readNode = contextNode((ctx) => {
116
+ return `${ctx.get(NAME)} has ${ctx.get(COUNTER)} points`
117
+ }).toContext(RESULT)
118
+ const flow = pipeline(
119
+ transformNode(
120
+ nameLens.set('Carol'),
121
+ counterLens.set(100),
122
+ ),
123
+ readNode,
124
+ )
125
+ await flow.run(ctx, runOptions)
126
+ expect(ctx.get(RESULT)).toBe('Carol has 100 points')
127
+ })
128
+ })
129
+
130
+ describe('pipeline', () => {
131
+ it('should create and run a linear sequence of nodes', async () => {
132
+ const ctx = new TypedContext([[COUNTER, 5]])
133
+ const add10 = mapNode(() => 10).toContext(COUNTER)
134
+ const multiplyBy3 = contextNode(ctx => (ctx.get(COUNTER) ?? 0) * 3).toContext(COUNTER)
135
+ const flow = pipeline(add10, multiplyBy3)
136
+ await flow.run(ctx, runOptions)
137
+ expect(ctx.get(COUNTER)).toBe(30)
138
+ })
139
+
140
+ it('should run successfully with an empty sequence', async () => {
141
+ const ctx = new TypedContext()
142
+ const emptyFlow = pipeline()
143
+ const action = await emptyFlow.run(ctx, runOptions)
144
+ expect(Array.from(ctx.entries())).toHaveLength(0)
145
+ expect(action).toBe(DEFAULT_ACTION)
146
+ })
147
+ })
148
+
149
+ describe('compose', () => {
150
+ it('should compose two synchronous functions', async () => {
151
+ const add5 = (x: number) => x + 5
152
+ const multiply2 = (x: number) => x * 2
153
+ // multiply2(add5(x)) => (x + 5) * 2
154
+ const addThenMultiply = compose(multiply2, add5)
155
+ expect(await addThenMultiply(10)).toBe(30)
156
+ })
157
+
158
+ it('should compose two asynchronous functions', async () => {
159
+ const add5Async = async (x: number) => {
160
+ await new Promise(resolve => setTimeout(resolve, 1))
161
+ return x + 5
162
+ }
163
+ const multiply2Async = async (x: number) => {
164
+ await new Promise(resolve => setTimeout(resolve, 1))
165
+ return x * 2
166
+ }
167
+ const composed = compose(multiply2Async, add5Async)
168
+ expect(await composed(10)).toBe(30)
169
+ })
170
+
171
+ it('should compose mixed sync and async functions', async () => {
172
+ const add5 = (x: number) => x + 5
173
+ const multiply2Async = async (x: number) => x * 2
174
+ const composed1 = compose(multiply2Async, add5) // async(sync(x))
175
+ const composed2 = compose(add5, multiply2Async) // sync(async(x))
176
+ expect(await composed1(10)).toBe(30)
177
+ expect(await composed2(10)).toBe(25)
178
+ })
179
+
180
+ it('should be usable inside a mapNode', async () => {
181
+ const ctx = new TypedContext()
182
+ const add5 = (p: { val: number }) => ({ ...p, val: p.val + 5 })
183
+ const multiply2 = (p: { val: number }) => ({ ...p, val: p.val * 2 })
184
+ const composedFn = compose(multiply2, add5)
185
+ const processNode = mapNode(composedFn)
186
+ .map(res => res.val)
187
+ .toContext(RESULT)
188
+ await processNode.withParams({ val: 10 }).run(ctx, runOptions)
189
+ expect(ctx.get(RESULT)).toBe(30) // (10 + 5) * 2
190
+ })
191
+ })
@@ -0,0 +1,117 @@
1
+ import type { ContextTransform } from './context'
2
+ import type { Context, Flow, Node, NodeArgs } from './workflow'
3
+ import { SequenceFlow } from './builder/collection'
4
+ import { composeContext } from './context'
5
+ import { Node as BaseNode } from './workflow'
6
+
7
+ /**
8
+ * A type for a pure function that can be executed within a `Node`,
9
+ * typically taking the node's `params` as input.
10
+ * @template TIn The input type, corresponding to `params`.
11
+ * @template TOut The output type, which becomes the node's `execRes`.
12
+ */
13
+ export type NodeFunction<TIn = any, TOut = any> = (input: TIn) => TOut | Promise<TOut>
14
+
15
+ /**
16
+ * A type for a function that operates on the shared `Context` in addition
17
+ * to the node's `params`.
18
+ * @template TIn The input type, corresponding to `params`.
19
+ * @template TOut The output type, which becomes the node's `execRes`.
20
+ */
21
+ export type ContextFunction<TIn = any, TOut = any> = (ctx: Context, input: TIn) => TOut | Promise<TOut>
22
+
23
+ /**
24
+ * Creates a `Node` from a simple, pure function that transforms an input to an output.
25
+ * The node's `params` object is passed as the input to the function.
26
+ *
27
+ * @example
28
+ * const add = (n: number) => mapNode<{ value: number }, number>(params => params.value + n)
29
+ * const add5Node = add(5) // A reusable node that adds 5 to its input parameter.
30
+ *
31
+ * @param fn A function that takes an input object and returns a result.
32
+ * @returns A new `Node` instance that wraps the function.
33
+ */
34
+ export function mapNode<TIn, TOut>(fn: NodeFunction<TIn, TOut>): Node<TIn, TOut> {
35
+ return new class extends BaseNode<TIn, TOut> {
36
+ async exec({ params }: NodeArgs<TIn>): Promise<TOut> {
37
+ return fn(params as TIn)
38
+ }
39
+ }()
40
+ }
41
+
42
+ /**
43
+ * Creates a `Node` from a function that requires access to the shared `Context`.
44
+ * Both the `Context` and the node's `params` are passed as arguments to the function.
45
+ *
46
+ * @example
47
+ * const greeter = contextNode((ctx, params: { name: string }) => {
48
+ * const language = ctx.get(LANGUAGE_KEY) || 'en'
49
+ * return language === 'en' ? `Hello, ${params.name}` : `Hola, ${params.name}`
50
+ * })
51
+ *
52
+ * @param fn A function that takes the context and an input object, and returns a result.
53
+ * @returns A new `Node` instance that wraps the function.
54
+ */
55
+ export function contextNode<TIn, TOut>(fn: ContextFunction<TIn, TOut>): Node<TIn, TOut> {
56
+ return new class extends BaseNode<TIn, TOut> {
57
+ async exec({ ctx, params }: NodeArgs<TIn>): Promise<TOut> {
58
+ return fn(ctx, params as TIn)
59
+ }
60
+ }()
61
+ }
62
+
63
+ /**
64
+ * Creates a `Node` that declaratively applies a series of transformations to the `Context`.
65
+ * This is a "side-effect" node used purely for state management; its logic runs in the `prep` phase,
66
+ * and it does not produce an `exec` output.
67
+ *
68
+ * @example
69
+ * const USER_ID = contextKey<string>('user_id')
70
+ * const userLens = lens(USER_ID)
71
+ * const setupUserContext = (userId: string) => transformNode(userLens.set(userId))
72
+ *
73
+ * @param transforms A sequence of `ContextTransform` functions (e.g., from a lens) to apply.
74
+ * @returns A new `Node` instance that will mutate the context when executed.
75
+ */
76
+ export function transformNode(...transforms: ContextTransform[]): Node {
77
+ return new class extends BaseNode {
78
+ async prep({ ctx }: NodeArgs) {
79
+ // Apply the composed transformations directly to the mutable context.
80
+ composeContext(...transforms)(ctx)
81
+ }
82
+ }()
83
+ }
84
+
85
+ /**
86
+ * A functional-style alias for `SequenceFlow`. It constructs a linear workflow
87
+ * where each node executes in the order it is provided.
88
+ *
89
+ * @example
90
+ * const mathPipeline = pipeline(addNode(5), multiplyNode(2))
91
+ *
92
+ * @param nodes A sequence of `Node` instances to chain together.
93
+ * @returns A `Flow` instance representing the linear sequence.
94
+ */
95
+ export function pipeline(...nodes: Node[]): Flow {
96
+ return new SequenceFlow(...nodes)
97
+ }
98
+
99
+ /**
100
+ * A classic functional composition utility. It takes two functions, `f` and `g`,
101
+ * and returns a new function that computes `f(g(x))`.
102
+ *
103
+ * This is a general-purpose helper, not a `Node` builder itself, but it can be
104
+ * used to create more complex `NodeFunction`s to pass to `mapNode`.
105
+ *
106
+ * @example
107
+ * const add5 = (x: number) => x + 5
108
+ * const multiply2 = (x: number) => x * 2
109
+ * const add5ThenMultiply2 = compose(multiply2, add5) // equivalent to: x => (x + 5) * 2
110
+ *
111
+ * @param f The outer function, which receives the result of `g`.
112
+ * @param g The inner function, which receives the initial input.
113
+ * @returns A new `NodeFunction` that combines both operations.
114
+ */
115
+ export function compose<A, B, C>(f: NodeFunction<B, C>, g: NodeFunction<A, B>): NodeFunction<A, C> {
116
+ return async (input: A) => f(await g(input))
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './builder/index'
2
+ export * from './executors/in-memory'
3
+ export * from './functions'
4
+ export * from './utils/index'
5
+ export * from './workflow'
package/src/logger.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Defines the interface for a logger that can be used by the workflow engine.
3
+ * This allows for plugging in any logging library (e.g., Pino, Winston).
4
+ */
5
+ export interface Logger {
6
+ debug: (message: string, context?: object) => void
7
+ info: (message: string, context?: object) => void
8
+ warn: (message: string, context?: object) => void
9
+ error: (message: string, context?: object) => void
10
+ }
11
+
12
+ /**
13
+ * A logger implementation that performs no action (a "no-op" logger).
14
+ * This is the default logger used by the framework if none is provided,
15
+ * making Flowcraft silent out-of-the-box.
16
+ */
17
+ export class NullLogger implements Logger {
18
+ debug() { /* no-op */ }
19
+ info() { /* no-op */ }
20
+ warn() { /* no-op */ }
21
+ error() { /* no-op */ }
22
+ }
23
+
24
+ /**
25
+ * A default logger implementation that writes messages to the `console`.
26
+ * Useful for development and debugging.
27
+ */
28
+ export class ConsoleLogger implements Logger {
29
+ private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: object) {
30
+ const fullMessage = `[${level.toUpperCase()}] ${message}`
31
+ if (context && Object.keys(context).length > 0)
32
+ console[level](fullMessage, context)
33
+ else
34
+ console[level](fullMessage)
35
+ }
36
+
37
+ debug(message: string, context?: object) { this.log('debug', message, context) }
38
+ info(message: string, context?: object) { this.log('info', message, context) }
39
+ warn(message: string, context?: object) { this.log('warn', message, context) }
40
+ error(message: string, context?: object) { this.log('error', message, context) }
41
+ }
package/src/types.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { Context } from './context'
2
+ import type { IExecutor } from './executor'
3
+ import type { Logger } from './logger'
4
+
5
+ /** A generic type for key-value parameters. */
6
+ export type Params = Record<string, any>
7
+
8
+ /** The default action returned by a node for linear progression. */
9
+ export const DEFAULT_ACTION = Symbol('default')
10
+
11
+ /** The action returned by a `.filter()` node when the predicate fails. */
12
+ export const FILTER_FAILED = Symbol('filter_failed')
13
+
14
+ /**
15
+ * The standard arguments object passed to a node's lifecycle methods.
16
+ * @template PrepRes The type of the `prepRes` property.
17
+ * @template ExecRes The type of the `execRes` property.
18
+ */
19
+ export interface NodeArgs<PrepRes = any, ExecRes = any> {
20
+ /** The shared, mutable context for the workflow run. */
21
+ ctx: Context
22
+ /** The static parameters for the node, merged from the node and flow's `withParams`. */
23
+ params: Params
24
+ /** An `AbortSignal` for handling cancellation. */
25
+ signal?: AbortSignal
26
+ /** The logger instance for the workflow run. */
27
+ logger: Logger
28
+ /** The result of the `prep` phase. */
29
+ prepRes: PrepRes
30
+ /** The result of the `exec` phase. */
31
+ execRes: ExecRes
32
+ /** The final error object, available only in `execFallback`. */
33
+ error?: Error
34
+ /** The name of the Node's constructor, for logging. */
35
+ name?: string
36
+ /** A reference to the current `IExecutor` running the flow. */
37
+ executor?: IExecutor
38
+ }
39
+
40
+ /**
41
+ * The context object passed to a node's internal `_run` method.
42
+ * @internal
43
+ */
44
+ export interface NodeRunContext {
45
+ ctx: Context
46
+ params: Params
47
+ signal?: AbortSignal
48
+ logger: Logger
49
+ executor?: IExecutor
50
+ }
51
+
52
+ /** Options for configuring a `Node` instance. */
53
+ export interface NodeOptions {
54
+ /** The total number of times the `exec` phase will be attempted. Defaults to `1`. */
55
+ maxRetries?: number
56
+ /** The time in milliseconds to wait between failed `exec` attempts. Defaults to `0`. */
57
+ wait?: number
58
+ }
59
+
60
+ /** Options for running a top-level `Flow`. */
61
+ export interface RunOptions {
62
+ /** An `AbortController` to gracefully cancel the workflow. */
63
+ controller?: AbortController
64
+ /** A `Logger` instance to receive logs from the execution engine. */
65
+ logger?: Logger
66
+ /** Top-level parameters to be merged into the context for the entire run. */
67
+ params?: Params
68
+ /** A custom `IExecutor` instance to run the workflow. Defaults to `InMemoryExecutor`. */
69
+ executor?: IExecutor
70
+ }
71
+
72
+ /** The function signature for the `next` function passed to middleware. */
73
+ export type MiddlewareNext = (args: NodeArgs) => Promise<any>
74
+ /** The function signature for a middleware function. */
75
+ export type Middleware = (args: NodeArgs, next: MiddlewareNext) => Promise<any>