@salesforce/graphiti 10.10.2 → 10.11.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 (44) hide show
  1. package/AGENT_GUIDE.md +23 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +17 -6
  4. package/dist/commands/connect.d.ts +2 -1
  5. package/dist/commands/connect.js +28 -7
  6. package/dist/commands/connect.js.map +1 -1
  7. package/dist/intent/build-connect.d.ts +24 -0
  8. package/dist/intent/build-connect.js +47 -0
  9. package/dist/intent/build-connect.js.map +1 -0
  10. package/dist/intent/types.d.ts +23 -0
  11. package/dist/lib/fs-utils.d.ts +3 -1
  12. package/dist/lib/fs-utils.js +7 -3
  13. package/dist/lib/fs-utils.js.map +1 -1
  14. package/dist/lib/introspect.js +20 -1
  15. package/dist/lib/introspect.js.map +1 -1
  16. package/dist/lib/prime-schema.d.ts +70 -16
  17. package/dist/lib/prime-schema.js +166 -33
  18. package/dist/lib/prime-schema.js.map +1 -1
  19. package/dist/lib/walker.d.ts +10 -0
  20. package/dist/lib/walker.js +26 -2
  21. package/dist/lib/walker.js.map +1 -1
  22. package/dist/mcp/server.js +2 -0
  23. package/dist/mcp/server.js.map +1 -1
  24. package/dist/mcp/tools/sf-gql-connect.d.ts +9 -0
  25. package/dist/mcp/tools/sf-gql-connect.js +27 -0
  26. package/dist/mcp/tools/sf-gql-connect.js.map +1 -0
  27. package/package.json +1 -1
  28. package/src/__tests__/helpers/object-info.ts +37 -0
  29. package/src/commands/__tests__/connect.spec.ts +92 -0
  30. package/src/commands/connect.ts +28 -6
  31. package/src/intent/__tests__/build-connect.spec.ts +103 -0
  32. package/src/intent/build-connect.ts +55 -0
  33. package/src/intent/types.ts +25 -0
  34. package/src/lib/__tests__/introspect.spec.ts +34 -0
  35. package/src/lib/__tests__/prime-schema.spec.ts +341 -0
  36. package/src/lib/__tests__/walker.spec.ts +13 -0
  37. package/src/lib/fs-utils.ts +8 -3
  38. package/src/lib/introspect.ts +29 -6
  39. package/src/lib/prime-schema.ts +184 -32
  40. package/src/lib/walker.ts +26 -2
  41. package/src/mcp/__tests__/server.spec.ts +1 -0
  42. package/src/mcp/server.ts +2 -0
  43. package/src/mcp/tools/__tests__/sf-gql-connect.spec.ts +202 -0
  44. package/src/mcp/tools/sf-gql-connect.ts +42 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { buildSchema } from "graphql";
11
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
12
+ import { makeNoopPrimeDeps } from "../../__tests__/helpers/prime-deps.js";
13
+ import { captureStdout } from "../../__tests__/helpers/stdout.js";
14
+ import { schemaCacheKeyForInstanceUrl, schemaDir } from "../../lib/introspect.js";
15
+ import { type PrimeDeps, SchemaRefreshError } from "../../lib/prime-schema.js";
16
+ import { connectCommand } from "../connect.js";
17
+
18
+ const ORG = "test-connect-cli";
19
+ const ORG_URL = "https://test-connect-cli.my.salesforce.com";
20
+ const SCHEMA = buildSchema(`type Query { x: Int }`);
21
+
22
+ describe("commands/connect", () => {
23
+ let tmpRoot: string;
24
+
25
+ beforeEach(() => {
26
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-cmd-connect-"));
27
+ process.env.GRAPHITI_HOME = tmpRoot;
28
+ });
29
+
30
+ afterEach(() => {
31
+ delete process.env.GRAPHITI_HOME;
32
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
33
+ });
34
+
35
+ function schemaFile(): string {
36
+ return path.join(schemaDir(), `${schemaCacheKeyForInstanceUrl(ORG_URL)}.json`);
37
+ }
38
+
39
+ it("--refresh routes through the shared path and clears the on-disk ObjectInfo cache", async () => {
40
+ const deps = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
41
+ await captureStdout(() => connectCommand(ORG, {}, deps)); // initial prime
42
+
43
+ // Seed an ObjectInfo disk cache entry for this org — the stale data a
44
+ // refresh must clear (the bug this story fixes).
45
+ const oiDir = path.join(tmpRoot, "cache", "objectInfos", ORG);
46
+ fs.mkdirSync(oiDir, { recursive: true });
47
+ fs.writeFileSync(path.join(oiDir, "Account.json"), "{}");
48
+ expect(fs.existsSync(oiDir)).toBe(true);
49
+
50
+ const out = await captureStdout(() => connectCommand(ORG, { refresh: true }, deps));
51
+ expect(fs.existsSync(oiDir)).toBe(false); // cleared by the refresh
52
+ expect(out).toMatch(/Refreshed test-connect-cli/);
53
+ });
54
+
55
+ it("--refresh failure throws SchemaRefreshError and keeps the cached schema", async () => {
56
+ const good = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
57
+ await captureStdout(() => connectCommand(ORG, {}, good)); // prime a real cache
58
+
59
+ const before = fs.readFileSync(schemaFile(), "utf-8");
60
+ const failing: PrimeDeps = {
61
+ getOrgAuth: good.getOrgAuth,
62
+ downloadSchema: async () => {
63
+ throw new Error("introspection failed");
64
+ },
65
+ };
66
+
67
+ await expect(
68
+ captureStdout(() => connectCommand(ORG, { refresh: true }, failing)),
69
+ ).rejects.toBeInstanceOf(SchemaRefreshError);
70
+
71
+ // The previously cached schema is left intact.
72
+ expect(fs.readFileSync(schemaFile(), "utf-8")).toBe(before);
73
+ });
74
+
75
+ it("connect without --refresh on a cached org reports 'already cached' and does not download", async () => {
76
+ const good = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
77
+ await captureStdout(() => connectCommand(ORG, {}, good)); // prime
78
+
79
+ let downloadCalls = 0;
80
+ const counting: PrimeDeps = {
81
+ getOrgAuth: good.getOrgAuth,
82
+ downloadSchema: async (a) => {
83
+ downloadCalls++;
84
+ return good.downloadSchema(a);
85
+ },
86
+ };
87
+
88
+ const out = await captureStdout(() => connectCommand(ORG, {}, counting));
89
+ expect(out).toMatch(/already cached/);
90
+ expect(downloadCalls).toBe(0);
91
+ });
92
+ });
@@ -5,18 +5,23 @@
5
5
  */
6
6
 
7
7
  import { getOrgAuth } from "../lib/auth.js";
8
- import { downloadSchema, getSchemaMetadata } from "../lib/introspect.js";
8
+ import { getSchemaMetadata } from "../lib/introspect.js";
9
+ import { type PrimeDeps, primeSchemaWithLock } from "../lib/prime-schema.js";
9
10
 
10
11
  export async function connectCommand(
11
12
  orgAlias: string,
12
13
  opts: { refresh?: boolean } = {},
14
+ deps?: PrimeDeps,
13
15
  ): Promise<void> {
16
+ const getAuth = deps?.getOrgAuth ?? getOrgAuth;
14
17
  console.log(`Connecting to ${orgAlias}...`);
15
18
 
16
- const auth = await getOrgAuth(orgAlias);
19
+ const auth = await getAuth(orgAlias);
17
20
  console.log(`Authenticated as ${auth.username} on ${auth.instanceUrl}`);
18
21
 
19
22
  if (!opts.refresh) {
23
+ // Resolve via the already-resolved instance URL (not the alias) so we
24
+ // don't trigger a second org resolution, and so injected deps stay honored.
20
25
  const existing = getSchemaMetadata(auth.instanceUrl);
21
26
  if (existing) {
22
27
  const age = formatSchemaAge(new Date(existing.downloadedAt));
@@ -26,21 +31,38 @@ export async function connectCommand(
26
31
  }
27
32
  }
28
33
 
29
- console.log("Downloading GraphQL schema via introspection...");
34
+ const verb = opts.refresh ? "Refreshing" : "Downloading";
35
+ console.log(`${verb} GraphQL schema via introspection...`);
30
36
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
31
37
  let i = 0;
32
38
  const startTime = Date.now();
33
39
  const spinner = setInterval(() => {
34
40
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
35
- process.stderr.write(`\r${frames[i++ % frames.length]} Downloading schema... (${elapsed}s)`);
41
+ process.stderr.write(`\r${frames[i++ % frames.length]} ${verb} schema... (${elapsed}s)`);
36
42
  }, 100);
37
43
 
38
44
  try {
39
- const meta = await downloadSchema(auth);
45
+ // Route through the shared priming layer so a refresh coherently clears
46
+ // all three caches (introspection JSON, in-memory schema, ObjectInfo) and
47
+ // coalesces with any concurrent CLI/MCP refresh. A failed refresh throws
48
+ // SchemaRefreshError (kept caches + staleness message), surfaced by cli.ts.
49
+ const result = await primeSchemaWithLock(orgAlias, deps, { forceRefresh: !!opts.refresh });
40
50
  clearInterval(spinner);
41
51
  process.stderr.write("\r");
42
52
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
43
- console.log(`Connected to ${orgAlias}. Schema cached: ${meta.typeCount} types (${elapsed}s).`);
53
+ // `result.typeCount` is set on the download path; a coalesced refresh
54
+ // (no download performed here) falls back to a one-time metadata read.
55
+ const typeCount = result.typeCount ?? getSchemaMetadata(auth.instanceUrl)?.typeCount ?? 0;
56
+ if (opts.refresh && !result.refreshed) {
57
+ // A concurrent refresh produced the new schema while we waited.
58
+ console.log(
59
+ `Schema for ${orgAlias} was just refreshed by a concurrent process: ${typeCount} types.`,
60
+ );
61
+ } else if (opts.refresh) {
62
+ console.log(`Refreshed ${orgAlias}. Schema cached: ${typeCount} types (${elapsed}s).`);
63
+ } else {
64
+ console.log(`Connected to ${orgAlias}. Schema cached: ${typeCount} types (${elapsed}s).`);
65
+ }
44
66
  } catch (err) {
45
67
  clearInterval(spinner);
46
68
  process.stderr.write("\r");
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { buildSchema } from "graphql";
11
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
12
+ import { makeNoopPrimeDeps } from "../../__tests__/helpers/prime-deps.js";
13
+ import { type PrimeDeps, SchemaRefreshError } from "../../lib/prime-schema.js";
14
+ import { buildConnect } from "../build-connect.js";
15
+
16
+ const ORG = "test-connect";
17
+ const ORG_URL = "https://test-connect.my.salesforce.com";
18
+ const SCHEMA = buildSchema(`type Query { x: Int }`);
19
+
20
+ function failingPrimeDeps(err: unknown): PrimeDeps {
21
+ return {
22
+ getOrgAuth: async () => ({
23
+ alias: ORG,
24
+ username: "u",
25
+ instanceUrl: ORG_URL,
26
+ accessToken: "t",
27
+ orgId: "00D",
28
+ }),
29
+ downloadSchema: async () => {
30
+ throw err;
31
+ },
32
+ };
33
+ }
34
+
35
+ describe("intent/build-connect", () => {
36
+ let tmpRoot: string;
37
+
38
+ beforeEach(() => {
39
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-build-connect-"));
40
+ process.env.GRAPHITI_HOME = tmpRoot;
41
+ });
42
+
43
+ afterEach(() => {
44
+ delete process.env.GRAPHITI_HOME;
45
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
46
+ });
47
+
48
+ it("lazily primes on first connect (cached: false, refreshed: false)", async () => {
49
+ const out = await buildConnect(
50
+ { org: ORG },
51
+ { primeDeps: makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA) },
52
+ );
53
+ expect(out.cached).toBe(false);
54
+ expect(out.refreshed).toBe(false);
55
+ expect(out.instanceUrl).toBe(ORG_URL);
56
+ expect(out.warnings).toBeUndefined();
57
+ });
58
+
59
+ it("returns cached when a connect finds an existing schema and forceRefresh is omitted", async () => {
60
+ const primeDeps = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
61
+ await buildConnect({ org: ORG }, { primeDeps }); // prime
62
+ const out = await buildConnect({ org: ORG }, { primeDeps });
63
+ expect(out.cached).toBe(true);
64
+ expect(out.refreshed).toBe(false);
65
+ });
66
+
67
+ it("forceRefresh re-downloads and reports refreshed: true", async () => {
68
+ const primeDeps = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
69
+ await buildConnect({ org: ORG }, { primeDeps }); // prime
70
+ const out = await buildConnect({ org: ORG, forceRefresh: true }, { primeDeps });
71
+ expect(out.refreshed).toBe(true);
72
+ expect(out.cached).toBe(false);
73
+ });
74
+
75
+ it("refresh failure with a surviving cache returns a soft staleness warning", async () => {
76
+ // Prime a real cache first, then fail the refresh.
77
+ await buildConnect({ org: ORG }, { primeDeps: makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA) });
78
+
79
+ const out = await buildConnect(
80
+ { org: ORG, forceRefresh: true },
81
+ {
82
+ primeDeps: failingPrimeDeps(new Error("introspection failed")),
83
+ },
84
+ );
85
+
86
+ expect(out.refreshed).toBe(false);
87
+ expect(out.cached).toBe(true);
88
+ expect(out.instanceUrl).toBe(ORG_URL);
89
+ expect(out.durationMs).toBe(0);
90
+ expect(out.warnings?.[0]).toMatch(/keeping the previously cached schema/i);
91
+ });
92
+
93
+ it("refresh failure with no prior cache propagates the error", async () => {
94
+ await expect(
95
+ buildConnect(
96
+ { org: ORG, forceRefresh: true },
97
+ {
98
+ primeDeps: failingPrimeDeps(new Error("introspection failed")),
99
+ },
100
+ ),
101
+ ).rejects.toBeInstanceOf(SchemaRefreshError);
102
+ });
103
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ import { type ConnectOutput, type ConnectSpec } from "./types.js";
8
+ import { type PrimeDeps, primeSchemaWithLock, SchemaRefreshError } from "../lib/prime-schema.js";
9
+
10
+ export interface ConnectDeps {
11
+ primeDeps?: PrimeDeps;
12
+ }
13
+
14
+ /**
15
+ * Build the `sf_gql_connect` payload (W-22845606). Primes the schema cache for
16
+ * `spec.org`, or — with `spec.forceRefresh` — re-downloads it and coherently
17
+ * clears all three caches (on-disk introspection JSON, in-memory parsed schema,
18
+ * ObjectInfo).
19
+ *
20
+ * Failure policy (Q6 of the plan): a failed *refresh* that leaves a usable
21
+ * cached schema behind is recoverable, so we return a soft `ConnectOutput`
22
+ * (`refreshed: false`, `cached: true`) carrying a staleness-aware `warnings[]`
23
+ * entry — the agent can keep working on the still-valid schema. A failure with
24
+ * no prior cache (or any non-refresh failure such as missing auth) propagates,
25
+ * so the MCP SDK surfaces it as a hard tool error.
26
+ */
27
+ export async function buildConnect(
28
+ spec: ConnectSpec,
29
+ deps: ConnectDeps = {},
30
+ ): Promise<ConnectOutput> {
31
+ try {
32
+ const prime = await primeSchemaWithLock(spec.org, deps.primeDeps, {
33
+ forceRefresh: spec.forceRefresh,
34
+ });
35
+ return {
36
+ org: spec.org,
37
+ instanceUrl: prime.instanceUrl,
38
+ refreshed: prime.refreshed,
39
+ cached: prime.cached,
40
+ durationMs: prime.durationMs,
41
+ };
42
+ } catch (e) {
43
+ if (e instanceof SchemaRefreshError && e.staleSince) {
44
+ return {
45
+ org: spec.org,
46
+ instanceUrl: e.instanceUrl,
47
+ refreshed: false,
48
+ cached: true,
49
+ durationMs: 0,
50
+ warnings: [e.message],
51
+ };
52
+ }
53
+ throw e;
54
+ }
55
+ }
@@ -164,6 +164,31 @@ export interface DeleteSpec {
164
164
  operationName?: string;
165
165
  }
166
166
 
167
+ export interface ConnectSpec {
168
+ org: string;
169
+ /**
170
+ * Re-download the schema even if it is already cached, coherently clearing
171
+ * all three caches (on-disk introspection JSON, in-memory parsed schema, and
172
+ * ObjectInfo). Defaults to false — a plain connect lazily primes on miss.
173
+ */
174
+ forceRefresh?: boolean;
175
+ }
176
+
177
+ export interface ConnectOutput {
178
+ org: string;
179
+ instanceUrl: string;
180
+ /** True when this call performed a forced re-download. */
181
+ refreshed: boolean;
182
+ /** True when an existing cache satisfied the request (no download performed). */
183
+ cached: boolean;
184
+ durationMs: number;
185
+ /**
186
+ * Populated when a refresh failed but a usable cached schema survived — the
187
+ * staleness-aware message tells the caller the cache is still valid.
188
+ */
189
+ warnings?: string[];
190
+ }
191
+
167
192
  export type RawOperation = "query" | "mutation" | "aggregate";
168
193
 
169
194
  export interface RawSpec {
@@ -376,6 +376,40 @@ describe("introspect", () => {
376
376
  }
377
377
  });
378
378
 
379
+ it("bounds the introspection with a timeout and opts the POST into one retry (W-22845606)", async () => {
380
+ const core = (await import("@salesforce/core")) as unknown as {
381
+ __request: ReturnType<typeof vi.fn>;
382
+ };
383
+ core.__request.mockClear();
384
+ core.__request.mockResolvedValueOnce({ data: { __schema: { types: [] } } });
385
+
386
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-dl-opts-"));
387
+ const prevHome = process.env.GRAPHITI_HOME;
388
+ process.env.GRAPHITI_HOME = tmpRoot;
389
+ try {
390
+ await downloadSchema(auth);
391
+ // connection.request(requestInfo, options) — the 2nd arg bounds the
392
+ // request (jsforce defaults to 30 min) and opts POST into retry
393
+ // (jsforce skips POST by default).
394
+ const options = core.__request.mock.calls[0][1] as {
395
+ timeout?: number;
396
+ retry?: { methods?: string[]; maxRetries?: number; statusCodes?: number[] };
397
+ };
398
+ expect(typeof options?.timeout).toBe("number");
399
+ expect(options.timeout as number).toBeGreaterThan(0);
400
+ expect(options?.retry?.methods).toContain("POST");
401
+ // Pin the transient-failure contract (W-22845606): exactly one retry on
402
+ // the listed 5xx/429/420 codes — jsforce's defaults (maxRetries 5, POST
403
+ // not retried) would otherwise apply, and value drift here is invisible
404
+ // to tsc/eslint since it's a plain object literal.
405
+ expect(options?.retry?.maxRetries).toBe(1);
406
+ expect(options?.retry?.statusCodes).toEqual([420, 429, 500, 502, 503, 504]);
407
+ } finally {
408
+ process.env.GRAPHITI_HOME = prevHome;
409
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
410
+ }
411
+ });
412
+
379
413
  it("throws when the response contains GraphQL errors", async () => {
380
414
  const core = (await import("@salesforce/core")) as unknown as {
381
415
  __request: ReturnType<typeof vi.fn>;