Skip to content

Commit 01544a2

Browse files
committed
Enforce JSON-safe step outputs without narrowing inference
1 parent c71eb84 commit 01544a2

12 files changed

+330
-61
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pgflow/dsl': patch
3+
---
4+
5+
Enforce JSON-compatible step outputs at .step() construction time
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@pgflow/dsl': patch
3+
'@pgflow/edge-worker': patch
4+
---
5+
6+
Make skippable leaf step keys optional in ExtractFlowOutput type

pkgs/dsl/__tests__/types/extract-flow-output.test-d.ts

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,16 @@ describe('ExtractFlowOutput utility type', () => {
112112
.step({ slug: 'step3', dependsOn: ['step1'] }, (deps) => ({
113113
value: deps.step1.value - 1,
114114
}))
115-
.step({ slug: 'step4', dependsOn: ['step2', 'step3'] }, async (deps, ctx) => {
116-
const flowInput = await ctx.flowInput;
117-
return {
118-
sum: deps.step2.value + deps.step3.value,
119-
original: flowInput.input,
120-
};
121-
});
115+
.step(
116+
{ slug: 'step4', dependsOn: ['step2', 'step3'] },
117+
async (deps, ctx) => {
118+
const flowInput = await ctx.flowInput;
119+
return {
120+
sum: deps.step2.value + deps.step3.value,
121+
original: flowInput.input,
122+
};
123+
}
124+
);
122125

123126
type FlowOutput = ExtractFlowOutput<typeof complexFlow>;
124127

@@ -138,4 +141,79 @@ describe('ExtractFlowOutput utility type', () => {
138141
step3: unknown;
139142
}>();
140143
});
144+
145+
it('makes skippable leaf steps optional in flow output', () => {
146+
const skippableLeafFlow = new Flow<{ input: string }>({
147+
slug: 'skippable_leaf_flow',
148+
})
149+
.step({ slug: 'prepare' }, (flowInput) => ({ text: flowInput.input }))
150+
.step(
151+
{
152+
slug: 'leaf_optional',
153+
dependsOn: ['prepare'],
154+
if: { prepare: { text: 'run' } },
155+
whenUnmet: 'skip',
156+
},
157+
(deps) => ({ value: deps.prepare.text.toUpperCase() })
158+
);
159+
160+
type FlowOutput = ExtractFlowOutput<typeof skippableLeafFlow>;
161+
162+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
163+
leaf_optional?: StepOutput<typeof skippableLeafFlow, 'leaf_optional'>;
164+
}>();
165+
166+
expectTypeOf<{
167+
leaf_optional?: StepOutput<typeof skippableLeafFlow, 'leaf_optional'>;
168+
}>().toMatchTypeOf<FlowOutput>();
169+
});
170+
171+
it('keeps non-skippable leaf steps required in flow output', () => {
172+
const requiredLeafFlow = new Flow<{ input: string }>({
173+
slug: 'required_leaf_flow',
174+
}).step({ slug: 'leaf_required' }, (flowInput) => ({
175+
value: flowInput.input.length,
176+
}));
177+
178+
type FlowOutput = ExtractFlowOutput<typeof requiredLeafFlow>;
179+
180+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
181+
leaf_required: StepOutput<typeof requiredLeafFlow, 'leaf_required'>;
182+
}>();
183+
184+
expectTypeOf<{
185+
leaf_required: StepOutput<typeof requiredLeafFlow, 'leaf_required'>;
186+
}>().toMatchTypeOf<FlowOutput>();
187+
});
188+
189+
it('supports mixed required and skippable leaf outputs', () => {
190+
const mixedLeafFlow = new Flow<{ input: string }>({
191+
slug: 'mixed_leaf_flow',
192+
})
193+
.step({ slug: 'prepare' }, (flowInput) => ({ text: flowInput.input }))
194+
.step({ slug: 'required_leaf', dependsOn: ['prepare'] }, (deps) => ({
195+
value: deps.prepare.text.length,
196+
}))
197+
.step(
198+
{
199+
slug: 'optional_leaf',
200+
dependsOn: ['prepare'],
201+
if: { prepare: { text: 'run' } },
202+
whenUnmet: 'skip-cascade',
203+
},
204+
(deps) => ({ value: deps.prepare.text.toUpperCase() })
205+
);
206+
207+
type FlowOutput = ExtractFlowOutput<typeof mixedLeafFlow>;
208+
209+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
210+
required_leaf: StepOutput<typeof mixedLeafFlow, 'required_leaf'>;
211+
optional_leaf?: StepOutput<typeof mixedLeafFlow, 'optional_leaf'>;
212+
}>();
213+
214+
expectTypeOf<{
215+
required_leaf: StepOutput<typeof mixedLeafFlow, 'required_leaf'>;
216+
optional_leaf?: StepOutput<typeof mixedLeafFlow, 'optional_leaf'>;
217+
}>().toMatchTypeOf<FlowOutput>();
218+
});
141219
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Flow } from '../../src/index.js';
2+
import { describe, expectTypeOf, it } from 'vitest';
3+
4+
interface FinalizeContextOutput {
5+
sessionId: string;
6+
sonioxTranscriptionId: string;
7+
isTerminal: boolean;
8+
}
9+
10+
describe('.step() supports interface DTO outputs', () => {
11+
it('keeps dependent step input precise for interface-based output', () => {
12+
new Flow<{ id: string }>({ slug: 'interface-output-flow' })
13+
.step({ slug: 'finalizeContext' }, async () => {
14+
const result: FinalizeContextOutput = {
15+
sessionId: 's1',
16+
sonioxTranscriptionId: 't1',
17+
isTerminal: false,
18+
};
19+
20+
return result;
21+
})
22+
.step(
23+
{
24+
slug: 'next',
25+
dependsOn: ['finalizeContext'],
26+
if: { finalizeContext: { isTerminal: false } },
27+
},
28+
(deps) => {
29+
expectTypeOf(deps.finalizeContext.sessionId).toEqualTypeOf<string>();
30+
expectTypeOf(
31+
deps.finalizeContext.sonioxTranscriptionId
32+
).toEqualTypeOf<string>();
33+
34+
return { ok: true };
35+
}
36+
);
37+
});
38+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Flow, type StepOutput } from '../../src/index.js';
2+
import { describe, expectTypeOf, it } from 'vitest';
3+
4+
interface InterfaceJsonDto {
5+
sessionId: string;
6+
isTerminal: boolean;
7+
}
8+
9+
describe('.step() JSON output constraints', () => {
10+
it('accepts JSON-compatible step outputs', () => {
11+
new Flow<{ x: string }>({ slug: 'test' })
12+
.step({ slug: 'valid1' }, () => ({ ok: true }))
13+
.step({ slug: 'valid2' }, () => ({ maybe: null as string | null }))
14+
.step({ slug: 'valid3' }, () => ['a', 'b', 'c'])
15+
.step({ slug: 'valid4' }, () => null)
16+
.step({ slug: 'valid5' }, () => 123)
17+
.step({ slug: 'valid6' }, () => {
18+
const dto: InterfaceJsonDto = {
19+
sessionId: 's1',
20+
isTerminal: false,
21+
};
22+
23+
return dto;
24+
})
25+
.step({ slug: 'valid7' }, (): { entryId?: string } => ({
26+
entryId: 'entry-1',
27+
}))
28+
.step({ slug: 'valid8' }, (): { entryId?: string } => ({}))
29+
// implied optional via inferred branch return (not explicit annotation)
30+
.step({ slug: 'valid9' }, () =>
31+
Math.random() > 0.5 ? { entryId: 'entry-1' } : {}
32+
);
33+
});
34+
35+
it('preserves widened output inference for step outputs', () => {
36+
const flow = new Flow<{ x: string }>({ slug: 'test_widened' }).step(
37+
{ slug: 'numberStep' },
38+
() => 123
39+
);
40+
41+
expectTypeOf<
42+
StepOutput<typeof flow, 'numberStep'>
43+
>().toEqualTypeOf<number>();
44+
});
45+
46+
it('rejects undefined in step outputs', () => {
47+
new Flow<{ x: string }>({ slug: 'test_invalid' })
48+
// @ts-expect-error - undefined is not Json
49+
.step({ slug: 'invalid1' }, () => undefined)
50+
// @ts-expect-error - property type includes undefined (not Json)
51+
.step({ slug: 'invalid2' }, () => ({
52+
ok: true,
53+
maybe: undefined as string | undefined,
54+
}))
55+
// @ts-expect-error - implied branch includes explicit undefined value
56+
.step({ slug: 'invalid5' }, () =>
57+
Math.random() > 0.5 ? { entryId: 'entry-1' } : { entryId: undefined }
58+
)
59+
// @ts-expect-error - top-level undefined branch is not Json
60+
.step({ slug: 'invalid6' }, () =>
61+
Math.random() > 0.5 ? { entryId: 'entry-1' } : undefined
62+
);
63+
});
64+
65+
it('rejects other non-JSON output types', () => {
66+
new Flow<{ x: string }>({ slug: 'test_invalid_other' })
67+
// @ts-expect-error - symbol is not Json
68+
.step({ slug: 'invalid3' }, () => Symbol('x'))
69+
// @ts-expect-error - function is not Json
70+
.step({ slug: 'invalid4' }, () => () => 'nope');
71+
});
72+
});

0 commit comments

Comments
 (0)