|
| 1 | +// ───────────────────────────────────────────────────────────────────────────── |
| 2 | +// State key recanonicalization — generic across all resource types. |
| 3 | +// |
| 4 | +// The pull engine generates UUID-suffixed state keys (`<base>-<uuid8>`) when |
| 5 | +// name-collision adoption is refused (src/pull.ts:findExistingResourceId / |
| 6 | +// generateResourceId). That rekey is fire-and-forget: once the underlying |
| 7 | +// collision resolves (e.g. the conflicting twin is deleted on the dashboard), |
| 8 | +// nothing ever collapses the UUID-suffixed entry back to its canonical slug. |
| 9 | +// |
| 10 | +// Without this collapse, subsequent pushes treat the canonical-slug local |
| 11 | +// file as a brand-new resource (orphan-YAML), and the operator either hits |
| 12 | +// the orphan-YAML gate (#30) and bypasses with `--allow-new-files`, or — in |
| 13 | +// older engine versions — silently creates a third dashboard duplicate. |
| 14 | +// |
| 15 | +// This module runs a safe collapse pass at end-of-pull and start-of-push. |
| 16 | +// The pass is resource-type-agnostic: it walks every section of StateFile |
| 17 | +// uniformly, so adding a new ResourceType doesn't need a code change here. |
| 18 | +// |
| 19 | +// SAFETY MODEL — every rekey must satisfy all five preconditions: |
| 20 | +// 1. Key matches `^(.+)-([0-9a-f]{8})$` — the engine's generated shape. |
| 21 | +// 2. The captured `<uuid8>` matches the entry's UUID prefix. This rules |
| 22 | +// out resources whose user-given names legitimately end in |
| 23 | +// `-abcd1234`. We only touch keys we can prove the engine wrote. |
| 24 | +// 3. Canonical slug `<base>` is unclaimed in the SAME state section. |
| 25 | +// Prevents collision when a same-name twin is intentionally tracked. |
| 26 | +// 4. A local file exists at `<base>` with any extension recognized by |
| 27 | +// the resource loader (`VALID_EXTENSIONS` in src/resources.ts — |
| 28 | +// .yml/.yaml/.ts/.md). Prevents creating phantom state mappings to |
| 29 | +// slugs that have no source file. The extension set is imported, not |
| 30 | +// hardcoded here, so the precondition stays in lockstep with the |
| 31 | +// loader; any future loader extension is automatically respected. |
| 32 | +// 5. NO local file exists at `<base>-<uuid8>` under any |
| 33 | +// `VALID_EXTENSIONS` shape. The UUID-suffixed file represents a |
| 34 | +// different content snapshot; rekeying state without consolidating |
| 35 | +// files would silently reassign which file PATCHes which dashboard |
| 36 | +// UUID — the data-loss shape we are explicitly refusing to introduce. |
| 37 | +// |
| 38 | +// When (5) fails we surface a CONFLICT (both files exist, ambiguous which |
| 39 | +// owns the dashboard UUID) so the operator can resolve it manually. We |
| 40 | +// never auto-pick a winner — silent data loss is worse than a duplicate. |
| 41 | +// ───────────────────────────────────────────────────────────────────────────── |
| 42 | + |
| 43 | +import { existsSync } from "fs"; |
| 44 | +import { join } from "path"; |
| 45 | +import { RESOURCES_DIR } from "./config.ts"; |
| 46 | +import { FOLDER_MAP, VALID_EXTENSIONS } from "./resources.ts"; |
| 47 | +import type { TouchedSets } from "./state-merge.ts"; |
| 48 | +import type { ResourceType, StateFile } from "./types.ts"; |
| 49 | +import { VALID_RESOURCE_TYPES } from "./types.ts"; |
| 50 | + |
| 51 | +const UUID_SUFFIX_RE = /^(.+)-([0-9a-f]{8})$/i; |
| 52 | + |
| 53 | +export interface RecanonicalizeRekey { |
| 54 | + type: ResourceType; |
| 55 | + fromKey: string; |
| 56 | + toKey: string; |
| 57 | + uuid: string; |
| 58 | +} |
| 59 | + |
| 60 | +export interface RecanonicalizeConflict { |
| 61 | + type: ResourceType; |
| 62 | + uuidSuffixedKey: string; |
| 63 | + canonicalKey: string; |
| 64 | + reason: |
| 65 | + | "canonical-slug-claimed-by-different-uuid" |
| 66 | + | "both-local-files-exist" |
| 67 | + | "canonical-local-file-missing"; |
| 68 | + uuid: string; |
| 69 | +} |
| 70 | + |
| 71 | +export interface RecanonicalizeReport { |
| 72 | + rekeys: RecanonicalizeRekey[]; |
| 73 | + conflicts: RecanonicalizeConflict[]; |
| 74 | +} |
| 75 | + |
| 76 | +export interface RecanonicalizeOptions { |
| 77 | + state: StateFile; |
| 78 | + // DI seam — defaults to filesystem check rooted at `RESOURCES_DIR`. Tests |
| 79 | + // pass a stub so they don't need a fixture tree. |
| 80 | + fileExists?: (relativePath: string) => boolean; |
| 81 | + // Restrict the pass to specific types (default: all). |
| 82 | + types?: ResourceType[]; |
| 83 | + // Optional `touched` set for scoped pushes. When provided, both the |
| 84 | + // deleted UUID-suffixed key AND the new canonical key are marked so |
| 85 | + // `mergeScoped` flushes the rename instead of silently re-persisting the |
| 86 | + // on-disk stale key. Pull doesn't pass this — it saves wholesale. |
| 87 | + touched?: TouchedSets; |
| 88 | +} |
| 89 | + |
| 90 | +function defaultFileExists(relativePath: string): boolean { |
| 91 | + return existsSync(join(RESOURCES_DIR, relativePath)); |
| 92 | +} |
| 93 | + |
| 94 | +function localFileExistsForId( |
| 95 | + type: ResourceType, |
| 96 | + resourceId: string, |
| 97 | + fileExists: (relativePath: string) => boolean, |
| 98 | +): boolean { |
| 99 | + const folder = FOLDER_MAP[type]; |
| 100 | + for (const ext of VALID_EXTENSIONS) { |
| 101 | + if (fileExists(`${folder}/${resourceId}${ext}`)) return true; |
| 102 | + } |
| 103 | + return false; |
| 104 | +} |
| 105 | + |
| 106 | +// Mutates `state` in place. Returns the list of changes for logging and the |
| 107 | +// list of unresolved conflicts the operator should see. Callers decide |
| 108 | +// whether conflicts halt the run (push) or are advisory (pull). |
| 109 | +export function recanonicalizeStateKeys( |
| 110 | + opts: RecanonicalizeOptions, |
| 111 | +): RecanonicalizeReport { |
| 112 | + const { |
| 113 | + state, |
| 114 | + fileExists = defaultFileExists, |
| 115 | + types = [...VALID_RESOURCE_TYPES], |
| 116 | + touched, |
| 117 | + } = opts; |
| 118 | + |
| 119 | + const rekeys: RecanonicalizeRekey[] = []; |
| 120 | + const conflicts: RecanonicalizeConflict[] = []; |
| 121 | + |
| 122 | + const markTouched = (type: ResourceType, key: string): void => { |
| 123 | + if (touched) touched[type].add(key); |
| 124 | + }; |
| 125 | + |
| 126 | + for (const type of types) { |
| 127 | + const section = state[type]; |
| 128 | + if (!section) continue; |
| 129 | + |
| 130 | + // Snapshot keys upfront so we can mutate the section during iteration. |
| 131 | + for (const stateKey of Object.keys(section)) { |
| 132 | + const entry = section[stateKey]; |
| 133 | + if (!entry) continue; |
| 134 | + |
| 135 | + const match = stateKey.match(UUID_SUFFIX_RE); |
| 136 | + if (!match) continue; |
| 137 | + |
| 138 | + const [, canonicalSlug, capturedUuid8] = match; |
| 139 | + if (!canonicalSlug || !capturedUuid8) continue; |
| 140 | + |
| 141 | + // Precondition 2 — only recanonicalize engine-generated suffixes. If |
| 142 | + // the captured 8 hex chars don't match the entry's UUID prefix, this |
| 143 | + // is a user-named resource that coincidentally looks suffixed. |
| 144 | + // |
| 145 | + // Mirrors generateResourceId() in src/pull.ts:265-273 — UUID dashes |
| 146 | + // start at index 8, so `slice(0, 8)` on the raw UUID is equivalent |
| 147 | + // to stripping dashes first; the dash-strip is defensive. |
| 148 | + const entryUuid8 = entry.uuid.replace(/-/g, "").slice(0, 8).toLowerCase(); |
| 149 | + if (capturedUuid8.toLowerCase() !== entryUuid8) continue; |
| 150 | + |
| 151 | + // Precondition 3 — canonical slot must be unclaimed in state. |
| 152 | + // |
| 153 | + // Special case: if the canonical slot is claimed by the SAME UUID, |
| 154 | + // both keys are aliases for one dashboard resource. This shape is |
| 155 | + // produced by older engine versions that wrote duplicate-aliased |
| 156 | + // state, or by a `mergeScoped` round-trip before recanonicalize was |
| 157 | + // touched-aware. Safe action: drop the redundant UUID-suffixed key |
| 158 | + // (canonical wins; its metadata is presumed more authoritative). |
| 159 | + // This is auto-resolved as a rekey for reporting purposes. |
| 160 | + const claimedEntry = section[canonicalSlug]; |
| 161 | + if (claimedEntry !== undefined) { |
| 162 | + if (claimedEntry.uuid === entry.uuid) { |
| 163 | + delete section[stateKey]; |
| 164 | + markTouched(type, stateKey); |
| 165 | + markTouched(type, canonicalSlug); |
| 166 | + rekeys.push({ |
| 167 | + type, |
| 168 | + fromKey: stateKey, |
| 169 | + toKey: canonicalSlug, |
| 170 | + uuid: entry.uuid, |
| 171 | + }); |
| 172 | + continue; |
| 173 | + } |
| 174 | + // Genuinely a different UUID — same-name twin tracked legitimately. |
| 175 | + conflicts.push({ |
| 176 | + type, |
| 177 | + uuidSuffixedKey: stateKey, |
| 178 | + canonicalKey: canonicalSlug, |
| 179 | + reason: "canonical-slug-claimed-by-different-uuid", |
| 180 | + uuid: entry.uuid, |
| 181 | + }); |
| 182 | + continue; |
| 183 | + } |
| 184 | + |
| 185 | + // Precondition 4 — canonical local file must exist. Otherwise we'd |
| 186 | + // be inventing a state mapping that has no source on disk. |
| 187 | + const canonicalFileExists = localFileExistsForId( |
| 188 | + type, |
| 189 | + canonicalSlug, |
| 190 | + fileExists, |
| 191 | + ); |
| 192 | + if (!canonicalFileExists) { |
| 193 | + conflicts.push({ |
| 194 | + type, |
| 195 | + uuidSuffixedKey: stateKey, |
| 196 | + canonicalKey: canonicalSlug, |
| 197 | + reason: "canonical-local-file-missing", |
| 198 | + uuid: entry.uuid, |
| 199 | + }); |
| 200 | + continue; |
| 201 | + } |
| 202 | + |
| 203 | + // Precondition 5 — UUID-suffixed local file must NOT exist. If both |
| 204 | + // files exist they represent different content snapshots; silently |
| 205 | + // rekeying would change which file PATCHes which dashboard UUID. |
| 206 | + const uuidSuffixedFileExists = localFileExistsForId( |
| 207 | + type, |
| 208 | + stateKey, |
| 209 | + fileExists, |
| 210 | + ); |
| 211 | + if (uuidSuffixedFileExists) { |
| 212 | + conflicts.push({ |
| 213 | + type, |
| 214 | + uuidSuffixedKey: stateKey, |
| 215 | + canonicalKey: canonicalSlug, |
| 216 | + reason: "both-local-files-exist", |
| 217 | + uuid: entry.uuid, |
| 218 | + }); |
| 219 | + continue; |
| 220 | + } |
| 221 | + |
| 222 | + // All preconditions met — collapse. Mark BOTH the deleted UUID-suffixed |
| 223 | + // key AND the new canonical key as touched, so scoped pushes flush the |
| 224 | + // rename via `mergeScoped` instead of silently re-persisting the |
| 225 | + // on-disk stale key. |
| 226 | + section[canonicalSlug] = entry; |
| 227 | + delete section[stateKey]; |
| 228 | + markTouched(type, stateKey); |
| 229 | + markTouched(type, canonicalSlug); |
| 230 | + rekeys.push({ |
| 231 | + type, |
| 232 | + fromKey: stateKey, |
| 233 | + toKey: canonicalSlug, |
| 234 | + uuid: entry.uuid, |
| 235 | + }); |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + return { rekeys, conflicts }; |
| 240 | +} |
| 241 | + |
| 242 | +// Human-readable summary for stdout. Empty report renders nothing — callers |
| 243 | +// can unconditionally log. |
| 244 | +export function formatRecanonicalizeReport( |
| 245 | + report: RecanonicalizeReport, |
| 246 | +): string { |
| 247 | + if (report.rekeys.length === 0 && report.conflicts.length === 0) return ""; |
| 248 | + |
| 249 | + const lines: string[] = []; |
| 250 | + if (report.rekeys.length > 0) { |
| 251 | + lines.push( |
| 252 | + `🔧 Recanonicalized ${report.rekeys.length} state key(s) — collapsed UUID-suffixed slug back to canonical:`, |
| 253 | + ); |
| 254 | + for (const r of report.rekeys) { |
| 255 | + lines.push(` ${r.type}/${r.fromKey} → ${r.type}/${r.toKey}`); |
| 256 | + } |
| 257 | + } |
| 258 | + if (report.conflicts.length > 0) { |
| 259 | + lines.push( |
| 260 | + `⚠️ ${report.conflicts.length} UUID-suffixed state key(s) NOT recanonicalized (manual resolution required):`, |
| 261 | + ); |
| 262 | + for (const c of report.conflicts) { |
| 263 | + const hint = |
| 264 | + c.reason === "canonical-slug-claimed-by-different-uuid" |
| 265 | + ? `canonical slug ${c.canonicalKey} already claimed by a different dashboard UUID (legitimate same-name twin)` |
| 266 | + : c.reason === "both-local-files-exist" |
| 267 | + ? `both ${c.canonicalKey}.{yml,yaml,ts,md} and ${c.uuidSuffixedKey}.{yml,yaml,ts,md} exist — pick one and delete the other before next push` |
| 268 | + : `no local file at ${c.canonicalKey}.{yml,yaml,ts,md} — state entry is stale; either restore the file or remove the state entry`; |
| 269 | + lines.push(` ${c.type}/${c.uuidSuffixedKey}: ${hint}`); |
| 270 | + } |
| 271 | + } |
| 272 | + return lines.join("\n"); |
| 273 | +} |
0 commit comments