Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ select pgflow.add_step(
'{first}', -- depends on first
null, null, null, null, -- default options
'single', -- step_type
'{"first": {"success": true}}'::jsonb, -- condition: first.success must be true
'{"first": {"success": true}}'::jsonb, -- if: first.success must be true
'skip' -- when_unmet
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ select pgflow.add_step(
'{first}', -- depends on first
null, null, null, null, -- default options
'single', -- step_type
'{"first": {"success": true}}'::jsonb, -- condition: first.success must be true
'{"first": {"success": true}}'::jsonb, -- if: first.success must be true
'skip' -- when_unmet
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ select pgflow.add_step(
'{}', -- root step
null, null, null, null,
'single',
'{"enabled": true}'::jsonb, -- condition: requires enabled=true
'{"enabled": true}'::jsonb, -- if: requires enabled=true
'skip' -- plain skip
);
select pgflow.add_step(
Expand All @@ -34,7 +34,7 @@ select pgflow.add_step(
'{step_a}', -- depends on a
null, null, null, null,
'single',
'{"step_a": {"success": true}}'::jsonb, -- condition: a.success must be true
'{"step_a": {"success": true}}'::jsonb, -- if: a.success must be true
'skip' -- plain skip (won't be met since a was skipped)
);
select pgflow.add_step(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ select pgflow.add_step(
'{}', -- root step
null, null, null, null, -- default options
'single', -- step_type
'{"enabled": true}'::jsonb, -- condition: requires enabled=true
'{"enabled": true}'::jsonb, -- if: requires enabled=true
'skip' -- when_unmet - plain skip (not skip-cascade)
);
-- Map consumer: no condition, just depends on producer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ select pgflow.add_step(
'{}', -- root step
null, null, null, null,
'single',
'{"enabled": true}'::jsonb, -- condition: requires enabled=true
'{"enabled": true}'::jsonb, -- if: requires enabled=true
'skip' -- plain skip
);
select pgflow.add_step(
Expand Down
56 changes: 29 additions & 27 deletions pkgs/dsl/__tests__/types/extract-flow-steps.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Flow, type ExtractFlowSteps } from '../../src/index.js';
import { Flow, type ExtractFlowSteps, type StepOutput } from '../../src/index.js';
import { describe, it, expectTypeOf } from 'vitest';

// ExtractFlowSteps returns step slugs as keys
// Use StepOutput<> to get the output type from a step
describe('ExtractFlowSteps utility type', () => {
it('should correctly extract steps from a flow with defined input', () => {
it('should correctly extract step slugs from a flow', () => {
const flow = new Flow<{ userId: number }>({ slug: 'user_flow' })
.step({ slug: 'fetchUser' }, () => ({ name: 'John', age: 30 }))
.step({ slug: 'fetchPosts', dependsOn: ['fetchUser'] }, () => [
Expand All @@ -12,15 +14,17 @@ describe('ExtractFlowSteps utility type', () => {

type Steps = ExtractFlowSteps<typeof flow>;

expectTypeOf<Steps>().toMatchTypeOf<{
fetchUser: { name: string; age: number };
fetchPosts: Array<{ id: number; title: string }>;
}>();
// Keys are step slugs
expectTypeOf<keyof Steps>().toEqualTypeOf<'fetchUser' | 'fetchPosts'>();

// ensure it doesn't extract non-existent fields
expectTypeOf<Steps>().not.toMatchTypeOf<{
nonExistentStep: number;
// Use StepOutput to get output types (public API)
expectTypeOf<StepOutput<typeof flow, 'fetchUser'>>().toMatchTypeOf<{
name: string;
age: number;
}>();
expectTypeOf<StepOutput<typeof flow, 'fetchPosts'>>().toMatchTypeOf<
Array<{ id: number; title: string }>
>();
});

it('should work with AnyFlow to extract steps from a generic flow', () => {
Expand All @@ -31,15 +35,14 @@ describe('ExtractFlowSteps utility type', () => {

type Steps = ExtractFlowSteps<typeof anyFlow>;

expectTypeOf<Steps>().toMatchTypeOf<{
step1: number;
step2: string;
step3: { complex: { nested: boolean } };
}>();
// Keys are step slugs
expectTypeOf<keyof Steps>().toEqualTypeOf<'step1' | 'step2' | 'step3'>();

// ensure it doesn't extract non-existent fields
expectTypeOf<Steps>().not.toMatchTypeOf<{
nonExistentStep: number;
// Use StepOutput to verify output types
expectTypeOf<StepOutput<typeof anyFlow, 'step1'>>().toEqualTypeOf<number>();
expectTypeOf<StepOutput<typeof anyFlow, 'step2'>>().toEqualTypeOf<string>();
expectTypeOf<StepOutput<typeof anyFlow, 'step3'>>().toMatchTypeOf<{
complex: { nested: boolean };
}>();
});

Expand All @@ -59,16 +62,15 @@ describe('ExtractFlowSteps utility type', () => {

type Steps = ExtractFlowSteps<typeof primitiveFlow>;

expectTypeOf<Steps>().toMatchTypeOf<{
numberStep: number;
stringStep: string;
booleanStep: boolean;
nullStep: null;
}>();
// Keys are step slugs
expectTypeOf<keyof Steps>().toEqualTypeOf<
'numberStep' | 'stringStep' | 'booleanStep' | 'nullStep'
>();

// ensure it doesn't extract non-existent fields
expectTypeOf<Steps>().not.toMatchTypeOf<{
nonExistentStep: number;
}>();
// Use StepOutput to verify output types
expectTypeOf<StepOutput<typeof primitiveFlow, 'numberStep'>>().toEqualTypeOf<number>();
expectTypeOf<StepOutput<typeof primitiveFlow, 'stringStep'>>().toEqualTypeOf<string>();
expectTypeOf<StepOutput<typeof primitiveFlow, 'booleanStep'>>().toEqualTypeOf<boolean>();
expectTypeOf<StepOutput<typeof primitiveFlow, 'nullStep'>>().toEqualTypeOf<null>();
});
});
108 changes: 108 additions & 0 deletions pkgs/dsl/__tests__/types/skippable-deps.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,3 +438,111 @@ describe('skippable deps type safety', () => {
});
});
});

/**
* Compile-time error tests for skippable deps
*
* These tests use @ts-expect-error to verify that TypeScript correctly
* rejects invalid patterns when accessing skippable dependencies.
*/
describe('skippable deps compile-time errors', () => {
describe('direct property access on optional deps', () => {
it('should reject direct property access on skippable dep without null check', () => {
new Flow<{ value: number }>({ slug: 'test' })
.step({ slug: 'maybeSkipped', retriesExhausted: 'skip' }, () => ({
data: 'result',
}))
.step({ slug: 'consumer', dependsOn: ['maybeSkipped'] }, (deps) => {
// @ts-expect-error - deps.maybeSkipped is optional, cannot access .data directly
const result: string = deps.maybeSkipped.data;
return { result };
});
});

it('should reject direct property access with else: skip', () => {
new Flow<{ value: number }>({ slug: 'test' })
.step({ slug: 'conditional', if: { value: 42 }, else: 'skip' }, () => ({
processed: true,
}))
.step({ slug: 'next', dependsOn: ['conditional'] }, (deps) => {
// @ts-expect-error - deps.conditional is optional due to else: 'skip'
const flag: boolean = deps.conditional.processed;
return { flag };
});
});

it('should reject direct property access with else: skip-cascade', () => {
new Flow<{ value: number }>({ slug: 'test' })
.step(
{ slug: 'cascading', if: { value: 42 }, else: 'skip-cascade' },
() => ({ count: 10 })
)
.step({ slug: 'next', dependsOn: ['cascading'] }, (deps) => {
// @ts-expect-error - deps.cascading is optional due to else: 'skip-cascade'
const num: number = deps.cascading.count;
return { num };
});
});

it('should reject direct property access with retriesExhausted: skip-cascade', () => {
new Flow<{ value: number }>({ slug: 'test' })
.step({ slug: 'risky', retriesExhausted: 'skip-cascade' }, () => ({
status: 'ok',
}))
.step({ slug: 'next', dependsOn: ['risky'] }, (deps) => {
// @ts-expect-error - deps.risky is optional due to retriesExhausted: skip-cascade
const s: string = deps.risky.status;
return { s };
});
});
});

describe('mixed deps - optional and required', () => {
it('should allow direct access on required dep but reject on optional', () => {
new Flow<{ value: number }>({ slug: 'test' })
.step({ slug: 'required' }, () => ({ reqData: 'always' }))
.step({ slug: 'optional', retriesExhausted: 'skip' }, () => ({
optData: 'maybe',
}))
.step(
{ slug: 'consumer', dependsOn: ['required', 'optional'] },
(deps) => {
// This is fine - required dep is always present
const req: string = deps.required.reqData;

// @ts-expect-error - deps.optional is optional, cannot access .optData directly
const opt: string = deps.optional.optData;

return { req, opt };
}
);
});
});

describe('array and map steps with skip modes', () => {
it('should reject direct access on skippable array step output', () => {
new Flow<{ items: string[] }>({ slug: 'test' })
.array({ slug: 'processed', retriesExhausted: 'skip' }, (input) =>
input.items.map((s) => s.toUpperCase())
)
.step({ slug: 'consumer', dependsOn: ['processed'] }, (deps) => {
// @ts-expect-error - deps.processed is optional, cannot access .length directly
const len: number = deps.processed.length;
return { len };
});
});

it('should reject direct access on skippable map step output', () => {
new Flow<string[]>({ slug: 'test' })
.map(
{ slug: 'doubled', retriesExhausted: 'skip' },
(item) => item + item
)
.step({ slug: 'consumer', dependsOn: ['doubled'] }, (deps) => {
// @ts-expect-error - deps.doubled is optional, cannot access [0] directly
const first: string = deps.doubled[0];
return { first };
});
});
});
});
2 changes: 1 addition & 1 deletion pkgs/dsl/src/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ export interface StepRuntimeOptions extends RuntimeOptions {
/**
* What to do when the 'if' pattern doesn't match the input
*
* @default 'fail'
* @default 'skip'
*
* @example
* { else: 'fail' } // Pattern doesn't match -> step fails -> run fails
Expand Down