mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:36:13 +08:00
333 lines
8.3 KiB
JavaScript
333 lines
8.3 KiB
JavaScript
require('ts-node/register/transpile-only');
|
|
|
|
const assert = require('node:assert/strict');
|
|
const { test } = require('node:test');
|
|
const {
|
|
InvalidMigrationStreamError,
|
|
MigrationStreamAheadOfCodeError,
|
|
MigrationStreamChecksumMismatchError,
|
|
MigrationStreamHistoryCorruptionError,
|
|
runMigrationStream,
|
|
} = require('../../back/migrations/core/migrationStream');
|
|
|
|
const CHECKSUM_A = 'a'.repeat(64);
|
|
const CHECKSUM_B = 'b'.repeat(64);
|
|
|
|
function stream(migrations) {
|
|
return {
|
|
id: 'postgresql-main',
|
|
dialect: 'postgresql',
|
|
migrationIdScheme: 'postgres-prefixed',
|
|
checksumScheme: 'sha256',
|
|
migrations,
|
|
};
|
|
}
|
|
|
|
function memoryStore(initial = []) {
|
|
const records = new Map(
|
|
initial.map((record) => [record.migrationId, record]),
|
|
);
|
|
let ensured = 0;
|
|
return {
|
|
records,
|
|
get ensured() {
|
|
return ensured;
|
|
},
|
|
store: {
|
|
async ensureHistory() {
|
|
ensured += 1;
|
|
},
|
|
async listAll() {
|
|
return [...records.values()].map((record) => ({ ...record }));
|
|
},
|
|
async findById(id) {
|
|
const record = records.get(id);
|
|
return record ? { ...record } : null;
|
|
},
|
|
async transaction(work) {
|
|
const staged = new Map(
|
|
[...records].map(([id, record]) => [id, { ...record }]),
|
|
);
|
|
const result = await work({
|
|
context: { statements: [] },
|
|
async findById(id) {
|
|
const record = staged.get(id);
|
|
return record ? { ...record } : null;
|
|
},
|
|
async insert(record) {
|
|
if (staged.has(record.migrationId)) {
|
|
throw new Error('duplicate migration history');
|
|
}
|
|
staged.set(record.migrationId, { ...record });
|
|
},
|
|
});
|
|
records.clear();
|
|
for (const [id, record] of staged) records.set(id, record);
|
|
return result;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
test('runs one prefixed migration atomically and replays without running up', async () => {
|
|
const state = memoryStore();
|
|
const statements = [];
|
|
const logs = [];
|
|
let calls = 0;
|
|
const definition = stream([
|
|
{
|
|
id: 'pg-0001-schema-history',
|
|
checksum: CHECKSUM_A,
|
|
async up(context) {
|
|
calls += 1;
|
|
context.statements.push('create schema metadata');
|
|
statements.push(...context.statements);
|
|
},
|
|
},
|
|
]);
|
|
await runMigrationStream({
|
|
stream: definition,
|
|
store: state.store,
|
|
clock: () => 100,
|
|
logger: {
|
|
info(message) {
|
|
logs.push(message);
|
|
},
|
|
},
|
|
});
|
|
await runMigrationStream({
|
|
stream: definition,
|
|
store: state.store,
|
|
clock: () => 200,
|
|
});
|
|
assert.equal(calls, 1);
|
|
assert.deepEqual(statements, ['create schema metadata']);
|
|
assert.equal(state.ensured, 2);
|
|
assert.deepEqual(
|
|
[...state.records.values()],
|
|
[
|
|
{
|
|
streamId: 'postgresql-main',
|
|
dialect: 'postgresql',
|
|
migrationId: 'pg-0001-schema-history',
|
|
checksum: CHECKSUM_A,
|
|
appliedAtMs: 100,
|
|
},
|
|
],
|
|
);
|
|
assert.deepEqual(logs, [
|
|
'[migration:postgresql-main] Applied pg-0001-schema-history',
|
|
]);
|
|
});
|
|
|
|
test('rejects checksum drift and corrupt dialect history before migration work', async () => {
|
|
const migrationId = 'pg-0001-schema-history';
|
|
const mismatch = memoryStore([
|
|
{
|
|
streamId: 'postgresql-main',
|
|
dialect: 'postgresql',
|
|
migrationId,
|
|
checksum: CHECKSUM_A,
|
|
appliedAtMs: 1,
|
|
},
|
|
]);
|
|
await assert.rejects(
|
|
runMigrationStream({
|
|
stream: stream([
|
|
{ id: migrationId, checksum: CHECKSUM_B, async up() {} },
|
|
]),
|
|
store: mismatch.store,
|
|
}),
|
|
(error) => {
|
|
assert.ok(error instanceof MigrationStreamChecksumMismatchError);
|
|
assert.equal(error.migrationId, migrationId);
|
|
assert.equal(error.databaseChecksum, CHECKSUM_A);
|
|
assert.equal(error.codeChecksum, CHECKSUM_B);
|
|
assert.equal(
|
|
error.message,
|
|
`Migration checksum mismatch: ${migrationId} ` +
|
|
`(database=${CHECKSUM_A}, code=${CHECKSUM_B})`,
|
|
);
|
|
return true;
|
|
},
|
|
);
|
|
|
|
const corrupt = memoryStore([
|
|
{
|
|
streamId: 'postgresql-main',
|
|
dialect: 'sqlite',
|
|
migrationId,
|
|
checksum: CHECKSUM_A,
|
|
appliedAtMs: 1,
|
|
},
|
|
]);
|
|
await assert.rejects(
|
|
runMigrationStream({
|
|
stream: stream([
|
|
{ id: migrationId, checksum: CHECKSUM_A, async up() {} },
|
|
]),
|
|
store: corrupt.store,
|
|
}),
|
|
MigrationStreamHistoryCorruptionError,
|
|
);
|
|
});
|
|
|
|
test('rolls migration work and history back together when up fails', async () => {
|
|
const state = memoryStore();
|
|
await assert.rejects(
|
|
runMigrationStream({
|
|
stream: stream([
|
|
{
|
|
id: 'pg-0001-schema-history',
|
|
checksum: CHECKSUM_A,
|
|
async up(context) {
|
|
context.statements.push('partial ddl');
|
|
throw new Error('ddl failed');
|
|
},
|
|
},
|
|
]),
|
|
store: state.store,
|
|
}),
|
|
/ddl failed/,
|
|
);
|
|
assert.equal(state.records.size, 0);
|
|
});
|
|
|
|
test('rechecks history inside the transaction for a concurrent leader winner', async () => {
|
|
const migrationId = 'pg-0001-schema-history';
|
|
const winner = {
|
|
streamId: 'postgresql-main',
|
|
dialect: 'postgresql',
|
|
migrationId,
|
|
checksum: CHECKSUM_A,
|
|
appliedAtMs: 10,
|
|
};
|
|
let outsideReads = 0;
|
|
let upCalls = 0;
|
|
const store = {
|
|
async ensureHistory() {},
|
|
async listAll() {
|
|
return [];
|
|
},
|
|
async findById() {
|
|
outsideReads += 1;
|
|
return null;
|
|
},
|
|
async transaction(work) {
|
|
return work({
|
|
context: {},
|
|
async findById() {
|
|
return winner;
|
|
},
|
|
async insert() {
|
|
throw new Error('unreachable');
|
|
},
|
|
});
|
|
},
|
|
};
|
|
await runMigrationStream({
|
|
stream: stream([
|
|
{
|
|
id: migrationId,
|
|
checksum: CHECKSUM_A,
|
|
async up() {
|
|
upCalls += 1;
|
|
},
|
|
},
|
|
]),
|
|
store,
|
|
});
|
|
assert.equal(outsideReads, 1);
|
|
assert.equal(upCalls, 0);
|
|
});
|
|
|
|
test('rejects ahead and non-prefix history before new migration work', async () => {
|
|
const first = {
|
|
id: 'pg-0001-schema-history',
|
|
checksum: CHECKSUM_A,
|
|
async up() {},
|
|
};
|
|
const second = {
|
|
id: 'pg-0002-run-core',
|
|
checksum: CHECKSUM_B,
|
|
async up() {},
|
|
};
|
|
const ahead = memoryStore([
|
|
{
|
|
streamId: 'postgresql-main',
|
|
dialect: 'postgresql',
|
|
migrationId: 'pg-0003-ahead',
|
|
checksum: CHECKSUM_A,
|
|
appliedAtMs: 1,
|
|
},
|
|
]);
|
|
await assert.rejects(
|
|
runMigrationStream({ stream: stream([first, second]), store: ahead.store }),
|
|
MigrationStreamAheadOfCodeError,
|
|
);
|
|
|
|
const gap = memoryStore([
|
|
{
|
|
streamId: 'postgresql-main',
|
|
dialect: 'postgresql',
|
|
migrationId: second.id,
|
|
checksum: second.checksum,
|
|
appliedAtMs: 2,
|
|
},
|
|
]);
|
|
await assert.rejects(
|
|
runMigrationStream({ stream: stream([first, second]), store: gap.store }),
|
|
MigrationStreamHistoryCorruptionError,
|
|
);
|
|
});
|
|
|
|
test('validates stream identity, immutable checksum and unique prefixed ids first', async () => {
|
|
const state = memoryStore();
|
|
for (const definition of [
|
|
{ ...stream([]), id: 'PostgreSQL' },
|
|
{ ...stream([]), migrationIdScheme: 'sqlite-numbered' },
|
|
{ ...stream([]), checksumScheme: 'unknown' },
|
|
stream([{ id: '0001', checksum: CHECKSUM_A, async up() {} }]),
|
|
stream([{ id: 'pg-0001', checksum: 'short', async up() {} }]),
|
|
stream([
|
|
{ id: 'pg-0001', checksum: CHECKSUM_A, async up() {} },
|
|
{ id: 'pg-0001', checksum: CHECKSUM_A, async up() {} },
|
|
]),
|
|
]) {
|
|
await assert.rejects(
|
|
runMigrationStream({ stream: definition, store: state.store }),
|
|
InvalidMigrationStreamError,
|
|
);
|
|
}
|
|
assert.equal(state.ensured, 0);
|
|
});
|
|
|
|
test('accepts the frozen numbered SQLite stream without rewriting its ids', async () => {
|
|
const state = memoryStore();
|
|
await runMigrationStream({
|
|
stream: {
|
|
id: 'sqlite-main',
|
|
dialect: 'sqlite',
|
|
migrationIdScheme: 'sqlite-numbered',
|
|
checksumScheme: 'legacy-opaque',
|
|
migrations: [
|
|
{
|
|
id: '0001-legacy-columns',
|
|
checksum: 'legacy checksum v1',
|
|
async up() {},
|
|
},
|
|
],
|
|
},
|
|
store: state.store,
|
|
clock: () => 1,
|
|
});
|
|
assert.equal(
|
|
state.records.get('0001-legacy-columns').migrationId,
|
|
'0001-legacy-columns',
|
|
);
|
|
assert.equal(
|
|
state.records.get('0001-legacy-columns').checksum,
|
|
'legacy checksum v1',
|
|
);
|
|
});
|