Skip to content
Merged
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
61 changes: 55 additions & 6 deletions src/extraction/getFlowSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export type SchematizedFlow = Flow & SignatureSchemas
*
* The schemas are computed from the real signatures, resolved through the same
* virtual TypeScript program that {@link generateFlowSourceCode} builds:
* - For the flow, the schemas come from the flow's own `signature`.
* - For the flow, the schemas come from the flow's own `signature`, resolved at
* the generated `flow(<settings>)` call so the flow settings instantiate its
* type parameters — a generic link between input and output (as a REST flow's
* `<T extends TYPE>(input_schema: T, …): REST_ADAPTER_INPUT<T>`) therefore
* carries the shape the settings actually supply.
* - For a sub-flow, the schemas come from the *value* generated at that argument
* position — the lambda `(...p) => { …body… }` emitted by
* {@link generateFlowSourceCode} — not from the callback parameter the
Expand Down Expand Up @@ -119,9 +123,23 @@ const subFlowAlias = (
): string => `Node_${sanitizeId(nodeId!)}_${parameterIndex}_${kind}`

/**
* Collects the flow's own input/output type expressions from its declared
* signature (`declare function flow(...): ...` in the generated source). A flow
* without a signature contributes nothing and falls back to the empty schemas.
* Collects the flow's own input/output type expressions from its signature.
*
* The signature is read from the generated `flow(...)` *call* — the one
* {@link generateFlowSourceCode} emits with the flow's settings as positional
* arguments — rather than from the `declare function flow(...)` declaration, so
* the settings instantiate the signature's type parameters. This is what makes a
* generic link between input and output hold: a REST flow declared as
* `<T extends TYPE>(input_schema: T, …): REST_ADAPTER_INPUT<T>` has its payload
* resolved to the shape the `input_schema` setting actually carries, instead of
* collapsing to the `TYPE` bound. The same resolution already drives the sub-flow
* schemas (whose lambdas are contextually typed by their call), which is why they
* reflect the settings while the flow's own schemas did not.
*
* Falls back to the declaration's signature when no call was generated (no flow)
* — a flow whose settings are missing or unfilled simply leaves each type
* parameter at its constraint. A flow without a signature contributes nothing and
* falls back to the empty schemas.
*/
const collectFlowTypeExpressions = (
checker: ts.TypeChecker,
Expand All @@ -132,17 +150,48 @@ const collectFlowTypeExpressions = (
.find((declaration) => declaration.name?.getText() === "flow")
if (!flowDeclaration) return {}

const flowSignature = checker.getSignatureFromDeclaration(flowDeclaration)
const flowCall = findFlowCallExpression(sourceFile)
const resolvedSignature = flowCall
? checker.getResolvedSignature(flowCall)
: undefined
const flowSignature =
resolvedSignature ?? checker.getSignatureFromDeclaration(flowDeclaration)
if (!flowSignature) return {}

const {input, output} = signatureToTypeExpressions(
checker,
flowSignature,
flowDeclaration,
flowCall ?? flowDeclaration,
)
return {[FLOW_INPUT_ALIAS]: input, [FLOW_OUTPUT_ALIAS]: output}
}

/**
* Finds the generated `flow(<settings>)` call, i.e. the initializer of the
* `const flow_<id> = flow(...)` statement. Traverses the AST because that
* constant lives inside the generated IIFE.
*/
const findFlowCallExpression = (
sourceFile: ts.SourceFile,
): ts.CallExpression | undefined => {
let flowCall: ts.CallExpression | undefined

sourceFile.forEachChild(function visit(child) {
if (flowCall) return
const isFlowCall =
ts.isCallExpression(child) &&
ts.isIdentifier(child.expression) &&
child.expression.text === "flow"
if (isFlowCall) {
flowCall = child as ts.CallExpression
return
}
child.forEachChild(visit)
})

return flowCall
}

/**
* Collects the input/output type expressions of every sub-flow parameter in the
* flow. The schema is read from the *value* generated for the sub-flow — i.e. the
Expand Down
13 changes: 4 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,6 @@ export function generateFlowSourceCode(
flow?: Flow,
functions?: FunctionDefinition[],
dataTypes?: DataType[],
isForInference: boolean = false,
assertNonNullReferences: boolean = true
): string {
const nodes = flow?.nodes?.nodes || [];
Expand Down Expand Up @@ -430,14 +429,10 @@ export function generateFlowSourceCode(
const params = (node.parameters?.nodes as NodeParameter[]) || [];
const args = params.map((p, index) => {
const val = p.value;
// A parameter without a value: during inference the `{}` placeholder
// is asserted to the cast so an unfilled slot still carries its
// intended type. During validation the `undefined` placeholder stays
// uncast — the cast would silently satisfy the parameter and hide the
// fact that nothing was provided.
const missing = isForInference
? `/* @pos ${id} ${index} */ ${applyCast("{}", p.cast)}`
: `/* @pos ${id} ${index} */ undefined`;
// A parameter without a value is emitted as an uncast `undefined`
// placeholder: applying the cast would silently satisfy the parameter
// and hide the fact that nothing was provided.
const missing = `/* @pos ${id} ${index} */ undefined`;
if (!val) return missing;
if (val.__typename === "ReferenceValue") {
return `/* @pos ${id} ${index} */ ${applyCast(renderReference(val as ReferenceValue), p.cast)}`;
Expand Down
2 changes: 1 addition & 1 deletion src/validation/getFlowValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export const getFlowValidation = (
// Validation keeps reference nullability (assertNonNullReferences = false) so that a
// possibly-null reference into a non-null parameter surfaces as a diagnostic, which is
// then downgraded to a warning below instead of being silently suppressed by a `!`.
const sourceCode = generateFlowSourceCode(flow, functions, dataTypes, false, false);
const sourceCode = generateFlowSourceCode(flow, functions, dataTypes, false);

// 3. Virtual TypeScript Compilation
const fileName = "index.ts";
Expand Down
10 changes: 6 additions & 4 deletions test/flowCast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,22 +156,24 @@ describe("cast handling in the generated flow source", () => {
FUNCTION_SIGNATURES,
DATA_TYPES,
false,
false,
);

expect(source).toContain("*/ undefined");
expect(source).not.toContain("(undefined) as");
});

it("asserts an unfilled parameter to its cast during inference", () => {
it("leaves an unfilled parameter uncast for inference and schemas too", () => {
// The unfilled slot stays an uncast `undefined` placeholder regardless of
// how references are rendered: applying the cast would make an empty slot
// look filled, so an unfilled parameter contributes no type anywhere.
const source = generateFlowSourceCode(
firstOf({value: null, cast: "LIST<TEXT>"}),
FUNCTION_SIGNATURES,
DATA_TYPES,
true,
);

expect(source).toContain("({}) as unknown as LIST<TEXT>");
expect(source).toContain("*/ undefined");
expect(source).not.toContain("as unknown as LIST<TEXT>");
});

it("asserts a flow setting value to its cast", () => {
Expand Down
69 changes: 63 additions & 6 deletions test/flowSchemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,25 +111,82 @@ describe("getFlowSchemas", () => {

const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES);

// TYPE is `any` underneath, so it maps to the open object schema; the
// concrete parameters resolve fully.
// The flow carries no settings, so there is nothing to bind T to and it
// stays at its `TYPE` bound — the open object schema. The concrete
// parameters resolve fully.
expect(result?.inputSchema.properties?.input_schema)
.toEqual({});
.toEqual({type: "object", additionalProperties: false});
expect(result?.inputSchema.properties?.httpURL).toEqual({type: "string"});
expect((result?.inputSchema.properties?.httpMethod as JsonSchema).enum)
.toEqual(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]);
expect(result?.inputSchema.required)
.toEqual(["input_schema", "httpURL", "httpMethod"]);

// REST_ADAPTER_INPUT<TYPE> resolves structurally; the TYPE payload maps to
// the open object schema while the concrete fields become object schemas.
// REST_ADAPTER_INPUT<TYPE> resolves structurally; with T unbound its
// payload is the same open object schema as the input slot, while the
// concrete fields become object schemas.
expect(result?.outputSchema.type).toBe("object");
expect(result?.outputSchema.properties?.payload)
.toEqual({});
.toEqual({type: "object", additionalProperties: false});
expect(result?.outputSchema.required)
.toEqual(["payload", "headers", "query_params", "path_params"]);
});

it("instantiates the flow's generic schemas from its settings", () => {
// The REST trigger links input to output generically: whatever
// `input_schema` binds T to is what REST_ADAPTER_INPUT<T> echoes as its
// payload. The settings are the values that binding comes from, so they
// have to reach the flow's own schemas — not just the sub-flows'.
const flow: Flow = {
...flowWithNodes(
[],
"<T extends TYPE>(input_schema: T, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT<T>",
),
settings: {
nodes: [
{value: {name: "text", age: 5}},
{value: "/users"},
{value: "GET"},
],
},
};

const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES);

const boundSchema: JsonSchema = {
type: "object",
additionalProperties: false,
properties: {name: {type: "string"}, age: {type: "number"}},
required: ["name", "age"],
};

// T is bound from the input_schema setting, so the input slot carries the
// concrete shape rather than the TYPE bound.
expect(result?.inputSchema.properties?.input_schema).toEqual(boundSchema);

// …and the generically linked payload echoes that same shape.
expect(result?.outputSchema.properties?.payload).toEqual(boundSchema);

// The remaining REST fields stay open objects.
expect(result?.outputSchema.required)
.toEqual(["payload", "headers", "query_params", "path_params"]);
});

it("falls back to the parameter bounds when a setting carries no value", () => {
// An unfilled setting binds nothing: T stays at its TYPE bound instead of
// collapsing to the `undefined` placeholder the slot renders as.
const flow: Flow = {
...flowWithNodes([], "<T extends TYPE>(input_schema: T): REST_ADAPTER_INPUT<T>"),
settings: {nodes: [{}]},
};

const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES);

expect(result?.inputSchema.properties?.input_schema)
.toEqual({type: "object", additionalProperties: false});
expect(result?.outputSchema.type).toBe("object");
});

it("maps the DATE data type to its underlying number type", () => {
const flow = flowWithNodes([], "(created: DATE): void");

Expand Down
Loading