/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import crypto from "crypto"; import fs from "fs"; import path from "path"; import { graphitiHome } from "./fs-utils.js"; // Evaluated lazily so that GRAPHITI_SESSIONS_DIR (or GRAPHITI_HOME) set in // tests takes effect even when the module is imported before the env var // is assigned. function getSessionsDir(): string { return process.env.GRAPHITI_SESSIONS_DIR ?? path.join(graphitiHome(), "sessions"); } export interface DirectiveNode { name: string; args: Record; } export interface VariableDefinition { name: string; type: string; defaultValue?: string; runtimeValue?: string; } export interface BaseProjectionNode { id: string; kind: "field" | "fragment"; parentId: string | null; schemaPath: string[]; directives: DirectiveNode[]; } export interface FieldProjectionNode extends BaseProjectionNode { kind: "field"; fieldName: string; alias?: string; args: Record; } export interface FragmentProjectionNode extends BaseProjectionNode { kind: "fragment"; onType: string; } export type ProjectionNode = FieldProjectionNode | FragmentProjectionNode; export type OperationType = "query" | "mutation" | "aggregate"; export interface QuerySession { id: string; name?: string; /** GraphQL operation name emitted by `renderQuery`. Distinct from `name`, which is the session's user-facing label. */ operationName?: string; orgAlias: string; /** Resolved Salesforce instance URL stored at session creation to avoid repeated sf-auth calls. */ instanceUrl?: string; operation: OperationType; navigationPath: string[]; nodes: ProjectionNode[]; variables: VariableDefinition[]; focusByPath: Record; createdAt: string; undoStack?: string[]; } const UNDO_STACK_LIMIT = 20; /** * Snapshots the current session state (excluding undoStack) and pushes it * onto the undo stack. Call this before any state-mutating operation. */ export function pushUndoSnapshot(session: QuerySession): void { if (!session.undoStack) session.undoStack = []; const { undoStack, ...rest } = session; session.undoStack.push(JSON.stringify(rest)); if (session.undoStack.length > UNDO_STACK_LIMIT) { session.undoStack.shift(); } } /** * Pops the most recent undo snapshot and returns a restored session. * The restored session inherits the current (truncated) undo stack. * Returns null if there is nothing to undo. */ export function popUndo(session: QuerySession): QuerySession | null { if (!session.undoStack || session.undoStack.length === 0) return null; const snapshot = session.undoStack.pop()!; const restored: QuerySession = JSON.parse(snapshot); restored.undoStack = session.undoStack; return restored; } export function isFragmentSegment(segment: string): boolean { return (segment.startsWith("[") && segment.endsWith("]")) || segment.startsWith("on:"); } /** * Normalizes a fragment segment to the canonical `[Type]` form. * Accepts `on:Type` as a shell-safe alternative that avoids zsh glob/redirect * issues with `[Type]` and ``. * * Examples: `on:User` → `[User]`, `[User]` stays as-is. */ export function normalizeFragmentSegment(segment: string): string { if (segment.startsWith("on:")) { return `[${segment.slice(3)}]`; } return segment; } /** Returns true for alias-encoded path segments like `openOpps(Opportunity)`. */ export function isAliasedSegment(segment: string): boolean { return /^[^[\](]+\([^()]+\)$/.test(segment); } /** * Parses an alias-encoded segment into its alias name and underlying field name. * Returns null for plain or fragment segments. */ export function parseAliasedSegment(segment: string): { alias: string; fieldName: string } | null { const m = segment.match(/^([^[\](]+)\(([^()]+)\)$/); if (!m) return null; return { alias: m[1], fieldName: m[2] }; } /** Strips the alias wrapper from a single path segment, leaving just the schema field name. */ export function schemaSegment(segment: string): string { return parseAliasedSegment(segment)?.fieldName ?? segment; } /** * Converts a navigation path (which may contain alias-encoded segments) to a * pure schema path suitable for schema resolution and focus-map lookups. */ export function toSchemaPath(navPath: string[]): string[] { return navPath.map(schemaSegment); } export function pathKey(pathSegments: string[]): string { return pathSegments.join("/"); } export function formatPath(pathSegments: string[]): string { if (pathSegments.length === 0) return "/"; return `/${pathSegments.join("/")}`; } function randomId(prefix: string): string { return `${prefix}_${crypto.randomBytes(4).toString("hex")}`; } function normalizeVariableName(name: string): string { return name.replace(/^\$/, ""); } export function createSession( orgAlias: string, operation: OperationType = "query", instanceUrl?: string, name?: string, ): QuerySession { return { id: `s_${crypto.randomBytes(2).toString("hex")}`, name, orgAlias, instanceUrl, operation, navigationPath: [], nodes: [], variables: [], focusByPath: {}, createdAt: new Date().toISOString(), }; } export function cloneSession(source: QuerySession, newName?: string): QuerySession { const cloned: QuerySession = JSON.parse(JSON.stringify(source)); cloned.id = `s_${crypto.randomBytes(2).toString("hex")}`; cloned.name = newName; cloned.createdAt = new Date().toISOString(); cloned.undoStack = []; return cloned; } export function getNodeById( session: QuerySession, id: string | null | undefined, ): ProjectionNode | null { if (!id) return null; return session.nodes.find((node) => node.id === id) ?? null; } export function getChildren(session: QuerySession, parentId: string | null): ProjectionNode[] { return session.nodes.filter((node) => node.parentId === parentId); } function clearFocusBelow(session: QuerySession, schemaPath: string[]): void { const prefix = pathKey(schemaPath); for (const key of Object.keys(session.focusByPath)) { if (key === prefix) continue; if (prefix === "" || key.startsWith(`${prefix}/`)) { delete session.focusByPath[key]; } } } function createFieldNode( parentId: string | null, schemaPath: string[], fieldName: string, alias?: string, ): FieldProjectionNode { return { id: randomId("fld"), kind: "field", parentId, schemaPath: [...schemaPath], fieldName, alias, args: {}, directives: [], }; } function createFragmentNode( parentId: string | null, schemaPath: string[], onType: string, ): FragmentProjectionNode { return { id: randomId("frag"), kind: "fragment", parentId, schemaPath: [...schemaPath], onType, directives: [], }; } function matchNodeToSegment(node: ProjectionNode, segment: string): boolean { if (node.kind === "fragment") { return segment === `[${node.onType}]`; } return node.fieldName === segment; } export function listInstancesAtPath(session: QuerySession, schemaPath: string[]): ProjectionNode[] { const parentPath = schemaPath.slice(0, -1); const parentNode = getFocusedNodeAtPath(session, parentPath, false); const parentId = parentNode?.id ?? null; const segment = schemaPath[schemaPath.length - 1]; if (!segment) return []; return getChildren(session, parentId).filter((node) => matchNodeToSegment(node, segment)); } function resolveNodeAtPathInternal( session: QuerySession, schemaPath: string[], createMissing: boolean, ): ProjectionNode | null { let parentId: string | null = null; let current: ProjectionNode | null = null; for (let i = 0; i < schemaPath.length; i += 1) { const currentPath = schemaPath.slice(0, i + 1); const currentKey = pathKey(currentPath); const segment = schemaPath[i]; const matching: ProjectionNode[] = getChildren(session, parentId).filter( (node: ProjectionNode) => matchNodeToSegment(node, segment), ); const focusedId = session.focusByPath[currentKey]; let next: ProjectionNode | null = matching.find((node: ProjectionNode) => node.id === focusedId) ?? null; if (!next && matching.length === 1) { next = matching[0]; } if (!next && matching.length > 1 && !createMissing) { throw new Error( `Path ${formatPath(currentPath)} has multiple projection instances. Use \`cd ()\` to navigate into one.`, ); } if (!next && createMissing) { next = isFragmentSegment(segment) ? createFragmentNode(parentId, currentPath, segment.slice(1, -1)) : createFieldNode(parentId, currentPath, segment); session.nodes.push(next); } if (!next) return null; session.focusByPath[currentKey] = next.id; parentId = next.id; current = next; } return current; } export function getFocusedNodeAtPath( session: QuerySession, schemaPath: string[], createMissing = false, ): ProjectionNode | null { if (schemaPath.length === 0) return null; return resolveNodeAtPathInternal(session, schemaPath, createMissing); } export function ensureFocusedChain( session: QuerySession, schemaPath: string[], ): ProjectionNode | null { return getFocusedNodeAtPath(session, schemaPath, true); } /** * Walks the query portion of the navigation path and syncs focusByPath entries * for any aliased segments (e.g. `recentAccounts(Account)`). This ensures that * subsequent select/assign operations target the correct aliased instance. */ export function syncFocusFromNavigationPath(session: QuerySession): void { const ctx = getNavigationContext(session.navigationPath); if (ctx !== "query") return; const queryIdx = 0; let parentId: string | null = null; for (let i = queryIdx + 1; i < session.navigationPath.length; i++) { const seg = session.navigationPath[i]; if (isArgsSegment(seg)) break; const schemaPath = toSchemaPath(session.navigationPath.slice(queryIdx + 1, i + 1)); const key = pathKey(schemaPath); const aliased = parseAliasedSegment(seg); if (aliased) { const aliasMatches: FieldProjectionNode[] = getChildren(session, parentId).filter( (n): n is FieldProjectionNode => n.kind === "field" && n.fieldName === aliased.fieldName && n.alias === aliased.alias, ); if (aliasMatches.length === 1) { session.focusByPath[key] = aliasMatches[0].id; parentId = aliasMatches[0].id; continue; } } const focusedId = session.focusByPath[key]; const fieldName = aliased ? aliased.fieldName : seg; const fieldMatches: FieldProjectionNode[] = getChildren(session, parentId).filter( (n): n is FieldProjectionNode => n.kind === "field" && n.fieldName === fieldName, ); const focusedMatch = fieldMatches.find((n: FieldProjectionNode) => n.id === focusedId); parentId = focusedMatch?.id ?? fieldMatches[0]?.id ?? null; } } export function focusNodeAtPath(session: QuerySession, schemaPath: string[], nodeId: string): void { const node = getNodeById(session, nodeId); if (!node) throw new Error(`Projection instance "${nodeId}" not found.`); if (pathKey(node.schemaPath) !== pathKey(schemaPath)) { throw new Error(`Projection instance "${nodeId}" is not at ${formatPath(schemaPath)}.`); } session.focusByPath[pathKey(schemaPath)] = nodeId; clearFocusBelow(session, schemaPath); } export function createSiblingFieldInstance( session: QuerySession, schemaPath: string[], alias?: string, ): FieldProjectionNode { if (schemaPath.length === 0) { throw new Error("Cannot create a projection instance at the root path."); } const parentPath = schemaPath.slice(0, -1); const parentNode = ensureFocusedChain(session, parentPath); const parentId = parentNode?.id ?? null; const fieldName = schemaPath[schemaPath.length - 1]; if (isFragmentSegment(fieldName)) { throw new Error("Use fragment navigation to create fragment projections."); } const node = createFieldNode(parentId, schemaPath, fieldName, alias); session.nodes.push(node); focusNodeAtPath(session, schemaPath, node.id); return node; } export function setAliasOnPath( session: QuerySession, schemaPath: string[], aliasName: string, ): FieldProjectionNode { const node = getFocusedNodeAtPath(session, schemaPath, true); if (!node || node.kind !== "field") { throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`); } node.alias = aliasName; focusNodeAtPath(session, schemaPath, node.id); return node; } export function clearAliasOnPath(session: QuerySession, schemaPath: string[]): void { const node = getFocusedNodeAtPath(session, schemaPath, false); if (!node || node.kind !== "field") { throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`); } delete node.alias; } export function focusInstanceByAliasOrId( session: QuerySession, schemaPath: string[], selector: string, ): ProjectionNode { const matches = listInstancesAtPath(session, schemaPath).filter((node) => { if (node.id === selector) return true; return node.kind === "field" && node.alias === selector; }); if (matches.length === 0) { throw new Error( `No projection instance matching "${selector}" exists at ${formatPath(schemaPath)}.`, ); } if (matches.length > 1) { throw new Error( `Multiple projection instances match "${selector}" at ${formatPath(schemaPath)}.`, ); } focusNodeAtPath(session, schemaPath, matches[0].id); return matches[0]; } export function setArg( session: QuerySession, schemaPath: string[], argName: string, value: string, ): void { const node = getFocusedNodeAtPath(session, schemaPath, true); if (!node || node.kind !== "field") { throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`); } node.args[argName] = value; } export function getArg( session: QuerySession, schemaPath: string[], argName: string, ): string | undefined { const node = getFocusedNodeAtPath(session, schemaPath, false); if (!node || node.kind !== "field") return undefined; return node.args[argName]; } export function removeArg(session: QuerySession, schemaPath: string[], argName: string): boolean { const node = getFocusedNodeAtPath(session, schemaPath, false); if (!node || node.kind !== "field") return false; if (!(argName in node.args)) return false; delete node.args[argName]; return true; } export function getEffectiveArgs( _session: QuerySession, node: ProjectionNode, ): Record { if (node.kind !== "field") return {}; return { ...node.args }; } // ── Navigation context helpers ─────────────────────────────────────────────── export type NavigationContext = "root" | "query" | "variables"; export function getNavigationContext(navPath: string[]): NavigationContext { if (navPath.length === 0) return "root"; if (navPath[0] === "variables") return "variables"; return "query"; } export const ARGS_SEGMENT = "@args"; export function isArgsSegment(segment: string): boolean { return segment === ARGS_SEGMENT; } /** Returns the index of the @args segment in the path, or -1 if not present. */ export function argsSegmentIndex(navPath: string[]): number { return navPath.indexOf(ARGS_SEGMENT); } /** Returns true if the navigation path is inside an @args context. */ export function isInArgsContext(navPath: string[]): boolean { return argsSegmentIndex(navPath) !== -1; } /** * Extracts the schema path of the field whose args are being navigated. * E.g. ["query", "uiapi", "query", "Account", "@args", "where", "Name"] * → ["uiapi", "query", "Account"] */ export function getArgsFieldPath(navPath: string[]): string[] { const idx = argsSegmentIndex(navPath); if (idx === -1) return []; const queryPath = navPath.slice(1, idx); return toSchemaPath(queryPath); } /** * Extracts the input sub-path after @args. * E.g. ["query", "uiapi", "query", "Account", "@args", "where", "Name"] * → ["where", "Name"] */ export function getInputSubPath(navPath: string[]): string[] { const idx = argsSegmentIndex(navPath); if (idx === -1) return []; return navPath.slice(idx + 1); } /** * For paths in the /query context, strips the "query" prefix and alias wrappers * to produce a pure schema path. Stops at @args if present. * E.g. ["query", "uiapi", "query", "Account"] → ["uiapi", "query", "Account"] */ export function queryNavToSchemaPath(navPath: string[]): string[] { const ctx = getNavigationContext(navPath); if (ctx !== "query") return []; const argsIdx = argsSegmentIndex(navPath); const end = argsIdx !== -1 ? argsIdx : navPath.length; return toSchemaPath(navPath.slice(1, end)); } /** * For paths in /variables, extracts the variable name (without $) and the * sub-path into its input type. */ export function parseVariablePath( navPath: string[], ): { varName: string; inputSubPath: string[] } | null { if (getNavigationContext(navPath) !== "variables") return null; if (navPath.length < 2) return null; const varName = normalizeVariableName(navPath[1]); return { varName, inputSubPath: navPath.slice(2) }; } // ── Deep-set helpers for incremental arg/variable assignment ───────────────── function deepSet( obj: Record, pathSegments: string[], value: unknown, ): Record { if (pathSegments.length === 0) return obj; const result = { ...obj }; let current: Record = result; for (let i = 0; i < pathSegments.length - 1; i++) { const seg = pathSegments[i]; const nextSeg = pathSegments[i + 1]; const nextIsIndex = nextSeg !== undefined && /^\d+$/.test(nextSeg); if (/^\d+$/.test(seg)) { const idx = Number(seg); if (!Array.isArray(current)) { throw new Error(`Expected array at path segment "${seg}"`); } const arr = current as unknown as unknown[]; while (arr.length <= idx) arr.push({}); if (typeof arr[idx] !== "object" || arr[idx] === null) arr[idx] = {}; current = arr[idx] as Record; } else { if (current[seg] === undefined || current[seg] === null || typeof current[seg] !== "object") { current[seg] = nextIsIndex ? [] : {}; } else if (nextIsIndex && !Array.isArray(current[seg])) { current[seg] = []; } else if (!Array.isArray(current[seg])) { current[seg] = { ...(current[seg] as Record) }; } current = current[seg] as Record; } } const lastSeg = pathSegments[pathSegments.length - 1]; if (/^\d+$/.test(lastSeg)) { const idx = Number(lastSeg); if (!Array.isArray(current)) { throw new Error(`Expected array at path segment "${lastSeg}"`); } (current as unknown as unknown[])[idx] = value; } else { current[lastSeg] = value; } return result; } function deepGet(obj: Record, pathSegments: string[]): unknown { let current: unknown = obj; for (const seg of pathSegments) { if (current === null || current === undefined) return undefined; if (/^\d+$/.test(seg)) { if (!Array.isArray(current)) return undefined; current = (current as unknown[])[Number(seg)]; } else { if (typeof current !== "object") return undefined; current = (current as Record)[seg]; } } return current; } function deepDelete(obj: Record, pathSegments: string[]): Record { if (pathSegments.length === 0) return obj; if (pathSegments.length === 1) { const result = { ...obj }; delete result[pathSegments[0]]; return result; } const [head, ...rest] = pathSegments; const child = obj[head]; if (typeof child !== "object" || child === null) return obj; const result = { ...obj }; result[head] = deepDelete(child as Record, rest); return result; } function parseArgValue(raw: string): unknown { const trimmed = raw.trim(); if (trimmed === "null") return null; if (trimmed === "true") return true; if (trimmed === "false") return false; if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); if ( (trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")) ) { try { return JSON.parse(trimmed); } catch { /* fall through */ } } if (trimmed.startsWith('"') && trimmed.endsWith('"')) { try { return JSON.parse(trimmed); } catch { /* fall through */ } } return trimmed; } /** * Incrementally sets a value deep inside a field argument's JSON structure. * If inputPath is empty, sets the top-level arg directly. * E.g. deepSetArg(session, path, "where", ["Name", "like"], '"Acme%"') * results in node.args["where"] = '{"Name":{"like":"Acme%"}}' */ export function deepSetArg( session: QuerySession, schemaPath: string[], argName: string, inputPath: string[], value: string, ): void { const node = getFocusedNodeAtPath(session, schemaPath, true); if (!node || node.kind !== "field") { throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`); } if (inputPath.length === 0) { node.args[argName] = value; return; } const existing = node.args[argName]; let obj: Record = {}; if (existing) { try { obj = JSON.parse(existing) as Record; } catch { obj = {}; } } const parsedValue = value.startsWith("$") ? value : parseArgValue(value); obj = deepSet(obj, inputPath, parsedValue); node.args[argName] = JSON.stringify(obj); } /** * Reads the current value at a nested path inside a field argument. */ export function deepGetArg( session: QuerySession, schemaPath: string[], argName: string, inputPath: string[], ): unknown { const node = getFocusedNodeAtPath(session, schemaPath, false); if (!node || node.kind !== "field") return undefined; const raw = node.args[argName]; if (raw === undefined) return undefined; if (inputPath.length === 0) return raw; try { const obj = JSON.parse(raw); return deepGet(obj, inputPath); } catch { return undefined; } } /** * Removes a value at a nested path inside a field argument. */ export function deepRemoveArg( session: QuerySession, schemaPath: string[], argName: string, inputPath: string[], ): boolean { const node = getFocusedNodeAtPath(session, schemaPath, false); if (!node || node.kind !== "field") return false; if (inputPath.length === 0) { if (!(argName in node.args)) return false; delete node.args[argName]; return true; } const raw = node.args[argName]; if (!raw) return false; try { let obj = JSON.parse(raw) as Record; obj = deepDelete(obj, inputPath); if (Object.keys(obj).length === 0) { delete node.args[argName]; } else { node.args[argName] = JSON.stringify(obj); } return true; } catch { return false; } } /** * Incrementally sets a value deep inside a variable's runtime value. * If inputPath is empty, sets the runtime value directly. */ export function deepSetVariableValue( session: QuerySession, varName: string, inputPath: string[], value: string, ): void { const cleanName = normalizeVariableName(varName); const variable = session.variables.find((v) => v.name === cleanName); if (!variable) throw new Error(`Variable "$${cleanName}" is not defined.`); if (inputPath.length === 0) { variable.runtimeValue = value; return; } let obj: Record = {}; if (variable.runtimeValue) { try { obj = JSON.parse(variable.runtimeValue) as Record; } catch { obj = {}; } } const parsedValue = parseArgValue(value); obj = deepSet(obj, inputPath, parsedValue); variable.runtimeValue = JSON.stringify(obj); } /** * Appends a new empty object to a list-type arg array. Returns the new index. */ export function appendListElement( session: QuerySession, schemaPath: string[], argName: string, inputPath: string[], ): number { const node = getFocusedNodeAtPath(session, schemaPath, true); if (!node || node.kind !== "field") { throw new Error(`No field projection exists at ${formatPath(schemaPath)}.`); } if (inputPath.length === 0) { const raw = node.args[argName]; let arr: unknown[] = []; if (raw) { try { arr = JSON.parse(raw); } catch { arr = []; } if (!Array.isArray(arr)) arr = []; } arr.push({}); node.args[argName] = JSON.stringify(arr); return arr.length - 1; } const raw = node.args[argName]; let obj: Record = {}; if (raw) { try { obj = JSON.parse(raw) as Record; } catch { obj = {}; } } let target = deepGet(obj, inputPath); if (!Array.isArray(target)) target = []; const arr = target as unknown[]; arr.push({}); obj = deepSet(obj, inputPath, arr); node.args[argName] = JSON.stringify(obj); return arr.length - 1; } /** * Removes an element from a list-type arg by index and compacts remaining indices. */ export function removeListElement( session: QuerySession, schemaPath: string[], argName: string, inputPath: string[], index: number, ): boolean { const node = getFocusedNodeAtPath(session, schemaPath, false); if (!node || node.kind !== "field") return false; const raw = node.args[argName]; if (!raw) return false; try { let obj: Record | unknown[] = JSON.parse(raw); if (inputPath.length === 0) { if (!Array.isArray(obj)) return false; if (index < 0 || index >= obj.length) return false; obj.splice(index, 1); if (obj.length === 0) { delete node.args[argName]; } else { node.args[argName] = JSON.stringify(obj); } return true; } const parent = deepGet(obj as Record, inputPath); if (!Array.isArray(parent)) return false; if (index < 0 || index >= parent.length) return false; parent.splice(index, 1); if (parent.length === 0) { obj = deepDelete(obj as Record, inputPath); } else { obj = deepSet(obj as Record, inputPath, parent); } node.args[argName] = JSON.stringify(obj); return true; } catch { return false; } } export function selectLeaf( session: QuerySession, schemaPath: string[], alias?: string, ): FieldProjectionNode { if (schemaPath.length === 0) { throw new Error("Cannot select the root path."); } const parentPath = schemaPath.slice(0, -1); const parentNode = ensureFocusedChain(session, parentPath); const parentId = parentNode?.id ?? null; const fieldName = schemaPath[schemaPath.length - 1]; if (isFragmentSegment(fieldName)) { throw new Error("Cannot select a fragment directory as a leaf."); } const siblings = getChildren(session, parentId).filter( (node) => node.kind === "field" && node.fieldName === fieldName, ) as FieldProjectionNode[]; const existing = siblings.find((node) => node.alias === alias); if (existing) { focusNodeAtPath(session, schemaPath, existing.id); return existing; } const node = createFieldNode(parentId, schemaPath, fieldName, alias); session.nodes.push(node); session.focusByPath[pathKey(schemaPath)] = node.id; return node; } function removeNodeRecursive(session: QuerySession, nodeId: string): void { const children = getChildren(session, nodeId); for (const child of children) { removeNodeRecursive(session, child.id); } session.nodes = session.nodes.filter((node) => node.id !== nodeId); for (const [key, focusedId] of Object.entries(session.focusByPath)) { if (focusedId === nodeId) { delete session.focusByPath[key]; } } } function pruneEmptyAncestors(session: QuerySession, startParentId: string | null): void { let currentId = startParentId; while (currentId) { const current = getNodeById(session, currentId); if (!current) return; if (getChildren(session, current.id).length > 0) return; const parentId = current.parentId; removeNodeRecursive(session, current.id); currentId = parentId; } } export function removeSelectionAtPath( session: QuerySession, schemaPath: string[], selector?: string, ): boolean { const parentPath = schemaPath.slice(0, -1); const parentNode = getFocusedNodeAtPath(session, parentPath, false); const parentId = parentNode?.id ?? null; const segment = schemaPath[schemaPath.length - 1]; if (!segment) return false; const matches = getChildren(session, parentId).filter((node) => { if (!matchNodeToSegment(node, segment)) return false; if (!selector) return true; if (node.id === selector) return true; return node.kind === "field" && node.alias === selector; }); if (matches.length === 0) return false; if (matches.length > 1) { throw new Error( `Multiple projection instances match ${formatPath(schemaPath)}. Use an alias or instance id to remove one.`, ); } const target = matches[0]; const parentToPrune = target.parentId; removeNodeRecursive(session, target.id); pruneEmptyAncestors(session, parentToPrune); return true; } export function findDescendantByAlias( session: QuerySession, parentId: string | null, alias: string, ): FieldProjectionNode | null { const children = getChildren(session, parentId); for (const child of children) { if (child.kind === "field" && child.alias === alias) return child; const found = findDescendantByAlias(session, child.id, alias); if (found) return found; } return null; } export function removeNodeByIdWithPrune(session: QuerySession, nodeId: string): boolean { const node = getNodeById(session, nodeId); if (!node) return false; const parentToPrune = node.parentId; removeNodeRecursive(session, nodeId); pruneEmptyAncestors(session, parentToPrune); return true; } /** * A type-conflicting `$var` redeclaration detected by {@link addVariable}. * The first-declared type is kept (first-wins); the later inference is ignored. */ export interface VariableTypeCollision { name: string; existingType: string; ignoredType: string; } export function addVariable( session: QuerySession, name: string, type: string, defaultValue?: string, ): VariableTypeCollision | undefined { const cleanName = normalizeVariableName(name); const existing = session.variables.find((variable) => variable.name === cleanName); if (existing) { // First-wins: a variable referenced more than once (e.g. across two filter // leaves) keeps its first-declared type. Last-wins would silently overwrite // it and render a query UIAPI rejects at runtime. Report the conflict so a // caller with a warnings sink can surface it. if (existing.type !== type) { return { name: cleanName, existingType: existing.type, ignoredType: type }; } existing.defaultValue = defaultValue; return undefined; } session.variables.push({ name: cleanName, type, defaultValue }); return undefined; } export function setVariableRuntimeValue( session: QuerySession, name: string, runtimeValue: string, ): void { const cleanName = normalizeVariableName(name); const variable = session.variables.find((item) => item.name === cleanName); if (!variable) { throw new Error(`Variable "$${cleanName}" is not defined.`); } variable.runtimeValue = runtimeValue; } export function setVariableDefault( session: QuerySession, name: string, defaultValue: string | undefined, ): void { const cleanName = normalizeVariableName(name); const variable = session.variables.find((item) => item.name === cleanName); if (!variable) { throw new Error(`Variable "$${cleanName}" is not defined.`); } variable.defaultValue = defaultValue; } /** * Finds all arg paths where a variable is referenced. * Returns entries like `["Account", "where"] → "$filter"` for display. */ export function findVariableReferences( session: QuerySession, varName: string, ): { fieldPath: string[]; argName: string; subPath: string[] }[] { const ref = `$${varName}`; const results: { fieldPath: string[]; argName: string; subPath: string[] }[] = []; for (const node of session.nodes) { if (node.kind !== "field") continue; for (const [argName, raw] of Object.entries(node.args)) { if (raw === ref) { results.push({ fieldPath: node.schemaPath, argName, subPath: [] }); continue; } if (raw.startsWith("{") || raw.startsWith("[")) { try { const parsed = JSON.parse(raw); findRefPaths(parsed, ref, []).forEach((subPath) => { results.push({ fieldPath: node.schemaPath, argName, subPath }); }); } catch { /* not JSON */ } } } } return results; } function findRefPaths(value: unknown, ref: string, currentPath: string[]): string[][] { if (value === ref) return [currentPath]; if (Array.isArray(value)) { const paths: string[][] = []; for (let i = 0; i < value.length; i++) { paths.push(...findRefPaths(value[i], ref, [...currentPath, String(i)])); } return paths; } if (typeof value === "object" && value !== null) { const paths: string[][] = []; for (const [k, v] of Object.entries(value as Record)) { paths.push(...findRefPaths(v, ref, [...currentPath, k])); } return paths; } return []; } export function removeVariable(session: QuerySession, name: string): boolean { const cleanName = normalizeVariableName(name); const before = session.variables.length; session.variables = session.variables.filter((variable) => variable.name !== cleanName); if (session.variables.length === before) return false; purgeVariableReferences(session, cleanName); return true; } /** * Removes all references to `$varName` from field arguments across the * entire projection tree. Handles both top-level arg values (`"$x"`) and * values nested inside JSON arg structures. */ function purgeVariableReferences(session: QuerySession, varName: string): void { const ref = `$${varName}`; for (const node of session.nodes) { if (node.kind !== "field") continue; for (const argKey of Object.keys(node.args)) { const raw = node.args[argKey]; if (raw === ref) { delete node.args[argKey]; continue; } if (raw.startsWith("{") || raw.startsWith("[")) { try { const parsed = JSON.parse(raw); const cleaned = purgeVarFromValue(parsed, ref); if (cleaned === undefined) { delete node.args[argKey]; } else { const updated = JSON.stringify(cleaned); if (updated !== raw) node.args[argKey] = updated; } } catch { /* not JSON, skip */ } } } } } function purgeVarFromValue(value: unknown, ref: string): unknown { if (value === ref) return undefined; if (Array.isArray(value)) { const filtered = value .map((item) => purgeVarFromValue(item, ref)) .filter((item) => item !== undefined); return filtered.length > 0 ? filtered : undefined; } if (typeof value === "object" && value !== null) { const obj = value as Record; const result: Record = {}; let hasKeys = false; for (const [k, v] of Object.entries(obj)) { const cleaned = purgeVarFromValue(v, ref); if (cleaned !== undefined) { result[k] = cleaned; hasKeys = true; } } return hasKeys ? result : undefined; } return value; } function parseVariableValue(raw: string): unknown { const trimmed = raw.trim(); if (trimmed === "null") return null; if (trimmed === "true") return true; if (trimmed === "false") return false; if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); if ( (trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith('"') && trimmed.endsWith('"')) ) { try { return JSON.parse(trimmed); } catch { return trimmed; } } return trimmed; } /** * Builds the variables map for query execution. * Priority: ad-hoc overrides > session runtimeValue > defaultValue. */ export function buildRuntimeVariables( session: QuerySession, overrides?: Record, ): Record { const variables: Record = {}; for (const variable of session.variables) { const override = overrides?.[variable.name]; if (override !== undefined) { variables[variable.name] = parseVariableValue(override); continue; } if (variable.runtimeValue !== undefined) { variables[variable.name] = parseVariableValue(variable.runtimeValue); continue; } if (variable.defaultValue !== undefined) { variables[variable.name] = parseVariableValue(variable.defaultValue); } } return variables; } function sessionPath(id: string): string { return path.join(getSessionsDir(), `${id}.json`); } export function saveSession(session: QuerySession): void { fs.mkdirSync(getSessionsDir(), { recursive: true }); fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2), "utf-8"); } function migrateSession(raw: Record): QuerySession { const navPath = (raw.navigationPath as string[]) ?? []; // Auto-migrate old sessions: prepend "query" if the path doesn't start with "query" or "variables" const migratedPath = navPath.length > 0 && navPath[0] !== "query" && navPath[0] !== "variables" ? ["query", ...navPath] : navPath; return { ...(raw as unknown as QuerySession), navigationPath: migratedPath, nodes: (raw.nodes as ProjectionNode[]) ?? [], variables: (raw.variables as VariableDefinition[]) ?? [], focusByPath: (raw.focusByPath as Record) ?? {}, undoStack: (raw.undoStack as string[]) ?? [], }; } export function loadSession(id: string): QuerySession { if (!fs.existsSync(sessionPath(id))) { const dir = getSessionsDir(); if (fs.existsSync(dir)) { const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); for (const file of files) { try { const raw = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")); if (raw.name === id) return migrateSession(raw); } catch { /* skip */ } } } throw new Error( `Session "${id}" not found. Create one with \`graphiti query new \`.`, ); } const raw = JSON.parse(fs.readFileSync(sessionPath(id), "utf-8")); return migrateSession(raw); } export function listSessions(): { id: string; name?: string; orgAlias: string; operation: string; createdAt: string; }[] { const dir = getSessionsDir(); if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir) .filter((fileName) => fileName.endsWith(".json")) .map((fileName) => { try { const raw = JSON.parse(fs.readFileSync(path.join(dir, fileName), "utf-8")); return { id: raw.id, name: raw.name, orgAlias: raw.orgAlias, operation: raw.operation, createdAt: raw.createdAt, }; } catch { return null; } }) .filter((session): session is NonNullable => session !== null); } export function deleteSession(id: string): boolean { const filePath = sessionPath(id); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); return true; } const dir = getSessionsDir(); if (fs.existsSync(dir)) { for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".json"))) { try { const raw = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")); if (raw.name === id) { fs.unlinkSync(path.join(dir, file)); return true; } } catch { /* skip */ } } } return false; }