Skip to content

Commit f41d0f1

Browse files
authored
feat: enforce JSON-compatible step outputs at construction time (#615)
This pull request enforces JSON-compatible step outputs at `.step()` construction time by adding TypeScript constraints to the step handler functions. The changes introduce a `Json` type constraint on step handler return values, preventing non-serializable types like `undefined`, `Symbol`, or functions from being returned. A new `WidenJson<T>` utility type is added to properly widen literal types (e.g., `123` becomes `number`) while maintaining JSON compatibility. All six `.step()` method overloads are updated to use a constrained `THandler` type parameter instead of a generic `TOutput`, ensuring type safety at compile time. Comprehensive type tests verify that valid JSON types are accepted while invalid types like `undefined` and functions are properly rejected with TypeScript errors.
1 parent 22f610d commit f41d0f1

14 files changed

+849
-108
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/array-method.test-d.ts

Lines changed: 101 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { Flow, type StepInput, type ExtractFlowContext } from '../../src/index.js';
1+
import {
2+
Flow,
3+
type StepInput,
4+
type ExtractFlowContext,
5+
} from '../../src/index.js';
26
import { describe, it, expectTypeOf } from 'vitest';
37

48
describe('.array() method type constraints', () => {
@@ -9,7 +13,10 @@ describe('.array() method type constraints', () => {
913
.array({ slug: 'objects' }, () => [{ id: 1 }, { id: 2 }])
1014
.array({ slug: 'strings' }, () => ['a', 'b', 'c'])
1115
.array({ slug: 'empty' }, () => [])
12-
.array({ slug: 'nested' }, () => [[1, 2], [3, 4]]);
16+
.array({ slug: 'nested' }, () => [
17+
[1, 2],
18+
[3, 4],
19+
]);
1320
});
1421

1522
it('should accept handlers that return Promise<Array>', () => {
@@ -42,12 +49,30 @@ describe('.array() method type constraints', () => {
4249
// @ts-expect-error - should reject Promise<null>
4350
.array({ slug: 'invalid_async_null' }, async () => null);
4451
});
52+
53+
it('should reject non-JSON array element shapes', () => {
54+
new Flow<Record<string, never>>({ slug: 'test_json' })
55+
// @ts-expect-error - undefined element is not Json
56+
.array({ slug: 'invalid_undefined_element' }, () => [undefined])
57+
// @ts-expect-error - function element is not Json
58+
.array({ slug: 'invalid_function_element' }, () => [() => 'x'])
59+
// @ts-expect-error - symbol element is not Json
60+
.array({ slug: 'invalid_symbol_element' }, () => [Symbol('x')])
61+
// @ts-expect-error - object property with undefined is not Json
62+
.array({ slug: 'invalid_object_property' }, () => [
63+
{ id: 1, maybe: undefined as string | undefined },
64+
]);
65+
});
4566
});
4667

4768
describe('type inference', () => {
4869
it('should provide correct input types for dependent steps', () => {
4970
new Flow<{ count: number }>({ slug: 'test' })
50-
.array({ slug: 'items' }, (flowInput) => Array(flowInput.count).fill(0).map((_, i) => i))
71+
.array({ slug: 'items' }, (flowInput) =>
72+
Array(flowInput.count)
73+
.fill(0)
74+
.map((_, i) => i)
75+
)
5176
.step({ slug: 'process', dependsOn: ['items'] }, (deps) => {
5277
expectTypeOf(deps).toMatchTypeOf<{
5378
items: number[];
@@ -58,18 +83,32 @@ describe('.array() method type constraints', () => {
5883

5984
it('should correctly infer element types from arrays', () => {
6085
new Flow<{ userId: string }>({ slug: 'test' })
61-
.array({ slug: 'users' }, () => [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }])
86+
.array({ slug: 'users' }, () => [
87+
{ id: 1, name: 'John' },
88+
{ id: 2, name: 'Jane' },
89+
])
6290
.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 }>();
91+
expectTypeOf(deps.users).toEqualTypeOf<
92+
{ id: number; name: string }[]
93+
>();
94+
expectTypeOf(deps.users[0]).toMatchTypeOf<{
95+
id: number;
96+
name: string;
97+
}>();
6598
return deps.users.length;
6699
});
67100
});
68101

69102
it('should handle complex nested array types', () => {
70103
new Flow<{ depth: number }>({ slug: 'test' })
71104
.array({ slug: 'matrix' }, (flowInput) =>
72-
Array(flowInput.depth).fill(0).map(() => Array(3).fill(0).map(() => ({ value: Math.random() })))
105+
Array(flowInput.depth)
106+
.fill(0)
107+
.map(() =>
108+
Array(3)
109+
.fill(0)
110+
.map(() => ({ value: Math.random() }))
111+
)
73112
)
74113
.step({ slug: 'flatten', dependsOn: ['matrix'] }, (deps) => {
75114
expectTypeOf(deps.matrix).toEqualTypeOf<{ value: number }[][]>();
@@ -83,12 +122,14 @@ describe('.array() method type constraints', () => {
83122
new Flow<{ url: string }>({ slug: 'test' })
84123
.array({ slug: 'data' }, async (flowInput) => {
85124
// Simulate async data fetching
86-
await new Promise(resolve => setTimeout(resolve, 1));
125+
await new Promise((resolve) => setTimeout(resolve, 1));
87126
return [{ url: flowInput.url, status: 200 }];
88127
})
89128
.step({ slug: 'validate', dependsOn: ['data'] }, (deps) => {
90-
expectTypeOf(deps.data).toEqualTypeOf<{ url: string; status: number }[]>();
91-
return deps.data.every(item => item.status === 200);
129+
expectTypeOf(deps.data).toEqualTypeOf<
130+
{ url: string; status: number }[]
131+
>();
132+
return deps.data.every((item) => item.status === 200);
92133
});
93134
});
94135
});
@@ -119,34 +160,49 @@ describe('.array() method type constraints', () => {
119160

120161
it('should correctly type multi-dependency array steps', () => {
121162
new Flow<{ base: number }>({ slug: 'test' })
122-
.array({ slug: 'numbers' }, (flowInput) => [flowInput.base, flowInput.base + 1])
163+
.array({ slug: 'numbers' }, (flowInput) => [
164+
flowInput.base,
165+
flowInput.base + 1,
166+
])
123167
.array({ slug: 'letters' }, () => ['a', 'b'])
124-
.array({ slug: 'combined', dependsOn: ['numbers', 'letters'] }, (deps) => {
125-
expectTypeOf(deps).toMatchTypeOf<{
126-
numbers: number[];
127-
letters: string[];
128-
}>();
168+
.array(
169+
{ slug: 'combined', dependsOn: ['numbers', 'letters'] },
170+
(deps) => {
171+
expectTypeOf(deps).toMatchTypeOf<{
172+
numbers: number[];
173+
letters: string[];
174+
}>();
129175

130-
return deps.numbers.map((num, i) => ({
131-
number: num,
132-
letter: deps.letters[i] || 'z'
133-
}));
134-
});
176+
return deps.numbers.map((num, i) => ({
177+
number: num,
178+
letter: deps.letters[i] || 'z',
179+
}));
180+
}
181+
);
135182
});
136183
});
137184

138185
describe('context typing', () => {
139186
it('should provide custom context via Flow type parameter', () => {
140187
// eslint-disable-next-line @typescript-eslint/no-explicit-any
141-
const flow = new Flow<{ id: number }, { api: { get: (id: number) => Promise<any> } }>({ slug: 'test' })
142-
.array({ slug: 'fetch_data' }, (flowInput, context) => {
188+
const flow = new Flow<
189+
{ id: number },
190+
{ api: { get: (id: number) => Promise<any> } }
191+
>({ slug: 'test' }).array(
192+
{ slug: 'fetch_data' },
193+
(flowInput, context) => {
143194
// No handler annotation needed! Type parameter provides context
144-
expectTypeOf(context.api).toEqualTypeOf<{ get: (id: number) => Promise<any> }>();
145-
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
195+
expectTypeOf(context.api).toEqualTypeOf<{
196+
get: (id: number) => Promise<any>;
197+
}>();
198+
expectTypeOf(context.env).toEqualTypeOf<
199+
Record<string, string | undefined>
200+
>();
146201
expectTypeOf(context.shutdownSignal).toEqualTypeOf<AbortSignal>();
147202

148203
return [{ id: flowInput.id, data: 'mock' }];
149-
});
204+
}
205+
);
150206

151207
// ExtractFlowContext should include FlowContext & custom resources
152208
type FlowCtx = ExtractFlowContext<typeof flow>;
@@ -160,10 +216,15 @@ describe('.array() method type constraints', () => {
160216
});
161217

162218
it('should share custom context across array and regular steps', () => {
163-
const flow = new Flow<{ count: number }, { generator: () => number; processor: (items: number[]) => string }>({ slug: 'test' })
219+
const flow = new Flow<
220+
{ count: number },
221+
{ generator: () => number; processor: (items: number[]) => string }
222+
>({ slug: 'test' })
164223
.array({ slug: 'items' }, (flowInput, context) => {
165224
// All steps get the same context automatically
166-
return Array(flowInput.count).fill(0).map(() => context.generator());
225+
return Array(flowInput.count)
226+
.fill(0)
227+
.map(() => context.generator());
167228
})
168229
.step({ slug: 'process' }, (flowInput, context) => {
169230
return context.processor([1, 2, 3]);
@@ -184,14 +245,23 @@ describe('.array() method type constraints', () => {
184245
describe('handler signature validation', () => {
185246
it('should correctly type array step handlers when using getStepDefinition', () => {
186247
const flow = new Flow<{ size: number }>({ slug: 'test' })
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);
248+
.array({ slug: 'data' }, (flowInput, _context) =>
249+
Array(flowInput.size)
250+
.fill(0)
251+
.map((_, i) => ({ index: i }))
252+
)
253+
.step(
254+
{ slug: 'dependent', dependsOn: ['data'] },
255+
(deps, _context) => deps.data.length
256+
);
189257

190258
const arrayStep = flow.getStepDefinition('data');
191259

192260
// Test array step handler type - root steps receive flowInput directly (no run key)
193261
expectTypeOf(arrayStep.handler).toBeFunction();
194-
expectTypeOf(arrayStep.handler).parameter(0).toMatchTypeOf<{ size: number }>();
262+
expectTypeOf(arrayStep.handler)
263+
.parameter(0)
264+
.toMatchTypeOf<{ size: number }>();
195265
expectTypeOf(arrayStep.handler).returns.toMatchTypeOf<
196266
{ index: number }[] | Promise<{ index: number }[]>
197267
>();
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+
});

0 commit comments

Comments
 (0)