@multiplier-labs/stepflow 0.3.1 → 0.3.4
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.
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/CODEOWNERS +15 -0
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +17 -5
- package/.github/workflows/security.yml +29 -0
- package/CHANGELOG.md +34 -0
- package/README.md +24 -0
- package/SECURITY.md +40 -0
- package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +60 -7
- package/dist/events/index.js +1 -1
- package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
- package/dist/index.d.ts +15 -23
- package/dist/index.js +784 -547
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +4 -4
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +322 -56
- package/dist/storage/index.js +4 -2
- package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
- package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
- package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
- package/docs/stepflow-api-reference.md +5 -4
- package/package.json +12 -8
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +536 -522
- package/src/core/engine.ts +116 -235
- package/src/core/index.ts +3 -3
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +57 -395
- package/src/core/types.ts +7 -29
- package/src/events/index.ts +4 -4
- package/src/events/memory.test.ts +39 -41
- package/src/events/memory.ts +7 -10
- package/src/events/socketio.test.ts +112 -162
- package/src/events/socketio.ts +20 -27
- package/src/events/types.ts +16 -19
- package/src/events/webhook.test.ts +489 -235
- package/src/events/webhook.ts +355 -139
- package/src/index.ts +19 -19
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/index.ts +6 -3
- package/src/planning/planner.ts +45 -152
- package/src/planning/planning.test.ts +303 -366
- package/src/planning/registry.test.ts +204 -251
- package/src/planning/registry.ts +20 -106
- package/src/planning/types.ts +23 -27
- package/src/scheduler/cron.test.ts +149 -150
- package/src/scheduler/cron.ts +58 -69
- package/src/scheduler/index.ts +4 -14
- package/src/scheduler/postgres-persistence.ts +89 -105
- package/src/scheduler/sqlite-persistence.test.ts +96 -96
- package/src/scheduler/sqlite-persistence.ts +40 -22
- package/src/scheduler/types.ts +4 -9
- package/src/storage/index.ts +5 -5
- package/src/storage/memory.test.ts +133 -183
- package/src/storage/memory.ts +21 -42
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +222 -937
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +330 -281
- package/src/storage/sqlite.ts +225 -170
- package/src/storage/types.ts +33 -58
- package/src/utils/errors.test.ts +78 -80
- package/src/utils/errors.ts +27 -57
- package/src/utils/id.ts +3 -3
- package/src/utils/index.ts +6 -4
- package/src/utils/logger.test.ts +52 -58
- package/src/utils/logger.ts +4 -4
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +20 -4
- package/src/utils/retry.test.ts +47 -67
- package/src/utils/retry.ts +9 -9
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-FQCT3I2A.js.map +0 -1
- package/dist/chunk-GXY66V7Q.js.map +0 -1
- package/dist/chunk-U5QGN3RT.js.map +0 -1
- package/dist/chunk-XCF4PLN7.js.map +0 -1
- package/src/scheduler/postgres-persistence.test.ts +0 -906
|
@@ -1,96 +1,87 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from "./registry";
|
|
7
|
-
import type { Recipe, RegisteredStepHandler } from "./types";
|
|
8
|
-
|
|
9
|
-
describe("MemoryStepHandlerRegistry", () => {
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MemoryStepHandlerRegistry, MemoryRecipeRegistry, createRegistry } from './registry';
|
|
3
|
+
import type { Recipe, RegisteredStepHandler } from './types';
|
|
4
|
+
|
|
5
|
+
describe('MemoryStepHandlerRegistry', () => {
|
|
10
6
|
let registry: MemoryStepHandlerRegistry;
|
|
11
7
|
|
|
12
8
|
beforeEach(() => {
|
|
13
9
|
registry = new MemoryStepHandlerRegistry();
|
|
14
10
|
});
|
|
15
11
|
|
|
16
|
-
it(
|
|
12
|
+
it('should register and retrieve a handler', () => {
|
|
17
13
|
const handler: RegisteredStepHandler = {
|
|
18
|
-
id:
|
|
19
|
-
description:
|
|
20
|
-
handler: async () =>
|
|
14
|
+
id: 'handler.a',
|
|
15
|
+
description: 'Handler A',
|
|
16
|
+
handler: async () => 'result',
|
|
21
17
|
};
|
|
22
18
|
|
|
23
19
|
registry.register(handler);
|
|
24
20
|
|
|
25
|
-
expect(registry.get(
|
|
26
|
-
expect(registry.get(
|
|
27
|
-
expect(registry.has(
|
|
21
|
+
expect(registry.get('handler.a')).toBeDefined();
|
|
22
|
+
expect(registry.get('handler.a')?.id).toBe('handler.a');
|
|
23
|
+
expect(registry.has('handler.a')).toBe(true);
|
|
28
24
|
});
|
|
29
25
|
|
|
30
|
-
it(
|
|
31
|
-
registry.register({ id:
|
|
26
|
+
it('should throw on duplicate registration', () => {
|
|
27
|
+
registry.register({ id: 'h1', handler: async () => {} });
|
|
32
28
|
|
|
33
|
-
expect(() =>
|
|
34
|
-
|
|
35
|
-
).toThrow("Step handler 'h1' is already registered");
|
|
29
|
+
expect(() => registry.register({ id: 'h1', handler: async () => {} }))
|
|
30
|
+
.toThrow("Step handler 'h1' is already registered");
|
|
36
31
|
});
|
|
37
32
|
|
|
38
|
-
it(
|
|
39
|
-
expect(registry.get(
|
|
40
|
-
expect(registry.has(
|
|
33
|
+
it('should return undefined for non-existent handler', () => {
|
|
34
|
+
expect(registry.get('nonexistent')).toBeUndefined();
|
|
35
|
+
expect(registry.has('nonexistent')).toBe(false);
|
|
41
36
|
});
|
|
42
37
|
|
|
43
|
-
it(
|
|
44
|
-
registry.register({ id:
|
|
45
|
-
registry.register({ id:
|
|
38
|
+
it('should list all handlers', () => {
|
|
39
|
+
registry.register({ id: 'h1', handler: async () => {} });
|
|
40
|
+
registry.register({ id: 'h2', handler: async () => {} });
|
|
46
41
|
|
|
47
42
|
const list = registry.list();
|
|
48
43
|
expect(list).toHaveLength(2);
|
|
49
|
-
expect(list.map(
|
|
44
|
+
expect(list.map(h => h.id).sort()).toEqual(['h1', 'h2']);
|
|
50
45
|
});
|
|
51
46
|
|
|
52
|
-
it(
|
|
53
|
-
registry.register({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
tags: ["ai", "text"],
|
|
57
|
-
});
|
|
58
|
-
registry.register({ id: "h2", handler: async () => {}, tags: ["ai"] });
|
|
59
|
-
registry.register({ id: "h3", handler: async () => {}, tags: ["io"] });
|
|
47
|
+
it('should list handlers by tag', () => {
|
|
48
|
+
registry.register({ id: 'h1', handler: async () => {}, tags: ['ai', 'text'] });
|
|
49
|
+
registry.register({ id: 'h2', handler: async () => {}, tags: ['ai'] });
|
|
50
|
+
registry.register({ id: 'h3', handler: async () => {}, tags: ['io'] });
|
|
60
51
|
|
|
61
|
-
const aiHandlers = registry.listByTag(
|
|
52
|
+
const aiHandlers = registry.listByTag('ai');
|
|
62
53
|
expect(aiHandlers).toHaveLength(2);
|
|
63
|
-
expect(aiHandlers.map(
|
|
54
|
+
expect(aiHandlers.map(h => h.id).sort()).toEqual(['h1', 'h2']);
|
|
64
55
|
|
|
65
|
-
expect(registry.listByTag(
|
|
56
|
+
expect(registry.listByTag('nonexistent')).toEqual([]);
|
|
66
57
|
});
|
|
67
58
|
|
|
68
|
-
it(
|
|
69
|
-
const fn = async () =>
|
|
70
|
-
registry.register({ id:
|
|
59
|
+
it('should resolve handler reference to handler function', () => {
|
|
60
|
+
const fn = async () => 'resolved';
|
|
61
|
+
registry.register({ id: 'h1', handler: fn });
|
|
71
62
|
|
|
72
|
-
expect(registry.resolve(
|
|
73
|
-
expect(registry.resolve(
|
|
63
|
+
expect(registry.resolve('h1')).toBe(fn);
|
|
64
|
+
expect(registry.resolve('nonexistent')).toBeUndefined();
|
|
74
65
|
});
|
|
75
66
|
|
|
76
|
-
it(
|
|
77
|
-
registry.register({ id:
|
|
67
|
+
it('should clear all handlers and tag indexes', () => {
|
|
68
|
+
registry.register({ id: 'h1', handler: async () => {}, tags: ['t1'] });
|
|
78
69
|
registry.clear();
|
|
79
70
|
|
|
80
71
|
expect(registry.list()).toHaveLength(0);
|
|
81
|
-
expect(registry.listByTag(
|
|
72
|
+
expect(registry.listByTag('t1')).toEqual([]);
|
|
82
73
|
});
|
|
83
74
|
});
|
|
84
75
|
|
|
85
|
-
describe(
|
|
76
|
+
describe('MemoryRecipeRegistry', () => {
|
|
86
77
|
let registry: MemoryRecipeRegistry;
|
|
87
78
|
|
|
88
79
|
const makeRecipe = (overrides: Partial<Recipe> = {}): Recipe => ({
|
|
89
|
-
id:
|
|
90
|
-
name:
|
|
91
|
-
workflowKind:
|
|
92
|
-
variant:
|
|
93
|
-
steps: [{ key:
|
|
80
|
+
id: 'recipe-1',
|
|
81
|
+
name: 'Recipe 1',
|
|
82
|
+
workflowKind: 'test.workflow',
|
|
83
|
+
variant: 'default',
|
|
84
|
+
steps: [{ key: 's1', name: 'S1', handlerRef: 'h1' }],
|
|
94
85
|
...overrides,
|
|
95
86
|
});
|
|
96
87
|
|
|
@@ -98,346 +89,308 @@ describe("MemoryRecipeRegistry", () => {
|
|
|
98
89
|
registry = new MemoryRecipeRegistry();
|
|
99
90
|
});
|
|
100
91
|
|
|
101
|
-
describe(
|
|
102
|
-
it(
|
|
92
|
+
describe('register / get / has', () => {
|
|
93
|
+
it('should register and retrieve a recipe', () => {
|
|
103
94
|
const recipe = makeRecipe();
|
|
104
95
|
registry.register(recipe);
|
|
105
96
|
|
|
106
|
-
expect(registry.get(
|
|
107
|
-
expect(registry.has(
|
|
97
|
+
expect(registry.get('recipe-1')).toEqual(recipe);
|
|
98
|
+
expect(registry.has('recipe-1')).toBe(true);
|
|
108
99
|
});
|
|
109
100
|
|
|
110
|
-
it(
|
|
101
|
+
it('should throw on duplicate recipe id', () => {
|
|
111
102
|
registry.register(makeRecipe());
|
|
112
103
|
|
|
113
|
-
expect(() => registry.register(makeRecipe()))
|
|
114
|
-
"Recipe 'recipe-1' is already registered"
|
|
115
|
-
);
|
|
104
|
+
expect(() => registry.register(makeRecipe()))
|
|
105
|
+
.toThrow("Recipe 'recipe-1' is already registered");
|
|
116
106
|
});
|
|
117
107
|
|
|
118
|
-
it(
|
|
119
|
-
registry.register(makeRecipe({ id:
|
|
108
|
+
it('should throw on duplicate kind:variant', () => {
|
|
109
|
+
registry.register(makeRecipe({ id: 'r1' }));
|
|
120
110
|
|
|
121
|
-
expect(() => registry.register(makeRecipe({ id:
|
|
122
|
-
"Recipe variant 'default' for 'test.workflow' is already registered"
|
|
123
|
-
);
|
|
111
|
+
expect(() => registry.register(makeRecipe({ id: 'r2' })))
|
|
112
|
+
.toThrow("Recipe variant 'default' for 'test.workflow' is already registered");
|
|
124
113
|
});
|
|
125
114
|
|
|
126
|
-
it(
|
|
127
|
-
expect(registry.get(
|
|
128
|
-
expect(registry.has(
|
|
115
|
+
it('should return undefined for non-existent recipe', () => {
|
|
116
|
+
expect(registry.get('nonexistent')).toBeUndefined();
|
|
117
|
+
expect(registry.has('nonexistent')).toBe(false);
|
|
129
118
|
});
|
|
130
119
|
});
|
|
131
120
|
|
|
132
|
-
describe(
|
|
133
|
-
it(
|
|
121
|
+
describe('registerAll', () => {
|
|
122
|
+
it('should register multiple recipes', () => {
|
|
134
123
|
registry.registerAll([
|
|
135
|
-
makeRecipe({ id:
|
|
136
|
-
makeRecipe({ id:
|
|
124
|
+
makeRecipe({ id: 'r1', variant: 'fast' }),
|
|
125
|
+
makeRecipe({ id: 'r2', variant: 'slow' }),
|
|
137
126
|
]);
|
|
138
127
|
|
|
139
128
|
expect(registry.list()).toHaveLength(2);
|
|
140
129
|
});
|
|
141
130
|
});
|
|
142
131
|
|
|
143
|
-
describe(
|
|
144
|
-
it(
|
|
145
|
-
registry.register(makeRecipe({ id:
|
|
146
|
-
registry.register(makeRecipe({ id:
|
|
147
|
-
registry.register(
|
|
148
|
-
makeRecipe({ id: "r3", workflowKind: "other", variant: "default" }),
|
|
149
|
-
);
|
|
132
|
+
describe('getByKind', () => {
|
|
133
|
+
it('should return recipes matching a workflow kind', () => {
|
|
134
|
+
registry.register(makeRecipe({ id: 'r1', variant: 'v1' }));
|
|
135
|
+
registry.register(makeRecipe({ id: 'r2', variant: 'v2' }));
|
|
136
|
+
registry.register(makeRecipe({ id: 'r3', workflowKind: 'other', variant: 'default' }));
|
|
150
137
|
|
|
151
|
-
const results = registry.getByKind(
|
|
138
|
+
const results = registry.getByKind('test.workflow');
|
|
152
139
|
expect(results).toHaveLength(2);
|
|
153
140
|
});
|
|
154
141
|
|
|
155
|
-
it(
|
|
156
|
-
expect(registry.getByKind(
|
|
142
|
+
it('should return empty array for unknown kind', () => {
|
|
143
|
+
expect(registry.getByKind('unknown')).toEqual([]);
|
|
157
144
|
});
|
|
158
145
|
});
|
|
159
146
|
|
|
160
|
-
describe(
|
|
161
|
-
it(
|
|
162
|
-
registry.register(makeRecipe({ id:
|
|
163
|
-
registry.register(
|
|
164
|
-
makeRecipe({ id: "r2", workflowKind: "other", variant: "default" }),
|
|
165
|
-
);
|
|
147
|
+
describe('getVariant', () => {
|
|
148
|
+
it('should return recipe by kind and variant', () => {
|
|
149
|
+
registry.register(makeRecipe({ id: 'r1', variant: 'fast' }));
|
|
150
|
+
registry.register(makeRecipe({ id: 'r2', workflowKind: 'other', variant: 'default' }));
|
|
166
151
|
|
|
167
|
-
expect(registry.getVariant(
|
|
168
|
-
expect(
|
|
169
|
-
registry.getVariant("test.workflow", "nonexistent"),
|
|
170
|
-
).toBeUndefined();
|
|
152
|
+
expect(registry.getVariant('test.workflow', 'fast')?.id).toBe('r1');
|
|
153
|
+
expect(registry.getVariant('test.workflow', 'nonexistent')).toBeUndefined();
|
|
171
154
|
});
|
|
172
155
|
});
|
|
173
156
|
|
|
174
|
-
describe(
|
|
157
|
+
describe('getDefault', () => {
|
|
175
158
|
it('should return the "default" variant', () => {
|
|
176
|
-
registry.register(makeRecipe({ id:
|
|
177
|
-
registry.register(makeRecipe({ id:
|
|
159
|
+
registry.register(makeRecipe({ id: 'r1', variant: 'default' }));
|
|
160
|
+
registry.register(makeRecipe({ id: 'r2', variant: 'fast' }));
|
|
178
161
|
|
|
179
|
-
expect(registry.getDefault(
|
|
162
|
+
expect(registry.getDefault('test.workflow')?.id).toBe('r1');
|
|
180
163
|
});
|
|
181
164
|
|
|
182
|
-
it(
|
|
183
|
-
registry.register(
|
|
184
|
-
|
|
185
|
-
);
|
|
186
|
-
registry.register(makeRecipe({ id: "r2", variant: "slow", priority: 1 }));
|
|
165
|
+
it('should fall back to lowest priority recipe', () => {
|
|
166
|
+
registry.register(makeRecipe({ id: 'r1', variant: 'fast', priority: 10 }));
|
|
167
|
+
registry.register(makeRecipe({ id: 'r2', variant: 'slow', priority: 1 }));
|
|
187
168
|
|
|
188
|
-
expect(registry.getDefault(
|
|
169
|
+
expect(registry.getDefault('test.workflow')?.id).toBe('r2');
|
|
189
170
|
});
|
|
190
171
|
|
|
191
|
-
it(
|
|
192
|
-
expect(registry.getDefault(
|
|
172
|
+
it('should return undefined for unknown kind', () => {
|
|
173
|
+
expect(registry.getDefault('unknown')).toBeUndefined();
|
|
193
174
|
});
|
|
194
175
|
|
|
195
|
-
it(
|
|
196
|
-
registry.register(makeRecipe({ id:
|
|
176
|
+
it('should handle recipes without explicit priority', () => {
|
|
177
|
+
registry.register(makeRecipe({ id: 'r1', variant: 'v1' }));
|
|
197
178
|
|
|
198
|
-
expect(registry.getDefault(
|
|
179
|
+
expect(registry.getDefault('test.workflow')?.id).toBe('r1');
|
|
199
180
|
});
|
|
200
181
|
});
|
|
201
182
|
|
|
202
|
-
describe(
|
|
203
|
-
it(
|
|
204
|
-
registry.register(makeRecipe({ id:
|
|
205
|
-
registry.register(makeRecipe({ id:
|
|
183
|
+
describe('listVariants', () => {
|
|
184
|
+
it('should list all variants for a kind', () => {
|
|
185
|
+
registry.register(makeRecipe({ id: 'r1', variant: 'fast' }));
|
|
186
|
+
registry.register(makeRecipe({ id: 'r2', variant: 'slow' }));
|
|
206
187
|
|
|
207
|
-
expect(registry.listVariants(
|
|
208
|
-
"fast",
|
|
209
|
-
"slow",
|
|
210
|
-
]);
|
|
188
|
+
expect(registry.listVariants('test.workflow').sort()).toEqual(['fast', 'slow']);
|
|
211
189
|
});
|
|
212
190
|
});
|
|
213
191
|
|
|
214
|
-
describe(
|
|
192
|
+
describe('query', () => {
|
|
215
193
|
beforeEach(() => {
|
|
216
|
-
registry.register(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
registry.
|
|
228
|
-
makeRecipe({
|
|
229
|
-
id: "r3",
|
|
230
|
-
variant: "conditional",
|
|
231
|
-
workflowKind: "other",
|
|
232
|
-
conditions: [{ field: "size", operator: "gt", value: 100 }],
|
|
233
|
-
}),
|
|
234
|
-
);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("should filter by workflowKind", () => {
|
|
238
|
-
const results = registry.query({ workflowKind: "other" });
|
|
194
|
+
registry.register(makeRecipe({ id: 'r1', variant: 'fast', tags: ['ai'] }));
|
|
195
|
+
registry.register(makeRecipe({ id: 'r2', variant: 'slow', tags: ['legacy'], workflowKind: 'other' }));
|
|
196
|
+
registry.register(makeRecipe({
|
|
197
|
+
id: 'r3',
|
|
198
|
+
variant: 'conditional',
|
|
199
|
+
workflowKind: 'other',
|
|
200
|
+
conditions: [{ field: 'size', operator: 'gt', value: 100 }],
|
|
201
|
+
}));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should filter by workflowKind', () => {
|
|
205
|
+
const results = registry.query({ workflowKind: 'other' });
|
|
239
206
|
expect(results).toHaveLength(2);
|
|
240
207
|
});
|
|
241
208
|
|
|
242
|
-
it(
|
|
243
|
-
const results = registry.query({ variant:
|
|
209
|
+
it('should filter by variant', () => {
|
|
210
|
+
const results = registry.query({ variant: 'fast' });
|
|
244
211
|
expect(results).toHaveLength(1);
|
|
245
|
-
expect(results[0].id).toBe(
|
|
212
|
+
expect(results[0].id).toBe('r1');
|
|
246
213
|
});
|
|
247
214
|
|
|
248
|
-
it(
|
|
249
|
-
const results = registry.query({ tags: [
|
|
215
|
+
it('should filter by tags', () => {
|
|
216
|
+
const results = registry.query({ tags: ['ai'] });
|
|
250
217
|
expect(results).toHaveLength(1);
|
|
251
|
-
expect(results[0].id).toBe(
|
|
218
|
+
expect(results[0].id).toBe('r1');
|
|
252
219
|
});
|
|
253
220
|
|
|
254
|
-
it(
|
|
221
|
+
it('should filter by matchConditions', () => {
|
|
255
222
|
const results = registry.query({ matchConditions: { size: 200 } });
|
|
256
223
|
// r1 has no conditions (passes), r2 has no conditions (passes), r3 has size > 100 (passes)
|
|
257
224
|
expect(results).toHaveLength(3);
|
|
258
225
|
});
|
|
259
226
|
|
|
260
|
-
it(
|
|
227
|
+
it('should exclude recipes where conditions fail', () => {
|
|
261
228
|
const results = registry.query({ matchConditions: { size: 50 } });
|
|
262
229
|
// r3 has size > 100 which fails for 50
|
|
263
230
|
expect(results).toHaveLength(2);
|
|
264
|
-
expect(results.find(
|
|
231
|
+
expect(results.find(r => r.id === 'r3')).toBeUndefined();
|
|
265
232
|
});
|
|
266
233
|
});
|
|
267
234
|
|
|
268
|
-
describe(
|
|
269
|
-
const registerConditional = (
|
|
270
|
-
field: string,
|
|
271
|
-
operator: string,
|
|
272
|
-
value?: unknown,
|
|
273
|
-
) => {
|
|
235
|
+
describe('condition evaluation', () => {
|
|
236
|
+
const registerConditional = (field: string, operator: string, value?: unknown) => {
|
|
274
237
|
const id = `cond-${Math.random()}`;
|
|
275
|
-
registry.register(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}),
|
|
281
|
-
);
|
|
238
|
+
registry.register(makeRecipe({
|
|
239
|
+
id,
|
|
240
|
+
variant: id,
|
|
241
|
+
conditions: [{ field, operator: operator as any, value }],
|
|
242
|
+
}));
|
|
282
243
|
return id;
|
|
283
244
|
};
|
|
284
245
|
|
|
285
|
-
it(
|
|
286
|
-
const eqId = registerConditional(
|
|
287
|
-
const neqId = registerConditional(
|
|
246
|
+
it('should evaluate eq/neq', () => {
|
|
247
|
+
const eqId = registerConditional('x', 'eq', 5);
|
|
248
|
+
const neqId = registerConditional('x', 'neq', 5);
|
|
288
249
|
|
|
289
250
|
let results = registry.query({ matchConditions: { x: 5 } });
|
|
290
|
-
expect(results.find(
|
|
251
|
+
expect(results.find(r => r.id === eqId)).toBeDefined();
|
|
291
252
|
|
|
292
253
|
results = registry.query({ matchConditions: { x: 3 } });
|
|
293
|
-
expect(results.find(
|
|
294
|
-
expect(results.find(
|
|
254
|
+
expect(results.find(r => r.id === eqId)).toBeUndefined();
|
|
255
|
+
expect(results.find(r => r.id === neqId)).toBeDefined();
|
|
295
256
|
});
|
|
296
257
|
|
|
297
|
-
it(
|
|
298
|
-
const gtId = registerConditional(
|
|
299
|
-
const gteId = registerConditional(
|
|
300
|
-
const ltId = registerConditional(
|
|
301
|
-
const lteId = registerConditional(
|
|
258
|
+
it('should evaluate gt/gte/lt/lte', () => {
|
|
259
|
+
const gtId = registerConditional('n', 'gt', 10);
|
|
260
|
+
const gteId = registerConditional('n', 'gte', 10);
|
|
261
|
+
const ltId = registerConditional('n', 'lt', 10);
|
|
262
|
+
const lteId = registerConditional('n', 'lte', 10);
|
|
302
263
|
|
|
303
264
|
let results = registry.query({ matchConditions: { n: 15 } });
|
|
304
|
-
expect(results.find(
|
|
305
|
-
expect(results.find(
|
|
306
|
-
expect(results.find(
|
|
265
|
+
expect(results.find(r => r.id === gtId)).toBeDefined();
|
|
266
|
+
expect(results.find(r => r.id === gteId)).toBeDefined();
|
|
267
|
+
expect(results.find(r => r.id === ltId)).toBeUndefined();
|
|
307
268
|
|
|
308
269
|
results = registry.query({ matchConditions: { n: 10 } });
|
|
309
|
-
expect(results.find(
|
|
310
|
-
expect(results.find(
|
|
311
|
-
expect(results.find(
|
|
270
|
+
expect(results.find(r => r.id === gtId)).toBeUndefined();
|
|
271
|
+
expect(results.find(r => r.id === gteId)).toBeDefined();
|
|
272
|
+
expect(results.find(r => r.id === lteId)).toBeDefined();
|
|
312
273
|
|
|
313
274
|
results = registry.query({ matchConditions: { n: 5 } });
|
|
314
|
-
expect(results.find(
|
|
315
|
-
expect(results.find(
|
|
275
|
+
expect(results.find(r => r.id === ltId)).toBeDefined();
|
|
276
|
+
expect(results.find(r => r.id === lteId)).toBeDefined();
|
|
316
277
|
});
|
|
317
278
|
|
|
318
|
-
it(
|
|
319
|
-
const gtId = registerConditional(
|
|
279
|
+
it('should return false for gt/lt with non-number values', () => {
|
|
280
|
+
const gtId = registerConditional('x', 'gt', 10);
|
|
320
281
|
|
|
321
|
-
const results = registry.query({
|
|
322
|
-
|
|
323
|
-
});
|
|
324
|
-
expect(results.find((r) => r.id === gtId)).toBeUndefined();
|
|
282
|
+
const results = registry.query({ matchConditions: { x: 'not a number' } });
|
|
283
|
+
expect(results.find(r => r.id === gtId)).toBeUndefined();
|
|
325
284
|
});
|
|
326
285
|
|
|
327
|
-
it(
|
|
328
|
-
const id = registerConditional(
|
|
286
|
+
it('should evaluate contains for strings', () => {
|
|
287
|
+
const id = registerConditional('text', 'contains', 'hello');
|
|
329
288
|
|
|
330
|
-
let results = registry.query({
|
|
331
|
-
|
|
332
|
-
});
|
|
333
|
-
expect(results.find((r) => r.id === id)).toBeDefined();
|
|
289
|
+
let results = registry.query({ matchConditions: { text: 'say hello world' } });
|
|
290
|
+
expect(results.find(r => r.id === id)).toBeDefined();
|
|
334
291
|
|
|
335
|
-
results = registry.query({ matchConditions: { text:
|
|
336
|
-
expect(results.find(
|
|
292
|
+
results = registry.query({ matchConditions: { text: 'no match' } });
|
|
293
|
+
expect(results.find(r => r.id === id)).toBeUndefined();
|
|
337
294
|
});
|
|
338
295
|
|
|
339
|
-
it(
|
|
340
|
-
const id = registerConditional(
|
|
296
|
+
it('should evaluate contains for arrays', () => {
|
|
297
|
+
const id = registerConditional('tags', 'contains', 'important');
|
|
341
298
|
|
|
342
|
-
const results = registry.query({
|
|
343
|
-
|
|
344
|
-
});
|
|
345
|
-
expect(results.find((r) => r.id === id)).toBeDefined();
|
|
299
|
+
const results = registry.query({ matchConditions: { tags: ['important', 'other'] } });
|
|
300
|
+
expect(results.find(r => r.id === id)).toBeDefined();
|
|
346
301
|
});
|
|
347
302
|
|
|
348
|
-
it(
|
|
349
|
-
const id = registerConditional(
|
|
303
|
+
it('should return false for contains with non-string/non-array', () => {
|
|
304
|
+
const id = registerConditional('x', 'contains', 'test');
|
|
350
305
|
|
|
351
306
|
const results = registry.query({ matchConditions: { x: 42 } });
|
|
352
|
-
expect(results.find(
|
|
307
|
+
expect(results.find(r => r.id === id)).toBeUndefined();
|
|
353
308
|
});
|
|
354
309
|
|
|
355
|
-
it(
|
|
356
|
-
const id = registerConditional(
|
|
310
|
+
it('should evaluate matches (regex)', () => {
|
|
311
|
+
const id = registerConditional('name', 'matches', '^test-\\d+$');
|
|
357
312
|
|
|
358
|
-
let results = registry.query({ matchConditions: { name:
|
|
359
|
-
expect(results.find(
|
|
313
|
+
let results = registry.query({ matchConditions: { name: 'test-123' } });
|
|
314
|
+
expect(results.find(r => r.id === id)).toBeDefined();
|
|
360
315
|
|
|
361
|
-
results = registry.query({ matchConditions: { name:
|
|
362
|
-
expect(results.find(
|
|
316
|
+
results = registry.query({ matchConditions: { name: 'not-matching' } });
|
|
317
|
+
expect(results.find(r => r.id === id)).toBeUndefined();
|
|
363
318
|
});
|
|
364
319
|
|
|
365
|
-
it(
|
|
366
|
-
const id = registerConditional(
|
|
320
|
+
it('should return false for matches with invalid regex', () => {
|
|
321
|
+
const id = registerConditional('x', 'matches', '[invalid');
|
|
367
322
|
|
|
368
|
-
const results = registry.query({ matchConditions: { x:
|
|
369
|
-
expect(results.find(
|
|
323
|
+
const results = registry.query({ matchConditions: { x: 'anything' } });
|
|
324
|
+
expect(results.find(r => r.id === id)).toBeUndefined();
|
|
370
325
|
});
|
|
371
326
|
|
|
372
|
-
it(
|
|
373
|
-
const id = registerConditional(
|
|
327
|
+
it('should return false for matches with non-string field', () => {
|
|
328
|
+
const id = registerConditional('x', 'matches', '.*');
|
|
374
329
|
|
|
375
330
|
const results = registry.query({ matchConditions: { x: 42 } });
|
|
376
|
-
expect(results.find(
|
|
331
|
+
expect(results.find(r => r.id === id)).toBeUndefined();
|
|
377
332
|
});
|
|
378
333
|
|
|
379
|
-
it(
|
|
380
|
-
const existsId = registerConditional(
|
|
381
|
-
const notExistsId = registerConditional(
|
|
334
|
+
it('should evaluate exists/notExists', () => {
|
|
335
|
+
const existsId = registerConditional('field', 'exists');
|
|
336
|
+
const notExistsId = registerConditional('field', 'notExists');
|
|
382
337
|
|
|
383
|
-
let results = registry.query({ matchConditions: { field:
|
|
384
|
-
expect(results.find(
|
|
385
|
-
expect(results.find(
|
|
338
|
+
let results = registry.query({ matchConditions: { field: 'present' } });
|
|
339
|
+
expect(results.find(r => r.id === existsId)).toBeDefined();
|
|
340
|
+
expect(results.find(r => r.id === notExistsId)).toBeUndefined();
|
|
386
341
|
|
|
387
|
-
results = registry.query({ matchConditions: { field:
|
|
388
|
-
expect(results.find(
|
|
389
|
-
expect(results.find(
|
|
342
|
+
results = registry.query({ matchConditions: { field: '' } });
|
|
343
|
+
expect(results.find(r => r.id === existsId)).toBeUndefined();
|
|
344
|
+
expect(results.find(r => r.id === notExistsId)).toBeDefined();
|
|
390
345
|
|
|
391
346
|
results = registry.query({ matchConditions: { field: null } });
|
|
392
|
-
expect(results.find(
|
|
393
|
-
expect(results.find(
|
|
347
|
+
expect(results.find(r => r.id === existsId)).toBeUndefined();
|
|
348
|
+
expect(results.find(r => r.id === notExistsId)).toBeDefined();
|
|
394
349
|
|
|
395
350
|
results = registry.query({ matchConditions: {} });
|
|
396
|
-
expect(results.find(
|
|
397
|
-
expect(results.find(
|
|
351
|
+
expect(results.find(r => r.id === existsId)).toBeUndefined();
|
|
352
|
+
expect(results.find(r => r.id === notExistsId)).toBeDefined();
|
|
398
353
|
});
|
|
399
354
|
|
|
400
|
-
it(
|
|
401
|
-
const id = registerConditional(
|
|
355
|
+
it('should handle nested field paths', () => {
|
|
356
|
+
const id = registerConditional('a.b.c', 'eq', 42);
|
|
402
357
|
|
|
403
|
-
let results = registry.query({
|
|
404
|
-
|
|
405
|
-
});
|
|
406
|
-
expect(results.find((r) => r.id === id)).toBeDefined();
|
|
358
|
+
let results = registry.query({ matchConditions: { a: { b: { c: 42 } } } });
|
|
359
|
+
expect(results.find(r => r.id === id)).toBeDefined();
|
|
407
360
|
|
|
408
361
|
results = registry.query({ matchConditions: { a: { b: { c: 99 } } } });
|
|
409
|
-
expect(results.find(
|
|
362
|
+
expect(results.find(r => r.id === id)).toBeUndefined();
|
|
410
363
|
});
|
|
411
364
|
|
|
412
|
-
it(
|
|
413
|
-
const id = registerConditional(
|
|
365
|
+
it('should return undefined for missing nested path segments', () => {
|
|
366
|
+
const id = registerConditional('a.b.c', 'exists');
|
|
414
367
|
|
|
415
|
-
const results = registry.query({ matchConditions: { a:
|
|
416
|
-
expect(results.find(
|
|
368
|
+
const results = registry.query({ matchConditions: { a: 'not-object' } });
|
|
369
|
+
expect(results.find(r => r.id === id)).toBeUndefined();
|
|
417
370
|
});
|
|
418
371
|
|
|
419
|
-
it(
|
|
420
|
-
const id = registerConditional(
|
|
372
|
+
it('should return false for unknown operator', () => {
|
|
373
|
+
const id = registerConditional('x', 'unknownOp' as any, 1);
|
|
421
374
|
|
|
422
375
|
const results = registry.query({ matchConditions: { x: 1 } });
|
|
423
|
-
expect(results.find(
|
|
376
|
+
expect(results.find(r => r.id === id)).toBeUndefined();
|
|
424
377
|
});
|
|
425
378
|
});
|
|
426
379
|
|
|
427
|
-
describe(
|
|
428
|
-
it(
|
|
429
|
-
registry.register(makeRecipe({ id:
|
|
380
|
+
describe('clear', () => {
|
|
381
|
+
it('should clear all registries', () => {
|
|
382
|
+
registry.register(makeRecipe({ id: 'r1', variant: 'v1', tags: ['t1'] }));
|
|
430
383
|
registry.clear();
|
|
431
384
|
|
|
432
385
|
expect(registry.list()).toHaveLength(0);
|
|
433
|
-
expect(registry.getByKind(
|
|
434
|
-
expect(registry.listVariants(
|
|
386
|
+
expect(registry.getByKind('test.workflow')).toEqual([]);
|
|
387
|
+
expect(registry.listVariants('test.workflow')).toEqual([]);
|
|
435
388
|
});
|
|
436
389
|
});
|
|
437
390
|
});
|
|
438
391
|
|
|
439
|
-
describe(
|
|
440
|
-
it(
|
|
392
|
+
describe('createRegistry', () => {
|
|
393
|
+
it('should return a combined registry', () => {
|
|
441
394
|
const reg = createRegistry();
|
|
442
395
|
expect(reg.recipes).toBeInstanceOf(MemoryRecipeRegistry);
|
|
443
396
|
expect(reg.handlers).toBeInstanceOf(MemoryStepHandlerRegistry);
|