diff --git a/pkgs/core/supabase/tests/condition_evaluation/dependent_step_condition_met.test.sql b/pkgs/core/supabase/tests/condition_evaluation/dependent_step_condition_met.test.sql index a3e275eb9..a0b1f4538 100644 --- a/pkgs/core/supabase/tests/condition_evaluation/dependent_step_condition_met.test.sql +++ b/pkgs/core/supabase/tests/condition_evaluation/dependent_step_condition_met.test.sql @@ -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 ); diff --git a/pkgs/core/supabase/tests/condition_evaluation/dependent_step_condition_unmet_skip.test.sql b/pkgs/core/supabase/tests/condition_evaluation/dependent_step_condition_unmet_skip.test.sql index 3dbf44a9a..be8c2b4ba 100644 --- a/pkgs/core/supabase/tests/condition_evaluation/dependent_step_condition_unmet_skip.test.sql +++ b/pkgs/core/supabase/tests/condition_evaluation/dependent_step_condition_unmet_skip.test.sql @@ -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 ); diff --git a/pkgs/core/supabase/tests/condition_evaluation/plain_skip_iterates_until_convergence.test.sql b/pkgs/core/supabase/tests/condition_evaluation/plain_skip_iterates_until_convergence.test.sql index 49d43091e..faea7f191 100644 --- a/pkgs/core/supabase/tests/condition_evaluation/plain_skip_iterates_until_convergence.test.sql +++ b/pkgs/core/supabase/tests/condition_evaluation/plain_skip_iterates_until_convergence.test.sql @@ -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( @@ -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( diff --git a/pkgs/core/supabase/tests/condition_evaluation/plain_skip_propagates_to_map.test.sql b/pkgs/core/supabase/tests/condition_evaluation/plain_skip_propagates_to_map.test.sql index 7d0d31d0c..cd850c1ce 100644 --- a/pkgs/core/supabase/tests/condition_evaluation/plain_skip_propagates_to_map.test.sql +++ b/pkgs/core/supabase/tests/condition_evaluation/plain_skip_propagates_to_map.test.sql @@ -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 diff --git a/pkgs/core/supabase/tests/condition_evaluation/skipped_deps_excluded_from_input.test.sql b/pkgs/core/supabase/tests/condition_evaluation/skipped_deps_excluded_from_input.test.sql index 8d3380d4f..abcdd5bb1 100644 --- a/pkgs/core/supabase/tests/condition_evaluation/skipped_deps_excluded_from_input.test.sql +++ b/pkgs/core/supabase/tests/condition_evaluation/skipped_deps_excluded_from_input.test.sql @@ -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( diff --git a/pkgs/dsl/__tests__/types/extract-flow-steps.test-d.ts b/pkgs/dsl/__tests__/types/extract-flow-steps.test-d.ts index 6b8143703..ed5f3f92e 100644 --- a/pkgs/dsl/__tests__/types/extract-flow-steps.test-d.ts +++ b/pkgs/dsl/__tests__/types/extract-flow-steps.test-d.ts @@ -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'] }, () => [ @@ -12,15 +14,17 @@ describe('ExtractFlowSteps utility type', () => { type Steps = ExtractFlowSteps; - expectTypeOf().toMatchTypeOf<{ - fetchUser: { name: string; age: number }; - fetchPosts: Array<{ id: number; title: string }>; - }>(); + // Keys are step slugs + expectTypeOf().toEqualTypeOf<'fetchUser' | 'fetchPosts'>(); - // ensure it doesn't extract non-existent fields - expectTypeOf().not.toMatchTypeOf<{ - nonExistentStep: number; + // Use StepOutput to get output types (public API) + expectTypeOf>().toMatchTypeOf<{ + name: string; + age: number; }>(); + expectTypeOf>().toMatchTypeOf< + Array<{ id: number; title: string }> + >(); }); it('should work with AnyFlow to extract steps from a generic flow', () => { @@ -31,15 +35,14 @@ describe('ExtractFlowSteps utility type', () => { type Steps = ExtractFlowSteps; - expectTypeOf().toMatchTypeOf<{ - step1: number; - step2: string; - step3: { complex: { nested: boolean } }; - }>(); + // Keys are step slugs + expectTypeOf().toEqualTypeOf<'step1' | 'step2' | 'step3'>(); - // ensure it doesn't extract non-existent fields - expectTypeOf().not.toMatchTypeOf<{ - nonExistentStep: number; + // Use StepOutput to verify output types + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toMatchTypeOf<{ + complex: { nested: boolean }; }>(); }); @@ -59,16 +62,15 @@ describe('ExtractFlowSteps utility type', () => { type Steps = ExtractFlowSteps; - expectTypeOf().toMatchTypeOf<{ - numberStep: number; - stringStep: string; - booleanStep: boolean; - nullStep: null; - }>(); + // Keys are step slugs + expectTypeOf().toEqualTypeOf< + 'numberStep' | 'stringStep' | 'booleanStep' | 'nullStep' + >(); - // ensure it doesn't extract non-existent fields - expectTypeOf().not.toMatchTypeOf<{ - nonExistentStep: number; - }>(); + // Use StepOutput to verify output types + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); }); }); diff --git a/pkgs/dsl/__tests__/types/skippable-deps.test-d.ts b/pkgs/dsl/__tests__/types/skippable-deps.test-d.ts index fc7d66330..54c2a8d3e 100644 --- a/pkgs/dsl/__tests__/types/skippable-deps.test-d.ts +++ b/pkgs/dsl/__tests__/types/skippable-deps.test-d.ts @@ -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({ 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 }; + }); + }); + }); +}); diff --git a/pkgs/dsl/src/dsl.ts b/pkgs/dsl/src/dsl.ts index 8703705a2..9805f03b5 100644 --- a/pkgs/dsl/src/dsl.ts +++ b/pkgs/dsl/src/dsl.ts @@ -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