test:Add database migration service with normalization and masking functions

- Implemented `normalizeMigrationInput` to standardize database migration input for sqlite, mysql, and postgres.
- Added `maskConnectionString` to obscure sensitive credentials in connection strings.
- Created comprehensive tests for migration input normalization and connection string masking.
- Established database schema creation logic for multiple dialects (sqlite, mysql, postgres).
- Developed functions for managing database transactions, including begin, commit, and rollback.
- Implemented data insertion logic from backup snapshots into the target database.
This commit is contained in:
cita
2026-03-05 17:38:52 +08:00
parent 0168592c95
commit 283c052fa5
79 changed files with 3410 additions and 1111 deletions
+250
View File
@@ -0,0 +1,250 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const mode = process.argv.includes('--write') ? 'write' : 'dry-run';
const root = process.cwd();
const targetDir = path.join(root, 'src', 'server');
const METHOD_NAMES = new Set(['all', 'get', 'run']);
const FUNCTION_KINDS = new Set([
ts.SyntaxKind.FunctionDeclaration,
ts.SyntaxKind.FunctionExpression,
ts.SyntaxKind.ArrowFunction,
ts.SyntaxKind.MethodDeclaration,
ts.SyntaxKind.GetAccessor,
ts.SyntaxKind.SetAccessor,
]);
function listTsFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
continue;
}
if (entry.isFile() && full.endsWith('.ts')) {
out.push(full);
}
}
}
return out;
}
function isFunctionLike(node) {
return FUNCTION_KINDS.has(node.kind);
}
function hasAsyncModifier(node) {
return !!node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.AsyncKeyword);
}
function getFunctionTokenPos(node, sourceFile) {
if (node.kind === ts.SyntaxKind.ArrowFunction) {
return node.getStart(sourceFile);
}
if (node.kind === ts.SyntaxKind.MethodDeclaration || node.kind === ts.SyntaxKind.GetAccessor || node.kind === ts.SyntaxKind.SetAccessor) {
if (!node.name) return node.getStart(sourceFile);
return node.name.getStart(sourceFile);
}
let pos = node.getStart(sourceFile);
const children = node.getChildren(sourceFile);
for (const child of children) {
if (child.kind === ts.SyntaxKind.FunctionKeyword) {
pos = child.getStart(sourceFile);
break;
}
}
return pos;
}
function findNearestFunction(node) {
let current = node.parent;
while (current) {
if (isFunctionLike(current)) return current;
current = current.parent;
}
return null;
}
function unwrapExpression(node) {
if (!node) return node;
if (ts.isParenthesizedExpression(node)) return unwrapExpression(node.expression);
if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression?.(node)) {
return unwrapExpression(node.expression);
}
return node;
}
function isDbIdentifier(node, queryVars) {
return ts.isIdentifier(node) && (node.text === 'db' || node.text === 'tx' || queryVars.has(node.text));
}
function dependsOnDb(node, queryVars) {
const expr = unwrapExpression(node);
if (!expr) return false;
if (isDbIdentifier(expr, queryVars)) return true;
if (ts.isPropertyAccessExpression(expr) || ts.isElementAccessExpression(expr)) {
return dependsOnDb(expr.expression, queryVars);
}
if (ts.isCallExpression(expr) || ts.isNewExpression(expr)) {
if (dependsOnDb(expr.expression, queryVars)) return true;
return expr.arguments?.some((arg) => dependsOnDb(arg, queryVars)) ?? false;
}
if (ts.isConditionalExpression(expr)) {
return dependsOnDb(expr.condition, queryVars)
|| dependsOnDb(expr.whenTrue, queryVars)
|| dependsOnDb(expr.whenFalse, queryVars);
}
if (ts.isBinaryExpression(expr)) {
return dependsOnDb(expr.left, queryVars) || dependsOnDb(expr.right, queryVars);
}
if (ts.isArrayLiteralExpression(expr)) {
return expr.elements.some((el) => dependsOnDb(el, queryVars));
}
if (ts.isObjectLiteralExpression(expr)) {
return expr.properties.some((prop) => {
if (ts.isPropertyAssignment(prop)) return dependsOnDb(prop.initializer, queryVars);
if (ts.isShorthandPropertyAssignment(prop)) return queryVars.has(prop.name.text);
if (ts.isSpreadAssignment(prop)) return dependsOnDb(prop.expression, queryVars);
return false;
});
}
return false;
}
function collectQueryVars(sourceFile) {
const queryVars = new Set();
let changed = true;
while (changed) {
changed = false;
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
if (!queryVars.has(node.name.text) && dependsOnDb(node.initializer, queryVars)) {
queryVars.add(node.name.text);
changed = true;
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
return queryVars;
}
function shouldAwaitDbCall(callNode, queryVars) {
if (!ts.isPropertyAccessExpression(callNode.expression)) return false;
const method = callNode.expression.name.text;
if (!METHOD_NAMES.has(method)) return false;
if (ts.isAwaitExpression(callNode.parent)) return false;
const targetExpr = callNode.expression.expression;
return dependsOnDb(targetExpr, queryVars);
}
function buildEditsForFile(filePath) {
const text = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const queryVars = collectQueryVars(sourceFile);
const awaitedCalls = [];
const asyncFunctions = new Map();
function visit(node) {
if (ts.isCallExpression(node) && shouldAwaitDbCall(node, queryVars)) {
awaitedCalls.push(node);
const fn = findNearestFunction(node);
if (fn && !hasAsyncModifier(fn)) {
asyncFunctions.set(fn.pos + ':' + fn.end, fn);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
const edits = [];
for (const callNode of awaitedCalls) {
const parent = callNode.parent;
const callStart = callNode.getStart(sourceFile);
const callEnd = callNode.getEnd();
const original = text.slice(callStart, callEnd);
const needsParens = (
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent))
&& parent.expression === callNode
);
const replacement = needsParens ? `(await ${original})` : `await ${original}`;
edits.push({ start: callStart, end: callEnd, text: replacement, type: 'await' });
}
for (const fn of asyncFunctions.values()) {
const insertPos = getFunctionTokenPos(fn, sourceFile);
edits.push({ start: insertPos, end: insertPos, text: 'async ', type: 'async' });
}
// De-duplicate same edit span/text
const uniq = new Map();
for (const edit of edits) {
const key = `${edit.start}:${edit.end}:${edit.text}`;
if (!uniq.has(key)) uniq.set(key, edit);
}
const finalEdits = [...uniq.values()].sort((a, b) => b.start - a.start || b.end - a.end);
if (finalEdits.length === 0) {
return { changed: false, text, nextText: text, awaitCount: 0, asyncCount: 0 };
}
let nextText = text;
for (const edit of finalEdits) {
nextText = nextText.slice(0, edit.start) + edit.text + nextText.slice(edit.end);
}
return {
changed: nextText !== text,
text,
nextText,
awaitCount: finalEdits.filter((e) => e.type === 'await').length,
asyncCount: finalEdits.filter((e) => e.type === 'async').length,
};
}
const files = listTsFiles(targetDir);
let changedFiles = 0;
let totalAwaitEdits = 0;
let totalAsyncEdits = 0;
const touched = [];
for (const file of files) {
const result = buildEditsForFile(file);
if (!result.changed) continue;
changedFiles += 1;
totalAwaitEdits += result.awaitCount;
totalAsyncEdits += result.asyncCount;
touched.push(path.relative(root, file));
if (mode === 'write') {
fs.writeFileSync(file, result.nextText, 'utf8');
}
}
console.log(JSON.stringify({
mode,
changedFiles,
totalAwaitEdits,
totalAsyncEdits,
sampleFiles: touched.slice(0, 20),
}, null, 2));
@@ -0,0 +1,95 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const mode = process.argv.includes('--write') ? 'write' : 'dry-run';
const root = process.cwd();
const targetDir = path.join(root, 'src', 'server');
const FUNCTION_KINDS = new Set([
ts.SyntaxKind.FunctionDeclaration,
ts.SyntaxKind.FunctionExpression,
ts.SyntaxKind.ArrowFunction,
ts.SyntaxKind.MethodDeclaration,
]);
function listTsFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
continue;
}
if (entry.isFile() && full.endsWith('.ts')) {
out.push(full);
}
}
}
return out;
}
function hasAsyncModifier(node) {
return !!node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.AsyncKeyword);
}
function isPromiseTypeNode(typeNode) {
if (!typeNode) return false;
if (!ts.isTypeReferenceNode(typeNode)) return false;
const typeName = typeNode.typeName;
return ts.isIdentifier(typeName) && typeName.text === 'Promise';
}
function applyEdits(text, edits) {
const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
let next = text;
for (const edit of sorted) {
next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
}
return next;
}
const files = listTsFiles(targetDir);
let changedFiles = 0;
let editsCount = 0;
const touched = [];
for (const filePath of files) {
const text = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const edits = [];
function visit(node) {
if (FUNCTION_KINDS.has(node.kind) && hasAsyncModifier(node) && node.type && !isPromiseTypeNode(node.type)) {
const start = node.type.getStart(sourceFile);
const end = node.type.getEnd();
const original = text.slice(start, end);
edits.push({ start, end, text: `Promise<${original}>` });
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (edits.length === 0) continue;
const next = applyEdits(text, edits);
if (next === text) continue;
changedFiles += 1;
editsCount += edits.length;
touched.push(path.relative(root, filePath));
if (mode === 'write') {
fs.writeFileSync(filePath, next, 'utf8');
}
}
console.log(JSON.stringify({
mode,
changedFiles,
editsCount,
sampleFiles: touched.slice(0, 20),
}, null, 2));
+111
View File
@@ -0,0 +1,111 @@
import { sql } from 'drizzle-orm';
type DbType = 'sqlite' | 'mysql' | 'postgres';
type CliOptions = {
dbType?: string;
dbUrl?: string;
};
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] || '';
if (!arg.startsWith('--')) continue;
const [rawKey, rawValue] = arg.slice(2).split('=', 2);
const key = rawKey.trim();
const value = rawValue !== undefined ? rawValue : (argv[i + 1]?.startsWith('--') ? '' : argv[++i] || '');
if (key === 'db-type') options.dbType = value.trim();
if (key === 'db-url') options.dbUrl = value.trim();
}
return options;
}
function normalizeDbType(input: string | undefined): DbType {
const normalized = (input || '').trim().toLowerCase();
if (normalized === 'mysql') return 'mysql';
if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres';
return 'sqlite';
}
function normalizeFirstScalar(value: unknown): number | string | null {
if (value === undefined || value === null) return null;
if (typeof value === 'number' || typeof value === 'string') return value;
if (Array.isArray(value)) {
if (value.length === 0) return null;
return normalizeFirstScalar(value[0]);
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
if (Array.isArray(record.rows)) {
return normalizeFirstScalar(record.rows);
}
const scalar = Object.values(record)[0];
return normalizeFirstScalar(scalar);
}
return null;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.dbType) process.env.DB_TYPE = options.dbType;
if (options.dbUrl !== undefined) process.env.DB_URL = options.dbUrl;
const { config } = await import('../../src/server/config.js');
const { db, runtimeDbDialect, closeDbConnections } = await import('../../src/server/db/index.js');
try {
const dbType = normalizeDbType(config.dbType || options.dbType);
const dbUrl = (config.dbUrl || '').trim();
if ((dbType === 'mysql' || dbType === 'postgres') && !dbUrl) {
throw new Error(`DB_URL is required for DB_TYPE=${dbType}`);
}
console.log(`[db-smoke] start dbType=${dbType} runtime=${runtimeDbDialect}`);
if (dbUrl) {
console.log(`[db-smoke] dbUrl=${dbUrl}`);
} else {
console.log('[db-smoke] dbUrl=(empty, using default sqlite path)');
}
const pingRows = await db.execute(sql`select 1 as ok`);
const pingScalar = normalizeFirstScalar(pingRows);
if (Number(pingScalar) !== 1) {
throw new Error(`unexpected ping result: ${JSON.stringify(pingRows)}`);
}
console.log('[db-smoke] ping ok');
const txRows = await db.transaction(async (tx: any) => tx.execute(sql`select 1 as ok`));
const txScalar = normalizeFirstScalar(txRows);
if (Number(txScalar) !== 1) {
throw new Error(`unexpected transaction ping result: ${JSON.stringify(txRows)}`);
}
console.log('[db-smoke] transaction ok');
const versionRows = dbType === 'sqlite'
? await db.execute(sql`select sqlite_version() as v`)
: await db.execute(sql`select version() as v`);
const version = normalizeFirstScalar(versionRows);
if (typeof version !== 'string' || version.trim().length === 0) {
throw new Error(`failed to read server version: ${JSON.stringify(versionRows)}`);
}
console.log(`[db-smoke] version=${version.slice(0, 120)}`);
console.log('[db-smoke] success');
} finally {
await closeDbConnections();
}
}
main().catch((error) => {
console.error('[db-smoke] failed');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});