ai-workflows 2.1.1 → 2.3.0

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 (211) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +17 -1
  3. package/README.md +305 -184
  4. package/dist/barrier.d.ts +159 -0
  5. package/dist/barrier.d.ts.map +1 -0
  6. package/dist/barrier.js +377 -0
  7. package/dist/barrier.js.map +1 -0
  8. package/dist/cascade-context.d.ts +149 -0
  9. package/dist/cascade-context.d.ts.map +1 -0
  10. package/dist/cascade-context.js +324 -0
  11. package/dist/cascade-context.js.map +1 -0
  12. package/dist/cascade-executor.d.ts +196 -0
  13. package/dist/cascade-executor.d.ts.map +1 -0
  14. package/dist/cascade-executor.js +384 -0
  15. package/dist/cascade-executor.js.map +1 -0
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +27 -8
  18. package/dist/context.js.map +1 -1
  19. package/dist/cron-parser.d.ts +65 -0
  20. package/dist/cron-parser.d.ts.map +1 -0
  21. package/dist/cron-parser.js +294 -0
  22. package/dist/cron-parser.js.map +1 -0
  23. package/dist/cron-scheduler.d.ts +117 -0
  24. package/dist/cron-scheduler.d.ts.map +1 -0
  25. package/dist/cron-scheduler.js +176 -0
  26. package/dist/cron-scheduler.js.map +1 -0
  27. package/dist/database-context.d.ts +184 -0
  28. package/dist/database-context.d.ts.map +1 -0
  29. package/dist/database-context.js +428 -0
  30. package/dist/database-context.js.map +1 -0
  31. package/dist/dependency-graph.d.ts +157 -0
  32. package/dist/dependency-graph.d.ts.map +1 -0
  33. package/dist/dependency-graph.js +382 -0
  34. package/dist/dependency-graph.js.map +1 -0
  35. package/dist/digital-objects-adapter.d.ts +159 -0
  36. package/dist/digital-objects-adapter.d.ts.map +1 -0
  37. package/dist/digital-objects-adapter.js +229 -0
  38. package/dist/digital-objects-adapter.js.map +1 -0
  39. package/dist/durable-execution-cloudflare.d.ts +427 -0
  40. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  41. package/dist/durable-execution-cloudflare.js +510 -0
  42. package/dist/durable-execution-cloudflare.js.map +1 -0
  43. package/dist/durable-execution.d.ts +482 -0
  44. package/dist/durable-execution.d.ts.map +1 -0
  45. package/dist/durable-execution.js +594 -0
  46. package/dist/durable-execution.js.map +1 -0
  47. package/dist/durable-workflow.d.ts +176 -0
  48. package/dist/durable-workflow.d.ts.map +1 -0
  49. package/dist/durable-workflow.js +552 -0
  50. package/dist/durable-workflow.js.map +1 -0
  51. package/dist/every.d.ts +31 -2
  52. package/dist/every.d.ts.map +1 -1
  53. package/dist/every.js +63 -32
  54. package/dist/every.js.map +1 -1
  55. package/dist/graph/index.d.ts +8 -0
  56. package/dist/graph/index.d.ts.map +1 -0
  57. package/dist/graph/index.js +8 -0
  58. package/dist/graph/index.js.map +1 -0
  59. package/dist/graph/topological-sort.d.ts +121 -0
  60. package/dist/graph/topological-sort.d.ts.map +1 -0
  61. package/dist/graph/topological-sort.js +292 -0
  62. package/dist/graph/topological-sort.js.map +1 -0
  63. package/dist/index.d.ts +10 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +25 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/logger.d.ts +101 -0
  68. package/dist/logger.d.ts.map +1 -0
  69. package/dist/logger.js +115 -0
  70. package/dist/logger.js.map +1 -0
  71. package/dist/on.d.ts +35 -10
  72. package/dist/on.d.ts.map +1 -1
  73. package/dist/on.js +53 -19
  74. package/dist/on.js.map +1 -1
  75. package/dist/runtime.d.ts +169 -0
  76. package/dist/runtime.d.ts.map +1 -0
  77. package/dist/runtime.js +275 -0
  78. package/dist/runtime.js.map +1 -0
  79. package/dist/send.d.ts.map +1 -1
  80. package/dist/send.js +4 -3
  81. package/dist/send.js.map +1 -1
  82. package/dist/telemetry.d.ts +150 -0
  83. package/dist/telemetry.d.ts.map +1 -0
  84. package/dist/telemetry.js +388 -0
  85. package/dist/telemetry.js.map +1 -0
  86. package/dist/timer-registry.d.ts +77 -0
  87. package/dist/timer-registry.d.ts.map +1 -0
  88. package/dist/timer-registry.js +154 -0
  89. package/dist/timer-registry.js.map +1 -0
  90. package/dist/types.d.ts +105 -6
  91. package/dist/types.d.ts.map +1 -1
  92. package/dist/types.js +17 -1
  93. package/dist/types.js.map +1 -1
  94. package/dist/worker/durable-step.d.ts +481 -0
  95. package/dist/worker/durable-step.d.ts.map +1 -0
  96. package/dist/worker/durable-step.js +606 -0
  97. package/dist/worker/durable-step.js.map +1 -0
  98. package/dist/worker/index.d.ts +106 -0
  99. package/dist/worker/index.d.ts.map +1 -0
  100. package/dist/worker/index.js +124 -0
  101. package/dist/worker/index.js.map +1 -0
  102. package/dist/worker/state-adapter.d.ts +230 -0
  103. package/dist/worker/state-adapter.d.ts.map +1 -0
  104. package/dist/worker/state-adapter.js +409 -0
  105. package/dist/worker/state-adapter.js.map +1 -0
  106. package/dist/worker/topological-executor.d.ts +282 -0
  107. package/dist/worker/topological-executor.d.ts.map +1 -0
  108. package/dist/worker/topological-executor.js +396 -0
  109. package/dist/worker/topological-executor.js.map +1 -0
  110. package/dist/worker/workflow-builder.d.ts +286 -0
  111. package/dist/worker/workflow-builder.d.ts.map +1 -0
  112. package/dist/worker/workflow-builder.js +565 -0
  113. package/dist/worker/workflow-builder.js.map +1 -0
  114. package/dist/worker.d.ts +800 -0
  115. package/dist/worker.d.ts.map +1 -0
  116. package/dist/worker.js +2428 -0
  117. package/dist/worker.js.map +1 -0
  118. package/dist/workflow-builder.d.ts +287 -0
  119. package/dist/workflow-builder.d.ts.map +1 -0
  120. package/dist/workflow-builder.js +762 -0
  121. package/dist/workflow-builder.js.map +1 -0
  122. package/dist/workflow.d.ts +14 -30
  123. package/dist/workflow.d.ts.map +1 -1
  124. package/dist/workflow.js +136 -292
  125. package/dist/workflow.js.map +1 -1
  126. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  127. package/examples/02-content-moderation-cascade.ts +454 -0
  128. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  129. package/examples/04-database-persistence.ts +518 -0
  130. package/examples/README.md +173 -0
  131. package/package.json +21 -4
  132. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  133. package/src/__tests__/durable-workflow.test.ts +297 -0
  134. package/src/barrier.ts +507 -0
  135. package/src/cascade-context.ts +495 -0
  136. package/src/cascade-executor.ts +588 -0
  137. package/src/context.ts +51 -17
  138. package/src/cron-parser.ts +347 -0
  139. package/src/cron-scheduler.ts +239 -0
  140. package/src/database-context.ts +658 -0
  141. package/src/dependency-graph.ts +518 -0
  142. package/src/digital-objects-adapter.ts +351 -0
  143. package/src/durable-execution-cloudflare.ts +855 -0
  144. package/src/durable-execution.ts +1042 -0
  145. package/src/durable-workflow.ts +717 -0
  146. package/src/every.ts +104 -35
  147. package/src/graph/index.ts +19 -0
  148. package/src/graph/topological-sort.ts +412 -0
  149. package/src/index.ts +147 -0
  150. package/src/logger.ts +148 -0
  151. package/src/on.ts +81 -26
  152. package/src/runtime.ts +436 -0
  153. package/src/send.ts +4 -5
  154. package/src/telemetry.ts +577 -0
  155. package/src/timer-registry.ts +179 -0
  156. package/src/types.ts +146 -10
  157. package/src/worker/durable-step.ts +976 -0
  158. package/src/worker/index.ts +216 -0
  159. package/src/worker/state-adapter.ts +589 -0
  160. package/src/worker/topological-executor.ts +625 -0
  161. package/src/worker/workflow-builder.ts +871 -0
  162. package/src/worker.ts +2906 -0
  163. package/src/workflow-builder.ts +1068 -0
  164. package/src/workflow.ts +199 -355
  165. package/test/barrier-join.test.ts +442 -0
  166. package/test/barrier-unhandled-rejections.test.ts +359 -0
  167. package/test/cascade-context.test.ts +390 -0
  168. package/test/cascade-executor.test.ts +852 -0
  169. package/test/cron-parser.test.ts +314 -0
  170. package/test/cron-scheduler.test.ts +291 -0
  171. package/test/database-context.test.ts +770 -0
  172. package/test/db-provider-adapter.test.ts +862 -0
  173. package/test/dependency-graph.test.ts +512 -0
  174. package/test/durable-execution-cloudflare.test.ts +606 -0
  175. package/test/durable-execution-in-process.test.ts +286 -0
  176. package/test/durable-execution.test.ts +247 -0
  177. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  178. package/test/graph/topological-sort.test.ts +586 -0
  179. package/test/integration.test.ts +442 -0
  180. package/test/rpc-surface.test.ts +946 -0
  181. package/test/runtime.test.ts +262 -0
  182. package/test/schedule-timer-cleanup.test.ts +353 -0
  183. package/test/send-race-conditions.test.ts +400 -0
  184. package/test/type-safety-every.test.ts +303 -0
  185. package/test/worker/durable-cascade.test.ts +1117 -0
  186. package/test/worker/durable-step.test.ts +723 -0
  187. package/test/worker/topological-executor.test.ts +1240 -0
  188. package/test/worker/workflow-builder.test.ts +1067 -0
  189. package/test/worker.test.ts +608 -0
  190. package/test/workflow-builder.test.ts +1670 -0
  191. package/test/workflow-cron.test.ts +256 -0
  192. package/test/workflow-state-adapter.test.ts +923 -0
  193. package/test/workflow.test.ts +25 -22
  194. package/tsconfig.json +3 -1
  195. package/vitest.config.ts +38 -1
  196. package/vitest.workers.config.ts +44 -0
  197. package/wrangler.jsonc +22 -0
  198. package/.turbo/turbo-test.log +0 -7
  199. package/src/context.js +0 -83
  200. package/src/every.js +0 -267
  201. package/src/index.js +0 -71
  202. package/src/on.js +0 -79
  203. package/src/send.js +0 -111
  204. package/src/types.js +0 -4
  205. package/src/workflow.js +0 -455
  206. package/test/context.test.js +0 -116
  207. package/test/every.test.js +0 -282
  208. package/test/on.test.js +0 -80
  209. package/test/send.test.js +0 -89
  210. package/test/workflow.test.js +0 -224
  211. package/vitest.config.js +0 -7
package/src/every.ts CHANGED
@@ -9,7 +9,16 @@
9
9
  * every('first Monday of the month at 9am', $ => { ... })
10
10
  */
11
11
 
12
- import type { ScheduleHandler, ScheduleRegistration, ScheduleInterval } from './types.js'
12
+ import type {
13
+ ScheduleHandler,
14
+ ScheduleRegistration,
15
+ ScheduleInterval,
16
+ EveryProxyTarget,
17
+ EveryProxy,
18
+ EveryProxyHandler,
19
+ DayScheduleProxyHandler,
20
+ } from './types.js'
21
+ import { PLURAL_UNITS, isPluralUnitKey } from './types.js'
13
22
 
14
23
  /**
15
24
  * Registry of schedule handlers
@@ -151,65 +160,125 @@ export async function toCron(description: string): Promise<string> {
151
160
  }
152
161
 
153
162
  /**
154
- * Create the `every` proxy
163
+ * Schedule registration callback type
164
+ * Used by createTypedEveryProxy to customize handler registration
155
165
  */
156
- function createEveryProxy() {
157
- const handler = {
158
- get(_target: unknown, prop: string) {
166
+ export type EveryProxyRegistrationCallback = (
167
+ interval: ScheduleInterval,
168
+ handler: ScheduleHandler
169
+ ) => void
170
+
171
+ /**
172
+ * Create a typed EveryProxy with proper TypeScript generics
173
+ *
174
+ * This factory function creates a callable proxy that supports:
175
+ * - Direct calls: every('natural language', handler)
176
+ * - Simple patterns: every.hour(handler)
177
+ * - Day + time: every.Monday.at9am(handler)
178
+ * - Intervals: every.minutes(30)(handler)
179
+ *
180
+ * @param registerCallback - Function called when a handler is registered
181
+ * @returns A properly typed EveryProxy
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * // Create proxy with custom registration
186
+ * const myEvery = createTypedEveryProxy((interval, handler) => {
187
+ * myRegistry.push({ interval, handler })
188
+ * })
189
+ *
190
+ * myEvery.hour(handler) // Properly typed!
191
+ * myEvery.Monday.at9am(handler) // Chained access typed!
192
+ * ```
193
+ */
194
+ export function createTypedEveryProxy(registerCallback: EveryProxyRegistrationCallback): EveryProxy {
195
+ // Create typed handler for day schedule patterns with time modifiers
196
+ const createDayScheduleHandler = (
197
+ pattern: string,
198
+ prop: string
199
+ ): DayScheduleProxyHandler => ({
200
+ get(
201
+ _target: (handler: ScheduleHandler) => void,
202
+ timeKey: string,
203
+ _receiver: unknown
204
+ ): ((handler: ScheduleHandler) => void) | undefined {
205
+ const time = TIME_PATTERNS[timeKey]
206
+ if (time) {
207
+ const cron = combineWithTime(pattern, time)
208
+ return (handlerFn: ScheduleHandler) => {
209
+ registerCallback({ type: 'cron', expression: cron, natural: `${prop}.${timeKey}` }, handlerFn)
210
+ }
211
+ }
212
+ return undefined
213
+ },
214
+ apply(
215
+ _target: (handler: ScheduleHandler) => void,
216
+ _thisArg: unknown,
217
+ args: [ScheduleHandler]
218
+ ): void {
219
+ registerCallback({ type: 'cron', expression: pattern, natural: prop }, args[0])
220
+ }
221
+ })
222
+
223
+ // Create the main EveryProxy handler
224
+ const everyHandler: EveryProxyHandler = {
225
+ get(
226
+ _target: EveryProxyTarget,
227
+ prop: string,
228
+ _receiver: unknown
229
+ ): unknown {
159
230
  // Check if it's a known pattern
160
231
  const pattern = KNOWN_PATTERNS[prop]
161
232
  if (pattern) {
162
233
  // Return an object that can either be called directly or have time accessors
163
234
  const result = (handlerFn: ScheduleHandler) => {
164
- registerScheduleHandler({ type: 'cron', expression: pattern, natural: prop }, handlerFn)
235
+ registerCallback({ type: 'cron', expression: pattern, natural: prop }, handlerFn)
165
236
  }
166
- // Add time accessors
167
- return new Proxy(result, {
168
- get(_t, timeKey: string) {
169
- const time = TIME_PATTERNS[timeKey]
170
- if (time) {
171
- const cron = combineWithTime(pattern, time)
172
- return (handlerFn: ScheduleHandler) => {
173
- registerScheduleHandler({ type: 'cron', expression: cron, natural: `${prop}.${timeKey}` }, handlerFn)
174
- }
175
- }
176
- return undefined
177
- },
178
- apply(_t, _thisArg, args) {
179
- registerScheduleHandler({ type: 'cron', expression: pattern, natural: prop }, args[0])
180
- }
181
- })
237
+ // Add time accessors with typed handler
238
+ return new Proxy(result, createDayScheduleHandler(pattern, prop))
182
239
  }
183
240
 
184
241
  // Check for plural time units (e.g., seconds(5), minutes(30))
185
- const pluralUnits: Record<string, string> = {
186
- seconds: 'second',
187
- minutes: 'minute',
188
- hours: 'hour',
189
- days: 'day',
190
- weeks: 'week',
191
- }
192
- if (pluralUnits[prop]) {
242
+ // Using type guard and typed constant for type-safe interval creation
243
+ if (isPluralUnitKey(prop)) {
244
+ const intervalType = PLURAL_UNITS[prop]
193
245
  return (value: number) => (handlerFn: ScheduleHandler) => {
194
- registerScheduleHandler({ type: pluralUnits[prop] as any, value, natural: `${value} ${prop}` }, handlerFn)
246
+ registerCallback({ type: intervalType, value, natural: `${value} ${prop}` }, handlerFn)
195
247
  }
196
248
  }
197
249
 
198
250
  return undefined
199
251
  },
200
252
 
201
- apply(_target: unknown, _thisArg: unknown, args: unknown[]) {
253
+ apply(
254
+ _target: EveryProxyTarget,
255
+ _thisArg: unknown,
256
+ args: [string, ScheduleHandler]
257
+ ): void {
202
258
  // Called as every('natural language description', handler)
203
- const [description, handler] = args as [string, ScheduleHandler]
259
+ const [description, handler] = args
204
260
 
205
261
  if (typeof description === 'string' && typeof handler === 'function') {
206
262
  // Register with natural language - will be converted to cron at runtime
207
- registerScheduleHandler({ type: 'natural', description }, handler)
263
+ registerCallback({ type: 'natural', description }, handler)
208
264
  }
209
265
  }
210
266
  }
211
267
 
212
- return new Proxy(function() {} as any, handler)
268
+ // Create callable target with proper typing
269
+ // The function serves as the Proxy target - actual behavior is in the handler's apply trap
270
+ const target: EveryProxyTarget = function(_description: string, _handler: ScheduleHandler) {}
271
+ return new Proxy(target, everyHandler) as EveryProxy
272
+ }
273
+
274
+ /**
275
+ * Create the `every` proxy using the global schedule registry
276
+ *
277
+ * This is the default implementation that uses registerScheduleHandler
278
+ * for backward compatibility with the standalone `every` export.
279
+ */
280
+ function createEveryProxy(): EveryProxy {
281
+ return createTypedEveryProxy(registerScheduleHandler)
213
282
  }
214
283
 
215
284
  /**
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Graph algorithms for workflow execution ordering
3
+ *
4
+ * Provides topological sorting and execution level grouping for
5
+ * managing workflow step dependencies.
6
+ */
7
+
8
+ export {
9
+ topologicalSort,
10
+ topologicalSortKahn,
11
+ topologicalSortDFS,
12
+ getExecutionLevels,
13
+ CycleDetectedError,
14
+ MissingNodeError,
15
+ type SortableNode,
16
+ type ExecutionLevel,
17
+ type TopologicalSortOptions,
18
+ type TopologicalSortResult,
19
+ } from './topological-sort.js'
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Topological Sort Implementation for Workflow Execution Ordering
3
+ *
4
+ * Provides multiple algorithms for topologically sorting workflow steps:
5
+ * - Kahn's algorithm (BFS-based, good for detecting cycles)
6
+ * - DFS-based algorithm (classic approach, provides cycle path)
7
+ *
8
+ * Features:
9
+ * - Cycle detection with path reporting
10
+ * - Parallel execution level grouping
11
+ * - Stable, deterministic ordering
12
+ * - Support for missing dependency handling
13
+ */
14
+
15
+ /**
16
+ * A node that can be topologically sorted
17
+ */
18
+ export interface SortableNode {
19
+ /** Unique identifier for the node */
20
+ id: string
21
+ /** IDs of nodes this node depends on */
22
+ dependencies: string[]
23
+ }
24
+
25
+ /**
26
+ * Execution level containing nodes that can run in parallel
27
+ */
28
+ export interface ExecutionLevel {
29
+ /** Level number (0 = no dependencies, 1 = depends on level 0, etc.) */
30
+ level: number
31
+ /** Node IDs that can run concurrently at this level */
32
+ nodes: string[]
33
+ }
34
+
35
+ /**
36
+ * Options for topological sort
37
+ */
38
+ export interface TopologicalSortOptions {
39
+ /** Throw CycleDetectedError instead of returning hasCycle: true (default: false) */
40
+ throwOnCycle?: boolean
41
+ /** Which algorithm to use (default: 'kahn') */
42
+ algorithm?: 'kahn' | 'dfs'
43
+ /** Throw on missing dependencies (default: false) */
44
+ strict?: boolean
45
+ }
46
+
47
+ /**
48
+ * Result of topological sort operation
49
+ */
50
+ export interface TopologicalSortResult {
51
+ /** Sorted node IDs in execution order */
52
+ order: string[]
53
+ /** Whether a cycle was detected */
54
+ hasCycle: boolean
55
+ /** Path of nodes forming the cycle (if detected) */
56
+ cyclePath?: string[]
57
+ /** Additional metadata from the algorithm */
58
+ metadata?: {
59
+ /** In-degrees for each node (Kahn's algorithm) */
60
+ inDegrees?: Record<string, number>
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Error thrown when a cycle is detected in the dependency graph
66
+ */
67
+ export class CycleDetectedError extends Error {
68
+ /** The path of nodes forming the cycle */
69
+ cyclePath: string[]
70
+
71
+ constructor(cyclePath: string[]) {
72
+ const pathStr = cyclePath.join(' -> ')
73
+ super(`Cycle detected in dependency graph: ${pathStr}`)
74
+ this.name = 'CycleDetectedError'
75
+ this.cyclePath = cyclePath
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Error thrown when a dependency references a non-existent node
81
+ */
82
+ export class MissingNodeError extends Error {
83
+ /** The missing node ID */
84
+ missingNode: string
85
+ /** The node that references the missing node */
86
+ referencedBy: string
87
+
88
+ constructor(missingNode: string, referencedBy: string) {
89
+ super(`Missing dependency '${missingNode}' referenced by '${referencedBy}'`)
90
+ this.name = 'MissingNodeError'
91
+ this.missingNode = missingNode
92
+ this.referencedBy = referencedBy
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Build adjacency list and compute in-degrees for Kahn's algorithm
98
+ */
99
+ function buildGraph(
100
+ nodes: SortableNode[],
101
+ strict: boolean
102
+ ): {
103
+ adjacencyList: Map<string, string[]>
104
+ inDegrees: Map<string, number>
105
+ nodeSet: Set<string>
106
+ } {
107
+ const nodeSet = new Set(nodes.map((n) => n.id))
108
+ const adjacencyList = new Map<string, string[]>()
109
+ const inDegrees = new Map<string, number>()
110
+
111
+ // Initialize all nodes
112
+ for (const node of nodes) {
113
+ adjacencyList.set(node.id, [])
114
+ inDegrees.set(node.id, 0)
115
+ }
116
+
117
+ // Build edges (dependency -> dependent)
118
+ for (const node of nodes) {
119
+ // Deduplicate dependencies
120
+ const uniqueDeps = [...new Set(node.dependencies)]
121
+
122
+ for (const dep of uniqueDeps) {
123
+ if (!nodeSet.has(dep)) {
124
+ if (strict) {
125
+ throw new MissingNodeError(dep, node.id)
126
+ }
127
+ // Skip missing dependencies in non-strict mode
128
+ continue
129
+ }
130
+
131
+ // Add edge from dependency to this node
132
+ adjacencyList.get(dep)!.push(node.id)
133
+ inDegrees.set(node.id, inDegrees.get(node.id)! + 1)
134
+ }
135
+ }
136
+
137
+ return { adjacencyList, inDegrees, nodeSet }
138
+ }
139
+
140
+ /**
141
+ * Topological sort using Kahn's algorithm (BFS-based)
142
+ *
143
+ * Algorithm:
144
+ * 1. Calculate in-degree for each node
145
+ * 2. Add all nodes with in-degree 0 to queue
146
+ * 3. While queue not empty:
147
+ * - Remove node from queue, add to result
148
+ * - Decrease in-degree of all dependents
149
+ * - Add nodes with in-degree 0 to queue
150
+ * 4. If result size < node count, cycle exists
151
+ */
152
+ export function topologicalSortKahn(
153
+ nodes: SortableNode[],
154
+ options: TopologicalSortOptions = {}
155
+ ): TopologicalSortResult {
156
+ const { strict = false } = options
157
+
158
+ if (nodes.length === 0) {
159
+ return { order: [], hasCycle: false }
160
+ }
161
+
162
+ const { adjacencyList, inDegrees, nodeSet } = buildGraph(nodes, strict)
163
+
164
+ const order: string[] = []
165
+ const inDegreesCopy = new Map(inDegrees)
166
+
167
+ // Start with nodes that have no dependencies (in-degree 0)
168
+ // Sort alphabetically for determinism
169
+ const queue: string[] = [...nodeSet].filter((id) => inDegreesCopy.get(id) === 0).sort()
170
+
171
+ while (queue.length > 0) {
172
+ // Sort queue for deterministic ordering
173
+ queue.sort()
174
+ const current = queue.shift()!
175
+ order.push(current)
176
+
177
+ // Decrease in-degree for all dependents
178
+ for (const dependent of adjacencyList.get(current) || []) {
179
+ const newDegree = inDegreesCopy.get(dependent)! - 1
180
+ inDegreesCopy.set(dependent, newDegree)
181
+
182
+ if (newDegree === 0) {
183
+ queue.push(dependent)
184
+ }
185
+ }
186
+ }
187
+
188
+ // If we didn't process all nodes, there's a cycle
189
+ const hasCycle = order.length < nodeSet.size
190
+
191
+ // Convert in-degrees to record
192
+ const inDegreesRecord: Record<string, number> = {}
193
+ for (const [id, degree] of inDegrees) {
194
+ inDegreesRecord[id] = degree
195
+ }
196
+
197
+ return {
198
+ order,
199
+ hasCycle,
200
+ metadata: {
201
+ inDegrees: inDegreesRecord,
202
+ },
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Topological sort using DFS-based algorithm
208
+ *
209
+ * Algorithm:
210
+ * 1. For each unvisited node:
211
+ * - Mark as "in progress"
212
+ * - Recursively visit all dependencies
213
+ * - Mark as "visited" and add to result (in reverse)
214
+ * 2. If we encounter a node "in progress", we found a cycle
215
+ */
216
+ export function topologicalSortDFS(
217
+ nodes: SortableNode[],
218
+ options: TopologicalSortOptions = {}
219
+ ): TopologicalSortResult {
220
+ const { strict = false } = options
221
+
222
+ if (nodes.length === 0) {
223
+ return { order: [], hasCycle: false }
224
+ }
225
+
226
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]))
227
+ const nodeSet = new Set(nodes.map((n) => n.id))
228
+
229
+ const visited = new Set<string>()
230
+ const inProgress = new Set<string>()
231
+ const order: string[] = []
232
+ let cyclePath: string[] | undefined
233
+
234
+ function dfs(nodeId: string, path: string[]): boolean {
235
+ if (inProgress.has(nodeId)) {
236
+ // Found a cycle - construct the cycle path
237
+ const cycleStart = path.indexOf(nodeId)
238
+ cyclePath = [...path.slice(cycleStart), nodeId]
239
+ return true // Cycle detected
240
+ }
241
+
242
+ if (visited.has(nodeId)) {
243
+ return false // Already processed
244
+ }
245
+
246
+ inProgress.add(nodeId)
247
+ path.push(nodeId)
248
+
249
+ const node = nodeMap.get(nodeId)
250
+ if (node) {
251
+ // Deduplicate and sort dependencies for determinism
252
+ const uniqueDeps = [...new Set(node.dependencies)].sort()
253
+
254
+ for (const dep of uniqueDeps) {
255
+ if (!nodeSet.has(dep)) {
256
+ if (strict) {
257
+ throw new MissingNodeError(dep, nodeId)
258
+ }
259
+ continue // Skip missing deps in non-strict mode
260
+ }
261
+
262
+ if (dfs(dep, path)) {
263
+ return true // Propagate cycle detection
264
+ }
265
+ }
266
+ }
267
+
268
+ path.pop()
269
+ inProgress.delete(nodeId)
270
+ visited.add(nodeId)
271
+ order.push(nodeId)
272
+
273
+ return false
274
+ }
275
+
276
+ // Process nodes in sorted order for determinism
277
+ const sortedNodeIds = [...nodeSet].sort()
278
+
279
+ for (const nodeId of sortedNodeIds) {
280
+ if (!visited.has(nodeId)) {
281
+ if (dfs(nodeId, [])) {
282
+ break // Stop on first cycle
283
+ }
284
+ }
285
+ }
286
+
287
+ const hasCycle = cyclePath !== undefined
288
+
289
+ return {
290
+ order: hasCycle ? order : order, // DFS produces correct order
291
+ hasCycle,
292
+ ...(cyclePath !== undefined && { cyclePath }),
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Main topological sort function with algorithm selection
298
+ *
299
+ * @param nodes - Array of nodes to sort
300
+ * @param options - Sort options
301
+ * @returns Sorted result with order and cycle information
302
+ */
303
+ export function topologicalSort(
304
+ nodes: SortableNode[],
305
+ options: TopologicalSortOptions = {}
306
+ ): TopologicalSortResult {
307
+ const { algorithm = 'dfs', throwOnCycle = false } = options
308
+
309
+ let result: TopologicalSortResult
310
+
311
+ if (algorithm === 'kahn') {
312
+ result = topologicalSortKahn(nodes, options)
313
+
314
+ // Kahn's algorithm doesn't provide cycle path, so detect it separately
315
+ if (result.hasCycle) {
316
+ const dfsResult = topologicalSortDFS(nodes, options)
317
+ if (dfsResult.cyclePath !== undefined) {
318
+ result = { ...result, cyclePath: dfsResult.cyclePath }
319
+ }
320
+ }
321
+ } else {
322
+ result = topologicalSortDFS(nodes, options)
323
+ }
324
+
325
+ if (result.hasCycle && throwOnCycle) {
326
+ throw new CycleDetectedError(result.cyclePath || ['unknown'])
327
+ }
328
+
329
+ return result
330
+ }
331
+
332
+ /**
333
+ * Get execution levels for parallel execution
334
+ *
335
+ * Groups nodes by their execution level:
336
+ * - Level 0: Nodes with no dependencies
337
+ * - Level N: Nodes whose dependencies are all at level < N
338
+ *
339
+ * @param nodes - Array of nodes to analyze
340
+ * @returns Array of execution levels, sorted by level number
341
+ * @throws CycleDetectedError if a cycle is detected
342
+ */
343
+ export function getExecutionLevels(nodes: SortableNode[]): ExecutionLevel[] {
344
+ if (nodes.length === 0) {
345
+ return []
346
+ }
347
+
348
+ // First, verify no cycles exist
349
+ const sortResult = topologicalSort(nodes, { throwOnCycle: true })
350
+
351
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]))
352
+ const nodeSet = new Set(nodes.map((n) => n.id))
353
+ const levels = new Map<string, number>()
354
+
355
+ // Calculate level for each node
356
+ function calculateLevel(nodeId: string): number {
357
+ if (levels.has(nodeId)) {
358
+ return levels.get(nodeId)!
359
+ }
360
+
361
+ const node = nodeMap.get(nodeId)
362
+ if (!node) {
363
+ return 0
364
+ }
365
+
366
+ // Filter to only existing dependencies
367
+ const validDeps = node.dependencies.filter((d) => nodeSet.has(d))
368
+
369
+ if (validDeps.length === 0) {
370
+ levels.set(nodeId, 0)
371
+ return 0
372
+ }
373
+
374
+ let maxDepLevel = -1
375
+ for (const dep of validDeps) {
376
+ const depLevel = calculateLevel(dep)
377
+ maxDepLevel = Math.max(maxDepLevel, depLevel)
378
+ }
379
+
380
+ const level = maxDepLevel + 1
381
+ levels.set(nodeId, level)
382
+ return level
383
+ }
384
+
385
+ // Calculate levels for all nodes
386
+ for (const node of nodes) {
387
+ calculateLevel(node.id)
388
+ }
389
+
390
+ // Group nodes by level
391
+ const levelGroups = new Map<number, string[]>()
392
+ for (const [nodeId, level] of levels) {
393
+ if (!levelGroups.has(level)) {
394
+ levelGroups.set(level, [])
395
+ }
396
+ levelGroups.get(level)!.push(nodeId)
397
+ }
398
+
399
+ // Convert to sorted array of ExecutionLevels
400
+ const result: ExecutionLevel[] = []
401
+ const sortedLevels = [...levelGroups.keys()].sort((a, b) => a - b)
402
+
403
+ for (const level of sortedLevels) {
404
+ result.push({
405
+ level,
406
+ // Sort nodes within level for determinism
407
+ nodes: levelGroups.get(level)!.sort(),
408
+ })
409
+ }
410
+
411
+ return result
412
+ }