mirror of
https://github.com/cita-777/metapi.git
synced 2026-11-03 06:04:15 +08:00
- Implemented site_disabled_models table with necessary SQL commands for SQLite, MySQL, and PostgreSQL. - Updated ensureSiteSchemaCompatibility to create site_disabled_models if it doesn't exist. - Enhanced database migration service to include site_disabled_models, proxy_video_tasks, and proxy_files in migration statements. - Created shared index compatibility for new tables and ensured existing indexes are maintained. - Added tests for schema parity to ensure consistency between runtime schema and defined schema.
704 lines
24 KiB
TypeScript
704 lines
24 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { db, schema } from '../db/index.js';
|
|
import {
|
|
createRuntimeSchemaClient,
|
|
ensureRuntimeDatabaseSchema,
|
|
type RuntimeSchemaClient,
|
|
type RuntimeSchemaDialect,
|
|
} from '../db/runtimeSchemaBootstrap.js';
|
|
|
|
export type MigrationDialect = RuntimeSchemaDialect;
|
|
|
|
export interface DatabaseMigrationInput {
|
|
dialect?: unknown;
|
|
connectionString?: unknown;
|
|
overwrite?: unknown;
|
|
ssl?: unknown;
|
|
}
|
|
|
|
export interface NormalizedDatabaseMigrationInput {
|
|
dialect: MigrationDialect;
|
|
connectionString: string;
|
|
overwrite: boolean;
|
|
ssl: boolean;
|
|
}
|
|
|
|
type BackupSnapshot = {
|
|
version: string;
|
|
timestamp: number;
|
|
accounts: {
|
|
sites: Array<Record<string, unknown>>;
|
|
siteDisabledModels: Array<Record<string, unknown>>;
|
|
accounts: Array<Record<string, unknown>>;
|
|
accountTokens: Array<Record<string, unknown>>;
|
|
checkinLogs: Array<Record<string, unknown>>;
|
|
modelAvailability: Array<Record<string, unknown>>;
|
|
tokenModelAvailability: Array<Record<string, unknown>>;
|
|
tokenRoutes: Array<Record<string, unknown>>;
|
|
routeChannels: Array<Record<string, unknown>>;
|
|
proxyLogs: Array<Record<string, unknown>>;
|
|
proxyVideoTasks: Array<Record<string, unknown>>;
|
|
proxyFiles: Array<Record<string, unknown>>;
|
|
downstreamApiKeys: Array<Record<string, unknown>>;
|
|
events: Array<Record<string, unknown>>;
|
|
};
|
|
preferences: {
|
|
settings: Array<{ key: string; value: unknown }>;
|
|
};
|
|
};
|
|
|
|
export interface DatabaseMigrationSummary {
|
|
dialect: MigrationDialect;
|
|
connection: string;
|
|
overwrite: boolean;
|
|
version: string;
|
|
timestamp: number;
|
|
rows: {
|
|
sites: number;
|
|
siteDisabledModels: number;
|
|
accounts: number;
|
|
accountTokens: number;
|
|
tokenRoutes: number;
|
|
routeChannels: number;
|
|
checkinLogs: number;
|
|
modelAvailability: number;
|
|
tokenModelAvailability: number;
|
|
proxyLogs: number;
|
|
proxyVideoTasks: number;
|
|
proxyFiles: number;
|
|
downstreamApiKeys: number;
|
|
events: number;
|
|
settings: number;
|
|
};
|
|
}
|
|
|
|
type SqlClient = RuntimeSchemaClient;
|
|
|
|
interface InsertStatement {
|
|
table: string;
|
|
columns: string[];
|
|
values: unknown[];
|
|
}
|
|
|
|
const DIALECTS: MigrationDialect[] = ['sqlite', 'mysql', 'postgres'];
|
|
|
|
function asString(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
if (typeof value === 'boolean') return value;
|
|
if (typeof value === 'number') return value !== 0;
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function asNumber(value: unknown, fallback: number | null = null): number | null {
|
|
if (value === null || value === undefined) return fallback;
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) ? numeric : fallback;
|
|
}
|
|
|
|
function asNullableString(value: unknown): string | null {
|
|
if (value === null || value === undefined) return null;
|
|
return String(value);
|
|
}
|
|
|
|
function toJsonString(value: unknown): string {
|
|
return JSON.stringify(value ?? null);
|
|
}
|
|
|
|
function assertDialectUrl(dialect: MigrationDialect, connectionString: string): void {
|
|
if (dialect === 'sqlite') return;
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(connectionString);
|
|
} catch {
|
|
throw new Error(`数据库连接串无效:${dialect} 需要合法 URL`);
|
|
}
|
|
|
|
if (dialect === 'postgres' && parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
|
|
throw new Error('PostgreSQL 连接串必须以 postgres:// 或 postgresql:// 开头');
|
|
}
|
|
|
|
if (dialect === 'mysql' && parsed.protocol !== 'mysql:') {
|
|
throw new Error('MySQL 连接串必须以 mysql:// 开头');
|
|
}
|
|
}
|
|
|
|
function normalizeSqliteTarget(raw: string): string {
|
|
if (!raw) throw new Error('SQLite 目标路径不能为空');
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) throw new Error('SQLite 目标路径不能为空');
|
|
if (trimmed === ':memory:') return trimmed;
|
|
|
|
const lower = trimmed.toLowerCase();
|
|
if (lower.startsWith('file://')) {
|
|
const parsed = new URL(trimmed);
|
|
return decodeURIComponent(parsed.pathname);
|
|
}
|
|
if (lower.startsWith('sqlite://')) {
|
|
return trimmed.slice('sqlite://'.length).trim();
|
|
}
|
|
|
|
// Guard against accidentally saving a network URL under sqlite dialect.
|
|
// This would be treated as a local sqlite file path and produce a broken runtime DB.
|
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
throw new Error('SQLite 连接串不能是网络 URL,请先选择 MySQL 或 PostgreSQL');
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
export function normalizeMigrationInput(input: DatabaseMigrationInput): NormalizedDatabaseMigrationInput {
|
|
const rawDialect = asString(input.dialect).toLowerCase();
|
|
if (!DIALECTS.includes(rawDialect as MigrationDialect)) {
|
|
throw new Error('数据库方言无效,仅支持 sqlite/mysql/postgres');
|
|
}
|
|
|
|
const dialect = rawDialect as MigrationDialect;
|
|
let connectionString = asString(input.connectionString);
|
|
if (!connectionString) {
|
|
throw new Error('数据库连接串不能为空');
|
|
}
|
|
|
|
if (dialect === 'sqlite') {
|
|
connectionString = normalizeSqliteTarget(connectionString);
|
|
} else {
|
|
assertDialectUrl(dialect, connectionString);
|
|
}
|
|
|
|
return {
|
|
dialect,
|
|
connectionString,
|
|
overwrite: input.overwrite === undefined ? true : asBoolean(input.overwrite, true),
|
|
ssl: asBoolean(input.ssl, false),
|
|
};
|
|
}
|
|
|
|
export function maskConnectionString(connectionString: string): string {
|
|
try {
|
|
const parsed = new URL(connectionString);
|
|
if (!parsed.password) return connectionString;
|
|
parsed.password = '***';
|
|
return parsed.toString();
|
|
} catch {
|
|
return connectionString;
|
|
}
|
|
}
|
|
|
|
function quoteIdent(dialect: MigrationDialect, identifier: string): string {
|
|
return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`;
|
|
}
|
|
|
|
function parseSettingValue(raw: string | null): unknown {
|
|
if (raw === null || raw === undefined) return null;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
async function toBackupSnapshot(): Promise<BackupSnapshot> {
|
|
const settingsRows = await db.select().from(schema.settings).all();
|
|
return {
|
|
version: 'live-db-snapshot',
|
|
timestamp: Date.now(),
|
|
accounts: {
|
|
sites: await db.select().from(schema.sites).all() as Array<Record<string, unknown>>,
|
|
siteDisabledModels: await db.select().from(schema.siteDisabledModels).all() as Array<Record<string, unknown>>,
|
|
accounts: await db.select().from(schema.accounts).all() as Array<Record<string, unknown>>,
|
|
accountTokens: await db.select().from(schema.accountTokens).all() as Array<Record<string, unknown>>,
|
|
checkinLogs: await db.select().from(schema.checkinLogs).all() as Array<Record<string, unknown>>,
|
|
modelAvailability: await db.select().from(schema.modelAvailability).all() as Array<Record<string, unknown>>,
|
|
tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array<Record<string, unknown>>,
|
|
tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array<Record<string, unknown>>,
|
|
routeChannels: await db.select().from(schema.routeChannels).all() as Array<Record<string, unknown>>,
|
|
proxyLogs: await db.select().from(schema.proxyLogs).all() as Array<Record<string, unknown>>,
|
|
proxyVideoTasks: await db.select().from(schema.proxyVideoTasks).all() as Array<Record<string, unknown>>,
|
|
proxyFiles: await db.select().from(schema.proxyFiles).all() as Array<Record<string, unknown>>,
|
|
downstreamApiKeys: await db.select().from(schema.downstreamApiKeys).all() as Array<Record<string, unknown>>,
|
|
events: await db.select().from(schema.events).all() as Array<Record<string, unknown>>,
|
|
},
|
|
preferences: {
|
|
settings: settingsRows.map((row) => ({ key: row.key, value: parseSettingValue(row.value) })),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function createClient(input: NormalizedDatabaseMigrationInput): Promise<SqlClient> {
|
|
return createRuntimeSchemaClient(input);
|
|
}
|
|
|
|
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
|
|
const siteCount = await client.queryScalar(`SELECT COUNT(*) FROM ${quoteIdent(client.dialect, 'sites')}`);
|
|
const settingCount = await client.queryScalar(`SELECT COUNT(*) FROM ${quoteIdent(client.dialect, 'settings')}`);
|
|
if (!overwrite && (siteCount > 0 || settingCount > 0)) {
|
|
throw new Error('目标数据库已包含数据。若确认覆盖,请勾选“覆盖目标数据库现有数据”');
|
|
}
|
|
}
|
|
|
|
async function clearTargetData(client: SqlClient): Promise<void> {
|
|
const tables = [
|
|
'route_channels',
|
|
'token_model_availability',
|
|
'model_availability',
|
|
'checkin_logs',
|
|
'proxy_logs',
|
|
'proxy_video_tasks',
|
|
'proxy_files',
|
|
'account_tokens',
|
|
'accounts',
|
|
'site_disabled_models',
|
|
'token_routes',
|
|
'sites',
|
|
'downstream_api_keys',
|
|
'events',
|
|
'settings',
|
|
];
|
|
for (const table of tables) {
|
|
await client.execute(`DELETE FROM ${quoteIdent(client.dialect, table)}`);
|
|
}
|
|
}
|
|
|
|
function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|
const statements: InsertStatement[] = [];
|
|
|
|
for (const row of snapshot.accounts.sites) {
|
|
statements.push({
|
|
table: 'sites',
|
|
columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'use_system_proxy', 'custom_headers', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNullableString(row.name),
|
|
asNullableString(row.url),
|
|
asNullableString(row.externalCheckinUrl),
|
|
asNullableString(row.platform),
|
|
asNullableString(row.proxyUrl),
|
|
asBoolean(row.useSystemProxy, false),
|
|
asNullableString(row.customHeaders),
|
|
asNullableString(row.status) ?? 'active',
|
|
asBoolean(row.isPinned, false),
|
|
asNumber(row.sortOrder, 0),
|
|
asNumber(row.globalWeight, 1),
|
|
asNullableString(row.apiKey),
|
|
asNullableString(row.createdAt),
|
|
asNullableString(row.updatedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.siteDisabledModels) {
|
|
statements.push({
|
|
table: 'site_disabled_models',
|
|
columns: ['id', 'site_id', 'model_name', 'created_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNumber(row.siteId, 0),
|
|
asNullableString(row.modelName),
|
|
asNullableString(row.createdAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.accounts) {
|
|
statements.push({
|
|
table: 'accounts',
|
|
columns: ['id', 'site_id', 'username', 'access_token', 'api_token', 'balance', 'balance_used', 'quota', 'unit_cost', 'value_score', 'status', 'is_pinned', 'sort_order', 'checkin_enabled', 'last_checkin_at', 'last_balance_refresh', 'extra_config', 'created_at', 'updated_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNumber(row.siteId, 0),
|
|
asNullableString(row.username),
|
|
asNullableString(row.accessToken),
|
|
asNullableString(row.apiToken),
|
|
asNumber(row.balance, 0),
|
|
asNumber(row.balanceUsed, 0),
|
|
asNumber(row.quota, 0),
|
|
asNumber(row.unitCost, null),
|
|
asNumber(row.valueScore, 0),
|
|
asNullableString(row.status) ?? 'active',
|
|
asBoolean(row.isPinned, false),
|
|
asNumber(row.sortOrder, 0),
|
|
asBoolean(row.checkinEnabled, true),
|
|
asNullableString(row.lastCheckinAt),
|
|
asNullableString(row.lastBalanceRefresh),
|
|
asNullableString(row.extraConfig),
|
|
asNullableString(row.createdAt),
|
|
asNullableString(row.updatedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.accountTokens) {
|
|
statements.push({
|
|
table: 'account_tokens',
|
|
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNumber(row.accountId, 0),
|
|
asNullableString(row.name),
|
|
asNullableString(row.token),
|
|
asNullableString(row.tokenGroup),
|
|
asNullableString(row.source) ?? 'manual',
|
|
asBoolean(row.enabled, true),
|
|
asBoolean(row.isDefault, false),
|
|
asNullableString(row.createdAt),
|
|
asNullableString(row.updatedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.checkinLogs) {
|
|
statements.push({
|
|
table: 'checkin_logs',
|
|
columns: ['id', 'account_id', 'status', 'message', 'reward', 'created_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNumber(row.accountId, 0),
|
|
asNullableString(row.status) ?? 'success',
|
|
asNullableString(row.message),
|
|
asNullableString(row.reward),
|
|
asNullableString(row.createdAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.modelAvailability) {
|
|
statements.push({
|
|
table: 'model_availability',
|
|
columns: ['id', 'account_id', 'model_name', 'available', 'latency_ms', 'checked_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNumber(row.accountId, 0),
|
|
asNullableString(row.modelName),
|
|
asBoolean(row.available, false),
|
|
asNumber(row.latencyMs, null),
|
|
asNullableString(row.checkedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.tokenModelAvailability) {
|
|
statements.push({
|
|
table: 'token_model_availability',
|
|
columns: ['id', 'token_id', 'model_name', 'available', 'latency_ms', 'checked_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNumber(row.tokenId, 0),
|
|
asNullableString(row.modelName),
|
|
asBoolean(row.available, false),
|
|
asNumber(row.latencyMs, null),
|
|
asNullableString(row.checkedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.tokenRoutes) {
|
|
statements.push({
|
|
table: 'token_routes',
|
|
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNullableString(row.modelPattern),
|
|
asNullableString(row.displayName),
|
|
asNullableString(row.displayIcon),
|
|
asNullableString(row.modelMapping),
|
|
asNullableString(row.decisionSnapshot),
|
|
asNullableString(row.decisionRefreshedAt),
|
|
asNullableString(row.routingStrategy),
|
|
asBoolean(row.enabled, true),
|
|
asNullableString(row.createdAt),
|
|
asNullableString(row.updatedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.routeChannels) {
|
|
statements.push({
|
|
table: 'route_channels',
|
|
columns: ['id', 'route_id', 'account_id', 'token_id', 'source_model', 'priority', 'weight', 'enabled', 'manual_override', 'success_count', 'fail_count', 'total_latency_ms', 'total_cost', 'last_used_at', 'last_selected_at', 'last_fail_at', 'consecutive_fail_count', 'cooldown_level', 'cooldown_until'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNumber(row.routeId, 0),
|
|
asNumber(row.accountId, 0),
|
|
asNumber(row.tokenId, null),
|
|
asNullableString(row.sourceModel),
|
|
asNumber(row.priority, 0),
|
|
asNumber(row.weight, 10),
|
|
asBoolean(row.enabled, true),
|
|
asBoolean(row.manualOverride, false),
|
|
asNumber(row.successCount, 0),
|
|
asNumber(row.failCount, 0),
|
|
asNumber(row.totalLatencyMs, 0),
|
|
asNumber(row.totalCost, 0),
|
|
asNullableString(row.lastUsedAt),
|
|
asNullableString(row.lastSelectedAt),
|
|
asNullableString(row.lastFailAt),
|
|
asNumber(row.consecutiveFailCount, 0),
|
|
asNumber(row.cooldownLevel, 0),
|
|
asNullableString(row.cooldownUntil),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.proxyLogs) {
|
|
statements.push({
|
|
table: 'proxy_logs',
|
|
columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNumber(row.routeId, null),
|
|
asNumber(row.channelId, null),
|
|
asNumber(row.accountId, null),
|
|
asNullableString(row.modelRequested),
|
|
asNullableString(row.modelActual),
|
|
asNullableString(row.status),
|
|
asNumber(row.httpStatus, null),
|
|
asNumber(row.latencyMs, null),
|
|
asNumber(row.promptTokens, null),
|
|
asNumber(row.completionTokens, null),
|
|
asNumber(row.totalTokens, null),
|
|
asNumber(row.estimatedCost, null),
|
|
asNullableString(row.billingDetails),
|
|
asNullableString(row.errorMessage),
|
|
asNumber(row.retryCount, 0),
|
|
asNullableString(row.createdAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.proxyVideoTasks) {
|
|
statements.push({
|
|
table: 'proxy_video_tasks',
|
|
columns: ['id', 'public_id', 'upstream_video_id', 'site_url', 'token_value', 'requested_model', 'actual_model', 'channel_id', 'account_id', 'status_snapshot', 'upstream_response_meta', 'last_upstream_status', 'last_polled_at', 'created_at', 'updated_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNullableString(row.publicId),
|
|
asNullableString(row.upstreamVideoId),
|
|
asNullableString(row.siteUrl),
|
|
asNullableString(row.tokenValue),
|
|
asNullableString(row.requestedModel),
|
|
asNullableString(row.actualModel),
|
|
asNumber(row.channelId, null),
|
|
asNumber(row.accountId, null),
|
|
asNullableString(row.statusSnapshot),
|
|
asNullableString(row.upstreamResponseMeta),
|
|
asNumber(row.lastUpstreamStatus, null),
|
|
asNullableString(row.lastPolledAt),
|
|
asNullableString(row.createdAt),
|
|
asNullableString(row.updatedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.proxyFiles) {
|
|
statements.push({
|
|
table: 'proxy_files',
|
|
columns: ['id', 'public_id', 'owner_type', 'owner_id', 'filename', 'mime_type', 'purpose', 'byte_size', 'sha256', 'content_base64', 'created_at', 'updated_at', 'deleted_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNullableString(row.publicId),
|
|
asNullableString(row.ownerType),
|
|
asNullableString(row.ownerId),
|
|
asNullableString(row.filename),
|
|
asNullableString(row.mimeType),
|
|
asNullableString(row.purpose),
|
|
asNumber(row.byteSize, 0),
|
|
asNullableString(row.sha256),
|
|
asNullableString(row.contentBase64),
|
|
asNullableString(row.createdAt),
|
|
asNullableString(row.updatedAt),
|
|
asNullableString(row.deletedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.downstreamApiKeys) {
|
|
statements.push({
|
|
table: 'downstream_api_keys',
|
|
columns: ['id', 'name', 'key', 'description', 'enabled', 'expires_at', 'max_cost', 'used_cost', 'max_requests', 'used_requests', 'supported_models', 'allowed_route_ids', 'site_weight_multipliers', 'last_used_at', 'created_at', 'updated_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNullableString(row.name),
|
|
asNullableString(row.key),
|
|
asNullableString(row.description),
|
|
asBoolean(row.enabled, true),
|
|
asNullableString(row.expiresAt),
|
|
asNumber(row.maxCost, null),
|
|
asNumber(row.usedCost, 0),
|
|
asNumber(row.maxRequests, null),
|
|
asNumber(row.usedRequests, 0),
|
|
asNullableString(row.supportedModels),
|
|
asNullableString(row.allowedRouteIds),
|
|
asNullableString(row.siteWeightMultipliers),
|
|
asNullableString(row.lastUsedAt),
|
|
asNullableString(row.createdAt),
|
|
asNullableString(row.updatedAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.accounts.events) {
|
|
statements.push({
|
|
table: 'events',
|
|
columns: ['id', 'type', 'title', 'message', 'level', 'read', 'related_id', 'related_type', 'created_at'],
|
|
values: [
|
|
asNumber(row.id, 0),
|
|
asNullableString(row.type),
|
|
asNullableString(row.title),
|
|
asNullableString(row.message),
|
|
asNullableString(row.level) ?? 'info',
|
|
asBoolean(row.read, false),
|
|
asNumber(row.relatedId, null),
|
|
asNullableString(row.relatedType),
|
|
asNullableString(row.createdAt),
|
|
],
|
|
});
|
|
}
|
|
|
|
for (const row of snapshot.preferences.settings) {
|
|
statements.push({
|
|
table: 'settings',
|
|
columns: ['key', 'value'],
|
|
values: [row.key, toJsonString(row.value)],
|
|
});
|
|
}
|
|
|
|
return statements;
|
|
}
|
|
|
|
function buildInsertSql(dialect: MigrationDialect, statement: InsertStatement): { sqlText: string; params: unknown[] } {
|
|
const table = quoteIdent(dialect, statement.table);
|
|
const columns = statement.columns.map((item) => quoteIdent(dialect, item)).join(', ');
|
|
const placeholders = statement.columns.map((_, index) => (dialect === 'postgres' ? `$${index + 1}` : '?')).join(', ');
|
|
const params = statement.values.map((value) => {
|
|
if (dialect === 'sqlite' && typeof value === 'boolean') {
|
|
return value ? 1 : 0;
|
|
}
|
|
return value;
|
|
});
|
|
return {
|
|
sqlText: `INSERT INTO ${table} (${columns}) VALUES (${placeholders})`,
|
|
params,
|
|
};
|
|
}
|
|
|
|
async function insertAllRows(client: SqlClient, statements: InsertStatement[]): Promise<void> {
|
|
for (const statement of statements) {
|
|
const { sqlText, params } = buildInsertSql(client.dialect, statement);
|
|
await client.execute(sqlText, params);
|
|
}
|
|
}
|
|
|
|
async function syncPostgresSequences(client: SqlClient): Promise<void> {
|
|
if (client.dialect !== 'postgres') return;
|
|
const tables = [
|
|
'sites',
|
|
'site_disabled_models',
|
|
'accounts',
|
|
'account_tokens',
|
|
'checkin_logs',
|
|
'model_availability',
|
|
'token_model_availability',
|
|
'token_routes',
|
|
'route_channels',
|
|
'proxy_logs',
|
|
'proxy_video_tasks',
|
|
'proxy_files',
|
|
'downstream_api_keys',
|
|
'events',
|
|
];
|
|
for (const table of tables) {
|
|
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
|
|
}
|
|
}
|
|
|
|
export async function bootstrapRuntimeDatabaseSchema(input: Pick<NormalizedDatabaseMigrationInput, 'dialect' | 'connectionString' | 'ssl'>): Promise<void> {
|
|
const client = await createClient({
|
|
dialect: input.dialect,
|
|
connectionString: input.connectionString,
|
|
overwrite: true,
|
|
ssl: input.ssl,
|
|
});
|
|
try {
|
|
await ensureRuntimeDatabaseSchema(client);
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
}
|
|
|
|
export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Promise<DatabaseMigrationSummary> {
|
|
const normalized = normalizeMigrationInput(input);
|
|
const snapshot = await toBackupSnapshot();
|
|
const client = await createClient(normalized);
|
|
|
|
try {
|
|
await ensureRuntimeDatabaseSchema(client);
|
|
await ensureTargetState(client, normalized.overwrite);
|
|
|
|
await client.begin();
|
|
try {
|
|
if (normalized.overwrite) {
|
|
await clearTargetData(client);
|
|
}
|
|
await insertAllRows(client, buildStatements(snapshot));
|
|
await syncPostgresSequences(client);
|
|
await client.commit();
|
|
} catch (error) {
|
|
await client.rollback();
|
|
throw error;
|
|
}
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
|
|
return {
|
|
dialect: normalized.dialect,
|
|
connection: maskConnectionString(normalized.connectionString),
|
|
overwrite: normalized.overwrite,
|
|
version: snapshot.version,
|
|
timestamp: snapshot.timestamp,
|
|
rows: {
|
|
sites: snapshot.accounts.sites.length,
|
|
siteDisabledModels: snapshot.accounts.siteDisabledModels.length,
|
|
accounts: snapshot.accounts.accounts.length,
|
|
accountTokens: snapshot.accounts.accountTokens.length,
|
|
tokenRoutes: snapshot.accounts.tokenRoutes.length,
|
|
routeChannels: snapshot.accounts.routeChannels.length,
|
|
checkinLogs: snapshot.accounts.checkinLogs.length,
|
|
modelAvailability: snapshot.accounts.modelAvailability.length,
|
|
tokenModelAvailability: snapshot.accounts.tokenModelAvailability.length,
|
|
proxyLogs: snapshot.accounts.proxyLogs.length,
|
|
proxyVideoTasks: snapshot.accounts.proxyVideoTasks.length,
|
|
proxyFiles: snapshot.accounts.proxyFiles.length,
|
|
downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length,
|
|
events: snapshot.accounts.events.length,
|
|
settings: snapshot.preferences.settings.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function testDatabaseConnection(input: DatabaseMigrationInput): Promise<{ dialect: MigrationDialect; connection: string }> {
|
|
const normalized = normalizeMigrationInput(input);
|
|
const client = await createClient(normalized);
|
|
try {
|
|
await client.execute('SELECT 1');
|
|
} finally {
|
|
await client.close();
|
|
}
|
|
|
|
return {
|
|
dialect: normalized.dialect,
|
|
connection: maskConnectionString(normalized.connectionString),
|
|
};
|
|
}
|
|
|
|
export const __databaseMigrationServiceTestUtils = {
|
|
ensureSchema: ensureRuntimeDatabaseSchema,
|
|
buildStatements,
|
|
};
|