diff --git a/pkgs/dsl/__tests__/types/condition-pattern.test-d.ts b/pkgs/dsl/__tests__/types/condition-pattern.test-d.ts new file mode 100644 index 000000000..e61f7a130 --- /dev/null +++ b/pkgs/dsl/__tests__/types/condition-pattern.test-d.ts @@ -0,0 +1,319 @@ +import { Flow, type ContainmentPattern } from '../../src/index.js'; +import { describe, it, expectTypeOf } from 'vitest'; + +describe('ContainmentPattern utility type', () => { + describe('primitive types', () => { + it('should allow exact value match for string', () => { + type Pattern = ContainmentPattern; + expectTypeOf().toEqualTypeOf(); + }); + + it('should allow exact value match for number', () => { + type Pattern = ContainmentPattern; + expectTypeOf().toEqualTypeOf(); + }); + + it('should allow exact value match for boolean', () => { + type Pattern = ContainmentPattern; + expectTypeOf().toEqualTypeOf(); + }); + + it('should allow exact value match for null', () => { + type Pattern = ContainmentPattern; + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe('object types', () => { + it('should make all keys optional for simple objects', () => { + type Input = { name: string; age: number }; + type Pattern = ContainmentPattern; + + // All keys should be optional + expectTypeOf().toEqualTypeOf<{ name?: string; age?: number }>(); + }); + + it('should allow empty object pattern (always matches)', () => { + type Input = { name: string; age: number }; + type Pattern = ContainmentPattern; + + // Empty object should be assignable to pattern + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + expectTypeOf<{}>().toMatchTypeOf(); + }); + + it('should handle nested objects recursively', () => { + type Input = { user: { name: string; role: string } }; + type Pattern = ContainmentPattern; + + // Nested object should have optional keys + expectTypeOf().toEqualTypeOf<{ + user?: { name?: string; role?: string }; + }>(); + }); + + it('should allow partial patterns for nested objects', () => { + type Input = { user: { name: string; role: string; age: number } }; + type Pattern = ContainmentPattern; + + // Should be able to specify only some nested keys + const validPattern: Pattern = { user: { role: 'admin' } }; + expectTypeOf(validPattern).toMatchTypeOf(); + }); + }); + + describe('array types', () => { + it('should allow array containment patterns', () => { + type Input = string[]; + type Pattern = ContainmentPattern; + + // Array pattern should be ContainmentPattern[] + expectTypeOf().toEqualTypeOf(); + }); + + it('should handle arrays of objects', () => { + type Input = { type: string; value: number }[]; + type Pattern = ContainmentPattern; + + // Should allow partial object patterns in array + expectTypeOf().toEqualTypeOf<{ type?: string; value?: number }[]>(); + }); + + it('should allow array pattern with specific elements', () => { + type Input = { type: string; value: number }[]; + type Pattern = ContainmentPattern; + + // Should be able to check for specific elements + const validPattern: Pattern = [{ type: 'error' }]; + expectTypeOf(validPattern).toMatchTypeOf(); + }); + + it('should handle readonly arrays', () => { + type Input = readonly string[]; + type Pattern = ContainmentPattern; + + // Should work with readonly arrays + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe('complex nested structures', () => { + it('should handle deeply nested objects', () => { + type Input = { + level1: { + level2: { + level3: { value: string }; + }; + }; + }; + type Pattern = ContainmentPattern; + + // All levels should have optional keys + expectTypeOf().toEqualTypeOf<{ + level1?: { + level2?: { + level3?: { value?: string }; + }; + }; + }>(); + }); + + it('should handle objects with array properties', () => { + type Input = { + items: { id: number; name: string }[]; + meta: { count: number }; + }; + type Pattern = ContainmentPattern; + + expectTypeOf().toEqualTypeOf<{ + items?: { id?: number; name?: string }[]; + meta?: { count?: number }; + }>(); + }); + }); +}); + +describe('condition option typing in step methods', () => { + describe('root step condition', () => { + it('should type condition as ContainmentPattern', () => { + type FlowInput = { userId: string; role: string }; + + // This should compile - valid partial pattern + const flow = new Flow({ slug: 'test_flow' }).step( + { slug: 'check', condition: { role: 'admin' } }, + (input) => input.userId + ); + + expectTypeOf(flow).toBeObject(); + }); + + it('should reject invalid keys in condition', () => { + type FlowInput = { userId: string; role: string }; + + // @ts-expect-error - 'invalidKey' does not exist on FlowInput + new Flow({ slug: 'test_flow' }).step( + { slug: 'check', condition: { invalidKey: 'value' } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (input: any) => input.userId + ); + }); + + it('should reject wrong value types in condition', () => { + type FlowInput = { userId: string; role: string }; + + // @ts-expect-error - role should be string, not number + new Flow({ slug: 'test_flow' }).step( + { slug: 'check', condition: { role: 123 } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (input: any) => input.userId + ); + }); + + it('should allow empty object condition (always matches)', () => { + type FlowInput = { userId: string; role: string }; + + // Empty object should be valid + const flow = new Flow({ slug: 'test_flow' }).step( + { slug: 'check', condition: {} }, + (input) => input.userId + ); + + expectTypeOf(flow).toBeObject(); + }); + + it('should allow nested object patterns', () => { + type FlowInput = { user: { name: string; role: string } }; + + const flow = new Flow({ slug: 'test_flow' }).step( + { slug: 'check', condition: { user: { role: 'admin' } } }, + (input) => input.user.name + ); + + expectTypeOf(flow).toBeObject(); + }); + }); + + describe('dependent step condition', () => { + it('should type condition as ContainmentPattern', () => { + const flow = new Flow<{ initial: string }>({ slug: 'test_flow' }) + .step({ slug: 'fetch' }, () => ({ status: 'ok', data: 'result' })) + .step( + { + slug: 'process', + dependsOn: ['fetch'], + condition: { fetch: { status: 'ok' } }, + }, + (deps) => deps.fetch.data + ); + + expectTypeOf(flow).toBeObject(); + }); + + it('should reject invalid dep slug in condition', () => { + new Flow<{ initial: string }>({ slug: 'test_flow' }) + .step({ slug: 'fetch' }, () => ({ status: 'ok' })) + .step( + { + slug: 'process', + dependsOn: ['fetch'], + // @ts-expect-error - 'nonexistent' is not a dependency + condition: { nonexistent: { status: 'ok' } }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (deps: any) => deps.fetch.status + ); + }); + + it('should reject invalid keys within dep output', () => { + new Flow<{ initial: string }>({ slug: 'test_flow' }) + .step({ slug: 'fetch' }, () => ({ status: 'ok' })) + .step( + { + slug: 'process', + dependsOn: ['fetch'], + // @ts-expect-error - 'invalidField' does not exist on fetch output + condition: { fetch: { invalidField: 'value' } }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (deps: any) => deps.fetch.status + ); + }); + + it('should handle multiple dependencies in condition', () => { + const flow = new Flow<{ initial: string }>({ slug: 'test_flow' }) + .step({ slug: 'step1' }, () => ({ ready: true })) + .step({ slug: 'step2' }, () => ({ valid: true })) + .step( + { + slug: 'final', + dependsOn: ['step1', 'step2'], + condition: { step1: { ready: true }, step2: { valid: true } }, + }, + (deps) => deps.step1.ready && deps.step2.valid + ); + + expectTypeOf(flow).toBeObject(); + }); + }); + + describe('array step condition', () => { + it('should type condition for root array step', () => { + type FlowInput = { items: string[]; enabled: boolean }; + + const flow = new Flow({ slug: 'test_flow' }).array( + { slug: 'getItems', condition: { enabled: true } }, + (input) => input.items + ); + + expectTypeOf(flow).toBeObject(); + }); + + it('should type condition for dependent array step', () => { + const flow = new Flow<{ initial: string }>({ slug: 'test_flow' }) + .step({ slug: 'fetch' }, () => ({ ready: true, items: ['a', 'b'] })) + .array( + { + slug: 'process', + dependsOn: ['fetch'], + condition: { fetch: { ready: true } }, + }, + (deps) => deps.fetch.items + ); + + expectTypeOf(flow).toBeObject(); + }); + }); + + describe('map step condition', () => { + it('should type condition for root map step', () => { + type FlowInput = { type: string; value: number }[]; + + const flow = new Flow({ slug: 'test_flow' }).map( + // Root map condition checks the array itself + { slug: 'process', condition: [{ type: 'active' }] }, + (item) => item.value * 2 + ); + + expectTypeOf(flow).toBeObject(); + }); + + it('should type condition for dependent map step', () => { + const flow = new Flow<{ initial: string }>({ slug: 'test_flow' }) + .step({ slug: 'fetch' }, () => [ + { id: 1, active: true }, + { id: 2, active: false }, + ]) + .map( + { + slug: 'process', + array: 'fetch', + // Condition checks the array dep + condition: { fetch: [{ active: true }] }, + }, + (item) => item.id + ); + + expectTypeOf(flow).toBeObject(); + }); + }); +}); diff --git a/pkgs/dsl/src/dsl.ts b/pkgs/dsl/src/dsl.ts index a6e0f6689..555274d9c 100644 --- a/pkgs/dsl/src/dsl.ts +++ b/pkgs/dsl/src/dsl.ts @@ -16,6 +16,22 @@ export type Json = // Used to flatten the types of a union of objects for readability export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; +/** + * ContainmentPattern - Type for JSON containment (@>) patterns + * + * Matches PostgreSQL's @> containment semantics where a pattern is a + * recursive partial structure that the target must contain: + * - Primitives: exact value match + * - Objects: all keys optional, recursively applied + * - Arrays: elements expected to be present in target array + */ +export type ContainmentPattern = + T extends readonly (infer U)[] + ? ContainmentPattern[] // Array: elements expected to be present + : T extends object + ? { [K in keyof T]?: ContainmentPattern } // Object: all keys optional + : T; // Primitive: exact value match + // Utility that unwraps Promise and keeps plain values unchanged // Note: `any[]` is required here for proper type inference in conditional types // `unknown[]` would be too restrictive and break type matching @@ -324,6 +340,7 @@ export type WhenUnmetMode = 'fail' | 'skip' | 'skip-cascade'; export type WhenFailedMode = 'fail' | 'skip' | 'skip-cascade'; // Step runtime options interface that extends flow options with step-specific options +// Note: condition is typed as Json here for internal storage; overloads provide type safety export interface StepRuntimeOptions extends RuntimeOptions { startDelay?: number; condition?: Json; // JSON pattern for @> containment check @@ -331,6 +348,23 @@ export interface StepRuntimeOptions extends RuntimeOptions { whenFailed?: WhenFailedMode; // What to do when handler fails after retries } +// Base runtime options without condition (for typed overloads) +interface BaseStepRuntimeOptions extends RuntimeOptions { + startDelay?: number; + whenUnmet?: WhenUnmetMode; + whenFailed?: WhenFailedMode; +} + +// Typed step options for root steps (condition matches FlowInput pattern) +type RootStepOptions = BaseStepRuntimeOptions & { + condition?: ContainmentPattern; +}; + +// Typed step options for dependent steps (condition matches deps object pattern) +type DependentStepOptions = BaseStepRuntimeOptions & { + condition?: ContainmentPattern; +}; + // Define the StepDefinition interface with integrated options export interface StepDefinition< TInput extends AnyInput, @@ -422,11 +456,12 @@ export class Flow< } // Overload 1: Root step (no dependsOn) - receives flowInput directly + // condition is typed as ContainmentPattern step< Slug extends string, TOutput >( - opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; dependsOn?: never } & StepRuntimeOptions>, + opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; dependsOn?: never } & RootStepOptions>, handler: ( flowInput: TFlowInput, context: FlowContext & TContext @@ -440,13 +475,14 @@ export class Flow< >; // Overload 2: Dependent step (with dependsOn) - receives deps, flowInput via context + // condition is typed as ContainmentPattern // Note: [Deps, ...Deps[]] requires at least one dependency - empty arrays are rejected at compile time step< Slug extends string, Deps extends Extract, TOutput >( - opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; dependsOn: [Deps, ...Deps[]] } & StepRuntimeOptions>, + opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; dependsOn: [Deps, ...Deps[]] } & DependentStepOptions<{ [K in Deps]: K extends keyof Steps ? Steps[K] : never }>>, handler: ( deps: { [K in Deps]: K extends keyof Steps ? Steps[K] : never }, context: FlowContext & TContext @@ -532,11 +568,12 @@ export class Flow< * @returns A new Flow instance with the array step added */ // Overload 1: Root array (no dependsOn) - receives flowInput directly + // condition is typed as ContainmentPattern array< Slug extends string, TOutput extends readonly any[] >( - opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; dependsOn?: never } & StepRuntimeOptions>, + opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; dependsOn?: never } & RootStepOptions>, handler: ( flowInput: TFlowInput, context: FlowContext & TContext @@ -550,13 +587,14 @@ export class Flow< >; // Overload 2: Dependent array (with dependsOn) - receives deps, flowInput via context + // condition is typed as ContainmentPattern // Note: [Deps, ...Deps[]] requires at least one dependency - empty arrays are rejected at compile time array< Slug extends string, Deps extends Extract, TOutput extends readonly any[] >( - opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; dependsOn: [Deps, ...Deps[]] } & StepRuntimeOptions>, + opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; dependsOn: [Deps, ...Deps[]] } & DependentStepOptions<{ [K in Deps]: K extends keyof Steps ? Steps[K] : never }>>, handler: ( deps: { [K in Deps]: K extends keyof Steps ? Steps[K] : never }, context: FlowContext & TContext @@ -587,13 +625,14 @@ export class Flow< * @returns A new Flow instance with the map step added */ // Overload for root map - handler receives item, context includes flowInput + // condition is typed as ContainmentPattern (checks the array itself) map< Slug extends string, THandler extends TFlowInput extends readonly (infer Item)[] ? (item: Item, context: FlowContext & TContext) => Json | Promise : never >( - opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug } & StepRuntimeOptions>, + opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug } & RootStepOptions>, handler: THandler ): Flow< TFlowInput, @@ -604,6 +643,7 @@ export class Flow< >; // Overload for dependent map - handler receives item, context includes flowInput + // condition is typed as ContainmentPattern<{ arrayDep: ArrayOutput }> (checks the dep object) map< Slug extends string, TArrayDep extends Extract, @@ -611,7 +651,7 @@ export class Flow< ? (item: Item, context: FlowContext & TContext) => Json | Promise : never >( - opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; array: TArrayDep } & StepRuntimeOptions>, + opts: Simplify<{ slug: Slug extends keyof Steps ? never : Slug; array: TArrayDep } & DependentStepOptions<{ [K in TArrayDep]: Steps[K] }>>, handler: THandler ): Flow< TFlowInput,