@ryanstark24/sfgraph-core 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/dist/analyze/__tests__/audit-graph.test.js +79 -0
  2. package/dist/analyze/__tests__/audit-graph.test.js.map +1 -1
  3. package/dist/analyze/__tests__/edge-filters.test.d.ts +2 -0
  4. package/dist/analyze/__tests__/edge-filters.test.d.ts.map +1 -0
  5. package/dist/analyze/__tests__/edge-filters.test.js +66 -0
  6. package/dist/analyze/__tests__/edge-filters.test.js.map +1 -0
  7. package/dist/analyze/__tests__/findings.test.js +19 -9
  8. package/dist/analyze/__tests__/findings.test.js.map +1 -1
  9. package/dist/analyze/__tests__/governor-risks-detection.test.js +90 -0
  10. package/dist/analyze/__tests__/governor-risks-detection.test.js.map +1 -1
  11. package/dist/analyze/__tests__/security-fls-filter.test.d.ts +2 -0
  12. package/dist/analyze/__tests__/security-fls-filter.test.d.ts.map +1 -0
  13. package/dist/analyze/__tests__/security-fls-filter.test.js +23 -0
  14. package/dist/analyze/__tests__/security-fls-filter.test.js.map +1 -0
  15. package/dist/analyze/audit-graph.d.ts +26 -2
  16. package/dist/analyze/audit-graph.d.ts.map +1 -1
  17. package/dist/analyze/audit-graph.js +52 -5
  18. package/dist/analyze/audit-graph.js.map +1 -1
  19. package/dist/analyze/dependencies.d.ts +2 -2
  20. package/dist/analyze/dependencies.d.ts.map +1 -1
  21. package/dist/analyze/dependencies.js +9 -3
  22. package/dist/analyze/dependencies.js.map +1 -1
  23. package/dist/analyze/dependents.d.ts +6 -1
  24. package/dist/analyze/dependents.d.ts.map +1 -1
  25. package/dist/analyze/dependents.js +12 -2
  26. package/dist/analyze/dependents.js.map +1 -1
  27. package/dist/analyze/edge-filters.d.ts +37 -0
  28. package/dist/analyze/edge-filters.d.ts.map +1 -0
  29. package/dist/analyze/edge-filters.js +51 -0
  30. package/dist/analyze/edge-filters.js.map +1 -0
  31. package/dist/analyze/find-nodes.d.ts.map +1 -1
  32. package/dist/analyze/find-nodes.js.map +1 -1
  33. package/dist/analyze/findings.d.ts +1 -1
  34. package/dist/analyze/findings.d.ts.map +1 -1
  35. package/dist/analyze/findings.js +1 -3
  36. package/dist/analyze/findings.js.map +1 -1
  37. package/dist/analyze/populate.d.ts.map +1 -1
  38. package/dist/analyze/populate.js +55 -10
  39. package/dist/analyze/populate.js.map +1 -1
  40. package/dist/analyze/security.d.ts +15 -0
  41. package/dist/analyze/security.d.ts.map +1 -1
  42. package/dist/analyze/security.js +59 -3
  43. package/dist/analyze/security.js.map +1 -1
  44. package/dist/analyze/wip-impact.d.ts.map +1 -1
  45. package/dist/analyze/wip-impact.js +12 -2
  46. package/dist/analyze/wip-impact.js.map +1 -1
  47. package/dist/domain/metadata-category.d.ts +1 -0
  48. package/dist/domain/metadata-category.d.ts.map +1 -1
  49. package/dist/domain/metadata-category.js +1 -0
  50. package/dist/domain/metadata-category.js.map +1 -1
  51. package/dist/embedding/embed.d.ts.map +1 -1
  52. package/dist/embedding/embed.js +14 -1
  53. package/dist/embedding/embed.js.map +1 -1
  54. package/dist/extractors/interfaces/metadata-source.d.ts +11 -0
  55. package/dist/extractors/interfaces/metadata-source.d.ts.map +1 -1
  56. package/dist/extractors/live-org/__tests__/bulk-retrieve.test.js.map +1 -1
  57. package/dist/extractors/live-org/__tests__/custom-settings.test.d.ts +2 -0
  58. package/dist/extractors/live-org/__tests__/custom-settings.test.d.ts.map +1 -0
  59. package/dist/extractors/live-org/__tests__/custom-settings.test.js +79 -0
  60. package/dist/extractors/live-org/__tests__/custom-settings.test.js.map +1 -0
  61. package/dist/extractors/live-org/__tests__/extractor-enrichment.test.js +70 -8
  62. package/dist/extractors/live-org/__tests__/extractor-enrichment.test.js.map +1 -1
  63. package/dist/extractors/live-org/__tests__/mcd-baseline.test.js +9 -6
  64. package/dist/extractors/live-org/__tests__/mcd-baseline.test.js.map +1 -1
  65. package/dist/extractors/live-org/__tests__/metadata-read-batch.test.js +1 -1
  66. package/dist/extractors/live-org/__tests__/metadata-read-batch.test.js.map +1 -1
  67. package/dist/extractors/live-org/__tests__/omnistudio-retrieve.test.js +1 -1
  68. package/dist/extractors/live-org/__tests__/omnistudio-retrieve.test.js.map +1 -1
  69. package/dist/extractors/live-org/__tests__/pools.test.js +4 -1
  70. package/dist/extractors/live-org/__tests__/pools.test.js.map +1 -1
  71. package/dist/extractors/live-org/__tests__/rate-limit.test.js +1 -6
  72. package/dist/extractors/live-org/__tests__/rate-limit.test.js.map +1 -1
  73. package/dist/extractors/live-org/__tests__/soql-rebatch.test.js.map +1 -1
  74. package/dist/extractors/live-org/__tests__/stop-waiting.test.d.ts +2 -0
  75. package/dist/extractors/live-org/__tests__/stop-waiting.test.d.ts.map +1 -0
  76. package/dist/extractors/live-org/__tests__/stop-waiting.test.js +243 -0
  77. package/dist/extractors/live-org/__tests__/stop-waiting.test.js.map +1 -0
  78. package/dist/extractors/live-org/__tests__/watchdog-budgets.test.d.ts +2 -0
  79. package/dist/extractors/live-org/__tests__/watchdog-budgets.test.d.ts.map +1 -0
  80. package/dist/extractors/live-org/__tests__/watchdog-budgets.test.js +40 -0
  81. package/dist/extractors/live-org/__tests__/watchdog-budgets.test.js.map +1 -0
  82. package/dist/extractors/live-org/__tests__/wedge-isolation.test.d.ts +2 -0
  83. package/dist/extractors/live-org/__tests__/wedge-isolation.test.d.ts.map +1 -0
  84. package/dist/extractors/live-org/__tests__/wedge-isolation.test.js +270 -0
  85. package/dist/extractors/live-org/__tests__/wedge-isolation.test.js.map +1 -0
  86. package/dist/extractors/live-org/bulk-retrieve.d.ts +169 -0
  87. package/dist/extractors/live-org/bulk-retrieve.d.ts.map +1 -1
  88. package/dist/extractors/live-org/bulk-retrieve.js +499 -70
  89. package/dist/extractors/live-org/bulk-retrieve.js.map +1 -1
  90. package/dist/extractors/live-org/capabilities.d.ts.map +1 -1
  91. package/dist/extractors/live-org/capabilities.js.map +1 -1
  92. package/dist/extractors/live-org/extractors/__tests__/apex.test.d.ts +2 -0
  93. package/dist/extractors/live-org/extractors/__tests__/apex.test.d.ts.map +1 -0
  94. package/dist/extractors/live-org/extractors/__tests__/apex.test.js +73 -0
  95. package/dist/extractors/live-org/extractors/__tests__/apex.test.js.map +1 -0
  96. package/dist/extractors/live-org/extractors/__tests__/lwc.parallel.test.d.ts +2 -0
  97. package/dist/extractors/live-org/extractors/__tests__/lwc.parallel.test.d.ts.map +1 -0
  98. package/dist/extractors/live-org/extractors/__tests__/lwc.parallel.test.js +183 -0
  99. package/dist/extractors/live-org/extractors/__tests__/lwc.parallel.test.js.map +1 -0
  100. package/dist/extractors/live-org/extractors/apex.d.ts.map +1 -1
  101. package/dist/extractors/live-org/extractors/apex.js +76 -51
  102. package/dist/extractors/live-org/extractors/apex.js.map +1 -1
  103. package/dist/extractors/live-org/extractors/custom-settings.d.ts +3 -0
  104. package/dist/extractors/live-org/extractors/custom-settings.d.ts.map +1 -0
  105. package/dist/extractors/live-org/extractors/custom-settings.js +66 -0
  106. package/dist/extractors/live-org/extractors/custom-settings.js.map +1 -0
  107. package/dist/extractors/live-org/extractors/flow.d.ts.map +1 -1
  108. package/dist/extractors/live-org/extractors/flow.js.map +1 -1
  109. package/dist/extractors/live-org/extractors/generic-metadata.d.ts.map +1 -1
  110. package/dist/extractors/live-org/extractors/generic-metadata.js.map +1 -1
  111. package/dist/extractors/live-org/extractors/generic-sobject.js.map +1 -1
  112. package/dist/extractors/live-org/extractors/lwc.d.ts.map +1 -1
  113. package/dist/extractors/live-org/extractors/lwc.js +81 -41
  114. package/dist/extractors/live-org/extractors/lwc.js.map +1 -1
  115. package/dist/extractors/live-org/extractors/mcd-baseline.d.ts.map +1 -1
  116. package/dist/extractors/live-org/extractors/mcd-baseline.js +2 -4
  117. package/dist/extractors/live-org/extractors/mcd-baseline.js.map +1 -1
  118. package/dist/extractors/live-org/extractors/object.d.ts.map +1 -1
  119. package/dist/extractors/live-org/extractors/object.js.map +1 -1
  120. package/dist/extractors/live-org/extractors/omnistudio-retrieve.d.ts.map +1 -1
  121. package/dist/extractors/live-org/extractors/omnistudio-retrieve.js.map +1 -1
  122. package/dist/extractors/live-org/extractors/omnistudio.d.ts +8 -1
  123. package/dist/extractors/live-org/extractors/omnistudio.d.ts.map +1 -1
  124. package/dist/extractors/live-org/extractors/omnistudio.js +59 -6
  125. package/dist/extractors/live-org/extractors/omnistudio.js.map +1 -1
  126. package/dist/extractors/live-org/rate-limit.d.ts +41 -15
  127. package/dist/extractors/live-org/rate-limit.d.ts.map +1 -1
  128. package/dist/extractors/live-org/rate-limit.js +185 -33
  129. package/dist/extractors/live-org/rate-limit.js.map +1 -1
  130. package/dist/extractors/live-org/vlocity/runner.d.ts.map +1 -1
  131. package/dist/extractors/live-org/vlocity/runner.js +30 -6
  132. package/dist/extractors/live-org/vlocity/runner.js.map +1 -1
  133. package/dist/ingest/__tests__/config-values.test.d.ts +2 -0
  134. package/dist/ingest/__tests__/config-values.test.d.ts.map +1 -0
  135. package/dist/ingest/__tests__/config-values.test.js +65 -0
  136. package/dist/ingest/__tests__/config-values.test.js.map +1 -0
  137. package/dist/ingest/__tests__/full-sync-deletion.test.js +11 -4
  138. package/dist/ingest/__tests__/full-sync-deletion.test.js.map +1 -1
  139. package/dist/ingest/__tests__/live-ingest.detect-deletions-guard.test.d.ts +10 -0
  140. package/dist/ingest/__tests__/live-ingest.detect-deletions-guard.test.d.ts.map +1 -0
  141. package/dist/ingest/__tests__/live-ingest.detect-deletions-guard.test.js +251 -0
  142. package/dist/ingest/__tests__/live-ingest.detect-deletions-guard.test.js.map +1 -0
  143. package/dist/ingest/__tests__/live-ingest.test.js.map +1 -1
  144. package/dist/ingest/__tests__/parser-dispatch.test.d.ts +2 -0
  145. package/dist/ingest/__tests__/parser-dispatch.test.d.ts.map +1 -0
  146. package/dist/ingest/__tests__/parser-dispatch.test.js +55 -0
  147. package/dist/ingest/__tests__/parser-dispatch.test.js.map +1 -0
  148. package/dist/ingest/__tests__/vlocity-dispatch-integration.test.d.ts +2 -0
  149. package/dist/ingest/__tests__/vlocity-dispatch-integration.test.d.ts.map +1 -0
  150. package/dist/ingest/__tests__/vlocity-dispatch-integration.test.js +134 -0
  151. package/dist/ingest/__tests__/vlocity-dispatch-integration.test.js.map +1 -0
  152. package/dist/ingest/detect-deletions-guard.d.ts +45 -0
  153. package/dist/ingest/detect-deletions-guard.d.ts.map +1 -0
  154. package/dist/ingest/detect-deletions-guard.js +63 -0
  155. package/dist/ingest/detect-deletions-guard.js.map +1 -0
  156. package/dist/ingest/live-ingest.d.ts +16 -0
  157. package/dist/ingest/live-ingest.d.ts.map +1 -1
  158. package/dist/ingest/live-ingest.js +776 -549
  159. package/dist/ingest/live-ingest.js.map +1 -1
  160. package/dist/parsers/__tests__/datatransform-structured.test.d.ts +2 -0
  161. package/dist/parsers/__tests__/datatransform-structured.test.d.ts.map +1 -0
  162. package/dist/parsers/__tests__/datatransform-structured.test.js +147 -0
  163. package/dist/parsers/__tests__/datatransform-structured.test.js.map +1 -0
  164. package/dist/parsers/__tests__/edge-provenance.test.js +4 -1
  165. package/dist/parsers/__tests__/edge-provenance.test.js.map +1 -1
  166. package/dist/parsers/__tests__/lwc-bindings.test.js +6 -3
  167. package/dist/parsers/__tests__/lwc-bindings.test.js.map +1 -1
  168. package/dist/parsers/__tests__/object-inline-fields.test.js +2 -2
  169. package/dist/parsers/__tests__/object-inline-fields.test.js.map +1 -1
  170. package/dist/parsers/__tests__/omnistudio.golden.test.js +5 -6
  171. package/dist/parsers/__tests__/omnistudio.golden.test.js.map +1 -1
  172. package/dist/parsers/__tests__/vlocity.golden.test.js +9 -13
  173. package/dist/parsers/__tests__/vlocity.golden.test.js.map +1 -1
  174. package/dist/parsers/apex/__tests__/ast-extractor.test.js +64 -4
  175. package/dist/parsers/apex/__tests__/ast-extractor.test.js.map +1 -1
  176. package/dist/parsers/apex/__tests__/empty-body.test.d.ts +2 -0
  177. package/dist/parsers/apex/__tests__/empty-body.test.d.ts.map +1 -0
  178. package/dist/parsers/apex/__tests__/empty-body.test.js +40 -0
  179. package/dist/parsers/apex/__tests__/empty-body.test.js.map +1 -0
  180. package/dist/parsers/apex/__tests__/is-test-attribute.test.js +4 -2
  181. package/dist/parsers/apex/__tests__/is-test-attribute.test.js.map +1 -1
  182. package/dist/parsers/apex/__tests__/regex-arg-count.test.js +1 -1
  183. package/dist/parsers/apex/__tests__/regex-arg-count.test.js.map +1 -1
  184. package/dist/parsers/apex/__tests__/regex-inloop.test.d.ts +2 -0
  185. package/dist/parsers/apex/__tests__/regex-inloop.test.d.ts.map +1 -0
  186. package/dist/parsers/apex/__tests__/regex-inloop.test.js +70 -0
  187. package/dist/parsers/apex/__tests__/regex-inloop.test.js.map +1 -0
  188. package/dist/parsers/apex/arity-resolver.d.ts.map +1 -1
  189. package/dist/parsers/apex/arity-resolver.js.map +1 -1
  190. package/dist/parsers/apex/ast-extractor.d.ts.map +1 -1
  191. package/dist/parsers/apex/ast-extractor.js +33 -1
  192. package/dist/parsers/apex/ast-extractor.js.map +1 -1
  193. package/dist/parsers/apex/class.d.ts.map +1 -1
  194. package/dist/parsers/apex/class.js +62 -3
  195. package/dist/parsers/apex/class.js.map +1 -1
  196. package/dist/parsers/apex/trigger.d.ts.map +1 -1
  197. package/dist/parsers/apex/trigger.js +10 -0
  198. package/dist/parsers/apex/trigger.js.map +1 -1
  199. package/dist/parsers/flow/__tests__/invocable-resolver.test.js.map +1 -1
  200. package/dist/parsers/flow/invocable-resolver.d.ts.map +1 -1
  201. package/dist/parsers/flow/invocable-resolver.js.map +1 -1
  202. package/dist/parsers/generic/reflection-walker.d.ts.map +1 -1
  203. package/dist/parsers/generic/reflection-walker.js +1 -3
  204. package/dist/parsers/generic/reflection-walker.js.map +1 -1
  205. package/dist/parsers/lwc/html-visitor.d.ts.map +1 -1
  206. package/dist/parsers/lwc/html-visitor.js +3 -2
  207. package/dist/parsers/lwc/html-visitor.js.map +1 -1
  208. package/dist/parsers/lwc/js-visitor.d.ts.map +1 -1
  209. package/dist/parsers/lwc/js-visitor.js +6 -2
  210. package/dist/parsers/lwc/js-visitor.js.map +1 -1
  211. package/dist/parsers/object/object.d.ts.map +1 -1
  212. package/dist/parsers/object/object.js +1 -5
  213. package/dist/parsers/object/object.js.map +1 -1
  214. package/dist/parsers/omnistudio/data-transform.d.ts.map +1 -1
  215. package/dist/parsers/omnistudio/data-transform.js +39 -6
  216. package/dist/parsers/omnistudio/data-transform.js.map +1 -1
  217. package/dist/parsers/omnistudio/integration-procedure.d.ts.map +1 -1
  218. package/dist/parsers/omnistudio/integration-procedure.js +3 -2
  219. package/dist/parsers/omnistudio/integration-procedure.js.map +1 -1
  220. package/dist/parsers/omnistudio/overlap-detector.d.ts.map +1 -1
  221. package/dist/parsers/omnistudio/overlap-detector.js +2 -9
  222. package/dist/parsers/omnistudio/overlap-detector.js.map +1 -1
  223. package/dist/parsers/omnistudio/process.d.ts.map +1 -1
  224. package/dist/parsers/omnistudio/process.js +3 -2
  225. package/dist/parsers/omnistudio/process.js.map +1 -1
  226. package/dist/parsers/omnistudio/ui-card.d.ts.map +1 -1
  227. package/dist/parsers/omnistudio/ui-card.js +3 -2
  228. package/dist/parsers/omnistudio/ui-card.js.map +1 -1
  229. package/dist/parsers/rules/custom-metadata-type.yml +7 -0
  230. package/dist/parsers/rules/custom-setting.yml +24 -0
  231. package/dist/parsers/vlocity/common.d.ts +16 -0
  232. package/dist/parsers/vlocity/common.d.ts.map +1 -1
  233. package/dist/parsers/vlocity/common.js +62 -0
  234. package/dist/parsers/vlocity/common.js.map +1 -1
  235. package/dist/parsers/vlocity/data-raptor.d.ts.map +1 -1
  236. package/dist/parsers/vlocity/data-raptor.js +43 -14
  237. package/dist/parsers/vlocity/data-raptor.js.map +1 -1
  238. package/dist/render/__tests__/sarif.test.js +1 -4
  239. package/dist/render/__tests__/sarif.test.js.map +1 -1
  240. package/dist/render/sarif.d.ts.map +1 -1
  241. package/dist/render/sarif.js +1 -3
  242. package/dist/render/sarif.js.map +1 -1
  243. package/dist/storage/__tests__/analysis-tables-migration.test.js +1 -1
  244. package/dist/storage/__tests__/graph-store.test.js +13 -0
  245. package/dist/storage/__tests__/graph-store.test.js.map +1 -1
  246. package/dist/storage/__tests__/migrations.test.js +4 -4
  247. package/dist/storage/__tests__/rename-stability.test.js.map +1 -1
  248. package/dist/storage/__tests__/sync-generation.test.d.ts +2 -0
  249. package/dist/storage/__tests__/sync-generation.test.d.ts.map +1 -0
  250. package/dist/storage/__tests__/sync-generation.test.js +159 -0
  251. package/dist/storage/__tests__/sync-generation.test.js.map +1 -0
  252. package/dist/storage/interfaces.d.ts +57 -0
  253. package/dist/storage/interfaces.d.ts.map +1 -1
  254. package/dist/storage/sqlite/graph-store.d.ts +15 -1
  255. package/dist/storage/sqlite/graph-store.d.ts.map +1 -1
  256. package/dist/storage/sqlite/graph-store.js +78 -0
  257. package/dist/storage/sqlite/graph-store.js.map +1 -1
  258. package/dist/storage/sqlite/migrations.d.ts.map +1 -1
  259. package/dist/storage/sqlite/migrations.js +19 -0
  260. package/dist/storage/sqlite/migrations.js.map +1 -1
  261. package/dist/storage/sqlite/rename-stability.d.ts +1 -1
  262. package/dist/storage/sqlite/rename-stability.d.ts.map +1 -1
  263. package/dist/storage/sqlite/rename-stability.js +2 -6
  264. package/dist/storage/sqlite/rename-stability.js.map +1 -1
  265. package/package.json +1 -1
@@ -11,10 +11,11 @@ import { EmbeddingQueue } from "../embedding/index.js";
11
11
  import { resolveOrg } from "../extractors/live-org/auth.js";
12
12
  import { bulkRetrieve } from "../extractors/live-org/bulk-retrieve.js";
13
13
  import { probeCapabilities } from "../extractors/live-org/capabilities.js";
14
- import { iterChanges } from "../extractors/live-org/source-member.js";
15
- import { startLivenessProbe, } from "../extractors/live-org/liveness.js";
14
+ import { startLivenessProbe } from "../extractors/live-org/liveness.js";
16
15
  import { dataPool, metadataPool, toolingPool } from "../extractors/live-org/rate-limit.js";
16
+ import { iterChanges } from "../extractors/live-org/source-member.js";
17
17
  import { loadAllRules } from "../parsers/rules/_loader.js";
18
+ import { evaluateDeletionGuard, resolveMaxDropRatio } from "./detect-deletions-guard.js";
18
19
  const NOOP_LOG = {
19
20
  debug: () => { },
20
21
  info: () => { },
@@ -112,7 +113,83 @@ function unwrapApexEnvelope(content) {
112
113
  }
113
114
  return { body: content };
114
115
  }
115
- function adaptParserInput(ref, content) {
116
+ /** Maps the Vlocity runner's emitted memberType (the bare VlocityDataPackType)
117
+ * to the parser-registry key the four rich Vlocity parsers register under.
118
+ * Only these four DataPack types have dedicated parsers; the other ~44 fall
119
+ * through to the opaque node parser (node-only, by design). */
120
+ const VLOCITY_MEMBER_TO_PARSER = {
121
+ DataRaptor: "VlocityDataRaptor",
122
+ VlocityDataRaptor: "VlocityDataRaptor",
123
+ IntegrationProcedure: "VlocityIntegrationProcedure",
124
+ VlocityIntegrationProcedure: "VlocityIntegrationProcedure",
125
+ OmniScript: "VlocityOmniScript",
126
+ VlocityOmniScript: "VlocityOmniScript",
127
+ VlocityCard: "VlocityCard",
128
+ };
129
+ /**
130
+ * Build the text embedded for a node's semantic vector. Beyond the
131
+ * label/qname/description baseline, this folds in CONFIG VALUES so
132
+ * `find_similar` matches on what a config artifact actually resolves to —
133
+ * a Custom Metadata record's field values, a Custom Label's text, a Custom
134
+ * Setting's rows. Without this, searching "endpoint for product push" can't
135
+ * reach `End_Point_Urls.ProductPush_PLDT` because only its name was embedded.
136
+ * Exported for tests.
137
+ */
138
+ export function buildEmbedText(label, qname, attributes) {
139
+ const a = attributes ?? {};
140
+ const parts = [`${label}: ${qname}`];
141
+ if (typeof a.description === "string" && a.description)
142
+ parts.push(a.description);
143
+ // Custom Metadata record field values: [{field,value}] | {field:value} | object.
144
+ const values = a.values;
145
+ if (Array.isArray(values)) {
146
+ for (const v of values) {
147
+ const f = v.field;
148
+ const val = v.value;
149
+ if (f != null && val != null)
150
+ parts.push(`${String(f)} = ${stringifyConfigVal(val)}`);
151
+ }
152
+ }
153
+ else if (values && typeof values === "object") {
154
+ for (const [k, v] of Object.entries(values)) {
155
+ if (v != null && v !== "")
156
+ parts.push(`${k} = ${stringifyConfigVal(v)}`);
157
+ }
158
+ }
159
+ // Custom Label text value.
160
+ if (typeof a.value === "string" && a.value)
161
+ parts.push(a.value);
162
+ // Custom Setting rows: array of row objects (field=value per row).
163
+ const rows = a.customSettingRows;
164
+ if (Array.isArray(rows)) {
165
+ for (const row of rows) {
166
+ if (row && typeof row === "object") {
167
+ const kv = Object.entries(row)
168
+ .filter(([, v]) => v != null && v !== "")
169
+ .map(([k, v]) => `${k}=${stringifyConfigVal(v)}`)
170
+ .join(", ");
171
+ if (kv)
172
+ parts.push(kv);
173
+ }
174
+ }
175
+ }
176
+ return parts.join("\n");
177
+ }
178
+ function stringifyConfigVal(v) {
179
+ if (v == null)
180
+ return "";
181
+ if (typeof v === "object") {
182
+ try {
183
+ return JSON.stringify(v);
184
+ }
185
+ catch {
186
+ return String(v);
187
+ }
188
+ }
189
+ return String(v);
190
+ }
191
+ /** Exported for tests — maps a RawMember to the registered parser's input shape. */
192
+ export function adaptParserInput(ref, content) {
116
193
  switch (ref.memberType) {
117
194
  case "ApexClass": {
118
195
  const { body, metaXml } = unwrapApexEnvelope(content);
@@ -167,10 +244,24 @@ function adaptParserInput(ref, content) {
167
244
  }
168
245
  return { type: ref.memberType, input: { name: ref.memberName, metadata } };
169
246
  }
247
+ // The Vlocity runner emits the bare VlocityDataPackType as memberType
248
+ // ("DataRaptor", "IntegrationProcedure", "OmniScript", "VlocityCard"),
249
+ // but the four rich parsers register under "Vlocity"-prefixed type names
250
+ // ("VlocityDataRaptor", …). Without this mapping the runner's members
251
+ // never matched a case here and fell through to the opaque fallback —
252
+ // shallow nodes with zero DR_/IP_/OS_ edges. Only "VlocityCard" happened
253
+ // to align on both sides, which is why a real ingest produced VC edges
254
+ // but no DataRaptor/IP/OmniScript lineage. The golden tests call the
255
+ // parsers directly, so this runner→dispatch seam was never covered.
256
+ // The legacy "Vlocity*"-prefixed keys are kept too in case any caller
257
+ // (or a future filesystem source) emits them.
258
+ case "DataRaptor":
170
259
  case "VlocityDataRaptor":
260
+ case "IntegrationProcedure":
171
261
  case "VlocityIntegrationProcedure":
172
- case "VlocityCard":
173
- case "VlocityOmniScript": {
262
+ case "OmniScript":
263
+ case "VlocityOmniScript":
264
+ case "VlocityCard": {
174
265
  let datapack = content;
175
266
  try {
176
267
  datapack = JSON.parse(content);
@@ -178,12 +269,53 @@ function adaptParserInput(ref, content) {
178
269
  catch {
179
270
  /* keep raw */
180
271
  }
181
- return { type: ref.memberType, input: { name: ref.memberName, datapack } };
272
+ return {
273
+ type: VLOCITY_MEMBER_TO_PARSER[ref.memberType] ?? ref.memberType,
274
+ input: { name: ref.memberName, datapack },
275
+ };
276
+ }
277
+ default: {
278
+ // Long-tail metadata types: route to a registered parser when one
279
+ // exists (the 21 rule-based YAML parsers register under their raw
280
+ // metadata type name, e.g. "Layout", "Dashboard"), otherwise fall
281
+ // back to the opaque node parser so the member still becomes a
282
+ // graph node. Before this fallback every generic-extractor member
283
+ // was silently dropped here — extracted, logged as ✓, and never
284
+ // merged — which left grant/reference edges dangling (e.g. 59k
285
+ // GRANTS_TAB_ACCESS edges pointing at CustomTab nodes that were
286
+ // never created).
287
+ if (parserRegistry.for(ref.memberType)) {
288
+ return { type: ref.memberType, input: genericRuleInput(ref.memberName, content) };
289
+ }
290
+ return {
291
+ type: "OpaqueMetadata",
292
+ input: { metadataType: ref.memberType, name: ref.memberName, raw: content },
293
+ };
182
294
  }
183
- default:
184
- return null;
185
295
  }
186
296
  }
297
+ /**
298
+ * Build the `{ name, xml, ...siblings }` input shape the rule-based parsers'
299
+ * `xml-string` coercion expects. Filesystem sources hand us raw XML; the
300
+ * live generic extractor hands us the JSON-serialized `metadata.read()`
301
+ * object — whose keys mirror the parsed XML root, so they can be promoted
302
+ * to sibling keys directly (coerceInput merges siblings into the record).
303
+ */
304
+ function genericRuleInput(name, content) {
305
+ const s = (content ?? "").trimStart();
306
+ if (s.startsWith("<"))
307
+ return { name, xml: content };
308
+ let parsed = {};
309
+ try {
310
+ const j = JSON.parse(s);
311
+ if (j && typeof j === "object" && !Array.isArray(j))
312
+ parsed = j;
313
+ }
314
+ catch {
315
+ /* not JSON — leave record empty; the node still gets name + label */
316
+ }
317
+ return { xml: "", ...parsed, name };
318
+ }
187
319
  export async function liveIngest(opts) {
188
320
  const logger = opts.logger ?? NOOP_LOG;
189
321
  // Load declarative rule parsers (idempotent).
@@ -206,617 +338,712 @@ export async function liveIngest(opts) {
206
338
  apiVersion,
207
339
  createdAt: now,
208
340
  });
209
- const caps = await probeCapabilities(resolved.conn);
210
- logger.info("live-ingest: probed capabilities", { caps });
211
- // Discovery is also invoked implicitly inside bulkRetrieve, but logging the
212
- // type-count up-front gives operators a quick coverage signal.
341
+ // W1.5-08: flag the org row as having an in-flight ingest before any
342
+ // extractor work. Done after upsertOrg so the row definitely exists.
343
+ // The matching markSyncComplete / markSyncFailed calls below are wired
344
+ // into a try/catch around the whole ingest body so concurrent MCP
345
+ // readers always see in_progress flip back to 0 even when ingest throws.
346
+ // Failure to record sync-start is non-fatal — log and continue rather
347
+ // than aborting an ingest because the bookkeeping column is unhappy.
348
+ const ingestStartedIso = new Date(now).toISOString();
213
349
  try {
214
- const { discoverMetadataTypes } = await import("../extractors/live-org/discovery.js");
215
- const discovered = await discoverMetadataTypes(resolved.conn, apiVersion);
216
- logger.info("live-ingest: discovered metadata types", { count: discovered.length });
350
+ graph.markSyncStarted(resolved.orgId, ingestStartedIso);
217
351
  }
218
352
  catch (e) {
219
- logger.warn("live-ingest: metadata.describe failed", { err: e.message });
220
- }
221
- const existing = graph.getOrg(resolved.orgId);
222
- const requestedMode = opts.mode ?? "auto";
223
- let mode;
224
- if (requestedMode === "auto") {
225
- mode = existing?.lastSyncedAt && caps.sourceTracking ? "incremental" : "full";
226
- }
227
- else {
228
- mode = requestedMode;
353
+ logger.warn("live-ingest: markSyncStarted failed", { err: e.message });
229
354
  }
230
- if (!opts.skipSnapshot && opts.snapshotStore) {
355
+ // W1.5-08: the rest of the ingest runs inside this try so a thrown
356
+ // exception still flips `sync_in_progress` back to 0 via markSyncFailed.
357
+ // Per-resolver try/catch isolation inside bulkRetrieve / live-ingest is
358
+ // preserved — this wrapper only catches exceptions that escape *all* of
359
+ // those inner handlers (i.e. genuinely fatal failures).
360
+ try {
361
+ const caps = await probeCapabilities(resolved.conn);
362
+ logger.info("live-ingest: probed capabilities", { caps });
363
+ // Discovery is also invoked implicitly inside bulkRetrieve, but logging the
364
+ // type-count up-front gives operators a quick coverage signal.
231
365
  try {
232
- opts.snapshotStore.createSnapshot(resolved.orgId, `pre-sync-${new Date(now).toISOString()}`, true);
366
+ const { discoverMetadataTypes } = await import("../extractors/live-org/discovery.js");
367
+ const discovered = await discoverMetadataTypes(resolved.conn, apiVersion);
368
+ logger.info("live-ingest: discovered metadata types", { count: discovered.length });
233
369
  }
234
370
  catch (e) {
235
- logger.warn("live-ingest: snapshot failed", { err: e.message });
236
- }
237
- }
238
- let membersProcessed = 0;
239
- let parseErrors = 0;
240
- let deletions = 0;
241
- // Set in the full-sync branch if the bulkRetrieve stream itself throws.
242
- // Detect-deletions reads it post-fan-out to bail out instead of mass-
243
- // wiping nodes that were never visited due to the abort.
244
- let streamAborted = false;
245
- const touchedQnames = new Set();
246
- // Skip/warning report collected across all branches. Populated by the
247
- // full-sync fan-out via bulkRetrieve's onError callback; incremental mode
248
- // currently emits no entries (single-stream iterChanges has no per-source
249
- // failure surface). Returned on LiveIngestResult.warnings so MCP consumers
250
- // can read what was skipped without parsing stdout.
251
- const skipReport = { skips: [] };
252
- const parseCtxBase = {
253
- orgId: resolved.orgId,
254
- parseTimestamp: new Date(now).toISOString(),
255
- namespace: null,
256
- logger,
257
- };
258
- const embedQueue = opts.vectorStore
259
- ? new EmbeddingQueue({
260
- vectorStore: opts.vectorStore,
261
- onError: (err) => logger.warn("live-ingest: embedding batch failed", { err: err.message }),
262
- })
263
- : null;
264
- const handleParsed = (parsed) => {
265
- if (parsed.nodes.length) {
266
- graph.mergeNodes(parsed.nodes);
267
- for (const n of parsed.nodes)
268
- touchedQnames.add(String(n.qualifiedName));
269
- }
270
- if (parsed.edges.length)
271
- graph.mergeEdges(parsed.edges);
272
- if (parsed.snippets?.length) {
273
- graph.transaction(() => {
274
- for (const s of parsed.snippets ?? []) {
275
- graph.upsertSnippet(s);
276
- }
277
- });
371
+ logger.warn("live-ingest: metadata.describe failed", { err: e.message });
278
372
  }
279
- if (embedQueue) {
280
- for (const n of parsed.nodes) {
281
- const desc = n.attributes?.description;
282
- const text = `${n.label}: ${n.qualifiedName}\n${typeof desc === "string" ? desc : ""}`;
283
- embedQueue.push({
284
- qname: String(n.qualifiedName),
285
- text,
286
- orgId: String(n.orgId),
287
- label: n.label,
288
- });
289
- }
373
+ const existing = graph.getOrg(resolved.orgId);
374
+ const requestedMode = opts.mode ?? "auto";
375
+ let mode;
376
+ if (requestedMode === "auto") {
377
+ mode = existing?.lastSyncedAt && caps.sourceTracking ? "incremental" : "full";
290
378
  }
291
- };
292
- const debugProcess = process.env.SFGRAPH_DEBUG_INGEST === "1";
293
- const processOne = async (ref, content) => {
294
- const qnameForLog = `${ref.memberType}:${ref.memberName}`;
295
- if (ref.obsolete) {
296
- // Build the qualified name same way parsers would: best-effort by member name.
297
- const qname = asQualifiedName(`${ref.memberType}:${ref.memberName}`);
298
- if (debugProcess)
299
- console.log(`ingest: [trace] delete ← ${qnameForLog}`);
300
- graph.deleteEdgesFor(resolved.orgId, qname);
301
- graph.deleteNode(resolved.orgId, qname);
302
- if (debugProcess)
303
- console.log(`ingest: [trace] delete ✓ ${qnameForLog}`);
304
- deletions += 1;
305
- return;
306
- }
307
- const adapted = adaptParserInput(ref, content);
308
- if (!adapted)
309
- return;
310
- const parser = parserRegistry.for(adapted.type);
311
- if (!parser)
312
- return;
313
- try {
314
- const ctx = {
315
- ...parseCtxBase,
316
- sourceUri: ref.sourceUri,
317
- namespace: ref.namespace ?? null,
318
- };
319
- if (debugProcess)
320
- console.log(`ingest: [trace] parse ← ${qnameForLog}`);
321
- const result = await parser.parse(adapted.input, ctx);
322
- if (debugProcess)
323
- console.log(`ingest: [trace] parse ✓ ${qnameForLog} (nodes=${result.nodes.length} edges=${result.edges.length})`);
324
- if (debugProcess)
325
- console.log(`ingest: [trace] graph-merge ← ${qnameForLog}`);
326
- handleParsed(result);
327
- if (debugProcess)
328
- console.log(`ingest: [trace] graph-merge ✓ ${qnameForLog}`);
329
- membersProcessed += 1;
379
+ else {
380
+ mode = requestedMode;
330
381
  }
331
- catch (e) {
332
- parseErrors += 1;
333
- logger.warn("live-ingest: parser failure", {
334
- type: ref.memberType,
335
- name: ref.memberName,
336
- err: e.message,
337
- });
338
- if (debugProcess) {
339
- console.error(`ingest: [trace] FAILURE ${qnameForLog}: ${e.message}\n${e.stack}`);
382
+ if (!opts.skipSnapshot && opts.snapshotStore) {
383
+ try {
384
+ opts.snapshotStore.createSnapshot(resolved.orgId, `pre-sync-${new Date(now).toISOString()}`, true);
385
+ }
386
+ catch (e) {
387
+ logger.warn("live-ingest: snapshot failed", { err: e.message });
340
388
  }
341
389
  }
342
- };
343
- if (mode === "incremental") {
344
- const sinceIso = existing?.lastSyncedAt
345
- ? new Date(existing.lastSyncedAt).toISOString()
346
- : new Date(now - 24 * 3600 * 1000).toISOString();
347
- for await (const ref of iterChanges(resolved.conn, resolved.orgId, sinceIso)) {
348
- // For incremental, content is best fetched on-demand; for deletions we skip content.
349
- await processOne(ref, "");
350
- }
351
- }
352
- else {
353
- // The outer iteration is also wrapped: bulkRetrieve uses fail-soft per
354
- // source, but a top-level catch protects against any iterator-protocol
355
- // surprises (e.g. generator throws during `next()` before yielding).
356
- // Read live pool caps so the log reflects --tooling-pool/--metadata-pool/
357
- // --data-pool overrides or SFGRAPH_*_POOL env vars rather than hardcoded
358
- // defaults.
359
- const { toolingPool, metadataPool, dataPool } = await import("../extractors/live-org/rate-limit.js");
360
- const tCap = toolingPool._store
361
- .storeOptions.maxConcurrent;
362
- const mCap = metadataPool._store
363
- .storeOptions.maxConcurrent;
364
- const dCap = dataPool._store
365
- .storeOptions.maxConcurrent;
366
- console.log(`ingest: starting full sync (Tooling pool ${tCap} / Metadata pool ${mCap} / Data pool ${dCap} concurrent)`);
367
- // MCD baseline runs BEFORE the parser fan-out so any (src, dst, REFERENCES)
368
- // edges it writes can be overwritten if a real parser produces the same
369
- // shape later. The MCD path uses generic REFERENCES while parsers use
370
- // specific rel-types — so in practice MCD and parsed coexist in
371
- // different edge tables rather than competing for the same row, but
372
- // the pre-fan-out ordering preserves the "parsed wins" guarantee for
373
- // the (rare) case where a parser does emit REFERENCES.
374
- if (!opts.disableMcdBaseline) {
390
+ let membersProcessed = 0;
391
+ let parseErrors = 0;
392
+ let deletions = 0;
393
+ // Set in the full-sync branch if the bulkRetrieve stream itself throws.
394
+ // Detect-deletions reads it post-fan-out to bail out instead of mass-
395
+ // wiping nodes that were never visited due to the abort.
396
+ let streamAborted = false;
397
+ const touchedQnames = new Set();
398
+ // W1.5-07: maintain per-label touched-qnames sets in parallel with the
399
+ // flat `touchedQnames` set so the deletion-sweep guard can compute
400
+ // priorCount/touchedCount per label without re-deriving labels later.
401
+ const touchedQnamesByLabel = new Map();
402
+ // Skip/warning report collected across all branches. Populated by the
403
+ // full-sync fan-out via bulkRetrieve's onError callback; incremental mode
404
+ // currently emits no entries (single-stream iterChanges has no per-source
405
+ // failure surface). Returned on LiveIngestResult.warnings so MCP consumers
406
+ // can read what was skipped without parsing stdout.
407
+ const skipReport = { skips: [] };
408
+ // Phase 1.5 W1.5-02: namespaced wedge warnings populated by bulkRetrieve's
409
+ // soft-isolate path (`wedge:<source>:<stage>:<detail>`). Concatenated with
410
+ // the legacy `${label}: ${reason}` strings from skipReport.skips into the
411
+ // final LiveIngestResult.warnings array. See bulk-retrieve.ts WedgeCoordinator.
412
+ const wedgeWarnings = [];
413
+ const parseCtxBase = {
414
+ orgId: resolved.orgId,
415
+ parseTimestamp: new Date(now).toISOString(),
416
+ namespace: null,
417
+ logger,
418
+ };
419
+ const embedQueue = opts.vectorStore
420
+ ? new EmbeddingQueue({
421
+ vectorStore: opts.vectorStore,
422
+ onError: (err) => logger.warn("live-ingest: embedding batch failed", { err: err.message }),
423
+ })
424
+ : null;
425
+ const handleParsed = (parsed) => {
426
+ if (parsed.nodes.length) {
427
+ graph.mergeNodes(parsed.nodes);
428
+ for (const n of parsed.nodes) {
429
+ const qn = String(n.qualifiedName);
430
+ touchedQnames.add(qn);
431
+ // W1.5-07: also bucket by label for the per-label deletion guard.
432
+ let bucket = touchedQnamesByLabel.get(n.label);
433
+ if (!bucket) {
434
+ bucket = new Set();
435
+ touchedQnamesByLabel.set(n.label, bucket);
436
+ }
437
+ bucket.add(qn);
438
+ }
439
+ }
440
+ if (parsed.edges.length)
441
+ graph.mergeEdges(parsed.edges);
442
+ if (parsed.snippets?.length) {
443
+ graph.transaction(() => {
444
+ for (const s of parsed.snippets ?? []) {
445
+ graph.upsertSnippet(s);
446
+ }
447
+ });
448
+ }
449
+ if (embedQueue) {
450
+ for (const n of parsed.nodes) {
451
+ embedQueue.push({
452
+ qname: String(n.qualifiedName),
453
+ text: buildEmbedText(n.label, String(n.qualifiedName), n.attributes),
454
+ orgId: String(n.orgId),
455
+ label: n.label,
456
+ });
457
+ }
458
+ }
459
+ };
460
+ const debugProcess = process.env.SFGRAPH_DEBUG_INGEST === "1";
461
+ const processOne = async (ref, content) => {
462
+ const qnameForLog = `${ref.memberType}:${ref.memberName}`;
463
+ if (ref.obsolete) {
464
+ // Build the qualified name same way parsers would: best-effort by member name.
465
+ const qname = asQualifiedName(`${ref.memberType}:${ref.memberName}`);
466
+ if (debugProcess)
467
+ console.log(`ingest: [trace] delete ← ${qnameForLog}`);
468
+ graph.deleteEdgesFor(resolved.orgId, qname);
469
+ graph.deleteNode(resolved.orgId, qname);
470
+ if (debugProcess)
471
+ console.log(`ingest: [trace] delete ✓ ${qnameForLog}`);
472
+ deletions += 1;
473
+ return;
474
+ }
475
+ const adapted = adaptParserInput(ref, content);
476
+ if (!adapted)
477
+ return;
478
+ const parser = parserRegistry.for(adapted.type);
479
+ if (!parser)
480
+ return;
375
481
  try {
376
- const { runMcdBaseline } = await import("../extractors/live-org/extractors/mcd-baseline.js");
377
- const mcdCtx = {
482
+ const ctx = {
378
483
  ...parseCtxBase,
379
- sourceUri: "mcd-baseline://tooling-soql",
484
+ sourceUri: ref.sourceUri,
485
+ namespace: ref.namespace ?? null,
380
486
  };
381
- const mcd = await runMcdBaseline(resolved.conn, {
382
- orgId: resolved.orgId,
383
- ctx: mcdCtx,
384
- onError: (label, err) => {
385
- skipReport.skips.push({
386
- label,
387
- reason: err.message,
388
- category: "unknown",
389
- });
390
- },
391
- });
392
- if (mcd.edges.length > 0) {
393
- graph.mergeEdges(mcd.edges);
394
- }
395
- logger.info("live-ingest: MCD baseline complete", {
396
- edges: mcd.edges.length,
397
- byType: mcd.byType,
398
- });
487
+ if (debugProcess)
488
+ console.log(`ingest: [trace] parse ← ${qnameForLog}`);
489
+ const result = await parser.parse(adapted.input, ctx);
490
+ if (debugProcess)
491
+ console.log(`ingest: [trace] parse ✓ ${qnameForLog} (nodes=${result.nodes.length} edges=${result.edges.length})`);
492
+ if (debugProcess)
493
+ console.log(`ingest: [trace] graph-merge ← ${qnameForLog}`);
494
+ handleParsed(result);
495
+ if (debugProcess)
496
+ console.log(`ingest: [trace] graph-merge ✓ ${qnameForLog}`);
497
+ membersProcessed += 1;
399
498
  }
400
499
  catch (e) {
401
- logger.warn("live-ingest: MCD baseline failed", { err: e.message });
402
- }
403
- }
404
- const fanOutStart = Date.now();
405
- let progressCount = 0;
406
- let lastTickAt = Date.now();
407
- let lastActivityAt = Date.now();
408
- let lastMemberLabel = "(none)";
409
- const PROGRESS_TICK_MS = 5000; // emit a heartbeat at least every 5s
410
- if (opts.onlyLabels && opts.onlyLabels.size > 0) {
411
- console.log(`ingest: --only filter active (${opts.onlyLabels.size} source${opts.onlyLabels.size === 1 ? "" : "s"}): ${[...opts.onlyLabels].join(", ")}`);
412
- }
413
- // Debug mode: SFGRAPH_DEBUG_INGEST=1 enables a heartbeat timer that
414
- // prints heap usage + seconds-since-last-activity every 10s. Critical
415
- // for diagnosing silent ingest deaths (OOM, native segfault, hung SF
416
- // call) where the normal progress log just stops without an error.
417
- const debug = process.env.SFGRAPH_DEBUG_INGEST === "1";
418
- let heartbeatTimer = null;
419
- let keepAlive = null;
420
- let livenessProbe = null;
421
- // Track signal handlers we register so we can remove them in finally.
422
- // Without this, every invocation of liveIngest in debug mode leaked one
423
- // listener per signal (SIGTERM/SIGINT/SIGUSR2), eventually triggering
424
- // MaxListenersExceededWarning and N-fold handler firing on first ^C in
425
- // multi-org / programmatic flows. (Audit finding C2.)
426
- const installedSignalHandlers = [];
427
- // streamAborted (hoisted at function scope) gates the post-fan-out
428
- // detect-deletions block. If the bulkRetrieve stream itself throws
429
- // (vs individual sources fail-softing), we've only seen a fraction of
430
- // the org and the `touchedQnames` set is not safe to use for stale-
431
- // qname computation — without this guard a transient SF error would
432
- // mass-delete the untouched 70% of the graph. (Audit finding C1.)
433
- try {
434
- // Keep-alive sentinel: a ref'd timer that exists purely to prevent the
435
- // event loop from draining while we're awaiting Bottleneck-scheduled
436
- // work. Bottleneck's local-datastore reservoir-refresh timer is unref'd
437
- // internally, jsforce's HTTP keep-alive sockets idle between requests,
438
- // and Node will happily exit(0) on a perfectly healthy pending Promise
439
- // if no ref'd handle is left.
440
- keepAlive = setInterval(() => { }, 60_000);
441
- // Background liveness probe. Polls conn.identity() every 30s with a
442
- // 10s deadline; after two consecutive failures it logs a prominent
443
- // "CONNECTION LOST" warning so the user knows why surviving
444
- // extractors are about to time out one by one. We intentionally do
445
- // NOT abort the ingest from here — per-call 60s timeouts (added in
446
- // 1.1.3) already cap exposure, and aborting mid-flight would need
447
- // an AbortSignal threaded through every extractor for marginal
448
- // benefit. The probe's job is observability, not control flow.
449
- // Disable via SFGRAPH_NO_LIVENESS_PROBE=1 for hermetic test runs
450
- // where the mock connection has no identity() implementation.
451
- if (process.env.SFGRAPH_NO_LIVENESS_PROBE !== "1") {
452
- livenessProbe = startLivenessProbe(resolved.conn);
453
- }
454
- // Wall-clock heartbeat — always on. Without this, silent phases (where
455
- // all in-window sources are awaiting Bottleneck-queued metadata reads
456
- // without yielding) look indistinguishable from a hung process. The
457
- // per-record progress tick at the bottom of the fan-out loop only
458
- // fires when a record arrives; this fires on a real timer regardless.
459
- // Debug mode adds heap/rss; baseline shows processed/lastSource/idle.
460
- const showPoolCounters = debug || process.env.SFGRAPH_DEBUG_POOLS === "1";
461
- heartbeatTimer = setInterval(() => {
462
- const idleSec = Math.round((Date.now() - lastActivityAt) / 1000);
463
- if (idleSec < 10)
464
- return; // suppress chatter when records are flowing
465
- if (debug) {
466
- const mem = process.memoryUsage();
467
- const heapMb = Math.round(mem.heapUsed / 1024 / 1024);
468
- const rssMb = Math.round(mem.rss / 1024 / 1024);
469
- const externalMb = Math.round(mem.external / 1024 / 1024);
470
- console.log(`ingest: [heartbeat] processed=${progressCount} lastSource=${lastMemberLabel} idle=${idleSec}s heap=${heapMb}MB rss=${rssMb}MB ext=${externalMb}MB`);
471
- }
472
- else {
473
- console.log(`ingest: …still working — processed=${progressCount} idle=${idleSec}s lastSource=${lastMemberLabel}`);
500
+ parseErrors += 1;
501
+ logger.warn("live-ingest: parser failure", {
502
+ type: ref.memberType,
503
+ name: ref.memberName,
504
+ err: e.message,
505
+ });
506
+ if (debugProcess) {
507
+ console.error(`ingest: [trace] FAILURE ${qnameForLog}: ${e.message}\n${e.stack}`);
474
508
  }
475
- // Pool diagnostics: snapshot Bottleneck state so a wedge can be
476
- // attributed concretely to reservoir starvation vs. stuck-in-flight
477
- // jobs vs. pre-yield iterator hangs. Counts are synchronous; the
478
- // reservoir read is async but fire-and-forget — by the time it
479
- // resolves we just log it.
480
- if (showPoolCounters) {
481
- const snapshot = [
482
- ["tool", toolingPool],
483
- ["meta", metadataPool],
484
- ["data", dataPool],
485
- ];
486
- for (const [name, p] of snapshot) {
487
- const c = p.counts();
488
- p.currentReservoir().then((reservoir) => {
489
- console.log(`ingest: [pool ${name}] running=${c.RUNNING} executing=${c.EXECUTING} queued=${c.QUEUED} received=${c.RECEIVED} reservoir=${reservoir}`);
490
- }, () => {
491
- /* swallow — never let a diag failure kill ingest */
492
- });
509
+ }
510
+ };
511
+ if (mode === "incremental") {
512
+ const sinceIso = existing?.lastSyncedAt
513
+ ? new Date(existing.lastSyncedAt).toISOString()
514
+ : new Date(now - 24 * 3600 * 1000).toISOString();
515
+ for await (const ref of iterChanges(resolved.conn, resolved.orgId, sinceIso)) {
516
+ // For incremental, content is best fetched on-demand; for deletions we skip content.
517
+ await processOne(ref, "");
518
+ }
519
+ }
520
+ else {
521
+ // The outer iteration is also wrapped: bulkRetrieve uses fail-soft per
522
+ // source, but a top-level catch protects against any iterator-protocol
523
+ // surprises (e.g. generator throws during `next()` before yielding).
524
+ // Read live pool caps so the log reflects --tooling-pool/--metadata-pool/
525
+ // --data-pool overrides or SFGRAPH_*_POOL env vars rather than hardcoded
526
+ // defaults.
527
+ const { toolingPool, metadataPool, dataPool } = await import("../extractors/live-org/rate-limit.js");
528
+ const tCap = toolingPool._store.storeOptions.maxConcurrent;
529
+ const mCap = metadataPool._store.storeOptions.maxConcurrent;
530
+ const dCap = dataPool
531
+ ._store.storeOptions.maxConcurrent;
532
+ console.log(`ingest: starting full sync (Tooling pool ${tCap} / Metadata pool ${mCap} / Data pool ${dCap} concurrent)`);
533
+ // MCD baseline runs BEFORE the parser fan-out so any (src, dst, REFERENCES)
534
+ // edges it writes can be overwritten if a real parser produces the same
535
+ // shape later. The MCD path uses generic REFERENCES while parsers use
536
+ // specific rel-types — so in practice MCD and parsed coexist in
537
+ // different edge tables rather than competing for the same row, but
538
+ // the pre-fan-out ordering preserves the "parsed wins" guarantee for
539
+ // the (rare) case where a parser does emit REFERENCES.
540
+ if (!opts.disableMcdBaseline) {
541
+ try {
542
+ const { runMcdBaseline } = await import("../extractors/live-org/extractors/mcd-baseline.js");
543
+ const mcdCtx = {
544
+ ...parseCtxBase,
545
+ sourceUri: "mcd-baseline://tooling-soql",
546
+ };
547
+ const mcd = await runMcdBaseline(resolved.conn, {
548
+ orgId: resolved.orgId,
549
+ ctx: mcdCtx,
550
+ onError: (label, err) => {
551
+ skipReport.skips.push({
552
+ label,
553
+ reason: err.message,
554
+ category: "unknown",
555
+ });
556
+ },
557
+ });
558
+ if (mcd.edges.length > 0) {
559
+ graph.mergeEdges(mcd.edges);
493
560
  }
561
+ logger.info("live-ingest: MCD baseline complete", {
562
+ edges: mcd.edges.length,
563
+ byType: mcd.byType,
564
+ });
494
565
  }
495
- }, 10_000);
496
- if (debug) {
497
- console.log("ingest: [debug] SFGRAPH_DEBUG_INGEST=1 active — heartbeat every 10s");
498
- // Signal handlers — surface what was running when the user (or OS)
499
- // sends a kill so silent terminations have at least one breadcrumb.
500
- const onSig = (sig) => () => {
501
- console.error(`ingest: [debug] received ${sig} after ${Math.round((Date.now() - fanOutStart) / 1000)}s — processed=${progressCount} lastSource=${lastMemberLabel}`);
502
- console.error(new Error(`signal:${sig}`).stack);
503
- process.exitCode = 130;
504
- };
505
- const onUsr2 = () => {
506
- const mem = process.memoryUsage();
507
- console.error(`ingest: [debug] SIGUSR2 — heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB rss=${Math.round(mem.rss / 1024 / 1024)}MB lastSource=${lastMemberLabel}`);
508
- };
509
- installedSignalHandlers.push({ sig: "SIGTERM", fn: onSig("SIGTERM") });
510
- installedSignalHandlers.push({ sig: "SIGINT", fn: onSig("SIGINT") });
511
- installedSignalHandlers.push({ sig: "SIGUSR2", fn: onUsr2 });
512
- for (const h of installedSignalHandlers)
513
- process.on(h.sig, h.fn);
566
+ catch (e) {
567
+ logger.warn("live-ingest: MCD baseline failed", { err: e.message });
568
+ }
569
+ }
570
+ const fanOutStart = Date.now();
571
+ let progressCount = 0;
572
+ let lastTickAt = Date.now();
573
+ let lastActivityAt = Date.now();
574
+ let lastMemberLabel = "(none)";
575
+ const PROGRESS_TICK_MS = 5000; // emit a heartbeat at least every 5s
576
+ if (opts.onlyLabels && opts.onlyLabels.size > 0) {
577
+ console.log(`ingest: --only filter active (${opts.onlyLabels.size} source${opts.onlyLabels.size === 1 ? "" : "s"}): ${[...opts.onlyLabels].join(", ")}`);
514
578
  }
579
+ // Debug mode: SFGRAPH_DEBUG_INGEST=1 enables a heartbeat timer that
580
+ // prints heap usage + seconds-since-last-activity every 10s. Critical
581
+ // for diagnosing silent ingest deaths (OOM, native segfault, hung SF
582
+ // call) where the normal progress log just stops without an error.
583
+ const debug = process.env.SFGRAPH_DEBUG_INGEST === "1";
584
+ let heartbeatTimer = null;
585
+ let keepAlive = null;
586
+ let livenessProbe = null;
587
+ // Track signal handlers we register so we can remove them in finally.
588
+ // Without this, every invocation of liveIngest in debug mode leaked one
589
+ // listener per signal (SIGTERM/SIGINT/SIGUSR2), eventually triggering
590
+ // MaxListenersExceededWarning and N-fold handler firing on first ^C in
591
+ // multi-org / programmatic flows. (Audit finding C2.)
592
+ const installedSignalHandlers = [];
593
+ // streamAborted (hoisted at function scope) gates the post-fan-out
594
+ // detect-deletions block. If the bulkRetrieve stream itself throws
595
+ // (vs individual sources fail-softing), we've only seen a fraction of
596
+ // the org and the `touchedQnames` set is not safe to use for stale-
597
+ // qname computation — without this guard a transient SF error would
598
+ // mass-delete the untouched 70% of the graph. (Audit finding C1.)
515
599
  try {
516
- for await (const member of bulkRetrieve(resolved.conn, caps, resolved.orgId, {
517
- skipReport,
518
- ...(opts.onlyLabels ? { onlyLabels: opts.onlyLabels } : {}),
519
- // The bulk-retrieve flag name follows the same convention as
520
- // the bulk-retrieve's other opts; semantically it's "do the
521
- // retrieve()" which is now on by default. Caller-side disable
522
- // routes through this same boolean inverted.
523
- ...(opts.disableOmnistudioRetrieve ? {} : { enableOmnistudioRetrieve: true }),
524
- // jsforce attaches the version it negotiated on conn.version; fall
525
- // back to a sensible recent release if absent (mocks, etc.)
526
- apiVersion: (resolved.conn.version) ?? "60.0",
527
- })) {
528
- try {
529
- await processOne(member.ref, member.content);
600
+ // Keep-alive sentinel: a ref'd timer that exists purely to prevent the
601
+ // event loop from draining while we're awaiting Bottleneck-scheduled
602
+ // work. Bottleneck's local-datastore reservoir-refresh timer is unref'd
603
+ // internally, jsforce's HTTP keep-alive sockets idle between requests,
604
+ // and Node will happily exit(0) on a perfectly healthy pending Promise
605
+ // if no ref'd handle is left.
606
+ keepAlive = setInterval(() => { }, 60_000);
607
+ // Background liveness probe. Polls conn.identity() every 30s with a
608
+ // 10s deadline; after two consecutive failures it logs a prominent
609
+ // "CONNECTION LOST" warning so the user knows why surviving
610
+ // extractors are about to time out one by one. We intentionally do
611
+ // NOT abort the ingest from here — per-call 60s timeouts (added in
612
+ // 1.1.3) already cap exposure, and aborting mid-flight would need
613
+ // an AbortSignal threaded through every extractor for marginal
614
+ // benefit. The probe's job is observability, not control flow.
615
+ // Disable via SFGRAPH_NO_LIVENESS_PROBE=1 for hermetic test runs
616
+ // where the mock connection has no identity() implementation.
617
+ if (process.env.SFGRAPH_NO_LIVENESS_PROBE !== "1") {
618
+ livenessProbe = startLivenessProbe(resolved.conn);
619
+ }
620
+ // Wall-clock heartbeat — always on. Without this, silent phases (where
621
+ // all in-window sources are awaiting Bottleneck-queued metadata reads
622
+ // without yielding) look indistinguishable from a hung process. The
623
+ // per-record progress tick at the bottom of the fan-out loop only
624
+ // fires when a record arrives; this fires on a real timer regardless.
625
+ // Debug mode adds heap/rss; baseline shows processed/lastSource/idle.
626
+ const showPoolCounters = debug || process.env.SFGRAPH_DEBUG_POOLS === "1";
627
+ heartbeatTimer = setInterval(() => {
628
+ const idleSec = Math.round((Date.now() - lastActivityAt) / 1000);
629
+ if (idleSec < 10)
630
+ return; // suppress chatter when records are flowing
631
+ if (debug) {
632
+ const mem = process.memoryUsage();
633
+ const heapMb = Math.round(mem.heapUsed / 1024 / 1024);
634
+ const rssMb = Math.round(mem.rss / 1024 / 1024);
635
+ const externalMb = Math.round(mem.external / 1024 / 1024);
636
+ console.log(`ingest: [heartbeat] processed=${progressCount} lastSource=${lastMemberLabel} idle=${idleSec}s heap=${heapMb}MB rss=${rssMb}MB ext=${externalMb}MB`);
530
637
  }
531
- catch (e) {
532
- parseErrors += 1;
533
- logger.warn("live-ingest: processOne failed", {
534
- qname: `${member.ref.memberType}:${member.ref.memberName}`,
535
- error: e.message,
536
- });
638
+ else {
639
+ console.log(`ingest: …still working — processed=${progressCount} idle=${idleSec}s lastSource=${lastMemberLabel}`);
537
640
  }
538
- progressCount += 1;
539
- lastActivityAt = Date.now();
540
- lastMemberLabel = `${member.ref.memberType}:${member.ref.memberName}`;
541
- if (progressCount % 200 === 0 || Date.now() - lastTickAt > PROGRESS_TICK_MS) {
542
- const elapsedSec = Math.round((Date.now() - fanOutStart) / 1000);
543
- const memSuffix = debug
544
- ? ` heap=${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`
545
- : "";
546
- console.log(`ingest: …${progressCount} members processed so far (${elapsedSec}s elapsed)${memSuffix}`);
547
- lastTickAt = Date.now();
641
+ // Pool diagnostics: snapshot Bottleneck state so a wedge can be
642
+ // attributed concretely to reservoir starvation vs. stuck-in-flight
643
+ // jobs vs. pre-yield iterator hangs. Counts are synchronous; the
644
+ // reservoir read is async but fire-and-forget — by the time it
645
+ // resolves we just log it.
646
+ if (showPoolCounters) {
647
+ const snapshot = [
648
+ ["tool", toolingPool],
649
+ ["meta", metadataPool],
650
+ ["data", dataPool],
651
+ ];
652
+ for (const [name, p] of snapshot) {
653
+ const c = p.counts();
654
+ p.currentReservoir().then((reservoir) => {
655
+ console.log(`ingest: [pool ${name}] running=${c.RUNNING} executing=${c.EXECUTING} queued=${c.QUEUED} received=${c.RECEIVED} reservoir=${reservoir}`);
656
+ }, () => {
657
+ /* swallow — never let a diag failure kill ingest */
658
+ });
659
+ }
548
660
  }
549
- if (progressCount % 500 === 0) {
550
- const gs = graph;
551
- if (typeof gs.checkpoint === "function")
552
- gs.checkpoint();
661
+ }, 10_000);
662
+ if (debug) {
663
+ console.log("ingest: [debug] SFGRAPH_DEBUG_INGEST=1 active — heartbeat every 10s");
664
+ // Signal handlers — surface what was running when the user (or OS)
665
+ // sends a kill so silent terminations have at least one breadcrumb.
666
+ const onSig = (sig) => () => {
667
+ console.error(`ingest: [debug] received ${sig} after ${Math.round((Date.now() - fanOutStart) / 1000)}s — processed=${progressCount} lastSource=${lastMemberLabel}`);
668
+ console.error(new Error(`signal:${sig}`).stack);
669
+ process.exitCode = 130;
670
+ };
671
+ const onUsr2 = () => {
672
+ const mem = process.memoryUsage();
673
+ console.error(`ingest: [debug] SIGUSR2 — heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB rss=${Math.round(mem.rss / 1024 / 1024)}MB lastSource=${lastMemberLabel}`);
674
+ };
675
+ installedSignalHandlers.push({ sig: "SIGTERM", fn: onSig("SIGTERM") });
676
+ installedSignalHandlers.push({ sig: "SIGINT", fn: onSig("SIGINT") });
677
+ installedSignalHandlers.push({ sig: "SIGUSR2", fn: onUsr2 });
678
+ for (const h of installedSignalHandlers)
679
+ process.on(h.sig, h.fn);
680
+ }
681
+ try {
682
+ for await (const member of bulkRetrieve(resolved.conn, caps, resolved.orgId, {
683
+ skipReport,
684
+ warnings: wedgeWarnings,
685
+ ...(opts.onlyLabels ? { onlyLabels: opts.onlyLabels } : {}),
686
+ // The bulk-retrieve flag name follows the same convention as
687
+ // the bulk-retrieve's other opts; semantically it's "do the
688
+ // retrieve()" which is now on by default. Caller-side disable
689
+ // routes through this same boolean inverted.
690
+ ...(opts.disableOmnistudioRetrieve ? {} : { enableOmnistudioRetrieve: true }),
691
+ // jsforce attaches the version it negotiated on conn.version; fall
692
+ // back to a sensible recent release if absent (mocks, etc.)
693
+ apiVersion: resolved.conn.version ?? "60.0",
694
+ })) {
695
+ try {
696
+ await processOne(member.ref, member.content);
697
+ }
698
+ catch (e) {
699
+ parseErrors += 1;
700
+ logger.warn("live-ingest: processOne failed", {
701
+ qname: `${member.ref.memberType}:${member.ref.memberName}`,
702
+ error: e.message,
703
+ });
704
+ }
705
+ progressCount += 1;
706
+ lastActivityAt = Date.now();
707
+ lastMemberLabel = `${member.ref.memberType}:${member.ref.memberName}`;
708
+ if (progressCount % 200 === 0 || Date.now() - lastTickAt > PROGRESS_TICK_MS) {
709
+ const elapsedSec = Math.round((Date.now() - fanOutStart) / 1000);
710
+ const memSuffix = debug
711
+ ? ` heap=${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`
712
+ : "";
713
+ console.log(`ingest: …${progressCount} members processed so far (${elapsedSec}s elapsed)${memSuffix}`);
714
+ lastTickAt = Date.now();
715
+ }
716
+ if (progressCount % 500 === 0) {
717
+ const gs = graph;
718
+ if (typeof gs.checkpoint === "function")
719
+ gs.checkpoint();
720
+ }
553
721
  }
722
+ console.log(`ingest: fan-out complete (${progressCount} members in ${Math.round((Date.now() - fanOutStart) / 1000)}s)`);
723
+ const gs = graph;
724
+ if (typeof gs.checkpoint === "function")
725
+ gs.checkpoint();
726
+ }
727
+ catch (e) {
728
+ // The stream itself threw (not an individual source — those are
729
+ // captured by failSoft). Mark so detect-deletions stays its hand.
730
+ streamAborted = true;
731
+ logger.warn("live-ingest: bulkRetrieve stream aborted", {
732
+ error: e.message,
733
+ });
554
734
  }
555
- console.log(`ingest: fan-out complete (${progressCount} members in ${Math.round((Date.now() - fanOutStart) / 1000)}s)`);
556
- const gs = graph;
557
- if (typeof gs.checkpoint === "function")
558
- gs.checkpoint();
559
735
  }
560
- catch (e) {
561
- // The stream itself threw (not an individual source — those are
562
- // captured by failSoft). Mark so detect-deletions stays its hand.
563
- streamAborted = true;
564
- logger.warn("live-ingest: bulkRetrieve stream aborted", {
565
- error: e.message,
566
- });
736
+ finally {
737
+ if (heartbeatTimer)
738
+ clearInterval(heartbeatTimer);
739
+ if (keepAlive)
740
+ clearInterval(keepAlive);
741
+ livenessProbe?.stop();
742
+ for (const h of installedSignalHandlers)
743
+ process.off(h.sig, h.fn);
744
+ }
745
+ // End-of-run skip summary. Grouped by category so the remediation is
746
+ // targeted to the specific class of failure.
747
+ if (skipReport.skips.length > 0) {
748
+ printSkipSummary(skipReport, resolved.alias);
749
+ }
750
+ // Persist the skip report so --retry-skipped can read it next run.
751
+ // Always written (even when empty) so users can tell whether the last
752
+ // run had skips or just hasn't recorded any yet.
753
+ if (opts.skipReportPath) {
754
+ try {
755
+ const { writeFileSync, mkdirSync } = await import("node:fs");
756
+ const { dirname } = await import("node:path");
757
+ mkdirSync(dirname(opts.skipReportPath), { recursive: true });
758
+ writeFileSync(opts.skipReportPath, JSON.stringify({ recordedAt: Date.now(), orgId: resolved.orgId, ...skipReport }, null, 2), "utf8");
759
+ }
760
+ catch (e) {
761
+ logger.warn("live-ingest: skip report write failed", { err: e.message });
762
+ }
567
763
  }
568
764
  }
569
- finally {
570
- if (heartbeatTimer)
571
- clearInterval(heartbeatTimer);
572
- if (keepAlive)
573
- clearInterval(keepAlive);
574
- livenessProbe?.stop();
575
- for (const h of installedSignalHandlers)
576
- process.off(h.sig, h.fn);
577
- }
578
- // End-of-run skip summary. Grouped by category so the remediation is
579
- // targeted to the specific class of failure.
580
- if (skipReport.skips.length > 0) {
581
- printSkipSummary(skipReport, resolved.alias);
582
- }
583
- // Persist the skip report so --retry-skipped can read it next run.
584
- // Always written (even when empty) so users can tell whether the last
585
- // run had skips or just hasn't recorded any yet.
586
- if (opts.skipReportPath) {
765
+ if (embedQueue) {
587
766
  try {
588
- const { writeFileSync, mkdirSync } = await import("node:fs");
589
- const { dirname } = await import("node:path");
590
- mkdirSync(dirname(opts.skipReportPath), { recursive: true });
591
- writeFileSync(opts.skipReportPath, JSON.stringify({ recordedAt: Date.now(), orgId: resolved.orgId, ...skipReport }, null, 2), "utf8");
767
+ await embedQueue.drain();
592
768
  }
593
769
  catch (e) {
594
- logger.warn("live-ingest: skip report write failed", { err: e.message });
770
+ logger.warn("live-ingest: embed drain failed", { err: e.message });
595
771
  }
596
772
  }
597
- }
598
- if (embedQueue) {
599
- try {
600
- await embedQueue.drain();
601
- }
602
- catch (e) {
603
- logger.warn("live-ingest: embed drain failed", { err: e.message });
604
- }
605
- }
606
- // Full-sync deletion detection: compute previously-known minus touched and
607
- // delete the difference. Skipped on incremental (SourceMember handles it),
608
- // skipped if any parse error occurred this run (avoid mass-wipe on
609
- // transient SF errors).
610
- if (opts.detectDeletions && mode === "full") {
611
- if (streamAborted) {
612
- logger.warn("live-ingest: detect-deletions skipped because bulkRetrieve stream aborted — touchedQnames is incomplete and a full deletion sweep would wrongly wipe untouched-but-still-present nodes");
613
- }
614
- else if (parseErrors > 0) {
615
- logger.warn("live-ingest: detect-deletions skipped due to parseErrors", { parseErrors });
616
- }
617
- else {
618
- try {
619
- const persisted = graph.listAllQnames(resolved.orgId);
620
- const stale = [];
621
- for (const q of persisted) {
622
- if (!touchedQnames.has(String(q)))
623
- stale.push(String(q));
773
+ // Full-sync deletion detection: compute previously-known minus touched and
774
+ // delete the difference. Skipped on incremental (SourceMember handles it),
775
+ // skipped if any parse error occurred this run (avoid mass-wipe on
776
+ // transient SF errors).
777
+ if (opts.detectDeletions && mode === "full") {
778
+ if (streamAborted) {
779
+ logger.warn("live-ingest: detect-deletions skipped because bulkRetrieve stream aborted — touchedQnames is incomplete and a full deletion sweep would wrongly wipe untouched-but-still-present nodes");
780
+ }
781
+ else if (parseErrors > 0) {
782
+ logger.warn("live-ingest: detect-deletions skipped due to parseErrors", { parseErrors });
783
+ }
784
+ else {
785
+ try {
786
+ // W1.5-07: per-label drop-ratio guard. The sweep used to run as a
787
+ // single set-difference across ALL labels; a wedged source with
788
+ // streamAborted=false + touchedCount=0 for its label would silently
789
+ // wipe every node of that label (graph-extinction risk that
790
+ // compounds with the W1.5-01/02 wedge cascade). Now we evaluate the
791
+ // guard per label and refuse when the implied drop is suspicious.
792
+ const maxDropRatio = resolveMaxDropRatio(process.env.SFGRAPH_DETECT_DELETIONS_MAX_DROP_RATIO);
793
+ const labels = graph.listAllLabels();
794
+ for (const label of labels) {
795
+ const priorCount = graph.countNodesByLabel(resolved.orgId, label);
796
+ const touchedCount = touchedQnamesByLabel.get(label)?.size ?? 0;
797
+ const verdict = evaluateDeletionGuard(label, priorCount, touchedCount, maxDropRatio);
798
+ if (!verdict.proceed) {
799
+ if (verdict.warning)
800
+ wedgeWarnings.push(verdict.warning);
801
+ logger.warn("live-ingest: detect-deletions refused", {
802
+ label,
803
+ priorCount,
804
+ touchedCount,
805
+ maxDropRatio,
806
+ warning: verdict.warning,
807
+ });
808
+ continue;
809
+ }
810
+ // Proceed with deletion for this label only. Scoped to this label's
811
+ // table via listNodesByLabel rather than the global listAllQnames
812
+ // walk, so a refusal for one label cannot accidentally affect
813
+ // sweeps for other labels.
814
+ const touchedSet = touchedQnamesByLabel.get(label);
815
+ const persisted = graph.listNodesByLabel(resolved.orgId, label);
816
+ let labelDeletions = 0;
817
+ for (const node of persisted) {
818
+ const q = String(node.qualifiedName);
819
+ if (touchedSet?.has(q))
820
+ continue;
821
+ graph.deleteEdgesFor(resolved.orgId, asQualifiedName(q));
822
+ graph.deleteNode(resolved.orgId, asQualifiedName(q));
823
+ deletions += 1;
824
+ labelDeletions += 1;
825
+ }
826
+ if (labelDeletions > 0) {
827
+ logger.info("live-ingest: detect-deletions removed stale qnames", {
828
+ label,
829
+ count: labelDeletions,
830
+ });
831
+ }
832
+ }
624
833
  }
625
- for (const q of stale) {
626
- graph.deleteEdgesFor(resolved.orgId, asQualifiedName(q));
627
- graph.deleteNode(resolved.orgId, asQualifiedName(q));
628
- deletions += 1;
834
+ catch (e) {
835
+ logger.warn("live-ingest: detect-deletions failed", { err: e.message });
629
836
  }
630
- if (stale.length > 0) {
631
- logger.info("live-ingest: detect-deletions removed stale qnames", {
632
- count: stale.length,
837
+ }
838
+ }
839
+ // -----------------------------------------------------------------------
840
+ // Post-merge resolver passes. Each step is isolated in try/catch so a
841
+ // single resolver bug never breaks ingest — it just reports zero work done.
842
+ // -----------------------------------------------------------------------
843
+ let crossFlavorEdges = 0;
844
+ let arityResolved = 0;
845
+ let flowMethodsResolved = 0;
846
+ let danglingEdges = 0;
847
+ let overlapResult = { matched: 0, diverged: 0, empty: 0, annotated: 0 };
848
+ let reflectionEdges = 0;
849
+ // Synth a ParseContext for post-passes that need to mint edges/nodes.
850
+ const postCtx = {
851
+ ...parseCtxBase,
852
+ sourceUri: "post-merge://resolver",
853
+ };
854
+ if (!opts.disableCrossFlavor) {
855
+ try {
856
+ crossFlavorEdges = resolveCrossFlavor(graph, {
857
+ orgId: resolved.orgId,
858
+ namespace: null,
859
+ ctx: postCtx,
860
+ });
861
+ if (crossFlavorEdges > 0) {
862
+ logger.info("live-ingest: cross-flavor resolver linked Vlocity↔OmniStudio", {
863
+ edges: crossFlavorEdges,
633
864
  });
634
865
  }
635
866
  }
636
867
  catch (e) {
637
- logger.warn("live-ingest: detect-deletions failed", { err: e.message });
868
+ logger.warn("live-ingest: cross-flavor resolver failed", { err: e.message });
638
869
  }
639
870
  }
640
- }
641
- // -----------------------------------------------------------------------
642
- // Post-merge resolver passes. Each step is isolated in try/catch so a
643
- // single resolver bug never breaks ingest — it just reports zero work done.
644
- // -----------------------------------------------------------------------
645
- let crossFlavorEdges = 0;
646
- let arityResolved = 0;
647
- let flowMethodsResolved = 0;
648
- let danglingEdges = 0;
649
- let overlapResult = { matched: 0, diverged: 0, empty: 0, annotated: 0 };
650
- let reflectionEdges = 0;
651
- // Synth a ParseContext for post-passes that need to mint edges/nodes.
652
- const postCtx = {
653
- ...parseCtxBase,
654
- sourceUri: "post-merge://resolver",
655
- };
656
- if (!opts.disableCrossFlavor) {
657
- try {
658
- crossFlavorEdges = resolveCrossFlavor(graph, {
659
- orgId: resolved.orgId,
660
- namespace: null,
661
- ctx: postCtx,
662
- });
663
- if (crossFlavorEdges > 0) {
664
- logger.info("live-ingest: cross-flavor resolver linked Vlocity↔OmniStudio", {
665
- edges: crossFlavorEdges,
871
+ // Overlap detector: opt-in (off by default) — annotates the CANONICAL_OF
872
+ // edges resolveCrossFlavor just emitted with signaturesMatch + divergence
873
+ // detail. Must run *after* cross-flavor and *before* the audit so the
874
+ // audit sees the annotated edges as the same edges (idempotent merge).
875
+ if (!opts.disableOverlapDetect) {
876
+ try {
877
+ const { detectOmnistudioOverlap } = await import("../parsers/omnistudio/overlap-detector.js");
878
+ overlapResult = detectOmnistudioOverlap(graph, {
879
+ orgId: resolved.orgId,
880
+ ctx: postCtx,
666
881
  });
882
+ if (overlapResult.annotated > 0) {
883
+ logger.info("live-ingest: omnistudio overlap detector annotated CANONICAL_OF pairs", {
884
+ matched: overlapResult.matched,
885
+ diverged: overlapResult.diverged,
886
+ empty: overlapResult.empty,
887
+ annotated: overlapResult.annotated,
888
+ });
889
+ }
667
890
  }
668
- }
669
- catch (e) {
670
- logger.warn("live-ingest: cross-flavor resolver failed", { err: e.message });
671
- }
672
- }
673
- // Overlap detector: opt-in (off by default) — annotates the CANONICAL_OF
674
- // edges resolveCrossFlavor just emitted with signaturesMatch + divergence
675
- // detail. Must run *after* cross-flavor and *before* the audit so the
676
- // audit sees the annotated edges as the same edges (idempotent merge).
677
- if (!opts.disableOverlapDetect) {
678
- try {
679
- const { detectOmnistudioOverlap } = await import("../parsers/omnistudio/overlap-detector.js");
680
- overlapResult = detectOmnistudioOverlap(graph, {
681
- orgId: resolved.orgId,
682
- ctx: postCtx,
683
- });
684
- if (overlapResult.annotated > 0) {
685
- logger.info("live-ingest: omnistudio overlap detector annotated CANONICAL_OF pairs", {
686
- matched: overlapResult.matched,
687
- diverged: overlapResult.diverged,
688
- empty: overlapResult.empty,
689
- annotated: overlapResult.annotated,
891
+ catch (e) {
892
+ logger.warn("live-ingest: omnistudio overlap detector failed", {
893
+ err: e.message,
690
894
  });
691
895
  }
692
896
  }
693
- catch (e) {
694
- logger.warn("live-ingest: omnistudio overlap detector failed", {
695
- err: e.message,
696
- });
897
+ if (!opts.disableFlowInvocableResolve) {
898
+ try {
899
+ const flowResult = resolveFlowApexMethods(graph, resolved.orgId, postCtx);
900
+ flowMethodsResolved = flowResult.resolved;
901
+ if (flowResult.scanned > 0) {
902
+ logger.info("live-ingest: flow→apex invocable resolver", {
903
+ scanned: flowResult.scanned,
904
+ resolved: flowResult.resolved,
905
+ missing: flowResult.missing,
906
+ ambiguous: flowResult.ambiguous,
907
+ });
908
+ }
909
+ }
910
+ catch (e) {
911
+ logger.warn("live-ingest: flow→apex resolver failed", { err: e.message });
912
+ }
697
913
  }
698
- }
699
- if (!opts.disableFlowInvocableResolve) {
700
- try {
701
- const flowResult = resolveFlowApexMethods(graph, resolved.orgId, postCtx);
702
- flowMethodsResolved = flowResult.resolved;
703
- if (flowResult.scanned > 0) {
704
- logger.info("live-ingest: flow→apex invocable resolver", {
705
- scanned: flowResult.scanned,
706
- resolved: flowResult.resolved,
707
- missing: flowResult.missing,
708
- ambiguous: flowResult.ambiguous,
914
+ if (!opts.disableArityResolve) {
915
+ try {
916
+ const arityResult = resolveApexMethodArity(graph, {
917
+ orgId: resolved.orgId,
918
+ ctx: postCtx,
709
919
  });
920
+ arityResolved = arityResult.resolved;
921
+ if (arityResult.scanned > 0) {
922
+ logger.info("live-ingest: apex method arity resolver", {
923
+ scanned: arityResult.scanned,
924
+ resolved: arityResult.resolved,
925
+ ambiguous: arityResult.ambiguous,
926
+ unresolved: arityResult.unresolved,
927
+ edgesEmitted: arityResult.edgesEmitted,
928
+ });
929
+ }
930
+ }
931
+ catch (e) {
932
+ logger.warn("live-ingest: apex arity resolver failed", { err: e.message });
710
933
  }
711
934
  }
712
- catch (e) {
713
- logger.warn("live-ingest: flow→apex resolver failed", { err: e.message });
714
- }
715
- }
716
- if (!opts.disableArityResolve) {
717
- try {
718
- const arityResult = resolveApexMethodArity(graph, {
719
- orgId: resolved.orgId,
720
- ctx: postCtx,
721
- });
722
- arityResolved = arityResult.resolved;
723
- if (arityResult.scanned > 0) {
724
- logger.info("live-ingest: apex method arity resolver", {
725
- scanned: arityResult.scanned,
726
- resolved: arityResult.resolved,
727
- ambiguous: arityResult.ambiguous,
728
- unresolved: arityResult.unresolved,
729
- edgesEmitted: arityResult.edgesEmitted,
935
+ // Reflection walker: runs BEFORE the dangling-edge audit so any
936
+ // REFERENCES edges it emits are validated by the audit alongside
937
+ // parser-emitted edges. Default on; disable via disableReflectionWalker.
938
+ if (!opts.disableReflectionWalker) {
939
+ try {
940
+ const { walkBlobsForReferences } = await import("../parsers/generic/reflection-walker.js");
941
+ const reflectionResult = walkBlobsForReferences(graph, {
942
+ orgId: resolved.orgId,
943
+ ctx: postCtx,
944
+ });
945
+ reflectionEdges = reflectionResult.edgesEmitted;
946
+ if (reflectionEdges > 0) {
947
+ logger.info("live-ingest: reflection walker emitted REFERENCES edges", {
948
+ scanned: reflectionResult.scanned,
949
+ edges: reflectionEdges,
950
+ truncated: reflectionResult.truncatedSources,
951
+ ambiguous: reflectionResult.ambiguousMatches,
952
+ });
953
+ }
954
+ }
955
+ catch (e) {
956
+ logger.warn("live-ingest: reflection walker failed", {
957
+ err: e.message,
730
958
  });
731
959
  }
732
960
  }
733
- catch (e) {
734
- logger.warn("live-ingest: apex arity resolver failed", { err: e.message });
961
+ if (!opts.disableAudit) {
962
+ try {
963
+ const auditResult = auditDanglingEdges(graph, resolved.orgId, { sampleSize: 25 });
964
+ danglingEdges = auditResult.danglingCount;
965
+ if (auditResult.danglingCount > 0) {
966
+ logger.info("live-ingest: graph audit found dangling edges", {
967
+ totalEdges: auditResult.totalEdges,
968
+ danglingCount: auditResult.danglingCount,
969
+ platformRefs: auditResult.platformRefCount,
970
+ unexpected: auditResult.unexpectedCount,
971
+ byRel: auditResult.byRel,
972
+ byDstPrefix: auditResult.byDstPrefix,
973
+ });
974
+ }
975
+ }
976
+ catch (e) {
977
+ logger.warn("live-ingest: graph audit failed", { err: e.message });
978
+ }
735
979
  }
736
- }
737
- // Reflection walker: runs BEFORE the dangling-edge audit so any
738
- // REFERENCES edges it emits are validated by the audit alongside
739
- // parser-emitted edges. Default on; disable via disableReflectionWalker.
740
- if (!opts.disableReflectionWalker) {
980
+ const completedIso = new Date().toISOString();
741
981
  try {
742
- const { walkBlobsForReferences } = await import("../parsers/generic/reflection-walker.js");
743
- const reflectionResult = walkBlobsForReferences(graph, {
744
- orgId: resolved.orgId,
745
- ctx: postCtx,
746
- });
747
- reflectionEdges = reflectionResult.edgesEmitted;
748
- if (reflectionEdges > 0) {
749
- logger.info("live-ingest: reflection walker emitted REFERENCES edges", {
750
- scanned: reflectionResult.scanned,
751
- edges: reflectionEdges,
752
- truncated: reflectionResult.truncatedSources,
753
- ambiguous: reflectionResult.ambiguousMatches,
754
- });
755
- }
982
+ // W1.5-08: markSyncComplete subsumes the old touchSync — it updates
983
+ // last_synced_at AND increments sync_generation AND clears the
984
+ // in-progress flag, atomically in one transaction.
985
+ graph.markSyncComplete(resolved.orgId, completedIso);
756
986
  }
757
987
  catch (e) {
758
- logger.warn("live-ingest: reflection walker failed", {
759
- err: e.message,
760
- });
988
+ logger.warn("live-ingest: markSyncComplete failed", { err: e.message });
761
989
  }
762
- }
763
- if (!opts.disableAudit) {
764
- try {
765
- const auditResult = auditDanglingEdges(graph, resolved.orgId, { sampleSize: 25 });
766
- danglingEdges = auditResult.danglingCount;
767
- if (auditResult.danglingCount > 0) {
768
- logger.info("live-ingest: graph audit found dangling edges", {
769
- totalEdges: auditResult.totalEdges,
770
- danglingCount: auditResult.danglingCount,
771
- byRel: auditResult.byRel,
772
- byDstPrefix: auditResult.byDstPrefix,
990
+ if (opts.analysisDb) {
991
+ try {
992
+ await populateAnalysisTables(graph, resolved.orgId, opts.analysisDb);
993
+ }
994
+ catch (e) {
995
+ logger.warn("live-ingest: populate analysis tables failed", {
996
+ err: e.message,
773
997
  });
774
998
  }
775
999
  }
776
- catch (e) {
777
- logger.warn("live-ingest: graph audit failed", { err: e.message });
1000
+ if (opts.snapshotStore) {
1001
+ try {
1002
+ opts.snapshotStore.prune(resolved.orgId, opts.snapshotRetentionDays ?? 30);
1003
+ }
1004
+ catch (e) {
1005
+ logger.warn("live-ingest: snapshot prune failed", { err: e.message });
1006
+ }
778
1007
  }
779
- }
780
- const completedIso = new Date().toISOString();
781
- try {
782
- graph.touchSync(resolved.orgId, completedIso);
1008
+ return {
1009
+ orgId: resolved.orgId,
1010
+ capabilities: caps,
1011
+ mode,
1012
+ membersProcessed,
1013
+ parseErrors,
1014
+ deletions,
1015
+ durationMs: Date.now() - startedAt,
1016
+ crossFlavorEdges,
1017
+ arityResolved,
1018
+ flowMethodsResolved,
1019
+ danglingEdges,
1020
+ reflectionEdges,
1021
+ overlap: overlapResult,
1022
+ warnings: [
1023
+ ...skipReport.skips.map((s) => `${s.label}: ${s.reason}`),
1024
+ // Phase 1.5 W1.5-02 namespaced wedge strings. Format documented in
1025
+ // PLAN.md: `wedge:<source>:<stage>:<detail>`. Forward-compatible
1026
+ // with Phase 1's structured-warnings refactor (W1-01).
1027
+ ...wedgeWarnings,
1028
+ ],
1029
+ };
783
1030
  }
784
1031
  catch (e) {
785
- logger.warn("live-ingest: touchSync failed", { err: e.message });
786
- }
787
- if (opts.analysisDb) {
1032
+ // W1.5-08: ingest threw before reaching markSyncComplete. Flip
1033
+ // sync_in_progress back to 0 (and clear sync_started_at) so concurrent
1034
+ // MCP readers don't see an orphaned in_progress flag forever. Generation
1035
+ // is intentionally NOT incremented — failed ingests don't count as
1036
+ // fresh graph data. Re-throw so callers (CLI / MCP job runner) see the
1037
+ // original error unchanged.
788
1038
  try {
789
- await populateAnalysisTables(graph, resolved.orgId, opts.analysisDb);
1039
+ graph.markSyncFailed(resolved.orgId);
790
1040
  }
791
- catch (e) {
792
- logger.warn("live-ingest: populate analysis tables failed", {
793
- err: e.message,
1041
+ catch (markErr) {
1042
+ logger.warn("live-ingest: markSyncFailed failed", {
1043
+ err: markErr.message,
794
1044
  });
795
1045
  }
1046
+ throw e;
796
1047
  }
797
- if (opts.snapshotStore) {
798
- try {
799
- opts.snapshotStore.prune(resolved.orgId, opts.snapshotRetentionDays ?? 30);
800
- }
801
- catch (e) {
802
- logger.warn("live-ingest: snapshot prune failed", { err: e.message });
803
- }
804
- }
805
- return {
806
- orgId: resolved.orgId,
807
- capabilities: caps,
808
- mode,
809
- membersProcessed,
810
- parseErrors,
811
- deletions,
812
- durationMs: Date.now() - startedAt,
813
- crossFlavorEdges,
814
- arityResolved,
815
- flowMethodsResolved,
816
- danglingEdges,
817
- reflectionEdges,
818
- overlap: overlapResult,
819
- warnings: skipReport.skips.map((s) => `${s.label}: ${s.reason}`),
820
- };
821
1048
  }
822
1049
  //# sourceMappingURL=live-ingest.js.map