(
requested: string[],
services: Record<string, ClosureEntry & { dispatchName?: string }> = SERVICES,
)
| 2086 | * Accepts an injected map for testing; defaults to the real SERVICES map. |
| 2087 | */ |
| 2088 | export function computePromoteClosure( |
| 2089 | requested: string[], |
| 2090 | services: Record<string, ClosureEntry & { dispatchName?: string }> = SERVICES, |
| 2091 | ): ClosurePlan { |
| 2092 | // 1) Resolve requested names → SSOT keys (own-key first, then dispatchName), |
| 2093 | // mirroring resolveTargetServices. Fail loud on an unknown name. |
| 2094 | const closure = new Set<string>(); |
| 2095 | const resolveKey = (raw: string): string => { |
| 2096 | const name = raw.trim(); |
| 2097 | if (Object.hasOwn(services, name)) return name; |
| 2098 | for (const [key, entry] of Object.entries(services)) { |
| 2099 | if (entry.dispatchName === name) return key; |
| 2100 | } |
| 2101 | throw new Error( |
| 2102 | `computePromoteClosure: unknown service "${raw}" — not an SSOT key or dispatch_name in railway-envs.ts.`, |
| 2103 | ); |
| 2104 | }; |
| 2105 | for (const raw of requested) { |
| 2106 | if (raw.trim() === "") continue; |
| 2107 | closure.add(resolveKey(raw)); |
| 2108 | } |
| 2109 | |
| 2110 | // A promote whose REQUESTED set is ENTIRELY standalone services pulls in NO |
| 2111 | // Tier-1 verification set: a standalone leaf (e.g. `docs`) is self-contained |
| 2112 | // and depends on nothing, so promoting it alone must promote ONLY itself, not |
| 2113 | // the whole control plane. A mixed or `all` request still forces Tier-1 below. |
| 2114 | const requestedKeys = [...closure]; |
| 2115 | const allStandalone = |
| 2116 | requestedKeys.length > 0 && |
| 2117 | requestedKeys.every((k) => services[k]?.standalone === true); |
| 2118 | |
| 2119 | // 2) Include the full Tier-1 verification set (the control plane + dashboard |
| 2120 | // the post-promote re-sweep / equivalence read run against) — UNLESS the |
| 2121 | // request is entirely standalone (a standalone leaf needs no control plane). |
| 2122 | if (!allStandalone) { |
| 2123 | for (const [key, entry] of Object.entries(services)) { |
| 2124 | if (tierOf(entry) === 1) closure.add(key); |
| 2125 | } |
| 2126 | } |
| 2127 | |
| 2128 | // 3) Transitive runtimeDeps closure (BFS). Every dep must be a real key — |
| 2129 | // a dangling dep is caught by assertClosureValid, but guard here too so |
| 2130 | // the pure function never dereferences a non-entry. |
| 2131 | const queue = [...closure]; |
| 2132 | while (queue.length > 0) { |
| 2133 | const key = queue.shift() as string; |
| 2134 | const entry = Object.hasOwn(services, key) ? services[key] : undefined; |
| 2135 | if (entry === undefined) continue; |
| 2136 | for (const dep of entry.runtimeDeps ?? []) { |
| 2137 | if (!closure.has(dep) && Object.hasOwn(services, dep)) { |
| 2138 | closure.add(dep); |
| 2139 | queue.push(dep); |
| 2140 | } |
| 2141 | } |
| 2142 | } |
| 2143 | |
| 2144 | // 4) Explicit imageOf consumers of any closure member (pull, do not inherit |
| 2145 | // the staging-redeploy env-aware expansion wholesale — §4.2). |
no test coverage detected
searching dependent graphs…