@salesforce/graphiti 10.10.2

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 (282) hide show
  1. package/AGENT_GUIDE.md +424 -0
  2. package/CHANGELOG.md +448 -0
  3. package/LICENSE.txt +82 -0
  4. package/README.md +204 -0
  5. package/TASK.md +249 -0
  6. package/dist/cli.d.ts +7 -0
  7. package/dist/cli.js +683 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/commands/args.d.ts +13 -0
  10. package/dist/commands/args.js +207 -0
  11. package/dist/commands/args.js.map +1 -0
  12. package/dist/commands/build.d.ts +11 -0
  13. package/dist/commands/build.js +209 -0
  14. package/dist/commands/build.js.map +1 -0
  15. package/dist/commands/connect.d.ts +8 -0
  16. package/dist/commands/connect.js +55 -0
  17. package/dist/commands/connect.js.map +1 -0
  18. package/dist/commands/describe.d.ts +6 -0
  19. package/dist/commands/describe.js +229 -0
  20. package/dist/commands/describe.js.map +1 -0
  21. package/dist/commands/meta.d.ts +9 -0
  22. package/dist/commands/meta.js +140 -0
  23. package/dist/commands/meta.js.map +1 -0
  24. package/dist/commands/navigate.d.ts +14 -0
  25. package/dist/commands/navigate.js +105 -0
  26. package/dist/commands/navigate.js.map +1 -0
  27. package/dist/commands/query-helpers.d.ts +80 -0
  28. package/dist/commands/query-helpers.js +865 -0
  29. package/dist/commands/query-helpers.js.map +1 -0
  30. package/dist/commands/query.d.ts +26 -0
  31. package/dist/commands/query.js +901 -0
  32. package/dist/commands/query.js.map +1 -0
  33. package/dist/commands/review.d.ts +18 -0
  34. package/dist/commands/review.js +533 -0
  35. package/dist/commands/review.js.map +1 -0
  36. package/dist/commands/session-mgmt.d.ts +25 -0
  37. package/dist/commands/session-mgmt.js +206 -0
  38. package/dist/commands/session-mgmt.js.map +1 -0
  39. package/dist/commands/type.d.ts +6 -0
  40. package/dist/commands/type.js +342 -0
  41. package/dist/commands/type.js.map +1 -0
  42. package/dist/commands/validate-input.d.ts +6 -0
  43. package/dist/commands/validate-input.js +32 -0
  44. package/dist/commands/validate-input.js.map +1 -0
  45. package/dist/intent/build-aggregate.d.ts +33 -0
  46. package/dist/intent/build-aggregate.js +134 -0
  47. package/dist/intent/build-aggregate.js.map +1 -0
  48. package/dist/intent/build-create.d.ts +14 -0
  49. package/dist/intent/build-create.js +16 -0
  50. package/dist/intent/build-create.js.map +1 -0
  51. package/dist/intent/build-delete.d.ts +30 -0
  52. package/dist/intent/build-delete.js +53 -0
  53. package/dist/intent/build-delete.js.map +1 -0
  54. package/dist/intent/build-detail.d.ts +32 -0
  55. package/dist/intent/build-detail.js +80 -0
  56. package/dist/intent/build-detail.js.map +1 -0
  57. package/dist/intent/build-discover.d.ts +30 -0
  58. package/dist/intent/build-discover.js +149 -0
  59. package/dist/intent/build-discover.js.map +1 -0
  60. package/dist/intent/build-list.d.ts +28 -0
  61. package/dist/intent/build-list.js +75 -0
  62. package/dist/intent/build-list.js.map +1 -0
  63. package/dist/intent/build-mutation.d.ts +23 -0
  64. package/dist/intent/build-mutation.js +54 -0
  65. package/dist/intent/build-mutation.js.map +1 -0
  66. package/dist/intent/build-output.d.ts +27 -0
  67. package/dist/intent/build-output.js +60 -0
  68. package/dist/intent/build-output.js.map +1 -0
  69. package/dist/intent/build-raw.d.ts +23 -0
  70. package/dist/intent/build-raw.js +54 -0
  71. package/dist/intent/build-raw.js.map +1 -0
  72. package/dist/intent/build-update.d.ts +14 -0
  73. package/dist/intent/build-update.js +16 -0
  74. package/dist/intent/build-update.js.map +1 -0
  75. package/dist/intent/get-schema-with-priming.d.ts +26 -0
  76. package/dist/intent/get-schema-with-priming.js +32 -0
  77. package/dist/intent/get-schema-with-priming.js.map +1 -0
  78. package/dist/intent/select-child-relationship.d.ts +19 -0
  79. package/dist/intent/select-child-relationship.js +38 -0
  80. package/dist/intent/select-child-relationship.js.map +1 -0
  81. package/dist/intent/types.d.ts +159 -0
  82. package/dist/intent/types.js +21 -0
  83. package/dist/intent/types.js.map +1 -0
  84. package/dist/lib/apply-command.d.ts +15 -0
  85. package/dist/lib/apply-command.js +238 -0
  86. package/dist/lib/apply-command.js.map +1 -0
  87. package/dist/lib/auth.d.ts +38 -0
  88. package/dist/lib/auth.js +113 -0
  89. package/dist/lib/auth.js.map +1 -0
  90. package/dist/lib/codegen.d.ts +32 -0
  91. package/dist/lib/codegen.js +700 -0
  92. package/dist/lib/codegen.js.map +1 -0
  93. package/dist/lib/command-registry.d.ts +59 -0
  94. package/dist/lib/command-registry.js +366 -0
  95. package/dist/lib/command-registry.js.map +1 -0
  96. package/dist/lib/formatter.d.ts +76 -0
  97. package/dist/lib/formatter.js +419 -0
  98. package/dist/lib/formatter.js.map +1 -0
  99. package/dist/lib/fs-utils.d.ts +23 -0
  100. package/dist/lib/fs-utils.js +46 -0
  101. package/dist/lib/fs-utils.js.map +1 -0
  102. package/dist/lib/graphql-name.d.ts +27 -0
  103. package/dist/lib/graphql-name.js +32 -0
  104. package/dist/lib/graphql-name.js.map +1 -0
  105. package/dist/lib/interactive.d.ts +6 -0
  106. package/dist/lib/interactive.js +562 -0
  107. package/dist/lib/interactive.js.map +1 -0
  108. package/dist/lib/introspect.d.ts +40 -0
  109. package/dist/lib/introspect.js +280 -0
  110. package/dist/lib/introspect.js.map +1 -0
  111. package/dist/lib/object-info.d.ts +79 -0
  112. package/dist/lib/object-info.js +313 -0
  113. package/dist/lib/object-info.js.map +1 -0
  114. package/dist/lib/path-selection.d.ts +50 -0
  115. package/dist/lib/path-selection.js +146 -0
  116. package/dist/lib/path-selection.js.map +1 -0
  117. package/dist/lib/prime-schema.d.ts +59 -0
  118. package/dist/lib/prime-schema.js +158 -0
  119. package/dist/lib/prime-schema.js.map +1 -0
  120. package/dist/lib/query-builder.d.ts +10 -0
  121. package/dist/lib/query-builder.js +168 -0
  122. package/dist/lib/query-builder.js.map +1 -0
  123. package/dist/lib/query-commands.d.ts +19 -0
  124. package/dist/lib/query-commands.js +262 -0
  125. package/dist/lib/query-commands.js.map +1 -0
  126. package/dist/lib/session.d.ts +205 -0
  127. package/dist/lib/session.js +1075 -0
  128. package/dist/lib/session.js.map +1 -0
  129. package/dist/lib/tokenize.d.ts +12 -0
  130. package/dist/lib/tokenize.js +48 -0
  131. package/dist/lib/tokenize.js.map +1 -0
  132. package/dist/lib/uiapi.d.ts +109 -0
  133. package/dist/lib/uiapi.js +159 -0
  134. package/dist/lib/uiapi.js.map +1 -0
  135. package/dist/lib/validator.d.ts +27 -0
  136. package/dist/lib/validator.js +100 -0
  137. package/dist/lib/validator.js.map +1 -0
  138. package/dist/lib/variable-promotion.d.ts +69 -0
  139. package/dist/lib/variable-promotion.js +95 -0
  140. package/dist/lib/variable-promotion.js.map +1 -0
  141. package/dist/lib/walker.d.ts +147 -0
  142. package/dist/lib/walker.js +723 -0
  143. package/dist/lib/walker.js.map +1 -0
  144. package/dist/mcp/server.d.ts +7 -0
  145. package/dist/mcp/server.js +34 -0
  146. package/dist/mcp/server.js.map +1 -0
  147. package/dist/mcp/stdio.d.ts +7 -0
  148. package/dist/mcp/stdio.js +25 -0
  149. package/dist/mcp/stdio.js.map +1 -0
  150. package/dist/mcp/tools/echo.d.ts +7 -0
  151. package/dist/mcp/tools/echo.js +17 -0
  152. package/dist/mcp/tools/echo.js.map +1 -0
  153. package/dist/mcp/tools/sf-gql-aggregate.d.ts +11 -0
  154. package/dist/mcp/tools/sf-gql-aggregate.js +75 -0
  155. package/dist/mcp/tools/sf-gql-aggregate.js.map +1 -0
  156. package/dist/mcp/tools/sf-gql-create.d.ts +11 -0
  157. package/dist/mcp/tools/sf-gql-create.js +35 -0
  158. package/dist/mcp/tools/sf-gql-create.js.map +1 -0
  159. package/dist/mcp/tools/sf-gql-delete.d.ts +11 -0
  160. package/dist/mcp/tools/sf-gql-delete.js +31 -0
  161. package/dist/mcp/tools/sf-gql-delete.js.map +1 -0
  162. package/dist/mcp/tools/sf-gql-detail.d.ts +11 -0
  163. package/dist/mcp/tools/sf-gql-detail.js +58 -0
  164. package/dist/mcp/tools/sf-gql-detail.js.map +1 -0
  165. package/dist/mcp/tools/sf-gql-discover.d.ts +9 -0
  166. package/dist/mcp/tools/sf-gql-discover.js +51 -0
  167. package/dist/mcp/tools/sf-gql-discover.js.map +1 -0
  168. package/dist/mcp/tools/sf-gql-list.d.ts +11 -0
  169. package/dist/mcp/tools/sf-gql-list.js +53 -0
  170. package/dist/mcp/tools/sf-gql-list.js.map +1 -0
  171. package/dist/mcp/tools/sf-gql-raw.d.ts +11 -0
  172. package/dist/mcp/tools/sf-gql-raw.js +39 -0
  173. package/dist/mcp/tools/sf-gql-raw.js.map +1 -0
  174. package/dist/mcp/tools/sf-gql-update.d.ts +11 -0
  175. package/dist/mcp/tools/sf-gql-update.js +35 -0
  176. package/dist/mcp/tools/sf-gql-update.js.map +1 -0
  177. package/dist/mcp/tools/shared/zod-schemas.d.ts +49 -0
  178. package/dist/mcp/tools/shared/zod-schemas.js +46 -0
  179. package/dist/mcp/tools/shared/zod-schemas.js.map +1 -0
  180. package/package.json +36 -0
  181. package/ralph-loop.sh +120 -0
  182. package/scripts/smoke-orderby.sh +190 -0
  183. package/src/__tests__/helpers/prime-deps.ts +46 -0
  184. package/src/__tests__/helpers/schema.ts +73 -0
  185. package/src/__tests__/helpers/stdout.ts +33 -0
  186. package/src/__tests__/setup.ts +19 -0
  187. package/src/cli.ts +764 -0
  188. package/src/commands/__tests__/query.spec.ts +137 -0
  189. package/src/commands/args.ts +306 -0
  190. package/src/commands/build.ts +288 -0
  191. package/src/commands/connect.ts +60 -0
  192. package/src/commands/describe.ts +246 -0
  193. package/src/commands/meta.ts +171 -0
  194. package/src/commands/navigate.ts +134 -0
  195. package/src/commands/query-helpers.ts +1202 -0
  196. package/src/commands/query.ts +1085 -0
  197. package/src/commands/review.ts +670 -0
  198. package/src/commands/session-mgmt.ts +256 -0
  199. package/src/commands/type.ts +437 -0
  200. package/src/commands/validate-input.ts +38 -0
  201. package/src/intent/__tests__/build-aggregate.spec.ts +931 -0
  202. package/src/intent/__tests__/build-create-validation.spec.ts +135 -0
  203. package/src/intent/__tests__/build-delete.spec.ts +121 -0
  204. package/src/intent/__tests__/build-detail.spec.ts +333 -0
  205. package/src/intent/__tests__/build-discover.spec.ts +432 -0
  206. package/src/intent/__tests__/build-list.spec.ts +284 -0
  207. package/src/intent/__tests__/build-mutation.spec.ts +108 -0
  208. package/src/intent/__tests__/build-output.spec.ts +55 -0
  209. package/src/intent/__tests__/build-raw.spec.ts +153 -0
  210. package/src/intent/__tests__/build-update-validation.spec.ts +134 -0
  211. package/src/intent/build-aggregate.ts +182 -0
  212. package/src/intent/build-create.ts +19 -0
  213. package/src/intent/build-delete.ts +62 -0
  214. package/src/intent/build-detail.ts +95 -0
  215. package/src/intent/build-discover.ts +199 -0
  216. package/src/intent/build-list.ts +91 -0
  217. package/src/intent/build-mutation.ts +75 -0
  218. package/src/intent/build-output.ts +72 -0
  219. package/src/intent/build-raw.ts +61 -0
  220. package/src/intent/build-update.ts +19 -0
  221. package/src/intent/get-schema-with-priming.ts +43 -0
  222. package/src/intent/select-child-relationship.ts +48 -0
  223. package/src/intent/types.ts +181 -0
  224. package/src/lib/__tests__/apply-command.parity.spec.ts +97 -0
  225. package/src/lib/__tests__/apply-command.spec.ts +171 -0
  226. package/src/lib/__tests__/auth.spec.ts +292 -0
  227. package/src/lib/__tests__/formatter.spec.ts +86 -0
  228. package/src/lib/__tests__/graphql-name.spec.ts +64 -0
  229. package/src/lib/__tests__/introspect.spec.ts +399 -0
  230. package/src/lib/__tests__/object-info.spec.ts +124 -0
  231. package/src/lib/__tests__/path-selection.spec.ts +219 -0
  232. package/src/lib/__tests__/prime-schema.spec.ts +269 -0
  233. package/src/lib/__tests__/query-builder.spec.ts +95 -0
  234. package/src/lib/__tests__/query-commands.spec.ts +74 -0
  235. package/src/lib/__tests__/session.spec.ts +292 -0
  236. package/src/lib/__tests__/tokenize.spec.ts +33 -0
  237. package/src/lib/__tests__/uiapi.spec.ts +192 -0
  238. package/src/lib/__tests__/variable-promotion.spec.ts +211 -0
  239. package/src/lib/__tests__/walker.spec.ts +250 -0
  240. package/src/lib/apply-command.ts +286 -0
  241. package/src/lib/auth.ts +157 -0
  242. package/src/lib/codegen.ts +899 -0
  243. package/src/lib/command-registry.ts +434 -0
  244. package/src/lib/formatter.ts +587 -0
  245. package/src/lib/fs-utils.ts +46 -0
  246. package/src/lib/graphql-name.ts +35 -0
  247. package/src/lib/interactive.ts +634 -0
  248. package/src/lib/introspect.ts +320 -0
  249. package/src/lib/object-info.ts +409 -0
  250. package/src/lib/path-selection.ts +162 -0
  251. package/src/lib/prime-schema.ts +195 -0
  252. package/src/lib/query-builder.ts +193 -0
  253. package/src/lib/query-commands.ts +290 -0
  254. package/src/lib/session.ts +1304 -0
  255. package/src/lib/tokenize.ts +43 -0
  256. package/src/lib/uiapi.ts +176 -0
  257. package/src/lib/validator.ts +143 -0
  258. package/src/lib/variable-promotion.ts +145 -0
  259. package/src/lib/walker.ts +975 -0
  260. package/src/mcp/__tests__/server.spec.ts +155 -0
  261. package/src/mcp/server.ts +38 -0
  262. package/src/mcp/stdio.ts +29 -0
  263. package/src/mcp/tools/__tests__/sf-gql-aggregate.spec.ts +173 -0
  264. package/src/mcp/tools/__tests__/sf-gql-create.spec.ts +235 -0
  265. package/src/mcp/tools/__tests__/sf-gql-delete.spec.ts +194 -0
  266. package/src/mcp/tools/__tests__/sf-gql-detail.spec.ts +246 -0
  267. package/src/mcp/tools/__tests__/sf-gql-discover.spec.ts +320 -0
  268. package/src/mcp/tools/__tests__/sf-gql-list.spec.ts +128 -0
  269. package/src/mcp/tools/__tests__/sf-gql-raw.spec.ts +141 -0
  270. package/src/mcp/tools/__tests__/sf-gql-update.spec.ts +207 -0
  271. package/src/mcp/tools/echo.ts +24 -0
  272. package/src/mcp/tools/sf-gql-aggregate.ts +102 -0
  273. package/src/mcp/tools/sf-gql-create.ts +55 -0
  274. package/src/mcp/tools/sf-gql-delete.ts +49 -0
  275. package/src/mcp/tools/sf-gql-detail.ts +85 -0
  276. package/src/mcp/tools/sf-gql-discover.ts +67 -0
  277. package/src/mcp/tools/sf-gql-list.ts +73 -0
  278. package/src/mcp/tools/sf-gql-raw.ts +56 -0
  279. package/src/mcp/tools/sf-gql-update.ts +55 -0
  280. package/src/mcp/tools/shared/zod-schemas.ts +55 -0
  281. package/tsconfig.json +18 -0
  282. package/vitest.config.ts +14 -0
@@ -0,0 +1,195 @@
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 path from "node:path";
9
+ import { getOrgAuth as realGetOrgAuth, type OrgAuth } from "./auth.js";
10
+ import {
11
+ downloadSchema as realDownloadSchema,
12
+ normalizeInstanceUrl,
13
+ schemaCacheKeyForInstanceUrl,
14
+ schemaDir,
15
+ type SchemaMetadata,
16
+ } from "./introspect.js";
17
+
18
+ // FR-13.7 originally suggested a 10-30s introspection budget. Empirical
19
+ // smoke testing against real Salesforce UIAPI schemas measures ~135s for
20
+ // a ~40MB introspection. We size the stale-lock threshold and the waiter
21
+ // timeout for ~3× that observed worst case (~7 min): comfortable headroom
22
+ // for normal operation while keeping crashed-holder recovery within a few
23
+ // minutes. If introspection ever legitimately exceeds 5 min the design
24
+ // assumption ("priming completes in minutes, not tens of minutes") has
25
+ // changed and these constants should be revisited together (likely with
26
+ // a heartbeat that refreshes the lock dir's mtime while held).
27
+ const STALE_LOCK_MS = 7 * 60_000;
28
+ const POLL_MS = 100;
29
+ const MAX_WAIT_MS = 7 * 60_000;
30
+
31
+ /**
32
+ * Acquire a filesystem advisory lock at `${finalPath}.lock` (a directory,
33
+ * since `mkdir` is atomic on POSIX), run `work`, then release the lock.
34
+ *
35
+ * If another process holds the lock, this polls every 100ms for the lock
36
+ * to be released. On release, if `finalPath` now exists, the work function
37
+ * is skipped (the other process already did it). If `finalPath` still does
38
+ * not exist (the other process aborted without writing), this acquires the
39
+ * lock and runs `work`.
40
+ *
41
+ * Contract: `work` will NOT run when `finalPath` exists at any point during
42
+ * the lock dance — including immediately after we acquire the lock (a holder
43
+ * may have finished and released the lock between our last EEXIST poll and
44
+ * our successful `mkdir`). In that case the lock is released and the
45
+ * function returns `undefined`.
46
+ *
47
+ * Stale locks (older than STALE_LOCK_MS) are reclaimed.
48
+ */
49
+ export async function withSchemaLock<T>(
50
+ finalPath: string,
51
+ work: () => Promise<T>,
52
+ ): Promise<T | undefined> {
53
+ const lockPath = `${finalPath}.lock`;
54
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
55
+
56
+ const startedWaitingAt = Date.now();
57
+ while (true) {
58
+ try {
59
+ fs.mkdirSync(lockPath); // atomic; throws EEXIST if held
60
+ break;
61
+ } catch (e: unknown) {
62
+ const err = e as NodeJS.ErrnoException;
63
+ if (err.code !== "EEXIST") throw e;
64
+
65
+ // If the cache exists now, another holder finished — short-circuit.
66
+ if (fs.existsSync(finalPath)) return undefined;
67
+
68
+ // Stale-lock reclaim. Known TOCTOU: between this `statSync` and
69
+ // the `rmSync` below, another process may have already reclaimed
70
+ // the stale lock and re-created a fresh one — in which case we'd
71
+ // remove the fresh lock. Window is microseconds and the next
72
+ // `mkdirSync` retry self-heals (we'd re-acquire or re-block);
73
+ // not worth fcntl-level locking to close.
74
+ try {
75
+ const stat = fs.statSync(lockPath);
76
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
77
+ // Recursive removal: a stray file inside the lock dir
78
+ // (e.g. macOS `.DS_Store`) would make `rmdirSync` throw
79
+ // ENOTEMPTY, which the bare catch below would swallow,
80
+ // leaving the stale lock in place.
81
+ fs.rmSync(lockPath, { recursive: true });
82
+ continue;
83
+ }
84
+ } catch {
85
+ // Lock vanished between EEXIST and stat — loop and retry mkdir.
86
+ }
87
+
88
+ if (Date.now() - startedWaitingAt > MAX_WAIT_MS) {
89
+ throw new Error(
90
+ `Timed out waiting ${MAX_WAIT_MS}ms for schema priming lock at ${lockPath}`,
91
+ );
92
+ }
93
+ await new Promise((r) => setTimeout(r, POLL_MS));
94
+ }
95
+ }
96
+
97
+ // We now hold the lock. Re-check for the cache: a holder that finished
98
+ // between our last EEXIST poll and our successful mkdir may have just
99
+ // primed it. Avoid running `work` redundantly.
100
+ if (fs.existsSync(finalPath)) {
101
+ try {
102
+ fs.rmSync(lockPath, { recursive: true });
103
+ } catch {
104
+ // best-effort
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ try {
110
+ return await work();
111
+ } finally {
112
+ // Recursive remove tolerates stray files inside the lock dir (e.g.
113
+ // macOS `.DS_Store`) that would otherwise make `rmdirSync` throw
114
+ // ENOTEMPTY and leak the lock until the 7-min stale timeout.
115
+ try {
116
+ fs.rmSync(lockPath, { recursive: true });
117
+ } catch {
118
+ // Already gone — fine.
119
+ }
120
+ }
121
+ }
122
+
123
+ export interface PrimeResult {
124
+ cached: boolean;
125
+ filePath: string;
126
+ durationMs: number;
127
+ /** Resolved instance URL (already auth-resolved). */
128
+ instanceUrl: string;
129
+ }
130
+
131
+ /**
132
+ * Dependencies that `primeSchemaWithLock` calls. Defaulted to the real
133
+ * graphiti implementations; tests pass stubs.
134
+ */
135
+ export interface PrimeDeps {
136
+ getOrgAuth: (orgAlias: string) => Promise<OrgAuth>;
137
+ downloadSchema: (auth: OrgAuth) => Promise<SchemaMetadata>;
138
+ }
139
+
140
+ const REAL_DEPS: PrimeDeps = {
141
+ getOrgAuth: realGetOrgAuth,
142
+ downloadSchema: realDownloadSchema,
143
+ };
144
+
145
+ /**
146
+ * Lazily prime the on-disk schema cache for `orgAlias`.
147
+ *
148
+ * - If `<HOME>/.graphiti/schemas/<hash>.json` already exists, returns
149
+ * `{cached: true}` immediately.
150
+ * - Otherwise: resolves auth via `deps.getOrgAuth` (throws verbatim if the
151
+ * alias is not authenticated, per FR-13.5), acquires the schema lock via
152
+ * `withSchemaLock`, calls `deps.downloadSchema(auth)` (which writes the
153
+ * cache atomically via `downloadSchema`'s internal use of
154
+ * `atomicWriteJson`), releases the lock, and returns
155
+ * `{cached: false, durationMs}`. Per FR-13.6, partial caches never reach
156
+ * disk because writes are atomic.
157
+ *
158
+ * Concurrent callers in the same or different processes serialize on the
159
+ * lock dir; only one performs introspection. Subsequent waiters observe
160
+ * the primed cache and short-circuit (FR-13.4).
161
+ */
162
+ export async function primeSchemaWithLock(
163
+ orgAlias: string,
164
+ deps: PrimeDeps = REAL_DEPS,
165
+ ): Promise<PrimeResult> {
166
+ // We must call `deps.getOrgAuth` first because it is the only injected
167
+ // source of the org's `instanceUrl`, which the cache key is derived from.
168
+ // In tests this returns a stub; in production it is memoized inside
169
+ // `auth.ts`'s in-process cache, so the org is resolved via `@salesforce/core`
170
+ // at most once per org alias per process.
171
+ const auth = await deps.getOrgAuth(orgAlias);
172
+ const instanceUrl = normalizeInstanceUrl(auth.instanceUrl);
173
+ const cacheKey = schemaCacheKeyForInstanceUrl(instanceUrl);
174
+ const filePath = path.join(schemaDir(), `${cacheKey}.json`);
175
+
176
+ if (fs.existsSync(filePath)) {
177
+ return { cached: true, filePath, durationMs: 0, instanceUrl };
178
+ }
179
+
180
+ const start = Date.now();
181
+ let primed = false;
182
+ await withSchemaLock(filePath, async () => {
183
+ // withSchemaLock re-checks `finalPath` after acquire and short-
184
+ // circuits if it now exists, so when our work runs we know we
185
+ // must do the introspection.
186
+ await deps.downloadSchema(auth);
187
+ primed = true;
188
+ });
189
+
190
+ if (!primed) {
191
+ // Another process primed while we were waiting.
192
+ return { cached: true, filePath, durationMs: Date.now() - start, instanceUrl };
193
+ }
194
+ return { cached: false, filePath, durationMs: Date.now() - start, instanceUrl };
195
+ }
@@ -0,0 +1,193 @@
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 { QuerySession, ProjectionNode, DirectiveNode } from "./session.js";
8
+ import { getChildren, getEffectiveArgs } from "./session.js";
9
+
10
+ /**
11
+ * Renders a QuerySession's selection tree into a properly formatted GraphQL query string.
12
+ */
13
+ export function renderQuery(session: QuerySession): string {
14
+ const parts: string[] = [];
15
+
16
+ // Operation line with variables
17
+ const varDefs =
18
+ session.variables.length > 0
19
+ ? `(${session.variables
20
+ .map((v) => {
21
+ let def = `$${v.name}: ${v.type}`;
22
+ if (v.defaultValue !== undefined) def += ` = ${v.defaultValue}`;
23
+ return def;
24
+ })
25
+ .join(", ")})`
26
+ : "";
27
+
28
+ const operationKeyword = session.operation === "aggregate" ? "query" : session.operation;
29
+ const operationName = session.operationName ? ` ${session.operationName}` : "";
30
+ const operationBody = renderChildren(session, null, 1);
31
+ if (operationBody) {
32
+ parts.push(`${operationKeyword}${operationName}${varDefs} {`);
33
+ parts.push(operationBody);
34
+ parts.push("}");
35
+ } else {
36
+ parts.push(`${operationKeyword}${operationName}${varDefs} { }`);
37
+ }
38
+
39
+ return parts.join("\n");
40
+ }
41
+
42
+ function renderChildren(session: QuerySession, parentId: string | null, depth: number): string {
43
+ const _indent = " ".repeat(depth);
44
+ const lines: string[] = [];
45
+
46
+ for (const child of getChildren(session, parentId)) {
47
+ if (child.kind === "field") {
48
+ lines.push(renderField(session, child, depth));
49
+ } else {
50
+ lines.push(renderInlineFragment(session, child, depth));
51
+ }
52
+ }
53
+
54
+ return lines.join("\n");
55
+ }
56
+
57
+ function renderField(
58
+ session: QuerySession,
59
+ node: Extract<ProjectionNode, { kind: "field" }>,
60
+ depth: number,
61
+ ): string {
62
+ const indent = " ".repeat(depth);
63
+ let line = indent;
64
+
65
+ // Alias
66
+ if (node.alias) {
67
+ line += `${node.alias}: `;
68
+ }
69
+
70
+ line += node.fieldName;
71
+
72
+ // Arguments
73
+ const argEntries = Object.entries(getEffectiveArgs(session, node));
74
+ if (argEntries.length > 0) {
75
+ const argParts = argEntries.map(([name, value]) => {
76
+ return `${name}: ${formatArgValue(value)}`;
77
+ });
78
+ line += `(${argParts.join(", ")})`;
79
+ }
80
+
81
+ // Directives
82
+ for (const dir of node.directives) {
83
+ line += ` ${renderDirective(dir)}`;
84
+ }
85
+
86
+ // Sub-selections
87
+ const childNodes = getChildren(session, node.id);
88
+ const hasChildren = childNodes.length > 0;
89
+
90
+ if (hasChildren) {
91
+ const childContent = renderChildren(session, node.id, depth + 1);
92
+ line += ` {\n${childContent}\n${indent}}`;
93
+ }
94
+
95
+ return line;
96
+ }
97
+
98
+ function renderInlineFragment(
99
+ session: QuerySession,
100
+ frag: Extract<ProjectionNode, { kind: "fragment" }>,
101
+ depth: number,
102
+ ): string {
103
+ const indent = " ".repeat(depth);
104
+ let line = `${indent}... on ${frag.onType}`;
105
+
106
+ for (const dir of frag.directives) {
107
+ line += ` ${renderDirective(dir)}`;
108
+ }
109
+
110
+ const childContent = renderChildren(session, frag.id, depth + 1);
111
+ if (childContent) {
112
+ line += ` {\n${childContent}\n${indent}}`;
113
+ } else {
114
+ line += " { }";
115
+ }
116
+
117
+ return line;
118
+ }
119
+
120
+ function renderDirective(dir: DirectiveNode): string {
121
+ const argEntries = Object.entries(dir.args);
122
+ if (argEntries.length === 0) {
123
+ return `@${dir.name}`;
124
+ }
125
+ const argParts = argEntries.map(([name, value]) => `${name}: ${formatArgValue(value)}`);
126
+ return `@${dir.name}(${argParts.join(", ")})`;
127
+ }
128
+
129
+ /**
130
+ * Formats an argument value for rendering in GraphQL.
131
+ * Handles variable references ($varName), raw JSON objects, numbers, booleans, and strings.
132
+ */
133
+ function formatArgValue(value: string): string {
134
+ const trimmed = value.trim();
135
+
136
+ // Variable reference
137
+ if (trimmed.startsWith("$")) return trimmed;
138
+
139
+ // Numeric
140
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
141
+
142
+ // Boolean / null
143
+ if (trimmed === "true" || trimmed === "false" || trimmed === "null") return trimmed;
144
+
145
+ // Enum value (unquoted identifier)
146
+ if (/^[A-Z_][A-Z0-9_]*$/i.test(trimmed) && trimmed === trimmed.toUpperCase()) return trimmed;
147
+
148
+ // JSON object or array — convert to GraphQL literal syntax
149
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
150
+ return jsonToGraphQL(trimmed);
151
+ }
152
+
153
+ // Quoted string — pass through
154
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed;
155
+
156
+ // Default: wrap in quotes
157
+ return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
158
+ }
159
+
160
+ /**
161
+ * Converts a JSON string to GraphQL object literal syntax.
162
+ * GraphQL uses unquoted keys: { Status: { ne: "Closed" } }
163
+ */
164
+ function jsonToGraphQL(jsonStr: string): string {
165
+ try {
166
+ const parsed = JSON.parse(jsonStr);
167
+ return valueToGraphQL(parsed);
168
+ } catch {
169
+ // If not valid JSON, return as-is (might already be in GraphQL format)
170
+ return jsonStr;
171
+ }
172
+ }
173
+
174
+ function valueToGraphQL(value: unknown): string {
175
+ if (value === null || value === undefined) return "null";
176
+ if (typeof value === "boolean") return String(value);
177
+ if (typeof value === "number") return String(value);
178
+ if (typeof value === "string") {
179
+ if (value.startsWith("$")) return value;
180
+ // Emit uppercase identifiers as bare enum tokens (e.g. DESC, ASC, EVERYTHING)
181
+ if (/^[A-Z_][A-Z0-9_]*$/.test(value)) return value;
182
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
183
+ }
184
+ if (Array.isArray(value)) {
185
+ return `[${value.map(valueToGraphQL).join(", ")}]`;
186
+ }
187
+ if (typeof value === "object") {
188
+ const entries = Object.entries(value as Record<string, unknown>);
189
+ const parts = entries.map(([k, v]) => `${k}: ${valueToGraphQL(v)}`);
190
+ return `{ ${parts.join(", ")} }`;
191
+ }
192
+ return String(value);
193
+ }
@@ -0,0 +1,290 @@
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 { COMMANDS, type CommandDef } from "./command-registry.js";
8
+ import type { QuerySession } from "./session.js";
9
+ import type { WalkerResult } from "./walker.js";
10
+
11
+ // ── Interactive mode flag ─────────────────────────────────────────────────────
12
+
13
+ let _interactiveMode = false;
14
+
15
+ export function setInteractiveMode(on: boolean): void {
16
+ _interactiveMode = on;
17
+ }
18
+
19
+ export function isInteractiveMode(): boolean {
20
+ return _interactiveMode;
21
+ }
22
+
23
+ // ── Backward-compatible re-export ─────────────────────────────────────────────
24
+ // QUERY_COMMANDS was the original registry. It now delegates to the canonical
25
+ // COMMANDS array in command-registry.ts so there is a single source of truth.
26
+ export { COMMANDS as QUERY_COMMANDS };
27
+
28
+ // ── Formatting helpers ───────────────────────────────────────────────────────
29
+
30
+ export function formatHelp(topic?: string): string {
31
+ const lines: string[] = [];
32
+
33
+ if (topic) {
34
+ const spec: CommandDef | undefined = COMMANDS.find((c) => c.name === topic);
35
+ if (!spec) {
36
+ return `Unknown help topic: "${topic}". Run \`help\` with no arguments for a full list.`;
37
+ }
38
+ lines.push(`${spec.name} — ${spec.summary}`);
39
+ lines.push("");
40
+ const usagePrefix = _interactiveMode ? "" : "graphiti ";
41
+ const usageStr = spec.usage.startsWith("graphiti ")
42
+ ? spec.usage
43
+ : `${usagePrefix}${spec.usage}`;
44
+ lines.push(`Usage: ${usageStr}`);
45
+ if (spec.subcommands && spec.subcommands.length > 0) {
46
+ lines.push("");
47
+ lines.push("Subcommands:");
48
+ for (const sub of spec.subcommands) {
49
+ lines.push(` ${sub.usage.padEnd(32)} ${sub.description}`);
50
+ }
51
+ }
52
+
53
+ if (topic === "ls") {
54
+ lines.push("");
55
+ lines.push("Search behavior (--search):");
56
+ lines.push(' Multiple terms are OR-matched: --search "Name Id" matches fields');
57
+ lines.push(' containing either "Name" or "Id". Each term is prefix-matched');
58
+ lines.push(' against CamelCase word segments, so "Id" matches "AccountId"');
59
+ lines.push(' but not "Hide".');
60
+ }
61
+
62
+ if (topic === "alias" || topic === "mkdir") {
63
+ lines.push("");
64
+ lines.push("Multi-query composition:");
65
+ lines.push(" Use alias to create multiple named instances of the same field,");
66
+ lines.push(" each with different arguments. This lets you combine several queries");
67
+ lines.push(" into a single GraphQL request (e.g. dashboard tiles + lists).");
68
+ lines.push("");
69
+ lines.push(" # Full end-to-end example (from /query/uiapi/query):");
70
+ lines.push(" alias newCases Case");
71
+ lines.push(' set newCases/@args/where \'{"Status":{"eq":"New"}}\'');
72
+ lines.push(" set newCases/@args/first 10");
73
+ lines.push(" select newCases/edges/node/Id newCases/edges/node/Subject/value");
74
+ lines.push("");
75
+ lines.push(" alias myCases Case");
76
+ lines.push(" set myCases/@args/scope MINE");
77
+ lines.push(" set myCases/@args/first 20");
78
+ lines.push(" select myCases/edges/node/Id myCases/edges/node/Status/value");
79
+ lines.push("");
80
+ lines.push(" show # see the combined query");
81
+ }
82
+
83
+ if (topic === "chain") {
84
+ lines.push("");
85
+ lines.push("Note: session management commands (new, use, connect, describe,");
86
+ lines.push("interactive) must be run as separate commands before chaining.");
87
+ lines.push("Only session-scoped commands (cd, ls, select, set, var, etc.)");
88
+ lines.push("can be used inside a chain.");
89
+ }
90
+
91
+ if (topic === "set" || topic === "assign") {
92
+ lines.push("");
93
+ lines.push("Inline key=value syntax (preferred):");
94
+ lines.push(" set first=10 # set on current field");
95
+ lines.push(" set uiapi/query/Case first=10 scope=MINE # with absolute field path");
96
+ lines.push(' set where=\'{"Status":{"eq":"New"}}\' # JSON value');
97
+ lines.push("");
98
+ lines.push("Legacy pair syntax:");
99
+ lines.push(" set @args/first 10");
100
+ lines.push(' set @args/where/Name/like "Acme%"');
101
+ }
102
+
103
+ lines.push("");
104
+ lines.push("Examples:");
105
+ for (const ex of spec.examples) {
106
+ const exPrefix = _interactiveMode ? " " : " ";
107
+ lines.push(`${exPrefix}${ex}`);
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+
112
+ lines.push("Graphiti CLI — Progressive GraphQL query builder for Salesforce");
113
+ lines.push("");
114
+ lines.push(
115
+ "Session resolution: --session / -s flag > GRAPHITI_SESSION env var > ~/.graphiti/active",
116
+ );
117
+ lines.push("JSON output: --json flag or GRAPHITI_AGENT=1 env var");
118
+ lines.push("");
119
+ lines.push("Setup:");
120
+ for (const name of ["new", "use", "connect"]) {
121
+ const spec = COMMANDS.find((c) => c.name === name);
122
+ if (spec) lines.push(` ${spec.name.padEnd(12)} ${spec.summary}`);
123
+ }
124
+ lines.push("");
125
+ lines.push("Navigation:");
126
+ for (const name of ["pwd", "ls", "cd"]) {
127
+ const spec = COMMANDS.find((c) => c.name === name)!;
128
+ lines.push(` ${spec.name.padEnd(12)} ${spec.summary}`);
129
+ }
130
+ lines.push("");
131
+ lines.push("Query Building:");
132
+ for (const name of ["select", "drop", "alias", "optional", "undo"]) {
133
+ const spec = COMMANDS.find((c) => c.name === name)!;
134
+ lines.push(` ${spec.name.padEnd(12)} ${spec.summary}`);
135
+ }
136
+ lines.push("");
137
+ lines.push("Arguments & Variables:");
138
+ for (const name of ["set", "unset", "var"]) {
139
+ const spec = COMMANDS.find((c) => c.name === name)!;
140
+ lines.push(` ${spec.name.padEnd(12)} ${spec.summary}`);
141
+ }
142
+ lines.push("");
143
+ lines.push("Review & Execute:");
144
+ for (const name of ["show", "check", "run", "describe", "codegen"]) {
145
+ const spec = COMMANDS.find((c) => c.name === name)!;
146
+ lines.push(` ${spec.name.padEnd(12)} ${spec.summary}`);
147
+ }
148
+ lines.push("");
149
+ lines.push("Session Management:");
150
+ for (const name of ["sessions", "clone", "reset"]) {
151
+ const spec = COMMANDS.find((c) => c.name === name)!;
152
+ lines.push(` ${spec.name.padEnd(12)} ${spec.summary}`);
153
+ }
154
+ lines.push("");
155
+ lines.push("Other:");
156
+ const otherCommands = _interactiveMode ? ["chain", "help"] : ["chain", "help", "interactive"];
157
+ for (const name of otherCommands) {
158
+ const spec = COMMANDS.find((c) => c.name === name);
159
+ if (spec) lines.push(` ${spec.name.padEnd(12)} ${spec.summary}`);
160
+ }
161
+ lines.push("");
162
+ lines.push("Run `help <subcommand>` for detailed usage and examples.");
163
+ return lines.join("\n");
164
+ }
165
+
166
+ // ── Next-steps hints ─────────────────────────────────────────────────────────
167
+
168
+ export interface NextStepsContext {
169
+ sessionId: string;
170
+ command: string;
171
+ walkerResult?: WalkerResult;
172
+ session?: QuerySession;
173
+ }
174
+
175
+ export function formatNextSteps(ctx: NextStepsContext): string {
176
+ const { sessionId: sid, command, walkerResult: wr, session } = ctx;
177
+ const lines: string[] = [];
178
+ const p = (cmd: string, desc: string) => {
179
+ if (_interactiveMode) {
180
+ lines.push(` ${cmd.padEnd(36)} # ${desc}`);
181
+ } else {
182
+ lines.push(` graphiti query ${sid} ${cmd.padEnd(36)} # ${desc}`);
183
+ }
184
+ };
185
+
186
+ const isRoot = !session || session.navigationPath.length === 0;
187
+ const hasSelections = session ? session.nodes.length > 0 : false;
188
+ const hasArgs = wr && wr.args.length > 0;
189
+ const isLeaf = wr?.isLeaf ?? false;
190
+ const _hasFragments = wr && wr.possibleTypes.length > 0;
191
+ const hasFields = wr && !wr.isLeaf && wr.fields.length > 0;
192
+
193
+ lines.push("");
194
+ lines.push("Next steps:");
195
+
196
+ switch (command) {
197
+ case "new": {
198
+ p("cd query", "navigate into the query tree");
199
+ p("ls", "see available root fields");
200
+ p("interactive", "start an interactive session");
201
+ break;
202
+ }
203
+
204
+ case "ls": {
205
+ if (isLeaf) {
206
+ if (!isRoot && session) {
207
+ const leaf = session.navigationPath[session.navigationPath.length - 1];
208
+ p(`cd ..`, "go up to parent directory");
209
+ p(`select ${leaf}`, "select this leaf field");
210
+ }
211
+ } else {
212
+ if (hasFields) {
213
+ const firstDir = wr!.fields.find((f) => f.typeKind !== "SCALAR" && f.typeKind !== "ENUM");
214
+ const firstLeaf = wr!.fields.find(
215
+ (f) => f.typeKind === "SCALAR" || f.typeKind === "ENUM",
216
+ );
217
+ if (firstDir) p(`cd ${firstDir.name}`, `navigate into ${firstDir.name}/`);
218
+ if (firstLeaf) p(`select ${firstLeaf.name}`, `add ${firstLeaf.name} to projection`);
219
+ const hasConnections = wr!.fields.some((f) => f.typeName.includes("Connection"));
220
+ if (hasConnections && firstDir)
221
+ p(`alias myAlias ${firstDir.name}`, "compose multiple queries via aliases");
222
+ }
223
+ if (hasArgs) p(`cd @args`, "navigate into arguments");
224
+ if (!isRoot) p(`cd ..`, "go up one level");
225
+ }
226
+ break;
227
+ }
228
+
229
+ case "cd": {
230
+ p(`ls`, "see contents at this path");
231
+ if (hasArgs) p(`cd @args`, "configure field arguments");
232
+ if (hasFields) {
233
+ const firstLeaf = wr!.fields.find((f) => f.typeKind === "SCALAR" || f.typeKind === "ENUM");
234
+ if (firstLeaf) p(`select ${firstLeaf.name}`, `add ${firstLeaf.name} to projection`);
235
+ }
236
+ break;
237
+ }
238
+
239
+ case "select": {
240
+ p(`ls`, "see more available fields");
241
+ if (!isRoot) p(`cd ..`, "go up to parent");
242
+ if (hasSelections) {
243
+ p(`show`, "preview the current query string");
244
+ p(`check`, "validate the query");
245
+ }
246
+ break;
247
+ }
248
+
249
+ case "assign":
250
+ case "set": {
251
+ p(`show`, "preview query with arguments");
252
+ p(`ls`, "see other fields to set");
253
+ if (hasSelections) p(`check`, "validate the query");
254
+ break;
255
+ }
256
+
257
+ case "define":
258
+ case "var": {
259
+ p(`cd /variables/$varName`, "navigate into the variable to set values");
260
+ p(`show`, "preview the query signature");
261
+ break;
262
+ }
263
+
264
+ case "show": {
265
+ p(`check`, "validate the query");
266
+ if (hasSelections) p(`run`, "execute the query against the org");
267
+ break;
268
+ }
269
+
270
+ case "validate":
271
+ case "check": {
272
+ if (hasSelections) p(`run`, "execute the validated query");
273
+ p(`show`, "preview the query string");
274
+ break;
275
+ }
276
+
277
+ default: {
278
+ p(`ls`, "list contents at current path");
279
+ p(`show`, "preview the current query");
280
+ if (hasSelections) p(`check`, "validate the query");
281
+ break;
282
+ }
283
+ }
284
+
285
+ if (lines.length <= 2) {
286
+ p(`help`, "show all available commands");
287
+ }
288
+
289
+ return lines.join("\n");
290
+ }