@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/commands/args.js +15 -2
  3. package/dist/commands/args.js.map +1 -1
  4. package/dist/intent/build-aggregate.js +6 -2
  5. package/dist/intent/build-aggregate.js.map +1 -1
  6. package/dist/intent/build-detail.js +3 -2
  7. package/dist/intent/build-detail.js.map +1 -1
  8. package/dist/intent/build-list.js +11 -7
  9. package/dist/intent/build-list.js.map +1 -1
  10. package/dist/intent/select-child-relationship.d.ts +1 -1
  11. package/dist/intent/select-child-relationship.js +3 -3
  12. package/dist/intent/select-child-relationship.js.map +1 -1
  13. package/dist/lib/auth.js +3 -2
  14. package/dist/lib/auth.js.map +1 -1
  15. package/dist/lib/errors.d.ts +38 -0
  16. package/dist/lib/errors.js +42 -0
  17. package/dist/lib/errors.js.map +1 -0
  18. package/dist/lib/introspect.js +4 -3
  19. package/dist/lib/introspect.js.map +1 -1
  20. package/dist/lib/prime-schema.js +13 -5
  21. package/dist/lib/prime-schema.js.map +1 -1
  22. package/dist/lib/query-builder.js +19 -6
  23. package/dist/lib/query-builder.js.map +1 -1
  24. package/dist/lib/session.d.ts +10 -1
  25. package/dist/lib/session.js +9 -2
  26. package/dist/lib/session.js.map +1 -1
  27. package/dist/lib/variable-promotion.js +7 -3
  28. package/dist/lib/variable-promotion.js.map +1 -1
  29. package/dist/lib/walker.js +8 -6
  30. package/dist/lib/walker.js.map +1 -1
  31. package/dist/mcp/tools/sf-gql-aggregate.js +2 -6
  32. package/dist/mcp/tools/sf-gql-aggregate.js.map +1 -1
  33. package/dist/mcp/tools/sf-gql-connect.js +3 -7
  34. package/dist/mcp/tools/sf-gql-connect.js.map +1 -1
  35. package/dist/mcp/tools/sf-gql-create.js +2 -6
  36. package/dist/mcp/tools/sf-gql-create.js.map +1 -1
  37. package/dist/mcp/tools/sf-gql-delete.js +2 -6
  38. package/dist/mcp/tools/sf-gql-delete.js.map +1 -1
  39. package/dist/mcp/tools/sf-gql-detail.js +2 -6
  40. package/dist/mcp/tools/sf-gql-detail.js.map +1 -1
  41. package/dist/mcp/tools/sf-gql-discover.js +2 -6
  42. package/dist/mcp/tools/sf-gql-discover.js.map +1 -1
  43. package/dist/mcp/tools/sf-gql-list.js +2 -6
  44. package/dist/mcp/tools/sf-gql-list.js.map +1 -1
  45. package/dist/mcp/tools/sf-gql-raw.js +2 -6
  46. package/dist/mcp/tools/sf-gql-raw.js.map +1 -1
  47. package/dist/mcp/tools/sf-gql-update.js +2 -6
  48. package/dist/mcp/tools/sf-gql-update.js.map +1 -1
  49. package/dist/schemas/tool-adapter.d.ts +56 -0
  50. package/dist/schemas/tool-adapter.js +129 -0
  51. package/dist/schemas/tool-adapter.js.map +1 -0
  52. package/package.json +1 -1
  53. package/src/commands/args.ts +23 -2
  54. package/src/intent/__tests__/build-aggregate.spec.ts +64 -0
  55. package/src/intent/__tests__/build-list.spec.ts +115 -2
  56. package/src/intent/build-aggregate.ts +7 -3
  57. package/src/intent/build-detail.ts +4 -2
  58. package/src/intent/build-list.ts +13 -8
  59. package/src/intent/select-child-relationship.ts +3 -2
  60. package/src/lib/__tests__/apply-command.spec.ts +17 -1
  61. package/src/lib/__tests__/query-builder.spec.ts +68 -0
  62. package/src/lib/__tests__/session.spec.ts +44 -0
  63. package/src/lib/__tests__/variable-promotion.spec.ts +58 -0
  64. package/src/lib/auth.ts +4 -2
  65. package/src/lib/errors.ts +45 -0
  66. package/src/lib/introspect.ts +6 -3
  67. package/src/lib/prime-schema.ts +12 -4
  68. package/src/lib/query-builder.ts +19 -6
  69. package/src/lib/session.ts +20 -3
  70. package/src/lib/variable-promotion.ts +9 -3
  71. package/src/lib/walker.ts +8 -6
  72. package/src/mcp/tools/__tests__/error-surface.contract.spec.ts +261 -0
  73. package/src/mcp/tools/sf-gql-aggregate.ts +2 -6
  74. package/src/mcp/tools/sf-gql-connect.ts +3 -7
  75. package/src/mcp/tools/sf-gql-create.ts +2 -6
  76. package/src/mcp/tools/sf-gql-delete.ts +2 -6
  77. package/src/mcp/tools/sf-gql-detail.ts +2 -6
  78. package/src/mcp/tools/sf-gql-discover.ts +2 -6
  79. package/src/mcp/tools/sf-gql-list.ts +2 -6
  80. package/src/mcp/tools/sf-gql-raw.ts +2 -6
  81. package/src/mcp/tools/sf-gql-update.ts +2 -6
  82. package/src/schemas/__tests__/tool-adapter.spec.ts +299 -0
  83. 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
  }
@@ -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 Error(
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 Error(
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
+ }
@@ -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 Error(`Introspection query returned errors:\n ${messages}`);
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 Error("Introspection query did not return a __schema field");
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 Error(`No cached schema for "${instanceUrl}". Run \`graphiti connect <org>\` first.`);
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
  }
@@ -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 Error(
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): surface the raw error verbatim
285
- // (FR-13.5/13.6) there is no stale cache to keep.
286
- if (!forceRefresh) throw cause;
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.
@@ -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
- if (trimmed.startsWith("$")) return trimmed;
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: wrap in quotes
157
- return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
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
- if (value.startsWith("$")) return value;
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
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
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(", ")}]`;
@@ -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
- ): void {
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
- existing.type = type;
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
- const declared = inferredType.endsWith("!") ? inferredType.slice(0, -1) : inferredType;
102
- addVariable(session, varName, declared);
102
+ declared = inferredType.endsWith("!") ? inferredType.slice(0, -1) : inferredType;
103
103
  } catch {
104
- addVariable(session, varName, "String");
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
  }