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 { drizzle as drizzlePgProxy } from 'drizzle-orm/pg-proxy';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
|
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
|
||||||
|
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { mkdirSync } from 'fs';
|
import { mkdirSync } from 'fs';
|
||||||
import { dirname, resolve } from 'path';
|
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 {
|
return {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
tableExists: async (table) => tableExists(table),
|
tableExists: async (table) => tableExists(table),
|
||||||
@@ -277,16 +285,9 @@ function createSqliteSiteSchemaInspector(): SiteSchemaInspector {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureSiteCompatibilityColumns(): Promise<void> {
|
function createMysqlSchemaInspector(): RuntimeSchemaInspector | null {
|
||||||
if (runtimeDbDialect === 'sqlite') {
|
if (!mysqlPool) return null;
|
||||||
await ensureSiteSchemaCompatibility(createSqliteSiteSchemaInspector());
|
return {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtimeDbDialect === 'mysql') {
|
|
||||||
if (!mysqlPool) return;
|
|
||||||
|
|
||||||
await ensureSiteSchemaCompatibility({
|
|
||||||
dialect: 'mysql',
|
dialect: 'mysql',
|
||||||
tableExists: async (table) => {
|
tableExists: async (table) => {
|
||||||
const [rows] = await mysqlPool!.query(
|
const [rows] = await mysqlPool!.query(
|
||||||
@@ -305,13 +306,12 @@ export async function ensureSiteCompatibilityColumns(): Promise<void> {
|
|||||||
execute: async (sqlText) => {
|
execute: async (sqlText) => {
|
||||||
await mysqlPool!.query(sqlText);
|
await mysqlPool!.query(sqlText);
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!pgPool) return;
|
function createPostgresSchemaInspector(): RuntimeSchemaInspector | null {
|
||||||
|
if (!pgPool) return null;
|
||||||
await ensureSiteSchemaCompatibility({
|
return {
|
||||||
dialect: 'postgres',
|
dialect: 'postgres',
|
||||||
tableExists: async (table) => {
|
tableExists: async (table) => {
|
||||||
const result = await pgPool!.query(
|
const result = await pgPool!.query(
|
||||||
@@ -330,7 +330,29 @@ export async function ensureSiteCompatibilityColumns(): Promise<void> {
|
|||||||
execute: async (sqlText) => {
|
execute: async (sqlText) => {
|
||||||
await pgPool!.query(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() {
|
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 {
|
import {
|
||||||
db,
|
db,
|
||||||
ensureProxyLogBillingDetailsColumn,
|
ensureProxyLogBillingDetailsColumn,
|
||||||
|
ensureRouteGroupingCompatibilityColumns,
|
||||||
ensureSiteCompatibilityColumns,
|
ensureSiteCompatibilityColumns,
|
||||||
runtimeDbDialect,
|
runtimeDbDialect,
|
||||||
schema,
|
schema,
|
||||||
@@ -217,6 +218,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureSiteCompatibilityColumns();
|
await ensureSiteCompatibilityColumns();
|
||||||
|
await ensureRouteGroupingCompatibilityColumns();
|
||||||
const finalRows = await db.select().from(schema.settings).all();
|
const finalRows = await db.select().from(schema.settings).all();
|
||||||
const finalMap = toSettingsMap(finalRows);
|
const finalMap = toSettingsMap(finalRows);
|
||||||
applyRuntimeSettings(finalMap);
|
applyRuntimeSettings(finalMap);
|
||||||
|
|||||||
@@ -130,6 +130,35 @@ describe('databaseMigrationService', () => {
|
|||||||
expect(useSystemProxySql).toContain('use_system_proxy');
|
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', () => {
|
it('includes useSystemProxy when building site migration statements', () => {
|
||||||
const statements = __databaseMigrationServiceTestUtils.buildStatements({
|
const statements = __databaseMigrationServiceTestUtils.buildStatements({
|
||||||
version: 'test',
|
version: 'test',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { mkdirSync } from 'node:fs';
|
|||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js';
|
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js';
|
||||||
|
import { ensureRouteGroupingSchemaCompatibility } from '../db/routeGroupingSchemaCompatibility.js';
|
||||||
|
|
||||||
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
|
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
|
|
||||||
@@ -433,6 +434,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
|
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
|
||||||
|
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
|
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user