Skip to content

Commit bc87285

Browse files
authored
Type Test Runner Bug, Add Standard Typecheck, and Rewrite Context Typing (#226)
### Summary - **Bugfix:** Type tests were not actually running due to faulty vitest config (`typecheck.include`), causing silent test passes instead of catching real errors. - **New:** Sets up reliable TypeScript typecheck (`tsconfig.typecheck.json`) and consistent `test:types` Nx targets in all packages, so type errors in `*.test-d.ts` files are now always caught. - **Refactor:** Major rewrite of flow/context typing in `@pgflow/dsl` and platform adapters: - Unified context: All flow/step handlers now use a strict `FlowContext<TEnv>` (with custom resources typed at the flow level, not as function annotation). - Improved platform resource typing and extraction/compatibility utility types. - Type test suites updated for new context and signature conventions. - Misc: Tweaked `.gitignore`, lockfile, and configs; minor dependency upgrades. ### Breaking Changes - All flow handlers: now strictly `(input, context)`; context is always unified and extracted from flow generics. - Custom resources/context must be typed via flow type parameters. - Consumers must update handler signatures, context param usage, and any custom type utilities. ### How to Test Run `nx run-many -t test:types`. All `*.test-d.ts` type errors will now block CI, confirming the test runner fix and new type safety. --- Closes: #XXX (if applicable)
1 parent 28252d9 commit bc87285

34 files changed

+768
-489
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ vitest.config.*.timestamp*
4646

4747
.env*
4848
tsconfig.vitest-temp.json
49+
*.tsbuildinfo
4950
deno-dist/
5051
.nx-inputs
5152
.vscode

examples/playground/supabase/functions/_flows/analyze_website.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Flow } from '@pgflow/dsl';
1+
import { Flow } from '@pgflow/dsl/supabase';
22
import scrapeWebsite from '../_tasks/scrapeWebsite.ts';
33
import summarizeWithAI from '../_tasks/summarizeWithAI.ts';
44
import extractTags from '../_tasks/extractTags.ts';
@@ -18,11 +18,11 @@ export default new Flow<Input>({
1818
})
1919
.step(
2020
{ slug: 'website' },
21-
async (input) => await scrapeWebsite(input.run.url),
21+
async (input) => await scrapeWebsite(input.run.url)
2222
)
2323
.step(
2424
{ slug: 'summary', dependsOn: ['website'] },
25-
async (input) => await summarizeWithAI(input.website.content),
25+
async (input) => await summarizeWithAI(input.website.content)
2626
)
2727
.step({ slug: 'tags', dependsOn: ['website'] }, async (input) => {
2828
await simulateFailure(input.run.url);
@@ -42,5 +42,5 @@ export default new Flow<Input>({
4242
const { website } = await saveWebsite(websiteData, supabase);
4343

4444
return website;
45-
},
45+
}
4646
);

examples/playground/tsconfig.json

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@
22
"extends": "../../tsconfig.base.json",
33
"compilerOptions": {
44
"target": "es5",
5-
"lib": [
6-
"dom",
7-
"dom.iterable",
8-
"esnext"
9-
],
5+
"lib": ["dom", "dom.iterable", "esnext"],
106
"allowJs": true,
117
"skipLibCheck": true,
128
"strict": true,
@@ -26,14 +22,9 @@
2622
],
2723
"baseUrl": ".",
2824
"paths": {
29-
"@/*": [
30-
"./*"
31-
]
25+
"@/*": ["./*"]
3226
},
33-
"typeRoots": [
34-
"./types",
35-
"./node_modules/@types"
36-
]
27+
"typeRoots": ["./types", "./node_modules/@types"]
3728
},
3829
"include": [
3930
"**/*.ts",
@@ -46,5 +37,13 @@
4637
"node_modules",
4738
"supabase/functions/**/*.ts",
4839
"supabase/functions/**/*.d.ts"
40+
],
41+
"references": [
42+
{
43+
"path": "../../pkgs/dsl"
44+
},
45+
{
46+
"path": "../../pkgs/client"
47+
}
4948
]
5049
}

pkgs/client/__tests__/helpers/cleanup.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ export async function cleanupFlow(
1515
// The queue name is the same as the flow slug
1616
try {
1717
await sql`SELECT pgmq.purge_queue(${flowSlug})`;
18-
} catch (error: any) {
18+
} catch (error: unknown) {
1919
// Ignore error if queue doesn't exist (42P01 = undefined_table)
20-
if (error.code !== '42P01') {
20+
if (typeof error === 'object' && error !== null && 'code' in error && error.code !== '42P01') {
2121
throw error;
2222
}
2323
}
@@ -59,9 +59,9 @@ export async function purgeQueue(
5959
): Promise<void> {
6060
try {
6161
await sql`SELECT pgmq.purge_queue(${flowSlug})`;
62-
} catch (error: any) {
62+
} catch (error: unknown) {
6363
// Ignore error if queue doesn't exist (42P01 = undefined_table)
64-
if (error.code !== '42P01') {
64+
if (typeof error === 'object' && error !== null && 'code' in error && error.code !== '42P01') {
6565
throw error;
6666
}
6767
}

pkgs/client/project.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@
175175
"commands": ["node scripts/performance-benchmark.mjs"],
176176
"parallel": false
177177
}
178+
},
179+
"test:types": {
180+
"executor": "nx:run-commands",
181+
"options": {
182+
"cwd": "{projectRoot}",
183+
"command": "tsc --project tsconfig.typecheck.json --noEmit"
184+
}
178185
}
179186
}
180187
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"noEmit": true,
5+
"composite": false,
6+
"module": "ESNext",
7+
"moduleResolution": "bundler",
8+
"allowImportingTsExtensions": true,
9+
"types": [
10+
"vitest/globals",
11+
"vitest/importMeta",
12+
"vite/client",
13+
"node",
14+
"vitest"
15+
]
16+
},
17+
"include": [
18+
"__tests__/**/*.test-d.ts",
19+
"__tests__/**/*.spec-d.ts"
20+
]
21+
}

pkgs/core/project.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,13 @@
262262
"parallel": false
263263
},
264264
"cache": true
265+
},
266+
"test:types": {
267+
"executor": "nx:run-commands",
268+
"options": {
269+
"cwd": "{projectRoot}",
270+
"command": "tsc --project tsconfig.typecheck.json --noEmit"
271+
}
265272
}
266273
}
267274
}

pkgs/core/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export type StepTaskRecord<TFlow extends AnyFlow> = {
3636
* Composite key that is enough to find a particular step task
3737
* Contains only the minimum fields needed to identify a task
3838
*/
39-
export type StepTaskKey = Pick<StepTaskRecord<any>, 'run_id' | 'step_slug'>;
39+
export type StepTaskKey = Pick<StepTaskRecord<AnyFlow>, 'run_id' | 'step_slug'>;
4040

4141

4242

pkgs/core/tsconfig.typecheck.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"noEmit": true,
5+
"composite": false,
6+
"module": "ESNext",
7+
"moduleResolution": "bundler",
8+
"allowImportingTsExtensions": true,
9+
"types": [
10+
"vitest/globals",
11+
"vitest/importMeta",
12+
"vite/client",
13+
"node",
14+
"vitest"
15+
]
16+
},
17+
"include": [
18+
"__tests__/**/*.test-d.ts",
19+
"__tests__/**/*.spec-d.ts"
20+
]
21+
}
Lines changed: 43 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Flow, BaseContext, Context, ExtractFlowContext } from '../../src/index.js';
1+
import { Flow, FlowContext, ExtractFlowContext } from '../../src/index.js';
22
import { describe, it, expectTypeOf } from 'vitest';
3+
import type { Json } from '../../src/index.js';
34

45
// Mock types for testing
56
interface TestSql {
@@ -11,150 +12,84 @@ interface TestRedis {
1112
set: (key: string, value: string) => Promise<void>;
1213
}
1314

14-
interface TestSupabase {
15-
from: (table: string) => any;
16-
}
17-
1815
describe('Context Type Inference Tests', () => {
19-
it('should have minimal context by default', () => {
16+
it('should have FlowContext by default (no custom resources)', () => {
2017
const flow = new Flow({ slug: 'minimal_flow' })
2118
.step({ slug: 'process' }, (input, context) => {
22-
expectTypeOf(context).toMatchTypeOf<BaseContext>();
19+
// Handler automatically gets FlowContext (no annotation needed!)
20+
expectTypeOf(context).toMatchTypeOf<FlowContext>();
2321
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
2422
expectTypeOf(context.shutdownSignal).toEqualTypeOf<AbortSignal>();
25-
26-
// Should not have sql by default
27-
expectTypeOf(context).not.toHaveProperty('sql');
28-
23+
expectTypeOf(context.stepTask.run_id).toEqualTypeOf<string>();
24+
expectTypeOf(context.rawMessage.msg_id).toEqualTypeOf<number>();
25+
2926
return { processed: true };
3027
});
3128

32-
// ExtractFlowContext should return BaseContext for minimal flow
33-
type FlowContext = ExtractFlowContext<typeof flow>;
34-
expectTypeOf<FlowContext>().toEqualTypeOf<BaseContext>();
29+
// ExtractFlowContext returns just FlowContext (no custom resources)
30+
type FlowCtx = ExtractFlowContext<typeof flow>;
31+
expectTypeOf<FlowCtx>().toEqualTypeOf<FlowContext>();
3532
});
3633

37-
it('should infer context from single handler type annotation', () => {
38-
const flow = new Flow({ slug: 'single_inferred' })
39-
.step({ slug: 'query' }, (input, context: { sql: TestSql }) => {
34+
it('should provide custom context via Flow type parameter', () => {
35+
const flow = new Flow<Json, { sql: TestSql }>({ slug: 'custom_context' })
36+
.step({ slug: 'query' }, (input, context) => {
37+
// No handler annotation needed! Type parameter provides context
4038
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
41-
// Base Context properties are STILL available even without typing
4239
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
4340
expectTypeOf(context.shutdownSignal).toEqualTypeOf<AbortSignal>();
44-
41+
4542
return { result: 'data' };
4643
});
4744

48-
// ExtractFlowContext should return BaseContext & { sql: TestSql }
49-
type FlowContext = ExtractFlowContext<typeof flow>;
50-
expectTypeOf<FlowContext>().toEqualTypeOf<BaseContext & { sql: TestSql }>();
45+
// ExtractFlowContext returns FlowContext & custom resources
46+
type FlowCtx = ExtractFlowContext<typeof flow>;
47+
expectTypeOf<FlowCtx>().toEqualTypeOf<FlowContext & { sql: TestSql }>();
5148
});
5249

53-
it('should accumulate context from multiple handlers', () => {
54-
const flow = new Flow({ slug: 'multi_inferred' })
55-
.step({ slug: 'query' }, (input, context: Context<{ sql: TestSql }>) => {
50+
it('should share custom context across all steps', () => {
51+
const flow = new Flow<Json, { sql: TestSql; redis: TestRedis }>({ slug: 'shared_context' })
52+
.step({ slug: 'query' }, (input, context) => {
53+
// All steps get the same context automatically
5654
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
55+
expectTypeOf(context.redis).toEqualTypeOf<TestRedis>();
5756
return { users: [] };
5857
})
59-
.step({ slug: 'cache' }, (input, context: Context<{ redis: TestRedis }>) => {
58+
.step({ slug: 'cache' }, (input, context) => {
59+
// Second step also has access to all resources
60+
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
6061
expectTypeOf(context.redis).toEqualTypeOf<TestRedis>();
6162
return { cached: true };
62-
})
63-
.step({ slug: 'notify' }, (input, context: Context<{ sql: TestSql, supabase: TestSupabase }>) => {
64-
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
65-
expectTypeOf(context.supabase).toEqualTypeOf<TestSupabase>();
66-
return { notified: true };
6763
});
6864

69-
// ExtractFlowContext should have BaseContext plus all accumulated resources
70-
type FlowContext = ExtractFlowContext<typeof flow>;
71-
expectTypeOf<FlowContext>().toEqualTypeOf<BaseContext & {
65+
// ExtractFlowContext returns FlowContext & all custom resources
66+
type FlowCtx = ExtractFlowContext<typeof flow>;
67+
expectTypeOf<FlowCtx>().toEqualTypeOf<FlowContext & {
7268
sql: TestSql;
7369
redis: TestRedis;
74-
supabase: TestSupabase;
7570
}>();
7671
});
7772

78-
it('should support explicit context type parameter', () => {
79-
interface ExplicitContext {
80-
sql: TestSql;
81-
cache: TestRedis;
82-
pubsub: { publish: (event: string) => void };
83-
}
84-
85-
const flow = new Flow<{ userId: string }, ExplicitContext>({ slug: 'explicit_flow' })
86-
.step({ slug: 'get_user' }, (input, context) => {
87-
// All properties from ExplicitContext should be available
88-
expectTypeOf(context).toMatchTypeOf<BaseContext & ExplicitContext>();
89-
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
90-
expectTypeOf(context.cache).toEqualTypeOf<TestRedis>();
91-
expectTypeOf(context.pubsub).toEqualTypeOf<{ publish: (event: string) => void }>();
92-
93-
return { id: 1, name: 'Test' };
94-
});
95-
96-
// ExtractFlowContext should return BaseContext merged with explicit type
97-
type FlowContext = ExtractFlowContext<typeof flow>;
98-
expectTypeOf<FlowContext>().toEqualTypeOf<BaseContext & ExplicitContext>();
99-
});
100-
101-
it('should support mixed explicit and inferred context', () => {
102-
const flow = new Flow<{ id: string }, { sql: TestSql }>({ slug: 'mixed_flow' })
103-
.step({ slug: 'query' }, (input, context) => {
104-
// Has sql from explicit type
105-
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
106-
return { data: 'result' };
107-
})
108-
.step({ slug: 'enhance' }, (input, context: Context<{ sql: TestSql, ai: { generate: () => string } }>) => {
109-
// Should have both sql (from explicit) and ai (from inference)
110-
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
111-
expectTypeOf(context.ai).toEqualTypeOf<{ generate: () => string }>();
112-
return { enhanced: true };
113-
});
114-
115-
// ExtractFlowContext should have BaseContext plus both explicit and inferred
116-
type FlowContext = ExtractFlowContext<typeof flow>;
117-
expectTypeOf<FlowContext>().toEqualTypeOf<BaseContext & {
118-
sql: TestSql;
119-
ai: { generate: () => string };
120-
}>();
121-
});
122-
123-
it('should allow handlers to specify only custom context but still get base Context', () => {
124-
const flow = new Flow({ slug: 'custom_only' })
125-
.step({ slug: 'process' }, (input, context: { customField: string }) => {
126-
expectTypeOf(context.customField).toEqualTypeOf<string>();
127-
// Base Context properties are ALWAYS available now
128-
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
129-
expectTypeOf(context.shutdownSignal).toEqualTypeOf<AbortSignal>();
130-
131-
return { processed: context.customField };
132-
});
133-
134-
// ExtractFlowContext should have BaseContext plus the custom field
135-
type FlowContext = ExtractFlowContext<typeof flow>;
136-
expectTypeOf<FlowContext>().toEqualTypeOf<BaseContext & { customField: string }>();
137-
});
138-
13973
it('should preserve existing step type inference while adding context', () => {
140-
const flow = new Flow<{ initial: number }>({ slug: 'step_chain' })
141-
.step({ slug: 'double' }, (input, context: Context<{ multiplier: number }>) => {
74+
const flow = new Flow<{ initial: number }, { multiplier: number }>({ slug: 'step_chain' })
75+
.step({ slug: 'double' }, (input, context) => {
76+
// Input inference still works
14277
expectTypeOf(input.run.initial).toEqualTypeOf<number>();
78+
// Custom context available
14379
expectTypeOf(context.multiplier).toEqualTypeOf<number>();
14480
return { doubled: input.run.initial * 2 };
14581
})
146-
.step({ slug: 'format', dependsOn: ['double'] }, (input, context: Context<{ formatter: (n: number) => string }>) => {
82+
.step({ slug: 'format', dependsOn: ['double'] }, (input, context) => {
83+
// Dependent step has access to previous step output
14784
expectTypeOf(input.run.initial).toEqualTypeOf<number>();
14885
expectTypeOf(input.double.doubled).toEqualTypeOf<number>();
149-
expectTypeOf(context.formatter).toEqualTypeOf<(n: number) => string>();
150-
return { formatted: context.formatter(input.double.doubled) };
86+
// And still has custom context
87+
expectTypeOf(context.multiplier).toEqualTypeOf<number>();
88+
return { formatted: String(input.double.doubled) };
15189
});
15290

153-
// Context should have base plus accumulated requirements
154-
type FlowContext = ExtractFlowContext<typeof flow>;
155-
expectTypeOf<FlowContext>().toEqualTypeOf<BaseContext & {
156-
multiplier: number;
157-
formatter: (n: number) => string;
158-
}>();
91+
// Context includes custom resources
92+
type FlowCtx = ExtractFlowContext<typeof flow>;
93+
expectTypeOf<FlowCtx>().toEqualTypeOf<FlowContext & { multiplier: number }>();
15994
});
160-
});
95+
});

0 commit comments

Comments
 (0)