Skip to content

Commit cffe9d0

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

13 files changed

Lines changed: 561 additions & 61 deletions
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
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, expectTypeOf, it } from 'vitest';
2+
import {
3+
Flow,
4+
type AnyFlow,
5+
type CompatibleFlow,
6+
type Json,
7+
} from '../../src/index.js';
8+
import {
9+
Flow as SupabaseFlow,
10+
type SupabaseResources,
11+
} from '../../src/platforms/supabase.js';
12+
13+
interface RedisClient {
14+
get: (key: string) => Promise<string | null>;
15+
}
16+
17+
type AcceptsCompatible<
18+
F extends AnyFlow,
19+
PR extends Record<string, unknown>,
20+
UR extends Record<string, unknown> = Record<string, never>
21+
> = (flow: CompatibleFlow<F, PR, UR>) => void;
22+
23+
const acceptCompatible = <
24+
F extends AnyFlow,
25+
PR extends Record<string, unknown>,
26+
UR extends Record<string, unknown> = Record<string, never>
27+
>(
28+
flow: CompatibleFlow<F, PR, UR>
29+
) => {
30+
void flow;
31+
};
32+
33+
describe('CompatibleFlow utility type', () => {
34+
it('accepts flows that only need base FlowContext', () => {
35+
const baseFlow = new Flow<Json>({ slug: 'base-compatible' }).step(
36+
{ slug: 's1' },
37+
(_input, ctx) => ({ hasSignal: !!ctx.shutdownSignal })
38+
);
39+
40+
acceptCompatible<typeof baseFlow, Record<string, never>>(baseFlow);
41+
42+
type Result = CompatibleFlow<typeof baseFlow, Record<string, never>>;
43+
expectTypeOf<Result>().toEqualTypeOf<typeof baseFlow>();
44+
});
45+
46+
it('accepts flows requiring platform resources when provided', () => {
47+
const platformFlow = new SupabaseFlow({ slug: 'platform-compatible' }).step(
48+
{ slug: 'db' },
49+
async (_input, ctx) => {
50+
const rows = await ctx.sql`SELECT 1`;
51+
void ctx.supabase;
52+
return { rows: rows.length };
53+
}
54+
);
55+
56+
acceptCompatible<typeof platformFlow, SupabaseResources>(platformFlow);
57+
58+
type Result = CompatibleFlow<typeof platformFlow, SupabaseResources>;
59+
expectTypeOf<Result>().toEqualTypeOf<typeof platformFlow>();
60+
});
61+
62+
it('rejects flows requiring platform resources when missing', () => {
63+
const platformFlow = new SupabaseFlow({ slug: 'platform-missing' }).step(
64+
{ slug: 'db' },
65+
async (_input, ctx) => {
66+
const rows = await ctx.sql`SELECT 1`;
67+
return { rows: rows.length };
68+
}
69+
);
70+
71+
const accept: AcceptsCompatible<
72+
typeof platformFlow,
73+
Record<string, never>
74+
> = acceptCompatible;
75+
// @ts-expect-error - platform resources are required by flow context
76+
accept(platformFlow);
77+
78+
type Result = CompatibleFlow<typeof platformFlow, Record<string, never>>;
79+
expectTypeOf<Result>().toEqualTypeOf<never>();
80+
});
81+
82+
it('accepts user resources when explicitly provided', () => {
83+
const customCtxFlow = new Flow<Json, { redis: RedisClient }>({
84+
slug: 'user-resource-ok',
85+
}).step({ slug: 'cache' }, async (_input, ctx) => {
86+
const value = await ctx.redis.get('k1');
87+
return { value };
88+
});
89+
90+
acceptCompatible<
91+
typeof customCtxFlow,
92+
Record<string, never>,
93+
{ redis: RedisClient }
94+
>(customCtxFlow);
95+
96+
type Result = CompatibleFlow<
97+
typeof customCtxFlow,
98+
Record<string, never>,
99+
{ redis: RedisClient }
100+
>;
101+
expectTypeOf<Result>().toEqualTypeOf<typeof customCtxFlow>();
102+
});
103+
104+
it('rejects user-resource flows when user resources are omitted', () => {
105+
const customCtxFlow = new Flow<Json, { redis: RedisClient }>({
106+
slug: 'user-resource-missing',
107+
}).step({ slug: 'cache' }, async (_input, ctx) => {
108+
const value = await ctx.redis.get('k1');
109+
return { value };
110+
});
111+
112+
const accept: AcceptsCompatible<
113+
typeof customCtxFlow,
114+
Record<string, never>
115+
> = acceptCompatible;
116+
// @ts-expect-error - missing required user resources
117+
accept(customCtxFlow);
118+
119+
type Result = CompatibleFlow<typeof customCtxFlow, Record<string, never>>;
120+
expectTypeOf<Result>().toEqualTypeOf<never>();
121+
});
122+
123+
it('accepts mixed platform and user resources', () => {
124+
const mixedFlow = new SupabaseFlow<Json, { redis: RedisClient }>({
125+
slug: 'mixed-compatible',
126+
}).step({ slug: 'mixed' }, async (_input, ctx) => {
127+
const rows = await ctx.sql`SELECT 1`;
128+
const value = await ctx.redis.get('k1');
129+
void ctx.supabase;
130+
return { rows: rows.length, value };
131+
});
132+
133+
acceptCompatible<
134+
typeof mixedFlow,
135+
SupabaseResources,
136+
{ redis: RedisClient }
137+
>(mixedFlow);
138+
139+
type Result = CompatibleFlow<
140+
typeof mixedFlow,
141+
SupabaseResources,
142+
{ redis: RedisClient }
143+
>;
144+
expectTypeOf<Result>().toEqualTypeOf<typeof mixedFlow>();
145+
});
146+
147+
it('is invariant to optional output keys in step outputs', () => {
148+
const optionalOutputFlow = new SupabaseFlow({
149+
slug: 'optional-output-flow',
150+
})
151+
.step({ slug: 'producer' }, (): { entryId?: string } =>
152+
Math.random() > 0.5 ? { entryId: 'entry-1' } : {}
153+
)
154+
.step({ slug: 'consumer', dependsOn: ['producer'] }, (deps) => ({
155+
hasEntry: 'entryId' in deps.producer,
156+
}));
157+
158+
acceptCompatible<typeof optionalOutputFlow, SupabaseResources>(
159+
optionalOutputFlow
160+
);
161+
162+
type Result = CompatibleFlow<typeof optionalOutputFlow, SupabaseResources>;
163+
expectTypeOf<Result>().toEqualTypeOf<typeof optionalOutputFlow>();
164+
});
165+
});

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

Lines changed: 128 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,122 @@ 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+
});
219+
220+
it('preserves optional inner keys for required leaf outputs', () => {
221+
const requiredOptionalInnerFlow = new Flow<{ input: string }>({
222+
slug: 'required_optional_inner_flow',
223+
}).step({ slug: 'leaf_required' }, (): { entryId?: string } =>
224+
Math.random() > 0.5 ? { entryId: 'entry-1' } : {}
225+
);
226+
227+
type FlowOutput = ExtractFlowOutput<typeof requiredOptionalInnerFlow>;
228+
229+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
230+
leaf_required: { entryId?: string };
231+
}>();
232+
expectTypeOf<{
233+
leaf_required: { entryId?: string };
234+
}>().toMatchTypeOf<FlowOutput>();
235+
});
236+
237+
it('supports optional outer key with optional inner keys for skippable leaves', () => {
238+
const skippableOptionalInnerFlow = new Flow<{ input: string }>({
239+
slug: 'skippable_optional_inner_flow',
240+
})
241+
.step({ slug: 'prepare' }, (flowInput) => ({ text: flowInput.input }))
242+
.step(
243+
{
244+
slug: 'leaf_optional',
245+
dependsOn: ['prepare'],
246+
if: { prepare: { text: 'run' } },
247+
whenUnmet: 'skip',
248+
},
249+
(): { meta?: { tag: string } } =>
250+
Math.random() > 0.5 ? { meta: { tag: 'ok' } } : {}
251+
);
252+
253+
type FlowOutput = ExtractFlowOutput<typeof skippableOptionalInnerFlow>;
254+
255+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
256+
leaf_optional?: { meta?: { tag: string } };
257+
}>();
258+
expectTypeOf<{
259+
leaf_optional?: { meta?: { tag: string } };
260+
}>().toMatchTypeOf<FlowOutput>();
261+
});
141262
});
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+
});

0 commit comments

Comments
 (0)