refactor(db): fence legacy runtime schema compat

This commit is contained in:
ciat-777
2026-03-14 23:56:04 +08:00
parent 627b04d456
commit cc675d51d5
5 changed files with 312 additions and 122 deletions
+61 -60
View File
@@ -9,6 +9,7 @@ import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteS
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
import { ensureSharedIndexSchemaCompatibility } from './sharedIndexSchemaCompatibility.js';
import { executeLegacyCompat, executeLegacyCompatSync } from './legacySchemaCompat.js';
import { config } from '../config.js';
import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js';
import { mkdirSync } from 'fs';
@@ -111,13 +112,19 @@ function tableColumnExists(table: string, column: string): boolean {
return rows.some((row) => row.name === column);
}
function execSqliteStatement(sqlText: string): void {
requireSqliteConnection().exec(sqlText);
}
function execSqliteLegacyCompat(sqlText: string): void {
executeLegacyCompatSync(execSqliteStatement, sqlText);
}
function ensureTokenManagementSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('accounts') || !tableExists('route_channels')) {
return;
}
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE TABLE IF NOT EXISTS account_tokens (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
account_id integer NOT NULL,
@@ -132,16 +139,15 @@ function ensureTokenManagementSchema() {
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE cascade
);
`);
if (!tableColumnExists('route_channels', 'token_id')) {
sqlite.exec('ALTER TABLE route_channels ADD COLUMN token_id integer;');
execSqliteLegacyCompat('ALTER TABLE route_channels ADD COLUMN token_id integer;');
}
if (!tableColumnExists('account_tokens', 'token_group')) {
sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;');
execSqliteLegacyCompat('ALTER TABLE account_tokens ADD COLUMN token_group text;');
}
sqlite.exec(`
execSqliteStatement(`
INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at)
SELECT
a.id,
@@ -163,7 +169,7 @@ function ensureTokenManagementSchema() {
);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE TABLE IF NOT EXISTS token_model_availability (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
token_id integer NOT NULL,
@@ -175,15 +181,14 @@ function ensureTokenManagementSchema() {
);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique
ON token_model_availability(token_id, model_name);
`);
}
function ensureProxyVideoTaskSchema() {
const sqlite = requireSqliteConnection();
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE TABLE IF NOT EXISTS proxy_video_tasks (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id text NOT NULL,
@@ -203,30 +208,29 @@ function ensureProxyVideoTaskSchema() {
);
`);
if (!tableColumnExists('proxy_video_tasks', 'status_snapshot')) {
sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN status_snapshot text;');
execSqliteLegacyCompat('ALTER TABLE proxy_video_tasks ADD COLUMN status_snapshot text;');
}
if (!tableColumnExists('proxy_video_tasks', 'upstream_response_meta')) {
sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN upstream_response_meta text;');
execSqliteLegacyCompat('ALTER TABLE proxy_video_tasks ADD COLUMN upstream_response_meta text;');
}
if (!tableColumnExists('proxy_video_tasks', 'last_upstream_status')) {
sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN last_upstream_status integer;');
execSqliteLegacyCompat('ALTER TABLE proxy_video_tasks ADD COLUMN last_upstream_status integer;');
}
if (!tableColumnExists('proxy_video_tasks', 'last_polled_at')) {
sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN last_polled_at text;');
execSqliteLegacyCompat('ALTER TABLE proxy_video_tasks ADD COLUMN last_polled_at text;');
}
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique
ON proxy_video_tasks(public_id);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx
ON proxy_video_tasks(upstream_video_id);
`);
}
function ensureProxyFileSchema() {
const sqlite = requireSqliteConnection();
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE TABLE IF NOT EXISTS proxy_files (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id text NOT NULL,
@@ -243,27 +247,26 @@ function ensureProxyFileSchema() {
deleted_at text
);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE UNIQUE INDEX IF NOT EXISTS proxy_files_public_id_unique
ON proxy_files(public_id);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE INDEX IF NOT EXISTS proxy_files_owner_lookup_idx
ON proxy_files(owner_type, owner_id, deleted_at);
`);
}
function ensureSiteStatusSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'status')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN status text DEFAULT 'active';`);
execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN status text DEFAULT 'active';`);
}
sqlite.exec(`
execSqliteStatement(`
UPDATE sites
SET status = lower(trim(status))
WHERE status IS NOT NULL
@@ -271,7 +274,7 @@ function ensureSiteStatusSchema() {
AND status != lower(trim(status));
`);
sqlite.exec(`
execSqliteStatement(`
UPDATE sites
SET status = 'active'
WHERE status IS NULL
@@ -281,27 +284,25 @@ function ensureSiteStatusSchema() {
}
function ensureSiteProxySchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'proxy_url')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN proxy_url text;`);
execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN proxy_url text;`);
}
}
function ensureSiteUseSystemProxySchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'use_system_proxy')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;`);
execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;`);
}
sqlite.exec(`
execSqliteLegacyCompat(`
UPDATE sites
SET use_system_proxy = 0
WHERE use_system_proxy IS NULL;
@@ -309,38 +310,35 @@ function ensureSiteUseSystemProxySchema() {
}
function ensureSiteCustomHeadersSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'custom_headers')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN custom_headers text;`);
execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN custom_headers text;`);
}
}
function ensureSiteExternalCheckinUrlSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'external_checkin_url')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN external_checkin_url text;`);
execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN external_checkin_url text;`);
}
}
function ensureSiteGlobalWeightSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'global_weight')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;`);
execSqliteLegacyCompat(`ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;`);
}
sqlite.exec(`
execSqliteLegacyCompat(`
UPDATE sites
SET global_weight = 1
WHERE global_weight IS NULL
@@ -361,7 +359,7 @@ function createSqliteSchemaInspector(): RuntimeSchemaInspector {
tableExists: async (table) => tableExists(table),
columnExists: async (table, column) => tableColumnExists(table, column),
execute: async (sqlText) => {
requireSqliteConnection().exec(sqlText);
executeLegacyCompatSync(execSqliteStatement, sqlText);
},
};
}
@@ -385,7 +383,7 @@ function createMysqlSchemaInspector(): RuntimeSchemaInspector | null {
return Array.isArray(rows) && rows.length > 0;
},
execute: async (sqlText) => {
await mysqlPool!.query(sqlText);
await executeLegacyCompat((statement) => mysqlPool!.query(statement).then(() => undefined), sqlText);
},
};
}
@@ -409,7 +407,7 @@ function createPostgresSchemaInspector(): RuntimeSchemaInspector | null {
return Number(result.rowCount || 0) > 0;
},
execute: async (sqlText) => {
await pgPool!.query(sqlText);
await executeLegacyCompat((statement) => pgPool!.query(statement).then(() => undefined), sqlText);
},
};
}
@@ -449,51 +447,49 @@ export async function ensureSharedIndexCompatibility(): Promise<void> {
}
function ensureRouteGroupingSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('token_routes') || !tableExists('route_channels')) {
return;
}
if (!tableColumnExists('token_routes', 'display_name')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_name text;`);
execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN display_name text;`);
}
if (!tableColumnExists('token_routes', 'display_icon')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_icon text;`);
execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN display_icon text;`);
}
if (!tableColumnExists('token_routes', 'decision_snapshot')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`);
execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`);
}
if (!tableColumnExists('token_routes', 'decision_refreshed_at')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;`);
execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;`);
}
if (!tableColumnExists('token_routes', 'routing_strategy')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT 'weighted';`);
execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT 'weighted';`);
}
if (!tableColumnExists('route_channels', 'source_model')) {
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`);
execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN source_model text;`);
}
if (!tableColumnExists('route_channels', 'last_selected_at')) {
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN last_selected_at text;`);
execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN last_selected_at text;`);
}
if (!tableColumnExists('route_channels', 'consecutive_fail_count')) {
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;`);
execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;`);
}
if (!tableColumnExists('route_channels', 'cooldown_level')) {
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`);
execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`);
}
}
function ensureDownstreamApiKeySchema() {
const sqlite = requireSqliteConnection();
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE TABLE IF NOT EXISTS downstream_api_keys (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
name text NOT NULL,
@@ -514,32 +510,31 @@ function ensureDownstreamApiKeySchema() {
);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique
ON downstream_api_keys(key);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx
ON downstream_api_keys(name);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx
ON downstream_api_keys(enabled);
`);
sqlite.exec(`
execSqliteLegacyCompat(`
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
ON downstream_api_keys(expires_at);
`);
}
function ensureProxyLogBillingDetailsSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('proxy_logs')) {
return;
}
if (!tableColumnExists('proxy_logs', 'billing_details')) {
sqlite.exec('ALTER TABLE proxy_logs ADD COLUMN billing_details text;');
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN billing_details text;');
}
proxyLogBillingDetailsColumnAvailable = true;
@@ -599,10 +594,16 @@ export async function ensureProxyLogBillingDetailsColumn(): Promise<boolean> {
try {
if (runtimeDbDialect === 'mysql') {
if (!mysqlPool) return false;
await mysqlPool.query('ALTER TABLE `proxy_logs` ADD COLUMN `billing_details` TEXT NULL');
await executeLegacyCompat(
(statement) => mysqlPool!.query(statement).then(() => undefined),
'ALTER TABLE `proxy_logs` ADD COLUMN `billing_details` TEXT NULL',
);
} else {
if (!pgPool) return false;
await pgPool.query('ALTER TABLE "proxy_logs" ADD COLUMN "billing_details" TEXT');
await executeLegacyCompat(
(statement) => pgPool!.query(statement).then(() => undefined),
'ALTER TABLE "proxy_logs" ADD COLUMN "billing_details" TEXT',
);
}
proxyLogBillingDetailsColumnAvailable = true;
return true;
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
describe('legacy schema compat boundary', () => {
it('allows only explicitly registered legacy upgrade shims', () => {
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN billing_details text;')).toBe('legacy');
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL')).toBe('legacy');
expect(classifyLegacyCompatMutation('ALTER TABLE sites ADD COLUMN brand_new_column text;')).toBe('forbidden');
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden');
});
});
+162
View File
@@ -0,0 +1,162 @@
import {
ensureAccountTokenSchemaCompatibility,
type AccountTokenSchemaInspector,
} from './accountTokenSchemaCompatibility.js';
import {
ensureProxyFileSchemaCompatibility,
type ProxyFileSchemaInspector,
} from './proxyFileSchemaCompatibility.js';
import {
ensureRouteGroupingSchemaCompatibility,
type RouteGroupingSchemaInspector,
} from './routeGroupingSchemaCompatibility.js';
import {
ensureSharedIndexSchemaCompatibility,
SHARED_INDEX_COMPATIBILITY_SPECS,
type SharedIndexSchemaInspector,
} from './sharedIndexSchemaCompatibility.js';
import {
ensureSiteSchemaCompatibility,
type SiteSchemaInspector,
} from './siteSchemaCompatibility.js';
export type LegacySchemaCompatClassification = 'legacy' | 'forbidden';
export interface LegacySchemaCompatInspector extends
SiteSchemaInspector,
RouteGroupingSchemaInspector,
ProxyFileSchemaInspector,
AccountTokenSchemaInspector,
SharedIndexSchemaInspector {}
const LEGACY_COMPAT_TABLES = new Set([
'account_tokens',
'token_model_availability',
'proxy_video_tasks',
'proxy_files',
'downstream_api_keys',
'site_disabled_models',
]);
const LEGACY_COMPAT_COLUMNS = new Set([
'sites.status',
'sites.proxy_url',
'sites.use_system_proxy',
'sites.custom_headers',
'sites.external_checkin_url',
'sites.global_weight',
'account_tokens.token_group',
'token_routes.display_name',
'token_routes.display_icon',
'token_routes.decision_snapshot',
'token_routes.decision_refreshed_at',
'token_routes.routing_strategy',
'route_channels.token_id',
'route_channels.source_model',
'route_channels.last_selected_at',
'route_channels.consecutive_fail_count',
'route_channels.cooldown_level',
'proxy_video_tasks.status_snapshot',
'proxy_video_tasks.upstream_response_meta',
'proxy_video_tasks.last_upstream_status',
'proxy_video_tasks.last_polled_at',
'proxy_logs.billing_details',
]);
const LEGACY_COMPAT_INDEXES = new Set([
'site_disabled_models_site_model_unique',
'site_disabled_models_site_id_idx',
'token_model_availability_token_model_unique',
'proxy_video_tasks_public_id_unique',
'proxy_video_tasks_upstream_video_id_idx',
'proxy_files_public_id_unique',
'proxy_files_owner_lookup_idx',
'downstream_api_keys_key_unique',
'downstream_api_keys_name_idx',
'downstream_api_keys_enabled_idx',
'downstream_api_keys_expires_at_idx',
...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
]);
function normalizeSqlText(sqlText: string): string {
return sqlText.trim().replace(/\s+/g, ' ').toLowerCase();
}
const LEGACY_COMPAT_UPDATES = new Set([
'UPDATE sites SET use_system_proxy = 0 WHERE use_system_proxy IS NULL;',
'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
'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',
'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
].map((sqlText) => normalizeSqlText(sqlText)));
export function classifyLegacyCompatMutation(sqlText: string): LegacySchemaCompatClassification {
const normalized = normalizeSqlText(sqlText);
if (LEGACY_COMPAT_UPDATES.has(normalized)) {
return 'legacy';
}
const createTableMatch = normalized.match(/^create table if not exists [`"]?([a-z0-9_]+)[`"]?/i);
if (createTableMatch) {
return LEGACY_COMPAT_TABLES.has(createTableMatch[1]) ? 'legacy' : 'forbidden';
}
const alterTableMatch = normalized.match(
/^alter table [`"]?([a-z0-9_]+)[`"]? add column [`"]?([a-z0-9_]+)[`"]?/i,
);
if (alterTableMatch) {
const [, tableName, columnName] = alterTableMatch;
return LEGACY_COMPAT_COLUMNS.has(`${tableName}.${columnName}`) ? 'legacy' : 'forbidden';
}
const createIndexMatch = normalized.match(
/^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i,
);
if (createIndexMatch) {
return LEGACY_COMPAT_INDEXES.has(createIndexMatch[1]) ? 'legacy' : 'forbidden';
}
return 'forbidden';
}
function assertLegacyCompatMutation(sqlText: string): void {
if (classifyLegacyCompatMutation(sqlText) === 'forbidden') {
throw new Error(`Forbidden legacy schema mutation: ${sqlText}`);
}
}
export async function executeLegacyCompat(
execute: (sqlText: string) => Promise<void>,
sqlText: string,
): Promise<void> {
assertLegacyCompatMutation(sqlText);
await execute(sqlText);
}
export function executeLegacyCompatSync(
execute: (sqlText: string) => void,
sqlText: string,
): void {
assertLegacyCompatMutation(sqlText);
execute(sqlText);
}
function wrapLegacyCompatInspector(inspector: LegacySchemaCompatInspector): LegacySchemaCompatInspector {
return {
...inspector,
execute: async (sqlText: string) => {
await executeLegacyCompat((statement) => inspector.execute(statement), sqlText);
},
};
}
export async function ensureLegacySchemaCompatibility(inspector: LegacySchemaCompatInspector): Promise<void> {
const wrappedInspector = wrapLegacyCompatInspector(inspector);
await ensureSiteSchemaCompatibility(wrappedInspector);
await ensureRouteGroupingSchemaCompatibility(wrappedInspector);
await ensureProxyFileSchemaCompatibility(wrappedInspector);
await ensureAccountTokenSchemaCompatibility(wrappedInspector);
await ensureSharedIndexSchemaCompatibility(wrappedInspector);
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import {
__runtimeSchemaBootstrapTestUtils,
ensureRuntimeDatabaseSchema,
type RuntimeSchemaClient,
type RuntimeSchemaDialect,
} from './runtimeSchemaBootstrap.js';
function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): RuntimeSchemaClient {
return {
dialect,
begin: async () => {},
commit: async () => {},
rollback: async () => {},
execute: async (sqlText: string) => {
executedSql.push(sqlText);
return [];
},
queryScalar: async (sqlText: string, params: unknown[] = []) => {
if (sqlText.includes('information_schema') || sqlText.includes('sqlite_master') || sqlText.includes('pragma_table_info')) {
return 1;
}
if (params.length > 0) {
return 1;
}
return 0;
},
close: async () => {},
};
}
describe('runtime schema bootstrap', () => {
it.each(['mysql', 'postgres'] as const)('loads generated bootstrap statements for %s', async (dialect) => {
const executedSql: string[] = [];
const expectedBootstrapSql = __runtimeSchemaBootstrapTestUtils.readGeneratedBootstrapStatements(dialect);
await ensureRuntimeDatabaseSchema(createStubClient(dialect, executedSql));
expect(executedSql.slice(0, expectedBootstrapSql.length)).toEqual(expectedBootstrapSql);
});
});
+37 -62
View File
@@ -1,13 +1,14 @@
import Database from 'better-sqlite3';
import mysql from 'mysql2/promise';
import pg from 'pg';
import { mkdirSync } from 'node:fs';
import { mkdirSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
import { ensureAccountTokenSchemaCompatibility } from './accountTokenSchemaCompatibility.js';
import { ensureSharedIndexSchemaCompatibility } from './sharedIndexSchemaCompatibility.js';
import {
ensureLegacySchemaCompatibility,
type LegacySchemaCompatInspector,
} from './legacySchemaCompat.js';
import { generateBootstrapSql, resolveGeneratedArtifactPath } from './schemaArtifactGenerator.js';
import type { SchemaContract } from './schemaContract.js';
export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
@@ -34,7 +35,7 @@ function validateIdentifier(identifier: string): string {
return identifier;
}
function createSiteSchemaInspector(client: RuntimeSchemaClient): SiteSchemaInspector {
function createLegacySchemaInspector(client: RuntimeSchemaClient): LegacySchemaCompatInspector {
if (client.dialect === 'sqlite') {
return {
dialect: 'sqlite',
@@ -98,6 +99,27 @@ function createSiteSchemaInspector(client: RuntimeSchemaClient): SiteSchemaInspe
};
}
function splitSqlStatements(sqlText: string): string[] {
const withoutCommentLines = sqlText
.split(/\r?\n/g)
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
return withoutCommentLines
.split(/;\s*(?:\r?\n|$)/g)
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
function readSchemaContract(): SchemaContract {
return JSON.parse(readFileSync(resolveGeneratedArtifactPath('schemaContract.json'), 'utf8')) as SchemaContract;
}
function readGeneratedBootstrapStatements(dialect: Exclude<RuntimeSchemaDialect, 'sqlite'>): string[] {
const filename = dialect === 'mysql' ? 'mysql.bootstrap.sql' : 'postgres.bootstrap.sql';
return splitSqlStatements(readFileSync(resolveGeneratedArtifactPath(filename), 'utf8'));
}
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
const clientOptions: pg.ClientConfig = { connectionString };
if (ssl) {
@@ -185,66 +207,15 @@ export async function createRuntimeSchemaClient(input: RuntimeSchemaConnectionIn
}
export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): Promise<void> {
const statements = client.dialect === 'postgres'
? [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "custom_headers" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_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 "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, "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 "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 "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
]
: client.dialect === 'mysql'
? [
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`custom_headers\` TEXT NULL, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
`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 \`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, \`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 \`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 \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
]
: [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "custom_headers" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_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 "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, "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 "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 "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
];
const statements = client.dialect === 'sqlite'
? splitSqlStatements(generateBootstrapSql('sqlite', readSchemaContract()))
: readGeneratedBootstrapStatements(client.dialect);
for (const sqlText of statements) {
await client.execute(sqlText);
}
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client));
await ensureAccountTokenSchemaCompatibility(createSiteSchemaInspector(client));
await ensureSharedIndexSchemaCompatibility(createSiteSchemaInspector(client));
await ensureLegacySchemaCompatibility(createLegacySchemaInspector(client));
}
export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise<void> {
@@ -255,3 +226,7 @@ export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnect
await client.close();
}
}
export const __runtimeSchemaBootstrapTestUtils = {
readGeneratedBootstrapStatements,
};