297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {
|
|
executeMigrationPlan,
|
|
loadMigrationPlan,
|
|
splitSqlStatements
|
|
} from '../dist/db/migration-runner.js';
|
|
|
|
assert.deepEqual(
|
|
splitSqlStatements("SELECT 'a;b'; -- comment\nSELECT `semi;colon`;"),
|
|
["SELECT 'a;b'", 'SELECT `semi;colon`']
|
|
);
|
|
|
|
const plan = await loadMigrationPlan('up');
|
|
assert.equal(plan.direction, 'up');
|
|
assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql/);
|
|
assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql/);
|
|
assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql/);
|
|
assert.match(plan.file, /2026061804_m02b_wechat_auth\.up\.sql/);
|
|
assert.match(plan.file, /2026061805_m02c_rbac\.up\.sql/);
|
|
assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql/);
|
|
assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql/);
|
|
assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql/);
|
|
assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql/);
|
|
assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/);
|
|
assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/);
|
|
assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/);
|
|
assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/);
|
|
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
|
|
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/);
|
|
assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/);
|
|
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql/);
|
|
assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql/);
|
|
assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
|
|
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
|
|
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
|
|
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
|
|
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/);
|
|
assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql/);
|
|
assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
|
|
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
|
|
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
|
|
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
|
|
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
|
|
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
|
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
|
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
|
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
|
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
|
|
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql$/);
|
|
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
|
assert.ok(plan.statements.length >= 11);
|
|
|
|
const verifyPlan = await loadMigrationPlan('verify');
|
|
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
|
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
|
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
|
|
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
|
|
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
|
|
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql/);
|
|
assert.match(
|
|
verifyPlan.file,
|
|
/2026081107_m09d1_product_inventory_foundation\.verify\.sql$/
|
|
);
|
|
|
|
const downPlan = await loadMigrationPlan('down');
|
|
assert.match(downPlan.file, /^database\/migrations\/2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
|
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
|
|
assert.ok(
|
|
downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
|
< downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
|
|
);
|
|
assert.ok(
|
|
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
|
|
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.down.sql')
|
|
);
|
|
|
|
const calls = [];
|
|
const fakePool = {
|
|
async query(sql) {
|
|
calls.push(sql);
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
};
|
|
|
|
const dryRun = await executeMigrationPlan(fakePool, plan, true);
|
|
assert.equal(dryRun.executed, false);
|
|
assert.equal(calls.length, 0);
|
|
|
|
const lockCalls = [];
|
|
let connectionReleased = false;
|
|
const lockedResult = await executeMigrationPlan({
|
|
async query() {
|
|
throw new Error('pool.query must not run while a dedicated migration connection exists');
|
|
},
|
|
async getConnection() {
|
|
return {
|
|
async query(sql) {
|
|
lockCalls.push(sql);
|
|
if (sql.includes('GET_LOCK')) return [[{ acquired: 1 }], []];
|
|
if (sql.includes('RELEASE_LOCK')) return [[{ released: 1 }], []];
|
|
return [{ affectedRows: 2 }, []];
|
|
},
|
|
release() {
|
|
connectionReleased = true;
|
|
}
|
|
};
|
|
}
|
|
}, {
|
|
direction: 'down', file: 'locked.sql', checksum: 'locked', statements: ['SELECT 1']
|
|
});
|
|
assert.equal(lockedResult.affectedRows, 2);
|
|
assert.equal(connectionReleased, true);
|
|
assert.match(lockCalls[0], /GET_LOCK/);
|
|
assert.equal(lockCalls[1], 'SELECT 1');
|
|
assert.match(lockCalls[2], /RELEASE_LOCK/);
|
|
|
|
let timedOutConnectionReleased = false;
|
|
await assert.rejects(
|
|
() => executeMigrationPlan({
|
|
async query() {},
|
|
async getConnection() {
|
|
return {
|
|
async query() { return [[{ acquired: 0 }], []]; },
|
|
release() { timedOutConnectionReleased = true; }
|
|
};
|
|
}
|
|
}, {
|
|
direction: 'down', file: 'lock-timeout.sql', checksum: 'lock-timeout', statements: []
|
|
}),
|
|
/MIGRATION_LOCK_TIMEOUT/
|
|
);
|
|
assert.equal(timedOutConnectionReleased, true);
|
|
|
|
const liveResult = await executeMigrationPlan(fakePool, {
|
|
direction: 'up',
|
|
file: 'test.sql',
|
|
checksum: 'test',
|
|
statements: ['SELECT 1', 'SELECT 2']
|
|
});
|
|
assert.equal(liveResult.executed, true);
|
|
assert.equal(liveResult.affectedRows, 2);
|
|
assert.deepEqual(calls, ['SELECT 1', 'SELECT 2']);
|
|
|
|
const triggerPlan = {
|
|
direction: 'up',
|
|
file: 'trigger.sql',
|
|
checksum: 'trigger',
|
|
statements: ['CREATE TRIGGER test_trigger BEFORE UPDATE ON test FOR EACH ROW SET @x = 1']
|
|
};
|
|
await assert.rejects(
|
|
() => executeMigrationPlan({
|
|
async query() { return [[{ logBin: 1, trustFunctionCreators: 0 }], []]; }
|
|
}, triggerPlan),
|
|
/MIGRATION_TRIGGER_PRIVILEGE_REQUIRED/
|
|
);
|
|
const triggerCalls = [];
|
|
await executeMigrationPlan({
|
|
async query(sql) {
|
|
triggerCalls.push(sql);
|
|
if (sql.includes('@@GLOBAL.log_bin')) {
|
|
return [[{ logBin: 1, trustFunctionCreators: 1 }], []];
|
|
}
|
|
return [{ affectedRows: 0 }, []];
|
|
}
|
|
}, triggerPlan);
|
|
assert.equal(triggerCalls.length, 2);
|
|
|
|
let indexAttempted = false;
|
|
const retryResult = await executeMigrationPlan({
|
|
async query(sql) {
|
|
if (sql.includes('ADD UNIQUE KEY uq_qipai_stores_tenant_id')) {
|
|
indexAttempted = true;
|
|
throw Object.assign(new Error('duplicate index'), { code: 'ER_DUP_KEYNAME' });
|
|
}
|
|
if (sql.includes('information_schema.statistics')) {
|
|
return [[{ nonUnique: 0, columns: 'tenant_id,id' }], []];
|
|
}
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
}, {
|
|
direction: 'up', file: 'retry.sql', checksum: 'retry',
|
|
statements: [
|
|
'ALTER TABLE qipai_stores ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id)',
|
|
'SELECT 1'
|
|
]
|
|
});
|
|
assert.equal(indexAttempted, true);
|
|
assert.equal(retryResult.affectedRows, 1);
|
|
await assert.rejects(
|
|
() => executeMigrationPlan({
|
|
async query(sql) {
|
|
if (sql.includes('ADD UNIQUE KEY')) {
|
|
throw Object.assign(new Error('wrong duplicate index'), { code: 'ER_DUP_KEYNAME' });
|
|
}
|
|
return [[{ nonUnique: 0, columns: 'id,tenant_id' }], []];
|
|
}
|
|
}, {
|
|
direction: 'up', file: 'retry-mismatch.sql', checksum: 'retry-mismatch',
|
|
statements: [
|
|
'ALTER TABLE qipai_stores ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id)'
|
|
]
|
|
}),
|
|
/wrong duplicate index/
|
|
);
|
|
|
|
const downRetryCalls = [];
|
|
const downRetryResult = await executeMigrationPlan({
|
|
async query(sql) {
|
|
downRetryCalls.push(sql);
|
|
if (/DROP INDEX uq_qipai_(?:stores|users)_tenant_id/.test(sql)
|
|
|| /DROP COLUMN checksum/.test(sql)) {
|
|
throw Object.assign(new Error('already removed'), { code: 'ER_CANT_DROP_FIELD_OR_KEY' });
|
|
}
|
|
if (sql.includes('information_schema.statistics')
|
|
|| sql.includes('information_schema.columns')) return [[], []];
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
}, {
|
|
direction: 'down', file: 'retry-down.sql', checksum: 'retry-down',
|
|
statements: [
|
|
'ALTER TABLE qipai_stores DROP INDEX uq_qipai_stores_tenant_id',
|
|
'ALTER TABLE qipai_users DROP INDEX uq_qipai_users_tenant_id',
|
|
'ALTER TABLE qipai_schema_migrations DROP COLUMN checksum',
|
|
'SELECT 1'
|
|
]
|
|
});
|
|
assert.equal(downRetryResult.affectedRows, 1);
|
|
assert.equal(downRetryCalls.filter((sql) => sql.includes('information_schema.')).length, 3);
|
|
await assert.rejects(
|
|
() => executeMigrationPlan({
|
|
async query(sql) {
|
|
if (sql.startsWith('ALTER TABLE')) {
|
|
throw Object.assign(new Error('drop target still exists'), {
|
|
code: 'ER_CANT_DROP_FIELD_OR_KEY'
|
|
});
|
|
}
|
|
return [[{ present: 1 }], []];
|
|
}
|
|
}, {
|
|
direction: 'down', file: 'retry-down-mismatch.sql', checksum: 'retry-down-mismatch',
|
|
statements: ['ALTER TABLE qipai_stores DROP INDEX uq_qipai_stores_tenant_id']
|
|
}),
|
|
/drop target still exists/
|
|
);
|
|
|
|
const versionedCalls = [];
|
|
const versionedPlan = {
|
|
direction: 'up', file: 'old.sql,new.sql', checksum: 'versioned',
|
|
statements: ['SELECT old', 'SELECT new'],
|
|
migrations: [
|
|
{ version: '1', name: 'old', file: 'old.sql', checksum: 'old-checksum', statements: ['SELECT old'] },
|
|
{ version: '2', name: 'new', file: 'new.sql', checksum: 'new-checksum', statements: ['SELECT new'] }
|
|
]
|
|
};
|
|
await executeMigrationPlan({
|
|
async query(sql, params = []) {
|
|
versionedCalls.push({ sql, params });
|
|
if (sql.startsWith('SELECT * FROM qipai_schema_migrations')) {
|
|
return [[{ version: '1', name: 'old', checksum: 'old-checksum' }], []];
|
|
}
|
|
if (sql.startsWith('SELECT version, name')) {
|
|
return [[{ version: '2', name: 'new' }], []];
|
|
}
|
|
if (sql.startsWith('UPDATE qipai_schema_migrations')) {
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
}, versionedPlan);
|
|
assert.equal(versionedCalls.some(({ sql }) => sql === 'SELECT old'), false);
|
|
assert.equal(versionedCalls.some(({ sql }) => sql === 'SELECT new'), true);
|
|
|
|
await assert.rejects(
|
|
() => executeMigrationPlan({
|
|
async query() {
|
|
return [[{ version: '1', name: 'old', checksum: 'changed' }], []];
|
|
}
|
|
}, versionedPlan),
|
|
/MIGRATION_CHECKSUM_MISMATCH/
|
|
);
|
|
|
|
await assert.rejects(
|
|
() => executeMigrationPlan({
|
|
async query() {
|
|
return [[], []];
|
|
}
|
|
}, {
|
|
direction: 'verify',
|
|
file: 'verify.sql',
|
|
checksum: 'test',
|
|
statements: ['SELECT table_name']
|
|
}),
|
|
/returned fewer than 10 rows/
|
|
);
|
|
|
|
console.log('PASS: migration runner plan, parser and execution contracts are present.');
|