feat: Add route grouping schema compatibility checks and integration into migration process
This commit is contained in:
+40
-18
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user