Skip to content

Commit b9c755a

Browse files
authored
Merge pull request #164 from code0-tech/feat/#163
Update to newest sagittarius and add support for inline references
2 parents 46ef77b + 3a6a563 commit b9c755a

6 files changed

Lines changed: 540 additions & 37 deletions

File tree

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"build": "vite build"
3636
},
3737
"devDependencies": {
38-
"@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2744343400-55133b9bb6062772c50f83d945a22a531a6e48e2",
38+
"@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2780674370-053799e843870f117430b3b00f75ffec35767193",
3939
"@types/node": "^25.6.0",
4040
"@typescript/vfs": "^1.6.4",
4141
"lossless-json": "^4.3.1",
@@ -46,7 +46,7 @@
4646
"vitest": "^4.1.8"
4747
},
4848
"peerDependencies": {
49-
"@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2744343400-55133b9bb6062772c50f83d945a22a531a6e48e2",
49+
"@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2780674370-053799e843870f117430b3b00f75ffec35767193",
5050
"@typescript/vfs": "^1.6.4",
5151
"lossless-json": "^4.3.0",
5252
"ts-json-schema-generator": "^2.9.0",

src/utils.ts

Lines changed: 155 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
NodeFunction,
77
SubFlowValue,
88
NodeParameter,
9-
ReferenceValue, Maybe
9+
ReferenceValue, Maybe,
10+
InlineReferenceValue
1011
} from "@code0-tech/sagittarius-graphql-types";
1112
import ts from "typescript";
1213
import {createSystem, createVirtualTypeScriptEnvironment, VirtualTypeScriptEnvironment} from "@typescript/vfs"
@@ -160,6 +161,75 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s
160161
*/
161162
export const sanitizeId = (id: string) => id?.replace(/[^a-zA-Z0-9]/g, '_');
162163

164+
/**
165+
* Escapes text for safe inclusion inside a template literal, neutralising
166+
* backslashes, backticks and any literal `${` that is not a resolved reference.
167+
*/
168+
const escapeTemplateText = (text: string): string =>
169+
text.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
170+
171+
/**
172+
* Emits a string leaf of a literal value. If the string contains `${signature}`
173+
* tokens that match known inline references, it is emitted as a template literal
174+
* with those tokens spliced as the referenced expressions; unknown tokens are
175+
* preserved literally. Strings without any known reference are emitted as plain
176+
* JSON string literals, identical to the prior behaviour.
177+
*/
178+
const emitStringLeaf = (value: string, refExpr: Map<string, string>): string => {
179+
// Whole-string standalone reference: the leaf is exactly `${signature}` with
180+
// no surrounding text. Emit the raw referenced expression so its actual type
181+
// is preserved (a NUMBER reference stays a NUMBER rather than being coerced
182+
// to string). This matters for references sitting directly on an array
183+
// element or object value, e.g. `[1, "${x}", 3]` or `{count: "${x}"}`.
184+
const standalone = value.match(/^\$\{([^}]+)\}$/);
185+
if (standalone && refExpr.has(standalone[1])) {
186+
return refExpr.get(standalone[1])!;
187+
}
188+
189+
const tokenRe = /\$\{([^}]+)\}/g;
190+
191+
let hasKnown = false;
192+
for (const match of value.matchAll(tokenRe)) {
193+
if (refExpr.has(match[1])) {
194+
hasKnown = true;
195+
break;
196+
}
197+
}
198+
if (!hasKnown) return stringify(value) ?? '""';
199+
200+
let result = "";
201+
let last = 0;
202+
for (const match of value.matchAll(tokenRe)) {
203+
const [full, signature] = match;
204+
const start = match.index!;
205+
result += escapeTemplateText(value.slice(last, start));
206+
result += refExpr.has(signature)
207+
? "${" + refExpr.get(signature) + "}"
208+
: escapeTemplateText(full);
209+
last = start + full.length;
210+
}
211+
result += escapeTemplateText(value.slice(last));
212+
return "`" + result + "`";
213+
};
214+
215+
/**
216+
* Recursively emits a literal JSON value as a TypeScript expression, splicing
217+
* inline references into string leaves. Non-string primitives keep their
218+
* lossless-JSON representation so number precision is preserved.
219+
*/
220+
const emitLiteralNode = (value: unknown, refExpr: Map<string, string>): string => {
221+
if (typeof value === "string") return emitStringLeaf(value, refExpr);
222+
if (Array.isArray(value)) {
223+
return `[${value.map(item => emitLiteralNode(item, refExpr)).join(", ")}]`;
224+
}
225+
if (value !== null && typeof value === "object") {
226+
const entries = Object.entries(value as Record<string, unknown>)
227+
.map(([key, val]) => `${stringify(key)}: ${emitLiteralNode(val, refExpr)}`);
228+
return `{${entries.join(", ")}}`;
229+
}
230+
return stringify(value) ?? "undefined";
231+
};
232+
163233
/**
164234
* Generates TypeScript source code for a flow, suitable for validation and type inference.
165235
*/
@@ -174,6 +244,87 @@ export function generateFlowSourceCode(
174244
const funcMap = new Map(functions?.map(f => [f.identifier, f]));
175245
const visited = new Set<NodeFunction['id']>();
176246

247+
// Emits the TypeScript expression for a reference value (without the leading
248+
// `@pos` marker). A reference may be nullable (`string | null`) or reach
249+
// through optional properties (an optional chain like `node_X?.text`). When
250+
// `assertNonNullReferences` is set (inference/schema), the nullish part is
251+
// waived with a `!` so the base type flows cleanly. During validation it is
252+
// left in place so a possibly-null reference feeding a non-null parameter
253+
// surfaces a diagnostic — which is then downgraded to a warning rather than
254+
// being silently erased. Base type mismatches still fail validation in both
255+
// modes. Reused both for top-level parameters and for inline references
256+
// spliced into literal values.
257+
const renderReference = (ref: ReferenceValue): string => {
258+
let refCode = typeof ref.inputIndex === "number"
259+
? `p_${sanitizeId(ref.nodeFunctionId ?? "undefined")}_${ref.parameterIndex}[${ref.inputIndex}]`
260+
: ref.nodeFunctionId ? `node_${sanitizeId(ref.nodeFunctionId)}` : `flow_${sanitizeId(flow?.id ?? "undefined")}`;
261+
ref.referencePath?.forEach(pathObj => {
262+
refCode += `?.${pathObj.path}`;
263+
});
264+
const nonNull = assertNonNullReferences ? "!" : "";
265+
return `(${refCode})${nonNull}`;
266+
};
267+
268+
// Emits the TypeScript expression for a sub-flow value (without the leading
269+
// `@pos` marker). A sub-flow that directly maps an existing function emits
270+
// that function reference so its signature drives the sub-flow's I/O;
271+
// otherwise a lambda wrapping the sub-tree is generated. The lambda parameter
272+
// name deliberately stays `p_${id}_${index}` (the parent node/param slot):
273+
// references to the sub-flow's own inputs resolve through that exact name
274+
// (see `renderReference`). Multiple sub-flows spliced into the same literal
275+
// (e.g. an array) are sibling expressions in separate function scopes, so an
276+
// identical parameter name across them does not collide.
277+
const renderSubFlow = (
278+
wrapper: SubFlowValue,
279+
id: NodeFunction['id'] | FunctionDefinition['identifier'],
280+
index: number,
281+
indent: string
282+
): string => {
283+
if (!wrapper.startingNodeId && wrapper.functionDefinition?.identifier) {
284+
return `fn_${wrapper.functionDefinition.identifier.replace(/::/g, '_')}`;
285+
}
286+
const lambdaArgName = `p_${sanitizeId(id as string)}_${index}`;
287+
const subTreeCode = generateNodeCode(wrapper.startingNodeId || wrapper.functionDefinition?.id!, indent + " ");
288+
return `(...${lambdaArgName}) => {\n${subTreeCode}${indent}}`;
289+
};
290+
291+
// Emits the TypeScript expression for a literal value. When the literal
292+
// carries inline references (addressable via `${signature}` inside its
293+
// string leaves), each occurrence is spliced with the generated expression
294+
// of the referenced value so the value is type-checked. A string leaf that
295+
// consists solely of a single `${signature}` preserves the referenced type
296+
// verbatim (a NUMBER reference stays a NUMBER); a leaf with surrounding text
297+
// or multiple tokens becomes a template literal (a string). References are
298+
// resolved recursively through nested literals, arrays and objects. Without
299+
// references, the original lossless-JSON stringification is preserved.
300+
const renderLiteral = (
301+
value: unknown,
302+
references: Maybe<InlineReferenceValue[]> | undefined,
303+
ctx: { id: NodeFunction['id'] | FunctionDefinition['identifier']; index: number; indent: string }
304+
): string | undefined => {
305+
if (value === null || value === undefined) return undefined;
306+
307+
const refExpr = new Map<string, string>();
308+
for (const r of references ?? []) {
309+
if (!r?.signature) continue;
310+
const inner = r.value;
311+
if (inner?.__typename === "ReferenceValue") {
312+
refExpr.set(r.signature, renderReference(inner as ReferenceValue));
313+
} else if (inner?.__typename === "LiteralValue") {
314+
const nested = renderLiteral(inner.value, inner.references, ctx);
315+
if (nested !== undefined) refExpr.set(r.signature, nested);
316+
} else if (inner?.__typename === "SubFlowValue") {
317+
refExpr.set(r.signature, renderSubFlow(inner as SubFlowValue, ctx.id, ctx.index, ctx.indent));
318+
}
319+
}
320+
321+
// No resolvable inline references: keep the exact prior behaviour so
322+
// existing generated code (and number precision) is unchanged.
323+
if (refExpr.size === 0) return stringify(value);
324+
325+
return emitLiteralNode(value, refExpr);
326+
};
327+
177328
const generateNodeCode = (id: NodeFunction['id'] | FunctionDefinition['identifier'], indent: string = ""): string => {
178329
const node = nodes.find(n => n?.id === id);
179330
if (!node || !node.functionDefinition) return "";
@@ -187,41 +338,19 @@ export function generateFlowSourceCode(
187338
const val = p.value;
188339
if (!val) return isForInference ? `/* @pos ${id} ${index} */ {}` : `/* @pos ${id} ${index} */ undefined`;
189340
if (val.__typename === "ReferenceValue") {
190-
const ref = val as ReferenceValue;
191-
let refCode = typeof ref.inputIndex === "number"
192-
? `p_${sanitizeId(ref.nodeFunctionId ?? "undefined")}_${ref.parameterIndex}[${ref.inputIndex}]`
193-
: ref.nodeFunctionId ? `node_${sanitizeId(ref.nodeFunctionId)}` : `flow_${sanitizeId(flow?.id ?? "undefined")}`;
194-
ref.referencePath?.forEach(pathObj => {
195-
refCode += `?.${pathObj.path}`;
196-
});
197-
// A reference may be nullable (`string | null`) or reach through optional
198-
// properties (an optional chain like `node_X?.text`). When `assertNonNullReferences`
199-
// is set (inference/schema), the nullish part is waived with a `!` so the base
200-
// type flows cleanly. During validation it is left in place so a possibly-null
201-
// reference feeding a non-null parameter surfaces a diagnostic — which is then
202-
// downgraded to a warning rather than being silently erased. Base type
203-
// mismatches still fail validation in both modes.
204-
const nonNull = assertNonNullReferences ? "!" : "";
205-
return `/* @pos ${id} ${index} */ (${refCode})${nonNull}`;
341+
return `/* @pos ${id} ${index} */ ${renderReference(val as ReferenceValue)}`;
206342
}
207343
if (val.__typename === "LiteralValue") {
208-
const jsonString = val?.value !== null && val?.value !== undefined ? stringify(val?.value) : undefined
344+
const jsonString = renderLiteral(val.value, val.references, {id, index, indent});
209345
return `/* @pos ${id} ${index} */ ${jsonString}`;
210346
}
211347
if (val.__typename === "SubFlowValue") {
212-
const wrapper = val as SubFlowValue;
213348
// Direct mapping: the sub-flow *is* an existing function, with no
214349
// node tree of its own (no startingNodeId). Emit the function
215350
// reference itself as the value so its own signature drives the
216351
// sub-flow's I/O — e.g. mapping `std::math::add` yields
217352
// `(a, b) => NUMBER` rather than an empty `(...p) => {}` lambda.
218-
if (!wrapper.startingNodeId && wrapper.functionDefinition?.identifier) {
219-
const funcName = `fn_${wrapper.functionDefinition.identifier.replace(/::/g, '_')}`;
220-
return `/* @pos ${id} ${index} */ ${funcName}`;
221-
}
222-
const lambdaArgName = `p_${sanitizeId(id as string)}_${index}`;
223-
const subTreeCode = generateNodeCode(wrapper.startingNodeId || wrapper.functionDefinition?.id!, indent + " ");
224-
return `/* @pos ${id} ${index} */ (...${lambdaArgName}) => {\n${subTreeCode}${indent}}`;
353+
return `/* @pos ${id} ${index} */ ${renderSubFlow(val as SubFlowValue, id, index, indent)}`;
225354
}
226355
return isForInference ? `/* @pos ${id} ${index} */ {}` : `/* @pos ${id} ${index} */ undefined`;
227356
});

test/flowSchemas.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {describe, expect, it} from "vitest";
2-
import type {Flow, NodeFunction, NodeParameterValue} from "@code0-tech/sagittarius-graphql-types";
2+
import type {Flow, FunctionDefinition, NodeFunction, NodeParameterValue} from "@code0-tech/sagittarius-graphql-types";
33
import {getFlowSchemas} from "../src/server";
44
import type {JsonSchema, SchematizedSubFlowValue} from "../src/server";
55
import {DATA_TYPES, FUNCTION_SIGNATURES} from "./data";
@@ -188,6 +188,59 @@ describe("getFlowSchemas", () => {
188188
expect(consumer.startingNodeId).toBe(BODY_NODE_ID);
189189
});
190190

191+
it("resolves an inline reference inside a for_each list literal into the item schema", () => {
192+
// A source node returns a NUMBER; the for_each list literal is `["${n}"]`
193+
// where the standalone `${n}` reference preserves that NUMBER type. Schema
194+
// generation must therefore infer the sub-flow item as a number — proving
195+
// inline references flow through getFlowSchemas, not just validation.
196+
const SRC_ID = "gid://sagittarius/NodeFunction/9" as NodeId;
197+
const SRC_FN: FunctionDefinition = {
198+
id: "gid://sagittarius/FunctionDefinition/9301",
199+
identifier: "custom::src::num",
200+
signature: "(): NUMBER",
201+
};
202+
203+
const flow: Flow = {
204+
id: FLOW_ID,
205+
startingNodeId: SRC_ID,
206+
nodes: {
207+
nodes: [
208+
{
209+
id: SRC_ID,
210+
functionDefinition: {identifier: "custom::src::num"},
211+
nextNodeId: LIST_NODE_ID,
212+
parameters: {nodes: []},
213+
},
214+
node(LIST_NODE_ID, "std::list::for_each", [
215+
{
216+
__typename: "LiteralValue",
217+
value: ["${n}"],
218+
references: [
219+
{
220+
__typename: "InlineReferenceValue",
221+
signature: "n",
222+
value: {__typename: "ReferenceValue", nodeFunctionId: SRC_ID},
223+
},
224+
],
225+
} as NodeParameterValue,
226+
subFlowStartingAt(BODY_NODE_ID),
227+
]),
228+
node(BODY_NODE_ID, "std::number::add", [
229+
subFlowInputReference(LIST_NODE_ID, 1),
230+
literal(10),
231+
]),
232+
],
233+
},
234+
};
235+
236+
const result = getFlowSchemas(flow, [...FUNCTION_SIGNATURES, SRC_FN], DATA_TYPES);
237+
const consumer = subFlowValueOf(result, LIST_NODE_ID, 1);
238+
239+
// T resolves to NUMBER, so the callback item is a number schema.
240+
expect((consumer.inputSchema?.properties?.item as JsonSchema)).toEqual({type: "number"});
241+
expect(consumer.__typename).toBe("SubFlowValue");
242+
});
243+
191244
it("enriches a filter predicate sub-flow with a boolean output", () => {
192245
// filter over [1, 2, 3], the sub-flow body converts each item to a boolean.
193246
const flow = flowWithNodes([

0 commit comments

Comments
 (0)