diff --git a/src/server/db/accountTokenSchemaCompatibility.ts b/src/server/db/accountTokenSchemaCompatibility.ts new file mode 100644 index 0000000..bd2ec40 --- /dev/null +++ b/src/server/db/accountTokenSchemaCompatibility.ts @@ -0,0 +1,64 @@ +export type AccountTokenSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; + +export interface AccountTokenSchemaInspector { + dialect: AccountTokenSchemaDialect; + tableExists(table: string): Promise; + columnExists(table: string, column: string): Promise; + execute(sqlText: string): Promise; +} + +type AccountTokenColumnCompatibilitySpec = { + table: 'account_tokens'; + column: string; + addSql: Record; +}; + +const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [ + { + table: 'account_tokens', + column: 'token_group', + addSql: { + sqlite: 'ALTER TABLE account_tokens ADD COLUMN token_group text;', + mysql: 'ALTER TABLE `account_tokens` ADD COLUMN `token_group` TEXT NULL', + postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" 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: AccountTokenSchemaInspector, sqlText: string): Promise { + try { + await inspector.execute(sqlText); + } catch (error) { + if (!isDuplicateColumnError(error)) { + throw error; + } + } +} + +export async function ensureAccountTokenSchemaCompatibility(inspector: AccountTokenSchemaInspector): Promise { + for (const spec of ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS) { + const hasTable = await inspector.tableExists(spec.table); + if (!hasTable) { + continue; + } + + const hasColumn = await inspector.columnExists(spec.table, spec.column); + if (!hasColumn) { + await executeAddColumn(inspector, spec.addSql[inspector.dialect]); + } + } +} diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 8ebe500..e76d71c 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -6,6 +6,7 @@ import { dirname, resolve } from 'node:path'; import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; +import { ensureAccountTokenSchemaCompatibility } from './accountTokenSchemaCompatibility.js'; export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; @@ -241,6 +242,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client)); await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client)); await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client)); + await ensureAccountTokenSchemaCompatibility(createSiteSchemaInspector(client)); } export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise {