@multiplier-labs/stepflow 0.3.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 (76) hide show
  1. package/.github/workflows/publish.yml +30 -0
  2. package/README.md +408 -0
  3. package/dist/chunk-FQCT3I2A.js +1512 -0
  4. package/dist/chunk-FQCT3I2A.js.map +1 -0
  5. package/dist/chunk-GXY66V7Q.js +111 -0
  6. package/dist/chunk-GXY66V7Q.js.map +1 -0
  7. package/dist/chunk-U5QGN3RT.js +715 -0
  8. package/dist/chunk-U5QGN3RT.js.map +1 -0
  9. package/dist/chunk-XCF4PLN7.js +575 -0
  10. package/dist/chunk-XCF4PLN7.js.map +1 -0
  11. package/dist/events/index.d.ts +346 -0
  12. package/dist/events/index.js +11 -0
  13. package/dist/events/index.js.map +1 -0
  14. package/dist/index-CPfGsmrp.d.ts +567 -0
  15. package/dist/index.d.ts +748 -0
  16. package/dist/index.js +1707 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/scheduler/index.d.ts +6 -0
  19. package/dist/scheduler/index.js +12 -0
  20. package/dist/scheduler/index.js.map +1 -0
  21. package/dist/storage/index.d.ts +402 -0
  22. package/dist/storage/index.js +13 -0
  23. package/dist/storage/index.js.map +1 -0
  24. package/dist/types-BVShq7zT.d.ts +150 -0
  25. package/dist/types-BlwFEGzj.d.ts +276 -0
  26. package/dist/types-DzkEqnQJ.d.ts +75 -0
  27. package/docs/github-packages-setup.md +97 -0
  28. package/docs/stepflow-api-reference.md +1662 -0
  29. package/package.json +85 -0
  30. package/src/core/engine.test.ts +1421 -0
  31. package/src/core/engine.ts +717 -0
  32. package/src/core/index.ts +7 -0
  33. package/src/core/orchestrator.ts +590 -0
  34. package/src/core/types.ts +242 -0
  35. package/src/events/index.ts +8 -0
  36. package/src/events/memory.test.ts +109 -0
  37. package/src/events/memory.ts +95 -0
  38. package/src/events/socketio.test.ts +428 -0
  39. package/src/events/socketio.ts +269 -0
  40. package/src/events/types.ts +121 -0
  41. package/src/events/webhook.test.ts +695 -0
  42. package/src/events/webhook.ts +591 -0
  43. package/src/index.ts +270 -0
  44. package/src/planning/index.ts +52 -0
  45. package/src/planning/planner.ts +601 -0
  46. package/src/planning/planning.test.ts +790 -0
  47. package/src/planning/registry.test.ts +445 -0
  48. package/src/planning/registry.ts +369 -0
  49. package/src/planning/types.ts +570 -0
  50. package/src/scheduler/cron.test.ts +480 -0
  51. package/src/scheduler/cron.ts +449 -0
  52. package/src/scheduler/index.ts +18 -0
  53. package/src/scheduler/postgres-persistence.test.ts +906 -0
  54. package/src/scheduler/postgres-persistence.ts +546 -0
  55. package/src/scheduler/sqlite-persistence.test.ts +235 -0
  56. package/src/scheduler/sqlite-persistence.ts +268 -0
  57. package/src/scheduler/types.ts +78 -0
  58. package/src/storage/index.ts +27 -0
  59. package/src/storage/memory.test.ts +399 -0
  60. package/src/storage/memory.ts +248 -0
  61. package/src/storage/postgres.ts +1550 -0
  62. package/src/storage/sqlite.test.ts +593 -0
  63. package/src/storage/sqlite.ts +662 -0
  64. package/src/storage/types.ts +368 -0
  65. package/src/utils/errors.test.ts +152 -0
  66. package/src/utils/errors.ts +191 -0
  67. package/src/utils/id.ts +21 -0
  68. package/src/utils/index.ts +8 -0
  69. package/src/utils/logger.test.ts +127 -0
  70. package/src/utils/logger.ts +96 -0
  71. package/src/utils/postgres-deps.ts +63 -0
  72. package/src/utils/retry.test.ts +197 -0
  73. package/src/utils/retry.ts +121 -0
  74. package/tsconfig.json +21 -0
  75. package/tsup.config.ts +14 -0
  76. package/vitest.config.ts +9 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Recipe and Step Handler registries.
3
+ * Provides storage and retrieval of recipes and handlers.
4
+ */
5
+
6
+ import RE2 from "re2";
7
+ import type { WorkflowStep } from "../core/types";
8
+ import type {
9
+ Recipe,
10
+ RecipeCondition,
11
+ RecipeRegistry,
12
+ RecipeQueryOptions,
13
+ RegisteredStepHandler,
14
+ StepHandlerRegistry,
15
+ WorkflowKind,
16
+ } from "./types";
17
+
18
+ // ============================================================================
19
+ // Step Handler Registry Implementation
20
+ // ============================================================================
21
+
22
+ /**
23
+ * In-memory implementation of the step handler registry.
24
+ */
25
+ export class MemoryStepHandlerRegistry implements StepHandlerRegistry {
26
+ private handlers = new Map<string, RegisteredStepHandler>();
27
+ private tagIndex = new Map<string, Set<string>>();
28
+
29
+ /** Register a step handler. Throws if a handler with the same ID is already registered. */
30
+ register<TInput = Record<string, unknown>>(
31
+ handler: RegisteredStepHandler<TInput>,
32
+ ): void {
33
+ if (this.handlers.has(handler.id)) {
34
+ throw new Error(`Step handler '${handler.id}' is already registered`);
35
+ }
36
+
37
+ this.handlers.set(handler.id, handler as RegisteredStepHandler);
38
+
39
+ // Index by tags
40
+ if (handler.tags) {
41
+ for (const tag of handler.tags) {
42
+ if (!this.tagIndex.has(tag)) {
43
+ this.tagIndex.set(tag, new Set());
44
+ }
45
+ this.tagIndex.get(tag)!.add(handler.id);
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Get a handler by its unique ID, or undefined if not registered. */
51
+ get(id: string): RegisteredStepHandler | undefined {
52
+ return this.handlers.get(id);
53
+ }
54
+
55
+ /** Check whether a handler with the given ID is registered. */
56
+ has(id: string): boolean {
57
+ return this.handlers.has(id);
58
+ }
59
+
60
+ /** List all registered step handlers. */
61
+ list(): RegisteredStepHandler[] {
62
+ return Array.from(this.handlers.values());
63
+ }
64
+
65
+ /** List all handlers tagged with the given tag. */
66
+ listByTag(tag: string): RegisteredStepHandler[] {
67
+ const ids = this.tagIndex.get(tag);
68
+ if (!ids) return [];
69
+
70
+ return Array.from(ids)
71
+ .map((id) => this.handlers.get(id))
72
+ .filter((h): h is RegisteredStepHandler => h !== undefined);
73
+ }
74
+
75
+ /**
76
+ * Resolve a handler reference to a WorkflowStep handler function.
77
+ * Returns undefined if the handler is not found.
78
+ */
79
+ resolve(handlerRef: string): WorkflowStep["handler"] | undefined {
80
+ const registered = this.handlers.get(handlerRef);
81
+ return registered?.handler;
82
+ }
83
+
84
+ /**
85
+ * Clear all registered handlers (useful for testing).
86
+ */
87
+ clear(): void {
88
+ this.handlers.clear();
89
+ this.tagIndex.clear();
90
+ }
91
+ }
92
+
93
+ // ============================================================================
94
+ // Recipe Registry Implementation
95
+ // ============================================================================
96
+
97
+ /**
98
+ * In-memory implementation of the recipe registry.
99
+ */
100
+ export class MemoryRecipeRegistry implements RecipeRegistry {
101
+ private recipes = new Map<string, Recipe>();
102
+ private kindIndex = new Map<WorkflowKind, Set<string>>();
103
+ private variantIndex = new Map<string, string>(); // "kind:variant" -> recipeId
104
+ private tagIndex = new Map<string, Set<string>>();
105
+
106
+ /** Register a single recipe. Throws if a recipe with the same ID or kind:variant pair is already registered. */
107
+ register(recipe: Recipe): void {
108
+ if (this.recipes.has(recipe.id)) {
109
+ throw new Error(`Recipe '${recipe.id}' is already registered`);
110
+ }
111
+
112
+ this.recipes.set(recipe.id, recipe);
113
+
114
+ // Index by workflow kind
115
+ if (!this.kindIndex.has(recipe.workflowKind)) {
116
+ this.kindIndex.set(recipe.workflowKind, new Set());
117
+ }
118
+ this.kindIndex.get(recipe.workflowKind)!.add(recipe.id);
119
+
120
+ // Index by kind:variant
121
+ const variantKey = `${recipe.workflowKind}:${recipe.variant}`;
122
+ if (this.variantIndex.has(variantKey)) {
123
+ throw new Error(
124
+ `Recipe variant '${recipe.variant}' for '${recipe.workflowKind}' is already registered`,
125
+ );
126
+ }
127
+ this.variantIndex.set(variantKey, recipe.id);
128
+
129
+ // Index by tags
130
+ if (recipe.tags) {
131
+ for (const tag of recipe.tags) {
132
+ if (!this.tagIndex.has(tag)) {
133
+ this.tagIndex.set(tag, new Set());
134
+ }
135
+ this.tagIndex.get(tag)!.add(recipe.id);
136
+ }
137
+ }
138
+ }
139
+
140
+ /** Register multiple recipes at once. */
141
+ registerAll(recipes: Recipe[]): void {
142
+ for (const recipe of recipes) {
143
+ this.register(recipe);
144
+ }
145
+ }
146
+
147
+ /** Get a recipe by its unique ID, or undefined if not registered. */
148
+ get(recipeId: string): Recipe | undefined {
149
+ return this.recipes.get(recipeId);
150
+ }
151
+
152
+ /** Check whether a recipe with the given ID is registered. */
153
+ has(recipeId: string): boolean {
154
+ return this.recipes.has(recipeId);
155
+ }
156
+
157
+ /** Get all recipes registered for a given workflow kind. */
158
+ getByKind(workflowKind: WorkflowKind): Recipe[] {
159
+ const ids = this.kindIndex.get(workflowKind);
160
+ if (!ids) return [];
161
+
162
+ return Array.from(ids)
163
+ .map((id) => this.recipes.get(id))
164
+ .filter((r): r is Recipe => r !== undefined);
165
+ }
166
+
167
+ /** Get the recipe for a specific workflow kind and variant combination. */
168
+ getVariant(workflowKind: WorkflowKind, variant: string): Recipe | undefined {
169
+ const variantKey = `${workflowKind}:${variant}`;
170
+ const recipeId = this.variantIndex.get(variantKey);
171
+ if (!recipeId) return undefined;
172
+ return this.recipes.get(recipeId);
173
+ }
174
+
175
+ /**
176
+ * Get the default recipe for a workflow kind.
177
+ * Returns the 'default' variant if one exists, otherwise falls back to the
178
+ * recipe with the lowest numeric priority value (lower number = higher precedence).
179
+ */
180
+ getDefault(workflowKind: WorkflowKind): Recipe | undefined {
181
+ // First try to find a 'default' variant
182
+ const defaultRecipe = this.getVariant(workflowKind, "default");
183
+ if (defaultRecipe) return defaultRecipe;
184
+
185
+ // Fall back to the recipe with the lowest numeric priority (highest precedence).
186
+ // Note: this ascending order is the inverse of RuleBasedPlanner.selectRecipe,
187
+ // which uses descending priority as a tiebreaker in condition-based scoring.
188
+ const recipes = this.getByKind(workflowKind);
189
+ if (recipes.length === 0) return undefined;
190
+
191
+ return recipes.reduce((lowest, current) => {
192
+ const currentPriority = current.priority ?? 0;
193
+ const lowestPriority = lowest.priority ?? 0;
194
+ return currentPriority < lowestPriority ? current : lowest;
195
+ });
196
+ }
197
+
198
+ /** List all variant names registered for a workflow kind. */
199
+ listVariants(workflowKind: WorkflowKind): string[] {
200
+ const recipes = this.getByKind(workflowKind);
201
+ return recipes.map((r) => r.variant);
202
+ }
203
+
204
+ /** Query recipes with optional filters for kind, variant, tags, and input conditions. */
205
+ query(options: RecipeQueryOptions): Recipe[] {
206
+ let results = this.list();
207
+
208
+ if (options.workflowKind) {
209
+ results = results.filter((r) => r.workflowKind === options.workflowKind);
210
+ }
211
+
212
+ if (options.variant) {
213
+ results = results.filter((r) => r.variant === options.variant);
214
+ }
215
+
216
+ if (options.tags && options.tags.length > 0) {
217
+ results = results.filter((r) =>
218
+ options.tags!.some((tag) => r.tags?.includes(tag)),
219
+ );
220
+ }
221
+
222
+ if (options.matchConditions) {
223
+ results = results.filter((r) => {
224
+ if (!r.conditions || r.conditions.length === 0) return true;
225
+ return this.evaluateConditions(r.conditions, options.matchConditions!);
226
+ });
227
+ }
228
+
229
+ return results;
230
+ }
231
+
232
+ /** List all registered recipes. */
233
+ list(): Recipe[] {
234
+ return Array.from(this.recipes.values());
235
+ }
236
+
237
+ /**
238
+ * Clear all registered recipes (useful for testing).
239
+ */
240
+ clear(): void {
241
+ this.recipes.clear();
242
+ this.kindIndex.clear();
243
+ this.variantIndex.clear();
244
+ this.tagIndex.clear();
245
+ }
246
+
247
+ /**
248
+ * Evaluate recipe conditions against an input.
249
+ * Returns true if all conditions match.
250
+ *
251
+ * Note: This intentionally mirrors the condition evaluation in planner.ts
252
+ * to keep registry queries independent of the planner implementation.
253
+ */
254
+ private evaluateConditions(
255
+ conditions: Recipe["conditions"],
256
+ input: Record<string, unknown>,
257
+ ): boolean {
258
+ if (!conditions || conditions.length === 0) return true;
259
+
260
+ return conditions.every((condition) => {
261
+ const fieldValue = this.getNestedValue(input, condition.field);
262
+ return this.evaluateCondition(condition, fieldValue);
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Get a nested value from an object using dot notation.
268
+ */
269
+ private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
270
+ return path.split(".").reduce<unknown>((current, key) => {
271
+ if (current && typeof current === "object" && key in current) {
272
+ return (current as Record<string, unknown>)[key];
273
+ }
274
+ return undefined;
275
+ }, obj);
276
+ }
277
+
278
+ /**
279
+ * Evaluate a single condition.
280
+ */
281
+ private evaluateCondition(
282
+ condition: RecipeCondition,
283
+ fieldValue: unknown,
284
+ ): boolean {
285
+ const { operator, value } = condition;
286
+
287
+ switch (operator) {
288
+ case "eq":
289
+ return fieldValue === value;
290
+
291
+ case "neq":
292
+ return fieldValue !== value;
293
+
294
+ case "gt":
295
+ return typeof fieldValue === "number" && typeof value === "number"
296
+ ? fieldValue > value
297
+ : false;
298
+
299
+ case "gte":
300
+ return typeof fieldValue === "number" && typeof value === "number"
301
+ ? fieldValue >= value
302
+ : false;
303
+
304
+ case "lt":
305
+ return typeof fieldValue === "number" && typeof value === "number"
306
+ ? fieldValue < value
307
+ : false;
308
+
309
+ case "lte":
310
+ return typeof fieldValue === "number" && typeof value === "number"
311
+ ? fieldValue <= value
312
+ : false;
313
+
314
+ case "contains":
315
+ if (typeof fieldValue === "string" && typeof value === "string") {
316
+ return fieldValue.includes(value);
317
+ }
318
+ if (Array.isArray(fieldValue)) {
319
+ return fieldValue.includes(value);
320
+ }
321
+ return false;
322
+
323
+ case "matches":
324
+ if (typeof fieldValue === "string" && typeof value === "string") {
325
+ try {
326
+ return new RE2(value).test(fieldValue);
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+ return false;
332
+
333
+ case "exists":
334
+ return (
335
+ fieldValue !== undefined && fieldValue !== null && fieldValue !== ""
336
+ );
337
+
338
+ case "notExists":
339
+ return (
340
+ fieldValue === undefined || fieldValue === null || fieldValue === ""
341
+ );
342
+
343
+ default:
344
+ return false;
345
+ }
346
+ }
347
+ }
348
+
349
+ // ============================================================================
350
+ // Default Instances
351
+ // ============================================================================
352
+
353
+ /**
354
+ * Create a combined registry that holds both recipes and step handlers.
355
+ */
356
+ export interface CombinedRegistry {
357
+ recipes: MemoryRecipeRegistry;
358
+ handlers: MemoryStepHandlerRegistry;
359
+ }
360
+
361
+ /**
362
+ * Create a new combined registry instance.
363
+ */
364
+ export function createRegistry(): CombinedRegistry {
365
+ return {
366
+ recipes: new MemoryRecipeRegistry(),
367
+ handlers: new MemoryStepHandlerRegistry(),
368
+ };
369
+ }