@dypai-ai/mcp 1.5.19 → 1.5.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +45 -23
- package/src/lib/workflow-placeholder-contract.d.ts +50 -0
- package/src/lib/workflow-placeholder-contract.js +364 -0
- package/src/tools/project-artifacts.js +627 -0
- package/src/tools/sync/codec.js +2 -0
- package/src/tools/sync/pull.js +11 -1
- package/src/tools/sync/push.js +1 -0
- package/src/tools/sync/validate.js +227 -8
- package/src/tools/capability-kits.js +0 -830
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -35,10 +35,7 @@ import { manageFrontendTool } from "./tools/frontend.js"
|
|
|
35
35
|
// import { scaffoldTool } from "./tools/scaffold.js"
|
|
36
36
|
import { manageDomainTool } from "./tools/domains.js"
|
|
37
37
|
import { bulkUpsertTool } from "./tools/bulk-upsert.js"
|
|
38
|
-
import {
|
|
39
|
-
searchCapabilityKitsTool,
|
|
40
|
-
manageCapabilityKitTool,
|
|
41
|
-
} from "./tools/capability-kits.js"
|
|
38
|
+
import { manageProjectArtifactTool } from "./tools/project-artifacts.js"
|
|
42
39
|
import { uploadFile } from "./tools/storage.js"
|
|
43
40
|
// dypaiTestTool (YAML test-suite runner) is intentionally not imported — deferred to v2.
|
|
44
41
|
// The format works but needs fixtures/auto-rollback/scaffolder + proper docs before being surfaced.
|
|
@@ -86,9 +83,8 @@ const LOCAL_TOOLS = [
|
|
|
86
83
|
manageDomainTool,
|
|
87
84
|
// ── Data ──────────────────────────────────────────────────────────────────
|
|
88
85
|
bulkUpsertTool,
|
|
89
|
-
// ──
|
|
90
|
-
|
|
91
|
-
manageCapabilityKitTool,
|
|
86
|
+
// ── Project artifacts (install via cloud GitHub fetch + local workspace copy) ──
|
|
87
|
+
manageProjectArtifactTool,
|
|
92
88
|
// ── Git-first source of truth ─────────────────────────────────────────────
|
|
93
89
|
// dypai_describe was merged into dypai_pull (now returns an `overview` block).
|
|
94
90
|
dypaiPullTool,
|
|
@@ -494,6 +490,34 @@ endpoint YAML and \`dypai_push\`. This tool does NOT modify the definition.`,
|
|
|
494
490
|
|
|
495
491
|
// ── Knowledge ─────────────────────────────────────────────────────────────
|
|
496
492
|
{ name: "search_docs", description: "Search DYPAI documentation. Use this when unsure about SDK usage, auth patterns, workflow nodes, or platform features. Returns relevant documentation chunks.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What you want to learn about" } }, required: ["query"] } },
|
|
493
|
+
{
|
|
494
|
+
name: "search_project_artifacts",
|
|
495
|
+
description: "Search installable project shells and feature modules from the unified dytemplates catalog (semantic). Returns shells, vertical features, and reusable modules (often kit-* slugs). Use before install via manage_project_artifact.",
|
|
496
|
+
inputSchema: {
|
|
497
|
+
type: "object",
|
|
498
|
+
properties: {
|
|
499
|
+
query: { type: "string", description: "Natural language need, e.g. 'calendario de reservas para hotel'." },
|
|
500
|
+
surface: { type: "string", enum: ["public", "private", "mixed", "agnostic"], description: "Filter by surface; agnostic modules always match when set." },
|
|
501
|
+
kind: { type: "string", enum: ["shell", "feature"], description: "Restrict to shells or feature modules." },
|
|
502
|
+
compatible_shell: { type: "string", description: "Shell slug; returns features compatible with it." },
|
|
503
|
+
limit: { type: "integer", default: 20, minimum: 1, maximum: 50 },
|
|
504
|
+
},
|
|
505
|
+
required: ["query"],
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
name: "fetch_project_artifact",
|
|
510
|
+
description: "Download artifact source from GitHub (server-side). Requires source_repo, source_path, source_ref from search_project_artifacts. Used internally by manage_project_artifact; agents normally call manage_project_artifact only.",
|
|
511
|
+
inputSchema: {
|
|
512
|
+
type: "object",
|
|
513
|
+
properties: {
|
|
514
|
+
source_repo: { type: "string", description: "e.g. dyapps-codes/artifacts" },
|
|
515
|
+
source_path: { type: "string", description: "e.g. kits/pricing-stripe" },
|
|
516
|
+
source_ref: { type: "string", default: "main" },
|
|
517
|
+
},
|
|
518
|
+
required: ["source_repo"],
|
|
519
|
+
},
|
|
520
|
+
},
|
|
497
521
|
{ name: "search_design_patterns", description: "Search compact DYPAI UI/design recipes. Use before designing substantial screens.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Design need, with starter/domain/screen/style context when known." }, starter_slug: { type: "string", description: "Optional: private-admin, user-accounts, landing-admin, or blank." }, app_type: { type: "string", description: "Optional domain/app type." }, screen_type: { type: "string", description: "Optional screen/workflow." }, visual_style: { type: "string", description: "Optional style." }, category: { type: "string", description: "Optional category." }, limit: { type: "integer", default: 3, minimum: 1, maximum: 4 } }, required: ["query"] } },
|
|
498
522
|
{ name: "search_workflow_templates", description: "Search workflow templates by description. Returns ready-to-use workflow code for common patterns: CRUD operations, payment gateways, email sending, AI chatbots, data pipelines, etc.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What the workflow should do (e.g. 'send email', 'stripe payment')" }, category: { type: "string", description: "Optional: AI, Database, Payments, Communication, Logic, Storage" } }, required: ["query"] } },
|
|
499
523
|
{ name: "search_project_templates", description: "Search visible DYPAI Studio project templates by description. Returns only the Studio catalog plus compact template briefs and selection_guidance: recommended_slug, confidence, fallback_slug, and agent_rule.", inputSchema: { type: "object", properties: { query: { type: "string", description: "What kind of project starter you need, in the user's language (e.g. 'app para barberia con reservas', 'private admin dashboard', 'landing plus admin')" }, category: { type: "string", description: "Optional category/type filter." }, limit: { type: "integer", default: 5, minimum: 1, maximum: 10 }, include_drafts: { type: "boolean", default: false, description: "Internal/local testing only. Include draft Studio catalog templates." } }, required: ["query"] } },
|
|
@@ -544,16 +568,13 @@ forms, calendars, or domain-specific screens), use \`search_design_patterns\`
|
|
|
544
568
|
with the app/starter/screen/style context. It returns curated recipes; adapt
|
|
545
569
|
them to the project instead of inventing generic starter UI.
|
|
546
570
|
|
|
547
|
-
For
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
\`
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
target workspace package.json and install them locally (never globally or in
|
|
555
|
-
the MCP server). Then wire the kit into the app, apply SQL if needed, validate
|
|
556
|
-
endpoints, and verify the frontend.
|
|
571
|
+
For shells, vertical features, and reusable modules (calendar, maps, CRUD tables,
|
|
572
|
+
Kanban, uploads, dashboards, editors), use \`search_project_artifacts\` first.
|
|
573
|
+
If an artifact fits, pass the exact \`slug\` from the search hit into
|
|
574
|
+
\`manage_project_artifact(operation: "inspect")\` then \`operation: "apply"\`
|
|
575
|
+
instead of building that complex slice from scratch. Installed artifact code is
|
|
576
|
+
editable workspace source; add missing deps to package.json locally, apply SQL
|
|
577
|
+
with execute_sql when needed, validate endpoints, and verify the frontend.
|
|
557
578
|
|
|
558
579
|
**The template system exists to save time when the fit is obvious, not to force-match every request.** When in doubt → use the safest returned Studio base. Iterating up from a smaller base is cheaper than deleting 80% of a mismatched template.
|
|
559
580
|
|
|
@@ -1036,7 +1057,7 @@ Endpoint naming is strict: the YAML \`name\` is the public API slug and must exa
|
|
|
1036
1057
|
|
|
1037
1058
|
Mental translations: "edge function" → workflow with one code node; "cron" → \`trigger.schedule\` in the YAML; "webhook receiver" → \`trigger.webhook\`; "internal API" → \`trigger.http_api auth_mode:jwt\`.
|
|
1038
1059
|
|
|
1039
|
-
→ Full workflow patterns + YAML shape: \`search_docs("workflow patterns")\` and \`search_docs("trigger model")\`. Node catalog (full input/output schemas): read \`dypai/node-catalog.json\`.
|
|
1060
|
+
→ Full workflow patterns + YAML shape: \`search_docs("workflow patterns")\` and \`search_docs("trigger model")\`. Public HTTP response vs internal node output: \`search_docs("placeholder cheatsheet")\`. Node catalog (full input/output schemas): read \`dypai/node-catalog.json\`.
|
|
1040
1061
|
|
|
1041
1062
|
## What the engine handles for you (don't reinvent)
|
|
1042
1063
|
|
|
@@ -1057,9 +1078,10 @@ Mental translations: "edge function" → workflow with one code node; "cron" →
|
|
|
1057
1078
|
3. **Treating \`dypai_push\` as a deploy** — it's "save as draft", not publish. Live traffic is untouched until \`manage_drafts(publish, confirm:true)\`. Push freely, only ask the user before publish.
|
|
1058
1079
|
4. **\`public\` auth_mode with \`\${current_user_id}\`** — no JWT → placeholder empty → SQL fails or returns wrong data. Use \`jwt\` if you need the user.
|
|
1059
1080
|
5. **Missing \`return: true\`** — endpoint returns \`null\`. Every path that should produce an HTTP response needs one node with \`return: true\`.
|
|
1060
|
-
6.
|
|
1061
|
-
7.
|
|
1062
|
-
8. **
|
|
1081
|
+
6. **SQL return + \`output.type: object\` without \`response_cardinality\` or \`set_fields\`** — runtime body is \`[{...}]\` but the frontend expects \`{...}\`. Add top-level \`response_cardinality: single\` for direct SQL returns, or compose the public object with \`set_fields\`. \`dypai_validate\` errors with \`response_cardinality_required\`. Do not unwrap arrays in frontend code.
|
|
1082
|
+
7. **\`tool_ids\` in YAML instead of \`tools\`** — write \`tools: [name1, name2]\`. \`tool_ids\` bypasses the codec and fails silently in prod.
|
|
1083
|
+
8. **Putting workflow placeholders inside \`javascript_code.code\`** — code is raw JavaScript, so JS template literals like \`\${where.join(" AND ")}\` are safe and not rendered by DYPAI. Pass workflow values via \`input_data\`, \`ctx.nodes\`, \`ctx.user\`, or \`ctx.env\`; do not write \`\${input.email}\` inside code or set \`code\` from another node output.
|
|
1084
|
+
9. **Human endpoint names** — \`name: Listar videos\` in \`list-videos.yaml\` creates a draft the frontend cannot call as \`list-videos\`. \`dypai_validate\` and \`dypai_push\` reject this; fix the slug instead of testing around it.
|
|
1063
1085
|
|
|
1064
1086
|
→ Longer list of common pitfalls + fixes: \`search_docs("troubleshooting")\`.
|
|
1065
1087
|
|
|
@@ -1080,7 +1102,7 @@ Mental translations: "edge function" → workflow with one code node; "cron" →
|
|
|
1080
1102
|
|
|
1081
1103
|
SDK is pre-configured at \`src/lib/dypai.ts\` (or \`src/dypai.ts\`). Import \`dypai\` from there. Every method returns \`{ data, error }\` — never throws.
|
|
1082
1104
|
|
|
1083
|
-
- **API**: \`dypai.api.get(name)\`, \`.post(name, body)\`, \`.put()\`, \`.delete()\`, \`.upload(name, file)\`, \`.stream(name, body)\`.
|
|
1105
|
+
- **API**: \`dypai.api.get(name)\`, \`.post(name, body)\`, \`.put()\`, \`.delete()\`, \`.upload(name, file)\`, \`.stream(name, body)\`. \`response.data\` matches the endpoint \`output\` schema — if SQL returns row arrays, declare \`response_cardinality: single\` or use \`set_fields\`; never unwrap in the UI.
|
|
1084
1106
|
- **Auth**: \`dypai.auth.signInWithPassword()\`, \`.signUp()\`, \`.signOut()\`, \`.getSession()\`. **Never** create login/signup workflows — auth is built-in.
|
|
1085
1107
|
- **Hooks**: \`useAuth\`, \`useEndpoint\`, \`useAction\`, \`useUpload\`, \`useRealtime\`, \`<ProtectedRoute>\`.
|
|
1086
1108
|
- **Rule**: NEVER \`fetch()\` directly — always through the SDK.
|
|
@@ -1121,7 +1143,7 @@ async function handleRequest(msg) {
|
|
|
1121
1143
|
return makeResponse(id, {
|
|
1122
1144
|
protocolVersion: "2024-11-05",
|
|
1123
1145
|
capabilities: { tools: {} },
|
|
1124
|
-
serverInfo: { name: "dypai", version: "1.5.
|
|
1146
|
+
serverInfo: { name: "dypai", version: "1.5.23" },
|
|
1125
1147
|
instructions: SERVER_INSTRUCTIONS,
|
|
1126
1148
|
})
|
|
1127
1149
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical workflow placeholder / SQL cardinality helpers.
|
|
3
|
+
* Copy into workspace/engine — do not import across service deploy boundaries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type SqlCardinality = "single" | "many" | null;
|
|
7
|
+
|
|
8
|
+
export type PlaceholderDiagnostic = {
|
|
9
|
+
rule: string;
|
|
10
|
+
message: string;
|
|
11
|
+
fix_hint: string;
|
|
12
|
+
example_fix?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function stripSqlForInference(sqlText: string): string;
|
|
16
|
+
|
|
17
|
+
export function hasTopLevelLimitOne(sqlText: string): boolean;
|
|
18
|
+
|
|
19
|
+
export function inferSqlCardinality(sqlText: string): SqlCardinality;
|
|
20
|
+
|
|
21
|
+
/** Alias: true when inferSqlCardinality returns "single". */
|
|
22
|
+
export function sqlProvesSingleRow(sqlText: string): boolean;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse node id from nodes.* expression; supports bracket index.
|
|
26
|
+
* nodes.foo[0].bar → "foo"
|
|
27
|
+
*/
|
|
28
|
+
export function referencedNodeIdFromNodesExpression(expr: string): string | null;
|
|
29
|
+
|
|
30
|
+
export function stripePlaceholderIssue(
|
|
31
|
+
placeholder: string,
|
|
32
|
+
nodeId: string,
|
|
33
|
+
nodeType: string,
|
|
34
|
+
): PlaceholderDiagnostic | null;
|
|
35
|
+
|
|
36
|
+
export function databaseManyRowPlaceholderIssue(
|
|
37
|
+
placeholder: string,
|
|
38
|
+
nodeId: string,
|
|
39
|
+
cardinality: SqlCardinality | "single" | "many",
|
|
40
|
+
): PlaceholderDiagnostic | null;
|
|
41
|
+
|
|
42
|
+
export function outputSchemaPropertyNames(outputSchema: unknown): Set<string>;
|
|
43
|
+
|
|
44
|
+
export function workflowOutputKeys(workflowOutput: unknown): Set<string>;
|
|
45
|
+
|
|
46
|
+
/** Keys present in workflow.output but missing from output.properties. */
|
|
47
|
+
export function workflowOutputMismatchKeys(
|
|
48
|
+
outputSchema: unknown,
|
|
49
|
+
workflowOutput: unknown,
|
|
50
|
+
): string[];
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical workflow placeholder / SQL cardinality helpers for DYPAI validators.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth for MCP (`dypai_validate`). Copy into other services (workspace,
|
|
5
|
+
* engine-side tooling) — do NOT import this file across service boundaries at runtime.
|
|
6
|
+
*
|
|
7
|
+
* @see test-fixtures/workflow-contract/ for golden parity checks
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const AGGREGATE_FUNCTIONS = [
|
|
11
|
+
"count",
|
|
12
|
+
"sum",
|
|
13
|
+
"avg",
|
|
14
|
+
"min",
|
|
15
|
+
"max",
|
|
16
|
+
"json_agg",
|
|
17
|
+
"jsonb_agg",
|
|
18
|
+
"array_agg",
|
|
19
|
+
"string_agg",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function stripSqlForInference(sqlText) {
|
|
23
|
+
return String(sqlText || "")
|
|
24
|
+
.replace(/--[^\n]*/g, " ")
|
|
25
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
26
|
+
.replace(/'(?:[^']|'')*'/g, "''")
|
|
27
|
+
.replace(/\s+/g, " ")
|
|
28
|
+
.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isWordAt(sql, index, word) {
|
|
32
|
+
if (sql.slice(index, index + word.length).toLowerCase() !== word) return false;
|
|
33
|
+
const before = index === 0 ? "" : sql[index - 1];
|
|
34
|
+
const after = sql[index + word.length] || "";
|
|
35
|
+
return !/[A-Za-z0-9_]/.test(before) && !/[A-Za-z0-9_]/.test(after);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function skipDoubleQuotedIdentifier(sql, index) {
|
|
39
|
+
if (sql[index] !== '"') return index;
|
|
40
|
+
for (let i = index + 1; i < sql.length; i++) {
|
|
41
|
+
if (sql[i] !== '"') continue;
|
|
42
|
+
if (sql[i + 1] === '"') {
|
|
43
|
+
i++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
return i;
|
|
47
|
+
}
|
|
48
|
+
return sql.length - 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function skipSqlSpaces(sql, index) {
|
|
52
|
+
let i = index;
|
|
53
|
+
while (i < sql.length && /\s/.test(sql[i])) i++;
|
|
54
|
+
return i;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findMatchingParen(sql, openIndex) {
|
|
58
|
+
if (sql[openIndex] !== "(") return -1;
|
|
59
|
+
let depth = 0;
|
|
60
|
+
for (let i = openIndex; i < sql.length; i++) {
|
|
61
|
+
const ch = sql[i];
|
|
62
|
+
if (ch === '"') {
|
|
63
|
+
i = skipDoubleQuotedIdentifier(sql, i);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (ch === "(") {
|
|
67
|
+
depth++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ch === ")") {
|
|
71
|
+
depth--;
|
|
72
|
+
if (depth === 0) return i;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return -1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findOuterSelectList(sqlText) {
|
|
79
|
+
const sql = stripSqlForInference(sqlText);
|
|
80
|
+
if (!sql) return null;
|
|
81
|
+
let depth = 0;
|
|
82
|
+
let selectStart = -1;
|
|
83
|
+
for (let i = 0; i < sql.length; i++) {
|
|
84
|
+
const ch = sql[i];
|
|
85
|
+
if (ch === '"') {
|
|
86
|
+
i = skipDoubleQuotedIdentifier(sql, i);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (ch === "(") depth++;
|
|
90
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
91
|
+
|
|
92
|
+
if (depth !== 0) continue;
|
|
93
|
+
if (selectStart < 0 && isWordAt(sql, i, "select")) {
|
|
94
|
+
selectStart = i + "select".length;
|
|
95
|
+
i += "select".length - 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (selectStart >= 0 && isWordAt(sql, i, "from")) {
|
|
99
|
+
return sql.slice(selectStart, i).trim();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (selectStart >= 0) return sql.slice(selectStart).trim();
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function hasTopLevelLimitOne(sqlText) {
|
|
107
|
+
const sql = stripSqlForInference(sqlText);
|
|
108
|
+
let depth = 0;
|
|
109
|
+
for (let i = 0; i < sql.length; i++) {
|
|
110
|
+
const ch = sql[i];
|
|
111
|
+
if (ch === '"') {
|
|
112
|
+
i = skipDoubleQuotedIdentifier(sql, i);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (ch === "(") {
|
|
116
|
+
depth++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (ch === ")") {
|
|
120
|
+
depth = Math.max(0, depth - 1);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (depth !== 0 || !isWordAt(sql, i, "limit")) continue;
|
|
124
|
+
const tail = sql.slice(skipSqlSpaces(sql, i + "limit".length));
|
|
125
|
+
if (/^1(?:\D|$)/.test(tail)) return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasTopLevelFetchOne(sqlText) {
|
|
131
|
+
const sql = stripSqlForInference(sqlText);
|
|
132
|
+
let depth = 0;
|
|
133
|
+
for (let i = 0; i < sql.length; i++) {
|
|
134
|
+
const ch = sql[i];
|
|
135
|
+
if (ch === '"') {
|
|
136
|
+
i = skipDoubleQuotedIdentifier(sql, i);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ch === "(") {
|
|
140
|
+
depth++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (ch === ")") {
|
|
144
|
+
depth = Math.max(0, depth - 1);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (depth !== 0 || !isWordAt(sql, i, "fetch")) continue;
|
|
148
|
+
|
|
149
|
+
let cursor = skipSqlSpaces(sql, i + "fetch".length);
|
|
150
|
+
if (isWordAt(sql, cursor, "first")) cursor += "first".length;
|
|
151
|
+
else if (isWordAt(sql, cursor, "next")) cursor += "next".length;
|
|
152
|
+
else continue;
|
|
153
|
+
cursor = skipSqlSpaces(sql, cursor);
|
|
154
|
+
if (/^1(?:\D|$)/.test(sql.slice(cursor))) return true;
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasTopLevelGroupBy(sqlText) {
|
|
160
|
+
const sql = stripSqlForInference(sqlText);
|
|
161
|
+
let depth = 0;
|
|
162
|
+
for (let i = 0; i < sql.length; i++) {
|
|
163
|
+
const ch = sql[i];
|
|
164
|
+
if (ch === '"') {
|
|
165
|
+
i = skipDoubleQuotedIdentifier(sql, i);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (ch === "(") {
|
|
169
|
+
depth++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === ")") {
|
|
173
|
+
depth = Math.max(0, depth - 1);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (depth !== 0 || !isWordAt(sql, i, "group")) continue;
|
|
177
|
+
|
|
178
|
+
const restStart = i + "group".length;
|
|
179
|
+
const rest = sql.slice(restStart);
|
|
180
|
+
const byOffset = rest.search(/\S/);
|
|
181
|
+
if (byOffset >= 0 && isWordAt(sql, restStart + byOffset, "by")) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function hasTopLevelSetOperation(sqlText) {
|
|
187
|
+
const sql = stripSqlForInference(sqlText);
|
|
188
|
+
let depth = 0;
|
|
189
|
+
for (let i = 0; i < sql.length; i++) {
|
|
190
|
+
const ch = sql[i];
|
|
191
|
+
if (ch === '"') {
|
|
192
|
+
i = skipDoubleQuotedIdentifier(sql, i);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (ch === "(") {
|
|
196
|
+
depth++;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (ch === ")") {
|
|
200
|
+
depth = Math.max(0, depth - 1);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (depth !== 0) continue;
|
|
204
|
+
if (isWordAt(sql, i, "union") || isWordAt(sql, i, "intersect") || isWordAt(sql, i, "except")) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isWindowAggregateCall(sql, openIndex) {
|
|
212
|
+
const closeIndex = findMatchingParen(sql, openIndex);
|
|
213
|
+
if (closeIndex < 0) return false;
|
|
214
|
+
|
|
215
|
+
let cursor = skipSqlSpaces(sql, closeIndex + 1);
|
|
216
|
+
if (isWordAt(sql, cursor, "filter")) {
|
|
217
|
+
cursor = skipSqlSpaces(sql, cursor + "filter".length);
|
|
218
|
+
if (sql[cursor] === "(") {
|
|
219
|
+
const filterClose = findMatchingParen(sql, cursor);
|
|
220
|
+
if (filterClose >= 0) cursor = skipSqlSpaces(sql, filterClose + 1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return isWordAt(sql, cursor, "over");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function hasOuterAggregateFunction(selectList) {
|
|
228
|
+
const sql = String(selectList || "");
|
|
229
|
+
let depth = 0;
|
|
230
|
+
const activeSubqueryDepths = [];
|
|
231
|
+
|
|
232
|
+
const insideSubquery = () => activeSubqueryDepths.some((d) => d <= depth);
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < sql.length; i++) {
|
|
235
|
+
const ch = sql[i];
|
|
236
|
+
if (ch === '"') {
|
|
237
|
+
i = skipDoubleQuotedIdentifier(sql, i);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (ch === "(") {
|
|
241
|
+
depth++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (ch === ")") {
|
|
245
|
+
activeSubqueryDepths.splice(
|
|
246
|
+
0,
|
|
247
|
+
activeSubqueryDepths.length,
|
|
248
|
+
...activeSubqueryDepths.filter((d) => d < depth),
|
|
249
|
+
);
|
|
250
|
+
depth = Math.max(0, depth - 1);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (depth > 0 && isWordAt(sql, i, "select")) {
|
|
255
|
+
activeSubqueryDepths.push(depth);
|
|
256
|
+
i += "select".length - 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const fn of AGGREGATE_FUNCTIONS) {
|
|
261
|
+
if (!isWordAt(sql, i, fn)) continue;
|
|
262
|
+
const openIndex = skipSqlSpaces(sql, i + fn.length);
|
|
263
|
+
if (sql[openIndex] === "(" && !insideSubquery() && !isWindowAggregateCall(sql, openIndex)) {
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Infer whether a SQL query returns a single row or many rows.
|
|
274
|
+
* Conservative rules: INSERT ... RETURNING → single; UPDATE/DELETE ... RETURNING → many.
|
|
275
|
+
*/
|
|
276
|
+
export function inferSqlCardinality(sqlText) {
|
|
277
|
+
const sql = stripSqlForInference(sqlText);
|
|
278
|
+
if (!sql) return null;
|
|
279
|
+
|
|
280
|
+
if (hasTopLevelLimitOne(sql) || hasTopLevelFetchOne(sql)) return "single";
|
|
281
|
+
|
|
282
|
+
if (/\breturning\b/i.test(sql) && /^insert\b/i.test(sql.trim())) return "single";
|
|
283
|
+
|
|
284
|
+
const startsLikeRead = /^(select|with)\b/i.test(sql);
|
|
285
|
+
if (startsLikeRead && hasTopLevelSetOperation(sql)) return "many";
|
|
286
|
+
|
|
287
|
+
const outerSelectList = findOuterSelectList(sql);
|
|
288
|
+
const hasAggregate = outerSelectList ? hasOuterAggregateFunction(outerSelectList) : false;
|
|
289
|
+
const hasGroupBy = hasTopLevelGroupBy(sql);
|
|
290
|
+
if (startsLikeRead && hasAggregate && !hasGroupBy) return "single";
|
|
291
|
+
|
|
292
|
+
return "many";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* True when SQL clearly proves at most one row (for output_cardinality: single validation).
|
|
297
|
+
*/
|
|
298
|
+
export function sqlProvesSingleRow(sqlText) {
|
|
299
|
+
return inferSqlCardinality(sqlText) === "single";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Parse node id from a nodes.* placeholder expression (supports bracket index).
|
|
304
|
+
* e.g. nodes.create_payment[0].id → create_payment
|
|
305
|
+
*/
|
|
306
|
+
export function referencedNodeIdFromNodesExpression(expr) {
|
|
307
|
+
const trimmed = String(expr || "").trim();
|
|
308
|
+
const match = /^nodes\.([A-Za-z_][A-Za-z0-9_-]*)/.exec(trimmed);
|
|
309
|
+
return match?.[1] ?? null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function stripePlaceholderIssue(placeholder, nodeId, nodeType) {
|
|
313
|
+
if (nodeType !== "stripe") return null;
|
|
314
|
+
const prefix = `nodes.${nodeId}.`;
|
|
315
|
+
if (!String(placeholder).startsWith(prefix)) return null;
|
|
316
|
+
const rest = String(placeholder).slice(prefix.length);
|
|
317
|
+
if (!rest || rest.startsWith("output.")) return null;
|
|
318
|
+
return {
|
|
319
|
+
rule: "stripe_output_wrong_path",
|
|
320
|
+
message: `Placeholder \${${placeholder}} is not the Stripe node output shape. Use output.metadata.*.`,
|
|
321
|
+
fix_hint: `Use \${nodes.${nodeId}.output.metadata.url} for checkout URL and \${nodes.${nodeId}.output.metadata.id} for session id.`,
|
|
322
|
+
example_fix: `\${nodes.${nodeId}.output.metadata.url}`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function databaseManyRowPlaceholderIssue(placeholder, nodeId, cardinality) {
|
|
327
|
+
if (cardinality !== "many") return null;
|
|
328
|
+
if (/\[\d+\]/.test(placeholder)) return null;
|
|
329
|
+
const match = /^nodes\.[A-Za-z_][A-Za-z0-9_-]*\.([A-Za-z_][A-Za-z0-9_-]*)/.exec(String(placeholder));
|
|
330
|
+
if (!match) return null;
|
|
331
|
+
const prop = match[1];
|
|
332
|
+
return {
|
|
333
|
+
rule: "node_output_many_direct_property",
|
|
334
|
+
message: `Placeholder \${${placeholder}} reads '${prop}' directly from node '${nodeId}', but that node returns many rows.`,
|
|
335
|
+
fix_hint: `Use \${nodes.${nodeId}[0].${prop}} or process \${nodes.${nodeId}} in javascript_code. For INSERT ... RETURNING (single row), use \${nodes.${nodeId}.${prop}} without [0].`,
|
|
336
|
+
example_fix: `\${nodes.${nodeId}[0].${prop}}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Top-level keys from endpoint output schema (`type: object` + `properties`). */
|
|
341
|
+
export function outputSchemaPropertyNames(outputSchema) {
|
|
342
|
+
if (!outputSchema || typeof outputSchema !== "object" || Array.isArray(outputSchema)) {
|
|
343
|
+
return new Set();
|
|
344
|
+
}
|
|
345
|
+
const props = outputSchema.properties;
|
|
346
|
+
if (!props || typeof props !== "object" || Array.isArray(props)) return new Set();
|
|
347
|
+
return new Set(Object.keys(props));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Keys on workflow.output mapping object. */
|
|
351
|
+
export function workflowOutputKeys(workflowOutput) {
|
|
352
|
+
if (!workflowOutput || typeof workflowOutput !== "object" || Array.isArray(workflowOutput)) {
|
|
353
|
+
return new Set();
|
|
354
|
+
}
|
|
355
|
+
return new Set(Object.keys(workflowOutput));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Compare workflow.output keys against declared output.properties (extras = mismatch). */
|
|
359
|
+
export function workflowOutputMismatchKeys(outputSchema, workflowOutput) {
|
|
360
|
+
const declared = outputSchemaPropertyNames(outputSchema);
|
|
361
|
+
const mapped = workflowOutputKeys(workflowOutput);
|
|
362
|
+
if (declared.size === 0 || mapped.size === 0) return [];
|
|
363
|
+
return [...mapped].filter((key) => !declared.has(key));
|
|
364
|
+
}
|