Files
qinglong/test/back/database-legacy-compat.test.cjs
whyour 15d54866a8 feat: migrate SQLite runtime and verify complete upgrade bundles
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon.

Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility.

Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
2026-10-22 13:47:26 +08:00

223 lines
7.1 KiB
JavaScript

const test = require('node:test'),
assert = require('node:assert/strict');
const fs = require('node:fs/promises'),
path = require('node:path'),
os = require('node:os');
const {
Sequelize,
DataTypes,
Op,
QueryTypes,
where,
col,
} = require('sequelize');
const { DatabaseClient } = require('../../back/data/databaseClient');
const { Repository } = require('../../back/data/repository');
const { initialize } = require('../helpers/repository-fixture.cjs');
const names = {
App: 'Apps',
Auth: 'Auths',
Crontab: 'Crontabs',
CrontabStat: 'CrontabStats',
CrontabView: 'CrontabViews',
Dependence: 'Dependences',
Env: 'Envs',
RunningInstance: 'RunningInstances',
Subscription: 'Subscriptions',
};
async function legacy(file) {
const database = new Sequelize({
dialect: 'sqlite',
storage: file,
logging: false,
});
const models = {};
for (const [name, tableName] of Object.entries(names)) {
const columns = await database.query(`PRAGMA table_info("${tableName}")`, {
type: QueryTypes.SELECT,
});
const attrs = Object.fromEntries(
columns.map((c) => [
c.name,
{
type:
c.type === 'JSON'
? DataTypes.JSON
: c.type === 'DATETIME'
? DataTypes.DATE
: c.type === 'TINYINT(1)'
? DataTypes.BOOLEAN
: /INT|NUMBER/.test(c.type)
? DataTypes.NUMBER
: DataTypes.STRING,
...(c.pk ? { primaryKey: true, autoIncrement: true } : {}),
},
]),
);
models[name] = database.define(name, attrs, {
tableName,
freezeTableName: true,
});
}
return { database, models };
}
test('all nine legacy tables remain readable in both versions without changing schema, IDs, dates or .env', async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-orm-legacy-'));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const file = path.join(dir, 'database.sqlite'),
envFile = path.join(dir, '.env');
const environment = 'QL_DATA_DIR=/synthetic/old-data\nSYNTHETIC_ENV=keep\n';
await fs.writeFile(envFile, environment);
const seed = new Sequelize({
dialect: 'sqlite',
storage: file,
logging: false,
});
try {
const sql = await fs.readFile(
path.resolve('test/fixtures/sequelize-2.23-schema.sql'),
'utf8',
);
for (const statement of sql
.replace(/^--.*$/gm, '')
.split(';')
.map((x) => x.trim())
.filter(Boolean))
await seed.query(statement);
} finally {
await seed.close();
}
let old = await legacy(file);
const payloads = {
App: { name: 'legacy', scopes: ['envs'], client_id: 'synthetic-client' },
Auth: {
type: 'authConfig',
info: {
username: 'synthetic-user',
password: 'synthetic-hash',
tokens: { desktop: ['keep'] },
},
},
Crontab: {
name: 'legacy',
command: 'echo synthetic',
schedule: '0 0 * * *',
saved: true,
labels: ['中文'],
},
CrontabStat: { ref_id: 7, date: '2026-10-21', run_count: 2 },
CrontabView: {
name: 'legacy',
filters: [{ property: 'status', operation: 'In', value: [1] }],
},
Dependence: { name: 'legacy', type: 0, log: ['旧日志'] },
Env: {
name: 'legacy',
value: '旧值',
labels: ['中文'],
position: 4500000000000000,
},
RunningInstance: { cron_id: 7, started_at: 100, status: 2, exit_code: 143 },
Subscription: {
name: 'legacy',
alias: 'legacy',
pull_option: { username: 'synthetic', password: 'synthetic-only' },
},
};
const date = new Date('2020-01-02T03:04:05.006Z');
for (const [name, payload] of Object.entries(payloads))
await old.models[name].create({
id: 7,
...payload,
createdAt: date,
updatedAt: date,
});
const schema = await old.database.query(
"SELECT type,name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name",
{ type: QueryTypes.SELECT },
);
await old.database.close();
const client = new DatabaseClient(file);
t.after(() => client.close());
await initialize(client);
for (const [name, payload] of Object.entries(payloads)) {
const repository = new Repository(name, () => client),
row = await repository.findOne({ id: 7 });
for (const [field, value] of Object.entries(payload))
assert.deepEqual(row[field], value, `${name}.${field}`);
assert.equal(row.createdAt.toISOString(), date.toISOString());
const added = await repository.insert({
...payload,
id: undefined,
...(payload.name ? { name: 'new' } : {}),
...(payload.alias ? { alias: 'new' } : {}),
...(payload.date ? { date: '2026-10-22' } : {}),
});
assert(added.id > 7, name);
assert(added.createdAt instanceof Date);
}
assert.deepEqual(
await client.query(
"SELECT type,name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name",
),
schema,
);
await client.close();
old = await legacy(file);
try {
for (const name of Object.keys(payloads)) {
const rows = await old.models[name].findAll({ order: [['id', 'ASC']] });
assert.equal(rows.length, 2);
assert.equal(rows[0].createdAt.toISOString(), date.toISOString());
assert(rows[1].createdAt instanceof Date);
assert(!Number.isNaN(+rows[1].createdAt));
}
assert.equal(await fs.readFile(envFile, 'utf8'), environment);
} finally {
await old.database.close();
}
});
test('native filters retain legacy NULL, IN, nested groups and JSON text matching', async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-orm-filters-'));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const file = path.join(dir, 'database.sqlite');
const client = new DatabaseClient(file);
t.after(() => client.close());
await initialize(client);
const repository = new Repository('Env', () => client);
for (const row of [
{ name: 'one', value: 'a', status: 0, labels: ['中文', 'keep'] },
{ name: 'two', value: 'b', status: 1, labels: [] },
{ name: null, value: 'c', status: 0, labels: null },
])
await repository.insert(row);
const old = await legacy(file);
t.after(() => old.database.close());
const cases = [
[{ status: [0, 1] }, { status: [0, 1] }],
[{ name: { [Op.not]: null } }, { name: { $ne: null } }],
[
{ [Op.or]: [{ name: 'missing' }, {}] },
{ $or: [{ name: 'missing' }, {}] },
],
[{ [Op.and]: [{ status: 0 }, {}] }, { $and: [{ status: 0 }, {}] }],
[{ [Op.or]: [] }, { $or: [] }],
[
where(col('labels'), { [Op.like]: '%keep%' }),
{ labels: { $like: '%keep%' } },
],
[{ name: { [Op.notIn]: ['one'] } }, { name: { $nin: ['one'] } }],
];
for (const [before, after] of cases) {
const expected = (
await old.models.Env.findAll({ where: before, order: [['id', 'ASC']] })
).map((x) => x.id);
const actual = (
await repository.find(after, { orderBy: [{ id: 'asc' }] })
).map((x) => x.id);
assert.deepEqual(actual, expected, JSON.stringify(after));
}
});