forked from boos/BoosAPI
feat: add proxy log client detection filters
This commit is contained in:
@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NU
|
||||
CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted_at` DATETIME);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON, `downstream_api_key_id` INT);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON, `downstream_api_key_id` INT, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL 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` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` DATETIME, `routing_strategy` VARCHAR(191) DEFAULT 'weighted', `route_mode` VARCHAR(191) DEFAULT 'pattern');
|
||||
CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `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` DATETIME, `last_selected_at` DATETIME, `last_fail_at` DATETIME, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` DATETIME, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
|
||||
@@ -45,6 +45,8 @@ CREATE INDEX `model_availability_account_available_idx` ON `model_availability`
|
||||
CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191));
|
||||
CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`);
|
||||
CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`);
|
||||
CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`);
|
||||
CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`);
|
||||
CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`);
|
||||
CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`);
|
||||
CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`);
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` DATETIME, `ends_at` DATETIME, `upstream_created_at` DATETIME, `upstream_updated_at` DATETIME, `first_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `read_at` DATETIME, `dismissed_at` DATETIME, `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191));
|
||||
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
|
||||
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`);
|
||||
-- no schema changes detected for mysql
|
||||
|
||||
@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFA
|
||||
CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL 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, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB, "downstream_api_key_id" INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL 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, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB, "downstream_api_key_id" INTEGER, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL 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" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TIMESTAMP, "routing_strategy" TEXT DEFAULT 'weighted', "route_mode" TEXT DEFAULT 'pattern');
|
||||
CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "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" TIMESTAMP, "last_selected_at" TIMESTAMP, "last_fail_at" TIMESTAMP, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
|
||||
@@ -45,6 +45,8 @@ CREATE INDEX "model_availability_account_available_idx" ON "model_availability"
|
||||
CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model_name");
|
||||
CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at");
|
||||
CREATE INDEX "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at");
|
||||
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
|
||||
CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at");
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TIMESTAMP, "ends_at" TIMESTAMP, "upstream_created_at" TIMESTAMP, "upstream_updated_at" TIMESTAMP, "first_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "last_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "read_at" TIMESTAMP, "dismissed_at" TIMESTAMP, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key");
|
||||
CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at");
|
||||
CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at");
|
||||
-- no schema changes detected for postgres
|
||||
|
||||
@@ -653,6 +653,30 @@
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"client_family": {
|
||||
"logicalType": "text",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"client_app_id": {
|
||||
"logicalType": "text",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"client_app_name": {
|
||||
"logicalType": "text",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"client_confidence": {
|
||||
"logicalType": "text",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1456,6 +1480,24 @@
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "proxy_logs_client_app_id_created_at_idx",
|
||||
"table": "proxy_logs",
|
||||
"columns": [
|
||||
"client_app_id",
|
||||
"created_at"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "proxy_logs_client_family_created_at_idx",
|
||||
"table": "proxy_logs",
|
||||
"columns": [
|
||||
"client_family",
|
||||
"created_at"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "proxy_logs_created_at_idx",
|
||||
"table": "proxy_logs",
|
||||
|
||||
@@ -44,6 +44,7 @@ let mysqlPool: mysql.Pool | null = null;
|
||||
let pgPool: pg.Pool | null = null;
|
||||
let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
|
||||
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
|
||||
let proxyLogClientColumnsAvailable: boolean | null = null;
|
||||
|
||||
function resolveSqlitePath(): string {
|
||||
const raw = (config.dbUrl || '').trim();
|
||||
@@ -120,6 +121,13 @@ function tableColumnExists(table: string, column: string): boolean {
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
function tableIndexExists(indexName: string): boolean {
|
||||
const sqlite = requireSqliteConnection();
|
||||
const row = sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1")
|
||||
.get(indexName) as { name?: string } | undefined;
|
||||
return !!row?.name;
|
||||
}
|
||||
|
||||
function execSqliteStatement(sqlText: string): void {
|
||||
requireSqliteConnection().exec(sqlText);
|
||||
}
|
||||
@@ -588,6 +596,40 @@ function ensureProxyLogDownstreamApiKeyIdSchema() {
|
||||
proxyLogDownstreamApiKeyIdColumnAvailable = true;
|
||||
}
|
||||
|
||||
function ensureProxyLogClientSchema() {
|
||||
if (!tableExists('proxy_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tableColumnExists('proxy_logs', 'client_family')) {
|
||||
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_family text;');
|
||||
}
|
||||
if (!tableColumnExists('proxy_logs', 'client_app_id')) {
|
||||
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;');
|
||||
}
|
||||
if (!tableColumnExists('proxy_logs', 'client_app_name')) {
|
||||
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_app_name text;');
|
||||
}
|
||||
if (!tableColumnExists('proxy_logs', 'client_confidence')) {
|
||||
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_confidence text;');
|
||||
}
|
||||
|
||||
if (!tableIndexExists('proxy_logs_client_app_id_created_at_idx')) {
|
||||
execSqliteLegacyCompat(`
|
||||
CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
|
||||
ON proxy_logs(client_app_id, created_at);
|
||||
`);
|
||||
}
|
||||
if (!tableIndexExists('proxy_logs_client_family_created_at_idx')) {
|
||||
execSqliteLegacyCompat(`
|
||||
CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
|
||||
ON proxy_logs(client_family, created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
proxyLogClientColumnsAvailable = true;
|
||||
}
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
@@ -600,6 +642,14 @@ function isDuplicateColumnError(error: unknown): boolean {
|
||||
return lowered.includes('duplicate column') || lowered.includes('already exists');
|
||||
}
|
||||
|
||||
function isDuplicateIndexError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate key name')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('relation')
|
||||
|| lowered.includes('duplicate index');
|
||||
}
|
||||
|
||||
export async function hasProxyLogBillingDetailsColumn(): Promise<boolean> {
|
||||
if (proxyLogBillingDetailsColumnAvailable !== null) {
|
||||
return proxyLogBillingDetailsColumnAvailable;
|
||||
@@ -732,9 +782,183 @@ export async function ensureProxyLogDownstreamApiKeyIdColumn(): Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
async function hasMysqlIndex(indexName: string): Promise<boolean> {
|
||||
if (!mysqlPool) return false;
|
||||
const [rows] = await mysqlPool.query(
|
||||
'SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1',
|
||||
['proxy_logs', indexName],
|
||||
);
|
||||
return Array.isArray(rows) && rows.length > 0;
|
||||
}
|
||||
|
||||
async function hasPostgresIndex(indexName: string): Promise<boolean> {
|
||||
if (!pgPool) return false;
|
||||
const result = await pgPool.query(
|
||||
'SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND tablename = $1 AND indexname = $2 LIMIT 1',
|
||||
['proxy_logs', indexName],
|
||||
);
|
||||
return Number(result.rowCount || 0) > 0;
|
||||
}
|
||||
|
||||
export async function hasProxyLogClientColumns(): Promise<boolean> {
|
||||
if (proxyLogClientColumnsAvailable !== null) {
|
||||
return proxyLogClientColumnsAvailable;
|
||||
}
|
||||
|
||||
const requiredColumns = [
|
||||
'client_family',
|
||||
'client_app_id',
|
||||
'client_app_name',
|
||||
'client_confidence',
|
||||
];
|
||||
|
||||
if (runtimeDbDialect === 'sqlite') {
|
||||
proxyLogClientColumnsAvailable = tableExists('proxy_logs')
|
||||
&& requiredColumns.every((columnName) => tableColumnExists('proxy_logs', columnName));
|
||||
return proxyLogClientColumnsAvailable;
|
||||
}
|
||||
|
||||
if (runtimeDbDialect === 'mysql') {
|
||||
if (!mysqlPool) return false;
|
||||
const [rows] = await mysqlPool.query(
|
||||
'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name IN (?, ?, ?, ?)',
|
||||
['proxy_logs', ...requiredColumns],
|
||||
) as [Array<{ column_name?: string }>, unknown];
|
||||
const available = new Set(
|
||||
Array.isArray(rows)
|
||||
? rows.map((row) => String(row?.column_name || '').trim().toLowerCase()).filter(Boolean)
|
||||
: [],
|
||||
);
|
||||
proxyLogClientColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName));
|
||||
return proxyLogClientColumnsAvailable;
|
||||
}
|
||||
|
||||
if (!pgPool) return false;
|
||||
const result = await pgPool.query(
|
||||
'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = ANY($2::text[])',
|
||||
['proxy_logs', requiredColumns],
|
||||
);
|
||||
const available = new Set(
|
||||
result.rows.map((row) => String((row as { column_name?: string }).column_name || '').trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
proxyLogClientColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName));
|
||||
return proxyLogClientColumnsAvailable;
|
||||
}
|
||||
|
||||
export async function ensureProxyLogClientColumns(): Promise<boolean> {
|
||||
const requiredColumns = [
|
||||
{ name: 'client_family', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
|
||||
{ name: 'client_app_id', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
|
||||
{ name: 'client_app_name', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
|
||||
{ name: 'client_confidence', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
|
||||
];
|
||||
const requiredIndexes = [
|
||||
{
|
||||
name: 'proxy_logs_client_app_id_created_at_idx',
|
||||
sqliteSql: 'CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);',
|
||||
mysqlSql: 'CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`(191))',
|
||||
postgresSql: 'CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at")',
|
||||
},
|
||||
{
|
||||
name: 'proxy_logs_client_family_created_at_idx',
|
||||
sqliteSql: 'CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx ON proxy_logs(client_family, created_at);',
|
||||
mysqlSql: 'CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`(191))',
|
||||
postgresSql: 'CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at")',
|
||||
},
|
||||
];
|
||||
|
||||
if (runtimeDbDialect === 'sqlite') {
|
||||
ensureProxyLogClientSchema();
|
||||
proxyLogClientColumnsAvailable = tableExists('proxy_logs')
|
||||
&& requiredColumns.every((column) => tableColumnExists('proxy_logs', column.name));
|
||||
return proxyLogClientColumnsAvailable;
|
||||
}
|
||||
|
||||
if (await hasProxyLogClientColumns()) {
|
||||
for (const requiredIndex of requiredIndexes) {
|
||||
const indexExists = runtimeDbDialect === 'mysql'
|
||||
? await hasMysqlIndex(requiredIndex.name)
|
||||
: await hasPostgresIndex(requiredIndex.name);
|
||||
if (indexExists) continue;
|
||||
try {
|
||||
if (runtimeDbDialect === 'mysql') {
|
||||
if (!mysqlPool) return false;
|
||||
await executeLegacyCompat(
|
||||
(statement) => mysqlPool!.query(statement).then(() => undefined),
|
||||
requiredIndex.mysqlSql,
|
||||
);
|
||||
} else {
|
||||
if (!pgPool) return false;
|
||||
await executeLegacyCompat(
|
||||
(statement) => pgPool!.query(statement).then(() => undefined),
|
||||
requiredIndex.postgresSql,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isDuplicateIndexError(error)) {
|
||||
console.warn(`[db] failed to ensure ${requiredIndex.name}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (runtimeDbDialect === 'mysql') {
|
||||
if (!mysqlPool) return false;
|
||||
for (const column of requiredColumns) {
|
||||
const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', [column.name]);
|
||||
if (Array.isArray(rows) && rows.length > 0) continue;
|
||||
await executeLegacyCompat(
|
||||
(statement) => mysqlPool!.query(statement).then(() => undefined),
|
||||
`ALTER TABLE \`proxy_logs\` ADD COLUMN \`${column.name}\` ${column.mysqlType}`,
|
||||
);
|
||||
}
|
||||
for (const requiredIndex of requiredIndexes) {
|
||||
if (await hasMysqlIndex(requiredIndex.name)) continue;
|
||||
await executeLegacyCompat(
|
||||
(statement) => mysqlPool!.query(statement).then(() => undefined),
|
||||
requiredIndex.mysqlSql,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!pgPool) return false;
|
||||
for (const column of requiredColumns) {
|
||||
const result = await pgPool.query(
|
||||
'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1',
|
||||
['proxy_logs', column.name],
|
||||
);
|
||||
if (Number(result.rowCount || 0) > 0) continue;
|
||||
await executeLegacyCompat(
|
||||
(statement) => pgPool!.query(statement).then(() => undefined),
|
||||
`ALTER TABLE "proxy_logs" ADD COLUMN "${column.name}" ${column.postgresType}`,
|
||||
);
|
||||
}
|
||||
for (const requiredIndex of requiredIndexes) {
|
||||
if (await hasPostgresIndex(requiredIndex.name)) continue;
|
||||
await executeLegacyCompat(
|
||||
(statement) => pgPool!.query(statement).then(() => undefined),
|
||||
requiredIndex.postgresSql,
|
||||
);
|
||||
}
|
||||
}
|
||||
proxyLogClientColumnsAvailable = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDuplicateColumnError(error) || isDuplicateIndexError(error)) {
|
||||
proxyLogClientColumnsAvailable = await hasProxyLogClientColumns();
|
||||
return proxyLogClientColumnsAvailable;
|
||||
}
|
||||
proxyLogClientColumnsAvailable = false;
|
||||
console.warn('[db] failed to ensure proxy_logs client columns', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetSchemaCapabilityCache() {
|
||||
proxyLogBillingDetailsColumnAvailable = null;
|
||||
proxyLogDownstreamApiKeyIdColumnAvailable = null;
|
||||
proxyLogClientColumnsAvailable = null;
|
||||
}
|
||||
|
||||
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
|
||||
@@ -995,6 +1219,7 @@ function initSqliteDb() {
|
||||
ensureRouteGroupingSchema();
|
||||
ensureDownstreamApiKeySchema();
|
||||
ensureProxyLogBillingDetailsSchema();
|
||||
ensureProxyLogClientSchema();
|
||||
ensureProxyVideoTaskSchema();
|
||||
ensureProxyFileSchema();
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ 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('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('CREATE INDEX proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);')).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');
|
||||
|
||||
@@ -66,6 +66,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([
|
||||
'downstream_api_keys.group_name',
|
||||
'downstream_api_keys.tags',
|
||||
'proxy_logs.billing_details',
|
||||
'proxy_logs.client_family',
|
||||
'proxy_logs.client_app_id',
|
||||
'proxy_logs.client_app_name',
|
||||
'proxy_logs.client_confidence',
|
||||
'proxy_logs.downstream_api_key_id',
|
||||
]);
|
||||
|
||||
@@ -81,6 +85,8 @@ const LEGACY_COMPAT_INDEXES = new Set([
|
||||
'downstream_api_keys_name_idx',
|
||||
'downstream_api_keys_enabled_idx',
|
||||
'downstream_api_keys_expires_at_idx',
|
||||
'proxy_logs_client_app_id_created_at_idx',
|
||||
'proxy_logs_client_family_created_at_idx',
|
||||
'proxy_logs_downstream_api_key_created_at_idx',
|
||||
'route_group_sources_group_source_unique',
|
||||
'route_group_sources_source_route_id_idx',
|
||||
|
||||
@@ -193,6 +193,10 @@ export const proxyLogs = sqliteTable('proxy_logs', {
|
||||
totalTokens: integer('total_tokens'),
|
||||
estimatedCost: real('estimated_cost'),
|
||||
billingDetails: text('billing_details'),
|
||||
clientFamily: text('client_family'),
|
||||
clientAppId: text('client_app_id'),
|
||||
clientAppName: text('client_app_name'),
|
||||
clientConfidence: text('client_confidence'),
|
||||
errorMessage: text('error_message'),
|
||||
retryCount: integer('retry_count').default(0),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
@@ -202,6 +206,8 @@ export const proxyLogs = sqliteTable('proxy_logs', {
|
||||
statusCreatedIdx: index('proxy_logs_status_created_at_idx').on(table.status, table.createdAt),
|
||||
modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt),
|
||||
downstreamKeyCreatedIdx: index('proxy_logs_downstream_api_key_created_at_idx').on(table.downstreamApiKeyId, table.createdAt),
|
||||
clientAppCreatedIdx: index('proxy_logs_client_app_id_created_at_idx').on(table.clientAppId, table.createdAt),
|
||||
clientFamilyCreatedIdx: index('proxy_logs_client_family_created_at_idx').on(table.clientFamily, table.createdAt),
|
||||
}));
|
||||
|
||||
export const proxyVideoTasks = sqliteTable('proxy_video_tasks', {
|
||||
|
||||
@@ -85,10 +85,18 @@ describe('database schema parity', () => {
|
||||
const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8');
|
||||
|
||||
expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer');
|
||||
expect(contract.tables.proxy_logs?.columns.client_app_id?.logicalType).toBe('text');
|
||||
expect(contract.tables.proxy_logs?.columns.client_family?.logicalType).toBe('text');
|
||||
expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true);
|
||||
expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_app_id_created_at_idx')).toBe(true);
|
||||
expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_family_created_at_idx')).toBe(true);
|
||||
expect(mysqlBootstrap).toContain('`downstream_api_key_id`');
|
||||
expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`');
|
||||
expect(mysqlBootstrap).toContain('`client_app_id`');
|
||||
expect(mysqlBootstrap).toContain('`proxy_logs_client_app_id_created_at_idx`');
|
||||
expect(postgresBootstrap).toContain('"downstream_api_key_id"');
|
||||
expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"');
|
||||
expect(postgresBootstrap).toContain('"client_app_id"');
|
||||
expect(postgresBootstrap).toContain('"proxy_logs_client_app_id_created_at_idx"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user