diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 71bbf9d..35f8089 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -8,7 +8,6 @@ import * as schema from './schema.js'; import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js'; 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'; @@ -447,12 +446,6 @@ export async function ensureProxyFileCompatibilityColumns(): Promise { await ensureProxyFileSchemaCompatibility(inspector); } -export async function ensureSharedIndexCompatibility(): Promise { - const inspector = createRuntimeSchemaInspector(); - if (!inspector) return; - await ensureSharedIndexSchemaCompatibility(inspector); -} - function ensureRouteGroupingSchema() { if (!tableExists('token_routes') || !tableExists('route_channels')) { return; diff --git a/src/server/db/runtimeSchemaBootstrap.test.ts b/src/server/db/runtimeSchemaBootstrap.test.ts index ec31d52..f47e790 100644 --- a/src/server/db/runtimeSchemaBootstrap.test.ts +++ b/src/server/db/runtimeSchemaBootstrap.test.ts @@ -2,6 +2,7 @@ import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaCon import currentContract from './generated/schemaContract.json' with { type: 'json' }; import { classifyLegacyCompatMutation } from './legacySchemaCompat.js'; import { generateUpgradeSql } from './schemaArtifactGenerator.js'; +import type { SchemaContract, SchemaContractColumn } from './schemaContract.js'; import { describe, expect, it } from 'vitest'; import { __runtimeSchemaBootstrapTestUtils, @@ -17,6 +18,9 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): commit: async () => {}, rollback: async () => {}, execute: async (sqlText: string) => { + if (sqlText.trim().toLowerCase().startsWith('select')) { + return []; + } executedSql.push(sqlText); return []; }, @@ -33,6 +37,16 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): }; } +function makeColumn(overrides: Partial = {}): SchemaContractColumn { + return { + logicalType: 'text', + notNull: false, + defaultValue: null, + primaryKey: false, + ...overrides, + }; +} + describe('runtime schema bootstrap', () => { it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => { const executedSql: string[] = []; @@ -149,4 +163,72 @@ describe('runtime schema bootstrap', () => { expect(executedSql).toContain(targetSql); }); + + it('adds mysql text prefixes for new indexes when live datetime-like columns are still stored as text', async () => { + const executedSql: string[] = []; + const minimalContract: SchemaContract = { + tables: { + proxy_logs: { + columns: { + downstream_api_key_id: makeColumn({ logicalType: 'integer' }), + created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }), + }, + }, + }, + indexes: [ + { + name: 'proxy_logs_downstream_api_key_created_at_idx', + table: 'proxy_logs', + columns: ['downstream_api_key_id', 'created_at'], + unique: false, + }, + ], + uniques: [], + foreignKeys: [], + }; + + await ensureRuntimeDatabaseSchema({ + ...createStubClient('mysql', executedSql), + execute: async (sqlText: string) => { + if (sqlText.includes('FROM information_schema.columns')) { + return [[ + { + table_name: 'proxy_logs', + column_name: 'downstream_api_key_id', + data_type: 'int', + column_type: 'int', + }, + { + table_name: 'proxy_logs', + column_name: 'created_at', + data_type: 'text', + column_type: 'text', + }, + ]]; + } + + executedSql.push(sqlText); + return []; + }, + queryScalar: async (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes('information_schema.tables')) { + return params[0] === 'proxy_logs' ? 1 : 0; + } + if (sqlText.includes('information_schema.columns')) { + return 1; + } + return 0; + }, + }, { + currentContract: minimalContract, + liveContract: { + ...minimalContract, + indexes: [], + }, + }); + + expect(executedSql).toContain( + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + ); + }); }); diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 1838527..b664af5 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -7,7 +7,11 @@ import { ensureLegacySchemaCompatibility, type LegacySchemaCompatInspector, } from './legacySchemaCompat.js'; -import { generateBootstrapSql, generateUpgradeSql } from './schemaArtifactGenerator.js'; +import { + generateBootstrapSql, + generateUpgradeSql, + type MysqlIndexPrefixRequirementMap, +} from './schemaArtifactGenerator.js'; import { introspectLiveSchema } from './schemaIntrospection.js'; import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js'; @@ -233,6 +237,92 @@ function buildCompatibleRuntimeBaseline( return baseline; } +function collectIndexedColumns(contract: SchemaContract): Map> { + const indexedColumns = new Map>(); + + for (const index of [...contract.indexes, ...contract.uniques]) { + let columns = indexedColumns.get(index.table); + if (!columns) { + columns = new Set(); + indexedColumns.set(index.table, columns); + } + + for (const columnName of index.columns) { + columns.add(columnName); + } + } + + return indexedColumns; +} + +function requiresMysqlIndexPrefixForColumnType(columnType: string): boolean { + const normalizedType = columnType.trim().toLowerCase(); + return normalizedType.includes('text') || normalizedType.includes('blob'); +} + +async function queryRuntimeRows( + client: RuntimeSchemaClient, + sqlText: string, + params: unknown[] = [], +): Promise>> { + const result = await client.execute(sqlText, params); + + if (!Array.isArray(result)) { + return []; + } + + const [first] = result; + if (Array.isArray(first)) { + return first as Array>; + } + + if (result.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) { + return result as Array>; + } + + return []; +} + +async function resolveMySqlIndexPrefixRequirements( + client: RuntimeSchemaClient, + currentContract: SchemaContract, +): Promise { + const indexedColumns = collectIndexedColumns(currentContract); + if (indexedColumns.size === 0) { + return {}; + } + + const rows = await queryRuntimeRows(client, ` + SELECT + table_name AS table_name, + column_name AS column_name, + data_type AS data_type, + column_type AS column_type + FROM information_schema.columns + WHERE table_schema = DATABASE() + `); + + const requirements: MysqlIndexPrefixRequirementMap = {}; + for (const row of rows) { + const tableName = String(row.table_name || ''); + const columnName = String(row.column_name || ''); + const trackedColumns = indexedColumns.get(tableName); + if (!trackedColumns || !trackedColumns.has(columnName)) { + continue; + } + + const declaredType = String(row.column_type || row.data_type || ''); + if (!requiresMysqlIndexPrefixForColumnType(declaredType)) { + continue; + } + + requirements[tableName] ??= {}; + requirements[tableName][columnName] = true; + } + + return requirements; +} + async function createPostgresClient(connectionString: string, ssl: boolean): Promise { const clientOptions: pg.ClientConfig = { connectionString }; if (ssl) { @@ -346,9 +436,12 @@ function buildExternalUpgradeStatements( dialect: Exclude, currentContract: SchemaContract, liveContract: SchemaContract, + mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap, ): string[] { const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract); - return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline)); + return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline, { + mysqlIndexPrefixRequirements, + })); } export async function ensureRuntimeDatabaseSchema( @@ -356,13 +449,23 @@ export async function ensureRuntimeDatabaseSchema( options: EnsureRuntimeDatabaseSchemaOptions = {}, ): Promise { const currentContract = options.currentContract ?? readSchemaContract(); - const statements = client.dialect === 'sqlite' - ? splitSqlStatements(generateBootstrapSql('sqlite', currentContract)) - : buildExternalUpgradeStatements( + let statements: string[]; + + if (client.dialect === 'sqlite') { + statements = splitSqlStatements(generateBootstrapSql('sqlite', currentContract)); + } else { + const liveContract = await resolveLiveContract(client, options.liveContract); + const mysqlIndexPrefixRequirements = client.dialect === 'mysql' + ? await resolveMySqlIndexPrefixRequirements(client, currentContract) + : undefined; + + statements = buildExternalUpgradeStatements( client.dialect, currentContract, - await resolveLiveContract(client, options.liveContract), + liveContract, + mysqlIndexPrefixRequirements, ); + } for (const sqlText of statements) { await executeBootstrapStatement(client, sqlText); diff --git a/src/server/db/schemaArtifactGenerator.test.ts b/src/server/db/schemaArtifactGenerator.test.ts index 8d6378d..8738516 100644 --- a/src/server/db/schemaArtifactGenerator.test.ts +++ b/src/server/db/schemaArtifactGenerator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { generateDialectArtifacts, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js'; +import { generateDialectArtifacts, generateUpgradeSql, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js'; import type { SchemaContract, SchemaContractColumn } from './schemaContract.js'; const dbDir = dirname(fileURLToPath(import.meta.url)); @@ -123,6 +123,45 @@ describe('schema artifact generator', () => { expect(artifacts.mysqlBootstrap).not.toContain('`read`(191)'); }); + it('allows mysql upgrade generation to force prefix lengths for live text-backed columns', () => { + const previousContract: SchemaContract = { + tables: { + proxy_logs: { + columns: { + downstream_api_key_id: makeColumn({ logicalType: 'integer' }), + created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }), + }, + }, + }, + indexes: [], + uniques: [], + foreignKeys: [], + }; + const currentContract: SchemaContract = { + ...previousContract, + indexes: [ + { + name: 'proxy_logs_downstream_api_key_created_at_idx', + table: 'proxy_logs', + columns: ['downstream_api_key_id', 'created_at'], + unique: false, + }, + ], + }; + + const mysqlUpgrade = generateUpgradeSql('mysql', currentContract, previousContract, { + mysqlIndexPrefixRequirements: { + proxy_logs: { + created_at: true, + }, + }, + }); + + expect(mysqlUpgrade).toContain( + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + ); + }); + it('rejects destructive diffs when generating additive upgrades', () => { const current = readSchemaContract(); const previous = structuredClone(current); diff --git a/src/server/db/schemaArtifactGenerator.ts b/src/server/db/schemaArtifactGenerator.ts index c29d16b..dd898e5 100644 --- a/src/server/db/schemaArtifactGenerator.ts +++ b/src/server/db/schemaArtifactGenerator.ts @@ -17,8 +17,13 @@ export interface GeneratedDialectArtifacts { postgresUpgrade: string; } +export type MysqlIndexPrefixRequirementMap = Record>; + type Dialect = 'mysql' | 'postgres'; type SqlDialect = 'sqlite' | Dialect; +type SqlGenerationOptions = { + mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap; +}; function resolveDbDir(): string { return dirname(fileURLToPath(import.meta.url)); @@ -36,6 +41,21 @@ function escapeMysqlTextPrefix(columnType: LogicalColumnType): string { return columnType === 'text' ? '(191)' : ''; } +function resolveMysqlIndexPrefix( + tableName: string, + columnName: string, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { + const override = options?.mysqlIndexPrefixRequirements?.[tableName]?.[columnName]; + if (override !== undefined) { + return override ? '(191)' : ''; + } + + const column = contract.tables[tableName]?.columns[columnName]; + return column ? escapeMysqlTextPrefix(column.logicalType) : ''; +} + function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string { if (dialect === 'sqlite') { switch (column.logicalType) { @@ -177,22 +197,34 @@ function buildCreateTableStatement( return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`; } -function buildUniqueIndexStatement(dialect: SqlDialect, uniqueIndex: SchemaContractUnique, contract: SchemaContract): string { +function buildUniqueIndexStatement( + dialect: SqlDialect, + uniqueIndex: SchemaContractUnique, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { const columns = uniqueIndex.columns .map((columnName) => { - const column = contract.tables[uniqueIndex.table]?.columns[columnName]; - const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : ''; + const suffix = dialect === 'mysql' + ? resolveMysqlIndexPrefix(uniqueIndex.table, columnName, contract, options) + : ''; return `${quoteIdentifier(dialect, columnName)}${suffix}`; }) .join(', '); return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`; } -function buildIndexStatement(dialect: SqlDialect, index: SchemaContractIndex, contract: SchemaContract): string { +function buildIndexStatement( + dialect: SqlDialect, + index: SchemaContractIndex, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { const columns = index.columns .map((columnName) => { - const column = contract.tables[index.table]?.columns[columnName]; - const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : ''; + const suffix = dialect === 'mysql' + ? resolveMysqlIndexPrefix(index.table, columnName, contract, options) + : ''; return `${quoteIdentifier(dialect, columnName)}${suffix}`; }) .join(', '); @@ -317,6 +349,7 @@ export function generateUpgradeSql( dialect: SqlDialect, currentContract: SchemaContract, previousContract?: SchemaContract | null, + options?: SqlGenerationOptions, ): string { if (!previousContract) { return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`; @@ -357,7 +390,7 @@ export function generateUpgradeSql( .filter((unique) => !previousUniqueNames.has(unique.name)) .slice() .sort((left, right) => left.name.localeCompare(right.name, 'en')) - .map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract)); + .map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract, options)); const currentUniqueNames = new Set(currentContract.uniques.map((unique) => unique.name)); const previousIndexNames = new Set(previousContract.indexes.map((index) => index.name)); @@ -366,7 +399,7 @@ export function generateUpgradeSql( .filter((index) => !previousIndexNames.has(index.name)) .slice() .sort((left, right) => left.name.localeCompare(right.name, 'en')) - .map((index) => buildIndexStatement(dialect, index, currentContract)); + .map((index) => buildIndexStatement(dialect, index, currentContract, options)); const statements = [...addedTableStatements, ...addColumnStatements, ...uniqueStatements, ...indexStatements]; if (statements.length === 0) { diff --git a/src/server/db/schemaParity.test.ts b/src/server/db/schemaParity.test.ts index d3f18b4..8a848d5 100644 --- a/src/server/db/schemaParity.test.ts +++ b/src/server/db/schemaParity.test.ts @@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { SchemaContract } from './schemaContract.js'; +import { SHARED_INDEX_COMPATIBILITY_SPECS } from './sharedIndexSchemaCompatibility.js'; const dbDir = dirname(fileURLToPath(import.meta.url)); const generatedDir = resolve(dbDir, 'generated'); @@ -63,4 +64,18 @@ describe('database schema parity', () => { expect(unknownTables).toEqual([]); expect(unknownIndexes).toEqual([]); }); + + it('does not duplicate contract-defined indexes inside shared index compatibility specs', () => { + const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + const contractIndexNames = new Set([ + ...contract.indexes.map((index) => index.name), + ...contract.uniques.map((unique) => unique.name), + ]); + + const duplicatedSpecs = SHARED_INDEX_COMPATIBILITY_SPECS + .map((spec) => spec.indexName) + .filter((indexName) => contractIndexNames.has(indexName)); + + expect(duplicatedSpecs).toEqual([]); + }); }); diff --git a/src/server/db/sharedIndexSchemaCompatibility.test.ts b/src/server/db/sharedIndexSchemaCompatibility.test.ts index c234e24..e7ceed5 100644 --- a/src/server/db/sharedIndexSchemaCompatibility.test.ts +++ b/src/server/db/sharedIndexSchemaCompatibility.test.ts @@ -25,33 +25,10 @@ function createInspector( 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 `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `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 "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at")', - 'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")', - ], - }, - ])('creates shared indexes for $dialect', async ({ dialect, expectedSql }) => { + 'sqlite', + 'mysql', + 'postgres', + ] as const)('is a no-op for %s now that contract-defined indexes come from runtime bootstrap', async (dialect) => { const { inspector, executedSql } = createInspector(dialect, { existingTables: [ 'sites', @@ -70,10 +47,7 @@ describe('ensureSharedIndexSchemaCompatibility', () => { }); await ensureSharedIndexSchemaCompatibility(inspector); - - for (const sqlText of expectedSql) { - expect(executedSql).toContain(sqlText); - } + expect(executedSql).toEqual([]); }); it('skips indexes for tables that do not exist', async () => { @@ -83,8 +57,6 @@ describe('ensureSharedIndexSchemaCompatibility', () => { await ensureSharedIndexSchemaCompatibility(inspector); - expect(executedSql).toEqual([ - 'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")', - ]); + expect(executedSql).toEqual([]); }); }); diff --git a/src/server/db/sharedIndexSchemaCompatibility.ts b/src/server/db/sharedIndexSchemaCompatibility.ts index 04a5c8d..f7ddd45 100644 --- a/src/server/db/sharedIndexSchemaCompatibility.ts +++ b/src/server/db/sharedIndexSchemaCompatibility.ts @@ -12,341 +12,10 @@ type SharedIndexCompatibilitySpec = { createSql: Record; }; -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_logs', - indexName: 'proxy_logs_downstream_api_key_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_downstream_api_key_created_at_idx ON proxy_logs (downstream_api_key_id, created_at);', - mysql: 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "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")', - }, - }, -]; +// Contract-defined indexes are owned by generated schema artifacts and +// runtimeSchemaBootstrap. This legacy hook is intentionally empty so startup +// does not maintain a second static source of index SQL. +export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = []; function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { diff --git a/src/server/index.ts b/src/server/index.ts index 0274891..b2179ec 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -36,7 +36,6 @@ import { ensureProxyLogDownstreamApiKeyIdColumn, ensureProxyLogBillingDetailsColumn, ensureRouteGroupingCompatibilityColumns, - ensureSharedIndexCompatibility, ensureSiteCompatibilityColumns, runtimeDbDialect, schema, @@ -267,7 +266,6 @@ try { await ensureRouteGroupingCompatibilityColumns(); await ensureProxyFileCompatibilityColumns(); await ensureProxyLogDownstreamApiKeyIdColumn(); - await ensureSharedIndexCompatibility(); const finalRows = await db.select().from(schema.settings).all(); const finalMap = toSettingsMap(finalRows); applyRuntimeSettings(finalMap); diff --git a/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts b/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts new file mode 100644 index 0000000..735adc1 --- /dev/null +++ b/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts @@ -0,0 +1,115 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('account token coverage refresh failure handling', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-failure-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const accountTokenRoutesModule = await import('./accountTokens.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(accountTokenRoutesModule.accountTokensRoutes); + }); + + beforeEach(async () => { + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('returns success after token persistence even when coverage rebuild fails', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-1', + url: 'https://site-1.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-1', + accessToken: 'acc-token-1', + status: 'active', + }).returning().get(); + + refreshModelsForAccountMock.mockResolvedValue({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: 1, + modelsPreview: ['gpt-4o-mini'], + tokenScanned: 1, + discoveredByCredential: false, + discoveredApiToken: false, + }); + rebuildTokenRoutesFromAvailabilityMock.mockRejectedValue(new Error('rebuild failed')); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'manual-token', + token: 'sk-manual-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + coverageRefresh: { + rebuild: { + success: false, + error: 'rebuild failed', + }, + }, + }); + + const tokens = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokens).toHaveLength(1); + expect(tokens[0]?.token).toBe('sk-manual-token'); + }); +}); diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 1a7c400..6a0407e 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -14,7 +14,11 @@ import { import { getAdapter } from '../../services/platforms/index.js'; import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; -import { rebuildTokenRoutesFromAvailability, refreshModelsForAccount } from '../../services/modelService.js'; +import { + rebuildTokenRoutesFromAvailability, + refreshModelsForAccount, + type ModelRefreshResult, +} from '../../services/modelService.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -39,6 +43,25 @@ type SyncExecutionResult = { defaultTokenId?: number | null; }; +type CoverageRefreshFailureItem = { + accountId: number; + refreshed: false; + status: 'failed'; + errorCode: 'coverage_refresh_failed'; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + reason: 'coverage_refresh_failed'; + tokenScanned: 0; + discoveredByCredential: false; + discoveredApiToken: false; +}; + +type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem; +type CoverageRefreshRebuildResult = + | { success: true; result: Awaited> } + | { success: false; error: string }; + const TOKEN_SYNC_TIMEOUT_MS = 15_000; const SYNC_ALL_BATCH_SIZE = 3; @@ -365,13 +388,62 @@ async function refreshCoverageForAccounts(accountIds: number[]) { return { refresh: [], rebuild: null }; } - const refresh = await Promise.all( - uniqueAccountIds.map(async (accountId) => refreshModelsForAccount(accountId)), - ); - const rebuild = await rebuildTokenRoutesFromAvailability(); + const refresh: CoverageRefreshItem[] = []; + for (let offset = 0; offset < uniqueAccountIds.length; offset += SYNC_ALL_BATCH_SIZE) { + const batch = uniqueAccountIds.slice(offset, offset + SYNC_ALL_BATCH_SIZE); + const settled = await Promise.allSettled( + batch.map(async (accountId) => refreshModelsForAccount(accountId)), + ); + settled.forEach((result, index) => { + if (result.status === 'fulfilled') { + refresh.push(result.value); + return; + } + + const accountId = batch[index] || 0; + const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason || 'coverage refresh failed'); + refresh.push(buildCoverageRefreshFailureItem(accountId, errorMessage)); + console.warn(`[account-tokens] coverage refresh failed for account ${accountId}: ${errorMessage}`); + }); + } + + let rebuild: CoverageRefreshRebuildResult | null = null; + try { + rebuild = { + success: true, + result: await rebuildTokenRoutesFromAvailability(), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed'); + rebuild = { + success: false, + error: errorMessage, + }; + console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${errorMessage}`); + } + return { refresh, rebuild }; } +function buildCoverageRefreshFailureItem( + accountId: number, + errorMessage: string, +): CoverageRefreshFailureItem { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'coverage_refresh_failed', + errorMessage, + modelCount: 0, + modelsPreview: [], + reason: 'coverage_refresh_failed', + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }; +} + export async function accountTokensRoutes(app: FastifyInstance) { app.get<{ Querystring: { accountId?: string } }>('/api/account-tokens', async (request) => { const accountId = request.query.accountId ? Number.parseInt(request.query.accountId, 10) : undefined; diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index b97ad79..17cd49c 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -12,6 +12,61 @@ const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const MODEL_REFRESH_BATCH_SIZE = 3; type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown'; +type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status'; + +export type ModelRefreshAccountNotFoundResult = { + accountId: number; + refreshed: false; + status: 'failed'; + errorCode: 'account_not_found'; + errorMessage: '账号不存在'; + modelCount: 0; + modelsPreview: string[]; + reason: 'account_not_found'; +}; + +export type ModelRefreshSkippedResult = { + accountId: number; + refreshed: false; + status: 'skipped'; + errorCode: ModelRefreshSkipCode; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + reason: ModelRefreshSkipCode; +}; + +export type ModelRefreshFailureResult = { + accountId: number; + refreshed: true; + status: 'failed'; + errorCode: ModelRefreshErrorCode; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}; + +export type ModelRefreshSuccessResult = { + accountId: number; + refreshed: true; + status: 'success'; + errorCode: null; + errorMessage: ''; + modelCount: number; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}; + +export type ModelRefreshResult = + | ModelRefreshAccountNotFoundResult + | ModelRefreshSkippedResult + | ModelRefreshFailureResult + | ModelRefreshSuccessResult; function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode { const lowered = message.toLowerCase(); @@ -64,23 +119,88 @@ async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMe } } -export async function refreshModelsForAccount(accountId: number) { +function buildAccountNotFoundRefreshResult(accountId: number): ModelRefreshAccountNotFoundResult { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'account_not_found', + errorMessage: '账号不存在', + modelCount: 0, + modelsPreview: [], + reason: 'account_not_found', + }; +} + +function buildSkippedRefreshResult( + accountId: number, + code: ModelRefreshSkipCode, + errorMessage: string, +): ModelRefreshSkippedResult { + return { + accountId, + refreshed: false, + status: 'skipped', + errorCode: code, + errorMessage, + modelCount: 0, + modelsPreview: [], + reason: code, + }; +} + +function buildFailedRefreshResult(input: { + accountId: number; + errorCode: ModelRefreshErrorCode; + errorMessage: string; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}): ModelRefreshFailureResult { + return { + accountId: input.accountId, + refreshed: true, + status: 'failed', + errorCode: input.errorCode, + errorMessage: input.errorMessage, + modelCount: 0, + modelsPreview: [], + tokenScanned: input.tokenScanned, + discoveredByCredential: input.discoveredByCredential, + discoveredApiToken: input.discoveredApiToken, + }; +} + +function buildSuccessfulRefreshResult(input: { + accountId: number; + modelCount: number; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}): ModelRefreshSuccessResult { + return { + accountId: input.accountId, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: input.modelCount, + modelsPreview: input.modelsPreview, + tokenScanned: input.tokenScanned, + discoveredByCredential: input.discoveredByCredential, + discoveredApiToken: input.discoveredApiToken, + }; +} + +export async function refreshModelsForAccount(accountId: number): Promise { const row = await db.select().from(schema.accounts) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .where(eq(schema.accounts.id, accountId)) .get(); if (!row) { - return { - accountId, - refreshed: false, - status: 'failed', - errorCode: 'account_not_found', - errorMessage: '账号不存在', - modelCount: 0, - modelsPreview: [], - reason: 'account_not_found', - }; + return buildAccountNotFoundRefreshResult(accountId); } const account = row.accounts; @@ -103,29 +223,11 @@ export async function refreshModelsForAccount(accountId: number) { } if (isSiteDisabled(site.status)) { - return { - accountId, - refreshed: false, - status: 'skipped', - errorCode: 'site_disabled', - errorMessage: '站点已禁用', - modelCount: 0, - modelsPreview: [], - reason: 'site_disabled', - }; + return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用'); } if (!adapter || account.status !== 'active') { - return { - accountId, - refreshed: false, - status: 'skipped', - errorCode: 'adapter_or_status', - errorMessage: '平台不可用或账号未激活', - modelCount: 0, - modelsPreview: [], - reason: 'adapter_or_status', - }; + return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活'); } const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); @@ -266,18 +368,14 @@ export async function refreshModelsForAccount(accountId: number) { source: 'model-discovery', checkedAt: new Date().toISOString(), }); - return { + return buildFailedRefreshResult({ accountId, - refreshed: true, - status: 'failed', errorCode, errorMessage, - modelCount: 0, - modelsPreview: [], tokenScanned: scannedTokenCount, discoveredByCredential, discoveredApiToken: !!discoveredApiToken, - }; + }); } const checkedAt = new Date().toISOString(); @@ -299,26 +397,22 @@ export async function refreshModelsForAccount(accountId: number) { }); const modelsPreview = Array.from(accountModels).slice(0, 10); - return { + return buildSuccessfulRefreshResult({ accountId, - refreshed: true, - status: 'success', - errorCode: null, - errorMessage: '', modelCount: accountModels.size, modelsPreview, tokenScanned: scannedTokenCount, discoveredByCredential, discoveredApiToken: !!discoveredApiToken, - }; + }); } -async function refreshModelsForAllActiveAccounts() { +async function refreshModelsForAllActiveAccounts(): Promise { const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts) .where(eq(schema.accounts.status, 'active')) .all(); - const results: any[] = []; + const results: ModelRefreshResult[] = []; for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) { const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE); const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));