Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.20.0] - 2026-09-17

### Fixed
- The migration CLI refuses unrecognized arguments instead of applying every pending migration.

## [0.19.0] - 2026-09-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# XChain Platform Decoder

<p align="center">
<img src="https://img.shields.io/badge/version-0.19.0-blue" alt="Version">
<img src="https://img.shields.io/badge/version-0.20.0-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-2%2C118%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/node-%3E%3D22-green" alt="Node">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-blue" alt="License">
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "xchain-decoder",
"description": "xchain-decoder decodes XChain platform transactions from a given blockchain and populates a database with the decoded data.",
"version": "0.19.0",
"version": "0.20.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
Expand Down
65 changes: 58 additions & 7 deletions src/migrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
* blanket `node src/migrate.js` would). Repeat the flag (or comma-separate) to
* target several files; an unknown name fails loudly instead of applying nothing.
*
* Any argument this CLI does not recognize is REFUSED with the usage text and
* exit 2. It is never ignored: a no-argument run means APPLY EVERYTHING, so an
* ignored token (a typo, `--dry-run`, `--help`) would silently apply every
* pending manual migration the operator was only asking about.
*
* Reads DECODER_DB_* from the service environment (.env).
*
********************************************************************/
Expand All @@ -41,9 +46,36 @@ dotenv.config();

const Database = require('./db.js');

// Spelled out for an operator reading it mid-incident: the difference between a
// blanket run and a scoped one is the whole risk of this command, so each mode
// says what it applies rather than naming a flag.
const USAGE = [
'Usage: node src/migrate.js [--file <name.sql> ...]',
'',
' (no arguments) APPLY EVERYTHING. Runs every pending migration, auto',
' AND manual, against the database in DECODER_DB_NAME.',
' Manual migrations are the destructive / backfill ones.',
' --file, -f <name.sql> APPLY ONE. Runs only the named migration file(s).',
' Repeat the flag or comma-separate to name several.',
' --help, -h Print this usage and exit 0. Touches no database.',
'',
'Reads DECODER_DB_HOST / DECODER_DB_PORT / DECODER_DB_NAME / DECODER_DB_USER /',
'DECODER_DB_PASS from the service environment (.env). Any other argument is',
'refused with exit 2, because ignoring one would mean APPLY EVERYTHING.',
].join('\n');

// Print the usage and exit. Returns null so main() bails even where process.exit
// is stubbed (tests), instead of falling through to an apply-everything run.
function refuse(message){
console.error('migrate: ' + message);
console.error(USAGE);
process.exit(2);
return null;
}

// Parse `--file <name>` / `--file=<name>` / `-f <name>` occurrences into a list of
// migration filenames to scope the run to. Values may be comma-separated. Returns []
// when no targeting flag is present (the default apply-everything behavior).
// migration filenames. Values may be comma-separated. [] means no targeting flag
// (the apply-everything default); null means refused or served, so main() must stop.
function parseFileTargets(argv){
const targets = [];
const push = (v) => {
Expand All @@ -54,23 +86,44 @@ function parseFileTargets(argv){
};
for(let i = 0; i < argv.length; i++){
const a = argv[i];
// Usage requests are served before anything else reads the environment, so
// asking what this command does never needs a loaded .env and never runs.
if(a === '--help' || a === '-h'){
console.log(USAGE);
process.exit(0);
return null; // (unreachable when exit is real; keeps a stubbed exit from applying)
}
const named = targets.length;
if(a === '--file' || a === '-f'){
const v = argv[i + 1];
if(v === undefined || v.startsWith('-')){
console.error('migrate: ' + a + ' requires a migration filename argument.');
process.exit(2);
return targets; // (unreachable when exit is real; guards stubbed-exit tests)
return refuse(a + ' requires a migration filename argument.');
}
push(v);
i++;
} else if(a.startsWith('--file=')){
push(a.slice('--file='.length));
} else {
// Refuse anything else, including a bare filename: only --file scopes a
// run, and guessing here is what turns a typo into APPLY EVERYTHING.
return refuse('unrecognized argument "' + a + '".');
}
// A targeting flag that named nothing (`--file=`, `--file ,`) would leave the
// scope empty, and an empty scope means APPLY EVERYTHING: the opposite of
// what the operator asked for. Refuse instead of widening the run.
if(targets.length === named){
return refuse(a + ' names no migration file.');
}
}
return targets;
}

async function main(){
// Argv is settled first so `--help` answers without a loaded .env, and so a
// refused argument never reaches the database checks below.
const only = parseFileTargets(process.argv.slice(2));
if(only === null) return;

const host = process.env.DECODER_DB_HOST;
const port = process.env.DECODER_DB_PORT;
const name = process.env.DECODER_DB_NAME;
Expand All @@ -81,8 +134,6 @@ async function main(){
process.exit(2);
}

const only = parseFileTargets(process.argv.slice(2));

const db = new Database(host, port, name, user, pass);

try {
Expand Down
75 changes: 71 additions & 4 deletions test/unit/migrate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,17 +221,19 @@ describe('migrate.js operator CLI @regression', function () {
beforeEach(prepareMigrateTest);
afterEach(restoreMigrateTest);

it('--file with no value exits 2 before building a DB handle @regression', async function () {
it('--file with no value exits 2 before building a DB handle @regression', function () {
process.env.DECODER_DB_HOST = 'db.test';
process.env.DECODER_DB_NAME = 'decoder_test';
process.env.DECODER_DB_USER = 'tester';
process.argv = ['node', 'migrate.js', '--file'];
const fake = makeFakeDb({ runMigrations: async () => ({ applied: [], pending: [] }) });
// process.exit is stubbed, so main() continues past the guard; assert the
// exit(2) signal and the actionable error fired before any migration ran.
// process.exit is stubbed, so the exit(2) does not end the process; main()
// must still bail rather than fall through to a blanket run. The refusal
// precedes main()'s first await, so it has run by the time require returns.
loadMigrateWith(fake.FakeDatabase);
await fake.done;
assert.strictEqual(exitStub.calledWith(2), true, 'expected process.exit(2) on a valueless --file');
assert.strictEqual(fake.runArgs, null, 'a refused argv must apply no migrations');
assert.strictEqual(fake.poolEnded, false, 'a refused argv must not open a DB handle');
assert.match(consoleErrStub.getCalls().map((c) => c.args[0]).join('\n'),
/--file requires a migration filename argument/);
});
Expand All @@ -247,3 +249,68 @@ describe('migrate.js operator CLI @regression', function () {
'a blanket run must NOT set opts.only');
});
});

// Unrecognized argv. An ignored token falls through to the no-argument meaning,
// which is apply-everything, so `migrate.js --help` would apply every pending
// manual migration. Each case pins the refusal by what it APPLIES, not what it says.

describe('migrate.js operator CLI argv refusal @regression', function () {
beforeEach(prepareMigrateTest);
afterEach(restoreMigrateTest);

// The refusal (and --help) run synchronously ahead of main()'s first await, so
// a case that must prove nothing ran asserts right after the require rather
// than awaiting a pool.end() that a correct CLI never reaches.
function loadWithArgv(args) {
process.argv = ['node', 'migrate.js', ...args];
const fake = makeFakeDb({ runMigrations: async () => ({ applied: [], pending: [] }) });
loadMigrateWith(fake.FakeDatabase);
return fake;
}

it('an unknown flag applies nothing and exits 2', function () {
const fake = loadWithArgv(['--dry-run']);
assert.strictEqual(fake.runArgs, null, 'an unknown flag must not run migrations');
assert.strictEqual(fake.poolEnded, false, 'an unknown flag must not open a DB handle');
assert.strictEqual(exitStub.calledWith(2), true, 'expected process.exit(2)');
});

it('a bare positional applies nothing and exits 2 (it is not a --file value)', function () {
const fake = loadWithArgv(['2026-06-13-dispensers-expiration-bigint.sql']);
assert.strictEqual(fake.runArgs, null, 'a bare filename must not become a blanket run');
assert.strictEqual(exitStub.calledWith(2), true, 'expected process.exit(2)');
});

it('an empty --file= value applies nothing rather than widening to everything', function () {
const fake = loadWithArgv(['--file=']);
assert.strictEqual(fake.runArgs, null, 'an empty scope must not mean apply-everything');
assert.strictEqual(exitStub.calledWith(2), true, 'expected process.exit(2)');
});

it('--help and -h apply nothing and exit 0, with no DECODER_DB_* loaded', function () {
for (const flag of ['--help', '-h']) {
const fake = loadWithArgv([flag]);
assert.strictEqual(fake.runArgs, null, flag + ' must not run migrations');
assert.strictEqual(fake.poolEnded, false, flag + ' must not open a DB handle');
assert.strictEqual(exitStub.calledWith(0), true, flag + ' must exit 0');
assert.strictEqual(exitStub.calledWith(2), false, flag + ' is not an error');
exitStub.resetHistory();
}
});

it('the refusal prints both modes so an operator can tell them apart', function () {
loadWithArgv(['--dry-run']);
const printed = consoleErrStub.getCalls().map((c) => c.args[0]).join('\n');
assert.match(printed, /APPLY EVERYTHING/, 'the usage must name the blanket mode');
assert.match(printed, /APPLY ONE/, 'the usage must name the scoped mode');
assert.match(printed, /--file/, 'the usage must show the flag that scopes a run');
});

it('--help prints both modes and starts no run', function () {
loadWithArgv(['--help']);
const printed = consoleLogStub.getCalls().map((c) => c.args[0]).join('\n');
assert.match(printed, /APPLY EVERYTHING/);
assert.match(printed, /APPLY ONE/);
assert.ok(!/applying pending migrations/.test(printed), '--help must not start a run');
});
});
Loading