1
0
forked from boos/BoosAPI

feat: add site_disabled_models and proxy tasks to database schema

- 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.
This commit is contained in:
ciat-777
2026-03-14 21:16:43 +08:00
parent 0c109278b9
commit b834a9bef8
10 changed files with 757 additions and 7 deletions
+7
View File
@@ -8,6 +8,7 @@ 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 { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
import { ensureSharedIndexSchemaCompatibility } from './sharedIndexSchemaCompatibility.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js'; import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js';
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
@@ -404,6 +405,12 @@ export async function ensureProxyFileCompatibilityColumns(): Promise<void> {
await ensureProxyFileSchemaCompatibility(inspector); await ensureProxyFileSchemaCompatibility(inspector);
} }
export async function ensureSharedIndexCompatibility(): Promise<void> {
const inspector = createRuntimeSchemaInspector();
if (!inspector) return;
await ensureSharedIndexSchemaCompatibility(inspector);
}
function ensureRouteGroupingSchema() { function ensureRouteGroupingSchema() {
const sqlite = requireSqliteConnection(); const sqlite = requireSqliteConnection();
if (!tableExists('token_routes') || !tableExists('route_channels')) { if (!tableExists('token_routes') || !tableExists('route_channels')) {
+8 -6
View File
@@ -7,6 +7,7 @@ import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteS
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
import { ensureAccountTokenSchemaCompatibility } from './accountTokenSchemaCompatibility.js'; import { ensureAccountTokenSchemaCompatibility } from './accountTokenSchemaCompatibility.js';
import { ensureSharedIndexSchemaCompatibility } from './sharedIndexSchemaCompatibility.js';
export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
@@ -195,9 +196,9 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`, `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT, CONSTRAINT "proxy_video_tasks_public_id_unique" UNIQUE ("public_id"))`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT, CONSTRAINT "downstream_api_keys_key_unique" UNIQUE ("key"))`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
] ]
@@ -212,9 +213,9 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`routing_strategy\` VARCHAR(32) NULL DEFAULT 'weighted', \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`routing_strategy\` VARCHAR(32) NULL DEFAULT 'weighted', \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT NOT NULL DEFAULT 0, \`cooldown_level\` INT NOT NULL DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`, `CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT NOT NULL DEFAULT 0, \`cooldown_level\` INT NOT NULL DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, UNIQUE KEY \`proxy_video_tasks_public_id_unique\` (\`public_id\`))`,
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, UNIQUE KEY \`downstream_api_keys_key_unique\` (\`key\`))`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
] ]
@@ -228,9 +229,9 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`, `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT, CONSTRAINT "proxy_video_tasks_public_id_unique" UNIQUE ("public_id"))`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT, CONSTRAINT "downstream_api_keys_key_unique" UNIQUE ("key"))`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
]; ];
@@ -243,6 +244,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client)); await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client)); await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client));
await ensureAccountTokenSchemaCompatibility(createSiteSchemaInspector(client)); await ensureAccountTokenSchemaCompatibility(createSiteSchemaInspector(client));
await ensureSharedIndexSchemaCompatibility(createSiteSchemaInspector(client));
} }
export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise<void> { export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise<void> {
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const dbDir = dirname(fileURLToPath(import.meta.url));
const schemaPath = resolve(dbDir, 'schema.ts');
const supportPaths = [
resolve(dbDir, 'runtimeSchemaBootstrap.ts'),
resolve(dbDir, 'siteSchemaCompatibility.ts'),
resolve(dbDir, 'routeGroupingSchemaCompatibility.ts'),
resolve(dbDir, 'proxyFileSchemaCompatibility.ts'),
resolve(dbDir, 'accountTokenSchemaCompatibility.ts'),
resolve(dbDir, 'sharedIndexSchemaCompatibility.ts'),
];
function extractAllMatches(content: string, pattern: RegExp): string[] {
return Array.from(content.matchAll(pattern), (match) => match[1]);
}
describe('database schema parity', () => {
it('keeps external runtime table support in sync with schema.ts', () => {
const schemaContent = readFileSync(schemaPath, 'utf8');
const supportContent = supportPaths
.map((filePath) => readFileSync(filePath, 'utf8'))
.join('\n');
const schemaTables = extractAllMatches(schemaContent, /sqliteTable\('([^']+)'/g);
const missingTables = schemaTables.filter((tableName) => !supportContent.includes(tableName));
expect(missingTables).toEqual([]);
});
it('keeps external runtime index support in sync with schema.ts', () => {
const schemaContent = readFileSync(schemaPath, 'utf8');
const supportContent = supportPaths
.map((filePath) => readFileSync(filePath, 'utf8'))
.join('\n');
const schemaIndexes = extractAllMatches(schemaContent, /(?:uniqueIndex|index)\('([^']+)'/g);
const missingIndexes = schemaIndexes.filter((indexName) => !supportContent.includes(indexName));
expect(missingIndexes).toEqual([]);
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { ensureSharedIndexSchemaCompatibility, type SharedIndexSchemaInspector } from './sharedIndexSchemaCompatibility.js';
function createInspector(
dialect: SharedIndexSchemaInspector['dialect'],
options?: {
existingTables?: string[];
},
) {
const executedSql: string[] = [];
const existingTables = new Set(options?.existingTables ?? []);
const inspector: SharedIndexSchemaInspector = {
dialect,
async tableExists(table) {
return existingTables.has(table);
},
async execute(sqlText) {
executedSql.push(sqlText);
},
};
return { inspector, executedSql };
}
describe('ensureSharedIndexSchemaCompatibility', () => {
it.each([
{
dialect: 'sqlite' as const,
expectedSql: [
'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);',
'CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx ON proxy_logs (status, created_at);',
'CREATE INDEX IF NOT EXISTS events_created_at_idx ON events (created_at);',
],
},
{
dialect: 'mysql' as const,
expectedSql: [
'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))',
'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))',
'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))',
],
},
{
dialect: 'postgres' as const,
expectedSql: [
'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")',
'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")',
'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")',
],
},
])('creates shared indexes for $dialect', async ({ dialect, expectedSql }) => {
const { inspector, executedSql } = createInspector(dialect, {
existingTables: [
'sites',
'accounts',
'account_tokens',
'checkin_logs',
'model_availability',
'token_model_availability',
'token_routes',
'route_channels',
'proxy_logs',
'proxy_video_tasks',
'downstream_api_keys',
'events',
],
});
await ensureSharedIndexSchemaCompatibility(inspector);
for (const sqlText of expectedSql) {
expect(executedSql).toContain(sqlText);
}
});
it('skips indexes for tables that do not exist', async () => {
const { inspector, executedSql } = createInspector('postgres', {
existingTables: ['sites'],
});
await ensureSharedIndexSchemaCompatibility(inspector);
expect(executedSql).toEqual([
'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")',
]);
});
});
@@ -0,0 +1,381 @@
export type SharedIndexSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
export interface SharedIndexSchemaInspector {
dialect: SharedIndexSchemaDialect;
tableExists(table: string): Promise<boolean>;
execute(sqlText: string): Promise<void>;
}
type SharedIndexCompatibilitySpec = {
table: string;
indexName: string;
createSql: Record<SharedIndexSchemaDialect, string>;
};
export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = [
{
table: 'sites',
indexName: 'sites_status_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS sites_status_idx ON sites (status);',
mysql: 'CREATE INDEX `sites_status_idx` ON `sites` (`status`)',
postgres: 'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")',
},
},
{
table: 'accounts',
indexName: 'accounts_site_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS accounts_site_id_idx ON accounts (site_id);',
mysql: 'CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "accounts_site_id_idx" ON "accounts" ("site_id")',
},
},
{
table: 'accounts',
indexName: 'accounts_status_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS accounts_status_idx ON accounts (status);',
mysql: 'CREATE INDEX `accounts_status_idx` ON `accounts` (`status`)',
postgres: 'CREATE INDEX IF NOT EXISTS "accounts_status_idx" ON "accounts" ("status")',
},
},
{
table: 'accounts',
indexName: 'accounts_site_status_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS accounts_site_status_idx ON accounts (site_id, status);',
mysql: 'CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`)',
postgres: 'CREATE INDEX IF NOT EXISTS "accounts_site_status_idx" ON "accounts" ("site_id", "status")',
},
},
{
table: 'account_tokens',
indexName: 'account_tokens_account_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx ON account_tokens (account_id);',
mysql: 'CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_account_id_idx" ON "account_tokens" ("account_id")',
},
},
{
table: 'account_tokens',
indexName: 'account_tokens_account_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx ON account_tokens (account_id, enabled);',
mysql: 'CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled")',
},
},
{
table: 'account_tokens',
indexName: 'account_tokens_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx ON account_tokens (enabled);',
mysql: 'CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_enabled_idx" ON "account_tokens" ("enabled")',
},
},
{
table: 'checkin_logs',
indexName: 'checkin_logs_account_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx ON checkin_logs (account_id, created_at);',
mysql: 'CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_account_created_at_idx" ON "checkin_logs" ("account_id", "created_at")',
},
},
{
table: 'checkin_logs',
indexName: 'checkin_logs_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx ON checkin_logs (created_at);',
mysql: 'CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_created_at_idx" ON "checkin_logs" ("created_at")',
},
},
{
table: 'checkin_logs',
indexName: 'checkin_logs_status_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_status_idx ON checkin_logs (status);',
mysql: 'CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`)',
postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_status_idx" ON "checkin_logs" ("status")',
},
},
{
table: 'model_availability',
indexName: 'model_availability_account_model_unique',
createSql: {
sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);',
mysql: 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))',
postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")',
},
},
{
table: 'model_availability',
indexName: 'model_availability_account_available_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS model_availability_account_available_idx ON model_availability (account_id, available);',
mysql: 'CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`)',
postgres: 'CREATE INDEX IF NOT EXISTS "model_availability_account_available_idx" ON "model_availability" ("account_id", "available")',
},
},
{
table: 'model_availability',
indexName: 'model_availability_model_name_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS model_availability_model_name_idx ON model_availability (model_name);',
mysql: 'CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "model_availability_model_name_idx" ON "model_availability" ("model_name")',
},
},
{
table: 'token_model_availability',
indexName: 'token_model_availability_token_model_unique',
createSql: {
sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique ON token_model_availability (token_id, model_name);',
mysql: 'CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191))',
postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name")',
},
},
{
table: 'token_model_availability',
indexName: 'token_model_availability_token_available_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx ON token_model_availability (token_id, available);',
mysql: 'CREATE INDEX `token_model_availability_token_available_idx` ON `token_model_availability` (`token_id`, `available`)',
postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_token_available_idx" ON "token_model_availability" ("token_id", "available")',
},
},
{
table: 'token_model_availability',
indexName: 'token_model_availability_model_name_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx ON token_model_availability (model_name);',
mysql: 'CREATE INDEX `token_model_availability_model_name_idx` ON `token_model_availability` (`model_name`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_model_name_idx" ON "token_model_availability" ("model_name")',
},
},
{
table: 'token_model_availability',
indexName: 'token_model_availability_available_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_available_idx ON token_model_availability (available);',
mysql: 'CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`)',
postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_available_idx" ON "token_model_availability" ("available")',
},
},
{
table: 'token_routes',
indexName: 'token_routes_model_pattern_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx ON token_routes (model_pattern);',
mysql: 'CREATE INDEX `token_routes_model_pattern_idx` ON `token_routes` (`model_pattern`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "token_routes_model_pattern_idx" ON "token_routes" ("model_pattern")',
},
},
{
table: 'token_routes',
indexName: 'token_routes_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_routes_enabled_idx ON token_routes (enabled);',
mysql: 'CREATE INDEX `token_routes_enabled_idx` ON `token_routes` (`enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "token_routes_enabled_idx" ON "token_routes" ("enabled")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_route_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_id_idx ON route_channels (route_id);',
mysql: 'CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_id_idx" ON "route_channels" ("route_id")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_account_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_account_id_idx ON route_channels (account_id);',
mysql: 'CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_account_id_idx" ON "route_channels" ("account_id")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_token_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_token_id_idx ON route_channels (token_id);',
mysql: 'CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_token_id_idx" ON "route_channels" ("token_id")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_route_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx ON route_channels (route_id, enabled);',
mysql: 'CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_enabled_idx" ON "route_channels" ("route_id", "enabled")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_route_token_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_token_idx ON route_channels (route_id, token_id);',
mysql: 'CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id")',
},
},
{
table: 'proxy_logs',
indexName: 'proxy_logs_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx ON proxy_logs (created_at);',
mysql: 'CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at")',
},
},
{
table: 'proxy_logs',
indexName: 'proxy_logs_account_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx ON proxy_logs (account_id, created_at);',
mysql: 'CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at")',
},
},
{
table: 'proxy_logs',
indexName: 'proxy_logs_status_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx ON proxy_logs (status, created_at);',
mysql: 'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")',
},
},
{
table: 'proxy_logs',
indexName: 'proxy_logs_model_actual_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx ON proxy_logs (model_actual, created_at);',
mysql: 'CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at")',
},
},
{
table: 'proxy_video_tasks',
indexName: 'proxy_video_tasks_upstream_video_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx ON proxy_video_tasks (upstream_video_id);',
mysql: 'CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_video_tasks_upstream_video_id_idx" ON "proxy_video_tasks" ("upstream_video_id")',
},
},
{
table: 'proxy_video_tasks',
indexName: 'proxy_video_tasks_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx ON proxy_video_tasks (created_at);',
mysql: 'CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at")',
},
},
{
table: 'downstream_api_keys',
indexName: 'downstream_api_keys_name_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx ON downstream_api_keys (name);',
mysql: 'CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_name_idx" ON "downstream_api_keys" ("name")',
},
},
{
table: 'downstream_api_keys',
indexName: 'downstream_api_keys_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx ON downstream_api_keys (enabled);',
mysql: 'CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_enabled_idx" ON "downstream_api_keys" ("enabled")',
},
},
{
table: 'downstream_api_keys',
indexName: 'downstream_api_keys_expires_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx ON downstream_api_keys (expires_at);',
mysql: 'CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_expires_at_idx" ON "downstream_api_keys" ("expires_at")',
},
},
{
table: 'events',
indexName: 'events_read_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS events_read_created_at_idx ON events (read, created_at);',
mysql: 'CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "events_read_created_at_idx" ON "events" ("read", "created_at")',
},
},
{
table: 'events',
indexName: 'events_type_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS events_type_created_at_idx ON events (type, created_at);',
mysql: 'CREATE INDEX `events_type_created_at_idx` ON `events` (`type`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "events_type_created_at_idx" ON "events" ("type", "created_at")',
},
},
{
table: 'events',
indexName: 'events_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS events_created_at_idx ON events (created_at);',
mysql: 'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")',
},
},
];
function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) {
return String((error as { message?: unknown }).message || '');
}
return String(error || '');
}
function isDuplicateSchemaError(error: unknown): boolean {
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
return lowered.includes('already exists')
|| lowered.includes('duplicate')
|| lowered.includes('relation') && lowered.includes('already exists');
}
async function executeIgnoreDuplicate(inspector: SharedIndexSchemaInspector, sqlText: string): Promise<void> {
try {
await inspector.execute(sqlText);
} catch (error) {
if (!isDuplicateSchemaError(error)) {
throw error;
}
}
}
export async function ensureSharedIndexSchemaCompatibility(inspector: SharedIndexSchemaInspector): Promise<void> {
const tableExistsCache = new Map<string, boolean>();
for (const spec of SHARED_INDEX_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;
}
await executeIgnoreDuplicate(inspector, spec.createSql[inspector.dialect]);
}
}
+20 -1
View File
@@ -5,17 +5,20 @@ function createInspector(
dialect: SiteSchemaInspector['dialect'], dialect: SiteSchemaInspector['dialect'],
options?: { options?: {
hasSitesTable?: boolean; hasSitesTable?: boolean;
existingTables?: string[];
existingColumns?: string[]; existingColumns?: string[];
}, },
) { ) {
const executedSql: string[] = []; const executedSql: string[] = [];
const hasSitesTable = options?.hasSitesTable ?? true; const hasSitesTable = options?.hasSitesTable ?? true;
const existingTables = new Set(options?.existingTables ?? []);
const existingColumns = new Set(options?.existingColumns ?? []); const existingColumns = new Set(options?.existingColumns ?? []);
const inspector: SiteSchemaInspector = { const inspector: SiteSchemaInspector = {
dialect, dialect,
async tableExists(table) { async tableExists(table) {
return table === 'sites' && hasSitesTable; if (table === 'sites') return hasSitesTable;
return existingTables.has(table);
}, },
async columnExists(table, column) { async columnExists(table, column) {
return table === 'sites' && existingColumns.has(column); return table === 'sites' && existingColumns.has(column);
@@ -40,6 +43,9 @@ describe('ensureSiteSchemaCompatibility', () => {
'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT', 'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT',
'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1', 'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1',
'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0', 'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
'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)',
'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")',
], ],
}, },
{ {
@@ -52,6 +58,9 @@ describe('ensureSiteSchemaCompatibility', () => {
'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL', 'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL',
'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1', 'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1',
'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0', 'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
'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)',
'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`)',
], ],
}, },
])('adds missing site proxy columns for $dialect', async ({ dialect, expectedSql }) => { ])('adds missing site proxy columns for $dialect', async ({ dialect, expectedSql }) => {
@@ -69,4 +78,14 @@ describe('ensureSiteSchemaCompatibility', () => {
expect(executedSql).toEqual([]); expect(executedSql).toEqual([]);
}); });
it('does not recreate site_disabled_models when it already exists', async () => {
const { inspector, executedSql } = createInspector('mysql', {
existingTables: ['site_disabled_models'],
});
await ensureSiteSchemaCompatibility(inspector);
expect(executedSql).not.toContain('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)');
});
}); });
+61
View File
@@ -13,6 +13,12 @@ type SiteColumnCompatibilitySpec = {
normalizeSql?: Record<SiteSchemaDialect, string>; normalizeSql?: Record<SiteSchemaDialect, string>;
}; };
type SiteTableCompatibilitySpec = {
table: string;
createSql: Record<SiteSchemaDialect, string>;
postCreateSql?: Record<SiteSchemaDialect, string[]>;
};
const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
{ {
column: 'proxy_url', column: 'proxy_url',
@@ -66,6 +72,31 @@ const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
}, },
]; ];
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 { function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) { if (typeof error === 'object' && error && 'message' in error) {
return String((error as { message?: unknown }).message || ''); return String((error as { message?: unknown }).message || '');
@@ -80,6 +111,15 @@ function isDuplicateColumnError(error: unknown): boolean {
|| lowered.includes('duplicate column name'); || 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> { async function executeAddColumn(inspector: SiteSchemaInspector, sqlText: string): Promise<void> {
try { try {
await inspector.execute(sqlText); await inspector.execute(sqlText);
@@ -90,6 +130,16 @@ async function executeAddColumn(inspector: SiteSchemaInspector, sqlText: string)
} }
} }
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> { export async function ensureSiteSchemaCompatibility(inspector: SiteSchemaInspector): Promise<void> {
const hasSitesTable = await inspector.tableExists('sites'); const hasSitesTable = await inspector.tableExists('sites');
if (!hasSitesTable) { if (!hasSitesTable) {
@@ -106,4 +156,15 @@ export async function ensureSiteSchemaCompatibility(inspector: SiteSchemaInspect
await inspector.execute(spec.normalizeSql[inspector.dialect]); 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);
}
}
} }
+2
View File
@@ -35,6 +35,7 @@ import {
ensureProxyFileCompatibilityColumns, ensureProxyFileCompatibilityColumns,
ensureProxyLogBillingDetailsColumn, ensureProxyLogBillingDetailsColumn,
ensureRouteGroupingCompatibilityColumns, ensureRouteGroupingCompatibilityColumns,
ensureSharedIndexCompatibility,
ensureSiteCompatibilityColumns, ensureSiteCompatibilityColumns,
runtimeDbDialect, runtimeDbDialect,
schema, schema,
@@ -264,6 +265,7 @@ try {
await ensureSiteCompatibilityColumns(); await ensureSiteCompatibilityColumns();
await ensureRouteGroupingCompatibilityColumns(); await ensureRouteGroupingCompatibilityColumns();
await ensureProxyFileCompatibilityColumns(); await ensureProxyFileCompatibilityColumns();
await ensureSharedIndexCompatibility();
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);
@@ -175,6 +175,7 @@ describe('databaseMigrationService', () => {
customHeaders: '{"x-site-scope":"internal"}', customHeaders: '{"x-site-scope":"internal"}',
status: 'active', status: 'active',
}], }],
siteDisabledModels: [],
accounts: [], accounts: [],
accountTokens: [], accountTokens: [],
checkinLogs: [], checkinLogs: [],
@@ -183,6 +184,8 @@ describe('databaseMigrationService', () => {
tokenRoutes: [], tokenRoutes: [],
routeChannels: [], routeChannels: [],
proxyLogs: [], proxyLogs: [],
proxyVideoTasks: [],
proxyFiles: [],
downstreamApiKeys: [], downstreamApiKeys: [],
events: [], events: [],
}, },
@@ -200,4 +203,69 @@ describe('databaseMigrationService', () => {
expect(customHeadersIndex).toBeGreaterThanOrEqual(0); expect(customHeadersIndex).toBeGreaterThanOrEqual(0);
expect(siteStatement?.values[customHeadersIndex]).toBe('{"x-site-scope":"internal"}'); expect(siteStatement?.values[customHeadersIndex]).toBe('{"x-site-scope":"internal"}');
}); });
it('includes disabled models, proxy video tasks, and proxy files in migration statements', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
timestamp: Date.now(),
accounts: {
sites: [],
siteDisabledModels: [{
id: 3,
siteId: 12,
modelName: 'claude-opus-4-6',
createdAt: '2026-03-14T00:00:00.000Z',
}],
accounts: [],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [],
routeChannels: [],
proxyLogs: [],
proxyVideoTasks: [{
id: 5,
publicId: 'video-public-id',
upstreamVideoId: 'upstream-video-id',
siteUrl: 'https://example.com',
tokenValue: 'sk-video',
requestedModel: 'veo-3',
actualModel: 'veo-3',
channelId: 7,
accountId: 9,
statusSnapshot: '{"status":"done"}',
upstreamResponseMeta: '{"id":"video"}',
lastUpstreamStatus: 200,
lastPolledAt: '2026-03-14T01:00:00.000Z',
createdAt: '2026-03-14T00:00:00.000Z',
updatedAt: '2026-03-14T01:00:00.000Z',
}],
proxyFiles: [{
id: 8,
publicId: 'file-public-id',
ownerType: 'downstream_key',
ownerId: 'key-1',
filename: 'demo.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 4,
sha256: 'abcd',
contentBase64: 'ZGVtbw==',
createdAt: '2026-03-14T00:00:00.000Z',
updatedAt: '2026-03-14T01:00:00.000Z',
deletedAt: null,
}],
downstreamApiKeys: [],
events: [],
},
preferences: {
settings: [],
},
} as any);
expect(statements.some((statement) => statement.table === 'site_disabled_models')).toBe(true);
expect(statements.some((statement) => statement.table === 'proxy_video_tasks')).toBe(true);
expect(statements.some((statement) => statement.table === 'proxy_files')).toBe(true);
});
}); });
@@ -28,6 +28,7 @@ type BackupSnapshot = {
timestamp: number; timestamp: number;
accounts: { accounts: {
sites: Array<Record<string, unknown>>; sites: Array<Record<string, unknown>>;
siteDisabledModels: Array<Record<string, unknown>>;
accounts: Array<Record<string, unknown>>; accounts: Array<Record<string, unknown>>;
accountTokens: Array<Record<string, unknown>>; accountTokens: Array<Record<string, unknown>>;
checkinLogs: Array<Record<string, unknown>>; checkinLogs: Array<Record<string, unknown>>;
@@ -36,6 +37,8 @@ type BackupSnapshot = {
tokenRoutes: Array<Record<string, unknown>>; tokenRoutes: Array<Record<string, unknown>>;
routeChannels: Array<Record<string, unknown>>; routeChannels: Array<Record<string, unknown>>;
proxyLogs: Array<Record<string, unknown>>; proxyLogs: Array<Record<string, unknown>>;
proxyVideoTasks: Array<Record<string, unknown>>;
proxyFiles: Array<Record<string, unknown>>;
downstreamApiKeys: Array<Record<string, unknown>>; downstreamApiKeys: Array<Record<string, unknown>>;
events: Array<Record<string, unknown>>; events: Array<Record<string, unknown>>;
}; };
@@ -52,6 +55,7 @@ export interface DatabaseMigrationSummary {
timestamp: number; timestamp: number;
rows: { rows: {
sites: number; sites: number;
siteDisabledModels: number;
accounts: number; accounts: number;
accountTokens: number; accountTokens: number;
tokenRoutes: number; tokenRoutes: number;
@@ -60,6 +64,8 @@ export interface DatabaseMigrationSummary {
modelAvailability: number; modelAvailability: number;
tokenModelAvailability: number; tokenModelAvailability: number;
proxyLogs: number; proxyLogs: number;
proxyVideoTasks: number;
proxyFiles: number;
downstreamApiKeys: number; downstreamApiKeys: number;
events: number; events: number;
settings: number; settings: number;
@@ -204,6 +210,7 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
timestamp: Date.now(), timestamp: Date.now(),
accounts: { accounts: {
sites: await db.select().from(schema.sites).all() as Array<Record<string, unknown>>, 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>>, 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>>, 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>>, checkinLogs: await db.select().from(schema.checkinLogs).all() as Array<Record<string, unknown>>,
@@ -212,6 +219,8 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
tokenRoutes: await db.select().from(schema.tokenRoutes).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>>, 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>>, 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>>, 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>>, events: await db.select().from(schema.events).all() as Array<Record<string, unknown>>,
}, },
@@ -240,8 +249,11 @@ async function clearTargetData(client: SqlClient): Promise<void> {
'model_availability', 'model_availability',
'checkin_logs', 'checkin_logs',
'proxy_logs', 'proxy_logs',
'proxy_video_tasks',
'proxy_files',
'account_tokens', 'account_tokens',
'accounts', 'accounts',
'site_disabled_models',
'token_routes', 'token_routes',
'sites', 'sites',
'downstream_api_keys', 'downstream_api_keys',
@@ -280,6 +292,19 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
}); });
} }
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) { for (const row of snapshot.accounts.accounts) {
statements.push({ statements.push({
table: 'accounts', table: 'accounts',
@@ -446,6 +471,52 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
}); });
} }
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) { for (const row of snapshot.accounts.downstreamApiKeys) {
statements.push({ statements.push({
table: 'downstream_api_keys', table: 'downstream_api_keys',
@@ -527,6 +598,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
if (client.dialect !== 'postgres') return; if (client.dialect !== 'postgres') return;
const tables = [ const tables = [
'sites', 'sites',
'site_disabled_models',
'accounts', 'accounts',
'account_tokens', 'account_tokens',
'checkin_logs', 'checkin_logs',
@@ -535,6 +607,8 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
'token_routes', 'token_routes',
'route_channels', 'route_channels',
'proxy_logs', 'proxy_logs',
'proxy_video_tasks',
'proxy_files',
'downstream_api_keys', 'downstream_api_keys',
'events', 'events',
]; ];
@@ -590,6 +664,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
timestamp: snapshot.timestamp, timestamp: snapshot.timestamp,
rows: { rows: {
sites: snapshot.accounts.sites.length, sites: snapshot.accounts.sites.length,
siteDisabledModels: snapshot.accounts.siteDisabledModels.length,
accounts: snapshot.accounts.accounts.length, accounts: snapshot.accounts.accounts.length,
accountTokens: snapshot.accounts.accountTokens.length, accountTokens: snapshot.accounts.accountTokens.length,
tokenRoutes: snapshot.accounts.tokenRoutes.length, tokenRoutes: snapshot.accounts.tokenRoutes.length,
@@ -598,6 +673,8 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
modelAvailability: snapshot.accounts.modelAvailability.length, modelAvailability: snapshot.accounts.modelAvailability.length,
tokenModelAvailability: snapshot.accounts.tokenModelAvailability.length, tokenModelAvailability: snapshot.accounts.tokenModelAvailability.length,
proxyLogs: snapshot.accounts.proxyLogs.length, proxyLogs: snapshot.accounts.proxyLogs.length,
proxyVideoTasks: snapshot.accounts.proxyVideoTasks.length,
proxyFiles: snapshot.accounts.proxyFiles.length,
downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length, downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length,
events: snapshot.accounts.events.length, events: snapshot.accounts.events.length,
settings: snapshot.preferences.settings.length, settings: snapshot.preferences.settings.length,