diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64619bf..2394cd8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 71f2f54..ebbff76 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# XChain Platform Decoder
-
+
diff --git a/package-lock.json b/package-lock.json
index fc7b856..11bd988 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "xchain-decoder",
- "version": "0.19.0",
+ "version": "0.20.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xchain-decoder",
- "version": "0.19.0",
+ "version": "0.20.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^1.18.1",
diff --git a/package.json b/package.json
index 856c9c5..617baef 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/migrate.js b/src/migrate.js
index 90f469f..441f960 100644
--- a/src/migrate.js
+++ b/src/migrate.js
@@ -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).
*
********************************************************************/
@@ -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 ...]',
+ '',
+ ' (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 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 ` / `--file=` / `-f ` 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) => {
@@ -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;
@@ -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 {
diff --git a/test/unit/migrate.test.js b/test/unit/migrate.test.js
index 6b8ec50..927d473 100644
--- a/test/unit/migrate.test.js
+++ b/test/unit/migrate.test.js
@@ -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/);
});
@@ -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');
+ });
+});