@salesforce/graphiti 11.57.4 → 11.59.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/AGENT_GUIDE.md CHANGED
@@ -40,6 +40,18 @@ Notes:
40
40
  - Exception: a **401/403 auth failure** during introspection (expired/unauthorized session) errors with an `Auth:` prefix instead of the soft staleness warning — a dead session makes even the cached schema unusable, so re-authenticate (`sf org login web --alias <org>`) rather than retrying.
41
41
  - ObjectInfo is keyed by org alias: refreshing alias `A` does not invalidate ObjectInfo cached under a different alias `B` for the same org (its 1-hour TTL heals it).
42
42
 
43
+ ## Sharing the schema cache from code
44
+
45
+ Build tooling (codegen, IDE integrations) should not run its own introspection — import graphiti and reuse the shared cache:
46
+
47
+ ```ts
48
+ import { downloadSchemaSdl } from "@salesforce/graphiti";
49
+
50
+ await downloadSchemaSdl({ org: "<org>", outPath: "schema.graphql", maxAgeMs: 10 * 60_000 });
51
+ ```
52
+
53
+ This primes the same instance-URL-keyed cache `connect` uses, writes canonical SDL to `outPath`, and (via `maxAgeMs`) re-introspects only when the cached schema is older than the gate — otherwise it serves the existing cache ("wait once"). See the README's "Programmatic API" section for the full option/result reference.
54
+
43
55
  ## Session Resolution
44
56
 
45
57
  All session commands target the **active session** implicitly. Resolution order:
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [11.59.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.58.0...v11.59.0) (2026-08-14)
7
+
8
+ **Note:** Version bump only for package @salesforce/graphiti
9
+
10
+ ## [11.58.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.57.4...v11.58.0) (2026-08-14)
11
+
12
+ ### Features
13
+
14
+ - **graphiti:** expose downloadSchemaSdl library API for shared schema cache @W-23545346@ ([#856](https://github.com/salesforce-experience-platform-emu/webapps/issues/856)) ([8a29abe](https://github.com/salesforce-experience-platform-emu/webapps/commit/8a29abe0e3925020b816a6bcd2352d5b3d7236d9))
15
+
6
16
  ## [11.57.4](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.57.3...v11.57.4) (2026-08-14)
7
17
 
8
18
  **Note:** Version bump only for package @salesforce/graphiti
package/README.md CHANGED
@@ -207,6 +207,34 @@ graphiti caches three things per org: the on-disk introspection JSON, the in-mem
207
207
 
208
208
  > **ObjectInfo is keyed by org alias, not instance URL.** Refreshing alias `A` does not invalidate ObjectInfo cached under a different alias `B` that points at the same org; refresh each alias you actively use, or let ObjectInfo's 1-hour TTL heal it.
209
209
 
210
+ ## Programmatic API (library)
211
+
212
+ graphiti is primarily a CLI, but it also exposes a small, stable programmatic surface so other tools can **share the same schema cache** instead of running their own introspection. The canonical use is a codegen or IDE pipeline that needs an org's GraphQL SDL on disk:
213
+
214
+ ```ts
215
+ import { downloadSchemaSdl } from "@salesforce/graphiti";
216
+
217
+ // Prime the shared cache (if needed) and write canonical SDL for codegen.
218
+ await downloadSchemaSdl({
219
+ org: process.env.SF_TARGET_ORG, // alias or username
220
+ outPath: "schema.graphql",
221
+ maxAgeMs: 10 * 60_000, // re-introspect if the cached schema is older than 10 min
222
+ });
223
+ ```
224
+
225
+ `downloadSchemaSdl(options)` primes the org's schema through the same instance-URL-keyed cache the CLI and MCP server use, then serializes it with `printSchema`:
226
+
227
+ | Option | Description |
228
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------- |
229
+ | `org` | Org alias or username (the value you'd pass to `--target-org`). |
230
+ | `outPath` | Where to atomically write the SDL (parent dirs are created). Omit to only receive the SDL in the result. |
231
+ | `forceRefresh` | Re-download even if cached, clearing all of graphiti's caches coherently (same as `connect --refresh`). |
232
+ | `maxAgeMs` | Age gate: force a refresh when the cached schema is older than this. A fresh cache — or an uncached org — is left to lazy prime. |
233
+
234
+ Because the **schema cache has no TTL** — a plain `connect` serves any existing schema indefinitely (only the separate ObjectInfo cache expires, on its own 1-hour TTL) — callers that must not run against stale metadata, such as codegen or IDE tooling, should pass a small `maxAgeMs`. Whoever asks first (CLI, MCP, or this call) pays the one-time introspection; everyone else reads the same cache, so you still "wait once."
235
+
236
+ The result reports what happened — `sdl`, `instanceUrl`, `cacheFilePath`, `outPath`, `downloaded`, `refreshed`, and `refreshedDueToAge`. `downloadSchemaSdl` is the only supported export today; deep `@salesforce/graphiti/dist/...` imports are internal and may change without a major version bump.
237
+
210
238
  ## Development
211
239
 
212
240
  ```bash
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+ /**
7
+ * Public library entry point for `@salesforce/graphiti`.
8
+ *
9
+ * graphiti is primarily a CLI. This module exposes just the one function build
10
+ * tooling needs — {@link downloadSchemaSdl} — so a codegen/IDE pipeline can
11
+ * obtain an org's GraphQL SDL from graphiti's single instance-URL-keyed schema
12
+ * cache (`~/.graphiti/schemas/`) instead of running its own introspection.
13
+ *
14
+ * Only the names re-exported here are supported; everything else (reachable via
15
+ * deep `@salesforce/graphiti/dist/...` paths) is internal and may change without
16
+ * a major version bump. The surface is kept intentionally small — more will be
17
+ * exported as concrete consumers need it, not before.
18
+ */
19
+ export { downloadSchemaSdl, type DownloadSchemaSdlOptions, type DownloadSchemaSdlResult, } from "./lib/download-schema.js";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+ /**
7
+ * Public library entry point for `@salesforce/graphiti`.
8
+ *
9
+ * graphiti is primarily a CLI. This module exposes just the one function build
10
+ * tooling needs — {@link downloadSchemaSdl} — so a codegen/IDE pipeline can
11
+ * obtain an org's GraphQL SDL from graphiti's single instance-URL-keyed schema
12
+ * cache (`~/.graphiti/schemas/`) instead of running its own introspection.
13
+ *
14
+ * Only the names re-exported here are supported; everything else (reachable via
15
+ * deep `@salesforce/graphiti/dist/...` paths) is internal and may change without
16
+ * a major version bump. The surface is kept intentionally small — more will be
17
+ * exported as concrete consumers need it, not before.
18
+ */
19
+ export { downloadSchemaSdl, } from "./lib/download-schema.js";
20
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;;;;;;;;GAYG;AAEH,OAAO,EACN,iBAAiB,GAGjB,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+ import { type PrimeDeps } from "./prime-schema.js";
7
+ export interface DownloadSchemaSdlOptions {
8
+ /** Org alias or username (the value normally passed to `--target-org`). */
9
+ org: string;
10
+ /**
11
+ * Where to write the canonical GraphQL SDL (e.g. `schema.graphql` for a
12
+ * codegen pipeline). Written atomically; parent directories are created as
13
+ * needed. Omit to skip the write and only receive the SDL in the result.
14
+ */
15
+ outPath?: string;
16
+ /**
17
+ * Re-download even if the org's schema is already cached. Routes through the
18
+ * shared refresh path so all of graphiti's caches (introspection JSON,
19
+ * in-memory parsed schema, ObjectInfo) are cleared coherently.
20
+ */
21
+ forceRefresh?: boolean;
22
+ /**
23
+ * Age gate. When the shared cache's schema is older than this many
24
+ * milliseconds, this call forces a refresh (equivalent to `forceRefresh`).
25
+ * A cache younger than the threshold — or a not-yet-primed org — is left to
26
+ * the normal lazy-prime path, so a fresh cache is still served without a
27
+ * second download ("wait once"). Undefined or `<= 0` disables the gate.
28
+ *
29
+ * graphiti's cache has no TTL of its own — a plain `connect` serves any
30
+ * existing schema indefinitely — so callers that must not run against stale
31
+ * metadata (codegen, IDE tooling) should pass a small `maxAgeMs`.
32
+ */
33
+ maxAgeMs?: number;
34
+ /**
35
+ * Injectable priming dependencies (auth + introspection download). Defaults
36
+ * to the real graphiti implementations; tests pass stubs.
37
+ */
38
+ deps?: PrimeDeps;
39
+ }
40
+ export interface DownloadSchemaSdlResult {
41
+ /** The org's canonical GraphQL SDL (`printSchema` of the primed schema). */
42
+ sdl: string;
43
+ /** Resolved Salesforce instance URL — the key the shared cache is stored under. */
44
+ instanceUrl: string;
45
+ /** Absolute path of the shared introspection JSON cache. */
46
+ cacheFilePath: string;
47
+ /** Absolute path the SDL was written to, or `undefined` when `outPath` was omitted. */
48
+ outPath?: string;
49
+ /**
50
+ * True when this call performed a network introspection (a fresh prime or a
51
+ * refresh that this process ran). A cache hit — including a refresh that
52
+ * coalesced onto a concurrent peer's download — is `false`.
53
+ */
54
+ downloaded: boolean;
55
+ /** True when a refresh was requested, whether explicitly or by the age gate. */
56
+ refreshed: boolean;
57
+ /** True when the age gate (`maxAgeMs`) is what triggered the refresh. */
58
+ refreshedDueToAge: boolean;
59
+ }
60
+ /**
61
+ * Prime the org's schema through graphiti's shared, lock-coalesced cache and
62
+ * serialize it to canonical SDL — the programmatic equivalent of
63
+ * `graphiti connect <org>` plus an SDL export.
64
+ *
65
+ * This is the sanctioned way for other tools (codegen, IDE integrations) to
66
+ * obtain an org's schema: it shares graphiti's single instance-URL-keyed cache
67
+ * (`~/.graphiti/schemas/`), so whoever asks first — the CLI, the MCP server, or
68
+ * this function — pays the one-time introspection cost, and everyone else reads
69
+ * the same cache.
70
+ *
71
+ * Priming semantics are inherited from {@link primeSchemaWithLock}: a lazy prime
72
+ * on an uncached org, a no-op on a cached one, and a coherent cache-clearing
73
+ * re-download on `forceRefresh`. The SDL is read back via {@link getSchema},
74
+ * which after a refresh rebuilds from the freshly-downloaded introspection JSON,
75
+ * so the returned SDL always matches what is on disk.
76
+ *
77
+ * @throws the underlying auth/schema error verbatim on a lazy-prime failure, or
78
+ * `SchemaRefreshError` when a forced refresh fails but a usable cache survives
79
+ * (see {@link primeSchemaWithLock}).
80
+ */
81
+ export declare function downloadSchemaSdl(opts: DownloadSchemaSdlOptions): Promise<DownloadSchemaSdlResult>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+ import path from "node:path";
7
+ import { printSchema } from "graphql";
8
+ import { getOrgAuth } from "./auth.js";
9
+ import { atomicWriteText } from "./fs-utils.js";
10
+ import { getSchemaMetadata } from "./introspect.js";
11
+ import { primeSchemaWithLock } from "./prime-schema.js";
12
+ import { getSchema } from "./walker.js";
13
+ /**
14
+ * Prime the org's schema through graphiti's shared, lock-coalesced cache and
15
+ * serialize it to canonical SDL — the programmatic equivalent of
16
+ * `graphiti connect <org>` plus an SDL export.
17
+ *
18
+ * This is the sanctioned way for other tools (codegen, IDE integrations) to
19
+ * obtain an org's schema: it shares graphiti's single instance-URL-keyed cache
20
+ * (`~/.graphiti/schemas/`), so whoever asks first — the CLI, the MCP server, or
21
+ * this function — pays the one-time introspection cost, and everyone else reads
22
+ * the same cache.
23
+ *
24
+ * Priming semantics are inherited from {@link primeSchemaWithLock}: a lazy prime
25
+ * on an uncached org, a no-op on a cached one, and a coherent cache-clearing
26
+ * re-download on `forceRefresh`. The SDL is read back via {@link getSchema},
27
+ * which after a refresh rebuilds from the freshly-downloaded introspection JSON,
28
+ * so the returned SDL always matches what is on disk.
29
+ *
30
+ * @throws the underlying auth/schema error verbatim on a lazy-prime failure, or
31
+ * `SchemaRefreshError` when a forced refresh fails but a usable cache survives
32
+ * (see {@link primeSchemaWithLock}).
33
+ */
34
+ export async function downloadSchemaSdl(opts) {
35
+ const { org, deps } = opts;
36
+ const maxAgeMs = opts.maxAgeMs;
37
+ let forceRefresh = !!opts.forceRefresh;
38
+ let refreshedDueToAge = false;
39
+ // Age gate: only when a max age is set and we are not already refreshing.
40
+ // Resolving auth here is the only way to learn the instance URL the cache is
41
+ // keyed by; auth is memoized per alias, so the subsequent resolution inside
42
+ // primeSchemaWithLock is free (and in tests the injected stub is trivial).
43
+ if (!forceRefresh && maxAgeMs !== undefined && maxAgeMs > 0) {
44
+ const getAuth = deps?.getOrgAuth ?? getOrgAuth;
45
+ const auth = await getAuth(org);
46
+ const meta = getSchemaMetadata(auth.instanceUrl);
47
+ if (meta) {
48
+ // A NaN age (unparseable timestamp) compares false — we treat an
49
+ // unreadable cache as "not stale" and let the cache-hit path serve it;
50
+ // getSchema self-heals from the JSON if the SDL side-file is bad.
51
+ const ageMs = Date.now() - new Date(meta.downloadedAt).getTime();
52
+ if (ageMs > maxAgeMs) {
53
+ forceRefresh = true;
54
+ refreshedDueToAge = true;
55
+ }
56
+ }
57
+ }
58
+ const prime = await primeSchemaWithLock(org, deps, { forceRefresh });
59
+ const { instanceUrl } = prime;
60
+ // getSchema rebuilds from the on-disk introspection JSON after a refresh
61
+ // (the refresh evicted the in-memory + SDL caches), so this is always the
62
+ // schema that was just primed — never a stale copy.
63
+ const sdl = printSchema(getSchema(instanceUrl));
64
+ let resolvedOut;
65
+ if (opts.outPath) {
66
+ resolvedOut = path.resolve(opts.outPath);
67
+ atomicWriteText(resolvedOut, sdl);
68
+ }
69
+ return {
70
+ sdl,
71
+ instanceUrl,
72
+ cacheFilePath: prime.filePath,
73
+ outPath: resolvedOut,
74
+ downloaded: !prime.cached,
75
+ refreshed: forceRefresh,
76
+ refreshedDueToAge,
77
+ };
78
+ }
79
+ //# sourceMappingURL=download-schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-schema.js","sourceRoot":"","sources":["../../src/lib/download-schema.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAkB,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAyDxC;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACtC,IAA8B;IAE9B,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;IAE/B,IAAI,YAAY,GAAG,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC;IACvC,IAAI,iBAAiB,GAAG,KAAK,CAAC;IAE9B,0EAA0E;IAC1E,6EAA6E;IAC7E,4EAA4E;IAC5E,2EAA2E;IAC3E,IAAI,CAAC,YAAY,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;QAC7D,MAAM,OAAO,GAAG,IAAI,EAAE,UAAU,IAAI,UAAU,CAAC;QAC/C,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;QAChC,MAAM,IAAI,GAAG,iBAAiB,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QACjD,IAAI,IAAI,EAAE,CAAC;YACV,iEAAiE;YACjE,uEAAuE;YACvE,kEAAkE;YAClE,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,OAAO,EAAE,CAAC;YACjE,IAAI,KAAK,GAAG,QAAQ,EAAE,CAAC;gBACtB,YAAY,GAAG,IAAI,CAAC;gBACpB,iBAAiB,GAAG,IAAI,CAAC;YAC1B,CAAC;QACF,CAAC;IACF,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,mBAAmB,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,YAAY,EAAE,CAAC,CAAC;IACrE,MAAM,EAAE,WAAW,EAAE,GAAG,KAAK,CAAC;IAE9B,yEAAyE;IACzE,0EAA0E;IAC1E,oDAAoD;IACpD,MAAM,GAAG,GAAG,WAAW,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC,CAAC;IAEhD,IAAI,WAA+B,CAAC;IACpC,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QAClB,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACzC,eAAe,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;IACnC,CAAC;IAED,OAAO;QACN,GAAG;QACH,WAAW;QACX,aAAa,EAAE,KAAK,CAAC,QAAQ;QAC7B,OAAO,EAAE,WAAW;QACpB,UAAU,EAAE,CAAC,KAAK,CAAC,MAAM;QACzB,SAAS,EAAE,YAAY;QACvB,iBAAiB;KACjB,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "@salesforce/graphiti",
3
- "version": "11.57.4",
3
+ "version": "11.59.0",
4
4
  "description": "Progressive GraphQL query builder CLI for Salesforce orgs",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
7
17
  "bin": {
8
18
  "graphiti": "./dist/cli.js",
9
19
  "graphiti-mcp": "./dist/mcp/stdio.js"
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
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
+ * Public library entry point for `@salesforce/graphiti`.
9
+ *
10
+ * graphiti is primarily a CLI. This module exposes just the one function build
11
+ * tooling needs — {@link downloadSchemaSdl} — so a codegen/IDE pipeline can
12
+ * obtain an org's GraphQL SDL from graphiti's single instance-URL-keyed schema
13
+ * cache (`~/.graphiti/schemas/`) instead of running its own introspection.
14
+ *
15
+ * Only the names re-exported here are supported; everything else (reachable via
16
+ * deep `@salesforce/graphiti/dist/...` paths) is internal and may change without
17
+ * a major version bump. The surface is kept intentionally small — more will be
18
+ * exported as concrete consumers need it, not before.
19
+ */
20
+
21
+ export {
22
+ downloadSchemaSdl,
23
+ type DownloadSchemaSdlOptions,
24
+ type DownloadSchemaSdlResult,
25
+ } from "./lib/download-schema.js";
@@ -0,0 +1,167 @@
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 { downloadSchemaSdl } from "../download-schema.js";
14
+ import { schemaCacheKeyForInstanceUrl, schemaDir } from "../introspect.js";
15
+ import { type PrimeDeps } from "../prime-schema.js";
16
+ import { clearSchemaCache } from "../walker.js";
17
+
18
+ const ORG = "download-sdl-org";
19
+ const ORG_URL = "https://download-sdl-org.my.salesforce.com";
20
+ // A schema with a recognizable field so we can assert the emitted SDL is the
21
+ // one we primed, not an empty stub.
22
+ const SCHEMA = buildSchema(`type Query { hello: String }`);
23
+
24
+ describe("lib/download-schema", () => {
25
+ let tmpRoot: string;
26
+
27
+ beforeEach(() => {
28
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-download-sdl-"));
29
+ process.env.GRAPHITI_HOME = tmpRoot;
30
+ });
31
+
32
+ afterEach(() => {
33
+ delete process.env.GRAPHITI_HOME;
34
+ // getSchema populates a module-level parsed-schema cache keyed by instance
35
+ // URL; clear it so a shared ORG_URL can't leak a schema between tests.
36
+ clearSchemaCache();
37
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
38
+ });
39
+
40
+ function cacheFile(): string {
41
+ return path.join(schemaDir(), `${schemaCacheKeyForInstanceUrl(ORG_URL)}.json`);
42
+ }
43
+
44
+ /** Wrap real deps with a download counter, mirroring connect.spec.ts. */
45
+ function counting(base: PrimeDeps): { deps: PrimeDeps; calls: () => number } {
46
+ let calls = 0;
47
+ return {
48
+ deps: {
49
+ getOrgAuth: base.getOrgAuth,
50
+ downloadSchema: async (a) => {
51
+ calls++;
52
+ return base.downloadSchema(a);
53
+ },
54
+ },
55
+ calls: () => calls,
56
+ };
57
+ }
58
+
59
+ it("primes a fresh (uncached) org, writes SDL to outPath, and reports downloaded", async () => {
60
+ const { deps, calls } = counting(makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA));
61
+ const outPath = path.join(tmpRoot, "schema.graphql");
62
+
63
+ const result = await downloadSchemaSdl({ org: ORG, outPath, deps });
64
+
65
+ expect(calls()).toBe(1); // fresh org → one introspection
66
+ expect(result.downloaded).toBe(true);
67
+ expect(result.refreshed).toBe(false);
68
+ expect(result.refreshedDueToAge).toBe(false);
69
+ expect(result.instanceUrl).toBe(ORG_URL);
70
+ expect(fs.existsSync(cacheFile())).toBe(true); // shared cache populated
71
+ expect(result.outPath).toBe(path.resolve(outPath));
72
+ expect(fs.readFileSync(outPath, "utf-8")).toBe(result.sdl);
73
+ expect(result.sdl).toMatch(/type Query/);
74
+ expect(result.sdl).toMatch(/hello: String/);
75
+ });
76
+
77
+ it("second call is a cache hit: no re-download, still returns and writes the SDL (wait once)", async () => {
78
+ const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
79
+ await downloadSchemaSdl({ org: ORG, deps: base }); // prime once
80
+
81
+ const { deps, calls } = counting(base);
82
+ const outPath = path.join(tmpRoot, "schema.graphql");
83
+ const result = await downloadSchemaSdl({ org: ORG, outPath, deps });
84
+
85
+ expect(calls()).toBe(0); // cache hit — no second introspection
86
+ expect(result.downloaded).toBe(false);
87
+ expect(result.refreshed).toBe(false);
88
+ expect(fs.readFileSync(outPath, "utf-8")).toBe(result.sdl);
89
+ expect(result.sdl).toMatch(/type Query/);
90
+ });
91
+
92
+ it("omitting outPath returns the SDL without writing an export file", async () => {
93
+ const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
94
+ const exportPath = path.join(tmpRoot, "schema.graphql");
95
+ const result = await downloadSchemaSdl({ org: ORG, deps: base });
96
+
97
+ expect(result.outPath).toBeUndefined();
98
+ expect(result.sdl).toMatch(/type Query/);
99
+ // With no outPath, downloadSchemaSdl writes no export. (getSchema still
100
+ // maintains its own `<cacheKey>.graphql` cache side-file under schemaDir,
101
+ // but never the caller-named export path.)
102
+ expect(fs.existsSync(exportPath)).toBe(false);
103
+ });
104
+
105
+ it("maxAgeMs forces a refresh when the cached schema is older than the gate", async () => {
106
+ const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
107
+ await downloadSchemaSdl({ org: ORG, deps: base }); // prime once (fresh)
108
+
109
+ // Backdate the shared cache so it reads as ~20 min old. getSchemaMetadata
110
+ // derives `downloadedAt` from the file mtime, so this is what the gate sees.
111
+ const past = new Date(Date.now() - 20 * 60_000);
112
+ fs.utimesSync(cacheFile(), past, past);
113
+
114
+ const { deps, calls } = counting(base);
115
+ const outPath = path.join(tmpRoot, "schema.graphql");
116
+ const result = await downloadSchemaSdl({
117
+ org: ORG,
118
+ outPath,
119
+ maxAgeMs: 10 * 60_000,
120
+ deps,
121
+ });
122
+
123
+ expect(calls()).toBe(1); // stale → forced re-download
124
+ expect(result.downloaded).toBe(true);
125
+ expect(result.refreshed).toBe(true);
126
+ expect(result.refreshedDueToAge).toBe(true);
127
+ expect(fs.readFileSync(outPath, "utf-8")).toBe(result.sdl);
128
+ });
129
+
130
+ it("maxAgeMs leaves a fresh cache untouched — wait once is preserved", async () => {
131
+ const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
132
+ await downloadSchemaSdl({ org: ORG, deps: base }); // prime once (just now)
133
+
134
+ const { deps, calls } = counting(base);
135
+ const result = await downloadSchemaSdl({ org: ORG, maxAgeMs: 10 * 60_000, deps });
136
+
137
+ expect(calls()).toBe(0); // young cache → no refresh
138
+ expect(result.downloaded).toBe(false);
139
+ expect(result.refreshed).toBe(false);
140
+ expect(result.refreshedDueToAge).toBe(false);
141
+ });
142
+
143
+ it("maxAgeMs on an uncached org lazily primes (no false 'stale') ", async () => {
144
+ const { deps, calls } = counting(makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA));
145
+ // No prior cache: the gate finds no metadata and must not force a refresh;
146
+ // the normal lazy prime downloads exactly once.
147
+ const result = await downloadSchemaSdl({ org: ORG, maxAgeMs: 10 * 60_000, deps });
148
+
149
+ expect(calls()).toBe(1);
150
+ expect(result.downloaded).toBe(true);
151
+ expect(result.refreshed).toBe(false);
152
+ expect(result.refreshedDueToAge).toBe(false);
153
+ });
154
+
155
+ it("forceRefresh re-downloads even a fresh cache; refreshedDueToAge stays false", async () => {
156
+ const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
157
+ await downloadSchemaSdl({ org: ORG, deps: base }); // prime once
158
+
159
+ const { deps, calls } = counting(base);
160
+ const result = await downloadSchemaSdl({ org: ORG, forceRefresh: true, deps });
161
+
162
+ expect(calls()).toBe(1); // explicit refresh re-introspects
163
+ expect(result.downloaded).toBe(true);
164
+ expect(result.refreshed).toBe(true);
165
+ expect(result.refreshedDueToAge).toBe(false); // it was explicit, not the gate
166
+ });
167
+ });
@@ -0,0 +1,143 @@
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 path from "node:path";
8
+ import { printSchema } from "graphql";
9
+ import { getOrgAuth } from "./auth.js";
10
+ import { atomicWriteText } from "./fs-utils.js";
11
+ import { getSchemaMetadata } from "./introspect.js";
12
+ import { type PrimeDeps, primeSchemaWithLock } from "./prime-schema.js";
13
+ import { getSchema } from "./walker.js";
14
+
15
+ export interface DownloadSchemaSdlOptions {
16
+ /** Org alias or username (the value normally passed to `--target-org`). */
17
+ org: string;
18
+ /**
19
+ * Where to write the canonical GraphQL SDL (e.g. `schema.graphql` for a
20
+ * codegen pipeline). Written atomically; parent directories are created as
21
+ * needed. Omit to skip the write and only receive the SDL in the result.
22
+ */
23
+ outPath?: string;
24
+ /**
25
+ * Re-download even if the org's schema is already cached. Routes through the
26
+ * shared refresh path so all of graphiti's caches (introspection JSON,
27
+ * in-memory parsed schema, ObjectInfo) are cleared coherently.
28
+ */
29
+ forceRefresh?: boolean;
30
+ /**
31
+ * Age gate. When the shared cache's schema is older than this many
32
+ * milliseconds, this call forces a refresh (equivalent to `forceRefresh`).
33
+ * A cache younger than the threshold — or a not-yet-primed org — is left to
34
+ * the normal lazy-prime path, so a fresh cache is still served without a
35
+ * second download ("wait once"). Undefined or `<= 0` disables the gate.
36
+ *
37
+ * graphiti's cache has no TTL of its own — a plain `connect` serves any
38
+ * existing schema indefinitely — so callers that must not run against stale
39
+ * metadata (codegen, IDE tooling) should pass a small `maxAgeMs`.
40
+ */
41
+ maxAgeMs?: number;
42
+ /**
43
+ * Injectable priming dependencies (auth + introspection download). Defaults
44
+ * to the real graphiti implementations; tests pass stubs.
45
+ */
46
+ deps?: PrimeDeps;
47
+ }
48
+
49
+ export interface DownloadSchemaSdlResult {
50
+ /** The org's canonical GraphQL SDL (`printSchema` of the primed schema). */
51
+ sdl: string;
52
+ /** Resolved Salesforce instance URL — the key the shared cache is stored under. */
53
+ instanceUrl: string;
54
+ /** Absolute path of the shared introspection JSON cache. */
55
+ cacheFilePath: string;
56
+ /** Absolute path the SDL was written to, or `undefined` when `outPath` was omitted. */
57
+ outPath?: string;
58
+ /**
59
+ * True when this call performed a network introspection (a fresh prime or a
60
+ * refresh that this process ran). A cache hit — including a refresh that
61
+ * coalesced onto a concurrent peer's download — is `false`.
62
+ */
63
+ downloaded: boolean;
64
+ /** True when a refresh was requested, whether explicitly or by the age gate. */
65
+ refreshed: boolean;
66
+ /** True when the age gate (`maxAgeMs`) is what triggered the refresh. */
67
+ refreshedDueToAge: boolean;
68
+ }
69
+
70
+ /**
71
+ * Prime the org's schema through graphiti's shared, lock-coalesced cache and
72
+ * serialize it to canonical SDL — the programmatic equivalent of
73
+ * `graphiti connect <org>` plus an SDL export.
74
+ *
75
+ * This is the sanctioned way for other tools (codegen, IDE integrations) to
76
+ * obtain an org's schema: it shares graphiti's single instance-URL-keyed cache
77
+ * (`~/.graphiti/schemas/`), so whoever asks first — the CLI, the MCP server, or
78
+ * this function — pays the one-time introspection cost, and everyone else reads
79
+ * the same cache.
80
+ *
81
+ * Priming semantics are inherited from {@link primeSchemaWithLock}: a lazy prime
82
+ * on an uncached org, a no-op on a cached one, and a coherent cache-clearing
83
+ * re-download on `forceRefresh`. The SDL is read back via {@link getSchema},
84
+ * which after a refresh rebuilds from the freshly-downloaded introspection JSON,
85
+ * so the returned SDL always matches what is on disk.
86
+ *
87
+ * @throws the underlying auth/schema error verbatim on a lazy-prime failure, or
88
+ * `SchemaRefreshError` when a forced refresh fails but a usable cache survives
89
+ * (see {@link primeSchemaWithLock}).
90
+ */
91
+ export async function downloadSchemaSdl(
92
+ opts: DownloadSchemaSdlOptions,
93
+ ): Promise<DownloadSchemaSdlResult> {
94
+ const { org, deps } = opts;
95
+ const maxAgeMs = opts.maxAgeMs;
96
+
97
+ let forceRefresh = !!opts.forceRefresh;
98
+ let refreshedDueToAge = false;
99
+
100
+ // Age gate: only when a max age is set and we are not already refreshing.
101
+ // Resolving auth here is the only way to learn the instance URL the cache is
102
+ // keyed by; auth is memoized per alias, so the subsequent resolution inside
103
+ // primeSchemaWithLock is free (and in tests the injected stub is trivial).
104
+ if (!forceRefresh && maxAgeMs !== undefined && maxAgeMs > 0) {
105
+ const getAuth = deps?.getOrgAuth ?? getOrgAuth;
106
+ const auth = await getAuth(org);
107
+ const meta = getSchemaMetadata(auth.instanceUrl);
108
+ if (meta) {
109
+ // A NaN age (unparseable timestamp) compares false — we treat an
110
+ // unreadable cache as "not stale" and let the cache-hit path serve it;
111
+ // getSchema self-heals from the JSON if the SDL side-file is bad.
112
+ const ageMs = Date.now() - new Date(meta.downloadedAt).getTime();
113
+ if (ageMs > maxAgeMs) {
114
+ forceRefresh = true;
115
+ refreshedDueToAge = true;
116
+ }
117
+ }
118
+ }
119
+
120
+ const prime = await primeSchemaWithLock(org, deps, { forceRefresh });
121
+ const { instanceUrl } = prime;
122
+
123
+ // getSchema rebuilds from the on-disk introspection JSON after a refresh
124
+ // (the refresh evicted the in-memory + SDL caches), so this is always the
125
+ // schema that was just primed — never a stale copy.
126
+ const sdl = printSchema(getSchema(instanceUrl));
127
+
128
+ let resolvedOut: string | undefined;
129
+ if (opts.outPath) {
130
+ resolvedOut = path.resolve(opts.outPath);
131
+ atomicWriteText(resolvedOut, sdl);
132
+ }
133
+
134
+ return {
135
+ sdl,
136
+ instanceUrl,
137
+ cacheFilePath: prime.filePath,
138
+ outPath: resolvedOut,
139
+ downloaded: !prime.cached,
140
+ refreshed: forceRefresh,
141
+ refreshedDueToAge,
142
+ };
143
+ }