@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,1304 @@
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 crypto from "crypto";
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { graphitiHome } from "./fs-utils.js";
11
+
12
+ // Evaluated lazily so that GRAPHITI_SESSIONS_DIR (or GRAPHITI_HOME) set in
13
+ // tests takes effect even when the module is imported before the env var
14
+ // is assigned.
15
+ function getSessionsDir(): string {
16
+ return process.env.GRAPHITI_SESSIONS_DIR ?? path.join(graphitiHome(), "sessions");
17
+ }
18
+
19
+ export interface DirectiveNode {
20
+ name: string;
21
+ args: Record<string, string>;
22
+ }
23
+
24
+ export interface VariableDefinition {
25
+ name: string;
26
+ type: string;
27
+ defaultValue?: string;
28
+ runtimeValue?: string;
29
+ }
30
+
31
+ export interface BaseProjectionNode {
32
+ id: string;
33
+ kind: "field" | "fragment";
34
+ parentId: string | null;
35
+ schemaPath: string[];
36
+ directives: DirectiveNode[];
37
+ }
38
+
39
+ export interface FieldProjectionNode extends BaseProjectionNode {
40
+ kind: "field";
41
+ fieldName: string;
42
+ alias?: string;
43
+ args: Record<string, string>;
44
+ }
45
+
46
+ export interface FragmentProjectionNode extends BaseProjectionNode {
47
+ kind: "fragment";
48
+ onType: string;
49
+ }
50
+
51
+ export type ProjectionNode = FieldProjectionNode | FragmentProjectionNode;
52
+
53
+ export type OperationType = "query" | "mutation" | "aggregate";
54
+
55
+ export interface QuerySession {
56
+ id: string;
57
+ name?: string;
58
+ /** GraphQL operation name emitted by `renderQuery`. Distinct from `name`, which is the session's user-facing label. */
59
+ operationName?: string;
60
+ orgAlias: string;
61
+ /** Resolved Salesforce instance URL stored at session creation to avoid repeated sf-auth calls. */
62
+ instanceUrl?: string;
63
+ operation: OperationType;
64
+ navigationPath: string[];
65
+ nodes: ProjectionNode[];
66
+ variables: VariableDefinition[];
67
+ focusByPath: Record<string, string>;
68
+ createdAt: string;
69
+ undoStack?: string[];
70
+ }
71
+
72
+ const UNDO_STACK_LIMIT = 20;
73
+
74
+ /**
75
+ * Snapshots the current session state (excluding undoStack) and pushes it
76
+ * onto the undo stack. Call this before any state-mutating operation.
77
+ */
78
+ export function pushUndoSnapshot(session: QuerySession): void {
79
+ if (!session.undoStack) session.undoStack = [];
80
+ const { undoStack, ...rest } = session;
81
+ session.undoStack.push(JSON.stringify(rest));
82
+ if (session.undoStack.length > UNDO_STACK_LIMIT) {
83
+ session.undoStack.shift();
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Pops the most recent undo snapshot and returns a restored session.
89
+ * The restored session inherits the current (truncated) undo stack.
90
+ * Returns null if there is nothing to undo.
91
+ */
92
+ export function popUndo(session: QuerySession): QuerySession | null {
93
+ if (!session.undoStack || session.undoStack.length === 0) return null;
94
+ const snapshot = session.undoStack.pop()!;
95
+ const restored: QuerySession = JSON.parse(snapshot);
96
+ restored.undoStack = session.undoStack;
97
+ return restored;
98
+ }
99
+
100
+ export function isFragmentSegment(segment: string): boolean {
101
+ return (segment.startsWith("[") && segment.endsWith("]")) || segment.startsWith("on:");
102
+ }
103
+
104
+ /**
105
+ * Normalizes a fragment segment to the canonical `[Type]` form.
106
+ * Accepts `on:Type` as a shell-safe alternative that avoids zsh glob/redirect
107
+ * issues with `[Type]` and `<Type>`.
108
+ *
109
+ * Examples: `on:User` → `[User]`, `[User]` stays as-is.
110
+ */
111
+ export function normalizeFragmentSegment(segment: string): string {
112
+ if (segment.startsWith("on:")) {
113
+ return `[${segment.slice(3)}]`;
114
+ }
115
+ return segment;
116
+ }
117
+
118
+ /** Returns true for alias-encoded path segments like `openOpps(Opportunity)`. */
119
+ export function isAliasedSegment(segment: string): boolean {
120
+ return /^[^[\](]+\([^()]+\)$/.test(segment);
121
+ }
122
+
123
+ /**
124
+ * Parses an alias-encoded segment into its alias name and underlying field name.
125
+ * Returns null for plain or fragment segments.
126
+ */
127
+ export function parseAliasedSegment(segment: string): { alias: string; fieldName: string } | null {
128
+ const m = segment.match(/^([^[\](]+)\(([^()]+)\)$/);
129
+ if (!m) return null;
130
+ return { alias: m[1], fieldName: m[2] };
131
+ }
132
+
133
+ /** Strips the alias wrapper from a single path segment, leaving just the schema field name. */
134
+ export function schemaSegment(segment: string): string {
135
+ return parseAliasedSegment(segment)?.fieldName ?? segment;
136
+ }
137
+
138
+ /**
139
+ * Converts a navigation path (which may contain alias-encoded segments) to a
140
+ * pure schema path suitable for schema resolution and focus-map lookups.
141
+ */
142
+ export function toSchemaPath(navPath: string[]): string[] {
143
+ return navPath.map(schemaSegment);
144
+ }
145
+
146
+ export function pathKey(pathSegments: string[]): string {
147
+ return pathSegments.join("/");
148
+ }
149
+
150
+ export function formatPath(pathSegments: string[]): string {
151
+ if (pathSegments.length === 0) return "/";
152
+ return `/${pathSegments.join("/")}`;
153
+ }
154
+
155
+ function randomId(prefix: string): string {
156
+ return `${prefix}_${crypto.randomBytes(4).toString("hex")}`;
157
+ }
158
+
159
+ function normalizeVariableName(name: string): string {
160
+ return name.replace(/^\$/, "");
161
+ }
162
+
163
+ export function createSession(
164
+ orgAlias: string,
165
+ operation: OperationType = "query",
166
+ instanceUrl?: string,
167
+ name?: string,
168
+ ): QuerySession {
169
+ return {
170
+ id: `s_${crypto.randomBytes(2).toString("hex")}`,
171
+ name,
172
+ orgAlias,
173
+ instanceUrl,
174
+ operation,
175
+ navigationPath: [],
176
+ nodes: [],
177
+ variables: [],
178
+ focusByPath: {},
179
+ createdAt: new Date().toISOString(),
180
+ };
181
+ }
182
+
183
+ export function cloneSession(source: QuerySession, newName?: string): QuerySession {
184
+ const cloned: QuerySession = JSON.parse(JSON.stringify(source));
185
+ cloned.id = `s_${crypto.randomBytes(2).toString("hex")}`;
186
+ cloned.name = newName;
187
+ cloned.createdAt = new Date().toISOString();
188
+ cloned.undoStack = [];
189
+ return cloned;
190
+ }
191
+
192
+ export function getNodeById(
193
+ session: QuerySession,
194
+ id: string | null | undefined,
195
+ ): ProjectionNode | null {
196
+ if (!id) return null;
197
+ return session.nodes.find((node) => node.id === id) ?? null;
198
+ }
199
+
200
+ export function getChildren(session: QuerySession, parentId: string | null): ProjectionNode[] {
201
+ return session.nodes.filter((node) => node.parentId === parentId);
202
+ }
203
+
204
+ function clearFocusBelow(session: QuerySession, schemaPath: string[]): void {
205
+ const prefix = pathKey(schemaPath);
206
+ for (const key of Object.keys(session.focusByPath)) {
207
+ if (key === prefix) continue;
208
+ if (prefix === "" || key.startsWith(`${prefix}/`)) {
209
+ delete session.focusByPath[key];
210
+ }
211
+ }
212
+ }
213
+
214
+ function createFieldNode(
215
+ parentId: string | null,
216
+ schemaPath: string[],
217
+ fieldName: string,
218
+ alias?: string,
219
+ ): FieldProjectionNode {
220
+ return {
221
+ id: randomId("fld"),
222
+ kind: "field",
223
+ parentId,
224
+ schemaPath: [...schemaPath],
225
+ fieldName,
226
+ alias,
227
+ args: {},
228
+ directives: [],
229
+ };
230
+ }
231
+
232
+ function createFragmentNode(
233
+ parentId: string | null,
234
+ schemaPath: string[],
235
+ onType: string,
236
+ ): FragmentProjectionNode {
237
+ return {
238
+ id: randomId("frag"),
239
+ kind: "fragment",
240
+ parentId,
241
+ schemaPath: [...schemaPath],
242
+ onType,
243
+ directives: [],
244
+ };
245
+ }
246
+
247
+ function matchNodeToSegment(node: ProjectionNode, segment: string): boolean {
248
+ if (node.kind === "fragment") {
249
+ return segment === `[${node.onType}]`;
250
+ }
251
+ return node.fieldName === segment;
252
+ }
253
+
254
+ export function listInstancesAtPath(session: QuerySession, schemaPath: string[]): ProjectionNode[] {
255
+ const parentPath = schemaPath.slice(0, -1);
256
+ const parentNode = getFocusedNodeAtPath(session, parentPath, false);
257
+ const parentId = parentNode?.id ?? null;
258
+ const segment = schemaPath[schemaPath.length - 1];
259
+ if (!segment) return [];
260
+ return getChildren(session, parentId).filter((node) => matchNodeToSegment(node, segment));
261
+ }
262
+
263
+ function resolveNodeAtPathInternal(
264
+ session: QuerySession,
265
+ schemaPath: string[],
266
+ createMissing: boolean,
267
+ ): ProjectionNode | null {
268
+ let parentId: string | null = null;
269
+ let current: ProjectionNode | null = null;
270
+
271
+ for (let i = 0; i < schemaPath.length; i += 1) {
272
+ const currentPath = schemaPath.slice(0, i + 1);
273
+ const currentKey = pathKey(currentPath);
274
+ const segment = schemaPath[i];
275
+ const matching: ProjectionNode[] = getChildren(session, parentId).filter(
276
+ (node: ProjectionNode) => matchNodeToSegment(node, segment),
277
+ );
278
+
279
+ const focusedId = session.focusByPath[currentKey];
280
+ let next: ProjectionNode | null =
281
+ matching.find((node: ProjectionNode) => node.id === focusedId) ?? null;
282
+
283
+ if (!next && matching.length === 1) {
284
+ next = matching[0];
285
+ }
286
+
287
+ if (!next && matching.length > 1 && !createMissing) {
288
+ throw new Error(
289
+ `Path ${formatPath(currentPath)} has multiple projection instances. Use \`cd <alias>(<field>)\` to navigate into one.`,
290
+ );
291
+ }
292
+
293
+ if (!next && createMissing) {
294
+ next = isFragmentSegment(segment)
295
+ ? createFragmentNode(parentId, currentPath, segment.slice(1, -1))
296
+ : createFieldNode(parentId, currentPath, segment);
297
+ session.nodes.push(next);
298
+ }
299
+
300
+ if (!next) return null;
301
+
302
+ session.focusByPath[currentKey] = next.id;
303
+ parentId = next.id;
304
+ current = next;
305
+ }
306
+
307
+ return current;
308
+ }
309
+
310
+ export function getFocusedNodeAtPath(
311
+ session: QuerySession,
312
+ schemaPath: string[],
313
+ createMissing = false,
314
+ ): ProjectionNode | null {
315
+ if (schemaPath.length === 0) return null;
316
+ return resolveNodeAtPathInternal(session, schemaPath, createMissing);
317
+ }
318
+
319
+ export function ensureFocusedChain(
320
+ session: QuerySession,
321
+ schemaPath: string[],
322
+ ): ProjectionNode | null {
323
+ return getFocusedNodeAtPath(session, schemaPath, true);
324
+ }
325
+
326
+ /**
327
+ * Walks the query portion of the navigation path and syncs focusByPath entries
328
+ * for any aliased segments (e.g. `recentAccounts(Account)`). This ensures that
329
+ * subsequent select/assign operations target the correct aliased instance.
330
+ */
331
+ export function syncFocusFromNavigationPath(session: QuerySession): void {
332
+ const ctx = getNavigationContext(session.navigationPath);
333
+ if (ctx !== "query") return;
334
+
335
+ const queryIdx = 0;
336
+ let parentId: string | null = null;
337
+
338
+ for (let i = queryIdx + 1; i < session.navigationPath.length; i++) {
339
+ const seg = session.navigationPath[i];
340
+ if (isArgsSegment(seg)) break;
341
+
342
+ const schemaPath = toSchemaPath(session.navigationPath.slice(queryIdx + 1, i + 1));
343
+ const key = pathKey(schemaPath);
344
+ const aliased = parseAliasedSegment(seg);
345
+
346
+ if (aliased) {
347
+ const aliasMatches: FieldProjectionNode[] = getChildren(session, parentId).filter(
348
+ (n): n is FieldProjectionNode =>
349
+ n.kind === "field" && n.fieldName === aliased.fieldName && n.alias === aliased.alias,
350
+ );
351
+ if (aliasMatches.length === 1) {
352
+ session.focusByPath[key] = aliasMatches[0].id;
353
+ parentId = aliasMatches[0].id;
354
+ continue;
355
+ }
356
+ }
357
+
358
+ const focusedId = session.focusByPath[key];
359
+ const fieldName = aliased ? aliased.fieldName : seg;
360
+ const fieldMatches: FieldProjectionNode[] = getChildren(session, parentId).filter(
361
+ (n): n is FieldProjectionNode => n.kind === "field" && n.fieldName === fieldName,
362
+ );
363
+ const focusedMatch = fieldMatches.find((n: FieldProjectionNode) => n.id === focusedId);
364
+ parentId = focusedMatch?.id ?? fieldMatches[0]?.id ?? null;
365
+ }
366
+ }
367
+
368
+ export function focusNodeAtPath(session: QuerySession, schemaPath: string[], nodeId: string): void {
369
+ const node = getNodeById(session, nodeId);
370
+ if (!node) throw new Error(`Projection instance "${nodeId}" not found.`);
371
+ if (pathKey(node.schemaPath) !== pathKey(schemaPath)) {
372
+ throw new Error(`Projection instance "${nodeId}" is not at ${formatPath(schemaPath)}.`);
373
+ }
374
+ session.focusByPath[pathKey(schemaPath)] = nodeId;
375
+ clearFocusBelow(session, schemaPath);
376
+ }
377
+
378
+ export function createSiblingFieldInstance(
379
+ session: QuerySession,
380
+ schemaPath: string[],
381
+ alias?: string,
382
+ ): FieldProjectionNode {
383
+ if (schemaPath.length === 0) {
384
+ throw new Error("Cannot create a projection instance at the root path.");
385
+ }
386
+ const parentPath = schemaPath.slice(0, -1);
387
+ const parentNode = ensureFocusedChain(session, parentPath);
388
+ const parentId = parentNode?.id ?? null;
389
+ const fieldName = schemaPath[schemaPath.length - 1];
390
+ if (isFragmentSegment(fieldName)) {
391
+ throw new Error("Use fragment navigation to create fragment projections.");
392
+ }
393
+ const node = createFieldNode(parentId, schemaPath, fieldName, alias);
394
+ session.nodes.push(node);
395
+ focusNodeAtPath(session, schemaPath, node.id);
396
+ return node;
397
+ }
398
+
399
+ export function setAliasOnPath(
400
+ session: QuerySession,
401
+ schemaPath: string[],
402
+ aliasName: string,
403
+ ): FieldProjectionNode {
404
+ const node = getFocusedNodeAtPath(session, schemaPath, true);
405
+ if (!node || node.kind !== "field") {
406
+ throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`);
407
+ }
408
+ node.alias = aliasName;
409
+ focusNodeAtPath(session, schemaPath, node.id);
410
+ return node;
411
+ }
412
+
413
+ export function clearAliasOnPath(session: QuerySession, schemaPath: string[]): void {
414
+ const node = getFocusedNodeAtPath(session, schemaPath, false);
415
+ if (!node || node.kind !== "field") {
416
+ throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`);
417
+ }
418
+ delete node.alias;
419
+ }
420
+
421
+ export function focusInstanceByAliasOrId(
422
+ session: QuerySession,
423
+ schemaPath: string[],
424
+ selector: string,
425
+ ): ProjectionNode {
426
+ const matches = listInstancesAtPath(session, schemaPath).filter((node) => {
427
+ if (node.id === selector) return true;
428
+ return node.kind === "field" && node.alias === selector;
429
+ });
430
+ if (matches.length === 0) {
431
+ throw new Error(
432
+ `No projection instance matching "${selector}" exists at ${formatPath(schemaPath)}.`,
433
+ );
434
+ }
435
+ if (matches.length > 1) {
436
+ throw new Error(
437
+ `Multiple projection instances match "${selector}" at ${formatPath(schemaPath)}.`,
438
+ );
439
+ }
440
+ focusNodeAtPath(session, schemaPath, matches[0].id);
441
+ return matches[0];
442
+ }
443
+
444
+ export function setArg(
445
+ session: QuerySession,
446
+ schemaPath: string[],
447
+ argName: string,
448
+ value: string,
449
+ ): void {
450
+ const node = getFocusedNodeAtPath(session, schemaPath, true);
451
+ if (!node || node.kind !== "field") {
452
+ throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`);
453
+ }
454
+ node.args[argName] = value;
455
+ }
456
+
457
+ export function getArg(
458
+ session: QuerySession,
459
+ schemaPath: string[],
460
+ argName: string,
461
+ ): string | undefined {
462
+ const node = getFocusedNodeAtPath(session, schemaPath, false);
463
+ if (!node || node.kind !== "field") return undefined;
464
+ return node.args[argName];
465
+ }
466
+
467
+ export function removeArg(session: QuerySession, schemaPath: string[], argName: string): boolean {
468
+ const node = getFocusedNodeAtPath(session, schemaPath, false);
469
+ if (!node || node.kind !== "field") return false;
470
+ if (!(argName in node.args)) return false;
471
+ delete node.args[argName];
472
+ return true;
473
+ }
474
+
475
+ export function getEffectiveArgs(
476
+ _session: QuerySession,
477
+ node: ProjectionNode,
478
+ ): Record<string, string> {
479
+ if (node.kind !== "field") return {};
480
+ return { ...node.args };
481
+ }
482
+
483
+ // ── Navigation context helpers ───────────────────────────────────────────────
484
+
485
+ export type NavigationContext = "root" | "query" | "variables";
486
+
487
+ export function getNavigationContext(navPath: string[]): NavigationContext {
488
+ if (navPath.length === 0) return "root";
489
+ if (navPath[0] === "variables") return "variables";
490
+ return "query";
491
+ }
492
+
493
+ export const ARGS_SEGMENT = "@args";
494
+
495
+ export function isArgsSegment(segment: string): boolean {
496
+ return segment === ARGS_SEGMENT;
497
+ }
498
+
499
+ /** Returns the index of the @args segment in the path, or -1 if not present. */
500
+ export function argsSegmentIndex(navPath: string[]): number {
501
+ return navPath.indexOf(ARGS_SEGMENT);
502
+ }
503
+
504
+ /** Returns true if the navigation path is inside an @args context. */
505
+ export function isInArgsContext(navPath: string[]): boolean {
506
+ return argsSegmentIndex(navPath) !== -1;
507
+ }
508
+
509
+ /**
510
+ * Extracts the schema path of the field whose args are being navigated.
511
+ * E.g. ["query", "uiapi", "query", "Account", "@args", "where", "Name"]
512
+ * → ["uiapi", "query", "Account"]
513
+ */
514
+ export function getArgsFieldPath(navPath: string[]): string[] {
515
+ const idx = argsSegmentIndex(navPath);
516
+ if (idx === -1) return [];
517
+ const queryPath = navPath.slice(1, idx);
518
+ return toSchemaPath(queryPath);
519
+ }
520
+
521
+ /**
522
+ * Extracts the input sub-path after @args.
523
+ * E.g. ["query", "uiapi", "query", "Account", "@args", "where", "Name"]
524
+ * → ["where", "Name"]
525
+ */
526
+ export function getInputSubPath(navPath: string[]): string[] {
527
+ const idx = argsSegmentIndex(navPath);
528
+ if (idx === -1) return [];
529
+ return navPath.slice(idx + 1);
530
+ }
531
+
532
+ /**
533
+ * For paths in the /query context, strips the "query" prefix and alias wrappers
534
+ * to produce a pure schema path. Stops at @args if present.
535
+ * E.g. ["query", "uiapi", "query", "Account"] → ["uiapi", "query", "Account"]
536
+ */
537
+ export function queryNavToSchemaPath(navPath: string[]): string[] {
538
+ const ctx = getNavigationContext(navPath);
539
+ if (ctx !== "query") return [];
540
+ const argsIdx = argsSegmentIndex(navPath);
541
+ const end = argsIdx !== -1 ? argsIdx : navPath.length;
542
+ return toSchemaPath(navPath.slice(1, end));
543
+ }
544
+
545
+ /**
546
+ * For paths in /variables, extracts the variable name (without $) and the
547
+ * sub-path into its input type.
548
+ */
549
+ export function parseVariablePath(
550
+ navPath: string[],
551
+ ): { varName: string; inputSubPath: string[] } | null {
552
+ if (getNavigationContext(navPath) !== "variables") return null;
553
+ if (navPath.length < 2) return null;
554
+ const varName = normalizeVariableName(navPath[1]);
555
+ return { varName, inputSubPath: navPath.slice(2) };
556
+ }
557
+
558
+ // ── Deep-set helpers for incremental arg/variable assignment ─────────────────
559
+
560
+ function deepSet(
561
+ obj: Record<string, unknown>,
562
+ pathSegments: string[],
563
+ value: unknown,
564
+ ): Record<string, unknown> {
565
+ if (pathSegments.length === 0) return obj;
566
+ const result = { ...obj };
567
+ let current: Record<string, unknown> = result;
568
+
569
+ for (let i = 0; i < pathSegments.length - 1; i++) {
570
+ const seg = pathSegments[i];
571
+ const nextSeg = pathSegments[i + 1];
572
+ const nextIsIndex = nextSeg !== undefined && /^\d+$/.test(nextSeg);
573
+ if (/^\d+$/.test(seg)) {
574
+ const idx = Number(seg);
575
+ if (!Array.isArray(current)) {
576
+ throw new Error(`Expected array at path segment "${seg}"`);
577
+ }
578
+ const arr = current as unknown as unknown[];
579
+ while (arr.length <= idx) arr.push({});
580
+ if (typeof arr[idx] !== "object" || arr[idx] === null) arr[idx] = {};
581
+ current = arr[idx] as Record<string, unknown>;
582
+ } else {
583
+ if (current[seg] === undefined || current[seg] === null || typeof current[seg] !== "object") {
584
+ current[seg] = nextIsIndex ? [] : {};
585
+ } else if (nextIsIndex && !Array.isArray(current[seg])) {
586
+ current[seg] = [];
587
+ } else if (!Array.isArray(current[seg])) {
588
+ current[seg] = { ...(current[seg] as Record<string, unknown>) };
589
+ }
590
+ current = current[seg] as Record<string, unknown>;
591
+ }
592
+ }
593
+
594
+ const lastSeg = pathSegments[pathSegments.length - 1];
595
+ if (/^\d+$/.test(lastSeg)) {
596
+ const idx = Number(lastSeg);
597
+ if (!Array.isArray(current)) {
598
+ throw new Error(`Expected array at path segment "${lastSeg}"`);
599
+ }
600
+ (current as unknown as unknown[])[idx] = value;
601
+ } else {
602
+ current[lastSeg] = value;
603
+ }
604
+
605
+ return result;
606
+ }
607
+
608
+ function deepGet(obj: Record<string, unknown>, pathSegments: string[]): unknown {
609
+ let current: unknown = obj;
610
+ for (const seg of pathSegments) {
611
+ if (current === null || current === undefined) return undefined;
612
+ if (/^\d+$/.test(seg)) {
613
+ if (!Array.isArray(current)) return undefined;
614
+ current = (current as unknown[])[Number(seg)];
615
+ } else {
616
+ if (typeof current !== "object") return undefined;
617
+ current = (current as Record<string, unknown>)[seg];
618
+ }
619
+ }
620
+ return current;
621
+ }
622
+
623
+ function deepDelete(obj: Record<string, unknown>, pathSegments: string[]): Record<string, unknown> {
624
+ if (pathSegments.length === 0) return obj;
625
+ if (pathSegments.length === 1) {
626
+ const result = { ...obj };
627
+ delete result[pathSegments[0]];
628
+ return result;
629
+ }
630
+ const [head, ...rest] = pathSegments;
631
+ const child = obj[head];
632
+ if (typeof child !== "object" || child === null) return obj;
633
+ const result = { ...obj };
634
+ result[head] = deepDelete(child as Record<string, unknown>, rest);
635
+ return result;
636
+ }
637
+
638
+ function parseArgValue(raw: string): unknown {
639
+ const trimmed = raw.trim();
640
+ if (trimmed === "null") return null;
641
+ if (trimmed === "true") return true;
642
+ if (trimmed === "false") return false;
643
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
644
+ if (
645
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
646
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
647
+ ) {
648
+ try {
649
+ return JSON.parse(trimmed);
650
+ } catch {
651
+ /* fall through */
652
+ }
653
+ }
654
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
655
+ try {
656
+ return JSON.parse(trimmed);
657
+ } catch {
658
+ /* fall through */
659
+ }
660
+ }
661
+ return trimmed;
662
+ }
663
+
664
+ /**
665
+ * Incrementally sets a value deep inside a field argument's JSON structure.
666
+ * If inputPath is empty, sets the top-level arg directly.
667
+ * E.g. deepSetArg(session, path, "where", ["Name", "like"], '"Acme%"')
668
+ * results in node.args["where"] = '{"Name":{"like":"Acme%"}}'
669
+ */
670
+ export function deepSetArg(
671
+ session: QuerySession,
672
+ schemaPath: string[],
673
+ argName: string,
674
+ inputPath: string[],
675
+ value: string,
676
+ ): void {
677
+ const node = getFocusedNodeAtPath(session, schemaPath, true);
678
+ if (!node || node.kind !== "field") {
679
+ throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`);
680
+ }
681
+
682
+ if (inputPath.length === 0) {
683
+ node.args[argName] = value;
684
+ return;
685
+ }
686
+
687
+ const existing = node.args[argName];
688
+ let obj: Record<string, unknown> = {};
689
+ if (existing) {
690
+ try {
691
+ obj = JSON.parse(existing) as Record<string, unknown>;
692
+ } catch {
693
+ obj = {};
694
+ }
695
+ }
696
+
697
+ const parsedValue = value.startsWith("$") ? value : parseArgValue(value);
698
+ obj = deepSet(obj, inputPath, parsedValue);
699
+ node.args[argName] = JSON.stringify(obj);
700
+ }
701
+
702
+ /**
703
+ * Reads the current value at a nested path inside a field argument.
704
+ */
705
+ export function deepGetArg(
706
+ session: QuerySession,
707
+ schemaPath: string[],
708
+ argName: string,
709
+ inputPath: string[],
710
+ ): unknown {
711
+ const node = getFocusedNodeAtPath(session, schemaPath, false);
712
+ if (!node || node.kind !== "field") return undefined;
713
+ const raw = node.args[argName];
714
+ if (raw === undefined) return undefined;
715
+ if (inputPath.length === 0) return raw;
716
+ try {
717
+ const obj = JSON.parse(raw);
718
+ return deepGet(obj, inputPath);
719
+ } catch {
720
+ return undefined;
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Removes a value at a nested path inside a field argument.
726
+ */
727
+ export function deepRemoveArg(
728
+ session: QuerySession,
729
+ schemaPath: string[],
730
+ argName: string,
731
+ inputPath: string[],
732
+ ): boolean {
733
+ const node = getFocusedNodeAtPath(session, schemaPath, false);
734
+ if (!node || node.kind !== "field") return false;
735
+
736
+ if (inputPath.length === 0) {
737
+ if (!(argName in node.args)) return false;
738
+ delete node.args[argName];
739
+ return true;
740
+ }
741
+
742
+ const raw = node.args[argName];
743
+ if (!raw) return false;
744
+ try {
745
+ let obj = JSON.parse(raw) as Record<string, unknown>;
746
+ obj = deepDelete(obj, inputPath);
747
+ if (Object.keys(obj).length === 0) {
748
+ delete node.args[argName];
749
+ } else {
750
+ node.args[argName] = JSON.stringify(obj);
751
+ }
752
+ return true;
753
+ } catch {
754
+ return false;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Incrementally sets a value deep inside a variable's runtime value.
760
+ * If inputPath is empty, sets the runtime value directly.
761
+ */
762
+ export function deepSetVariableValue(
763
+ session: QuerySession,
764
+ varName: string,
765
+ inputPath: string[],
766
+ value: string,
767
+ ): void {
768
+ const cleanName = normalizeVariableName(varName);
769
+ const variable = session.variables.find((v) => v.name === cleanName);
770
+ if (!variable) throw new Error(`Variable "$${cleanName}" is not defined.`);
771
+
772
+ if (inputPath.length === 0) {
773
+ variable.runtimeValue = value;
774
+ return;
775
+ }
776
+
777
+ let obj: Record<string, unknown> = {};
778
+ if (variable.runtimeValue) {
779
+ try {
780
+ obj = JSON.parse(variable.runtimeValue) as Record<string, unknown>;
781
+ } catch {
782
+ obj = {};
783
+ }
784
+ }
785
+
786
+ const parsedValue = parseArgValue(value);
787
+ obj = deepSet(obj, inputPath, parsedValue);
788
+ variable.runtimeValue = JSON.stringify(obj);
789
+ }
790
+
791
+ /**
792
+ * Appends a new empty object to a list-type arg array. Returns the new index.
793
+ */
794
+ export function appendListElement(
795
+ session: QuerySession,
796
+ schemaPath: string[],
797
+ argName: string,
798
+ inputPath: string[],
799
+ ): number {
800
+ const node = getFocusedNodeAtPath(session, schemaPath, true);
801
+ if (!node || node.kind !== "field") {
802
+ throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`);
803
+ }
804
+
805
+ if (inputPath.length === 0) {
806
+ const raw = node.args[argName];
807
+ let arr: unknown[] = [];
808
+ if (raw) {
809
+ try {
810
+ arr = JSON.parse(raw);
811
+ } catch {
812
+ arr = [];
813
+ }
814
+ if (!Array.isArray(arr)) arr = [];
815
+ }
816
+ arr.push({});
817
+ node.args[argName] = JSON.stringify(arr);
818
+ return arr.length - 1;
819
+ }
820
+
821
+ const raw = node.args[argName];
822
+ let obj: Record<string, unknown> = {};
823
+ if (raw) {
824
+ try {
825
+ obj = JSON.parse(raw) as Record<string, unknown>;
826
+ } catch {
827
+ obj = {};
828
+ }
829
+ }
830
+ let target = deepGet(obj, inputPath);
831
+ if (!Array.isArray(target)) target = [];
832
+ const arr = target as unknown[];
833
+ arr.push({});
834
+ obj = deepSet(obj, inputPath, arr);
835
+ node.args[argName] = JSON.stringify(obj);
836
+ return arr.length - 1;
837
+ }
838
+
839
+ /**
840
+ * Removes an element from a list-type arg by index and compacts remaining indices.
841
+ */
842
+ export function removeListElement(
843
+ session: QuerySession,
844
+ schemaPath: string[],
845
+ argName: string,
846
+ inputPath: string[],
847
+ index: number,
848
+ ): boolean {
849
+ const node = getFocusedNodeAtPath(session, schemaPath, false);
850
+ if (!node || node.kind !== "field") return false;
851
+
852
+ const raw = node.args[argName];
853
+ if (!raw) return false;
854
+
855
+ try {
856
+ let obj: Record<string, unknown> | unknown[] = JSON.parse(raw);
857
+
858
+ if (inputPath.length === 0) {
859
+ if (!Array.isArray(obj)) return false;
860
+ if (index < 0 || index >= obj.length) return false;
861
+ obj.splice(index, 1);
862
+ if (obj.length === 0) {
863
+ delete node.args[argName];
864
+ } else {
865
+ node.args[argName] = JSON.stringify(obj);
866
+ }
867
+ return true;
868
+ }
869
+
870
+ const parent = deepGet(obj as Record<string, unknown>, inputPath);
871
+ if (!Array.isArray(parent)) return false;
872
+ if (index < 0 || index >= parent.length) return false;
873
+ parent.splice(index, 1);
874
+ if (parent.length === 0) {
875
+ obj = deepDelete(obj as Record<string, unknown>, inputPath);
876
+ } else {
877
+ obj = deepSet(obj as Record<string, unknown>, inputPath, parent);
878
+ }
879
+ node.args[argName] = JSON.stringify(obj);
880
+ return true;
881
+ } catch {
882
+ return false;
883
+ }
884
+ }
885
+
886
+ export function selectLeaf(
887
+ session: QuerySession,
888
+ schemaPath: string[],
889
+ alias?: string,
890
+ ): FieldProjectionNode {
891
+ if (schemaPath.length === 0) {
892
+ throw new Error("Cannot select the root path.");
893
+ }
894
+ const parentPath = schemaPath.slice(0, -1);
895
+ const parentNode = ensureFocusedChain(session, parentPath);
896
+ const parentId = parentNode?.id ?? null;
897
+ const fieldName = schemaPath[schemaPath.length - 1];
898
+ if (isFragmentSegment(fieldName)) {
899
+ throw new Error("Cannot select a fragment directory as a leaf.");
900
+ }
901
+
902
+ const siblings = getChildren(session, parentId).filter(
903
+ (node) => node.kind === "field" && node.fieldName === fieldName,
904
+ ) as FieldProjectionNode[];
905
+
906
+ const existing = siblings.find((node) => node.alias === alias);
907
+ if (existing) {
908
+ focusNodeAtPath(session, schemaPath, existing.id);
909
+ return existing;
910
+ }
911
+
912
+ const node = createFieldNode(parentId, schemaPath, fieldName, alias);
913
+ session.nodes.push(node);
914
+ session.focusByPath[pathKey(schemaPath)] = node.id;
915
+ return node;
916
+ }
917
+
918
+ function removeNodeRecursive(session: QuerySession, nodeId: string): void {
919
+ const children = getChildren(session, nodeId);
920
+ for (const child of children) {
921
+ removeNodeRecursive(session, child.id);
922
+ }
923
+ session.nodes = session.nodes.filter((node) => node.id !== nodeId);
924
+ for (const [key, focusedId] of Object.entries(session.focusByPath)) {
925
+ if (focusedId === nodeId) {
926
+ delete session.focusByPath[key];
927
+ }
928
+ }
929
+ }
930
+
931
+ function pruneEmptyAncestors(session: QuerySession, startParentId: string | null): void {
932
+ let currentId = startParentId;
933
+ while (currentId) {
934
+ const current = getNodeById(session, currentId);
935
+ if (!current) return;
936
+ if (getChildren(session, current.id).length > 0) return;
937
+ const parentId = current.parentId;
938
+ removeNodeRecursive(session, current.id);
939
+ currentId = parentId;
940
+ }
941
+ }
942
+
943
+ export function removeSelectionAtPath(
944
+ session: QuerySession,
945
+ schemaPath: string[],
946
+ selector?: string,
947
+ ): boolean {
948
+ const parentPath = schemaPath.slice(0, -1);
949
+ const parentNode = getFocusedNodeAtPath(session, parentPath, false);
950
+ const parentId = parentNode?.id ?? null;
951
+ const segment = schemaPath[schemaPath.length - 1];
952
+ if (!segment) return false;
953
+
954
+ const matches = getChildren(session, parentId).filter((node) => {
955
+ if (!matchNodeToSegment(node, segment)) return false;
956
+ if (!selector) return true;
957
+ if (node.id === selector) return true;
958
+ return node.kind === "field" && node.alias === selector;
959
+ });
960
+
961
+ if (matches.length === 0) return false;
962
+ if (matches.length > 1) {
963
+ throw new Error(
964
+ `Multiple projection instances match ${formatPath(schemaPath)}. Use an alias or instance id to remove one.`,
965
+ );
966
+ }
967
+
968
+ const target = matches[0];
969
+ const parentToPrune = target.parentId;
970
+ removeNodeRecursive(session, target.id);
971
+ pruneEmptyAncestors(session, parentToPrune);
972
+ return true;
973
+ }
974
+
975
+ export function findDescendantByAlias(
976
+ session: QuerySession,
977
+ parentId: string | null,
978
+ alias: string,
979
+ ): FieldProjectionNode | null {
980
+ const children = getChildren(session, parentId);
981
+ for (const child of children) {
982
+ if (child.kind === "field" && child.alias === alias) return child;
983
+ const found = findDescendantByAlias(session, child.id, alias);
984
+ if (found) return found;
985
+ }
986
+ return null;
987
+ }
988
+
989
+ export function removeNodeByIdWithPrune(session: QuerySession, nodeId: string): boolean {
990
+ const node = getNodeById(session, nodeId);
991
+ if (!node) return false;
992
+ const parentToPrune = node.parentId;
993
+ removeNodeRecursive(session, nodeId);
994
+ pruneEmptyAncestors(session, parentToPrune);
995
+ return true;
996
+ }
997
+
998
+ export function addVariable(
999
+ session: QuerySession,
1000
+ name: string,
1001
+ type: string,
1002
+ defaultValue?: string,
1003
+ ): void {
1004
+ const cleanName = normalizeVariableName(name);
1005
+ const existing = session.variables.find((variable) => variable.name === cleanName);
1006
+ if (existing) {
1007
+ existing.type = type;
1008
+ existing.defaultValue = defaultValue;
1009
+ return;
1010
+ }
1011
+ session.variables.push({ name: cleanName, type, defaultValue });
1012
+ }
1013
+
1014
+ export function setVariableRuntimeValue(
1015
+ session: QuerySession,
1016
+ name: string,
1017
+ runtimeValue: string,
1018
+ ): void {
1019
+ const cleanName = normalizeVariableName(name);
1020
+ const variable = session.variables.find((item) => item.name === cleanName);
1021
+ if (!variable) {
1022
+ throw new Error(`Variable "$${cleanName}" is not defined.`);
1023
+ }
1024
+ variable.runtimeValue = runtimeValue;
1025
+ }
1026
+
1027
+ export function setVariableDefault(
1028
+ session: QuerySession,
1029
+ name: string,
1030
+ defaultValue: string | undefined,
1031
+ ): void {
1032
+ const cleanName = normalizeVariableName(name);
1033
+ const variable = session.variables.find((item) => item.name === cleanName);
1034
+ if (!variable) {
1035
+ throw new Error(`Variable "$${cleanName}" is not defined.`);
1036
+ }
1037
+ variable.defaultValue = defaultValue;
1038
+ }
1039
+
1040
+ /**
1041
+ * Finds all arg paths where a variable is referenced.
1042
+ * Returns entries like `["Account", "where"] → "$filter"` for display.
1043
+ */
1044
+ export function findVariableReferences(
1045
+ session: QuerySession,
1046
+ varName: string,
1047
+ ): { fieldPath: string[]; argName: string; subPath: string[] }[] {
1048
+ const ref = `$${varName}`;
1049
+ const results: { fieldPath: string[]; argName: string; subPath: string[] }[] = [];
1050
+
1051
+ for (const node of session.nodes) {
1052
+ if (node.kind !== "field") continue;
1053
+ for (const [argName, raw] of Object.entries(node.args)) {
1054
+ if (raw === ref) {
1055
+ results.push({ fieldPath: node.schemaPath, argName, subPath: [] });
1056
+ continue;
1057
+ }
1058
+ if (raw.startsWith("{") || raw.startsWith("[")) {
1059
+ try {
1060
+ const parsed = JSON.parse(raw);
1061
+ findRefPaths(parsed, ref, []).forEach((subPath) => {
1062
+ results.push({ fieldPath: node.schemaPath, argName, subPath });
1063
+ });
1064
+ } catch {
1065
+ /* not JSON */
1066
+ }
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ return results;
1072
+ }
1073
+
1074
+ function findRefPaths(value: unknown, ref: string, currentPath: string[]): string[][] {
1075
+ if (value === ref) return [currentPath];
1076
+ if (Array.isArray(value)) {
1077
+ const paths: string[][] = [];
1078
+ for (let i = 0; i < value.length; i++) {
1079
+ paths.push(...findRefPaths(value[i], ref, [...currentPath, String(i)]));
1080
+ }
1081
+ return paths;
1082
+ }
1083
+ if (typeof value === "object" && value !== null) {
1084
+ const paths: string[][] = [];
1085
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
1086
+ paths.push(...findRefPaths(v, ref, [...currentPath, k]));
1087
+ }
1088
+ return paths;
1089
+ }
1090
+ return [];
1091
+ }
1092
+
1093
+ export function removeVariable(session: QuerySession, name: string): boolean {
1094
+ const cleanName = normalizeVariableName(name);
1095
+ const before = session.variables.length;
1096
+ session.variables = session.variables.filter((variable) => variable.name !== cleanName);
1097
+ if (session.variables.length === before) return false;
1098
+ purgeVariableReferences(session, cleanName);
1099
+ return true;
1100
+ }
1101
+
1102
+ /**
1103
+ * Removes all references to `$varName` from field arguments across the
1104
+ * entire projection tree. Handles both top-level arg values (`"$x"`) and
1105
+ * values nested inside JSON arg structures.
1106
+ */
1107
+ function purgeVariableReferences(session: QuerySession, varName: string): void {
1108
+ const ref = `$${varName}`;
1109
+ for (const node of session.nodes) {
1110
+ if (node.kind !== "field") continue;
1111
+ for (const argKey of Object.keys(node.args)) {
1112
+ const raw = node.args[argKey];
1113
+ if (raw === ref) {
1114
+ delete node.args[argKey];
1115
+ continue;
1116
+ }
1117
+ if (raw.startsWith("{") || raw.startsWith("[")) {
1118
+ try {
1119
+ const parsed = JSON.parse(raw);
1120
+ const cleaned = purgeVarFromValue(parsed, ref);
1121
+ if (cleaned === undefined) {
1122
+ delete node.args[argKey];
1123
+ } else {
1124
+ const updated = JSON.stringify(cleaned);
1125
+ if (updated !== raw) node.args[argKey] = updated;
1126
+ }
1127
+ } catch {
1128
+ /* not JSON, skip */
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ function purgeVarFromValue(value: unknown, ref: string): unknown {
1136
+ if (value === ref) return undefined;
1137
+ if (Array.isArray(value)) {
1138
+ const filtered = value
1139
+ .map((item) => purgeVarFromValue(item, ref))
1140
+ .filter((item) => item !== undefined);
1141
+ return filtered.length > 0 ? filtered : undefined;
1142
+ }
1143
+ if (typeof value === "object" && value !== null) {
1144
+ const obj = value as Record<string, unknown>;
1145
+ const result: Record<string, unknown> = {};
1146
+ let hasKeys = false;
1147
+ for (const [k, v] of Object.entries(obj)) {
1148
+ const cleaned = purgeVarFromValue(v, ref);
1149
+ if (cleaned !== undefined) {
1150
+ result[k] = cleaned;
1151
+ hasKeys = true;
1152
+ }
1153
+ }
1154
+ return hasKeys ? result : undefined;
1155
+ }
1156
+ return value;
1157
+ }
1158
+
1159
+ function parseVariableValue(raw: string): unknown {
1160
+ const trimmed = raw.trim();
1161
+ if (trimmed === "null") return null;
1162
+ if (trimmed === "true") return true;
1163
+ if (trimmed === "false") return false;
1164
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
1165
+ if (
1166
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
1167
+ (trimmed.startsWith("[") && trimmed.endsWith("]")) ||
1168
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
1169
+ ) {
1170
+ try {
1171
+ return JSON.parse(trimmed);
1172
+ } catch {
1173
+ return trimmed;
1174
+ }
1175
+ }
1176
+ return trimmed;
1177
+ }
1178
+
1179
+ /**
1180
+ * Builds the variables map for query execution.
1181
+ * Priority: ad-hoc overrides > session runtimeValue > defaultValue.
1182
+ */
1183
+ export function buildRuntimeVariables(
1184
+ session: QuerySession,
1185
+ overrides?: Record<string, string>,
1186
+ ): Record<string, unknown> {
1187
+ const variables: Record<string, unknown> = {};
1188
+ for (const variable of session.variables) {
1189
+ const override = overrides?.[variable.name];
1190
+ if (override !== undefined) {
1191
+ variables[variable.name] = parseVariableValue(override);
1192
+ continue;
1193
+ }
1194
+ if (variable.runtimeValue !== undefined) {
1195
+ variables[variable.name] = parseVariableValue(variable.runtimeValue);
1196
+ continue;
1197
+ }
1198
+ if (variable.defaultValue !== undefined) {
1199
+ variables[variable.name] = parseVariableValue(variable.defaultValue);
1200
+ }
1201
+ }
1202
+ return variables;
1203
+ }
1204
+
1205
+ function sessionPath(id: string): string {
1206
+ return path.join(getSessionsDir(), `${id}.json`);
1207
+ }
1208
+
1209
+ export function saveSession(session: QuerySession): void {
1210
+ fs.mkdirSync(getSessionsDir(), { recursive: true });
1211
+ fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2), "utf-8");
1212
+ }
1213
+
1214
+ function migrateSession(raw: Record<string, unknown>): QuerySession {
1215
+ const navPath = (raw.navigationPath as string[]) ?? [];
1216
+ // Auto-migrate old sessions: prepend "query" if the path doesn't start with "query" or "variables"
1217
+ const migratedPath =
1218
+ navPath.length > 0 && navPath[0] !== "query" && navPath[0] !== "variables"
1219
+ ? ["query", ...navPath]
1220
+ : navPath;
1221
+
1222
+ return {
1223
+ ...(raw as unknown as QuerySession),
1224
+ navigationPath: migratedPath,
1225
+ nodes: (raw.nodes as ProjectionNode[]) ?? [],
1226
+ variables: (raw.variables as VariableDefinition[]) ?? [],
1227
+ focusByPath: (raw.focusByPath as Record<string, string>) ?? {},
1228
+ undoStack: (raw.undoStack as string[]) ?? [],
1229
+ };
1230
+ }
1231
+
1232
+ export function loadSession(id: string): QuerySession {
1233
+ if (!fs.existsSync(sessionPath(id))) {
1234
+ const dir = getSessionsDir();
1235
+ if (fs.existsSync(dir)) {
1236
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
1237
+ for (const file of files) {
1238
+ try {
1239
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
1240
+ if (raw.name === id) return migrateSession(raw);
1241
+ } catch {
1242
+ /* skip */
1243
+ }
1244
+ }
1245
+ }
1246
+ throw new Error(
1247
+ `Session "${id}" not found. Create one with \`graphiti query new <org-alias>\`.`,
1248
+ );
1249
+ }
1250
+ const raw = JSON.parse(fs.readFileSync(sessionPath(id), "utf-8"));
1251
+ return migrateSession(raw);
1252
+ }
1253
+
1254
+ export function listSessions(): {
1255
+ id: string;
1256
+ name?: string;
1257
+ orgAlias: string;
1258
+ operation: string;
1259
+ createdAt: string;
1260
+ }[] {
1261
+ const dir = getSessionsDir();
1262
+ if (!fs.existsSync(dir)) return [];
1263
+ return fs
1264
+ .readdirSync(dir)
1265
+ .filter((fileName) => fileName.endsWith(".json"))
1266
+ .map((fileName) => {
1267
+ try {
1268
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, fileName), "utf-8"));
1269
+ return {
1270
+ id: raw.id,
1271
+ name: raw.name,
1272
+ orgAlias: raw.orgAlias,
1273
+ operation: raw.operation,
1274
+ createdAt: raw.createdAt,
1275
+ };
1276
+ } catch {
1277
+ return null;
1278
+ }
1279
+ })
1280
+ .filter((session): session is NonNullable<typeof session> => session !== null);
1281
+ }
1282
+
1283
+ export function deleteSession(id: string): boolean {
1284
+ const filePath = sessionPath(id);
1285
+ if (fs.existsSync(filePath)) {
1286
+ fs.unlinkSync(filePath);
1287
+ return true;
1288
+ }
1289
+ const dir = getSessionsDir();
1290
+ if (fs.existsSync(dir)) {
1291
+ for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".json"))) {
1292
+ try {
1293
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
1294
+ if (raw.name === id) {
1295
+ fs.unlinkSync(path.join(dir, file));
1296
+ return true;
1297
+ }
1298
+ } catch {
1299
+ /* skip */
1300
+ }
1301
+ }
1302
+ }
1303
+ return false;
1304
+ }