upgrading/app/upgrades/0.16-to-0.17/strip-sha256-hash-prefixes.ts
upgrading/app/upgrades/0.16-to-0.17/strip-sha256-hash-prefixes.tsBrowse 76 files
3,673 tokens
14,715 bytes
Token encoding: o200k_base
Snapshot fac8604
← Back to SKILL.md
1/**2 * Brings checked-in `migrations/` trees onto the 0.17 bare-hex hash3 * representation: strips the legacy `sha256:` prefix from every hash literal,4 * maps the empty-tree sentinel `sha256:empty` to `empty`, recomputes each5 * migration's `migrationHash` over the bare-hex content, and repoints6 * `refs/*.json` at the recomputed hashes.7 *8 * Background: starting at the 0.17 release, content hashes are bare9 * lowercase hex — the `sha256:` prefix is gone from every surface (emitted10 * `contract.json` / `contract.d.ts`, migration manifests, refs, CLI output,11 * and the marker/ledger tables). The algorithm never varied per hash, so the12 * prefix carried no information; a format change is signalled by the hash13 * value changing, not by an in-band tag. Loaders now reject the legacy14 * prefixed form outright.15 *16 * Two distinct effects on checked-in migration artifacts:17 *18 * - Contract hashes (`from` / `to` in `migration.json`, `storageHash` /19 * `profileHash` in contract snapshots and `.d.ts` branded literals,20 * `storageHash` stamps inside `ops.json`) keep their VALUE — only the21 * `sha256:` prefix drops.22 * - `migrationHash` VALUES change, because the hashed manifest bytes embed23 * the (now bare) `from` / `to` strings. Every manifest gets a freshly24 * recomputed hash, and every `refs/*.json` that pointed at an old25 * migration hash is rewritten to the recomputed one.26 *27 * Before 0.17 a manifest was:28 *29 * {30 * "from": "sha256:8ee1e7ce…",31 * "to": "sha256:059f3f35…",32 * "providedInvariants": [],33 * "createdAt": "2026-…",34 * "migrationHash": "sha256:3c5205d2…"35 * }36 *37 * Starting at 0.17 the same manifest is:38 *39 * {40 * "from": "8ee1e7ce…",41 * "to": "059f3f35…",42 * "providedInvariants": [],43 * "createdAt": "2026-…",44 * "migrationHash": "2be2085f…" // recomputed over the bare-hex bytes45 * }46 *47 * Format-preserving edit: hash literals are rewritten in place via a targeted48 * pattern (`"sha256:<64 hex>"` / `"sha256:empty"`, single- or double-quoted),49 * and the `migrationHash` value is swapped in place. Every other byte (key50 * order, indentation, inline-vs-expanded arrays) is left exactly as the51 * authoring tool wrote it, so diffs stay minimal.52 *53 * Confinement: an on-disk migration package is a `migration.json` paired54 * with a sibling `ops.json`. The walk keys off that pair; within a package55 * directory every `.json` / `.ts` sibling (pre-store contract snapshots,56 * `.d.ts` branded types, the executable `migration.ts`) has its hash57 * literals stripped. Content-addressed store entries58 * (`migrations/snapshots/<hex>/contract.json` + `contract.d.ts`) are also59 * covered, so the codemod handles both the pre-store sibling layout and the60 * store layout — run it BEFORE `scripts/migrate-migrations-layout.mjs` when61 * converting the layout in the same upgrade (the 0.17 migrator accepts only62 * bare-hex trees). Store directory names are the hash's hex, which is63 * unchanged by the prefix drop, so no directory is renamed. Ref files are64 * `refs/*.json` under a directory named `migrations`; each gets old-hash →65 * recomputed-hash repointing plus prefix stripping (which also maps the66 * `sha256:empty` sentinel to `empty`).67 *68 * The hash algorithm is replicated inline (canonicalisation rules from69 * `@internal/framework-components` `canonicalizeJson` + the70 * migration-tools `computeMigrationHash`, which returns bare hex from 0.17)71 * so this script stays self-contained — consumers run it via `pnpm exec tsx`72 * from their project root with no dependency on any `@internal/*` package73 * being resolvable from that root.74 *75 * The codemod is idempotent: an already-bare tree carries no `sha256:`76 * literals and its recomputed hashes match the stored ones, so the edit is a77 * no-op and every file is left untouched.78 *79 * Flags:80 * --check dry-run; lists files that still need fixing and exits 1 if81 * any remain.82 */83import { createHash } from 'node:crypto';84import { readdir, readFile, writeFile } from 'node:fs/promises';85import { basename, dirname, join, sep } from 'node:path';86 87const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);88 89const dryRun = process.argv.includes('--check');90const projectRoot = process.cwd();91 92// --- Inline canonicalisation + hash --------------------------------------93// Replicated from `@internal/framework-components` `canonicalizeJson`94// (sortKeys + JSON.stringify) and the migration-tools `computeMigrationHash`.95// Kept inline so the script has no `@internal/*` import — pnpm's strict96// node_modules layout won't resolve transitive framework deps from a97// consumer's project root.98 99function sortKeys(value: unknown): unknown {100 if (value === null || typeof value !== 'object') {101 return value;102 }103 if (Array.isArray(value)) {104 return value.map(sortKeys);105 }106 const sorted: Record<string, unknown> = Object.create(null);107 for (const [key, entry] of Object.entries(value).sort(([a], [b]) =>108 a < b ? -1 : a > b ? 1 : 0,109 )) {110 sorted[key] = sortKeys(entry);111 }112 return sorted;113}114 115function canonicalizeJson(value: unknown): string {116 return JSON.stringify(sortKeys(value));117}118 119function sha256Hex(input: string): string {120 return createHash('sha256').update(input).digest('hex');121}122 123/**124 * Content-addressed migration hash over (metadata envelope, ops), 0.17 form:125 * bare hex, no prefix. The `migrationHash` field is stripped before hashing126 * so the same function works at write time (no hash yet) and at recompute127 * time (rehashing an already-attested record over the bare-hex envelope).128 */129function computeMigrationHash(metadata: Record<string, unknown>, ops: unknown): string {130 const { migrationHash: _migrationHash, ...strippedMeta } = metadata;131 132 const partHashes = [canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex);133 return sha256Hex(canonicalizeJson(partHashes));134}135 136// --- Prefix stripping ------------------------------------------------------137 138// A legacy hash literal: `sha256:` + 64 lowercase hex chars, or the139// empty-tree sentinel `sha256:empty`, in single or double quotes. Quoting is140// required so prose that merely mentions the prefix is never rewritten.141const LEGACY_HASH_LITERAL = /(["'])sha256:([0-9a-f]{64}|empty)\1/g;142 143function stripHashPrefixes(text: string): string {144 return text.replace(LEGACY_HASH_LITERAL, (_full, quote: string, hash: string) => {145 return `${quote}${hash}${quote}`;146 });147}148 149function replaceMigrationHash(text: string, oldHash: string, newHash: string): string {150 if (oldHash === newHash) return text;151 const re = new RegExp(`("migrationHash"[ \\t]*:[ \\t]*)"${oldHash}"`);152 if (re.exec(text) === null) {153 throw new Error('could not locate the migrationHash value to replace');154 }155 return text.replace(re, (_full, prefix: string) => `${prefix}"${newHash}"`);156}157 158// --- Filesystem walk ------------------------------------------------------159 160interface WalkResult {161 readonly manifests: string[];162 readonly refFiles: string[];163 readonly snapshotFiles: string[];164}165 166async function findMigrationArtifacts(root: string): Promise<WalkResult> {167 const manifests: string[] = [];168 const refFiles: string[] = [];169 const snapshotFiles: string[] = [];170 171 async function walk(dir: string): Promise<void> {172 let entries: Awaited<ReturnType<typeof readdir>>;173 try {174 entries = await readdir(dir, { withFileTypes: true });175 } catch {176 // Unreadable directory — skip silently. The consumer's project root may177 // legitimately contain restricted directories.178 return;179 }180 for (const entry of entries) {181 const path = join(dir, entry.name);182 if (entry.isDirectory()) {183 if (SKIP_DIRS.has(entry.name)) continue;184 await walk(path);185 } else if (entry.isFile() && entry.name === 'migration.json') {186 manifests.push(path);187 } else if (188 entry.isFile() &&189 entry.name.endsWith('.json') &&190 basename(dir) === 'refs' &&191 dirname(dir).split(sep).includes('migrations')192 ) {193 refFiles.push(path);194 } else if (195 entry.isFile() &&196 (entry.name.endsWith('.json') || entry.name.endsWith('.ts')) &&197 basename(dirname(dir)) === 'snapshots' &&198 dirname(dirname(dir)).split(sep).includes('migrations')199 ) {200 snapshotFiles.push(path);201 }202 }203 }204 205 await walk(root);206 return {207 manifests: manifests.sort(),208 refFiles: refFiles.sort(),209 snapshotFiles: snapshotFiles.sort(),210 };211}212 213// --- Per-file transforms ---------------------------------------------------214 215/** Narrows an arbitrary JSON-parsed value to a plain object (manifest shape). */216function isJsonObject(value: unknown): value is Record<string, unknown> {217 return typeof value === 'object' && value !== null && !Array.isArray(value);218}219 220type Status = 'already-clean' | 'needs-fix' | 'fixed' | 'skipped-no-ops';221 222interface Result {223 readonly path: string;224 readonly status: Status;225}226 227const results: Result[] = [];228/** Old migration hash (as previously stored, prefixed) → recomputed bare hash. */229const migrationHashMap = new Map<string, string>();230 231async function emit(path: string, before: string, after: string): Promise<Result> {232 if (after === before) {233 return { path, status: 'already-clean' };234 }235 if (!dryRun) await writeFile(path, after, 'utf-8');236 return { path, status: dryRun ? 'needs-fix' : 'fixed' };237}238 239async function processSibling(path: string): Promise<Result> {240 const raw = await readFile(path, 'utf-8');241 return emit(path, raw, stripHashPrefixes(raw));242}243 244async function processPackage(manifestPath: string): Promise<Result[]> {245 const raw = await readFile(manifestPath, 'utf-8');246 247 let parsed: unknown;248 try {249 parsed = JSON.parse(raw);250 } catch (error) {251 throw new Error(252 `${manifestPath}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,253 );254 }255 if (!isJsonObject(parsed)) {256 return [{ path: manifestPath, status: 'already-clean' }]; // not a manifest object257 }258 259 const packageDir = dirname(manifestPath);260 261 // A complete on-disk migration package pairs `migration.json` with a sibling262 // `ops.json` (the operations the hash is computed over); without it we cannot263 // recompute the hash, so this is not a package we should touch.264 const opsPath = join(packageDir, 'ops.json');265 let opsRaw: string;266 try {267 opsRaw = await readFile(opsPath, 'utf-8');268 } catch {269 return [{ path: manifestPath, status: 'skipped-no-ops' }];270 }271 272 const out: Result[] = [];273 274 // Ops: strip prefixes (e.g. `meta.storageHash` stamps inside operation275 // payloads), then parse the stripped text — the recomputed hash covers the276 // bare-hex operations exactly as they will sit on disk.277 const strippedOpsRaw = stripHashPrefixes(opsRaw);278 const ops: unknown = JSON.parse(strippedOpsRaw);279 out.push(await emit(opsPath, opsRaw, strippedOpsRaw));280 281 // Manifest: strip prefixes from `from` / `to` (and the sentinel), then282 // recompute `migrationHash` over the bare-hex envelope + bare-hex ops.283 // Canonicalisation is order/whitespace independent, so parsing the stripped284 // text is the right input regardless of on-disk formatting.285 const strippedManifestRaw = stripHashPrefixes(raw);286 const strippedMeta = JSON.parse(strippedManifestRaw);287 if (!isJsonObject(strippedMeta)) {288 throw new Error(`${manifestPath}: manifest is not a JSON object`);289 }290 const newHash = computeMigrationHash(strippedMeta, ops);291 292 const oldStoredHash = parsed['migrationHash'];293 if (typeof oldStoredHash !== 'string') {294 throw new Error(`${manifestPath}: manifest is missing a string \`migrationHash\` field`);295 }296 const strippedOldHash = stripHashPrefixes(`"${oldStoredHash}"`).slice(1, -1);297 migrationHashMap.set(oldStoredHash, newHash);298 migrationHashMap.set(strippedOldHash, newHash);299 300 out.push(301 await emit(302 manifestPath,303 raw,304 replaceMigrationHash(strippedManifestRaw, strippedOldHash, newHash),305 ),306 );307 308 // Siblings: contract snapshots (`*-contract.json`), branded-literal type309 // files (`*.d.ts`), and the executable `migration.ts` all carry contract310 // hash literals whose value is stable — only the prefix drops.311 let entries: Awaited<ReturnType<typeof readdir>>;312 try {313 entries = await readdir(packageDir, { withFileTypes: true });314 } catch {315 return out;316 }317 for (const entry of entries) {318 if (!entry.isFile()) continue;319 if (entry.name === 'migration.json' || entry.name === 'ops.json') continue;320 if (!entry.name.endsWith('.json') && !entry.name.endsWith('.ts')) continue;321 out.push(await processSibling(join(packageDir, entry.name)));322 }323 return out;324}325 326async function processRefFile(path: string): Promise<Result> {327 const raw = await readFile(path, 'utf-8');328 329 // Repoint at recomputed migration hashes first (a ref stores the migration330 // hash of the package it names), then strip any remaining prefixes — which331 // covers contract-hash refs and maps `sha256:empty` → `empty`.332 let text = raw;333 for (const [oldHash, newHash] of migrationHashMap) {334 if (oldHash === newHash) continue;335 text = text.replaceAll(`"${oldHash}"`, `"${newHash}"`);336 }337 text = stripHashPrefixes(text);338 339 return emit(path, raw, text);340}341 342// --- Driver ---------------------------------------------------------------343 344const { manifests, refFiles, snapshotFiles } = await findMigrationArtifacts(projectRoot);345if (manifests.length === 0 && refFiles.length === 0 && snapshotFiles.length === 0) {346 console.error(`No migration artifacts found under ${projectRoot}.`);347 process.exit(1);348}349 350for (const manifestPath of manifests) {351 results.push(...(await processPackage(manifestPath)));352}353for (const snapshotPath of snapshotFiles) {354 results.push(await processSibling(snapshotPath));355}356for (const refPath of refFiles) {357 results.push(await processRefFile(refPath));358}359 360let changed = 0;361let alreadyClean = 0;362let skipped = 0;363for (const result of results) {364 const rel = result.path.slice(projectRoot.length + 1);365 if (result.status === 'already-clean') {366 alreadyClean += 1;367 } else if (result.status === 'skipped-no-ops') {368 skipped += 1;369 console.log(`SKIP ${rel} (no sibling ops.json — not a migration package)`);370 } else {371 changed += 1;372 const verb = dryRun ? 'WOULD FIX' : 'FIXED';373 console.log(`${verb} ${rel}`);374 }375}376 377console.log();378console.log(379 `${results.length} file(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean${skipped > 0 ? `, ${skipped} skipped (no ops.json)` : ''}.`,380);381 382if (dryRun && changed > 0) process.exit(1);383