Skip to content

Commit 86a8ccf

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

11 files changed

+246
-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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Flow, type StepOutput } from '../../src/index.js';
2+
import { describe, expectTypeOf, it } from 'vitest';
3+
4+
describe('.step() JSON output constraints', () => {
5+
it('accepts JSON-compatible step outputs', () => {
6+
new Flow<{ x: string }>({ slug: 'test' })
7+
.step({ slug: 'valid1' }, () => ({ ok: true }))
8+
.step({ slug: 'valid2' }, () => ({ maybe: null as string | null }))
9+
.step({ slug: 'valid3' }, () => ['a', 'b', 'c'])
10+
.step({ slug: 'valid4' }, () => null)
11+
.step({ slug: 'valid5' }, () => 123);
12+
});
13+
14+
it('preserves widened output inference for step outputs', () => {
15+
const flow = new Flow<{ x: string }>({ slug: 'test_widened' }).step(
16+
{ slug: 'numberStep' },
17+
() => 123
18+
);
19+
20+
expectTypeOf<
21+
StepOutput<typeof flow, 'numberStep'>
22+
>().toEqualTypeOf<number>();
23+
});
24+
25+
it('rejects undefined in step outputs', () => {
26+
new Flow<{ x: string }>({ slug: 'test_invalid' })
27+
// @ts-expect-error - undefined is not Json
28+
.step({ slug: 'invalid1' }, () => undefined)
29+
// @ts-expect-error - property type includes undefined (not Json)
30+
.step({ slug: 'invalid2' }, () => ({
31+
ok: true,
32+
maybe: undefined as string | undefined,
33+
}));
34+
});
35+
36+
it('rejects other non-JSON output types', () => {
37+
new Flow<{ x: string }>({ slug: 'test_invalid_other' })
38+
// @ts-expect-error - symbol is not Json
39+
.step({ slug: 'invalid3' }, () => Symbol('x'))
40+
// @ts-expect-error - function is not Json
41+
.step({ slug: 'invalid4' }, () => () => 'nope');
42+
});
43+
});

pkgs/dsl/src/dsl.ts

Lines changed: 59 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ type AwaitedReturn<T> = T extends (...args: any[]) => Promise<infer R>
4040
? R
4141
: never;
4242

43+
type WidenJson<T> = T extends string
44+
? string
45+
: T extends number
46+
? number
47+
: T extends boolean
48+
? boolean
49+
: T extends null
50+
? null
51+
: T extends readonly (infer U)[]
52+
? WidenJson<U>[]
53+
: T extends object
54+
? { [K in keyof T]: WidenJson<T[K]> }
55+
: T;
56+
4357
// ========================
4458
// ENVIRONMENT TYPE SYSTEM
4559
// ========================
@@ -144,8 +158,16 @@ export type ExtractFlowOutput<TFlow extends AnyFlow> = TFlow extends Flow<
144158
>
145159
? {
146160
[K in keyof ExtractFlowLeafSteps<TFlow> as K extends string
147-
? K
161+
? GetSkippableMode<TFlow, K> extends false
162+
? K
163+
: never
148164
: never]: StepOutput<TFlow, K & string>;
165+
} & {
166+
[K in keyof ExtractFlowLeafSteps<TFlow> as K extends string
167+
? GetSkippableMode<TFlow, K> extends false
168+
? never
169+
: K
170+
: never]?: StepOutput<TFlow, K & string>;
149171
}
150172
: never;
151173

@@ -716,7 +738,10 @@ export class Flow<
716738
// Overload 1: Root step without conditions
717739
step<
718740
Slug extends string,
719-
TOutput,
741+
THandler extends (
742+
flowInput: TFlowInput,
743+
context: FlowContext<TEnv, TFlowInput> & TContext
744+
) => Json | Promise<Json>,
720745
TRetries extends WhenExhaustedMode | undefined = undefined
721746
>(
722747
opts: Simplify<
@@ -727,16 +752,13 @@ export class Flow<
727752
} & WithoutCondition &
728753
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
729754
>,
730-
handler: (
731-
flowInput: TFlowInput,
732-
context: FlowContext<TEnv, TFlowInput> & TContext
733-
) => TOutput | Promise<TOutput>
755+
handler: THandler
734756
): Flow<
735757
TFlowInput,
736758
TContext,
737759
Steps & {
738760
[K in Slug]: StepMeta<
739-
Awaited<TOutput>,
761+
WidenJson<AwaitedReturn<THandler>>,
740762
TRetries extends 'skip' | 'skip-cascade' ? TRetries : false
741763
>;
742764
},
@@ -747,7 +769,10 @@ export class Flow<
747769
// Overload 2: Root step with condition and omitted whenUnmet defaults to 'skip'
748770
step<
749771
Slug extends string,
750-
TOutput,
772+
THandler extends (
773+
flowInput: TFlowInput,
774+
context: FlowContext<TEnv, TFlowInput> & TContext
775+
) => Json | Promise<Json>,
751776
TRetries extends WhenExhaustedMode | undefined = undefined
752777
>(
753778
opts: Simplify<
@@ -761,15 +786,12 @@ export class Flow<
761786
) &
762787
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
763788
>,
764-
handler: (
765-
flowInput: TFlowInput,
766-
context: FlowContext<TEnv, TFlowInput> & TContext
767-
) => TOutput | Promise<TOutput>
789+
handler: THandler
768790
): Flow<
769791
TFlowInput,
770792
TContext,
771793
Steps & {
772-
[K in Slug]: StepMeta<Awaited<TOutput>, 'skip'>;
794+
[K in Slug]: StepMeta<WidenJson<AwaitedReturn<THandler>>, 'skip'>;
773795
},
774796
StepDependencies & { [K in Slug]: [] },
775797
TEnv
@@ -778,7 +800,10 @@ export class Flow<
778800
// Overload 3: Root step with explicit whenUnmet
779801
step<
780802
Slug extends string,
781-
TOutput,
803+
THandler extends (
804+
flowInput: TFlowInput,
805+
context: FlowContext<TEnv, TFlowInput> & TContext
806+
) => Json | Promise<Json>,
782807
TWhenUnmet extends WhenUnmetMode,
783808
TRetries extends WhenExhaustedMode | undefined = undefined
784809
>(
@@ -793,16 +818,13 @@ export class Flow<
793818
) &
794819
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
795820
>,
796-
handler: (
797-
flowInput: TFlowInput,
798-
context: FlowContext<TEnv, TFlowInput> & TContext
799-
) => TOutput | Promise<TOutput>
821+
handler: THandler
800822
): Flow<
801823
TFlowInput,
802824
TContext,
803825
Steps & {
804826
[K in Slug]: StepMeta<
805-
Awaited<TOutput>,
827+
WidenJson<AwaitedReturn<THandler>>,
806828
TWhenUnmet extends 'skip' | 'skip-cascade'
807829
? TWhenUnmet
808830
: TRetries extends 'skip' | 'skip-cascade'
@@ -818,7 +840,10 @@ export class Flow<
818840
step<
819841
Slug extends string,
820842
Deps extends Extract<keyof Steps, string>,
821-
TOutput,
843+
THandler extends (
844+
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
845+
context: FlowContext<TEnv, TFlowInput> & TContext
846+
) => Json | Promise<Json>,
822847
TRetries extends WhenExhaustedMode | undefined = undefined
823848
>(
824849
opts: Simplify<
@@ -829,16 +854,13 @@ export class Flow<
829854
} & WithoutCondition &
830855
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
831856
>,
832-
handler: (
833-
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
834-
context: FlowContext<TEnv, TFlowInput> & TContext
835-
) => TOutput | Promise<TOutput>
857+
handler: THandler
836858
): Flow<
837859
TFlowInput,
838860
TContext,
839861
Steps & {
840862
[K in Slug]: StepMeta<
841-
Awaited<TOutput>,
863+
WidenJson<AwaitedReturn<THandler>>,
842864
TRetries extends 'skip' | 'skip-cascade' ? TRetries : false
843865
>;
844866
},
@@ -850,7 +872,10 @@ export class Flow<
850872
step<
851873
Slug extends string,
852874
Deps extends Extract<keyof Steps, string>,
853-
TOutput,
875+
THandler extends (
876+
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
877+
context: FlowContext<TEnv, TFlowInput> & TContext
878+
) => Json | Promise<Json>,
854879
TRetries extends WhenExhaustedMode | undefined = undefined
855880
>(
856881
opts: Simplify<
@@ -870,15 +895,12 @@ export class Flow<
870895
) &
871896
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
872897
>,
873-
handler: (
874-
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
875-
context: FlowContext<TEnv, TFlowInput> & TContext
876-
) => TOutput | Promise<TOutput>
898+
handler: THandler
877899
): Flow<
878900
TFlowInput,
879901
TContext,
880902
Steps & {
881-
[K in Slug]: StepMeta<Awaited<TOutput>, 'skip'>;
903+
[K in Slug]: StepMeta<WidenJson<AwaitedReturn<THandler>>, 'skip'>;
882904
},
883905
StepDependencies & { [K in Slug]: Deps[] },
884906
TEnv
@@ -888,7 +910,10 @@ export class Flow<
888910
step<
889911
Slug extends string,
890912
Deps extends Extract<keyof Steps, string>,
891-
TOutput,
913+
THandler extends (
914+
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
915+
context: FlowContext<TEnv, TFlowInput> & TContext
916+
) => Json | Promise<Json>,
892917
TWhenUnmet extends WhenUnmetMode,
893918
TRetries extends WhenExhaustedMode | undefined = undefined
894919
>(
@@ -907,16 +932,13 @@ export class Flow<
907932
) &
908933
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
909934
>,
910-
handler: (
911-
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
912-
context: FlowContext<TEnv, TFlowInput> & TContext
913-
) => TOutput | Promise<TOutput>
935+
handler: THandler
914936
): Flow<
915937
TFlowInput,
916938
TContext,
917939
Steps & {
918940
[K in Slug]: StepMeta<
919-
Awaited<TOutput>,
941+
WidenJson<AwaitedReturn<THandler>>,
920942
TWhenUnmet extends 'skip' | 'skip-cascade'
921943
? TWhenUnmet
922944
: TRetries extends 'skip' | 'skip-cascade'

0 commit comments

Comments
 (0)