coalesce-transform-mcp 0.2.3 → 0.4.1

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 (320) hide show
  1. package/README.md +84 -2
  2. package/dist/client.d.ts +1 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +27 -3
  5. package/dist/client.js.map +1 -1
  6. package/dist/coalesce/api/environments.d.ts +0 -12
  7. package/dist/coalesce/api/environments.d.ts.map +1 -1
  8. package/dist/coalesce/api/environments.js +0 -4
  9. package/dist/coalesce/api/environments.js.map +1 -1
  10. package/dist/coalesce/api/jobs.d.ts +3 -5
  11. package/dist/coalesce/api/jobs.d.ts.map +1 -1
  12. package/dist/coalesce/api/jobs.js +3 -6
  13. package/dist/coalesce/api/jobs.js.map +1 -1
  14. package/dist/coalesce/api/nodes.d.ts +3 -3
  15. package/dist/coalesce/api/nodes.d.ts.map +1 -1
  16. package/dist/coalesce/api/nodes.js +6 -4
  17. package/dist/coalesce/api/nodes.js.map +1 -1
  18. package/dist/coalesce/api/runs.d.ts.map +1 -1
  19. package/dist/coalesce/api/runs.js +11 -1
  20. package/dist/coalesce/api/runs.js.map +1 -1
  21. package/dist/coalesce/api/scan.d.ts +14 -0
  22. package/dist/coalesce/api/scan.d.ts.map +1 -0
  23. package/dist/coalesce/api/scan.js +64 -0
  24. package/dist/coalesce/api/scan.js.map +1 -0
  25. package/dist/coalesce/api/subgraphs.d.ts +3 -2
  26. package/dist/coalesce/api/subgraphs.d.ts.map +1 -1
  27. package/dist/coalesce/api/subgraphs.js +3 -2
  28. package/dist/coalesce/api/subgraphs.js.map +1 -1
  29. package/dist/coalesce/run-schemas.d.ts.map +1 -1
  30. package/dist/coalesce/run-schemas.js +26 -16
  31. package/dist/coalesce/run-schemas.js.map +1 -1
  32. package/dist/coalesce/tool-response.d.ts +1 -13
  33. package/dist/coalesce/tool-response.d.ts.map +1 -1
  34. package/dist/coalesce/tool-response.js +20 -6
  35. package/dist/coalesce/tool-response.js.map +1 -1
  36. package/dist/coalesce/tool-schemas.d.ts +1 -2
  37. package/dist/coalesce/tool-schemas.d.ts.map +1 -1
  38. package/dist/coalesce/tool-schemas.js +368 -5
  39. package/dist/coalesce/tool-schemas.js.map +1 -1
  40. package/dist/coalesce/types.d.ts +8 -0
  41. package/dist/coalesce/types.d.ts.map +1 -1
  42. package/dist/coalesce/types.js +8 -3
  43. package/dist/coalesce/types.js.map +1 -1
  44. package/dist/constants.d.ts +18 -0
  45. package/dist/constants.d.ts.map +1 -0
  46. package/dist/constants.js +21 -0
  47. package/dist/constants.js.map +1 -0
  48. package/dist/mcp/cache.d.ts +2 -1
  49. package/dist/mcp/cache.d.ts.map +1 -1
  50. package/dist/mcp/cache.js +122 -138
  51. package/dist/mcp/cache.js.map +1 -1
  52. package/dist/mcp/environments.d.ts +2 -1
  53. package/dist/mcp/environments.d.ts.map +1 -1
  54. package/dist/mcp/environments.js +56 -112
  55. package/dist/mcp/environments.js.map +1 -1
  56. package/dist/mcp/git-accounts.d.ts +2 -1
  57. package/dist/mcp/git-accounts.d.ts.map +1 -1
  58. package/dist/mcp/git-accounts.js +74 -96
  59. package/dist/mcp/git-accounts.js.map +1 -1
  60. package/dist/mcp/jobs.d.ts +2 -1
  61. package/dist/mcp/jobs.d.ts.map +1 -1
  62. package/dist/mcp/jobs.js +68 -122
  63. package/dist/mcp/jobs.js.map +1 -1
  64. package/dist/mcp/lineage.d.ts +5 -0
  65. package/dist/mcp/lineage.d.ts.map +1 -0
  66. package/dist/mcp/lineage.js +410 -0
  67. package/dist/mcp/lineage.js.map +1 -0
  68. package/dist/mcp/node-type-corpus.d.ts +2 -1
  69. package/dist/mcp/node-type-corpus.d.ts.map +1 -1
  70. package/dist/mcp/node-type-corpus.js +148 -151
  71. package/dist/mcp/node-type-corpus.js.map +1 -1
  72. package/dist/mcp/nodes.d.ts +2 -1
  73. package/dist/mcp/nodes.d.ts.map +1 -1
  74. package/dist/mcp/nodes.js +358 -464
  75. package/dist/mcp/nodes.js.map +1 -1
  76. package/dist/mcp/pipelines.d.ts +2 -1
  77. package/dist/mcp/pipelines.d.ts.map +1 -1
  78. package/dist/mcp/pipelines.js +514 -314
  79. package/dist/mcp/pipelines.js.map +1 -1
  80. package/dist/mcp/projects.d.ts +2 -1
  81. package/dist/mcp/projects.d.ts.map +1 -1
  82. package/dist/mcp/projects.js +66 -100
  83. package/dist/mcp/projects.js.map +1 -1
  84. package/dist/mcp/repo-node-types.d.ts +2 -1
  85. package/dist/mcp/repo-node-types.d.ts.map +1 -1
  86. package/dist/mcp/repo-node-types.js +92 -121
  87. package/dist/mcp/repo-node-types.js.map +1 -1
  88. package/dist/mcp/runs.d.ts +4 -1
  89. package/dist/mcp/runs.d.ts.map +1 -1
  90. package/dist/mcp/runs.js +94 -147
  91. package/dist/mcp/runs.js.map +1 -1
  92. package/dist/mcp/skills.d.ts +13 -0
  93. package/dist/mcp/skills.d.ts.map +1 -0
  94. package/dist/mcp/skills.js +85 -0
  95. package/dist/mcp/skills.js.map +1 -0
  96. package/dist/mcp/subgraphs.d.ts +2 -1
  97. package/dist/mcp/subgraphs.d.ts.map +1 -1
  98. package/dist/mcp/subgraphs.js +61 -98
  99. package/dist/mcp/subgraphs.js.map +1 -1
  100. package/dist/mcp/tool-helpers.d.ts +37 -0
  101. package/dist/mcp/tool-helpers.d.ts.map +1 -0
  102. package/dist/mcp/tool-helpers.js +82 -0
  103. package/dist/mcp/tool-helpers.js.map +1 -0
  104. package/dist/mcp/users.d.ts +2 -1
  105. package/dist/mcp/users.d.ts.map +1 -1
  106. package/dist/mcp/users.js +92 -145
  107. package/dist/mcp/users.js.map +1 -1
  108. package/dist/mcp/workshop.d.ts +2 -1
  109. package/dist/mcp/workshop.d.ts.map +1 -1
  110. package/dist/mcp/workshop.js +66 -101
  111. package/dist/mcp/workshop.js.map +1 -1
  112. package/dist/mcp/workspaces.d.ts +2 -1
  113. package/dist/mcp/workspaces.d.ts.map +1 -1
  114. package/dist/mcp/workspaces.js +19 -34
  115. package/dist/mcp/workspaces.js.map +1 -1
  116. package/dist/prompts/index.d.ts.map +1 -1
  117. package/dist/prompts/index.js +85 -0
  118. package/dist/prompts/index.js.map +1 -1
  119. package/dist/resources/context/pipeline-workshop-guide.md +1 -1
  120. package/dist/resources/context/tool-usage.md +7 -0
  121. package/dist/resources/index.d.ts +13 -0
  122. package/dist/resources/index.d.ts.map +1 -1
  123. package/dist/resources/index.js +105 -5
  124. package/dist/resources/index.js.map +1 -1
  125. package/dist/schemas/node-payloads.d.ts +2 -2
  126. package/dist/server.d.ts +2 -1
  127. package/dist/server.d.ts.map +1 -1
  128. package/dist/server.js +173 -45
  129. package/dist/server.js.map +1 -1
  130. package/dist/services/cache/snapshots.d.ts.map +1 -1
  131. package/dist/services/cache/snapshots.js +9 -5
  132. package/dist/services/cache/snapshots.js.map +1 -1
  133. package/dist/services/config/schema-resolver.d.ts.map +1 -1
  134. package/dist/services/config/schema-resolver.js +3 -6
  135. package/dist/services/config/schema-resolver.js.map +1 -1
  136. package/dist/services/lineage/lineage-cache.d.ts +53 -0
  137. package/dist/services/lineage/lineage-cache.d.ts.map +1 -0
  138. package/dist/services/lineage/lineage-cache.js +335 -0
  139. package/dist/services/lineage/lineage-cache.js.map +1 -0
  140. package/dist/services/lineage/lineage-documentation.d.ts +29 -0
  141. package/dist/services/lineage/lineage-documentation.d.ts.map +1 -0
  142. package/dist/services/lineage/lineage-documentation.js +80 -0
  143. package/dist/services/lineage/lineage-documentation.js.map +1 -0
  144. package/dist/services/lineage/lineage-propagation.d.ts +47 -0
  145. package/dist/services/lineage/lineage-propagation.d.ts.map +1 -0
  146. package/dist/services/lineage/lineage-propagation.js +176 -0
  147. package/dist/services/lineage/lineage-propagation.js.map +1 -0
  148. package/dist/services/lineage/lineage-search.d.ts +33 -0
  149. package/dist/services/lineage/lineage-search.d.ts.map +1 -0
  150. package/dist/services/lineage/lineage-search.js +133 -0
  151. package/dist/services/lineage/lineage-search.js.map +1 -0
  152. package/dist/services/lineage/lineage-traversal.d.ts +34 -0
  153. package/dist/services/lineage/lineage-traversal.d.ts.map +1 -0
  154. package/dist/services/lineage/lineage-traversal.js +283 -0
  155. package/dist/services/lineage/lineage-traversal.js.map +1 -0
  156. package/dist/services/pipelines/clause-extraction.d.ts +3 -0
  157. package/dist/services/pipelines/clause-extraction.d.ts.map +1 -0
  158. package/dist/services/pipelines/clause-extraction.js +27 -0
  159. package/dist/services/pipelines/clause-extraction.js.map +1 -0
  160. package/dist/services/pipelines/column-helpers.d.ts +8 -0
  161. package/dist/services/pipelines/column-helpers.d.ts.map +1 -0
  162. package/dist/services/pipelines/column-helpers.js +125 -0
  163. package/dist/services/pipelines/column-helpers.js.map +1 -0
  164. package/dist/services/pipelines/cte-parsing.d.ts +29 -0
  165. package/dist/services/pipelines/cte-parsing.d.ts.map +1 -0
  166. package/dist/services/pipelines/cte-parsing.js +160 -0
  167. package/dist/services/pipelines/cte-parsing.js.map +1 -0
  168. package/dist/services/pipelines/cte-planning.d.ts +22 -0
  169. package/dist/services/pipelines/cte-planning.d.ts.map +1 -0
  170. package/dist/services/pipelines/cte-planning.js +206 -0
  171. package/dist/services/pipelines/cte-planning.js.map +1 -0
  172. package/dist/services/pipelines/execution.d.ts.map +1 -1
  173. package/dist/services/pipelines/execution.js +0 -1
  174. package/dist/services/pipelines/execution.js.map +1 -1
  175. package/dist/services/pipelines/intent-parsing.d.ts +24 -0
  176. package/dist/services/pipelines/intent-parsing.d.ts.map +1 -0
  177. package/dist/services/pipelines/intent-parsing.js +245 -0
  178. package/dist/services/pipelines/intent-parsing.js.map +1 -0
  179. package/dist/services/pipelines/intent-resolution.d.ts +24 -0
  180. package/dist/services/pipelines/intent-resolution.d.ts.map +1 -0
  181. package/dist/services/pipelines/intent-resolution.js +141 -0
  182. package/dist/services/pipelines/intent-resolution.js.map +1 -0
  183. package/dist/services/pipelines/intent.d.ts +4 -45
  184. package/dist/services/pipelines/intent.d.ts.map +1 -1
  185. package/dist/services/pipelines/intent.js +14 -408
  186. package/dist/services/pipelines/intent.js.map +1 -1
  187. package/dist/services/pipelines/node-type-candidates.d.ts +6 -0
  188. package/dist/services/pipelines/node-type-candidates.d.ts.map +1 -0
  189. package/dist/services/pipelines/node-type-candidates.js +165 -0
  190. package/dist/services/pipelines/node-type-candidates.js.map +1 -0
  191. package/dist/services/pipelines/node-type-intent.d.ts +1 -5
  192. package/dist/services/pipelines/node-type-intent.d.ts.map +1 -1
  193. package/dist/services/pipelines/node-type-intent.js +1 -5
  194. package/dist/services/pipelines/node-type-intent.js.map +1 -1
  195. package/dist/services/pipelines/node-type-scoring.d.ts +13 -0
  196. package/dist/services/pipelines/node-type-scoring.d.ts.map +1 -0
  197. package/dist/services/pipelines/node-type-scoring.js +322 -0
  198. package/dist/services/pipelines/node-type-scoring.js.map +1 -0
  199. package/dist/services/pipelines/node-type-selection.d.ts +22 -2
  200. package/dist/services/pipelines/node-type-selection.d.ts.map +1 -1
  201. package/dist/services/pipelines/node-type-selection.js +16 -538
  202. package/dist/services/pipelines/node-type-selection.js.map +1 -1
  203. package/dist/services/pipelines/plan-builder.d.ts +33 -0
  204. package/dist/services/pipelines/plan-builder.d.ts.map +1 -0
  205. package/dist/services/pipelines/plan-builder.js +224 -0
  206. package/dist/services/pipelines/plan-builder.js.map +1 -0
  207. package/dist/services/pipelines/planning-types.d.ts +543 -0
  208. package/dist/services/pipelines/planning-types.d.ts.map +1 -0
  209. package/dist/services/pipelines/planning-types.js +85 -0
  210. package/dist/services/pipelines/planning-types.js.map +1 -0
  211. package/dist/services/pipelines/planning.d.ts +8 -537
  212. package/dist/services/pipelines/planning.d.ts.map +1 -1
  213. package/dist/services/pipelines/planning.js +10 -1956
  214. package/dist/services/pipelines/planning.js.map +1 -1
  215. package/dist/services/pipelines/review.d.ts.map +1 -1
  216. package/dist/services/pipelines/review.js +3 -8
  217. package/dist/services/pipelines/review.js.map +1 -1
  218. package/dist/services/pipelines/select-parsing.d.ts +7 -0
  219. package/dist/services/pipelines/select-parsing.d.ts.map +1 -0
  220. package/dist/services/pipelines/select-parsing.js +185 -0
  221. package/dist/services/pipelines/select-parsing.js.map +1 -0
  222. package/dist/services/pipelines/source-parsing.d.ts +8 -0
  223. package/dist/services/pipelines/source-parsing.d.ts.map +1 -0
  224. package/dist/services/pipelines/source-parsing.js +151 -0
  225. package/dist/services/pipelines/source-parsing.js.map +1 -0
  226. package/dist/services/pipelines/sql-parsing.d.ts +8 -0
  227. package/dist/services/pipelines/sql-parsing.d.ts.map +1 -0
  228. package/dist/services/pipelines/sql-parsing.js +9 -0
  229. package/dist/services/pipelines/sql-parsing.js.map +1 -0
  230. package/dist/services/pipelines/sql-tokenizer.d.ts +42 -0
  231. package/dist/services/pipelines/sql-tokenizer.d.ts.map +1 -0
  232. package/dist/services/pipelines/sql-tokenizer.js +493 -0
  233. package/dist/services/pipelines/sql-tokenizer.js.map +1 -0
  234. package/dist/services/pipelines/sql-utils.d.ts +30 -0
  235. package/dist/services/pipelines/sql-utils.d.ts.map +1 -0
  236. package/dist/services/pipelines/sql-utils.js +62 -0
  237. package/dist/services/pipelines/sql-utils.js.map +1 -0
  238. package/dist/services/pipelines/workshop.d.ts.map +1 -1
  239. package/dist/services/pipelines/workshop.js +53 -25
  240. package/dist/services/pipelines/workshop.js.map +1 -1
  241. package/dist/services/pipelines/workspace-resolution.d.ts +18 -0
  242. package/dist/services/pipelines/workspace-resolution.d.ts.map +1 -0
  243. package/dist/services/pipelines/workspace-resolution.js +279 -0
  244. package/dist/services/pipelines/workspace-resolution.js.map +1 -0
  245. package/dist/services/runs/diagnostics.d.ts.map +1 -1
  246. package/dist/services/runs/diagnostics.js +3 -8
  247. package/dist/services/runs/diagnostics.js.map +1 -1
  248. package/dist/services/shared/elicitation.d.ts +14 -0
  249. package/dist/services/shared/elicitation.d.ts.map +1 -0
  250. package/dist/services/shared/elicitation.js +56 -0
  251. package/dist/services/shared/elicitation.js.map +1 -0
  252. package/dist/services/workspace/join-operations.d.ts +56 -0
  253. package/dist/services/workspace/join-operations.d.ts.map +1 -0
  254. package/dist/services/workspace/join-operations.js +303 -0
  255. package/dist/services/workspace/join-operations.js.map +1 -0
  256. package/dist/services/workspace/mutations.d.ts +3 -99
  257. package/dist/services/workspace/mutations.d.ts.map +1 -1
  258. package/dist/services/workspace/mutations.js +13 -1220
  259. package/dist/services/workspace/mutations.js.map +1 -1
  260. package/dist/services/workspace/node-creation.d.ts +48 -0
  261. package/dist/services/workspace/node-creation.d.ts.map +1 -0
  262. package/dist/services/workspace/node-creation.js +448 -0
  263. package/dist/services/workspace/node-creation.js.map +1 -0
  264. package/dist/services/workspace/node-update-helpers.d.ts +89 -0
  265. package/dist/services/workspace/node-update-helpers.d.ts.map +1 -0
  266. package/dist/services/workspace/node-update-helpers.js +492 -0
  267. package/dist/services/workspace/node-update-helpers.js.map +1 -0
  268. package/dist/tasks/index.d.ts +5 -0
  269. package/dist/tasks/index.d.ts.map +1 -0
  270. package/dist/tasks/index.js +10 -0
  271. package/dist/tasks/index.js.map +1 -0
  272. package/dist/tasks/retry-and-wait-task.d.ts +4 -0
  273. package/dist/tasks/retry-and-wait-task.d.ts.map +1 -0
  274. package/dist/tasks/retry-and-wait-task.js +61 -0
  275. package/dist/tasks/retry-and-wait-task.js.map +1 -0
  276. package/dist/tasks/run-and-wait-task.d.ts +4 -0
  277. package/dist/tasks/run-and-wait-task.d.ts.map +1 -0
  278. package/dist/tasks/run-and-wait-task.js +61 -0
  279. package/dist/tasks/run-and-wait-task.js.map +1 -0
  280. package/dist/tasks/start-run-task.d.ts +4 -0
  281. package/dist/tasks/start-run-task.d.ts.map +1 -0
  282. package/dist/tasks/start-run-task.js +47 -0
  283. package/dist/tasks/start-run-task.js.map +1 -0
  284. package/dist/tasks/store.d.ts +2 -0
  285. package/dist/tasks/store.d.ts.map +1 -0
  286. package/dist/tasks/store.js +2 -0
  287. package/dist/tasks/store.js.map +1 -0
  288. package/dist/utils.d.ts +11 -0
  289. package/dist/utils.d.ts.map +1 -1
  290. package/dist/utils.js +20 -1
  291. package/dist/utils.js.map +1 -1
  292. package/dist/workflows/get-environment-health.d.ts +49 -0
  293. package/dist/workflows/get-environment-health.d.ts.map +1 -0
  294. package/dist/workflows/get-environment-health.js +310 -0
  295. package/dist/workflows/get-environment-health.js.map +1 -0
  296. package/dist/workflows/get-environment-overview.d.ts +2 -1
  297. package/dist/workflows/get-environment-overview.d.ts.map +1 -1
  298. package/dist/workflows/get-environment-overview.js +13 -19
  299. package/dist/workflows/get-environment-overview.js.map +1 -1
  300. package/dist/workflows/get-run-details.d.ts +2 -2
  301. package/dist/workflows/get-run-details.d.ts.map +1 -1
  302. package/dist/workflows/get-run-details.js +14 -19
  303. package/dist/workflows/get-run-details.js.map +1 -1
  304. package/dist/workflows/poll-run.d.ts +14 -0
  305. package/dist/workflows/poll-run.d.ts.map +1 -0
  306. package/dist/workflows/poll-run.js +86 -0
  307. package/dist/workflows/poll-run.js.map +1 -0
  308. package/dist/workflows/progress.d.ts +1 -0
  309. package/dist/workflows/progress.d.ts.map +1 -1
  310. package/dist/workflows/progress.js +3 -0
  311. package/dist/workflows/progress.js.map +1 -1
  312. package/dist/workflows/retry-and-wait.d.ts +1 -1
  313. package/dist/workflows/retry-and-wait.d.ts.map +1 -1
  314. package/dist/workflows/retry-and-wait.js +18 -89
  315. package/dist/workflows/retry-and-wait.js.map +1 -1
  316. package/dist/workflows/run-and-wait.d.ts +1 -1
  317. package/dist/workflows/run-and-wait.d.ts.map +1 -1
  318. package/dist/workflows/run-and-wait.js +18 -89
  319. package/dist/workflows/run-and-wait.js.map +1 -1
  320. package/package.json +2 -2
@@ -1,497 +1,17 @@
1
- import { randomUUID } from "node:crypto";
2
- import { CoalesceApiError } from "../../client.js";
3
- import { getWorkspaceNode, setWorkspaceNode, createWorkspaceNode, } from "../../coalesce/api/nodes.js";
1
+ import { getWorkspaceNode, setWorkspaceNode, } from "../../coalesce/api/nodes.js";
4
2
  import { fetchAllWorkspaceNodes } from "../cache/snapshots.js";
5
3
  import { assertNoSqlOverridePayload } from "../policies/sql-override.js";
6
- import { completeNodeConfiguration } from "../config/intelligent.js";
7
- import { isPlainObject, uniqueInOrder } from "../../utils.js";
8
- import { selectPipelineNodeType, inferFamily } from "../pipelines/node-type-selection.js";
9
- import { detectSpecializedPatternPenalty } from "../pipelines/node-type-intent.js";
10
- import { getNodeColumnCount, getNodeStorageLocationCount, getNodeConfigKeyCount, getRequestedNodeName, getRequestedColumnNames, getRequestedConfig, getRequestedLocationFields, getNodeColumnNames, getNodeDependencyNames, normalizeColumnName, normalizeDataType, } from "./node-inspection.js";
11
- import { buildPredecessorSummary, buildJoinSuggestions, generateJoinSQL, generateRefJoinSQL, inferDatatype, analyzeColumnsForGroupBy, extractPredecessorNodeIDs, extractPredecessorRefInfo, getReferencedPredecessorNodeIDs, appendWhereToJoinCondition, } from "./join-helpers.js";
12
- import { isPassthroughTransform } from "../shared/node-helpers.js";
13
- const CONFIG_COMPLETION_SKIP_MSG = "call complete_node_configuration with repoPath after creation to apply node type config and column-level attributes.";
14
- async function tryCompleteNodeConfiguration(client, params) {
15
- try {
16
- const configCompletion = await completeNodeConfiguration(client, params);
17
- return { configCompletion };
18
- }
19
- catch (error) {
20
- if (error instanceof CoalesceApiError && [401, 403, 503].includes(error.status)) {
21
- throw error;
22
- }
23
- const reason = error instanceof Error ? error.message : String(error);
24
- return { configCompletionSkipped: `Config completion failed (${reason}) — ${CONFIG_COMPLETION_SKIP_MSG}` };
25
- }
26
- }
27
- /**
28
- * Validates the requested nodeType against all available types from repo + workspace.
29
- * Returns ranking info so the agent (and user) can see all considered options.
30
- * Throws if the requested type is excluded (e.g., inputMode: 'sql').
31
- * Returns null on non-critical validation failures (creation proceeds).
32
- */
33
- async function validateNodeTypeChoice(client, params) {
34
- try {
35
- const nodeTypesResult = await listWorkspaceNodeTypes(client, {
36
- workspaceID: params.workspaceID,
37
- });
38
- const selectionResult = selectPipelineNodeType({
39
- explicitNodeType: params.nodeType,
40
- sourceCount: params.predecessorCount,
41
- workspaceNodeTypes: nodeTypesResult.nodeTypes,
42
- workspaceNodeTypeCounts: nodeTypesResult.counts,
43
- repoPath: params.repoPath,
44
- goal: params.goal,
45
- });
46
- // Hard block: if the requested type was excluded (e.g., inputMode: 'sql'), throw
47
- const exclusionWarning = selectionResult.warnings.find((w) => w.includes("is excluded because it relies on raw SQL override"));
48
- if (exclusionWarning) {
49
- throw new Error(exclusionWarning);
50
- }
51
- const topRanked = selectionResult.selection.consideredNodeTypes[0] ?? null;
52
- const isTopRanked = topRanked?.nodeType === params.nodeType;
53
- const validation = {
54
- requestedNodeType: params.nodeType,
55
- topRankedNodeType: topRanked?.nodeType ?? null,
56
- isTopRanked,
57
- strategy: selectionResult.selection.strategy,
58
- consideredNodeTypes: selectionResult.selection.consideredNodeTypes.slice(0, 5).map((c) => ({
59
- nodeType: c.nodeType,
60
- displayName: c.displayName,
61
- score: c.score,
62
- reasons: c.reasons,
63
- })),
64
- };
65
- if (!isTopRanked && topRanked) {
66
- validation.warning =
67
- `Requested nodeType "${params.nodeType}" is not the top-ranked type. ` +
68
- `Consider using "${topRanked.nodeType}" (score: ${topRanked.score}) instead. ` +
69
- `Call plan_pipeline first to discover and rank all available node types.`;
70
- }
71
- // Hard block specialized materialization patterns (Dynamic Tables, Incremental, etc.)
72
- // unless the goal explicitly requests them.
73
- // Build candidate signals from the matched type's display name and short name,
74
- // since the raw nodeType ID (e.g., "65") won't match pattern detection.
75
- const matchedCandidate = selectionResult.selection.consideredNodeTypes.find((c) => c.nodeType === params.nodeType);
76
- const candidateSignals = [
77
- params.nodeType,
78
- matchedCandidate?.displayName ?? "",
79
- matchedCandidate?.shortName ?? "",
80
- ].join(" ");
81
- const contextText = params.goal ?? "";
82
- const specializedPenalty = detectSpecializedPatternPenalty(candidateSignals, contextText);
83
- if (specializedPenalty) {
84
- // Hard block — specialized types require explicit context
85
- const patternName = specializedPenalty.reason.split(" pattern")[0] ?? "Specialized";
86
- throw new Error(`Cannot create node with nodeType "${params.nodeType}" (${matchedCandidate?.displayName ?? "unknown"}): ` +
87
- `${specializedPenalty.reason}. ` +
88
- `${patternName} types require an explicit use case (e.g., "${patternName.toLowerCase()}" in the goal). ` +
89
- `For standard batch ETL, staging, joins, and aggregations, use a general-purpose type ` +
90
- `(Stage, Work, View, Dimension, Fact). ` +
91
- `Call plan_pipeline to discover the correct nodeType for your use case.`);
92
- }
93
- return validation;
94
- }
95
- catch (error) {
96
- // Auth and network errors indicate a broken session — let them propagate
97
- if (error instanceof CoalesceApiError && [401, 403, 503].includes(error.status)) {
98
- throw error;
99
- }
100
- // Re-throw hard block errors — exclusion and specialized pattern blocks
101
- if (error instanceof Error && (error.message.includes("is excluded") ||
102
- error.message.startsWith("Cannot create node"))) {
103
- throw error;
104
- }
105
- // Log unexpected errors so they are not completely invisible
106
- const reason = error instanceof Error ? error.message : String(error);
107
- process.stderr.write(`[validateNodeTypeChoice] Unexpected error for nodeType "${params.nodeType}": ${reason}. Validation skipped.\n`);
108
- return null;
109
- }
110
- }
111
- /**
112
- * Blocks creation of "Source" node types — Source nodes are read-only data
113
- * definitions created via the Coalesce UI, not downstream processing nodes.
114
- * Agents often confuse "Source" with "Stage".
115
- */
116
- function assertNotSourceNodeType(nodeType) {
117
- const normalized = nodeType.toLowerCase().replace(/.*:::/, "");
118
- if (normalized === "source") {
119
- throw new Error(`Cannot create a node with nodeType "Source". Source nodes are read-only data ` +
120
- `definitions — they represent external tables and are created via the Coalesce UI, ` +
121
- `not via the API. You probably want "Stage" for a staging/transform node. ` +
122
- `Call plan_pipeline to discover the correct nodeType.`);
123
- }
124
- }
125
- function mergeWorkspaceNodeChanges(current, changes) {
126
- if (Array.isArray(changes)) {
127
- return changes;
128
- }
129
- if (isPlainObject(current) && isPlainObject(changes)) {
130
- const merged = { ...current };
131
- for (const [key, value] of Object.entries(changes)) {
132
- merged[key] = mergeWorkspaceNodeChanges(current[key], value);
133
- }
134
- return merged;
135
- }
136
- return changes;
137
- }
138
- function syncNodeNameIntoMetadataSourceMapping(current, merged, changes) {
139
- if (typeof changes.name !== "string") {
140
- return merged;
141
- }
142
- const metadataChanges = isPlainObject(changes.metadata) ? changes.metadata : undefined;
143
- if (metadataChanges && "sourceMapping" in metadataChanges) {
144
- return merged;
145
- }
146
- const mergedMetadata = isPlainObject(merged.metadata) ? merged.metadata : undefined;
147
- if (!mergedMetadata || !Array.isArray(mergedMetadata.sourceMapping)) {
148
- return merged;
149
- }
150
- const previousName = typeof current.name === "string" && current.name.trim().length > 0
151
- ? current.name
152
- : null;
153
- const updateSingleUnnamedMapping = previousName === null && mergedMetadata.sourceMapping.length === 1;
154
- return {
155
- ...merged,
156
- metadata: {
157
- ...mergedMetadata,
158
- sourceMapping: mergedMetadata.sourceMapping.map((entry) => {
159
- if (!isPlainObject(entry)) {
160
- return entry;
161
- }
162
- const shouldRename = (previousName !== null && entry.name === previousName) ||
163
- updateSingleUnnamedMapping;
164
- if (!shouldRename) {
165
- return entry;
166
- }
167
- return {
168
- ...entry,
169
- name: changes.name,
170
- };
171
- }),
172
- },
173
- };
174
- }
175
- export function buildUpdatedWorkspaceNodeBody(current, changes) {
176
- if (!isPlainObject(current)) {
177
- throw new Error("Workspace node response was not an object");
178
- }
179
- const merged = mergeWorkspaceNodeChanges(current, changes);
180
- if (!isPlainObject(merged)) {
181
- throw new Error("Merged workspace node update was not an object");
182
- }
183
- const synchronized = syncNodeNameIntoMetadataSourceMapping(current, merged, changes);
184
- if (!isPlainObject(synchronized)) {
185
- throw new Error("Synchronized workspace node update was not an object");
186
- }
187
- // Preserve the node name casing as provided by the user or existing node.
188
- // Snowflake treats unquoted identifiers as uppercase, but users may choose
189
- // lowercase names (e.g., for Databricks or personal preference) — respect that.
190
- // Validate nodeType and materializationType compatibility
191
- validateNodeTypeMaterializationCompatibility(synchronized.nodeType, synchronized.materializationType);
192
- // Strip invalid fields from metadata before sending to Coalesce
193
- if (isPlainObject(synchronized.metadata)) {
194
- synchronized.metadata = cleanMetadata(synchronized.metadata);
195
- }
196
- // Ensure API-required fields (table, overrideSQL, columnIDs) are preserved
197
- ensureRequiredApiFields(current, synchronized);
198
- return synchronized;
199
- }
200
- /**
201
- * Reconcile external target columns against a node's auto-populated columns.
202
- *
203
- * For each target column:
204
- * - If it matches an auto-populated predecessor column by name, the source
205
- * linkage is preserved but the dataType is overridden to match the external schema.
206
- * - If it has no match in the predecessor, it is added as a new column
207
- * (flagged as needing a transform unless one was provided).
208
- *
209
- * Predecessor columns not present in the target list are dropped.
210
- */
211
- function reconcileExternalSchema(autoPopulatedColumns, targetColumns) {
212
- // Index auto-populated columns by normalized name
213
- const existingByName = new Map();
214
- for (const col of autoPopulatedColumns) {
215
- if (isPlainObject(col) && typeof col.name === "string") {
216
- existingByName.set(normalizeColumnName(col.name), col);
217
- }
218
- }
219
- const targetNameSet = new Set(targetColumns.map((tc) => normalizeColumnName(tc.name)));
220
- const reconciliation = {
221
- matched: [],
222
- added: [],
223
- dropped: [],
224
- typeChanges: [],
225
- };
226
- // Find dropped columns (in predecessor, not in target)
227
- for (const col of autoPopulatedColumns) {
228
- if (!isPlainObject(col) || typeof col.name !== "string")
229
- continue;
230
- if (!targetNameSet.has(normalizeColumnName(col.name))) {
231
- reconciliation.dropped.push({
232
- name: col.name,
233
- reason: "not in target schema",
234
- });
235
- }
236
- }
237
- // Build reconciled column list in target column order
238
- const reconciledColumns = [];
239
- for (const target of targetColumns) {
240
- const normalizedName = normalizeColumnName(target.name);
241
- const existing = existingByName.get(normalizedName);
242
- if (existing) {
243
- // Matched — preserve source linkage, override dataType
244
- const sourceDataType = typeof existing.dataType === "string" ? existing.dataType : null;
245
- const typeChanged = sourceDataType !== null && normalizeDataType(sourceDataType) !== normalizeDataType(target.dataType);
246
- const reconciledCol = {
247
- ...structuredClone(existing),
248
- name: target.name,
249
- dataType: target.dataType,
250
- nullable: target.nullable ?? true,
251
- description: target.description ?? (typeof existing.description === "string" ? existing.description : ""),
252
- };
253
- if (target.transform) {
254
- reconciledCol.transform = target.transform;
255
- }
256
- reconciledColumns.push(reconciledCol);
257
- reconciliation.matched.push({
258
- name: target.name,
259
- sourceDataType,
260
- targetDataType: target.dataType,
261
- typeChanged,
262
- });
263
- }
264
- else {
265
- // New column — no predecessor match
266
- const needsTransform = !target.transform;
267
- reconciledColumns.push({
268
- name: target.name,
269
- dataType: target.dataType,
270
- nullable: target.nullable ?? true,
271
- description: target.description ?? "",
272
- ...(target.transform ? { transform: target.transform } : {}),
273
- });
274
- reconciliation.added.push({
275
- name: target.name,
276
- dataType: target.dataType,
277
- needsTransform,
278
- });
279
- }
280
- }
281
- // Derive typeChanges from matched entries to avoid dual-update maintenance
282
- reconciliation.typeChanges = reconciliation.matched
283
- .filter((m) => m.typeChanged && m.sourceDataType !== null)
284
- .map((m) => ({ name: m.name, from: m.sourceDataType, to: m.targetDataType }));
285
- return { columns: reconciledColumns, reconciliation };
286
- }
287
- /**
288
- * Valid metadata fields for the Coalesce PUT API.
289
- * The GET response may include additional read-only fields (e.g., appliedNodeTests,
290
- * cteString, materializationOption) that the PUT schema rejects as additional properties.
291
- */
292
- const VALID_METADATA_FIELDS = new Set([
293
- "columns",
294
- "sourceMapping",
295
- "enabledColumnTestIDs",
296
- ]);
297
- function cleanMetadata(metadata) {
298
- if (!isPlainObject(metadata)) {
299
- return {};
300
- }
301
- const cleaned = {};
302
- for (const key of Object.keys(metadata)) {
303
- if (VALID_METADATA_FIELDS.has(key)) {
304
- cleaned[key] = metadata[key];
305
- }
306
- }
307
- return cleaned;
308
- }
309
- /**
310
- * Preserves columnID, sources, columnReference, and placement from existing
311
- * columns when new columns are provided by the agent.
312
- *
313
- * When agents provide columns in changes.metadata.columns, those columns
314
- * typically lack the source linkage fields that Coalesce auto-populates
315
- * from predecessors. This function restores them by matching column names.
316
- */
317
- function preserveColumnLinkage(currentMetadata, mergedMetadata) {
318
- if (!Array.isArray(currentMetadata.columns) || !Array.isArray(mergedMetadata.columns)) {
319
- return;
320
- }
321
- const existingByName = new Map();
322
- for (const col of currentMetadata.columns) {
323
- if (isPlainObject(col) && typeof col.name === "string") {
324
- existingByName.set(normalizeColumnName(col.name), col);
325
- }
326
- }
327
- if (existingByName.size === 0) {
328
- return;
329
- }
330
- for (const col of mergedMetadata.columns) {
331
- if (!isPlainObject(col) || typeof col.name !== "string") {
332
- continue;
333
- }
334
- const existing = existingByName.get(normalizeColumnName(col.name));
335
- if (!existing) {
336
- continue;
337
- }
338
- // Preserve columnID if not already set
339
- if ((!col.columnID || (typeof col.columnID === "string" && col.columnID.length === 0))
340
- && typeof existing.columnID === "string") {
341
- col.columnID = existing.columnID;
342
- }
343
- // Preserve source linkage fields — these connect the column back to its
344
- // predecessor node. Agents almost never provide these, so inherit from
345
- // the auto-populated column.
346
- if (!Array.isArray(col.sources) && Array.isArray(existing.sources)) {
347
- const clonedSources = structuredClone(existing.sources);
348
- col.sources = clonedSources;
349
- // If the agent provided a non-passthrough transform on the top-level column
350
- // (e.g., UPPER("TABLE"."COL")), propagate it into sources[*].transform so
351
- // the Coalesce UI displays it correctly.
352
- // The UI reads transforms from sources[0].transform, not a top-level transform field.
353
- if (typeof col.transform === "string" &&
354
- col.transform.trim().length > 0 &&
355
- typeof col.name === "string" &&
356
- !isPassthroughTransform(col.transform, col.name)) {
357
- for (const source of clonedSources) {
358
- if (isPlainObject(source)) {
359
- source.transform = col.transform;
360
- }
361
- }
362
- }
363
- }
364
- if (!isPlainObject(col.columnReference) && isPlainObject(existing.columnReference)) {
365
- col.columnReference = existing.columnReference;
366
- }
367
- // Preserve placement (column ordering metadata)
368
- if (col.placement === undefined && existing.placement !== undefined) {
369
- col.placement = existing.placement;
370
- }
371
- }
372
- }
373
- /**
374
- * Ensures the Coalesce API required fields (table, overrideSQL) are present
375
- * in the body being sent to the PUT endpoint. These are preserved from the
376
- * current node — the agent is never allowed to set overrideSQL.
377
- */
378
- function ensureRequiredApiFields(current, body) {
379
- // Ensure 'table' is present — required by the Coalesce PUT API.
380
- // Prefer current node's value, fall back to node name.
381
- if (!body.table || (typeof body.table === "string" && body.table.trim().length === 0)) {
382
- if (current.table && typeof current.table === "string" && current.table.trim().length > 0) {
383
- body.table = current.table;
384
- }
385
- else {
386
- const name = typeof body.name === "string" ? body.name : typeof current.name === "string" ? current.name : "";
387
- if (name.length > 0) {
388
- body.table = name;
389
- }
390
- }
391
- }
392
- // Preserve 'overrideSQL' from current node — agent must never set this
393
- if ("overrideSQL" in current) {
394
- body.overrideSQL = current.overrideSQL;
395
- }
396
- // Preserve columnIDs from current node's columns
397
- const currentMetadata = isPlainObject(current.metadata) ? current.metadata : undefined;
398
- const bodyMetadata = isPlainObject(body.metadata) ? body.metadata : undefined;
399
- // Strip backslash-escaped quotes from transforms BEFORE preserveColumnLinkage,
400
- // so cleaned transforms get propagated into sources[*].transform correctly.
401
- if (bodyMetadata && Array.isArray(bodyMetadata.columns)) {
402
- for (const col of bodyMetadata.columns) {
403
- if (isPlainObject(col) && typeof col.transform === "string" && col.transform.includes("\\")) {
404
- col.transform = col.transform.replace(/\\"/g, '"');
405
- }
406
- }
407
- }
408
- if (currentMetadata && bodyMetadata) {
409
- preserveColumnLinkage(currentMetadata, bodyMetadata);
410
- }
411
- // Ensure all columns have required fields and strip invalid properties
412
- if (bodyMetadata && Array.isArray(bodyMetadata.columns)) {
413
- // Build lookup of current node's columns for dataType fallback
414
- const currentColumns = currentMetadata && Array.isArray(currentMetadata.columns) ? currentMetadata.columns : [];
415
- const currentDataTypes = new Map();
416
- for (const col of currentColumns) {
417
- if (isPlainObject(col) && typeof col.name === "string" && typeof col.dataType === "string") {
418
- currentDataTypes.set(col.name.toUpperCase(), col.dataType);
419
- }
420
- }
421
- for (const col of bodyMetadata.columns) {
422
- if (!isPlainObject(col))
423
- continue;
424
- // Ensure 'dataType' — required by the Coalesce PUT API.
425
- // Prefer the current node's dataType for the same column name, otherwise default to "VARCHAR".
426
- if (!col.dataType || (typeof col.dataType === "string" && col.dataType.trim().length === 0)) {
427
- const colName = typeof col.name === "string" ? col.name.toUpperCase() : "";
428
- const existingType = currentDataTypes.get(colName);
429
- col.dataType = existingType ?? "VARCHAR";
430
- }
431
- // Ensure 'nullable' — required by the Coalesce PUT API
432
- if (!("nullable" in col)) {
433
- col.nullable = true;
434
- }
435
- // Ensure 'description' — required by the Coalesce PUT API
436
- if (!("description" in col)) {
437
- col.description = "";
438
- }
439
- // Generate columnID for columns that don't have one —
440
- // the PUT API requires columnID on every column
441
- if (!col.columnID || (typeof col.columnID === "string" && col.columnID.length === 0)) {
442
- col.columnID = randomUUID();
443
- }
444
- // Strip passthrough transforms — if the transform just references
445
- // the column's own name (e.g., "ALIAS"."COL" or {{ ref(...) }}."COL"),
446
- // remove it so Coalesce auto-populates the source mapping.
447
- if (typeof col.transform === "string" && typeof col.name === "string") {
448
- if (isPassthroughTransform(col.transform, col.name)) {
449
- delete col.transform;
450
- }
451
- }
452
- // Ensure computed columns (new columns with a transform but no sources) get a
453
- // synthetic sources entry so the Coalesce UI displays the transform correctly.
454
- // The UI reads transforms from sources[0].transform, not the top-level transform field.
455
- if (typeof col.transform === "string" &&
456
- col.transform.trim().length > 0 &&
457
- !Array.isArray(col.sources)) {
458
- col.sources = [{ transform: col.transform, columnReferences: [] }];
459
- }
460
- // Strip properties that are not valid Coalesce column fields.
461
- // Valid: name, dataType, transform, nullable, description, columnID,
462
- // sources, placement, plus columnSelector attributes (isBusinessKey, etc.)
463
- delete col.primaryKey;
464
- delete col.foreignKey;
465
- delete col.unique;
466
- delete col.index;
467
- }
468
- }
469
- // Ensure 'enabledColumnTestIDs' is present on metadata — required by the Coalesce PUT API
470
- if (bodyMetadata && !Array.isArray(bodyMetadata.enabledColumnTestIDs)) {
471
- const currentEnabled = currentMetadata && Array.isArray(currentMetadata.enabledColumnTestIDs)
472
- ? currentMetadata.enabledColumnTestIDs
473
- : [];
474
- bodyMetadata.enabledColumnTestIDs = currentEnabled;
475
- }
476
- }
477
- /**
478
- * Validates that nodeType and materializationType are compatible
479
- * Throws error with actionable message if incompatible
480
- */
481
- function validateNodeTypeMaterializationCompatibility(nodeType, materializationType) {
482
- if (typeof nodeType !== "string" || typeof materializationType !== "string") {
483
- return; // Skip validation if either field is missing or invalid type
484
- }
485
- const normalizedNodeType = nodeType.toLowerCase();
486
- const normalizedMaterialization = materializationType.toLowerCase();
487
- // View nodes can ONLY be materialized as views
488
- if (normalizedNodeType === "view" && normalizedMaterialization === "table") {
489
- throw new Error(`Invalid configuration: nodeType "View" cannot use materializationType "table". ` +
490
- `Either keep materializationType as "view" OR change nodeType to a table-capable type like "Dimension", "Fact", "Stage", or "Work".`);
491
- }
492
- // Note: Other node types (Dimension, Fact, Stage, Work, etc.) can be either table or view
493
- // So we only need to check the View + table combination
494
- }
4
+ import { isPlainObject } from "../../utils.js";
5
+ import { appendWhereToJoinCondition, } from "./join-helpers.js";
6
+ import { tryCompleteNodeConfiguration, reconcileExternalSchema, mergeWorkspaceNodeChanges, buildUpdatedWorkspaceNodeBody, } from "./node-update-helpers.js";
7
+ import { createWorkspaceNodeFromPredecessor } from "./node-creation.js";
8
+ // ── Barrel re-exports ──────────────────────────────────────────────────
9
+ // Preserve existing import paths: everything that used to be exported from
10
+ // this module is re-exported from the new files below.
11
+ export { tryCompleteNodeConfiguration, validateNodeTypeChoice, assertNotSourceNodeType, mergeWorkspaceNodeChanges, syncNodeNameIntoMetadataSourceMapping, buildUpdatedWorkspaceNodeBody, reconcileExternalSchema, } from "./node-update-helpers.js";
12
+ export { createWorkspaceNodeFromScratch, createWorkspaceNodeFromPredecessor, suggestNamingConvention, buildPostCreationNextSteps, } from "./node-creation.js";
13
+ export { convertJoinToAggregation, applyJoinCondition, } from "./join-operations.js";
14
+ // ── Group B: Core update operations (kept in this file) ────────────────
495
15
  export async function updateWorkspaceNode(client, params) {
496
16
  assertNoSqlOverridePayload(params.changes, "update_workspace_node changes");
497
17
  const current = await getWorkspaceNode(client, params);
@@ -544,734 +64,7 @@ export async function replaceWorkspaceNodeColumns(client, params) {
544
64
  body: updated,
545
65
  });
546
66
  }
547
- function buildScratchNodeChanges(params) {
548
- let merged = params.changes ? { ...params.changes } : {};
549
- if (params.name !== undefined) {
550
- merged = mergeWorkspaceNodeChanges(merged, { name: params.name });
551
- }
552
- if (params.description !== undefined) {
553
- merged = mergeWorkspaceNodeChanges(merged, {
554
- description: params.description,
555
- });
556
- }
557
- if (params.storageLocations !== undefined) {
558
- merged = mergeWorkspaceNodeChanges(merged, {
559
- storageLocations: params.storageLocations,
560
- });
561
- }
562
- if (params.config !== undefined) {
563
- merged = mergeWorkspaceNodeChanges(merged, {
564
- config: params.config,
565
- });
566
- }
567
- if (params.metadata !== undefined) {
568
- merged = mergeWorkspaceNodeChanges(merged, {
569
- metadata: params.metadata,
570
- });
571
- }
572
- return merged;
573
- }
574
- function buildScratchNodeValidation(node, completionLevel, requested) {
575
- const requestedName = getRequestedNodeName(requested.changes);
576
- const requestedNameSatisfied = requestedName !== undefined ? node.name === requestedName : true;
577
- const requestedColumnNames = getRequestedColumnNames(requested.changes);
578
- const actualColumnNameSet = new Set(getNodeColumnNames(node).map((name) => normalizeColumnName(name)));
579
- const requestedColumnsSatisfied = requestedColumnNames.length === 0
580
- ? getNodeColumnCount(node) > 0
581
- : requestedColumnNames.every((name) => actualColumnNameSet.has(normalizeColumnName(name)));
582
- const requestedConfig = getRequestedConfig(requested.changes);
583
- const nodeConfig = isPlainObject(node.config) ? node.config : undefined;
584
- const requestedConfigSatisfied = requestedConfig === undefined
585
- ? isPlainObject(node.config)
586
- : Object.entries(requestedConfig).every(([key, value]) => nodeConfig && Object.is(nodeConfig[key], value));
587
- const requestedLocationFields = getRequestedLocationFields(requested.changes);
588
- const requestedLocationSatisfied = Object.entries(requestedLocationFields).every(([key, value]) => Object.is(node[key], value));
589
- const nameSet = typeof node.name === "string" && node.name.trim().length > 0;
590
- const storageLocationsSet = getNodeStorageLocationCount(node) > 0;
591
- const columnCount = getNodeColumnCount(node);
592
- const configPresent = isPlainObject(node.config);
593
- const configKeyCount = getNodeConfigKeyCount(node);
594
- const nameRequired = completionLevel !== "created" || requestedName !== undefined;
595
- const storageLocationsRequired = requested.storageLocations !== undefined;
596
- let completionSatisfied = true;
597
- if (completionLevel === "named") {
598
- completionSatisfied =
599
- (!nameRequired || (requestedName ? requestedNameSatisfied : nameSet)) &&
600
- requestedLocationSatisfied &&
601
- (!storageLocationsRequired || storageLocationsSet);
602
- }
603
- else if (completionLevel === "configured") {
604
- completionSatisfied =
605
- (!nameRequired || (requestedName ? requestedNameSatisfied : nameSet)) &&
606
- requestedLocationSatisfied &&
607
- (!storageLocationsRequired || storageLocationsSet) &&
608
- requestedColumnsSatisfied &&
609
- requestedConfigSatisfied;
610
- }
611
- return {
612
- requestedCompletionLevel: completionLevel,
613
- completionSatisfied,
614
- nameRequired,
615
- nameSet,
616
- requestedName: requestedName ?? null,
617
- requestedNameSatisfied,
618
- requestedLocationKeys: Object.keys(requestedLocationFields),
619
- requestedLocationSatisfied,
620
- storageLocationsRequired,
621
- storageLocationCount: getNodeStorageLocationCount(node),
622
- storageLocationsSet,
623
- columnCount,
624
- configPresent,
625
- configKeyCount,
626
- requestedColumnCount: requestedColumnNames.length,
627
- requestedColumnNames,
628
- requestedColumnsSatisfied,
629
- requestedConfigKeys: requestedConfig ? Object.keys(requestedConfig) : [],
630
- requestedConfigSatisfied,
631
- };
632
- }
633
- function assertConfiguredScratchInput(changes) {
634
- const requestedName = getRequestedNodeName(changes);
635
- const requestedColumnNames = getRequestedColumnNames(changes);
636
- const missing = [];
637
- if (!requestedName) {
638
- missing.push("name");
639
- }
640
- if (requestedColumnNames.length === 0) {
641
- missing.push("metadata.columns");
642
- }
643
- if (missing.length > 0) {
644
- throw new Error(`Configured scratch node creation requires ${missing.join(" and ")}. Provide them explicitly or lower completionLevel to "named" or "created".`);
645
- }
646
- }
647
- function buildScratchNodeNextSteps(nodeType, node) {
648
- const steps = [];
649
- const family = inferFamily([nodeType]);
650
- // Naming convention
651
- const currentName = typeof node.name === "string" ? node.name : "";
652
- if (!currentName || currentName === nodeType || /^[A-Z]+_\d+$/.test(currentName)) {
653
- steps.push(`Name this node following conventions: ${suggestNamingConvention(family)}`);
654
- }
655
- // Scratch nodes have no predecessors — remind to add columns if missing
656
- const metadata = isPlainObject(node.metadata) ? node.metadata : {};
657
- const columns = Array.isArray(metadata.columns) ? metadata.columns : [];
658
- if (columns.length === 0) {
659
- steps.push("This node has no columns. Add columns using replace_workspace_node_columns or update_workspace_node.");
660
- }
661
- // Family-specific guidance
662
- if (family === "fact" || family === "dimension") {
663
- steps.push(`Verify materialization: ${family === "fact" ? "Fact" : "Dimension"} nodes should typically materialize as tables, not views. ` +
664
- "Check that materializationType is 'table' in the config.");
665
- if (family === "dimension") {
666
- steps.push("For dimensions: identify the business key (natural key from the source system) and mark it isBusinessKey = true. " +
667
- "If this is a slowly changing dimension (SCD Type 2), ensure START_DATE/END_DATE/IS_CURRENT columns exist.");
668
- }
669
- }
670
- // Verification
671
- steps.push("Verify the node: call get_workspace_node to confirm columns and config are correct before proceeding.");
672
- return steps;
673
- }
674
- export async function createWorkspaceNodeFromScratch(client, params) {
675
- assertNotSourceNodeType(params.nodeType);
676
- const completionLevel = params.completionLevel ?? "configured";
677
- const scratchChanges = buildScratchNodeChanges(params);
678
- assertNoSqlOverridePayload(scratchChanges, "create_workspace_node_from_scratch changes");
679
- if (completionLevel === "configured") {
680
- assertConfiguredScratchInput(scratchChanges);
681
- }
682
- // Validate node type choice — throws if the type is excluded (e.g., inputMode: 'sql')
683
- // or if a specialized pattern is detected without matching context
684
- const nodeTypeValidation = await validateNodeTypeChoice(client, {
685
- workspaceID: params.workspaceID,
686
- nodeType: params.nodeType,
687
- predecessorCount: 0,
688
- repoPath: params.repoPath,
689
- goal: params.goal,
690
- });
691
- const created = await createWorkspaceNode(client, {
692
- workspaceID: params.workspaceID,
693
- nodeType: params.nodeType,
694
- });
695
- if (!isPlainObject(created) || typeof created.id !== "string") {
696
- throw new Error("Workspace node creation did not return a node ID");
697
- }
698
- const createdNode = await getWorkspaceNode(client, {
699
- workspaceID: params.workspaceID,
700
- nodeID: created.id,
701
- });
702
- if (!isPlainObject(createdNode)) {
703
- throw new Error("Created workspace node response was not an object");
704
- }
705
- let finalNode = createdNode;
706
- if (Object.keys(scratchChanges).length > 0) {
707
- const body = buildUpdatedWorkspaceNodeBody(createdNode, scratchChanges);
708
- await setWorkspaceNode(client, {
709
- workspaceID: params.workspaceID,
710
- nodeID: created.id,
711
- body,
712
- });
713
- finalNode = await getWorkspaceNode(client, {
714
- workspaceID: params.workspaceID,
715
- nodeID: created.id,
716
- });
717
- }
718
- if (!isPlainObject(finalNode)) {
719
- throw new Error("Final workspace node response was not an object");
720
- }
721
- const validation = buildScratchNodeValidation(finalNode, completionLevel, {
722
- changes: scratchChanges,
723
- storageLocations: params.storageLocations,
724
- });
725
- const nextSteps = buildScratchNodeNextSteps(params.nodeType, finalNode);
726
- if (!validation.completionSatisfied) {
727
- if (completionLevel === "configured") {
728
- throw new Error(`Workspace node ${created.id} was created, but configured scratch validation failed. ` +
729
- `Check name, metadata.columns, and config values on the saved node body. ` +
730
- `To clean up, use delete_workspace_node with nodeID "${created.id}".`);
731
- }
732
- return {
733
- node: finalNode,
734
- validation,
735
- nextSteps,
736
- warning: "Workspace node was created, but the requested scratch completion level was not fully satisfied. Review the node body and provide any missing name, storageLocations, metadata.columns, or config fields.",
737
- ...(nodeTypeValidation ? { nodeTypeValidation } : {}),
738
- };
739
- }
740
- // Automatically complete node configuration using intelligent rules (best-effort)
741
- const completion = await tryCompleteNodeConfiguration(client, {
742
- workspaceID: params.workspaceID,
743
- nodeID: created.id,
744
- repoPath: params.repoPath,
745
- });
746
- return {
747
- node: completion.configCompletion?.node ?? finalNode,
748
- validation,
749
- nextSteps,
750
- ...completion,
751
- ...(nodeTypeValidation ? { nodeTypeValidation } : {}),
752
- };
753
- }
754
- function suggestNamingConvention(family) {
755
- const conventions = {
756
- stage: "STG_<SOURCE_NAME> (e.g., STG_CUSTOMERS, STG_ORDERS)",
757
- dimension: "DIM_<ENTITY> (e.g., DIM_CUSTOMER, DIM_PRODUCT)",
758
- fact: "FACT_<BUSINESS_PROCESS> or FCT_<BUSINESS_PROCESS> (e.g., FACT_SALES, FACT_CLV)",
759
- view: "V_<PURPOSE> or INT_<PURPOSE> (e.g., V_CUSTOMER_ORDERS)",
760
- work: "INT_<PURPOSE> or WRK_<PURPOSE> (e.g., INT_ORDER_ENRICHMENT)",
761
- hub: "HUB_<BUSINESS_KEY> (e.g., HUB_CUSTOMER)",
762
- satellite: "SAT_<HUB>_<CONTEXT> (e.g., SAT_CUSTOMER_DETAILS)",
763
- link: "LNK_<RELATIONSHIP> (e.g., LNK_CUSTOMER_ORDER)",
764
- };
765
- return conventions[family] ?? "Use a descriptive, layer-appropriate name";
766
- }
767
- function buildPostCreationNextSteps(predecessorCount, nodeType, joinSuggestions, node) {
768
- const steps = [];
769
- const family = inferFamily([nodeType]);
770
- // Naming convention
771
- const currentName = typeof node.name === "string" ? node.name : "";
772
- if (!currentName || currentName === nodeType || /^[A-Z]+_\d+$/.test(currentName)) {
773
- steps.push(`Name this node following conventions: ${suggestNamingConvention(family)}`);
774
- }
775
- // Multi-predecessor: join setup is REQUIRED
776
- if (predecessorCount > 1) {
777
- const hasCommonColumns = joinSuggestions.some((s) => s.commonColumns.length > 0);
778
- steps.push("REQUIRED: Set up the join condition. This multi-predecessor node needs a FROM/JOIN/ON clause in the joinCondition. " +
779
- "Review joinSuggestions above to identify join columns, then either:" +
780
- "\n - Call convert_join_to_aggregation (for GROUP BY / aggregation use cases)" +
781
- "\n - Call apply_join_condition (for row-level joins — auto-generates FROM/JOIN/ON with {{ ref() }} syntax)");
782
- if (hasCommonColumns) {
783
- steps.push("Verify join columns: joinSuggestions shows common column names between predecessors. " +
784
- "Confirm these are the correct join keys (business keys, not surrogate keys). " +
785
- "Choose the right join type: INNER JOIN (only matching rows), LEFT JOIN (keep all rows from first table), " +
786
- "FULL OUTER JOIN (keep all rows from both).");
787
- }
788
- else {
789
- steps.push("WARNING: No common columns found between predecessors. You may need a cross join, " +
790
- "or the join columns have different names. Verify the correct join keys with the user.");
791
- }
792
- }
793
- // Family-specific guidance
794
- if (family === "fact" || family === "dimension") {
795
- steps.push(`Verify materialization: ${family === "fact" ? "Fact" : "Dimension"} nodes should typically materialize as tables, not views. ` +
796
- "Check that materializationType is 'table' in the config.");
797
- if (family === "fact" && predecessorCount > 1) {
798
- steps.push("For fact tables: define the grain (the set of columns that uniquely identify each row). " +
799
- "These grain columns become your GROUP BY columns in convert_join_to_aggregation. " +
800
- "Mark them as isBusinessKey = true.");
801
- }
802
- if (family === "dimension") {
803
- steps.push("For dimensions: identify the business key (natural key from the source system) and mark it isBusinessKey = true. " +
804
- "If this is a slowly changing dimension (SCD Type 2), ensure START_DATE/END_DATE/IS_CURRENT columns exist.");
805
- }
806
- }
807
- // Single predecessor: simpler guidance
808
- if (predecessorCount === 1) {
809
- steps.push("Review auto-populated columns. Remove columns you don't need and add transforms where appropriate. " +
810
- "Columns without transforms are pass-throughs (inherited as-is from the predecessor).");
811
- }
812
- // Verification
813
- steps.push("Verify the node: call get_workspace_node to confirm columns, config, and join condition are correct before proceeding to downstream nodes.");
814
- return steps;
815
- }
816
- export async function createWorkspaceNodeFromPredecessor(client, params) {
817
- assertNotSourceNodeType(params.nodeType);
818
- const effectivePredecessorNodeIDs = uniqueInOrder(params.predecessorNodeIDs);
819
- if (params.changes) {
820
- assertNoSqlOverridePayload(params.changes, "create_workspace_node_from_predecessor changes");
821
- }
822
- // Validate mutually exclusive params
823
- if (params.columns && (params.groupByColumns || params.aggregates)) {
824
- throw new Error("Cannot provide both 'columns' and 'groupByColumns'/'aggregates'. " +
825
- "Use 'columns' for column replacement, or 'groupByColumns'+'aggregates' for aggregation.");
826
- }
827
- if (params.aggregates && !params.groupByColumns) {
828
- throw new Error("'aggregates' requires 'groupByColumns' to be provided.");
829
- }
830
- if (params.groupByColumns && !params.aggregates) {
831
- throw new Error("'groupByColumns' requires 'aggregates' to be provided.");
832
- }
833
- if (params.whereCondition && params.groupByColumns) {
834
- throw new Error("'whereCondition' cannot be combined with 'groupByColumns'/'aggregates'. " +
835
- "For aggregation nodes, WHERE/HAVING filters should be applied via a separate update_workspace_node call.");
836
- }
837
- // Validate node type choice and fetch predecessors in parallel
838
- // validateNodeTypeChoice throws if the type is excluded (e.g., inputMode: 'sql')
839
- // or if a specialized pattern is detected without matching context
840
- const [nodeTypeValidation, predecessorNodes] = await Promise.all([
841
- validateNodeTypeChoice(client, {
842
- workspaceID: params.workspaceID,
843
- nodeType: params.nodeType,
844
- predecessorCount: effectivePredecessorNodeIDs.length,
845
- repoPath: params.repoPath,
846
- goal: params.goal,
847
- }),
848
- Promise.all(effectivePredecessorNodeIDs.map(async (nodeID) => {
849
- const predecessor = await getWorkspaceNode(client, {
850
- workspaceID: params.workspaceID,
851
- nodeID,
852
- });
853
- if (!isPlainObject(predecessor)) {
854
- throw new Error(`Predecessor node response was not an object for nodeID ${nodeID}`);
855
- }
856
- return buildPredecessorSummary(nodeID, predecessor);
857
- })),
858
- ]);
859
- const joinSuggestions = buildJoinSuggestions(predecessorNodes);
860
- const created = await createWorkspaceNode(client, {
861
- workspaceID: params.workspaceID,
862
- nodeType: params.nodeType,
863
- predecessorNodeIDs: effectivePredecessorNodeIDs,
864
- });
865
- if (!isPlainObject(created) || typeof created.id !== "string") {
866
- throw new Error("Workspace node creation did not return a node ID");
867
- }
868
- const createdNode = await getWorkspaceNode(client, {
869
- workspaceID: params.workspaceID,
870
- nodeID: created.id,
871
- });
872
- if (!isPlainObject(createdNode)) {
873
- throw new Error("Created workspace node response was not an object");
874
- }
875
- const referencedPredecessorNodeIDs = getReferencedPredecessorNodeIDs(createdNode, effectivePredecessorNodeIDs);
876
- const allPredecessorsRepresented = referencedPredecessorNodeIDs.length === effectivePredecessorNodeIDs.length;
877
- const autoPopulatedColumns = getNodeColumnCount(createdNode) > 0 &&
878
- (effectivePredecessorNodeIDs.length === 1
879
- ? referencedPredecessorNodeIDs.length > 0
880
- : allPredecessorsRepresented);
881
- const validation = {
882
- autoPopulatedColumns,
883
- allPredecessorsRepresented,
884
- columnCount: getNodeColumnCount(createdNode),
885
- dependencyCount: getNodeDependencyNames(createdNode).length,
886
- dependencyNames: getNodeDependencyNames(createdNode),
887
- predecessorNodeIDs: effectivePredecessorNodeIDs,
888
- referencedPredecessorNodeIDs,
889
- };
890
- // Build context-aware next steps for multi-predecessor nodes
891
- const nextSteps = buildPostCreationNextSteps(effectivePredecessorNodeIDs.length, params.nodeType, joinSuggestions, createdNode);
892
- if (!validation.autoPopulatedColumns) {
893
- const warning = effectivePredecessorNodeIDs.length > 1
894
- ? "Workspace node was created from predecessor(s), but columns were not auto-populated from all requested predecessors. Review the suggested join columns and verify the node in Coalesce before proceeding."
895
- : "Workspace node was created from predecessor(s), but columns were not auto-populated. Verify the node in Coalesce before proceeding.";
896
- return {
897
- node: createdNode,
898
- predecessors: predecessorNodes,
899
- joinSuggestions,
900
- validation,
901
- warning,
902
- nextSteps,
903
- ...(nodeTypeValidation ? { nodeTypeValidation } : {}),
904
- };
905
- }
906
- if (params.changes && Object.keys(params.changes).length > 0) {
907
- const body = buildUpdatedWorkspaceNodeBody(createdNode, params.changes);
908
- await setWorkspaceNode(client, {
909
- workspaceID: params.workspaceID,
910
- nodeID: created.id,
911
- body,
912
- });
913
- }
914
- // Single-call aggregation path: create + replace columns + write joinCondition + config completion
915
- // convertJoinToAggregation handles all of this internally, including config completion.
916
- if (params.groupByColumns && params.aggregates) {
917
- const aggResult = await convertJoinToAggregation(client, {
918
- workspaceID: params.workspaceID,
919
- nodeID: created.id,
920
- groupByColumns: params.groupByColumns,
921
- aggregates: params.aggregates,
922
- joinType: params.joinType,
923
- repoPath: params.repoPath,
924
- });
925
- return {
926
- node: aggResult.node,
927
- predecessors: predecessorNodes,
928
- joinSuggestions,
929
- validation,
930
- joinSQL: aggResult.joinSQL,
931
- groupByAnalysis: aggResult.groupByAnalysis,
932
- aggregationValidation: aggResult.validation,
933
- ...(aggResult.configCompletion ? { configCompletion: aggResult.configCompletion } : {}),
934
- ...(aggResult.configCompletionSkipped ? { configCompletionSkipped: aggResult.configCompletionSkipped } : {}),
935
- nextSteps,
936
- ...(nodeTypeValidation ? { nodeTypeValidation } : {}),
937
- };
938
- }
939
- // Single-call column replacement path: create + replace columns + WHERE + config completion
940
- if (params.columns) {
941
- await replaceWorkspaceNodeColumns(client, {
942
- workspaceID: params.workspaceID,
943
- nodeID: created.id,
944
- columns: params.columns,
945
- whereCondition: params.whereCondition,
946
- });
947
- }
948
- // Automatically complete node configuration using intelligent rules (best-effort)
949
- const completion = await tryCompleteNodeConfiguration(client, {
950
- workspaceID: params.workspaceID,
951
- nodeID: created.id,
952
- repoPath: params.repoPath,
953
- });
954
- // Re-fetch the node if changes were applied but config completion failed
955
- let resultNode = completion.configCompletion?.node ?? createdNode;
956
- if (completion.configCompletionSkipped) {
957
- const hasChanges = (params.changes && Object.keys(params.changes).length > 0) || params.columns;
958
- if (hasChanges) {
959
- try {
960
- resultNode = await getWorkspaceNode(client, { workspaceID: params.workspaceID, nodeID: created.id });
961
- }
962
- catch (error) {
963
- const reason = error instanceof Error ? error.message : String(error);
964
- process.stderr.write(`[createWorkspaceNodeFromPredecessor] Re-fetch after config completion skip failed: ${reason}\n`);
965
- resultNode = createdNode;
966
- }
967
- }
968
- }
969
- return {
970
- node: resultNode,
971
- predecessors: predecessorNodes,
972
- joinSuggestions,
973
- validation,
974
- ...completion,
975
- nextSteps,
976
- ...(nodeTypeValidation ? { nodeTypeValidation } : {}),
977
- };
978
- }
979
- export async function convertJoinToAggregation(client, params) {
980
- // Get the current node to analyze predecessors
981
- const current = await getWorkspaceNode(client, {
982
- workspaceID: params.workspaceID,
983
- nodeID: params.nodeID,
984
- });
985
- if (!isPlainObject(current)) {
986
- throw new Error("Node response was not an object");
987
- }
988
- const metadata = isPlainObject(current.metadata) ? current.metadata : {};
989
- const predecessorNodeIDs = extractPredecessorNodeIDs(metadata);
990
- // Fetch predecessor nodes to build join suggestions and ref info
991
- const predecessorNodes = [];
992
- const predecessorRefInfos = [];
993
- const shouldMaintainJoins = params.maintainJoins !== false; // default true
994
- if (shouldMaintainJoins && predecessorNodeIDs.length > 0) {
995
- const fetched = await Promise.all(predecessorNodeIDs.map(async (nodeID) => ({
996
- nodeID,
997
- node: await getWorkspaceNode(client, { workspaceID: params.workspaceID, nodeID }),
998
- })));
999
- for (const { nodeID, node: predecessor } of fetched) {
1000
- if (isPlainObject(predecessor)) {
1001
- predecessorNodes.push(buildPredecessorSummary(nodeID, predecessor));
1002
- const refInfo = extractPredecessorRefInfo(nodeID, predecessor);
1003
- if (refInfo) {
1004
- predecessorRefInfos.push(refInfo);
1005
- }
1006
- }
1007
- }
1008
- }
1009
- // Generate JOIN SQL with {{ ref() }} syntax if maintaining joins
1010
- const joinSuggestions = buildJoinSuggestions(predecessorNodes);
1011
- let joinSQL;
1012
- if (predecessorRefInfos.length >= 2) {
1013
- joinSQL = generateRefJoinSQL(predecessorRefInfos, joinSuggestions, params.joinType || "INNER JOIN");
1014
- }
1015
- else {
1016
- // Fallback: predecessors missing locationName — use bare-name join SQL
1017
- const bareJoin = generateJoinSQL(joinSuggestions, params.joinType || "INNER JOIN");
1018
- joinSQL = {
1019
- fromClause: bareJoin.fromClause,
1020
- joinClauses: bareJoin.joinClauses.map((jc) => `${jc.type} ${jc.rightTableAlias}\n ON ${jc.onConditions.join("\n AND ")}`),
1021
- fullSQL: bareJoin.fullSQL,
1022
- warnings: predecessorNodeIDs.length >= 2
1023
- ? ["Predecessors are missing locationName — generated join uses bare table names instead of {{ ref() }} syntax. Set locationName on predecessor nodes for proper Coalesce references."]
1024
- : [],
1025
- };
1026
- }
1027
- // Build a lookup map of existing column datatypes from the current node
1028
- // so GROUP BY pass-through columns can inherit their predecessor's dataType
1029
- const existingColumns = Array.isArray(metadata.columns) ? metadata.columns : [];
1030
- const existingDataTypeByName = new Map();
1031
- for (const col of existingColumns) {
1032
- if (isPlainObject(col) && typeof col.name === "string" && typeof col.dataType === "string") {
1033
- existingDataTypeByName.set(normalizeColumnName(col.name), col.dataType);
1034
- }
1035
- }
1036
- // Build columns: group by columns + aggregates
1037
- const columns = [];
1038
- // Add GROUP BY columns
1039
- for (const groupByCol of params.groupByColumns) {
1040
- const colName = groupByCol.split(".").pop()?.replace(/"/g, "") || groupByCol;
1041
- const inferredDatatype = inferDatatype(groupByCol)
1042
- ?? existingDataTypeByName.get(normalizeColumnName(colName))
1043
- ?? "VARCHAR";
1044
- columns.push({
1045
- name: colName,
1046
- transform: groupByCol,
1047
- dataType: inferredDatatype,
1048
- });
1049
- }
1050
- // Add aggregate columns
1051
- for (const agg of params.aggregates) {
1052
- const transform = `${agg.function}(${agg.expression})`;
1053
- const inferredDatatype = inferDatatype(transform) ?? "VARCHAR";
1054
- columns.push({
1055
- name: agg.name,
1056
- transform,
1057
- dataType: inferredDatatype,
1058
- description: agg.description,
1059
- });
1060
- }
1061
- // Analyze GROUP BY requirements
1062
- const groupByAnalysis = analyzeColumnsForGroupBy(columns);
1063
- const warnings = [];
1064
- if (!groupByAnalysis.validation.valid) {
1065
- warnings.push(...groupByAnalysis.validation.errors);
1066
- }
1067
- // Derive business key and change tracking column names
1068
- // Business key = GROUP BY columns (dimensions)
1069
- // Change tracking = aggregate columns (measures that change over time)
1070
- const businessKeyColumnNames = new Set(params.groupByColumns.map((col) => col.split(".").pop()?.replace(/"/g, "") || col));
1071
- const changeTrackingColumnNames = new Set(params.aggregates.map((agg) => agg.name));
1072
- // Convert columns to metadata format with column-level attributes
1073
- // columnSelector attributes (isBusinessKey, isChangeTracking) are set directly
1074
- // on each column object — this is how Coalesce node type definitions work
1075
- const metadataColumns = columns.map((col) => {
1076
- const metadataCol = {
1077
- name: col.name,
1078
- dataType: col.dataType ?? "VARCHAR",
1079
- transform: col.transform,
1080
- nullable: true,
1081
- };
1082
- if (col.description) {
1083
- metadataCol.description = col.description;
1084
- }
1085
- if (businessKeyColumnNames.has(col.name)) {
1086
- metadataCol.isBusinessKey = true;
1087
- }
1088
- if (changeTrackingColumnNames.has(col.name)) {
1089
- metadataCol.isChangeTracking = true;
1090
- }
1091
- return metadataCol;
1092
- });
1093
- // Replace columns with aggregation columns
1094
- const updated = await replaceWorkspaceNodeColumns(client, {
1095
- workspaceID: params.workspaceID,
1096
- nodeID: params.nodeID,
1097
- columns: metadataColumns,
1098
- });
1099
- // Write the generated JOIN SQL and/or GROUP BY directly to the node's sourceMapping.
1100
- // Re-fetch the node to get fresh sourceMapping after column replacement.
1101
- const hasJoinSQL = joinSQL.fullSQL.length > 0;
1102
- const hasGroupBy = groupByAnalysis.groupByColumns.length > 0;
1103
- if (hasJoinSQL || hasGroupBy) {
1104
- const freshNode = await getWorkspaceNode(client, {
1105
- workspaceID: params.workspaceID,
1106
- nodeID: params.nodeID,
1107
- });
1108
- const freshMetadata = isPlainObject(freshNode) && isPlainObject(freshNode.metadata)
1109
- ? freshNode.metadata
1110
- : {};
1111
- const freshSourceMapping = Array.isArray(freshMetadata.sourceMapping)
1112
- ? freshMetadata.sourceMapping
1113
- : [];
1114
- const firstEntry = freshSourceMapping.find(isPlainObject);
1115
- if (!firstEntry) {
1116
- joinSQL.warnings.push("Could not write joinCondition — node has no sourceMapping entries. " +
1117
- "The generated SQL is returned but was not persisted to the node.");
1118
- }
1119
- else {
1120
- const existingJoin = isPlainObject(firstEntry.join) ? firstEntry.join : {};
1121
- const existingJoinCondition = typeof existingJoin.joinCondition === "string"
1122
- ? existingJoin.joinCondition.trim()
1123
- : "";
1124
- let fullJoinCondition;
1125
- if (hasJoinSQL) {
1126
- // Multi-predecessor: use generated FROM/JOIN + GROUP BY
1127
- const groupByClause = hasGroupBy
1128
- ? `\nGROUP BY ${groupByAnalysis.groupByColumns.join(", ")}`
1129
- : "";
1130
- fullJoinCondition = joinSQL.fullSQL + groupByClause;
1131
- }
1132
- else {
1133
- // Single-predecessor aggregation: append GROUP BY to existing joinCondition
1134
- const groupByClause = `\nGROUP BY ${groupByAnalysis.groupByColumns.join(", ")}`;
1135
- fullJoinCondition = existingJoinCondition.length > 0
1136
- ? existingJoinCondition + groupByClause
1137
- : groupByClause.trim();
1138
- }
1139
- const updatedSourceMapping = freshSourceMapping.map((entry) => entry === firstEntry
1140
- ? { ...firstEntry, join: { ...existingJoin, joinCondition: fullJoinCondition } }
1141
- : entry);
1142
- await updateWorkspaceNode(client, {
1143
- workspaceID: params.workspaceID,
1144
- nodeID: params.nodeID,
1145
- changes: {
1146
- metadata: {
1147
- sourceMapping: updatedSourceMapping,
1148
- },
1149
- },
1150
- });
1151
- }
1152
- }
1153
- // Complete configuration with intelligent rules (best-effort)
1154
- const completion = await tryCompleteNodeConfiguration(client, {
1155
- workspaceID: params.workspaceID,
1156
- nodeID: params.nodeID,
1157
- repoPath: params.repoPath,
1158
- });
1159
- return {
1160
- node: completion.configCompletion?.node ?? updated,
1161
- joinSQL,
1162
- groupByAnalysis,
1163
- validation: {
1164
- valid: groupByAnalysis.validation.valid && warnings.length === 0,
1165
- warnings,
1166
- },
1167
- ...completion,
1168
- };
1169
- }
1170
- export async function applyJoinCondition(client, params) {
1171
- const joinType = params.joinType ?? "INNER JOIN";
1172
- // Fetch the node to extract predecessors
1173
- const current = await getWorkspaceNode(client, {
1174
- workspaceID: params.workspaceID,
1175
- nodeID: params.nodeID,
1176
- });
1177
- if (!isPlainObject(current)) {
1178
- throw new Error("Node response was not an object");
1179
- }
1180
- const metadata = isPlainObject(current.metadata) ? current.metadata : {};
1181
- const sourceMapping = Array.isArray(metadata.sourceMapping) ? metadata.sourceMapping : [];
1182
- const predecessorNodeIDs = extractPredecessorNodeIDs(metadata);
1183
- if (predecessorNodeIDs.length < 2) {
1184
- throw new Error("apply_join_condition requires a node with 2+ predecessors. " +
1185
- "This node has " + predecessorNodeIDs.length + " predecessor(s). " +
1186
- "For single-predecessor nodes, set the joinCondition directly via update_workspace_node.");
1187
- }
1188
- // Fetch all predecessors in parallel to get their names, locationNames, and columns
1189
- const predecessorRefInfos = [];
1190
- const predecessorSummaries = [];
1191
- const warnings = [];
1192
- const fetchedPredecessors = await Promise.all(predecessorNodeIDs.map(async (nodeID) => ({
1193
- nodeID,
1194
- node: await getWorkspaceNode(client, { workspaceID: params.workspaceID, nodeID }),
1195
- })));
1196
- for (const { nodeID, node: predecessor } of fetchedPredecessors) {
1197
- if (!isPlainObject(predecessor)) {
1198
- warnings.push(`Could not fetch predecessor ${nodeID}`);
1199
- continue;
1200
- }
1201
- const refInfo = extractPredecessorRefInfo(nodeID, predecessor);
1202
- if (!refInfo) {
1203
- const name = typeof predecessor.name === "string" ? predecessor.name : nodeID;
1204
- warnings.push(`Predecessor "${name}" is missing locationName. ` +
1205
- `Set it in the Coalesce UI or via update_workspace_node before applying joins.`);
1206
- continue;
1207
- }
1208
- predecessorRefInfos.push(refInfo);
1209
- predecessorSummaries.push(buildPredecessorSummary(nodeID, predecessor));
1210
- }
1211
- if (predecessorRefInfos.length < 2) {
1212
- throw new Error("Could not resolve 2+ predecessors with valid name and locationName. " +
1213
- "Ensure all predecessor nodes have a locationName set.");
1214
- }
1215
- // Build join suggestions from common columns
1216
- const joinSuggestions = buildJoinSuggestions(predecessorSummaries);
1217
- // Generate FROM/JOIN/ON with {{ ref() }} syntax
1218
- const joinResult = generateRefJoinSQL(predecessorRefInfos, joinSuggestions, joinType, params.joinColumnOverrides);
1219
- warnings.push(...joinResult.warnings);
1220
- // Build full joinCondition: FROM/JOIN + WHERE + QUALIFY
1221
- const parts = [joinResult.fullSQL];
1222
- if (params.whereClause) {
1223
- const trimmedWhere = params.whereClause.trim();
1224
- const whereStr = /^where\b/i.test(trimmedWhere)
1225
- ? trimmedWhere
1226
- : `WHERE ${trimmedWhere}`;
1227
- parts.push(whereStr);
1228
- }
1229
- if (params.qualifyClause) {
1230
- const trimmedQualify = params.qualifyClause.trim();
1231
- const qualifyStr = /^qualify\b/i.test(trimmedQualify)
1232
- ? trimmedQualify
1233
- : `QUALIFY ${trimmedQualify}`;
1234
- parts.push(qualifyStr);
1235
- }
1236
- const fullJoinCondition = parts.join("\n");
1237
- // Write to node's sourceMapping
1238
- const firstEntry = sourceMapping.find(isPlainObject);
1239
- if (!firstEntry) {
1240
- warnings.push("Could not write joinCondition — node has no sourceMapping entries. " +
1241
- "The generated SQL is returned but was not persisted to the node.");
1242
- }
1243
- else {
1244
- const updatedSourceMapping = sourceMapping.map((entry) => entry === firstEntry
1245
- ? {
1246
- ...firstEntry,
1247
- join: {
1248
- ...(isPlainObject(firstEntry.join) ? firstEntry.join : {}),
1249
- joinCondition: fullJoinCondition,
1250
- },
1251
- }
1252
- : entry);
1253
- await updateWorkspaceNode(client, {
1254
- workspaceID: params.workspaceID,
1255
- nodeID: params.nodeID,
1256
- changes: {
1257
- metadata: {
1258
- sourceMapping: updatedSourceMapping,
1259
- },
1260
- },
1261
- });
1262
- }
1263
- return {
1264
- joinCondition: fullJoinCondition,
1265
- joinSuggestions,
1266
- predecessors: predecessorRefInfos.map((p) => ({
1267
- nodeID: p.nodeID,
1268
- nodeName: p.nodeName,
1269
- locationName: p.locationName,
1270
- columnCount: p.columnNames.length,
1271
- })),
1272
- warnings,
1273
- };
1274
- }
67
+ // ── Group F: Workspace node types + external schema ────────────────────
1275
68
  /**
1276
69
  * Returns the distinct node types observed in existing workspace nodes.
1277
70
  * This is intentionally observation-based and should not be treated as a true