Get it back - Data schema spec fix: TSON instead of JSON (#3566) (#3569)

Co-authored-by: Jonathan Dobson <jon.m.dobson@gmail.com>
This commit is contained in:
Shuchang Zheng
2025-09-30 17:36:53 -07:00
committed by GitHub
parent bb1e7316a3
commit 94c05e092a
4 changed files with 339 additions and 48 deletions

View File

@@ -17,6 +17,7 @@ import { AutoResizingTextarea } from "../AutoResizingTextarea/AutoResizingTextar
import { Button } from "../ui/button";
import { AxiosError } from "axios";
import { toast } from "../ui/use-toast";
import { TSON } from "@/util/tson";
import { cn } from "@/util/utils";
type Props = {
@@ -36,42 +37,13 @@ function WorkflowDataSchemaInputGroup({
const [generateWithAIActive, setGenerateWithAIActive] = useState(false);
const [generateWithAIPrompt, setGenerateWithAIPrompt] = useState("");
function computeJsonError(
jsonText: string,
): { message: string; line?: number; column?: number } | null {
try {
JSON.parse(jsonText);
return null;
} catch (e) {
const message = e instanceof Error ? e.message : "Invalid JSON";
// Try to extract position and compute line/column for friendlier feedback
const match = message.match(/position\s+(\d+)/i);
if (!match) {
return { message };
}
const pos = Number(match[1]);
if (Number.isNaN(pos)) {
return { message };
}
let line = 1;
let col = 1;
for (let i = 0; i < Math.min(pos, jsonText.length); i++) {
if (jsonText[i] === "\n") {
line += 1;
col = 1;
} else {
col += 1;
}
}
return { message, line, column: col };
}
}
const jsonError = useMemo(() => {
const tsonResult = useMemo(() => {
if (value === "null") return null;
return computeJsonError(value);
return TSON.parse(value);
}, [value]);
console.log({ tsonResult });
const getDataSchemaSuggestionMutation = useMutation({
mutationFn: async () => {
const client = await getClient(credentialGetter);
@@ -161,7 +133,9 @@ function WorkflowDataSchemaInputGroup({
<div
className={cn(
"rounded-md",
jsonError ? "ring-1 ring-red-500" : undefined,
tsonResult && !tsonResult.success
? "ring-1 ring-red-500"
: undefined,
)}
>
<CodeEditor
@@ -172,12 +146,8 @@ function WorkflowDataSchemaInputGroup({
fontSize={8}
/>
</div>
{jsonError && (
<div className="text-xs text-red-400">
{jsonError.line && jsonError.column
? `Invalid JSON (${jsonError.line}:${jsonError.column}) — ${jsonError.message}`
: `Invalid JSON — ${jsonError.message}`}
</div>
{tsonResult !== null && !tsonResult.success && tsonResult.error && (
<div className="text-xs text-red-400">{tsonResult.error}</div>
)}
</div>
)}

View File

@@ -2,6 +2,9 @@ import Dagre from "@dagrejs/dagre";
import type { Node } from "@xyflow/react";
import { Edge } from "@xyflow/react";
import { nanoid } from "nanoid";
import { TSON } from "@/util/tson";
import {
WorkflowBlockType,
WorkflowBlockTypes,
@@ -2188,10 +2191,12 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
}
// Validate Task data schema JSON when enabled (value different from "null")
if (node.data.dataSchema && node.data.dataSchema !== "null") {
try {
JSON.parse(node.data.dataSchema);
} catch {
errors.push(`${node.data.label}: Data schema is not valid JSON.`);
const result = TSON.parse(node.data.dataSchema);
if (!result.success) {
errors.push(
`${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`,
);
}
}
});
@@ -2227,10 +2232,12 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
}
// Validate Extraction data schema JSON when enabled (value different from "null")
if (node.data.dataSchema && node.data.dataSchema !== "null") {
try {
JSON.parse(node.data.dataSchema);
} catch {
errors.push(`${node.data.label}: Data schema is not valid JSON.`);
const result = TSON.parse(node.data.dataSchema);
if (!result.success) {
errors.push(
`${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`,
);
}
}
});

View File

@@ -0,0 +1,91 @@
// TSON.parse.test.ts
import { TSON } from "./tson";
import { describe, test, expect } from "vitest";
describe("TSON.parse", () => {
test("single top-level template works", () => {
const input = "{{ hello }}";
const result = TSON.parse(input);
expect(result.success).toBe(true);
expect(result.data).toEqual("<STUB>");
});
test("preserves double braces inside quoted strings", () => {
const input = '{"a": "{{ hello }}"}';
const result = TSON.parse(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ a: "{{ hello }}" });
});
test("replaces double braces outside strings with stub", () => {
const input = '{"a": {{ hello }} }';
const result = TSON.parse(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ a: "<STUB>" });
});
test("handles double braces in keys and values", () => {
const input = `
{
"hello": "world",
{{foo}}: "bar",
"baz": {{quux}}
}`;
const result = TSON.parse(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({
hello: "world",
"<STUB>": "bar",
baz: "<STUB>",
});
});
test("does not allow trailing commas", () => {
const input = `
{
"hello": "world",
{{foo}}: "bar",
"baz": {{quux}},
}`;
const result = TSON.parse(input);
expect(result.success).toBe(false);
expect(result.error).toContain("Expected double-quoted property name");
});
test("detects unclosed double braces", () => {
const input = "{{ unclosed";
const result = TSON.parse(input);
expect(result.success).toBe(false);
expect(result.error).toContain("Unclosed");
});
test("detects unmatched closing double braces", () => {
const input = "closed }}";
const result = TSON.parse(input);
expect(result.success).toBe(false);
expect(result.error).toContain("Unmatched");
});
test("handles nested double braces", () => {
const input = "{{ {{ nested }} }}";
const result = TSON.parse(input);
expect(result.success).toBe(true);
expect(result.data).toEqual("<STUB>");
});
test("handles double braces in arrays", () => {
const input = '[{{ }}, {{ }}, "normal"]';
const result = TSON.parse(input);
expect(result.success).toBe(true);
expect(result.data).toEqual(["<STUB>", "<STUB>", "normal"]);
});
});

View File

@@ -0,0 +1,223 @@
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
interface ParseResult {
success: boolean;
data?: JSONValue;
error?: string;
}
const placeholder = () => "<STUB>";
/**
* TSON ("templated JSON") is a superset of JSON, where double curly braces {{...}} can:
* - exist anywhere outside of string literals, and
* - are treated as placeholders
*/
const TSON = {
parse(input: string): ParseResult {
try {
const balanceCheck = checkDoubleBraceBalance(input);
if (!balanceCheck.balanced) {
return {
success: false,
error: balanceCheck.error,
};
}
const pipeline = [
replaceBracesOutsideQuotes,
// removeTrailingCommas,
JSON.parse,
];
const parsed = pipeline.reduce((acc, fn) => fn(acc), input) as JSONValue;
return {
success: true,
data: parsed,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
},
};
function checkDoubleBraceBalance(input: string): {
balanced: boolean;
error?: string;
} {
let inString = false;
let escapeNext = false;
let depth = 0;
for (let i = 0; i < input.length; i++) {
const char = input[i];
const nextChar = input[i + 1];
// handle escape sequences
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === "\\") {
escapeNext = true;
continue;
}
// inside-string tracking
if (char === '"') {
inString = !inString;
continue;
}
// double braces counts (only outside strings)
if (!inString) {
if (char === "{" && nextChar === "{") {
depth++;
i++; // skip next char
} else if (char === "}" && nextChar === "}") {
depth--;
if (depth < 0) {
return {
balanced: false,
error: `Unmatched closing }} at position ${i}`,
};
}
i++; // skip next char
}
}
}
if (depth > 0) {
return {
balanced: false,
error: `Unclosed {{ - missing ${depth} closing }}`,
};
}
return { balanced: true };
}
function replaceBracesOutsideQuotes(input: string): string {
let result = "";
let inString = false;
let escapeNext = false;
let inDoubleBrace = 0; // track nesting depth of {{...}}
for (let i = 0; i < input.length; i++) {
const char = input[i];
const nextChar = input[i + 1];
// escape sequences
if (escapeNext) {
if (inDoubleBrace === 0) {
result += char;
}
escapeNext = false;
continue;
}
if (char === "\\") {
if (inDoubleBrace === 0) {
result += char;
}
escapeNext = true;
continue;
}
// inside-string tracking
if (char === '"') {
inString = !inString;
if (inDoubleBrace === 0) {
result += char;
}
continue;
}
// double braces (only outside strings)
if (!inString) {
if (char === "{" && nextChar === "{") {
if (inDoubleBrace === 0) {
result += `"${placeholder()}"`;
}
inDoubleBrace++;
i++; // skip next char
continue;
} else if (char === "}" && nextChar === "}") {
inDoubleBrace--;
i++; // skip next char
continue;
}
}
// add characters when we're not inside double braces
if (inDoubleBrace === 0) {
result += char;
}
}
return result;
}
export function removeTrailingCommas(input: string): string {
let result = "";
let inString = false;
let escapeNext = false;
for (let i = 0; i < input.length; i++) {
const char = input[i];
// escape sequences
if (escapeNext) {
result += char;
escapeNext = false;
continue;
}
if (char === "\\") {
result += char;
escapeNext = true;
continue;
}
// inside-string tracking
if (char === '"') {
inString = !inString;
result += char;
continue;
}
// check for trailing commas (outside strings)
if (!inString && char === ",") {
// look-ahead for the next non-whitespace character
let j = i + 1;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
while (j < input.length && /\s/.test(input[j])) {
j++;
}
// if next non-whitespace is } or ], skip the comma
if (j < input.length && (input[j] === "}" || input[j] === "]")) {
continue; // Skip this comma
}
}
result += char;
}
return result;
}
export { TSON };