@salesforce/graphiti 10.10.3 → 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 +6 -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
@@ -7,7 +7,9 @@
7
7
  import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
+ import { buildSchema } from "graphql";
10
11
  import { describe, expect, it } from "vitest";
12
+ import { makeObjectInfo } from "../../__tests__/helpers/object-info.js";
11
13
 
12
14
  describe("lib/prime-schema", () => {
13
15
  it("atomic-write: temp file is renamed to final path, no .tmp left behind", async () => {
@@ -27,6 +29,23 @@ describe("lib/prime-schema", () => {
27
29
  fs.rmSync(tmpRoot, { recursive: true, force: true });
28
30
  });
29
31
 
32
+ it("atomic-write: atomicWriteText renames into place with no .tmp left behind", async () => {
33
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-prime-text-"));
34
+ const finalPath = path.join(tmpRoot, "schema.graphql");
35
+
36
+ const { atomicWriteText } = await import("../fs-utils.js");
37
+
38
+ atomicWriteText(finalPath, "type Query { a: Int }");
39
+
40
+ expect(fs.existsSync(finalPath)).toBe(true);
41
+ expect(fs.readFileSync(finalPath, "utf-8")).toBe("type Query { a: Int }");
42
+
43
+ const leftovers = fs.readdirSync(tmpRoot).filter((f) => f.includes(".tmp"));
44
+ expect(leftovers).toEqual([]);
45
+
46
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
47
+ });
48
+
30
49
  it("atomic-write: parallel calls in the same process do not collide", async () => {
31
50
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-prime-parallel-"));
32
51
  const finalPath = path.join(tmpRoot, "p.json");
@@ -231,6 +250,328 @@ describe("lib/prime-schema", () => {
231
250
  }
232
251
  });
233
252
 
253
+ // ── forceRefresh (W-22845606) ──────────────────────────────────────────────
254
+
255
+ const INSTANCE_URL = "https://example.my.salesforce.com";
256
+
257
+ async function schemaFilePath(tmpRoot: string): Promise<string> {
258
+ const { schemaCacheKeyForInstanceUrl } = await import("../introspect.js");
259
+ return path.join(tmpRoot, "schemas", `${schemaCacheKeyForInstanceUrl(INSTANCE_URL)}.json`);
260
+ }
261
+
262
+ async function writeSchemaFile(tmpRoot: string): Promise<string> {
263
+ const { atomicWriteJson } = await import("../fs-utils.js");
264
+ const fp = await schemaFilePath(tmpRoot);
265
+ atomicWriteJson(fp, {
266
+ data: { __schema: { types: [] } },
267
+ __graphiti: {
268
+ instanceUrl: INSTANCE_URL,
269
+ alias: "test-org",
270
+ cachedAt: new Date().toISOString(),
271
+ },
272
+ });
273
+ return fp;
274
+ }
275
+
276
+ // Build stub PrimeDeps whose downloadSchema increments a counter, optionally
277
+ // throws (per the `throwOn` map), and otherwise writes the cache file.
278
+ function makeStubDeps(
279
+ tmpRoot: string,
280
+ opts: { throwOn?: Record<number, unknown>; delayMs?: number } = {},
281
+ ) {
282
+ let calls = 0;
283
+ const deps = {
284
+ getOrgAuth: async () => ({
285
+ alias: "test-org",
286
+ instanceUrl: INSTANCE_URL,
287
+ accessToken: "fake-token",
288
+ username: "user@example.com",
289
+ orgId: "00Dxx0000000000",
290
+ }),
291
+ downloadSchema: async () => {
292
+ calls++;
293
+ const toThrow = opts.throwOn?.[calls];
294
+ if (toThrow) throw toThrow;
295
+ if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));
296
+ await writeSchemaFile(tmpRoot);
297
+ const { schemaCacheKeyForInstanceUrl } = await import("../introspect.js");
298
+ return {
299
+ cacheKey: schemaCacheKeyForInstanceUrl(INSTANCE_URL),
300
+ instanceUrl: INSTANCE_URL,
301
+ typeCount: 0,
302
+ downloadedAt: new Date().toISOString(),
303
+ filePath: await schemaFilePath(tmpRoot),
304
+ };
305
+ },
306
+ };
307
+ return { deps, calls: () => calls };
308
+ }
309
+
310
+ it("withSchemaLock: honors a custom skipIf predicate", async () => {
311
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-skipif-"));
312
+ const finalPath = path.join(tmpRoot, "s.json");
313
+ try {
314
+ const { withSchemaLock } = await import("../prime-schema.js");
315
+
316
+ let ran = false;
317
+ const skipResult = await withSchemaLock(finalPath, async () => (ran = true), {
318
+ skipIf: () => true,
319
+ });
320
+ expect(ran).toBe(false);
321
+ expect(skipResult).toBeUndefined();
322
+
323
+ await withSchemaLock(finalPath, async () => (ran = true), { skipIf: () => false });
324
+ expect(ran).toBe(true);
325
+ } finally {
326
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
327
+ }
328
+ });
329
+
330
+ it("primeSchemaWithLock: forceRefresh re-downloads even though the file exists", async () => {
331
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-"));
332
+ process.env.GRAPHITI_HOME = tmpRoot;
333
+ try {
334
+ const { primeSchemaWithLock } = await import("../prime-schema.js");
335
+ const { deps, calls } = makeStubDeps(tmpRoot);
336
+
337
+ const first = await primeSchemaWithLock("test-org", deps);
338
+ expect(first.cached).toBe(false);
339
+ expect(first.refreshed).toBe(false);
340
+ expect(calls()).toBe(1);
341
+
342
+ // Without forceRefresh, a second call is a pure cache hit.
343
+ const hit = await primeSchemaWithLock("test-org", deps);
344
+ expect(hit.cached).toBe(true);
345
+ expect(hit.refreshed).toBe(false);
346
+ expect(calls()).toBe(1);
347
+
348
+ // With forceRefresh, it re-downloads despite the existing file.
349
+ const refreshed = await primeSchemaWithLock("test-org", deps, { forceRefresh: true });
350
+ expect(refreshed.refreshed).toBe(true);
351
+ expect(refreshed.cached).toBe(false);
352
+ expect(calls()).toBe(2);
353
+ } finally {
354
+ delete process.env.GRAPHITI_HOME;
355
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
356
+ }
357
+ });
358
+
359
+ it("primeSchemaWithLock: forceRefresh evicts the in-memory parsed-schema cache (cache #2)", async () => {
360
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-schemacache-"));
361
+ process.env.GRAPHITI_HOME = tmpRoot;
362
+ try {
363
+ const { primeSchemaWithLock } = await import("../prime-schema.js");
364
+ const { primeSchemaCache, getSchema } = await import("../walker.js");
365
+
366
+ // Seed the in-memory parsed-schema cache under the instance URL key.
367
+ const sentinel = buildSchema("type Query { sentinel: Int }");
368
+ primeSchemaCache(INSTANCE_URL, sentinel);
369
+ expect(getSchema(INSTANCE_URL)).toBe(sentinel);
370
+
371
+ // A forced refresh must route clearSchemaCacheByUrl with the resolved
372
+ // instance URL, evicting the sentinel. After eviction, getSchema misses
373
+ // the cache and rebuilds from the freshly-written introspection on disk
374
+ // — so it returns a NEW schema object, never the seeded sentinel.
375
+ await primeSchemaWithLock("test-org", makeStubDeps(tmpRoot).deps, { forceRefresh: true });
376
+ expect(getSchema(INSTANCE_URL)).not.toBe(sentinel);
377
+ } finally {
378
+ delete process.env.GRAPHITI_HOME;
379
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
380
+ }
381
+ });
382
+
383
+ it("primeSchemaWithLock: forceRefresh clears the ObjectInfo cache on success", async () => {
384
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-oi-"));
385
+ process.env.GRAPHITI_HOME = tmpRoot;
386
+ try {
387
+ const { primeSchemaWithLock } = await import("../prime-schema.js");
388
+ const { setCachedObjectInfo, getCachedObjectInfo } = await import("../object-info.js");
389
+ const { deps } = makeStubDeps(tmpRoot);
390
+
391
+ await primeSchemaWithLock("test-org", deps); // prime first
392
+ setCachedObjectInfo("test-org", "Account", makeObjectInfo());
393
+ expect(getCachedObjectInfo("test-org", "Account")).not.toBeNull();
394
+
395
+ await primeSchemaWithLock("test-org", deps, { forceRefresh: true });
396
+ expect(getCachedObjectInfo("test-org", "Account")).toBeNull();
397
+ } finally {
398
+ delete process.env.GRAPHITI_HOME;
399
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
400
+ }
401
+ });
402
+
403
+ it("primeSchemaWithLock: concurrent forceRefresh coalesces into a single download", async () => {
404
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-coalesce-"));
405
+ process.env.GRAPHITI_HOME = tmpRoot;
406
+ try {
407
+ const { primeSchemaWithLock } = await import("../prime-schema.js");
408
+
409
+ // Pre-existing cache, backdated so the new download's mtime is strictly newer.
410
+ const fp = await writeSchemaFile(tmpRoot);
411
+ const past = new Date(Date.now() - 5_000);
412
+ fs.utimesSync(fp, past, past);
413
+
414
+ const { deps, calls } = makeStubDeps(tmpRoot, { delayMs: 200 });
415
+
416
+ const results = await Promise.all([
417
+ primeSchemaWithLock("test-org", deps, { forceRefresh: true }),
418
+ primeSchemaWithLock("test-org", deps, { forceRefresh: true }),
419
+ ]);
420
+
421
+ // This test owns the *single-introspection* guarantee. That the coalesced
422
+ // (loser) caller still clears ITS OWN stale caches is proven separately by
423
+ // the R4 test below — here both callers share one alias, so a cache
424
+ // assertion couldn't distinguish the loser's clear from the winner's.
425
+ expect(calls()).toBe(1); // single introspection
426
+ const refreshedCount = results.filter((r) => r.refreshed).length;
427
+ expect(refreshedCount).toBe(1); // exactly one performed the refresh
428
+ } finally {
429
+ delete process.env.GRAPHITI_HOME;
430
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
431
+ }
432
+ });
433
+
434
+ it("primeSchemaWithLock: sequential forceRefresh each re-download", async () => {
435
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-seq-"));
436
+ process.env.GRAPHITI_HOME = tmpRoot;
437
+ try {
438
+ const { primeSchemaWithLock } = await import("../prime-schema.js");
439
+ const { deps, calls } = makeStubDeps(tmpRoot);
440
+
441
+ await primeSchemaWithLock("test-org", deps); // 1
442
+ await primeSchemaWithLock("test-org", deps, { forceRefresh: true }); // 2
443
+ await primeSchemaWithLock("test-org", deps, { forceRefresh: true }); // 3
444
+ expect(calls()).toBe(3);
445
+ } finally {
446
+ delete process.env.GRAPHITI_HOME;
447
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
448
+ }
449
+ });
450
+
451
+ it("primeSchemaWithLock: a failed forceRefresh keeps the old caches and throws SchemaRefreshError", async () => {
452
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-fail-"));
453
+ process.env.GRAPHITI_HOME = tmpRoot;
454
+ try {
455
+ const { primeSchemaWithLock, SchemaRefreshError } = await import("../prime-schema.js");
456
+
457
+ const fp = await writeSchemaFile(tmpRoot);
458
+ const originalContent = fs.readFileSync(fp, "utf-8");
459
+ const past = new Date(Date.now() - 5_000);
460
+ fs.utimesSync(fp, past, past);
461
+
462
+ // downloadSchema throws after the connection layer exhausts its
463
+ // timeout/retry. A forced refresh must keep every cache intact and
464
+ // surface a staleness-aware SchemaRefreshError.
465
+ const { deps, calls } = makeStubDeps(tmpRoot, {
466
+ throwOn: { 1: new Error("introspection failed") },
467
+ });
468
+
469
+ const err = await primeSchemaWithLock("test-org", deps, { forceRefresh: true }).catch(
470
+ (e) => e,
471
+ );
472
+ expect(err).toBeInstanceOf(SchemaRefreshError);
473
+ expect(err.staleSince).toBeTruthy(); // surviving cache age anchor
474
+ expect(err.instanceUrl).toBe(INSTANCE_URL);
475
+ expect(calls()).toBe(1); // primeSchemaWithLock calls downloadSchema once
476
+ expect(fs.readFileSync(fp, "utf-8")).toBe(originalContent); // old cache intact
477
+ } finally {
478
+ delete process.env.GRAPHITI_HOME;
479
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
480
+ }
481
+ });
482
+
483
+ it("primeSchemaWithLock: a failed refresh with NO prior cache has no staleSince", async () => {
484
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-nocache-"));
485
+ process.env.GRAPHITI_HOME = tmpRoot;
486
+ try {
487
+ const { primeSchemaWithLock, SchemaRefreshError } = await import("../prime-schema.js");
488
+
489
+ const { deps } = makeStubDeps(tmpRoot, {
490
+ throwOn: { 1: new Error("introspection failed") },
491
+ });
492
+
493
+ const err = await primeSchemaWithLock("test-org", deps, { forceRefresh: true }).catch(
494
+ (e) => e,
495
+ );
496
+ expect(err).toBeInstanceOf(SchemaRefreshError);
497
+ expect(err.staleSince).toBeUndefined();
498
+ } finally {
499
+ delete process.env.GRAPHITI_HOME;
500
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
501
+ }
502
+ });
503
+
504
+ it("primeSchemaWithLock: a coalesced refresh clears its OWN stale caches (R4, isolated from the winner)", async () => {
505
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-refresh-r4-"));
506
+ process.env.GRAPHITI_HOME = tmpRoot;
507
+ try {
508
+ const { primeSchemaWithLock } = await import("../prime-schema.js");
509
+ const { setCachedObjectInfo, getCachedObjectInfo } = await import("../object-info.js");
510
+
511
+ // Pre-existing cache, backdated so the winner's download is strictly newer.
512
+ const fp = await writeSchemaFile(tmpRoot);
513
+ const past = new Date(Date.now() - 5_000);
514
+ fs.utimesSync(fp, past, past);
515
+
516
+ // Two distinct aliases for the SAME org (instanceUrl). Seed ObjectInfo
517
+ // ONLY under the loser's alias: the winner's clearObjectInfoCache(winner)
518
+ // can never touch "loser:Account", so if it ends up null it was the
519
+ // loser's post-lock R4 block that cleared it — not the winner's success path.
520
+ setCachedObjectInfo("loser", "Account", makeObjectInfo());
521
+
522
+ let winnerDownloading = false;
523
+ const winnerDeps = {
524
+ getOrgAuth: async () => ({
525
+ alias: "winner",
526
+ instanceUrl: INSTANCE_URL,
527
+ accessToken: "t",
528
+ username: "u@e.com",
529
+ orgId: "00D",
530
+ }),
531
+ downloadSchema: async () => {
532
+ winnerDownloading = true;
533
+ await new Promise((r) => setTimeout(r, 200));
534
+ await writeSchemaFile(tmpRoot);
535
+ const { schemaCacheKeyForInstanceUrl } = await import("../introspect.js");
536
+ return {
537
+ cacheKey: schemaCacheKeyForInstanceUrl(INSTANCE_URL),
538
+ instanceUrl: INSTANCE_URL,
539
+ typeCount: 0,
540
+ downloadedAt: new Date().toISOString(),
541
+ filePath: fp,
542
+ };
543
+ },
544
+ };
545
+ let loserDownloads = 0;
546
+ const loserDeps = {
547
+ getOrgAuth: async () => ({
548
+ alias: "loser",
549
+ instanceUrl: INSTANCE_URL,
550
+ accessToken: "t",
551
+ username: "u@e.com",
552
+ orgId: "00D",
553
+ }),
554
+ downloadSchema: async () => {
555
+ loserDownloads++;
556
+ throw new Error("loser must coalesce, not download");
557
+ },
558
+ };
559
+
560
+ const winner = primeSchemaWithLock("winner", winnerDeps, { forceRefresh: true });
561
+ while (!winnerDownloading) await new Promise((r) => setTimeout(r, 5));
562
+ const loser = primeSchemaWithLock("loser", loserDeps, { forceRefresh: true });
563
+
564
+ const [, loserResult] = await Promise.all([winner, loser]);
565
+ expect(loserResult.refreshed).toBe(false); // coalesced
566
+ expect(loserResult.cached).toBe(true);
567
+ expect(loserDownloads).toBe(0); // never downloaded
568
+ expect(getCachedObjectInfo("loser", "Account")).toBeNull(); // cleared by R4, not the winner
569
+ } finally {
570
+ delete process.env.GRAPHITI_HOME;
571
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
572
+ }
573
+ });
574
+
234
575
  it("primeSchemaWithLock: awaits an async getOrgAuth and returns its instanceUrl", async () => {
235
576
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-prime-async-"));
236
577
  process.env.GRAPHITI_HOME = tmpRoot;
@@ -22,6 +22,7 @@ import { createSession } from "../session.js";
22
22
  import { validateQuery } from "../validator.js";
23
23
  import {
24
24
  clearSchemaCache,
25
+ clearSchemaCacheByUrl,
25
26
  filterDataCloudFields,
26
27
  type FieldInfo,
27
28
  getSchema,
@@ -70,6 +71,18 @@ describe("walker", () => {
70
71
  expect(() => selectLeafInSession(session2, "id")).not.toThrow();
71
72
  });
72
73
 
74
+ it("clearSchemaCacheByUrl drops the URL-keyed entry", () => {
75
+ const instanceUrl = "https://refresh-test.my.salesforce.com";
76
+ primeSchemaCache(instanceUrl, TEST_SCHEMA);
77
+ // Cached: getSchema by URL returns the primed schema without touching disk.
78
+ expect(getSchema(instanceUrl)).toBe(TEST_SCHEMA);
79
+
80
+ clearSchemaCacheByUrl(instanceUrl);
81
+ // Evicted: the next read misses the in-memory cache and falls back to
82
+ // disk, which fails because no introspection file exists for this URL.
83
+ expect(() => getSchema(instanceUrl)).toThrow();
84
+ });
85
+
73
86
  it("getSchema returns a primed schema by instance URL", () => {
74
87
  const url = "https://example.my.salesforce.com";
75
88
  clearSchemaCache();
@@ -23,16 +23,16 @@ export function graphitiHome(): string {
23
23
  }
24
24
 
25
25
  /**
26
- * Write JSON to `finalPath` atomically. Writes to a sibling
26
+ * Write `text` to `finalPath` atomically. Writes to a sibling
27
27
  * `<base>.tmp.<pid>.<timestamp>.<rand>` file, then renames over the final
28
28
  * path. Concurrent readers see either the old contents or the fully-written
29
29
  * new contents — never a partial. The temp file is unlinked if the rename
30
30
  * fails, so failure paths do not leak partial files.
31
31
  */
32
- export function atomicWriteJson(finalPath: string, payload: unknown): void {
32
+ export function atomicWriteText(finalPath: string, text: string): void {
33
33
  fs.mkdirSync(path.dirname(finalPath), { recursive: true });
34
34
  const tmp = `${finalPath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString("hex")}`;
35
- fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8");
35
+ fs.writeFileSync(tmp, text, "utf-8");
36
36
  try {
37
37
  fs.renameSync(tmp, finalPath);
38
38
  } catch (e) {
@@ -44,3 +44,8 @@ export function atomicWriteJson(finalPath: string, payload: unknown): void {
44
44
  throw e;
45
45
  }
46
46
  }
47
+
48
+ /** Write JSON to `finalPath` atomically (pretty-printed). See {@link atomicWriteText}. */
49
+ export function atomicWriteJson(finalPath: string, payload: unknown): void {
50
+ atomicWriteText(finalPath, JSON.stringify(payload, null, 2));
51
+ }
@@ -24,6 +24,26 @@ async function getConnection(auth: OrgAuth): Promise<Connection> {
24
24
  return org.getConnection();
25
25
  }
26
26
 
27
+ // Per-request options for the introspection POST (W-22845606). `@salesforce/core`
28
+ // passes these straight through to jsforce:
29
+ // - `timeout` bounds EACH attempt (jsforce defaults to 30 min) so a wedged gateway
30
+ // can't hang the schema-lock holder indefinitely. A single attempt (5 min) aborts
31
+ // before prime-schema's 7-min STALE_LOCK_MS, so the holder normally releases the
32
+ // lock before any waiter reclaims it. A timeout *plus* the retry below can run
33
+ // ~2x the timeout and exceed STALE_LOCK_MS — a waiter may then reclaim mid-retry
34
+ // and run a second introspection: benign, it degrades to the redundant-download
35
+ // case coalescing already tolerates (atomic writes keep the cache correct). A
36
+ // strict bound would require timeout × (maxRetries + 1) < STALE_LOCK_MS.
37
+ // - jsforce does NOT retry POST by default, so we opt this idempotent introspection
38
+ // read into one retry on transient HTTP/network failures (with jsforce's built-in
39
+ // exponential backoff). Network errnos (ECONNRESET/ETIMEDOUT/…) are covered by
40
+ // jsforce's default `errorCodes`.
41
+ const INTROSPECTION_TIMEOUT_MS = 5 * 60_000;
42
+ const INTROSPECTION_REQUEST_OPTIONS = {
43
+ timeout: INTROSPECTION_TIMEOUT_MS,
44
+ retry: { methods: ["POST"], maxRetries: 1, statusCodes: [420, 429, 500, 502, 503, 504] },
45
+ };
46
+
27
47
  /** Resolves the directory holding cached introspection JSON files. */
28
48
  export function schemaDir(): string {
29
49
  return path.join(graphitiHome(), "schemas");
@@ -243,12 +263,15 @@ export async function downloadSchema(auth: OrgAuth): Promise<SchemaMetadata> {
243
263
  const connection = await getConnection(auth);
244
264
  const url = `${auth.instanceUrl}/services/data/v${connection.getApiVersion()}/graphql`;
245
265
 
246
- const rawResult = (await connection.request({
247
- method: "POST",
248
- url,
249
- body: JSON.stringify({ query: INTROSPECTION_QUERY }),
250
- headers: { "Content-Type": "application/json", "X-Chatter-Entity-Encoding": "false" },
251
- })) as any;
266
+ const rawResult = (await connection.request(
267
+ {
268
+ method: "POST",
269
+ url,
270
+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
271
+ headers: { "Content-Type": "application/json", "X-Chatter-Entity-Encoding": "false" },
272
+ },
273
+ INTROSPECTION_REQUEST_OPTIONS,
274
+ )) as any;
252
275
 
253
276
  if (rawResult?.errors?.length) {
254
277
  const messages = (rawResult.errors as any[])