Files
metapi/src/server/db/siteSchemaCompatibility.ts
T

171 lines
6.7 KiB
TypeScript

export type SiteSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
export interface SiteSchemaInspector {
dialect: SiteSchemaDialect;
tableExists(table: string): Promise<boolean>;
columnExists(table: string, column: string): Promise<boolean>;
execute(sqlText: string): Promise<void>;
}
export type SiteColumnCompatibilitySpec = {
column: string;
addSql: Record<SiteSchemaDialect, string>;
normalizeSql?: Record<SiteSchemaDialect, string>;
};
export type SiteTableCompatibilitySpec = {
table: string;
createSql: Record<SiteSchemaDialect, string>;
postCreateSql?: Record<SiteSchemaDialect, string[]>;
};
export const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
{
column: 'proxy_url',
addSql: {
sqlite: 'ALTER TABLE sites ADD COLUMN proxy_url text;',
mysql: 'ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT NULL',
postgres: 'ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT',
},
},
{
column: 'use_system_proxy',
addSql: {
sqlite: 'ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;',
mysql: 'ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT FALSE',
postgres: 'ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT FALSE',
},
normalizeSql: {
sqlite: 'UPDATE sites SET use_system_proxy = 0 WHERE use_system_proxy IS NULL;',
mysql: 'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
postgres: 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
},
},
{
column: 'custom_headers',
addSql: {
sqlite: 'ALTER TABLE sites ADD COLUMN custom_headers text;',
mysql: 'ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT NULL',
postgres: 'ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT',
},
},
{
column: 'external_checkin_url',
addSql: {
sqlite: 'ALTER TABLE sites ADD COLUMN external_checkin_url text;',
mysql: 'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL',
postgres: 'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT',
},
},
{
column: 'global_weight',
addSql: {
sqlite: 'ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;',
mysql: 'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1',
postgres: 'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1',
},
normalizeSql: {
sqlite: 'UPDATE sites SET global_weight = 1 WHERE global_weight IS NULL OR global_weight <= 0;',
mysql: 'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
postgres: 'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
},
},
];
export const SITE_TABLE_COMPATIBILITY_SPECS: SiteTableCompatibilitySpec[] = [
{
table: 'site_disabled_models',
createSql: {
sqlite: 'CREATE TABLE IF NOT EXISTS site_disabled_models (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, site_id integer NOT NULL REFERENCES sites(id) ON DELETE cascade, model_name text NOT NULL, created_at text DEFAULT (datetime(\'now\')));',
mysql: 'CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `model_name` VARCHAR(191) NOT NULL, `created_at` TEXT NULL, CONSTRAINT `site_disabled_models_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)',
postgres: 'CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "created_at" TEXT)',
},
postCreateSql: {
sqlite: [
'CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique ON site_disabled_models (site_id, model_name);',
'CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx ON site_disabled_models (site_id);',
],
mysql: [
'CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191))',
'CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`)',
],
postgres: [
'CREATE UNIQUE INDEX IF NOT EXISTS "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name")',
'CREATE INDEX IF NOT EXISTS "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id")',
],
},
},
];
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');
}
function isExistingSchemaObjectError(error: unknown): boolean {
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
return lowered.includes('duplicate column')
|| lowered.includes('already exists')
|| lowered.includes('duplicate column name')
|| lowered.includes('duplicate key name')
|| (lowered.includes('relation') && lowered.includes('already exists'));
}
async function executeAddColumn(inspector: SiteSchemaInspector, sqlText: string): Promise<void> {
try {
await inspector.execute(sqlText);
} catch (error) {
if (!isDuplicateColumnError(error)) {
throw error;
}
}
}
async function executeCreateSchemaObject(inspector: SiteSchemaInspector, sqlText: string): Promise<void> {
try {
await inspector.execute(sqlText);
} catch (error) {
if (!isExistingSchemaObjectError(error)) {
throw error;
}
}
}
export async function ensureSiteSchemaCompatibility(inspector: SiteSchemaInspector): Promise<void> {
const hasSitesTable = await inspector.tableExists('sites');
if (!hasSitesTable) {
return;
}
for (const spec of SITE_COLUMN_COMPATIBILITY_SPECS) {
const hasColumn = await inspector.columnExists('sites', spec.column);
if (!hasColumn) {
await executeAddColumn(inspector, spec.addSql[inspector.dialect]);
}
if (spec.normalizeSql) {
await inspector.execute(spec.normalizeSql[inspector.dialect]);
}
}
for (const spec of SITE_TABLE_COMPATIBILITY_SPECS) {
const hasTable = await inspector.tableExists(spec.table);
if (!hasTable) {
await executeCreateSchemaObject(inspector, spec.createSql[inspector.dialect]);
}
for (const sqlText of spec.postCreateSql?.[inspector.dialect] ?? []) {
await executeCreateSchemaObject(inspector, sqlText);
}
}
}