@salesforce/graphiti 10.20.2 → 10.22.0
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/CHANGELOG.md +11 -0
- package/dist/commands/args.js +15 -2
- package/dist/commands/args.js.map +1 -1
- package/dist/intent/build-aggregate.js +6 -2
- package/dist/intent/build-aggregate.js.map +1 -1
- package/dist/intent/build-detail.js +3 -2
- package/dist/intent/build-detail.js.map +1 -1
- package/dist/intent/build-list.js +11 -7
- package/dist/intent/build-list.js.map +1 -1
- package/dist/intent/select-child-relationship.d.ts +1 -1
- package/dist/intent/select-child-relationship.js +3 -3
- package/dist/intent/select-child-relationship.js.map +1 -1
- package/dist/lib/auth.js +3 -2
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/errors.d.ts +38 -0
- package/dist/lib/errors.js +42 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/introspect.js +4 -3
- package/dist/lib/introspect.js.map +1 -1
- package/dist/lib/prime-schema.js +13 -5
- package/dist/lib/prime-schema.js.map +1 -1
- package/dist/lib/query-builder.js +19 -6
- package/dist/lib/query-builder.js.map +1 -1
- package/dist/lib/session.d.ts +10 -1
- package/dist/lib/session.js +9 -2
- package/dist/lib/session.js.map +1 -1
- package/dist/lib/variable-promotion.js +7 -3
- package/dist/lib/variable-promotion.js.map +1 -1
- package/dist/lib/walker.js +8 -6
- package/dist/lib/walker.js.map +1 -1
- package/dist/mcp/tools/sf-gql-aggregate.js +2 -6
- package/dist/mcp/tools/sf-gql-aggregate.js.map +1 -1
- package/dist/mcp/tools/sf-gql-connect.js +3 -7
- package/dist/mcp/tools/sf-gql-connect.js.map +1 -1
- package/dist/mcp/tools/sf-gql-create.js +2 -6
- package/dist/mcp/tools/sf-gql-create.js.map +1 -1
- package/dist/mcp/tools/sf-gql-delete.js +2 -6
- package/dist/mcp/tools/sf-gql-delete.js.map +1 -1
- package/dist/mcp/tools/sf-gql-detail.js +2 -6
- package/dist/mcp/tools/sf-gql-detail.js.map +1 -1
- package/dist/mcp/tools/sf-gql-discover.js +2 -6
- package/dist/mcp/tools/sf-gql-discover.js.map +1 -1
- package/dist/mcp/tools/sf-gql-list.js +2 -6
- package/dist/mcp/tools/sf-gql-list.js.map +1 -1
- package/dist/mcp/tools/sf-gql-raw.js +2 -6
- package/dist/mcp/tools/sf-gql-raw.js.map +1 -1
- package/dist/mcp/tools/sf-gql-update.js +2 -6
- package/dist/mcp/tools/sf-gql-update.js.map +1 -1
- package/dist/schemas/tool-adapter.d.ts +56 -0
- package/dist/schemas/tool-adapter.js +129 -0
- package/dist/schemas/tool-adapter.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/args.ts +23 -2
- package/src/intent/__tests__/build-aggregate.spec.ts +64 -0
- package/src/intent/__tests__/build-list.spec.ts +115 -2
- package/src/intent/build-aggregate.ts +7 -3
- package/src/intent/build-detail.ts +4 -2
- package/src/intent/build-list.ts +13 -8
- package/src/intent/select-child-relationship.ts +3 -2
- package/src/lib/__tests__/apply-command.spec.ts +17 -1
- package/src/lib/__tests__/query-builder.spec.ts +68 -0
- package/src/lib/__tests__/session.spec.ts +44 -0
- package/src/lib/__tests__/variable-promotion.spec.ts +58 -0
- package/src/lib/auth.ts +4 -2
- package/src/lib/errors.ts +45 -0
- package/src/lib/introspect.ts +6 -3
- package/src/lib/prime-schema.ts +12 -4
- package/src/lib/query-builder.ts +19 -6
- package/src/lib/session.ts +20 -3
- package/src/lib/variable-promotion.ts +9 -3
- package/src/lib/walker.ts +8 -6
- package/src/mcp/tools/__tests__/error-surface.contract.spec.ts +261 -0
- package/src/mcp/tools/sf-gql-aggregate.ts +2 -6
- package/src/mcp/tools/sf-gql-connect.ts +3 -7
- package/src/mcp/tools/sf-gql-create.ts +2 -6
- package/src/mcp/tools/sf-gql-delete.ts +2 -6
- package/src/mcp/tools/sf-gql-detail.ts +2 -6
- package/src/mcp/tools/sf-gql-discover.ts +2 -6
- package/src/mcp/tools/sf-gql-list.ts +2 -6
- package/src/mcp/tools/sf-gql-raw.ts +2 -6
- package/src/mcp/tools/sf-gql-update.ts +2 -6
- package/src/schemas/__tests__/tool-adapter.spec.ts +299 -0
- package/src/schemas/tool-adapter.ts +165 -0
|
@@ -68,6 +68,13 @@ export async function buildAggregate(spec: AggregateSpec, deps?: PrimeDeps): Pro
|
|
|
68
68
|
const extraWarnings: string[] = [];
|
|
69
69
|
applyGroupBy(session, connectionPath, aggregateNodePath, groupBy);
|
|
70
70
|
|
|
71
|
+
// Declare the reserved cursor variable before promoting user filter/orderBy
|
|
72
|
+
// variables, so a filter that reuses `$after` keeps its `String` type
|
|
73
|
+
// (first-wins) and surfaces a collision warning instead of overwriting the
|
|
74
|
+
// pagination arg's type (W-22697670). Mirrors buildList's ordering.
|
|
75
|
+
addVariable(session, "after", "String");
|
|
76
|
+
deepSetArg(session, connectionPath, "after", [], "$after");
|
|
77
|
+
|
|
71
78
|
if (spec.filter) {
|
|
72
79
|
promoteVariables(session, schema, connectionPath, "where", spec.filter, extraWarnings);
|
|
73
80
|
deepSetArg(session, connectionPath, "where", [], JSON.stringify(spec.filter));
|
|
@@ -88,9 +95,6 @@ export async function buildAggregate(spec: AggregateSpec, deps?: PrimeDeps): Pro
|
|
|
88
95
|
deepSetArg(session, connectionPath, "first", [], String(spec.first));
|
|
89
96
|
}
|
|
90
97
|
|
|
91
|
-
addVariable(session, "after", "String");
|
|
92
|
-
deepSetArg(session, connectionPath, "after", [], "$after");
|
|
93
|
-
|
|
94
98
|
selectLeaf(session, [...connectionPath, "pageInfo", "hasNextPage"]);
|
|
95
99
|
selectLeaf(session, [...connectionPath, "pageInfo", "endCursor"]);
|
|
96
100
|
|
|
@@ -63,9 +63,11 @@ export async function buildDetail(spec: DetailSpec, deps?: PrimeDeps): Promise<T
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
const extraWarnings: string[] = [];
|
|
67
|
+
|
|
66
68
|
if (spec.childRelationships) {
|
|
67
69
|
for (const child of spec.childRelationships) {
|
|
68
|
-
selectChildRelationship(session, schema, node, child);
|
|
70
|
+
selectChildRelationship(session, schema, node, child, extraWarnings);
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
@@ -91,5 +93,5 @@ export async function buildDetail(spec: DetailSpec, deps?: PrimeDeps): Promise<T
|
|
|
91
93
|
deepSetArg(session, connection, "where", ["Id", "eq"], `$${idVar}`);
|
|
92
94
|
deepSetArg(session, connection, "first", [], "1");
|
|
93
95
|
|
|
94
|
-
return buildOutput(session, schema, primingNote);
|
|
96
|
+
return buildOutput(session, schema, primingNote, extraWarnings);
|
|
95
97
|
}
|
package/src/intent/build-list.ts
CHANGED
|
@@ -56,36 +56,41 @@ export async function buildList(spec: ListSpec, deps?: PrimeDeps): Promise<ToolO
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
const extraWarnings: string[] = [];
|
|
60
|
+
|
|
61
|
+
// Declare the reserved cursor variable before promoting ANY user variables —
|
|
62
|
+
// including childRelationship filters — so a filter reusing $after keeps String
|
|
63
|
+
// (first-wins) and warns, instead of overwriting the pagination arg's type (W-22697670).
|
|
64
|
+
addVariable(session, "after", "String");
|
|
65
|
+
deepSetArg(session, connectionPath, "after", [], "$after");
|
|
66
|
+
|
|
59
67
|
if (spec.childRelationships) {
|
|
60
68
|
for (const child of spec.childRelationships) {
|
|
61
|
-
selectChildRelationship(session, schema, nodePath, child);
|
|
69
|
+
selectChildRelationship(session, schema, nodePath, child, extraWarnings);
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
const first = spec.first ?? 10;
|
|
66
74
|
deepSetArg(session, connectionPath, "first", [], String(first));
|
|
67
75
|
|
|
68
|
-
addVariable(session, "after", "String");
|
|
69
|
-
deepSetArg(session, connectionPath, "after", [], "$after");
|
|
70
|
-
|
|
71
76
|
selectLeaf(session, [...connectionPath, "pageInfo", "hasNextPage"]);
|
|
72
77
|
selectLeaf(session, [...connectionPath, "pageInfo", "endCursor"]);
|
|
73
78
|
|
|
74
79
|
if (spec.filter) {
|
|
75
|
-
promoteVariables(session, schema, connectionPath, "where", spec.filter);
|
|
80
|
+
promoteVariables(session, schema, connectionPath, "where", spec.filter, extraWarnings);
|
|
76
81
|
deepSetArg(session, connectionPath, "where", [], JSON.stringify(spec.filter));
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
const orderBy = normalizeOrderBy(spec.orderBy);
|
|
80
85
|
if (orderBy) {
|
|
81
|
-
promoteVariables(session, schema, connectionPath, "orderBy", orderBy);
|
|
86
|
+
promoteVariables(session, schema, connectionPath, "orderBy", orderBy, extraWarnings);
|
|
82
87
|
deepSetArg(session, connectionPath, "orderBy", [], JSON.stringify(orderBy));
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
if (spec.scope) {
|
|
86
|
-
promoteVariables(session, schema, connectionPath, "scope", spec.scope);
|
|
91
|
+
promoteVariables(session, schema, connectionPath, "scope", spec.scope, extraWarnings);
|
|
87
92
|
deepSetArg(session, connectionPath, "scope", [], spec.scope);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
return buildOutput(session, schema, primingNote);
|
|
95
|
+
return buildOutput(session, schema, primingNote, extraWarnings);
|
|
91
96
|
}
|
|
@@ -25,6 +25,7 @@ export function selectChildRelationship(
|
|
|
25
25
|
schema: GraphQLSchema,
|
|
26
26
|
parentNodePath: string[],
|
|
27
27
|
child: ChildRelationshipSpec,
|
|
28
|
+
warnings?: string[],
|
|
28
29
|
): void {
|
|
29
30
|
const childConnectionPath = [...parentNodePath, child.relationshipName];
|
|
30
31
|
const childNodePath = [...childConnectionPath, "edges", "node"];
|
|
@@ -37,12 +38,12 @@ export function selectChildRelationship(
|
|
|
37
38
|
deepSetArg(session, childConnectionPath, "first", [], String(child.first));
|
|
38
39
|
}
|
|
39
40
|
if (child.filter) {
|
|
40
|
-
promoteVariables(session, schema, childConnectionPath, "where", child.filter);
|
|
41
|
+
promoteVariables(session, schema, childConnectionPath, "where", child.filter, warnings);
|
|
41
42
|
deepSetArg(session, childConnectionPath, "where", [], JSON.stringify(child.filter));
|
|
42
43
|
}
|
|
43
44
|
const childOrderBy = normalizeOrderBy(child.orderBy);
|
|
44
45
|
if (childOrderBy) {
|
|
45
|
-
promoteVariables(session, schema, childConnectionPath, "orderBy", childOrderBy);
|
|
46
|
+
promoteVariables(session, schema, childConnectionPath, "orderBy", childOrderBy, warnings);
|
|
46
47
|
deepSetArg(session, childConnectionPath, "orderBy", [], JSON.stringify(childOrderBy));
|
|
47
48
|
}
|
|
48
49
|
}
|
|
@@ -17,10 +17,11 @@ const SCHEMA: GraphQLSchema = buildSchema(`
|
|
|
17
17
|
type RecordQuery {
|
|
18
18
|
Case(first: Int, after: String, where: Case_Filter, orderBy: Case_OrderBy): CaseConnection!
|
|
19
19
|
}
|
|
20
|
-
input Case_Filter { Status: PicklistOperators, Subject: StringOperators }
|
|
20
|
+
input Case_Filter { Status: PicklistOperators, Subject: StringOperators, Amount: DoubleOperators }
|
|
21
21
|
input Case_OrderBy { CreatedDate: OrderByClause }
|
|
22
22
|
input PicklistOperators { eq: String, ne: String, in: [String!] }
|
|
23
23
|
input StringOperators { eq: String, like: String }
|
|
24
|
+
input DoubleOperators { eq: Float, gt: Float }
|
|
24
25
|
input OrderByClause { order: Order! }
|
|
25
26
|
enum Order { ASC DESC }
|
|
26
27
|
type CaseConnection { edges: [CaseEdge!]!, pageInfo: PageInfo! }
|
|
@@ -95,6 +96,21 @@ describe("lib/apply-command — var", () => {
|
|
|
95
96
|
const session = createSession(ORG, "query", ORG_URL);
|
|
96
97
|
expect(() => applyCommand(session, SCHEMA, "var")).toThrow();
|
|
97
98
|
});
|
|
99
|
+
|
|
100
|
+
// W-22697670: re-declaring a $var with a conflicting type is rejected (first-wins
|
|
101
|
+
// keeps the original type; the define/var path surfaces the collision instead of
|
|
102
|
+
// silently misreporting the new type and producing an invalid query).
|
|
103
|
+
it("throws when a $var is re-declared with a conflicting type", () => {
|
|
104
|
+
const session = createSession(ORG, "query", ORG_URL);
|
|
105
|
+
applyCommand(session, SCHEMA, "var $x uiapi/query/Case/@args/where/Status/eq"); // String
|
|
106
|
+
expect(() =>
|
|
107
|
+
applyCommand(session, SCHEMA, "var $x uiapi/query/Case/@args/where/Amount/gt"),
|
|
108
|
+
).toThrow(/already declared as String; cannot redefine it as Float/);
|
|
109
|
+
// First-wins: $x stays String and is not bound at the Float position.
|
|
110
|
+
const q = renderQuery(session);
|
|
111
|
+
expect(q).toMatch(/\$x:\s*String/);
|
|
112
|
+
expect(q).not.toMatch(/\$x:\s*Float/);
|
|
113
|
+
});
|
|
98
114
|
});
|
|
99
115
|
|
|
100
116
|
describe("lib/apply-command — set", () => {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* For full license text, see the LICENSE.txt file
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { parse } from "graphql";
|
|
7
8
|
import { describe, expect, it } from "vitest";
|
|
8
9
|
import { makeSession, TEST_SCHEMA } from "../../__tests__/helpers/schema.js";
|
|
9
10
|
import { selectLeafInSession } from "../../commands/query.js";
|
|
@@ -58,6 +59,73 @@ describe("query-builder", () => {
|
|
|
58
59
|
});
|
|
59
60
|
});
|
|
60
61
|
|
|
62
|
+
describe("bare-$ variable render guard", () => {
|
|
63
|
+
it("renders a valid placeholder as a bare variable reference", () => {
|
|
64
|
+
const session = makeSession();
|
|
65
|
+
|
|
66
|
+
addVariable(session, "$foo", "Int");
|
|
67
|
+
|
|
68
|
+
session.navigationPath = ["query", "accounts"];
|
|
69
|
+
setArg(session, ["accounts"], "first", "$foo");
|
|
70
|
+
|
|
71
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
72
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
73
|
+
|
|
74
|
+
const query = renderQuery(session);
|
|
75
|
+
// Bare reference: emitted as $foo, not quoted "$foo".
|
|
76
|
+
expect(query).toMatch(/first: \$foo\b/);
|
|
77
|
+
expect(query).not.toContain('"$foo"');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("renders an invalid placeholder ($1var) as a quoted literal", () => {
|
|
81
|
+
const session = makeSession();
|
|
82
|
+
|
|
83
|
+
session.navigationPath = ["query", "accounts"];
|
|
84
|
+
setArg(session, ["accounts"], "first", "$1var");
|
|
85
|
+
|
|
86
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
87
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
88
|
+
|
|
89
|
+
const query = renderQuery(session);
|
|
90
|
+
// $1var is not a valid GraphQL Name -> quoted literal, not a bare var.
|
|
91
|
+
expect(query).toContain('first: "$1var"');
|
|
92
|
+
expect(query).not.toMatch(/first: \$1var\b/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders a hyphenated placeholder ($foo-bar) as a quoted literal", () => {
|
|
96
|
+
const session = makeSession();
|
|
97
|
+
|
|
98
|
+
session.navigationPath = ["query", "accounts"];
|
|
99
|
+
setArg(session, ["accounts"], "first", "$foo-bar");
|
|
100
|
+
|
|
101
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
102
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
103
|
+
|
|
104
|
+
const query = renderQuery(session);
|
|
105
|
+
// $foo-bar is not a valid GraphQL Name -> quoted literal, not a bare var.
|
|
106
|
+
expect(query).toContain('first: "$foo-bar"');
|
|
107
|
+
expect(query).not.toMatch(/first: \$foo-bar\b/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// W-22697670 (PR #654 review): control chars in a string value are escaped via
|
|
111
|
+
// JSON.stringify, so the rendered literal is well-formed and graphql.parse()
|
|
112
|
+
// accepts it — a raw newline previously produced an "Unterminated string" error.
|
|
113
|
+
it("escapes control chars in a string literal so the query stays parseable", () => {
|
|
114
|
+
const session = makeSession();
|
|
115
|
+
|
|
116
|
+
session.navigationPath = ["query", "accounts"];
|
|
117
|
+
setArg(session, ["accounts"], "first", "a\nb\tc");
|
|
118
|
+
|
|
119
|
+
session.navigationPath = ["query", "accounts", "edges", "node"];
|
|
120
|
+
selectLeaf(session, ["accounts", "edges", "node", "name"]);
|
|
121
|
+
|
|
122
|
+
const query = renderQuery(session);
|
|
123
|
+
expect(query).toContain("first: " + JSON.stringify("a\nb\tc"));
|
|
124
|
+
expect(query).not.toContain("a\nb"); // no raw newline inside the literal
|
|
125
|
+
expect(() => parse(query)).not.toThrow();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
61
129
|
describe("aliasing rendering", () => {
|
|
62
130
|
it("renders multiple aliased instances with different variables", () => {
|
|
63
131
|
const session = makeSession();
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
8
|
import { makeSession } from "../../__tests__/helpers/schema.js";
|
|
9
9
|
import { renderQuery } from "../query-builder.js";
|
|
10
|
+
import type { VariableTypeCollision } from "../session.js";
|
|
10
11
|
import {
|
|
11
12
|
addVariable,
|
|
12
13
|
appendListElement,
|
|
@@ -270,6 +271,49 @@ describe("session", () => {
|
|
|
270
271
|
});
|
|
271
272
|
});
|
|
272
273
|
|
|
274
|
+
describe("addVariable type-collision (W-22697670)", () => {
|
|
275
|
+
it("returns undefined and records the entry for a new variable name", () => {
|
|
276
|
+
const session = makeSession();
|
|
277
|
+
|
|
278
|
+
const collision = addVariable(session, "$limit", "Int");
|
|
279
|
+
|
|
280
|
+
expect(collision).toBeUndefined();
|
|
281
|
+
expect(session.variables).toHaveLength(1);
|
|
282
|
+
expect(session.variables[0]).toMatchObject({ name: "limit", type: "Int" });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("returns undefined when the same name is re-declared with the SAME type", () => {
|
|
286
|
+
const session = makeSession();
|
|
287
|
+
|
|
288
|
+
const first = addVariable(session, "$status", "String");
|
|
289
|
+
const second = addVariable(session, "$status", "String");
|
|
290
|
+
|
|
291
|
+
expect(first).toBeUndefined();
|
|
292
|
+
expect(second).toBeUndefined();
|
|
293
|
+
expect(session.variables).toHaveLength(1);
|
|
294
|
+
expect(session.variables[0]).toMatchObject({ name: "status", type: "String" });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("keeps the first-declared type and reports a collision on a DIFFERENT type (first-wins)", () => {
|
|
298
|
+
const session = makeSession();
|
|
299
|
+
|
|
300
|
+
const first = addVariable(session, "$amount", "String");
|
|
301
|
+
const second = addVariable(session, "$amount", "Float");
|
|
302
|
+
|
|
303
|
+
expect(first).toBeUndefined();
|
|
304
|
+
const collision: VariableTypeCollision | undefined = second;
|
|
305
|
+
expect(collision).toEqual({
|
|
306
|
+
name: "amount",
|
|
307
|
+
existingType: "String",
|
|
308
|
+
ignoredType: "Float",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// First-wins: the stored variable still has the FIRST type, not the conflicting one.
|
|
312
|
+
expect(session.variables).toHaveLength(1);
|
|
313
|
+
expect(session.variables[0]).toMatchObject({ name: "amount", type: "String" });
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
273
317
|
describe("runtime variable building", () => {
|
|
274
318
|
it("runtime variables are built from runtime values and defaults", () => {
|
|
275
319
|
const session = makeSession();
|
|
@@ -170,6 +170,64 @@ describe("variable-promotion", () => {
|
|
|
170
170
|
expect(session.variables).toHaveLength(0);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
it("warns on a type collision and keeps the first inferred type (first-wins)", () => {
|
|
174
|
+
const session = makeSession();
|
|
175
|
+
const warnings: string[] = [];
|
|
176
|
+
|
|
177
|
+
// The same $dup reference appears at two leaves with DIFFERENT inferred
|
|
178
|
+
// types: Status.eq is `Picklist` (PicklistOperators.eq) and Industry.eq
|
|
179
|
+
// is `String` (StringOperators.eq). The walker processes keys in
|
|
180
|
+
// insertion order, so Status is seen first and wins; the later String
|
|
181
|
+
// inference is reported as a collision and ignored.
|
|
182
|
+
promoteVariables(
|
|
183
|
+
session,
|
|
184
|
+
schema,
|
|
185
|
+
accountFieldPath,
|
|
186
|
+
"where",
|
|
187
|
+
{
|
|
188
|
+
Status: { eq: "$dup" },
|
|
189
|
+
Industry: { eq: "$dup" },
|
|
190
|
+
},
|
|
191
|
+
warnings,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// First-wins: only one variable, keeping the first (Picklist) type.
|
|
195
|
+
expect(session.variables).toHaveLength(1);
|
|
196
|
+
expect(session.variables[0]!.name).toBe("dup");
|
|
197
|
+
expect(session.variables[0]!.type).toBe("Picklist");
|
|
198
|
+
|
|
199
|
+
// A collision warning naming the variable was surfaced to the sink.
|
|
200
|
+
const collisionWarning = warnings.find((w) => w.includes("type collision for $"));
|
|
201
|
+
expect(collisionWarning).toBeDefined();
|
|
202
|
+
expect(collisionWarning).toContain("type collision for $dup");
|
|
203
|
+
// The warning explains which type was kept and which was ignored.
|
|
204
|
+
expect(collisionWarning).toContain("Picklist");
|
|
205
|
+
expect(collisionWarning).toContain("String");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("does not warn when the same $var is reused with the same inferred type", () => {
|
|
209
|
+
const session = makeSession();
|
|
210
|
+
const warnings: string[] = [];
|
|
211
|
+
|
|
212
|
+
// Both leaves are `String` (StringOperators.eq / .like), so reusing $dup
|
|
213
|
+
// is consistent — no collision, no warning, one variable.
|
|
214
|
+
promoteVariables(
|
|
215
|
+
session,
|
|
216
|
+
schema,
|
|
217
|
+
accountFieldPath,
|
|
218
|
+
"where",
|
|
219
|
+
{
|
|
220
|
+
Industry: { eq: "$dup", like: "$dup" },
|
|
221
|
+
},
|
|
222
|
+
warnings,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(session.variables).toHaveLength(1);
|
|
226
|
+
expect(session.variables[0]!.name).toBe("dup");
|
|
227
|
+
expect(session.variables[0]!.type).toBe("String");
|
|
228
|
+
expect(warnings.some((w) => w.includes("type collision"))).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
173
231
|
it("strips a trailing ! when the inferred type is non-null", () => {
|
|
174
232
|
// The schema has Account_Filter.Or: [Account_OrderBy!] — the nested
|
|
175
233
|
// element is non-null. If a $var is placed where a non-null is required,
|
package/src/lib/auth.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import path from "path";
|
|
9
9
|
import { Org, AuthInfo } from "@salesforce/core";
|
|
10
|
+
import { AuthError } from "./errors.js";
|
|
10
11
|
import { graphitiHome } from "./fs-utils.js";
|
|
11
12
|
|
|
12
13
|
export interface OrgAuth {
|
|
@@ -70,14 +71,15 @@ export async function getOrgAuth(orgAlias: string): Promise<OrgAuth> {
|
|
|
70
71
|
connection = org.getConnection();
|
|
71
72
|
} catch (err) {
|
|
72
73
|
const msg = err instanceof Error ? err.message : String(err);
|
|
73
|
-
throw new
|
|
74
|
+
throw new AuthError(
|
|
74
75
|
`Failed to get org info for "${orgAlias}". Is the alias correct? Have you run \`sf org login web --alias ${orgAlias}\`?\n${msg}`,
|
|
76
|
+
{ cause: err },
|
|
75
77
|
);
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
const fields = connection.getAuthInfo().getFields();
|
|
79
81
|
if (!connection.accessToken || !connection.instanceUrl) {
|
|
80
|
-
throw new
|
|
82
|
+
throw new AuthError(
|
|
81
83
|
`Missing accessToken or instanceUrl for "${orgAlias}". Token may have expired -- try \`sf org login web --alias ${orgAlias}\` to re-authenticate.`,
|
|
82
84
|
);
|
|
83
85
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Typed error markers used to classify failures at the MCP tool boundary
|
|
9
|
+
* (W-22697673). The MCP adapter (`schemas/tool-adapter.ts`) maps these
|
|
10
|
+
* to category prefixes — `Auth:` / `Schema:` / `UserInput:` — so an agent can
|
|
11
|
+
* distinguish an infra/auth/schema failure from a user-input mistake without
|
|
12
|
+
* parsing free text. `MutationContextError` (walker) is also treated as
|
|
13
|
+
* UserInput; message heuristics remain a secondary fallback for untyped throws.
|
|
14
|
+
* Everything unmatched falls through to `Internal:`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Credential/auth resolution failure (e.g. unknown org, expired token). → `Auth:` */
|
|
18
|
+
export class AuthError extends Error {
|
|
19
|
+
constructor(message: string, opts?: { cause?: unknown }) {
|
|
20
|
+
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
21
|
+
this.name = "AuthError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Agent-supplied input or spec violation that the user can fix: an unknown
|
|
27
|
+
* type/field/argument named in a request, an invalid navigation, a malformed
|
|
28
|
+
* command. → `UserInput:`. Carrying this typed marker keeps classification off
|
|
29
|
+
* the brittle message-shape heuristics for the navigation/validation sites that
|
|
30
|
+
* throw it.
|
|
31
|
+
*/
|
|
32
|
+
export class UserInputError extends Error {
|
|
33
|
+
constructor(message: string, opts?: { cause?: unknown }) {
|
|
34
|
+
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
35
|
+
this.name = "UserInputError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Schema introspection / priming / build failure. → `Schema:` */
|
|
40
|
+
export class SchemaError extends Error {
|
|
41
|
+
constructor(message: string, opts?: { cause?: unknown }) {
|
|
42
|
+
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
43
|
+
this.name = "SchemaError";
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/lib/introspect.ts
CHANGED
|
@@ -10,6 +10,7 @@ import fs from "fs";
|
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { Org, type Connection } from "@salesforce/core";
|
|
12
12
|
import type { OrgAuth } from "./auth.js";
|
|
13
|
+
import { SchemaError } from "./errors.js";
|
|
13
14
|
import { atomicWriteJson, graphitiHome } from "./fs-utils.js";
|
|
14
15
|
|
|
15
16
|
// Re-export for backward compatibility with existing graphiti consumers.
|
|
@@ -277,12 +278,12 @@ export async function downloadSchema(auth: OrgAuth): Promise<SchemaMetadata> {
|
|
|
277
278
|
const messages = (rawResult.errors as any[])
|
|
278
279
|
.map((e: any) => e.message ?? JSON.stringify(e))
|
|
279
280
|
.join("\n ");
|
|
280
|
-
throw new
|
|
281
|
+
throw new SchemaError(`Introspection query returned errors:\n ${messages}`);
|
|
281
282
|
}
|
|
282
283
|
|
|
283
284
|
const rawSchema = rawResult?.data?.__schema ?? rawResult?.__schema;
|
|
284
285
|
if (!rawSchema) {
|
|
285
|
-
throw new
|
|
286
|
+
throw new SchemaError("Introspection query did not return a __schema field");
|
|
286
287
|
}
|
|
287
288
|
|
|
288
289
|
const { result, removedCount } = stripDataCloudTypes(rawResult);
|
|
@@ -317,7 +318,9 @@ export async function downloadSchema(auth: OrgAuth): Promise<SchemaMetadata> {
|
|
|
317
318
|
export function loadIntrospectionResult(instanceUrl: string): any {
|
|
318
319
|
const fp = schemaPathForInstanceUrl(normalizeInstanceUrl(instanceUrl));
|
|
319
320
|
if (!fs.existsSync(fp)) {
|
|
320
|
-
throw new
|
|
321
|
+
throw new SchemaError(
|
|
322
|
+
`No cached schema for "${instanceUrl}". Run \`graphiti connect <org>\` first.`,
|
|
323
|
+
);
|
|
321
324
|
}
|
|
322
325
|
return JSON.parse(fs.readFileSync(fp, "utf-8"));
|
|
323
326
|
}
|
package/src/lib/prime-schema.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { getOrgAuth as realGetOrgAuth, type OrgAuth } from "./auth.js";
|
|
10
|
+
import { SchemaError } from "./errors.js";
|
|
10
11
|
import {
|
|
11
12
|
downloadSchema as realDownloadSchema,
|
|
12
13
|
getSchemaMetadata,
|
|
@@ -95,7 +96,7 @@ export async function withSchemaLock<T>(
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
if (Date.now() - startedWaitingAt > MAX_WAIT_MS) {
|
|
98
|
-
throw new
|
|
99
|
+
throw new SchemaError(
|
|
99
100
|
`Timed out waiting ${MAX_WAIT_MS}ms for schema priming lock at ${lockPath}`,
|
|
100
101
|
);
|
|
101
102
|
}
|
|
@@ -281,9 +282,16 @@ export async function primeSchemaWithLock(
|
|
|
281
282
|
// through.
|
|
282
283
|
meta = await deps.downloadSchema(auth);
|
|
283
284
|
} catch (cause) {
|
|
284
|
-
// Lazy prime (no existing cache):
|
|
285
|
-
// (FR-13.5/13.6)
|
|
286
|
-
|
|
285
|
+
// Lazy prime (no existing cache): no stale cache to keep, so surface
|
|
286
|
+
// the underlying failure (FR-13.5/13.6). Wrap untyped causes (e.g. a
|
|
287
|
+
// raw @salesforce/core/jsforce network error from connection.request)
|
|
288
|
+
// in SchemaError so the MCP boundary classifies priming failures as
|
|
289
|
+
// `Schema:`; a cause that is already SchemaError passes through.
|
|
290
|
+
if (!forceRefresh) {
|
|
291
|
+
if (cause instanceof SchemaError) throw cause;
|
|
292
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
293
|
+
throw new SchemaError(`Schema priming failed for "${orgAlias}": ${msg}`, { cause });
|
|
294
|
+
}
|
|
287
295
|
// Forced refresh: atomic writes mean the old JSON is still on
|
|
288
296
|
// disk, so surface a staleness-aware error and leave every cache
|
|
289
297
|
// untouched.
|
package/src/lib/query-builder.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* For full license text, see the LICENSE.txt file
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { GRAPHQL_NAME_RE } from "./graphql-name.js";
|
|
7
8
|
import type { QuerySession, ProjectionNode, DirectiveNode } from "./session.js";
|
|
8
9
|
import { getChildren, getEffectiveArgs } from "./session.js";
|
|
9
10
|
|
|
@@ -133,8 +134,10 @@ function renderDirective(dir: DirectiveNode): string {
|
|
|
133
134
|
function formatArgValue(value: string): string {
|
|
134
135
|
const trimmed = value.trim();
|
|
135
136
|
|
|
136
|
-
// Variable reference
|
|
137
|
-
|
|
137
|
+
// Variable reference — only when the name is a valid GraphQL Name. A typo'd
|
|
138
|
+
// $-string (e.g. "$1var", "$foo-bar") falls through and is quoted as a literal
|
|
139
|
+
// rather than emitted as an undeclared bare variable reference.
|
|
140
|
+
if (trimmed.startsWith("$") && GRAPHQL_NAME_RE.test(trimmed.slice(1))) return trimmed;
|
|
138
141
|
|
|
139
142
|
// Numeric
|
|
140
143
|
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
@@ -153,8 +156,12 @@ function formatArgValue(value: string): string {
|
|
|
153
156
|
// Quoted string — pass through
|
|
154
157
|
if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed;
|
|
155
158
|
|
|
156
|
-
// Default:
|
|
157
|
-
|
|
159
|
+
// Default: a string literal. JSON.stringify produces a spec-valid GraphQL string
|
|
160
|
+
// literal — it escapes line terminators and control chars (\n \r \t \b \f) and
|
|
161
|
+
// quotes/backslashes — so values like "a\nb" don't render a raw newline that
|
|
162
|
+
// graphql.parse() would reject as an unterminated string. (U+2028/U+2029 pass
|
|
163
|
+
// through raw and are handled at the MCP text boundary, not here.)
|
|
164
|
+
return JSON.stringify(trimmed);
|
|
158
165
|
}
|
|
159
166
|
|
|
160
167
|
/**
|
|
@@ -176,10 +183,16 @@ function valueToGraphQL(value: unknown): string {
|
|
|
176
183
|
if (typeof value === "boolean") return String(value);
|
|
177
184
|
if (typeof value === "number") return String(value);
|
|
178
185
|
if (typeof value === "string") {
|
|
179
|
-
|
|
186
|
+
// Bare variable reference only when the name is a valid GraphQL Name;
|
|
187
|
+
// otherwise quote it as a literal (a typo'd $-string is not a declared var).
|
|
188
|
+
if (value.startsWith("$") && GRAPHQL_NAME_RE.test(value.slice(1))) return value;
|
|
180
189
|
// Emit uppercase identifiers as bare enum tokens (e.g. DESC, ASC, EVERYTHING)
|
|
181
190
|
if (/^[A-Z_][A-Z0-9_]*$/.test(value)) return value;
|
|
182
|
-
|
|
191
|
+
// JSON.stringify yields a spec-valid GraphQL string literal that escapes line
|
|
192
|
+
// terminators and control chars (\n \r \t \b \f), unlike a manual \ / " escape
|
|
193
|
+
// which would leave a raw newline that graphql.parse() rejects. (U+2028/U+2029
|
|
194
|
+
// pass through raw and are handled at the MCP text boundary, not here.)
|
|
195
|
+
return JSON.stringify(value);
|
|
183
196
|
}
|
|
184
197
|
if (Array.isArray(value)) {
|
|
185
198
|
return `[${value.map(valueToGraphQL).join(", ")}]`;
|
package/src/lib/session.ts
CHANGED
|
@@ -995,20 +995,37 @@ export function removeNodeByIdWithPrune(session: QuerySession, nodeId: string):
|
|
|
995
995
|
return true;
|
|
996
996
|
}
|
|
997
997
|
|
|
998
|
+
/**
|
|
999
|
+
* A type-conflicting `$var` redeclaration detected by {@link addVariable}.
|
|
1000
|
+
* The first-declared type is kept (first-wins); the later inference is ignored.
|
|
1001
|
+
*/
|
|
1002
|
+
export interface VariableTypeCollision {
|
|
1003
|
+
name: string;
|
|
1004
|
+
existingType: string;
|
|
1005
|
+
ignoredType: string;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
998
1008
|
export function addVariable(
|
|
999
1009
|
session: QuerySession,
|
|
1000
1010
|
name: string,
|
|
1001
1011
|
type: string,
|
|
1002
1012
|
defaultValue?: string,
|
|
1003
|
-
):
|
|
1013
|
+
): VariableTypeCollision | undefined {
|
|
1004
1014
|
const cleanName = normalizeVariableName(name);
|
|
1005
1015
|
const existing = session.variables.find((variable) => variable.name === cleanName);
|
|
1006
1016
|
if (existing) {
|
|
1007
|
-
|
|
1017
|
+
// First-wins: a variable referenced more than once (e.g. across two filter
|
|
1018
|
+
// leaves) keeps its first-declared type. Last-wins would silently overwrite
|
|
1019
|
+
// it and render a query UIAPI rejects at runtime. Report the conflict so a
|
|
1020
|
+
// caller with a warnings sink can surface it.
|
|
1021
|
+
if (existing.type !== type) {
|
|
1022
|
+
return { name: cleanName, existingType: existing.type, ignoredType: type };
|
|
1023
|
+
}
|
|
1008
1024
|
existing.defaultValue = defaultValue;
|
|
1009
|
-
return;
|
|
1025
|
+
return undefined;
|
|
1010
1026
|
}
|
|
1011
1027
|
session.variables.push({ name: cleanName, type, defaultValue });
|
|
1028
|
+
return undefined;
|
|
1012
1029
|
}
|
|
1013
1030
|
|
|
1014
1031
|
export function setVariableRuntimeValue(
|
|
@@ -93,15 +93,21 @@ function walk(
|
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
const varName = m[1];
|
|
96
|
+
let declared: string;
|
|
96
97
|
try {
|
|
97
98
|
const { inferredType } = inferTypeFromArgsPath(schema, session.operation, fieldSchemaPath, [
|
|
98
99
|
argName,
|
|
99
100
|
...pathInsideArg,
|
|
100
101
|
]);
|
|
101
|
-
|
|
102
|
-
addVariable(session, varName, declared);
|
|
102
|
+
declared = inferredType.endsWith("!") ? inferredType.slice(0, -1) : inferredType;
|
|
103
103
|
} catch {
|
|
104
|
-
|
|
104
|
+
declared = "String";
|
|
105
|
+
}
|
|
106
|
+
const collision = addVariable(session, varName, declared);
|
|
107
|
+
if (collision && warnings) {
|
|
108
|
+
warnings.push(
|
|
109
|
+
`Variable: type collision for $${collision.name} — first declaration as '${collision.existingType}' kept, later inference '${collision.ignoredType}' ignored. UIAPI requires one type per variable; reference $${collision.name} consistently or use distinct variable names.`,
|
|
110
|
+
);
|
|
105
111
|
}
|
|
106
112
|
return;
|
|
107
113
|
}
|