feat: Add route grouping schema compatibility checks and integration into migration process

This commit is contained in:
cita
2026-03-08 13:29:25 +08:00
parent 4172ad4297
commit 7081be14c7
6 changed files with 259 additions and 18 deletions
+40 -18
View File
@@ -6,6 +6,7 @@ import { drizzle as drizzleMysqlProxy } from 'drizzle-orm/mysql-proxy';
import { drizzle as drizzlePgProxy } from 'drizzle-orm/pg-proxy';
import * as schema from './schema.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { config } from '../config.js';
import { mkdirSync } from 'fs';
import { dirname, resolve } from 'path';
@@ -266,7 +267,14 @@ function ensureSiteGlobalWeightSchema() {
`);
}
function createSqliteSiteSchemaInspector(): SiteSchemaInspector {
type RuntimeSchemaInspector = {
dialect: SiteSchemaInspector['dialect'];
tableExists(table: string): Promise<boolean>;
columnExists(table: string, column: string): Promise<boolean>;
execute(sqlText: string): Promise<void>;
};
function createSqliteSchemaInspector(): RuntimeSchemaInspector {
return {
dialect: 'sqlite',
tableExists: async (table) => tableExists(table),
@@ -277,16 +285,9 @@ function createSqliteSiteSchemaInspector(): SiteSchemaInspector {
};
}
export async function ensureSiteCompatibilityColumns(): Promise<void> {
if (runtimeDbDialect === 'sqlite') {
await ensureSiteSchemaCompatibility(createSqliteSiteSchemaInspector());
return;
}
if (runtimeDbDialect === 'mysql') {
if (!mysqlPool) return;
await ensureSiteSchemaCompatibility({
function createMysqlSchemaInspector(): RuntimeSchemaInspector | null {
if (!mysqlPool) return null;
return {
dialect: 'mysql',
tableExists: async (table) => {
const [rows] = await mysqlPool!.query(
@@ -305,13 +306,12 @@ export async function ensureSiteCompatibilityColumns(): Promise<void> {
execute: async (sqlText) => {
await mysqlPool!.query(sqlText);
},
});
return;
}
};
}
if (!pgPool) return;
await ensureSiteSchemaCompatibility({
function createPostgresSchemaInspector(): RuntimeSchemaInspector | null {
if (!pgPool) return null;
return {
dialect: 'postgres',
tableExists: async (table) => {
const result = await pgPool!.query(
@@ -330,7 +330,29 @@ export async function ensureSiteCompatibilityColumns(): Promise<void> {
execute: async (sqlText) => {
await pgPool!.query(sqlText);
},
});
};
}
function createRuntimeSchemaInspector(): RuntimeSchemaInspector | null {
if (runtimeDbDialect === 'sqlite') {
return createSqliteSchemaInspector();
}
if (runtimeDbDialect === 'mysql') {
return createMysqlSchemaInspector();
}
return createPostgresSchemaInspector();
}
export async function ensureSiteCompatibilityColumns(): Promise<void> {
const inspector = createRuntimeSchemaInspector();
if (!inspector) return;
await ensureSiteSchemaCompatibility(inspector);
}
export async function ensureRouteGroupingCompatibilityColumns(): Promise<void> {
const inspector = createRuntimeSchemaInspector();
if (!inspector) return;
await ensureRouteGroupingSchemaCompatibility(inspector);
}
function ensureRouteGroupingSchema() {
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
ensureRouteGroupingSchemaCompatibility,
type RouteGroupingSchemaInspector,
} from './routeGroupingSchemaCompatibility.js';
function createInspector(
dialect: RouteGroupingSchemaInspector['dialect'],
options?: {
existingColumnsByTable?: Partial<Record<'token_routes' | 'route_channels', string[]>>;
},
) {
const executedSql: string[] = [];
const existingColumnsByTable = {
token_routes: new Set(options?.existingColumnsByTable?.token_routes ?? []),
route_channels: new Set(options?.existingColumnsByTable?.route_channels ?? []),
};
const inspector: RouteGroupingSchemaInspector = {
dialect,
async tableExists(table) {
return table === 'token_routes' || table === 'route_channels';
},
async columnExists(table, column) {
if (table === 'token_routes' || table === 'route_channels') {
return existingColumnsByTable[table].has(column);
}
return false;
},
async execute(sqlText) {
executedSql.push(sqlText);
},
};
return { inspector, executedSql };
}
describe('ensureRouteGroupingSchemaCompatibility', () => {
it.each([
{
dialect: 'postgres' as const,
expectedSql: [
'ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT',
'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT',
'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT',
'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT',
'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
],
},
{
dialect: 'mysql' as const,
expectedSql: [
'ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT NULL',
'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL',
'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL',
'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL',
'ALTER TABLE `route_channels` ADD COLUMN `source_model` TEXT NULL',
],
},
])('adds missing route grouping columns for $dialect', async ({ dialect, expectedSql }) => {
const { inspector, executedSql } = createInspector(dialect);
await ensureRouteGroupingSchemaCompatibility(inspector);
expect(executedSql).toEqual(expectedSql);
});
it('skips existing columns', async () => {
const { inspector, executedSql } = createInspector('postgres', {
existingColumnsByTable: {
token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at'],
route_channels: ['source_model'],
},
});
await ensureRouteGroupingSchemaCompatibility(inspector);
expect(executedSql).toEqual([]);
});
});
@@ -0,0 +1,106 @@
export type RouteGroupingSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
export interface RouteGroupingSchemaInspector {
dialect: RouteGroupingSchemaDialect;
tableExists(table: string): Promise<boolean>;
columnExists(table: string, column: string): Promise<boolean>;
execute(sqlText: string): Promise<void>;
}
type RouteGroupingColumnCompatibilitySpec = {
table: 'token_routes' | 'route_channels';
column: string;
addSql: Record<RouteGroupingSchemaDialect, string>;
};
const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [
{
table: 'token_routes',
column: 'display_name',
addSql: {
sqlite: 'ALTER TABLE token_routes ADD COLUMN display_name text;',
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT NULL',
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT',
},
},
{
table: 'token_routes',
column: 'display_icon',
addSql: {
sqlite: 'ALTER TABLE token_routes ADD COLUMN display_icon text;',
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL',
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT',
},
},
{
table: 'token_routes',
column: 'decision_snapshot',
addSql: {
sqlite: 'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;',
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL',
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT',
},
},
{
table: 'token_routes',
column: 'decision_refreshed_at',
addSql: {
sqlite: 'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;',
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL',
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT',
},
},
{
table: 'route_channels',
column: 'source_model',
addSql: {
sqlite: 'ALTER TABLE route_channels ADD COLUMN source_model text;',
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `source_model` TEXT NULL',
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
},
},
];
function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) {
return String((error as { message?: unknown }).message || '');
}
return String(error || '');
}
function isDuplicateColumnError(error: unknown): boolean {
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
return lowered.includes('duplicate column')
|| lowered.includes('already exists')
|| lowered.includes('duplicate column name');
}
async function executeAddColumn(inspector: RouteGroupingSchemaInspector, sqlText: string): Promise<void> {
try {
await inspector.execute(sqlText);
} catch (error) {
if (!isDuplicateColumnError(error)) {
throw error;
}
}
}
export async function ensureRouteGroupingSchemaCompatibility(inspector: RouteGroupingSchemaInspector): Promise<void> {
const tableExistsCache = new Map<string, boolean>();
for (const spec of ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS) {
let hasTable = tableExistsCache.get(spec.table);
if (hasTable === undefined) {
hasTable = await inspector.tableExists(spec.table);
tableExistsCache.set(spec.table, hasTable);
}
if (!hasTable) {
continue;
}
const hasColumn = await inspector.columnExists(spec.table, spec.column);
if (!hasColumn) {
await executeAddColumn(inspector, spec.addSql[inspector.dialect]);
}
}
}
+2
View File
@@ -31,6 +31,7 @@ import { dirname, normalize, resolve, sep } from 'path';
import {
db,
ensureProxyLogBillingDetailsColumn,
ensureRouteGroupingCompatibilityColumns,
ensureSiteCompatibilityColumns,
runtimeDbDialect,
schema,
@@ -217,6 +218,7 @@ try {
}
await ensureSiteCompatibilityColumns();
await ensureRouteGroupingCompatibilityColumns();
const finalRows = await db.select().from(schema.settings).all();
const finalMap = toSettingsMap(finalRows);
applyRuntimeSettings(finalMap);
@@ -130,6 +130,35 @@ describe('databaseMigrationService', () => {
expect(useSystemProxySql).toContain('use_system_proxy');
});
it.each(['postgres', 'mysql'] as const)('patches token_routes decision snapshot columns for %s', async (dialect) => {
const executedSql: string[] = [];
await __databaseMigrationServiceTestUtils.ensureSchema({
dialect,
begin: async () => {},
commit: async () => {},
rollback: async () => {},
execute: async (sqlText) => {
executedSql.push(sqlText);
return [];
},
queryScalar: async (sqlText, params = []) => {
if (sqlText.includes('information_schema.tables')) {
return 1;
}
if (sqlText.includes('information_schema.columns')) {
const columnName = String(params[1] ?? '');
return columnName === 'decision_snapshot' ? 0 : 1;
}
return 0;
},
close: async () => {},
});
expect(
executedSql.some((sqlText) => sqlText.includes('ADD COLUMN') && sqlText.includes('decision_snapshot')),
).toBe(true);
});
it('includes useSystemProxy when building site migration statements', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
@@ -5,6 +5,7 @@ import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { db, schema } from '../db/index.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from '../db/routeGroupingSchemaCompatibility.js';
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
@@ -433,6 +434,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
}
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
}
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {