@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
@@ -1,6 +1,7 @@
1
1
  import { discoverMetadataTypes } from "./discovery.js";
2
2
  import { buildDispatchTable } from "./dispatch.js";
3
3
  import { iterApex } from "./extractors/apex.js";
4
+ import { iterCustomSettings } from "./extractors/custom-settings.js";
4
5
  import { iterFlow } from "./extractors/flow.js";
5
6
  import { iterGenericMetadata } from "./extractors/generic-metadata.js";
6
7
  import { iterIntegration } from "./extractors/integration.js";
@@ -9,6 +10,28 @@ import { iterObject } from "./extractors/object.js";
9
10
  import { iterOmnistudio } from "./extractors/omnistudio.js";
10
11
  import { iterSecurity } from "./extractors/security.js";
11
12
  import { iterVlocity } from "./extractors/vlocity.js";
13
+ import { StopWaitingError, stopSignalContext } from "./rate-limit.js";
14
+ function createWedgeCoordinator(warnings = [], overrides) {
15
+ const capEnv = Number.parseInt(process.env.SFGRAPH_MAX_BACKGROUND_WEDGES ?? "", 10);
16
+ // Default 32 (was 4): a full --rebuild on a large org wedges many slow/empty
17
+ // generic-metadata sources at the 90s first-yield watchdog AND the heavy Apex
18
+ // source; a cap of 4 evicted most of them (their data lost). The drain loop
19
+ // skips already-resolved entries cheaply, so a high cap costs little.
20
+ const cap = Number.isFinite(capEnv) && capEnv > 0 ? capEnv : 32;
21
+ const drainEnv = Number.parseInt(process.env.SFGRAPH_LATE_DRAIN_BUDGET_MS ?? "", 10);
22
+ // Default 300s (was 60s): the per-entry budget is TOTAL drain wall-clock.
23
+ // Apex needs ~75s to stream all classes once the Tooling pool frees up after
24
+ // the main loop; 60s clipped it mid-drain. Empty/fast entries still return
25
+ // `done` immediately and cost nothing, so the larger budget only helps the
26
+ // genuinely-heavy entries finish.
27
+ const drain = Number.isFinite(drainEnv) && drainEnv >= 0 ? drainEnv : 300_000;
28
+ return {
29
+ warnings,
30
+ backgroundWedges: [],
31
+ maxBackgroundWedges: overrides?.maxBackgroundWedges ?? cap,
32
+ lateDrainBudgetMs: overrides?.lateDrainBudgetMs ?? drain,
33
+ };
34
+ }
12
35
  /** Best-effort classification so the end-of-run recommendation is targeted. */
13
36
  function classifySkip(msg) {
14
37
  const m = msg.toUpperCase();
@@ -34,27 +57,27 @@ export async function* mergeAsyncIterables(...iters) {
34
57
  yield v;
35
58
  }
36
59
  }
37
- /**
38
- * Sliding-window parallel merge. Keeps exactly `concurrency` iterators
39
- * live at any moment — when one completes (done:true), the next queued
40
- * iterator is started in its slot. Within the window, iterators race via
41
- * `Promise.race` for max throughput.
42
- *
43
- * Replaces the previous wave-based merger, which had a synchronisation
44
- * barrier at each wave boundary: all 6 iterators in a wave had to finish
45
- * before any of wave 2 could start. One hung source (very common for
46
- * `generic:Layout` on managed-package-heavy orgs) parked the whole wave
47
- * indefinitely with zero observable progress.
48
- *
49
- * Default concurrency = 8: enough to saturate the three rate-limit pools
50
- * (Tooling 5 / Metadata 5 / Data 10) with headroom for one slot to be
51
- * blocked on a slow source without starving the others. Override via
52
- * `SFGRAPH_SOURCE_CONCURRENCY=<n>` (1 = strictly sequential).
53
- *
54
- * Output ordering is non-deterministic. `live-ingest`'s processOne is
55
- * order-independent (idempotent per-record upserts), so this is safe.
56
- */
57
- export async function* mergeAsyncIterablesParallel(...iters) {
60
+ export function mergeAsyncIterablesParallel(...args) {
61
+ // Disambiguate the two overloads. The 2-arg array+opts form is used by
62
+ // tests; the rest-args form is the production fan-out path.
63
+ let iters;
64
+ let opts;
65
+ if (args.length === 2 &&
66
+ Array.isArray(args[0]) &&
67
+ args[1] != null &&
68
+ typeof args[1] === "object" &&
69
+ !(args[1] instanceof Promise) &&
70
+ !(Symbol.asyncIterator in args[1])) {
71
+ iters = args[0];
72
+ opts = args[1];
73
+ }
74
+ else {
75
+ iters = args;
76
+ opts = undefined;
77
+ }
78
+ return mergeAsyncIterablesParallelImpl(iters, opts);
79
+ }
80
+ async function* mergeAsyncIterablesParallelImpl(iters, opts) {
58
81
  const envConcurrency = Number.parseInt(process.env.SFGRAPH_SOURCE_CONCURRENCY ?? "", 10);
59
82
  // Raised 8 -> 12: a wider window lets Tooling-backed sources (apex, lwc)
60
83
  // start immediately rather than wait behind the cohort of metadata-pool
@@ -62,7 +85,11 @@ export async function* mergeAsyncIterablesParallel(...iters) {
62
85
  // which spend most of their time queued in Bottleneck. Per-pool concurrency
63
86
  // still caps total HTTP fan-out, so this only opens up parallelism that
64
87
  // was being throttled at the wrong layer.
65
- const concurrency = Number.isFinite(envConcurrency) && envConcurrency > 0 ? envConcurrency : 12;
88
+ const concurrency = opts?.concurrency != null && opts.concurrency > 0
89
+ ? opts.concurrency
90
+ : Number.isFinite(envConcurrency) && envConcurrency > 0
91
+ ? envConcurrency
92
+ : 12;
66
93
  const iterators = iters.map((it) => it[Symbol.asyncIterator]());
67
94
  const pending = new Map();
68
95
  let nextIterIdx = 0;
@@ -97,60 +124,275 @@ export async function* mergeAsyncIterablesParallel(...iters) {
97
124
  advance(idx);
98
125
  }
99
126
  }
100
- /** Wrap an iterable so a thrown error is captured + the stream ends cleanly
101
- * instead of aborting the whole ingest. The error is recorded into a
102
- * shared skip report (consumed at end-of-run) and a compact ✗ line is
103
- * printed so the user sees something happened without the full error
104
- * message scrolling past during the run. */
105
- async function* failSoft(label, factory, onError) {
127
+ /**
128
+ * Per-source watchdog budgets. Centralized here so the failSoft wrapper and
129
+ * tests share the same constants.
130
+ *
131
+ * Phase 1.5 W1.5-01 contract: these are the budgets the watchdog uses, but
132
+ * the clock against which they measure starts at slot-acquisition time
133
+ * (i.e., on the FIRST `it.next()` call inside `failSoft`'s body), NOT at
134
+ * source registration. Queued-but-not-yet-executing sources accumulate
135
+ * ZERO clock time, so a wedge in one source can no longer cascade-kill
136
+ * its queued neighbors.
137
+ *
138
+ * Anti-feature reminder: do NOT raise these to "fix" a real wedge. The
139
+ * correct fix is per-source override (W1.5-future) or fixing the underlying
140
+ * pre-yield setup call. See `.planning/phase-1.5/PLAN.md` anti-features.
141
+ */
142
+ const WATCHDOG_INACTIVITY_MS_DEFAULT = 5 * 60_000;
143
+ const WATCHDOG_FIRST_YIELD_MS_DEFAULT = 90_000;
144
+ /**
145
+ * Per-source watchdog overrides (the "per-source override" the anti-feature
146
+ * note above points to). Some sources legitimately need far longer than the
147
+ * 90s default to produce their FIRST record — most notably the Vlocity runner,
148
+ * which executes a long sequence of `vlocity_cmt` SObject queries and assembles
149
+ * DataPacks before yielding member #1, and can have multi-minute gaps between
150
+ * DataPack types once streaming. Raising the GLOBAL budget to accommodate it is
151
+ * wrong: it makes every other slow/empty source hold its merger slot 10x longer
152
+ * and starves the pool (observed: a 600s global first-yield stalled the whole
153
+ * fan-out behind a few empty generic sources). So scope the larger budget to
154
+ * the specific label only. Explicit env vars still win (debug/tests).
155
+ */
156
+ const SOURCE_WATCHDOG_OVERRIDES = {
157
+ // Vlocity must stay INLINE: the post-merger background-drain cannot reliably
158
+ // recover it (it needs minutes to first-yield, longer than any sane drain
159
+ // budget, and competes for drain slots). Giving it a long inline budget lets
160
+ // it hold its DATA-pool slot and stream once the pool frees up. NB: do NOT
161
+ // do this for Tooling-pool sources like Apex — holding a scarce Tooling slot
162
+ // that long starves every other Tooling source and stalls the whole fan-out.
163
+ // Apex instead wedges fast (releasing its slot) and is recovered by the
164
+ // background-drain pass, which is sized generously in createWedgeCoordinator.
165
+ vlocity: { firstYieldMs: 20 * 60_000, inactivityMs: 10 * 60_000 },
166
+ };
167
+ /** Read watchdog budgets per-call so unit tests can override via env vars
168
+ * without restarting the process. Production env should leave these unset:
169
+ * ordinary sources get the documented 90s first-yield / 5min inactivity;
170
+ * a source in SOURCE_WATCHDOG_OVERRIDES gets its scoped budget instead. */
171
+ function readWatchdogBudgets(label) {
172
+ const fy = Number.parseInt(process.env.SFGRAPH_WATCHDOG_FIRST_YIELD_MS ?? "", 10);
173
+ const inact = Number.parseInt(process.env.SFGRAPH_WATCHDOG_INACTIVITY_MS ?? "", 10);
174
+ const override = label ? SOURCE_WATCHDOG_OVERRIDES[label] : undefined;
175
+ return {
176
+ firstYieldMs: Number.isFinite(fy) && fy > 0
177
+ ? fy
178
+ : (override?.firstYieldMs ?? WATCHDOG_FIRST_YIELD_MS_DEFAULT),
179
+ inactivityMs: Number.isFinite(inact) && inact > 0
180
+ ? inact
181
+ : (override?.inactivityMs ?? WATCHDOG_INACTIVITY_MS_DEFAULT),
182
+ };
183
+ }
184
+ /** Build the namespaced wedge warning string per the schema documented in
185
+ * PLAN.md (`wedge:<source>:<stage>:<detail>`). Centralized here so the
186
+ * format is stable across emit sites + grep-friendly. */
187
+ function fmtWedgeWarning(source, stage, detail) {
188
+ const parts = [`wedge:${source}:${stage}`];
189
+ for (const [k, v] of Object.entries(detail)) {
190
+ parts.push(`${k}=${v}`);
191
+ }
192
+ return parts.join(":");
193
+ }
194
+ /**
195
+ * Enforce the background-wedge cap. When admitting a new entry would push
196
+ * the count above `maxBackgroundWedges`, mark the OLDEST entries' stop-
197
+ * waiting flags so their drain loops bail at the next iteration. Emits a
198
+ * `wedge:cap:backgroundWedgeAborted:...` warning per evicted entry. The
199
+ * iterator object itself is left in the set; the late-drain loop is what
200
+ * sees the flag and skips it. (We do NOT remove from `backgroundWedges`
201
+ * here so post-merger inspection still sees every wedge that ever fired
202
+ * during the run.)
203
+ */
204
+ function enforceBackgroundWedgeCap(coordinator) {
205
+ const active = coordinator.backgroundWedges.filter((e) => !e.stopWaitingRef.value);
206
+ const overage = active.length - coordinator.maxBackgroundWedges;
207
+ if (overage <= 0)
208
+ return;
209
+ // Sort by registeredAt ascending; oldest first.
210
+ active.sort((a, b) => a.registeredAt - b.registeredAt);
211
+ for (let i = 0; i < overage; i += 1) {
212
+ const victim = active[i];
213
+ if (!victim)
214
+ continue;
215
+ victim.stopWaitingRef.value = true;
216
+ const ageMs = Date.now() - victim.registeredAt;
217
+ coordinator.warnings.push(fmtWedgeWarning("cap", "backgroundWedgeAborted", {
218
+ source: victim.label,
219
+ ageMs,
220
+ reason: "backgroundWedgeCapExceeded",
221
+ }));
222
+ }
223
+ }
224
+ /**
225
+ * Drain a single background-wedge iterator. Called once per wedged source,
226
+ * post-merger, with a wall-clock budget. Each iteration races the iterator's
227
+ * `it.next()` against the remaining budget. Records arriving inside the
228
+ * budget are yielded with `attributes.lateYield = true`. The pending HTTP
229
+ * request (if the wedge is on a network call) is NOT actively cancelled
230
+ * (W1.5-03's job); we just stop awaiting it.
231
+ *
232
+ * Bails immediately if `stopWaitingRef.value === true` (cap eviction).
233
+ */
234
+ async function* drainBackgroundWedge(entry, budgetMs, warnings) {
235
+ const debug = process.env.SFGRAPH_DEBUG_INGEST === "1";
236
+ const startedAt = Date.now();
237
+ let lateCount = 0;
238
+ while (!entry.stopWaitingRef.value) {
239
+ const remaining = startedAt + budgetMs - Date.now();
240
+ if (remaining <= 0)
241
+ break;
242
+ // First iteration: consume the abandoned-but-still-pending `it.next()`
243
+ // promise from when the watchdog fired. Subsequent iterations call
244
+ // `iterator.next()` normally. Async-generator semantics queue
245
+ // concurrent `.next()` calls, so we MUST consume the pending one
246
+ // before issuing a new one — otherwise the late-yielded value goes
247
+ // to the abandoned promise and the drain sees the NEXT yield (or
248
+ // `done:true` if the generator is single-yield).
249
+ const nextPromise = entry.pendingFirstNext !== null
250
+ ? entry.pendingFirstNext
251
+ : stopSignalContext.run(entry.stopWaitingRef, () => entry.iterator.next());
252
+ if (entry.pendingFirstNext !== null)
253
+ entry.pendingFirstNext = null;
254
+ let next;
255
+ try {
256
+ next = await Promise.race([
257
+ nextPromise,
258
+ new Promise((resolve) => {
259
+ setTimeout(
260
+ // Resolve (not reject) with a synthetic done sentinel — bailing
261
+ // is the normal exit path from a budget-exceeded drain, not an
262
+ // error condition.
263
+ () => resolve({ value: undefined, done: true }), remaining);
264
+ }),
265
+ ]);
266
+ }
267
+ catch (e) {
268
+ // Late-arriving error from the wedged iterator. Don't crash the drain;
269
+ // surface as a warning and stop.
270
+ const err = e?.message ?? String(e);
271
+ warnings.push(fmtWedgeWarning(entry.label, "lateError", { error: err.slice(0, 120) }));
272
+ break;
273
+ }
274
+ if (next.done)
275
+ break;
276
+ lateCount += 1;
277
+ const rec = next.value;
278
+ // Tag with lateYield so downstream consumers / tests can identify the
279
+ // record as having arrived after slot release.
280
+ const tagged = {
281
+ ...rec,
282
+ attributes: { ...(rec.attributes ?? {}), lateYield: true },
283
+ };
284
+ yield tagged;
285
+ }
286
+ if (lateCount > 0) {
287
+ const totalMs = Date.now() - entry.registeredAt;
288
+ warnings.push(fmtWedgeWarning(entry.label, "resolvedLate", {
289
+ totalMs,
290
+ records: lateCount,
291
+ }));
292
+ }
293
+ if (debug) {
294
+ console.log(`ingest: [debug] ${entry.label} background-wedge drain finished (${lateCount} late records, stopWaiting=${entry.stopWaitingRef.value})`);
295
+ }
296
+ }
297
+ /**
298
+ * Wrap an iterable so a thrown error is captured + the stream ends cleanly
299
+ * instead of aborting the whole ingest. The error is recorded into a
300
+ * shared skip report (consumed at end-of-run) and a compact ✗ line is
301
+ * printed so the user sees something happened without the full error
302
+ * message scrolling past during the run.
303
+ *
304
+ * Phase 1.5 W1.5-01 + W1.5-02 contract:
305
+ *
306
+ * - The watchdog clock starts when this generator's BODY first executes
307
+ * (i.e., when the merger calls `.next()` on us for the first time —
308
+ * that's slot-acquisition time). Queued sources still inside
309
+ * `mergeAsyncIterablesParallel`'s waiting list accumulate ZERO clock
310
+ * time. Implemented via an internal `startedAtRef` that's written once
311
+ * on body entry; if the caller passes a `startedAtRef` we update it in
312
+ * place so the merger can observe the slot-acquisition moment.
313
+ *
314
+ * - On watchdog fire, we DO NOT throw (which would abort the merger's
315
+ * Promise.race for our slot AND lose the still-pending iterator).
316
+ * Instead we:
317
+ * 1. Emit a `wedge:<label>:firstYield:90s:...` (or `:inactivity:300s:`)
318
+ * warning into coordinator.warnings.
319
+ * 2. Register the still-pending iterator into coordinator.background
320
+ * Wedges with a stopWaitingRef. Enforce the cap (evict oldest).
321
+ * 3. RETURN from the generator. This cleanly releases the merger's
322
+ * slot — `pending.delete(idx)` runs in mergeAsyncIterablesParallel
323
+ * and the next queued source acquires the slot.
324
+ * Post-merger, the bulkRetrieve top-level fan-out drains the background
325
+ * wedge set; records arriving inside the late-drain budget are yielded
326
+ * with `attributes.lateYield = true`.
327
+ */
328
+ async function* failSoft(label, factory, onError, coordinator,
329
+ /** Optional outer-visible clock cell. The merger does not currently read
330
+ * this, but the contract is "set by failSoft on body entry"; tests use
331
+ * it to assert slot-acquisition timing. */
332
+ startedAtRef) {
106
333
  const debug = process.env.SFGRAPH_DEBUG_INGEST === "1";
334
+ // W1.5-01: the clock starts HERE — body-entry, which is slot-acquisition
335
+ // time. NOT at failSoft() invocation (that's source registration, which
336
+ // can be hundreds of seconds before the merger picks us out of the queue).
107
337
  const startedAt = Date.now();
338
+ if (startedAtRef)
339
+ startedAtRef.value = startedAt;
108
340
  let count = 0;
109
341
  let started = false;
110
- // Watchdog at 5 minutes. Per-call withTimeouts (120s) catch hung HTTP;
111
- // this catches the case where every call ALSO times out and the source
112
- // never yields a single record. 5 min = ~2.5× the per-call budget,
113
- // generous for queued-but-healthy, tight enough that a genuinely
114
- // wedged source surfaces fast and doesn't park its sliding-window slot
115
- // for half an hour. With the no-retry-on-timeout fix in
116
- // readMetadataBatchAdaptive, the inner loop also can't balloon, so 5
117
- // minutes is a real ceiling now.
118
- const inactivityMs = 5 * 60_000;
119
- // Tighter deadline for the FIRST record. The 5-min inactivity watchdog
120
- // is for healthy-but-slow sources that have already started yielding;
121
- // a source that hasn't yielded a single record in 90s is almost
122
- // certainly parked on a pre-yield setup call (metadata.list, describe,
123
- // EntityDefinition pagination) and won't recover. Killing it fast frees
124
- // the sliding-window slot for queued peers.
125
- const firstYieldMs = 90_000;
342
+ // Track last-yielded qualifiedName for diagnostic warnings — the smoking-
343
+ // gun field that names WHICH record was in flight when the wedge fired.
344
+ let lastYieldedQName = "<none>";
345
+ const { firstYieldMs, inactivityMs } = readWatchdogBudgets(label);
346
+ // W1.5-03: pre-allocate the per-source stop-signal at body entry and
347
+ // publish it into AsyncLocalStorage so any downstream rate-limit wrapper
348
+ // (`withTimeout`, `soqlWithTimeout`, `scheduleQuery`) inside the wedged
349
+ // iterator can observe `value = true` after cap-eviction flips it. Same
350
+ // ref is later attached to the BackgroundWedgeEntry so the keystone's
351
+ // `enforceBackgroundWedgeCap` flip propagates to in-flight calls without
352
+ // any per-extractor signature change. AsyncLocalStorage propagates across
353
+ // awaits within an async function started inside .run() — we factor the
354
+ // iterator into a helper started inside the .run() callback.
355
+ const stopWaitingRef = { value: false };
356
+ const it = stopSignalContext.run(stopWaitingRef, () => factory()[Symbol.asyncIterator]());
357
+ if (debug)
358
+ console.log(`ingest: [debug] ${label} ← invoked at ${startedAt}`);
126
359
  try {
127
- if (debug)
128
- console.log(`ingest: [debug] ${label} ← invoked at ${startedAt}`);
129
- const it = factory()[Symbol.asyncIterator]();
130
360
  while (true) {
131
361
  const remainingFirstYield = started
132
362
  ? Number.POSITIVE_INFINITY
133
363
  : startedAt + firstYieldMs - Date.now();
134
364
  const watchdogMs = Math.min(inactivityMs, remainingFirstYield);
135
- if (watchdogMs <= 0) {
136
- throw new Error(`source watchdog (first-yield ${firstYieldMs / 1000}s): no record yielded — pre-yield setup wedged`);
137
- }
138
- const watchdogLabel = started
139
- ? `${inactivityMs / 60_000}m inactivity`
140
- : `first-yield ${firstYieldMs / 1000}s`;
141
- const next = await Promise.race([
142
- it.next(),
143
- new Promise((_resolve, reject) => {
144
- setTimeout(() => reject(new Error(`source watchdog (${watchdogLabel}): no record yielded — pool jammed or call wedged`)), watchdogMs);
365
+ const stage = started ? "inactivity" : "firstYield";
366
+ const stageDetail = started ? `${inactivityMs / 1000}s` : `${firstYieldMs / 1000}s`;
367
+ // Run each `.next()` synchronously inside the ALS context so awaits
368
+ // inside the underlying generator's body that call into rate-limit
369
+ // wrappers can resolve the stop-signal via `getStore()`.
370
+ const pending = stopSignalContext.run(stopWaitingRef, () => it.next());
371
+ let timer;
372
+ const wedged = await Promise.race([
373
+ pending.then((result) => {
374
+ if (timer)
375
+ clearTimeout(timer);
376
+ return { wedge: false, result };
377
+ }),
378
+ new Promise((resolve) => {
379
+ // watchdogMs may be <=0 if the budget is already burned (defensive
380
+ // — should be rare since the clock starts at body-entry). setTimeout
381
+ // with non-positive delay fires on the next event-loop tick, which
382
+ // is the correct wedge-immediately behavior.
383
+ timer = setTimeout(() => resolve({ wedge: true }), Math.max(0, watchdogMs));
145
384
  }),
146
385
  ]);
386
+ if (wedged.wedge) {
387
+ // Hand off the still-pending it.next() so the drain can consume
388
+ // it directly — see BackgroundWedgeEntry.pendingFirstNext.
389
+ emitWedge(stage, stageDetail, pending);
390
+ return;
391
+ }
392
+ const next = wedged.result;
147
393
  if (next.done)
148
394
  break;
149
- if (!started) {
150
- started = true;
151
- console.log(`ingest: ${label} → starting…`);
152
- }
153
- count += 1;
395
+ recordYield(next.value);
154
396
  yield next.value;
155
397
  }
156
398
  if (started) {
@@ -175,6 +417,64 @@ async function* failSoft(label, factory, onError) {
175
417
  console.log(`ingest: [debug] ${label} → finalised (${count} records, ${Date.now() - startedAt}ms)`);
176
418
  }
177
419
  }
420
+ /** Inline helper: extracts qualifiedName-ish text from a RawMember for the
421
+ * `lastYielded=...` diagnostic field. Safe against undefined. */
422
+ function recordYield(v) {
423
+ if (!started) {
424
+ started = true;
425
+ console.log(`ingest: ${label} → starting…`);
426
+ }
427
+ count += 1;
428
+ const ref = v.ref;
429
+ if (ref) {
430
+ lastYieldedQName = `${ref.memberType}/${ref.memberName}`;
431
+ }
432
+ }
433
+ /**
434
+ * Slot-release + soft-isolate on watchdog fire. Two side effects:
435
+ * 1. Push a `wedge:<label>:<stage>:<detail>` warning into the shared sink.
436
+ * 2. Park the still-pending iterator into the background-wedge set so
437
+ * the post-merger late-drain pass can capture any records that
438
+ * eventually arrive. Enforce the cap (oldest evicted with a
439
+ * `backgroundWedgeAborted` warning).
440
+ * The generator then RETURNS (caller is `for await` of the merger; the
441
+ * merger sees `done:true` and releases the slot).
442
+ */
443
+ function emitWedge(stage, stageDetail, pendingFirstNext) {
444
+ const wedgedFor = Date.now() - startedAt;
445
+ if (coordinator) {
446
+ // Schema (PLAN.md): `wedge:<label>:<stage>:<stageDetail>:<k=v>:<k=v>`.
447
+ // <stageDetail> is a positional value (e.g. "90s"), not a key=value.
448
+ const w = `wedge:${label}:${stage}:${stageDetail}` +
449
+ `:lastYielded=${lastYieldedQName}` +
450
+ `:wedgedForMs=${wedgedFor}`;
451
+ coordinator.warnings.push(w);
452
+ console.log(`ingest: ${label} ⚠ wedged (${stage} ${stageDetail}); slot released, draining in background`);
453
+ // W1.5-03: reuse the SAME stopWaitingRef that was published into ALS
454
+ // at body entry. When enforceBackgroundWedgeCap later flips this ref's
455
+ // `.value = true` on cap eviction, any in-flight rate-limit wrapper
456
+ // inside the wedged iterator's continuing background pumps will see
457
+ // the flag through `stopSignalContext.getStore()` and reject with
458
+ // `StopWaitingError` quickly. The underlying jsforce HTTP call still
459
+ // leaks until socket idle timeout — documented in rate-limit.ts.
460
+ coordinator.backgroundWedges.push({
461
+ label,
462
+ iterator: it,
463
+ pendingFirstNext,
464
+ registeredAt: Date.now(),
465
+ stopWaitingRef,
466
+ });
467
+ enforceBackgroundWedgeCap(coordinator);
468
+ }
469
+ else {
470
+ // No coordinator (e.g. unit-test path that didn't wire one up, or
471
+ // back-compat caller): fall back to the old "log+skip" behavior so
472
+ // the caller still gets observable feedback.
473
+ const msg = `source watchdog (${stage} ${stageDetail}): no record yielded — pool jammed or call wedged`;
474
+ onError?.(label, new Error(msg));
475
+ console.log(`ingest: ${label} ✗ skipped (${msg.slice(0, 80)})`);
476
+ }
477
+ }
178
478
  }
179
479
  /** Typed-extractor ownership map: which XML type names a dedicated extractor covers. */
180
480
  const APEX_TYPES = new Set(["ApexClass", "ApexTrigger"]);
@@ -291,10 +591,29 @@ const GENERIC_TYPE_WHITELIST = new Set([
291
591
  "OmniUiCard",
292
592
  "OmniProcess",
293
593
  ]);
594
+ /**
595
+ * Types whose data is fully captured by a CONTAINER type, so routing them
596
+ * individually only produces value-less opaque duplicates that clobber the
597
+ * container's richer nodes. `CustomLabel` (singular) is covered by the
598
+ * `CustomLabels` container — whose rule extracts each label's value/language;
599
+ * the singular path would emit `CustomLabel:Name` nodes with no value and,
600
+ * depending on merge order, overwrite the real ones. Skip them.
601
+ */
602
+ const CONTAINER_COVERED_TYPES = new Set(["CustomLabel"]);
294
603
  function shouldRouteGeneric(type) {
295
- if (process.env.SFGRAPH_INCLUDE_ALL_GENERIC === "1")
296
- return true;
297
- return GENERIC_TYPE_WHITELIST.has(type);
604
+ // Never route a type a container already covers with richer data.
605
+ if (CONTAINER_COVERED_TYPES.has(type))
606
+ return false;
607
+ // Default: route EVERY long-tail metadata type through the generic
608
+ // extractor so the graph — and therefore semantic search — covers the org
609
+ // completely. Anything without a dedicated extractor still becomes a typed
610
+ // node (the rule parsers key off the type name) or an opaque node, which is
611
+ // what search needs. The old curated whitelist silently dropped ~250
612
+ // discovered types (search returned nothing for them). Set
613
+ // SFGRAPH_GENERIC_WHITELIST_ONLY=1 to restore the restricted subset.
614
+ if (process.env.SFGRAPH_GENERIC_WHITELIST_ONLY === "1")
615
+ return GENERIC_TYPE_WHITELIST.has(type);
616
+ return true;
298
617
  }
299
618
  export async function* bulkRetrieve(conn, caps, orgId, opts = {}) {
300
619
  // Back-compat: callers used to pass IngestSkipReport directly as the 4th arg.
@@ -304,6 +623,13 @@ export async function* bulkRetrieve(conn, caps, orgId, opts = {}) {
304
623
  : opts;
305
624
  const skipReport = normalized.skipReport;
306
625
  const onlyLabels = normalized.onlyLabels;
626
+ // Phase 1.5 W1.5-02 coordinator. Built only when a warnings sink was
627
+ // supplied — without one, no place to push wedge strings, so the legacy
628
+ // "watchdog fires → skip entry" behavior is preserved. The coordinator
629
+ // owns the background-wedge cap + late-drain budget.
630
+ const coordinator = normalized.warnings
631
+ ? createWedgeCoordinator(normalized.warnings)
632
+ : undefined;
307
633
  // Discover the type list this org actually supports. If discovery fails or
308
634
  // returns nothing usable, fall back to invoking every known extractor —
309
635
  // preserves Commit-A behavior for mocks that don't implement describe.
@@ -336,7 +662,7 @@ export async function* bulkRetrieve(conn, caps, orgId, opts = {}) {
336
662
  // ingest. The wrapper records the source label + error into skipReport
337
663
  // (consumed at end of run for a consolidated summary) and ends the
338
664
  // stream cleanly.
339
- sources.push(failSoft(key, factory, onSkip));
665
+ sources.push(failSoft(key, factory, onSkip, coordinator));
340
666
  };
341
667
  if (types.length === 0) {
342
668
  // Discovery unavailable: invoke every dedicated extractor once.
@@ -394,12 +720,54 @@ export async function* bulkRetrieve(conn, caps, orgId, opts = {}) {
394
720
  break;
395
721
  }
396
722
  }
723
+ // Child metadata types (ValidationRule, ListView, CompactLayout, FieldSet,
724
+ // WebLink, BusinessProcess, ...) are returned by describe() only as
725
+ // `childXmlNames` of a parent (mostly CustomObject), never as top-level
726
+ // types — so the dispatch loop above never fetches them. Most ARE
727
+ // independently listable via metadata.list, so route each through the
728
+ // generic extractor. This is how validation rules / list views on STANDARD
729
+ // objects get captured — iterObject only sees children embedded in the
730
+ // custom objects it reads, missing the bulk that live on Account/Case/etc.
731
+ // Children that aren't independently listable fail-soft to nothing. Skip
732
+ // children already owned by a dedicated extractor or dispatched above.
733
+ if (process.env.SFGRAPH_GENERIC_WHITELIST_ONLY !== "1") {
734
+ const typedOwned = new Set([
735
+ ...APEX_TYPES,
736
+ ...LWC_TYPES,
737
+ ...FLOW_TYPES,
738
+ ...OBJECT_TYPES,
739
+ ...SECURITY_TYPES,
740
+ ...INTEGRATION_TYPES,
741
+ "CustomField", // fetched in bulk by iterObject; re-listing is wasteful
742
+ ]);
743
+ const childTypes = new Set();
744
+ for (const t of types)
745
+ for (const c of t.childXmlNames)
746
+ childTypes.add(c);
747
+ for (const child of childTypes) {
748
+ if (dispatch.has(child) || typedOwned.has(child))
749
+ continue;
750
+ if (CONTAINER_COVERED_TYPES.has(child))
751
+ continue; // CustomLabel ⊂ CustomLabels
752
+ invoke(`generic:${child}`, () => iterGenericMetadata(conn, String(orgId), child));
753
+ }
754
+ }
397
755
  }
398
756
  if (caps.vlocityLegacy) {
399
757
  invoke("vlocity", () => iterVlocity(conn, caps, String(orgId), onSkip));
400
758
  }
759
+ // Custom Setting ROW capture — self-guards (yields nothing if the org has no
760
+ // custom settings or EntityDefinition is unavailable), so invoke always.
761
+ invoke("custom-settings", () => iterCustomSettings(conn, String(orgId)));
401
762
  if (caps.omnistudioOncore) {
402
- invoke("omnistudio", () => iterOmnistudio(conn));
763
+ // When the Metadata-API retrieve path is enabled it covers
764
+ // OmniDataTransform and OmniUiCard with higher fidelity — skip them on
765
+ // the SOQL path so the same component is not ingested twice (the
766
+ // retrieve XML would otherwise clobber the SOQL-enriched node, and
767
+ // vice versa, nondeterministically by merge order).
768
+ invoke("omnistudio", () => iterOmnistudio(conn, normalized.enableOmnistudioRetrieve
769
+ ? { skipTypes: new Set(["OmniDataTransform", "OmniUiCard"]) }
770
+ : {}));
403
771
  if (normalized.enableOmnistudioRetrieve) {
404
772
  invoke("omnistudio-retrieve", async function* () {
405
773
  const { iterOmnistudioRetrieve } = await import("./extractors/omnistudio-retrieve.js");
@@ -410,10 +778,16 @@ export async function* bulkRetrieve(conn, caps, orgId, opts = {}) {
410
778
  });
411
779
  }
412
780
  }
413
- // Log the generic-type filter summary once at fan-out start — makes the
414
- // skip-vs-route decision visible without --debug.
781
+ // Log the generic-type routing summary once at fan-out start — makes the
782
+ // skip-vs-route decision visible without --debug. The default is FULL
783
+ // coverage (every discovered type routed); the opt-out is
784
+ // SFGRAPH_GENERIC_WHITELIST_ONLY=1 (restricts to the curated subset).
415
785
  if (types.length > 0) {
416
- console.log(`ingest: dispatch routed=${sources.length} sources (${types.length} discovered metadata types; generic-type whitelist active — set SFGRAPH_INCLUDE_ALL_GENERIC=1 to invoke all)`);
786
+ const whitelistOnly = process.env.SFGRAPH_GENERIC_WHITELIST_ONLY === "1";
787
+ const mode = whitelistOnly
788
+ ? "curated whitelist only (SFGRAPH_GENERIC_WHITELIST_ONLY=1) — unset to route all"
789
+ : "full coverage (all discovered types) — set SFGRAPH_GENERIC_WHITELIST_ONLY=1 to restrict";
790
+ console.log(`ingest: dispatch routed=${sources.length} sources (${types.length} discovered metadata types; ${mode})`);
417
791
  }
418
792
  // Parallel by default: fan out across all source iterators so different
419
793
  // pools (Tooling/Metadata/Data) saturate simultaneously. Escape hatch:
@@ -421,5 +795,60 @@ export async function* bulkRetrieve(conn, caps, orgId, opts = {}) {
421
795
  // anyone who hits an ordering bug or wants the old log layout.
422
796
  const sequential = process.env.SFGRAPH_SEQUENTIAL_SOURCES === "1";
423
797
  yield* sequential ? mergeAsyncIterables(...sources) : mergeAsyncIterablesParallel(...sources);
798
+ // Phase 1.5 W1.5-02 late-yield drain. When the merger has finished its
799
+ // main loop (all live slots cleared), drain any background-wedge iterators
800
+ // for the configured budget. Records arriving inside the budget are
801
+ // yielded with `attributes.lateYield = true`. Iterators whose stopWaiting
802
+ // flag was set by the cap-eviction path skip immediately. Anything still
803
+ // unresolved after the budget is dropped on the floor — its underlying
804
+ // jsforce HTTP request continues running until libuv tears it down at
805
+ // process exit (W1.5-03 will surface a "stop-waiting" semantic for this).
806
+ if (coordinator && coordinator.backgroundWedges.length > 0 && coordinator.lateDrainBudgetMs > 0) {
807
+ if (process.env.SFGRAPH_DEBUG_INGEST === "1") {
808
+ console.log(`ingest: [debug] late-drain pass: ${coordinator.backgroundWedges.length} background wedge(s), budget=${coordinator.lateDrainBudgetMs}ms`);
809
+ }
810
+ for (const entry of coordinator.backgroundWedges) {
811
+ yield* drainBackgroundWedge(entry, coordinator.lateDrainBudgetMs, coordinator.warnings);
812
+ }
813
+ }
424
814
  }
815
+ /**
816
+ * Internal-surface exports for unit tests in `__tests__/`. Not part of the
817
+ * public package API — these are intentionally namespaced under `__testing`
818
+ * so they don't appear in the package's exports surface to downstream
819
+ * consumers. (They're still importable, but the underscore convention
820
+ * signals "test only".)
821
+ *
822
+ * Exposed:
823
+ * - `failSoft`: wrap an iterable with the W1.5-01/02 watchdog + soft-isolate.
824
+ * - `createWedgeCoordinator`: build a coordinator + warnings sink with
825
+ * optional cap/drain overrides for deterministic tests.
826
+ * - `drainBackgroundWedge`: run the post-merger late-drain pass against
827
+ * a single background-wedge entry.
828
+ * - `mergeAsyncIterablesParallel`: re-export for symmetry; the (array,
829
+ * opts) overload is what tests use to fix concurrency.
830
+ * - `setTestWatchdogBudgets` / `resetTestWatchdogBudgets`: env-var-backed
831
+ * knobs to squeeze the watchdog budget for fast tests. Always paired
832
+ * via try/finally in tests so the environment is left clean.
833
+ */
834
+ export const __testing = {
835
+ failSoft,
836
+ createWedgeCoordinator,
837
+ drainBackgroundWedge,
838
+ mergeAsyncIterablesParallel,
839
+ readWatchdogBudgets,
840
+ setTestWatchdogBudgets(opts) {
841
+ process.env.SFGRAPH_WATCHDOG_FIRST_YIELD_MS = String(opts.firstYieldMs);
842
+ process.env.SFGRAPH_WATCHDOG_INACTIVITY_MS = String(opts.inactivityMs);
843
+ },
844
+ resetTestWatchdogBudgets() {
845
+ // Note: assigning `undefined` to a `process.env.X` key in Node coerces to
846
+ // the string "undefined". That's fine here because readWatchdogBudgets()
847
+ // runs parseInt() on the value, parseInt("undefined") === NaN, and NaN
848
+ // falls through to the *_MS_DEFAULT branch. So semantically equivalent
849
+ // to `delete`, but satisfies biome's lint/performance/noDelete.
850
+ process.env.SFGRAPH_WATCHDOG_FIRST_YIELD_MS = undefined;
851
+ process.env.SFGRAPH_WATCHDOG_INACTIVITY_MS = undefined;
852
+ },
853
+ };
425
854
  //# sourceMappingURL=bulk-retrieve.js.map