Skip to content

Commit 211672e

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

3 files changed

Lines changed: 98 additions & 36 deletions

File tree

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: 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: 50 additions & 36 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
// ========================
@@ -716,7 +730,10 @@ export class Flow<
716730
// Overload 1: Root step without conditions
717731
step<
718732
Slug extends string,
719-
TOutput,
733+
THandler extends (
734+
flowInput: TFlowInput,
735+
context: FlowContext<TEnv, TFlowInput> & TContext
736+
) => Json | Promise<Json>,
720737
TRetries extends WhenExhaustedMode | undefined = undefined
721738
>(
722739
opts: Simplify<
@@ -727,16 +744,13 @@ export class Flow<
727744
} & WithoutCondition &
728745
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
729746
>,
730-
handler: (
731-
flowInput: TFlowInput,
732-
context: FlowContext<TEnv, TFlowInput> & TContext
733-
) => TOutput | Promise<TOutput>
747+
handler: THandler
734748
): Flow<
735749
TFlowInput,
736750
TContext,
737751
Steps & {
738752
[K in Slug]: StepMeta<
739-
Awaited<TOutput>,
753+
WidenJson<AwaitedReturn<THandler>>,
740754
TRetries extends 'skip' | 'skip-cascade' ? TRetries : false
741755
>;
742756
},
@@ -747,7 +761,10 @@ export class Flow<
747761
// Overload 2: Root step with condition and omitted whenUnmet defaults to 'skip'
748762
step<
749763
Slug extends string,
750-
TOutput,
764+
THandler extends (
765+
flowInput: TFlowInput,
766+
context: FlowContext<TEnv, TFlowInput> & TContext
767+
) => Json | Promise<Json>,
751768
TRetries extends WhenExhaustedMode | undefined = undefined
752769
>(
753770
opts: Simplify<
@@ -761,15 +778,12 @@ export class Flow<
761778
) &
762779
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
763780
>,
764-
handler: (
765-
flowInput: TFlowInput,
766-
context: FlowContext<TEnv, TFlowInput> & TContext
767-
) => TOutput | Promise<TOutput>
781+
handler: THandler
768782
): Flow<
769783
TFlowInput,
770784
TContext,
771785
Steps & {
772-
[K in Slug]: StepMeta<Awaited<TOutput>, 'skip'>;
786+
[K in Slug]: StepMeta<WidenJson<AwaitedReturn<THandler>>, 'skip'>;
773787
},
774788
StepDependencies & { [K in Slug]: [] },
775789
TEnv
@@ -778,7 +792,10 @@ export class Flow<
778792
// Overload 3: Root step with explicit whenUnmet
779793
step<
780794
Slug extends string,
781-
TOutput,
795+
THandler extends (
796+
flowInput: TFlowInput,
797+
context: FlowContext<TEnv, TFlowInput> & TContext
798+
) => Json | Promise<Json>,
782799
TWhenUnmet extends WhenUnmetMode,
783800
TRetries extends WhenExhaustedMode | undefined = undefined
784801
>(
@@ -793,16 +810,13 @@ export class Flow<
793810
) &
794811
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
795812
>,
796-
handler: (
797-
flowInput: TFlowInput,
798-
context: FlowContext<TEnv, TFlowInput> & TContext
799-
) => TOutput | Promise<TOutput>
813+
handler: THandler
800814
): Flow<
801815
TFlowInput,
802816
TContext,
803817
Steps & {
804818
[K in Slug]: StepMeta<
805-
Awaited<TOutput>,
819+
WidenJson<AwaitedReturn<THandler>>,
806820
TWhenUnmet extends 'skip' | 'skip-cascade'
807821
? TWhenUnmet
808822
: TRetries extends 'skip' | 'skip-cascade'
@@ -818,7 +832,10 @@ export class Flow<
818832
step<
819833
Slug extends string,
820834
Deps extends Extract<keyof Steps, string>,
821-
TOutput,
835+
THandler extends (
836+
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
837+
context: FlowContext<TEnv, TFlowInput> & TContext
838+
) => Json | Promise<Json>,
822839
TRetries extends WhenExhaustedMode | undefined = undefined
823840
>(
824841
opts: Simplify<
@@ -829,16 +846,13 @@ export class Flow<
829846
} & WithoutCondition &
830847
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
831848
>,
832-
handler: (
833-
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
834-
context: FlowContext<TEnv, TFlowInput> & TContext
835-
) => TOutput | Promise<TOutput>
849+
handler: THandler
836850
): Flow<
837851
TFlowInput,
838852
TContext,
839853
Steps & {
840854
[K in Slug]: StepMeta<
841-
Awaited<TOutput>,
855+
WidenJson<AwaitedReturn<THandler>>,
842856
TRetries extends 'skip' | 'skip-cascade' ? TRetries : false
843857
>;
844858
},
@@ -850,7 +864,10 @@ export class Flow<
850864
step<
851865
Slug extends string,
852866
Deps extends Extract<keyof Steps, string>,
853-
TOutput,
867+
THandler extends (
868+
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
869+
context: FlowContext<TEnv, TFlowInput> & TContext
870+
) => Json | Promise<Json>,
854871
TRetries extends WhenExhaustedMode | undefined = undefined
855872
>(
856873
opts: Simplify<
@@ -870,15 +887,12 @@ export class Flow<
870887
) &
871888
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
872889
>,
873-
handler: (
874-
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
875-
context: FlowContext<TEnv, TFlowInput> & TContext
876-
) => TOutput | Promise<TOutput>
890+
handler: THandler
877891
): Flow<
878892
TFlowInput,
879893
TContext,
880894
Steps & {
881-
[K in Slug]: StepMeta<Awaited<TOutput>, 'skip'>;
895+
[K in Slug]: StepMeta<WidenJson<AwaitedReturn<THandler>>, 'skip'>;
882896
},
883897
StepDependencies & { [K in Slug]: Deps[] },
884898
TEnv
@@ -888,7 +902,10 @@ export class Flow<
888902
step<
889903
Slug extends string,
890904
Deps extends Extract<keyof Steps, string>,
891-
TOutput,
905+
THandler extends (
906+
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
907+
context: FlowContext<TEnv, TFlowInput> & TContext
908+
) => Json | Promise<Json>,
892909
TWhenUnmet extends WhenUnmetMode,
893910
TRetries extends WhenExhaustedMode | undefined = undefined
894911
>(
@@ -907,16 +924,13 @@ export class Flow<
907924
) &
908925
Omit<BaseStepRuntimeOptions, 'whenExhausted'>
909926
>,
910-
handler: (
911-
deps: Simplify<DepsWithOptionalSkippable<Steps, Deps>>,
912-
context: FlowContext<TEnv, TFlowInput> & TContext
913-
) => TOutput | Promise<TOutput>
927+
handler: THandler
914928
): Flow<
915929
TFlowInput,
916930
TContext,
917931
Steps & {
918932
[K in Slug]: StepMeta<
919-
Awaited<TOutput>,
933+
WidenJson<AwaitedReturn<THandler>>,
920934
TWhenUnmet extends 'skip' | 'skip-cascade'
921935
? TWhenUnmet
922936
: TRetries extends 'skip' | 'skip-cascade'

0 commit comments

Comments
 (0)