Skip to content

Commit 86ca44b

Browse files
committed
feat: asymmetric handler signatures for step/array methods
- Root steps receive flowInput directly (no run key wrapper) - Dependent steps receive deps object only, flowInput via context - Add flowInput to FlowContext type - Update all type tests and example flows - Update edge-worker and client packages for compatibility
1 parent a13db51 commit 86ca44b

27 files changed

+512
-498
lines changed

pkgs/client/__tests__/types/client-basic.test-d.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ const AnalyzeWebsite = new Flow<{ url: string }>({
2020
baseDelay: 5,
2121
timeout: 10,
2222
})
23-
.step({ slug: 'website' }, (input) => ({
24-
content: `Content for ${input.run.url}`,
23+
.step({ slug: 'website' }, (flowInput) => ({
24+
content: `Content for ${flowInput.url}`,
2525
}))
2626
.step({ slug: 'sentiment', dependsOn: ['website'] }, (_input) => ({
2727
score: 0.75,
@@ -150,12 +150,16 @@ describe('PgflowClient Type Tests', () => {
150150
.resolves.toHaveProperty('output')
151151
.toEqualTypeOf<SentimentOutput | null>();
152152

153-
// Check input type for a step
153+
// Check input type for a step - dependent steps only get their deps (no run key)
154+
// flowInput is available via context.flowInput for dependent steps
154155
type SentimentInput = StepInput<typeof AnalyzeWebsite, 'sentiment'>;
155156
expectTypeOf<SentimentInput>().toMatchTypeOf<{
156-
run: { url: string };
157157
website: { content: string };
158158
}>();
159+
// Verify it does NOT have run key (asymmetric input)
160+
expectTypeOf<SentimentInput>().not.toMatchTypeOf<{
161+
run: { url: string };
162+
}>();
159163
});
160164

161165
it('should properly type event subscription', async () => {

pkgs/client/__tests__/unit/concurrent-operations.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { RUN_ID, FLOW_SLUG, STEP_SLUG, startedRunSnapshot, stepStatesSample } fr
2323
// Create a test flow for proper typing
2424
const TestFlow = new Flow<{ test: string }>({ slug: 'test_flow' }).step(
2525
{ slug: 'test_step' },
26-
(input) => ({ result: input.run.test })
26+
(flowInput) => ({ result: flowInput.test })
2727
);
2828

2929
// Mock uuid to return predictable IDs

pkgs/dsl/__tests__/types/array-method.test-d.ts

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -47,49 +47,48 @@ describe('.array() method type constraints', () => {
4747
describe('type inference', () => {
4848
it('should provide correct input types for dependent steps', () => {
4949
new Flow<{ count: number }>({ slug: 'test' })
50-
.array({ slug: 'items' }, ({ run }) => Array(run.count).fill(0).map((_, i) => i))
51-
.step({ slug: 'process', dependsOn: ['items'] }, (input) => {
52-
expectTypeOf(input).toMatchTypeOf<{
53-
run: { count: number };
50+
.array({ slug: 'items' }, (flowInput) => Array(flowInput.count).fill(0).map((_, i) => i))
51+
.step({ slug: 'process', dependsOn: ['items'] }, (deps) => {
52+
expectTypeOf(deps).toMatchTypeOf<{
5453
items: number[];
5554
}>();
56-
return input.items.length;
55+
return deps.items.length;
5756
});
5857
});
5958

6059
it('should correctly infer element types from arrays', () => {
6160
new Flow<{ userId: string }>({ slug: 'test' })
6261
.array({ slug: 'users' }, () => [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }])
63-
.step({ slug: 'count_users', dependsOn: ['users'] }, (input) => {
64-
expectTypeOf(input.users).toEqualTypeOf<{ id: number; name: string }[]>();
65-
expectTypeOf(input.users[0]).toMatchTypeOf<{ id: number; name: string }>();
66-
return input.users.length;
62+
.step({ slug: 'count_users', dependsOn: ['users'] }, (deps) => {
63+
expectTypeOf(deps.users).toEqualTypeOf<{ id: number; name: string }[]>();
64+
expectTypeOf(deps.users[0]).toMatchTypeOf<{ id: number; name: string }>();
65+
return deps.users.length;
6766
});
6867
});
6968

7069
it('should handle complex nested array types', () => {
7170
new Flow<{ depth: number }>({ slug: 'test' })
72-
.array({ slug: 'matrix' }, ({ run }) =>
73-
Array(run.depth).fill(0).map(() => Array(3).fill(0).map(() => ({ value: Math.random() })))
71+
.array({ slug: 'matrix' }, (flowInput) =>
72+
Array(flowInput.depth).fill(0).map(() => Array(3).fill(0).map(() => ({ value: Math.random() })))
7473
)
75-
.step({ slug: 'flatten', dependsOn: ['matrix'] }, (input) => {
76-
expectTypeOf(input.matrix).toEqualTypeOf<{ value: number }[][]>();
77-
expectTypeOf(input.matrix[0]).toEqualTypeOf<{ value: number }[]>();
78-
expectTypeOf(input.matrix[0][0]).toMatchTypeOf<{ value: number }>();
79-
return input.matrix.flat();
74+
.step({ slug: 'flatten', dependsOn: ['matrix'] }, (deps) => {
75+
expectTypeOf(deps.matrix).toEqualTypeOf<{ value: number }[][]>();
76+
expectTypeOf(deps.matrix[0]).toEqualTypeOf<{ value: number }[]>();
77+
expectTypeOf(deps.matrix[0][0]).toMatchTypeOf<{ value: number }>();
78+
return deps.matrix.flat();
8079
});
8180
});
8281

8382
it('should correctly type async array handlers', () => {
8483
new Flow<{ url: string }>({ slug: 'test' })
85-
.array({ slug: 'data' }, async ({ run }) => {
84+
.array({ slug: 'data' }, async (flowInput) => {
8685
// Simulate async data fetching
8786
await new Promise(resolve => setTimeout(resolve, 1));
88-
return [{ url: run.url, status: 200 }];
87+
return [{ url: flowInput.url, status: 200 }];
8988
})
90-
.step({ slug: 'validate', dependsOn: ['data'] }, (input) => {
91-
expectTypeOf(input.data).toEqualTypeOf<{ url: string; status: number }[]>();
92-
return input.data.every(item => item.status === 200);
89+
.step({ slug: 'validate', dependsOn: ['data'] }, (deps) => {
90+
expectTypeOf(deps.data).toEqualTypeOf<{ url: string; status: number }[]>();
91+
return deps.data.every(item => item.status === 200);
9392
});
9493
});
9594
});
@@ -106,33 +105,31 @@ describe('.array() method type constraints', () => {
106105
new Flow<string>({ slug: 'test' })
107106
.array({ slug: 'items1' }, () => [1, 2, 3])
108107
.array({ slug: 'items2' }, () => ['a', 'b', 'c'])
109-
.array({ slug: 'combined', dependsOn: ['items1'] }, (input) => {
110-
expectTypeOf(input).toMatchTypeOf<{
111-
run: string;
108+
.array({ slug: 'combined', dependsOn: ['items1'] }, (deps) => {
109+
expectTypeOf(deps).toMatchTypeOf<{
112110
items1: number[];
113111
}>();
114112

115113
// Verify that items2 is not accessible
116-
expectTypeOf(input).not.toHaveProperty('items2');
114+
expectTypeOf(deps).not.toHaveProperty('items2');
117115

118-
return input.items1.map(String);
116+
return deps.items1.map(String);
119117
});
120118
});
121119

122120
it('should correctly type multi-dependency array steps', () => {
123121
new Flow<{ base: number }>({ slug: 'test' })
124-
.array({ slug: 'numbers' }, ({ run }) => [run.base, run.base + 1])
122+
.array({ slug: 'numbers' }, (flowInput) => [flowInput.base, flowInput.base + 1])
125123
.array({ slug: 'letters' }, () => ['a', 'b'])
126-
.array({ slug: 'combined', dependsOn: ['numbers', 'letters'] }, (input) => {
127-
expectTypeOf(input).toMatchTypeOf<{
128-
run: { base: number };
124+
.array({ slug: 'combined', dependsOn: ['numbers', 'letters'] }, (deps) => {
125+
expectTypeOf(deps).toMatchTypeOf<{
129126
numbers: number[];
130127
letters: string[];
131128
}>();
132-
133-
return input.numbers.map((num, i) => ({
129+
130+
return deps.numbers.map((num, i) => ({
134131
number: num,
135-
letter: input.letters[i] || 'z'
132+
letter: deps.letters[i] || 'z'
136133
}));
137134
});
138135
});
@@ -142,13 +139,13 @@ describe('.array() method type constraints', () => {
142139
it('should provide custom context via Flow type parameter', () => {
143140
// eslint-disable-next-line @typescript-eslint/no-explicit-any
144141
const flow = new Flow<{ id: number }, { api: { get: (id: number) => Promise<any> } }>({ slug: 'test' })
145-
.array({ slug: 'fetch_data' }, (input, context) => {
142+
.array({ slug: 'fetch_data' }, (flowInput, context) => {
146143
// No handler annotation needed! Type parameter provides context
147144
expectTypeOf(context.api).toEqualTypeOf<{ get: (id: number) => Promise<any> }>();
148145
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
149146
expectTypeOf(context.shutdownSignal).toEqualTypeOf<AbortSignal>();
150147

151-
return [{ id: input.run.id, data: 'mock' }];
148+
return [{ id: flowInput.id, data: 'mock' }];
152149
});
153150

154151
// ExtractFlowContext should include FlowContext & custom resources
@@ -164,11 +161,11 @@ describe('.array() method type constraints', () => {
164161

165162
it('should share custom context across array and regular steps', () => {
166163
const flow = new Flow<{ count: number }, { generator: () => number; processor: (items: number[]) => string }>({ slug: 'test' })
167-
.array({ slug: 'items' }, (input, context) => {
164+
.array({ slug: 'items' }, (flowInput, context) => {
168165
// All steps get the same context automatically
169-
return Array(input.run.count).fill(0).map(() => context.generator());
166+
return Array(flowInput.count).fill(0).map(() => context.generator());
170167
})
171-
.step({ slug: 'process' }, (input, context) => {
168+
.step({ slug: 'process' }, (flowInput, context) => {
172169
return context.processor([1, 2, 3]);
173170
});
174171

@@ -187,21 +184,21 @@ describe('.array() method type constraints', () => {
187184
describe('handler signature validation', () => {
188185
it('should correctly type array step handlers when using getStepDefinition', () => {
189186
const flow = new Flow<{ size: number }>({ slug: 'test' })
190-
.array({ slug: 'data' }, (input, _context) => Array(input.run.size).fill(0).map((_, i) => ({ index: i })))
191-
.step({ slug: 'dependent', dependsOn: ['data'] }, (input, _context) => input.data.length);
187+
.array({ slug: 'data' }, (flowInput, _context) => Array(flowInput.size).fill(0).map((_, i) => ({ index: i })))
188+
.step({ slug: 'dependent', dependsOn: ['data'] }, (deps, _context) => deps.data.length);
192189

193190
const arrayStep = flow.getStepDefinition('data');
194191

195-
// Test array step handler type - handlers have 2 params (input, context)
192+
// Test array step handler type - root steps receive flowInput directly (no run key)
196193
expectTypeOf(arrayStep.handler).toBeFunction();
197-
expectTypeOf(arrayStep.handler).parameter(0).toMatchTypeOf<{ run: { size: number } }>();
194+
expectTypeOf(arrayStep.handler).parameter(0).toMatchTypeOf<{ size: number }>();
198195
expectTypeOf(arrayStep.handler).returns.toMatchTypeOf<
199196
{ index: number }[] | Promise<{ index: number }[]>
200197
>();
201198

202199
const dependentStep = flow.getStepDefinition('dependent');
200+
// Dependent steps receive deps only (no run key)
203201
expectTypeOf(dependentStep.handler).parameter(0).toMatchTypeOf<{
204-
run: { size: number };
205202
data: { index: number }[];
206203
}>();
207204
});
@@ -213,20 +210,20 @@ describe('.array() method type constraints', () => {
213210
.array({ slug: 'items' }, () => [{ id: 1 }, { id: 2 }])
214211
.array({ slug: 'processed', dependsOn: ['items'] }, () => ['a', 'b']);
215212

216-
// Test StepInput type extraction
213+
// Test StepInput type extraction - root steps get flow input directly
217214
type ItemsInput = StepInput<typeof flow, 'items'>;
218215
expectTypeOf<ItemsInput>().toMatchTypeOf<{
219-
run: { userId: string };
216+
userId: string;
220217
}>();
221218

219+
// Dependent steps get deps only (no run key)
222220
type ProcessedInput = StepInput<typeof flow, 'processed'>;
223221
expectTypeOf<ProcessedInput>().toMatchTypeOf<{
224-
run: { userId: string };
225222
items: { id: number }[];
226223
}>();
227224

228225
// Should not contain non-dependencies
229226
expectTypeOf<ProcessedInput>().not.toHaveProperty('nonExistent');
230227
});
231228
});
232-
});
229+
});

pkgs/dsl/__tests__/types/context-inference.test-d.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface TestRedis {
1515
describe('Context Type Inference Tests', () => {
1616
it('should have FlowContext by default (no custom resources)', () => {
1717
const flow = new Flow({ slug: 'minimal_flow' })
18-
.step({ slug: 'process' }, (input, context) => {
18+
.step({ slug: 'process' }, (flowInput, context) => {
1919
// Handler automatically gets FlowContext (no annotation needed!)
2020
expectTypeOf(context).toMatchTypeOf<FlowContext>();
2121
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
@@ -33,7 +33,7 @@ describe('Context Type Inference Tests', () => {
3333

3434
it('should provide custom context via Flow type parameter', () => {
3535
const flow = new Flow<Json, { sql: TestSql }>({ slug: 'custom_context' })
36-
.step({ slug: 'query' }, (input, context) => {
36+
.step({ slug: 'query' }, (flowInput, context) => {
3737
// No handler annotation needed! Type parameter provides context
3838
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
3939
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
@@ -49,13 +49,13 @@ describe('Context Type Inference Tests', () => {
4949

5050
it('should share custom context across all steps', () => {
5151
const flow = new Flow<Json, { sql: TestSql; redis: TestRedis }>({ slug: 'shared_context' })
52-
.step({ slug: 'query' }, (input, context) => {
52+
.step({ slug: 'query' }, (flowInput, context) => {
5353
// All steps get the same context automatically
5454
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
5555
expectTypeOf(context.redis).toEqualTypeOf<TestRedis>();
5656
return { users: [] };
5757
})
58-
.step({ slug: 'cache' }, (input, context) => {
58+
.step({ slug: 'cache' }, (flowInput, context) => {
5959
// Second step also has access to all resources
6060
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
6161
expectTypeOf(context.redis).toEqualTypeOf<TestRedis>();
@@ -72,20 +72,21 @@ describe('Context Type Inference Tests', () => {
7272

7373
it('should preserve existing step type inference while adding context', () => {
7474
const flow = new Flow<{ initial: number }, { multiplier: number }>({ slug: 'step_chain' })
75-
.step({ slug: 'double' }, (input, context) => {
76-
// Input inference still works
77-
expectTypeOf(input.run.initial).toEqualTypeOf<number>();
75+
.step({ slug: 'double' }, (flowInput, context) => {
76+
// Input inference still works - root step gets flow input directly
77+
expectTypeOf(flowInput.initial).toEqualTypeOf<number>();
7878
// Custom context available
7979
expectTypeOf(context.multiplier).toEqualTypeOf<number>();
80-
return { doubled: input.run.initial * 2 };
80+
return { doubled: flowInput.initial * 2 };
8181
})
82-
.step({ slug: 'format', dependsOn: ['double'] }, (input, context) => {
83-
// Dependent step has access to previous step output
84-
expectTypeOf(input.run.initial).toEqualTypeOf<number>();
85-
expectTypeOf(input.double.doubled).toEqualTypeOf<number>();
82+
.step({ slug: 'format', dependsOn: ['double'] }, (deps, context) => {
83+
// Dependent step has access to previous step output via deps
84+
expectTypeOf(deps.double.doubled).toEqualTypeOf<number>();
8685
// And still has custom context
8786
expectTypeOf(context.multiplier).toEqualTypeOf<number>();
88-
return { formatted: String(input.double.doubled) };
87+
// Access flow input via context.flowInput
88+
expectTypeOf(context.flowInput.initial).toEqualTypeOf<number>();
89+
return { formatted: String(deps.double.doubled) };
8990
});
9091

9192
// Context includes custom resources

0 commit comments

Comments
 (0)