|
| 1 | +#!/usr/bin/env node |
| 2 | +/********************************************************************* |
| 3 | + * |
| 4 | + * Copyright © 2025–2026 Dankest, LLC |
| 5 | + * Based on XChain Platform by Dankest, LLC – https://dankest.llc |
| 6 | + * |
| 7 | + * SPDX-License-Identifier: AGPL-3.0-or-later |
| 8 | + * |
| 9 | + * This file is part of XChain Platform. Licensed under the GNU Affero |
| 10 | + * General Public License v3.0 or later; see LICENSE.md. A commercial |
| 11 | + * license (without AGPL source-disclosure terms) is available - |
| 12 | + * contact legal@dankest.llc. |
| 13 | + * |
| 14 | + ********************************************************************** |
| 15 | + * |
| 16 | + * The migration immutability pin: two sha256 values per committed migration in |
| 17 | + * test/fixtures/migration-executable-residue.json, so an in-place edit to an |
| 18 | + * applied file fails in CI instead of surfacing on a fleet as `content CHANGED`. |
| 19 | + * Ported from the indexer's pin of the same name; the decoder's runner hashes |
| 20 | + * migrations the same way (whole-file sha256 in schema_migrations). |
| 21 | + * |
| 22 | + * residue the file with every `--` line and blank line dropped (the basis the |
| 23 | + * MIGRATION_CHECKSUM_REBASELINES entries are verified on). A change |
| 24 | + * here is an executable edit and always fails. |
| 25 | + * sha256 the whole file, exactly what schema_migrations records. A change here |
| 26 | + * passes only when a reviewed rebaseline heals the pinned hash to the |
| 27 | + * current one, since that is the only case a deployed DB survives. |
| 28 | + * |
| 29 | + * A file the pin does not hold yet passes when its name sorts after the newest pinned |
| 30 | + * migration, so a new migration needs no fixture edit; `--add` pins it. A file that |
| 31 | + * sorts before the newest pinned migration fails because it was missed or backdated. |
| 32 | + * An existing entry moves only through `--accept <file>`, which is legitimate solely |
| 33 | + * for a file no database has applied. |
| 34 | + * |
| 35 | + * USAGE (from the repo root) |
| 36 | + * node bin/lib/migration_residue_pin.js check; exit 1 on a violation |
| 37 | + * node bin/lib/migration_residue_pin.js --add pin every unpinned file |
| 38 | + * node bin/lib/migration_residue_pin.js --accept <file> [--accept <file>] |
| 39 | + * |
| 40 | + ********************************************************************/ |
| 41 | + |
| 42 | +'use strict'; |
| 43 | + |
| 44 | +const crypto = require('crypto'); |
| 45 | +const fs = require('fs'); |
| 46 | +const path = require('path'); |
| 47 | + |
| 48 | +// Loads the rebaseline table only; requiring src/db opens no connection and reads no env. |
| 49 | +const { MIGRATION_CHECKSUM_REBASELINES } = require('../../src/db'); |
| 50 | + |
| 51 | +const ROOT = path.join(__dirname, '..', '..'); |
| 52 | +const MIG_DIR = path.join(ROOT, 'src', 'sql', 'migrations'); |
| 53 | +const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'migration-executable-residue.json'); |
| 54 | + |
| 55 | +const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex'); |
| 56 | + |
| 57 | +function executableResidue(raw){ |
| 58 | + return String(raw).split('\n').filter((l) => !/^\s*--/.test(l) && l.trim() !== '').join('\n'); |
| 59 | +} |
| 60 | + |
| 61 | +function pinOf(raw){ |
| 62 | + return { residue: sha256(executableResidue(raw)), sha256: sha256(raw) }; |
| 63 | +} |
| 64 | + |
| 65 | +// Every violation of the pin over `files` (name -> raw text), as { file, kind, message }. |
| 66 | +function findViolations({ files, fixture, rebaselines }){ |
| 67 | + const out = []; |
| 68 | + const newest = Object.keys(fixture).sort().pop() || ''; |
| 69 | + for(const file of Object.keys(files).filter((f) => !Object.hasOwn(fixture, f) && f < newest).sort()){ |
| 70 | + out.push({ file, kind: 'unpinned', message: file + ' sorts before the newest pinned migration ' + newest + |
| 71 | + ' but is not pinned: run node bin/lib/migration_residue_pin.js --add and commit the fixture.' }); |
| 72 | + } |
| 73 | + for(const file of Object.keys(fixture).sort()){ |
| 74 | + const pinned = fixture[file]; |
| 75 | + // Deleting or renaming an applied migration is forbidden: the decoder has no ledger rename map. |
| 76 | + if(!Object.hasOwn(files, file)){ |
| 77 | + out.push({ file, kind: 'missing', message: file + ' is pinned but gone: deleting or renaming an applied ' + |
| 78 | + 'migration breaks every ledger that recorded it.' }); |
| 79 | + continue; |
| 80 | + } |
| 81 | + const now = pinOf(files[file]); |
| 82 | + // A changed statement is never healable; it needs a new dated migration. |
| 83 | + if(now.residue !== pinned.residue){ |
| 84 | + out.push({ file, kind: 'residue', message: file + ': the executable statements of a pinned migration ' + |
| 85 | + 'changed. Revert and write a new dated migration; MIGRATION_CHECKSUM_REBASELINES heals the ledger only.' }); |
| 86 | + continue; |
| 87 | + } |
| 88 | + // A comment-only change passes only under a rebaseline healing exactly the pinned hash to the current one. |
| 89 | + const heal = rebaselines && rebaselines[file]; |
| 90 | + const healed = heal && heal.to === now.sha256 && [].concat(heal.from).includes(pinned.sha256); |
| 91 | + if(now.sha256 !== pinned.sha256 && !healed){ |
| 92 | + out.push({ file, kind: 'content', message: file + ': content changed with no rebaseline from ' + |
| 93 | + pinned.sha256.slice(0, 12) + ' to ' + now.sha256.slice(0, 12) + ', so every DB that applied it logs ' + |
| 94 | + '`content CHANGED` and migrate.js fails closed there. Revert, or add a reviewed comment-only rebaseline.' }); |
| 95 | + } |
| 96 | + } |
| 97 | + return out; |
| 98 | +} |
| 99 | + |
| 100 | +// Every committed migration (`.sql` only; the README beside them is not one), name -> raw text. |
| 101 | +function readTree(){ |
| 102 | + const files = {}; |
| 103 | + for(const f of fs.readdirSync(MIG_DIR).filter((n) => n.endsWith('.sql'))) files[f] = fs.readFileSync(path.join(MIG_DIR, f), 'utf8'); |
| 104 | + return files; |
| 105 | +} |
| 106 | + |
| 107 | +function writeFixture(fixture){ |
| 108 | + const sorted = {}; |
| 109 | + for(const k of Object.keys(fixture).sort()) sorted[k] = fixture[k]; |
| 110 | + fs.writeFileSync(FIXTURE, JSON.stringify(sorted, null, 2) + '\n'); |
| 111 | +} |
| 112 | + |
| 113 | +function main(argv){ |
| 114 | + const files = readTree(); |
| 115 | + const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); |
| 116 | + const accept = argv.flatMap((a, i) => (a === '--accept' ? [argv[i + 1]] : [])); |
| 117 | + if(argv.includes('--add') || accept.length){ |
| 118 | + const added = Object.keys(files).filter((f) => !fixture[f]); |
| 119 | + if(argv.includes('--add')) for(const f of added) fixture[f] = pinOf(files[f]); |
| 120 | + for(const f of accept){ |
| 121 | + if(!files[f]) throw new Error('--accept ' + f + ': no such migration'); |
| 122 | + console.warn('ACCEPTED a new pin for ' + f + ': legitimate only if no database has applied it.'); |
| 123 | + fixture[f] = pinOf(files[f]); |
| 124 | + } |
| 125 | + writeFixture(fixture); |
| 126 | + console.log('pinned ' + (argv.includes('--add') ? added.length : 0) + ' new, accepted ' + accept.length + |
| 127 | + '. Next: node bin/lib/migration_residue_pin.js'); |
| 128 | + return 0; |
| 129 | + } |
| 130 | + const bad = findViolations({ files, fixture, rebaselines: MIGRATION_CHECKSUM_REBASELINES }); |
| 131 | + for(const v of bad) console.log(v.kind + ': ' + v.message); |
| 132 | + const unpinned = Object.keys(files).filter((f) => !fixture[f]).length; |
| 133 | + console.log(bad.length + ' violation(s), ' + unpinned + ' unpinned file(s).' + |
| 134 | + (unpinned ? ' Next: node bin/lib/migration_residue_pin.js --add' : '')); |
| 135 | + return bad.length ? 1 : 0; |
| 136 | +} |
| 137 | + |
| 138 | +module.exports = { executableResidue, pinOf, findViolations, readTree, FIXTURE }; |
| 139 | + |
| 140 | +if(require.main === module) process.exitCode = main(process.argv.slice(2)); |
0 commit comments