forked from boos/BoosAPI
feat: enhance MySQL upgrade generation with index prefix length control
- Added support for specifying prefix lengths for live text-backed columns in MySQL upgrade SQL generation. - Introduced `mysqlIndexPrefixRequirements` option to `generateUpgradeSql` function. - Updated tests to validate the new functionality for MySQL upgrade generation. - Removed legacy shared index compatibility specifications to avoid duplication with runtime schema bootstrap. - Improved error handling and response structure in account token coverage refresh logic. - Added tests for coverage refresh failure scenarios to ensure proper handling of token persistence. # Conflicts: # src/server/db/sharedIndexSchemaCompatibility.test.ts # src/server/db/sharedIndexSchemaCompatibility.ts # src/server/index.ts
This commit is contained in:
@@ -8,7 +8,6 @@ import * as schema from './schema.js';
|
|||||||
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
|
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
|
||||||
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
|
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
|
||||||
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
|
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
|
||||||
import { ensureSharedIndexSchemaCompatibility } from './sharedIndexSchemaCompatibility.js';
|
|
||||||
import { executeLegacyCompat, executeLegacyCompatSync } from './legacySchemaCompat.js';
|
import { executeLegacyCompat, executeLegacyCompatSync } from './legacySchemaCompat.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js';
|
import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js';
|
||||||
@@ -447,12 +446,6 @@ export async function ensureProxyFileCompatibilityColumns(): Promise<void> {
|
|||||||
await ensureProxyFileSchemaCompatibility(inspector);
|
await ensureProxyFileSchemaCompatibility(inspector);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureSharedIndexCompatibility(): Promise<void> {
|
|
||||||
const inspector = createRuntimeSchemaInspector();
|
|
||||||
if (!inspector) return;
|
|
||||||
await ensureSharedIndexSchemaCompatibility(inspector);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureRouteGroupingSchema() {
|
function ensureRouteGroupingSchema() {
|
||||||
if (!tableExists('token_routes') || !tableExists('route_channels')) {
|
if (!tableExists('token_routes') || !tableExists('route_channels')) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaCon
|
|||||||
import currentContract from './generated/schemaContract.json' with { type: 'json' };
|
import currentContract from './generated/schemaContract.json' with { type: 'json' };
|
||||||
import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
|
import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
|
||||||
import { generateUpgradeSql } from './schemaArtifactGenerator.js';
|
import { generateUpgradeSql } from './schemaArtifactGenerator.js';
|
||||||
|
import type { SchemaContract, SchemaContractColumn } from './schemaContract.js';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
__runtimeSchemaBootstrapTestUtils,
|
__runtimeSchemaBootstrapTestUtils,
|
||||||
@@ -17,6 +18,9 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]):
|
|||||||
commit: async () => {},
|
commit: async () => {},
|
||||||
rollback: async () => {},
|
rollback: async () => {},
|
||||||
execute: async (sqlText: string) => {
|
execute: async (sqlText: string) => {
|
||||||
|
if (sqlText.trim().toLowerCase().startsWith('select')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
executedSql.push(sqlText);
|
executedSql.push(sqlText);
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
@@ -33,6 +37,16 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeColumn(overrides: Partial<SchemaContractColumn> = {}): SchemaContractColumn {
|
||||||
|
return {
|
||||||
|
logicalType: 'text',
|
||||||
|
notNull: false,
|
||||||
|
defaultValue: null,
|
||||||
|
primaryKey: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('runtime schema bootstrap', () => {
|
describe('runtime schema bootstrap', () => {
|
||||||
it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => {
|
it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => {
|
||||||
const executedSql: string[] = [];
|
const executedSql: string[] = [];
|
||||||
@@ -149,4 +163,72 @@ describe('runtime schema bootstrap', () => {
|
|||||||
|
|
||||||
expect(executedSql).toContain(targetSql);
|
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))',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
ensureLegacySchemaCompatibility,
|
ensureLegacySchemaCompatibility,
|
||||||
type LegacySchemaCompatInspector,
|
type LegacySchemaCompatInspector,
|
||||||
} from './legacySchemaCompat.js';
|
} from './legacySchemaCompat.js';
|
||||||
import { generateBootstrapSql, generateUpgradeSql } from './schemaArtifactGenerator.js';
|
import {
|
||||||
|
generateBootstrapSql,
|
||||||
|
generateUpgradeSql,
|
||||||
|
type MysqlIndexPrefixRequirementMap,
|
||||||
|
} from './schemaArtifactGenerator.js';
|
||||||
import { introspectLiveSchema } from './schemaIntrospection.js';
|
import { introspectLiveSchema } from './schemaIntrospection.js';
|
||||||
import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js';
|
import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js';
|
||||||
|
|
||||||
@@ -233,6 +237,92 @@ function buildCompatibleRuntimeBaseline(
|
|||||||
return baseline;
|
return baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectIndexedColumns(contract: SchemaContract): Map<string, Set<string>> {
|
||||||
|
const indexedColumns = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const index of [...contract.indexes, ...contract.uniques]) {
|
||||||
|
let columns = indexedColumns.get(index.table);
|
||||||
|
if (!columns) {
|
||||||
|
columns = new Set<string>();
|
||||||
|
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<Array<Record<string, unknown>>> {
|
||||||
|
const result = await client.execute(sqlText, params);
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first] = result;
|
||||||
|
if (Array.isArray(first)) {
|
||||||
|
return first as Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) {
|
||||||
|
return result as Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMySqlIndexPrefixRequirements(
|
||||||
|
client: RuntimeSchemaClient,
|
||||||
|
currentContract: SchemaContract,
|
||||||
|
): Promise<MysqlIndexPrefixRequirementMap> {
|
||||||
|
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<RuntimeSchemaClient> {
|
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
|
||||||
const clientOptions: pg.ClientConfig = { connectionString };
|
const clientOptions: pg.ClientConfig = { connectionString };
|
||||||
if (ssl) {
|
if (ssl) {
|
||||||
@@ -346,9 +436,12 @@ function buildExternalUpgradeStatements(
|
|||||||
dialect: Exclude<RuntimeSchemaDialect, 'sqlite'>,
|
dialect: Exclude<RuntimeSchemaDialect, 'sqlite'>,
|
||||||
currentContract: SchemaContract,
|
currentContract: SchemaContract,
|
||||||
liveContract: SchemaContract,
|
liveContract: SchemaContract,
|
||||||
|
mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap,
|
||||||
): string[] {
|
): string[] {
|
||||||
const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract);
|
const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract);
|
||||||
return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline));
|
return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline, {
|
||||||
|
mysqlIndexPrefixRequirements,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureRuntimeDatabaseSchema(
|
export async function ensureRuntimeDatabaseSchema(
|
||||||
@@ -356,13 +449,23 @@ export async function ensureRuntimeDatabaseSchema(
|
|||||||
options: EnsureRuntimeDatabaseSchemaOptions = {},
|
options: EnsureRuntimeDatabaseSchemaOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const currentContract = options.currentContract ?? readSchemaContract();
|
const currentContract = options.currentContract ?? readSchemaContract();
|
||||||
const statements = client.dialect === 'sqlite'
|
let statements: string[];
|
||||||
? splitSqlStatements(generateBootstrapSql('sqlite', currentContract))
|
|
||||||
: buildExternalUpgradeStatements(
|
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,
|
client.dialect,
|
||||||
currentContract,
|
currentContract,
|
||||||
await resolveLiveContract(client, options.liveContract),
|
liveContract,
|
||||||
|
mysqlIndexPrefixRequirements,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const sqlText of statements) {
|
for (const sqlText of statements) {
|
||||||
await executeBootstrapStatement(client, sqlText);
|
await executeBootstrapStatement(client, sqlText);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import type { SchemaContract, SchemaContractColumn } from './schemaContract.js';
|
||||||
|
|
||||||
const dbDir = dirname(fileURLToPath(import.meta.url));
|
const dbDir = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -123,6 +123,45 @@ describe('schema artifact generator', () => {
|
|||||||
expect(artifacts.mysqlBootstrap).not.toContain('`read`(191)');
|
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', () => {
|
it('rejects destructive diffs when generating additive upgrades', () => {
|
||||||
const current = readSchemaContract();
|
const current = readSchemaContract();
|
||||||
const previous = structuredClone(current);
|
const previous = structuredClone(current);
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ export interface GeneratedDialectArtifacts {
|
|||||||
postgresUpgrade: string;
|
postgresUpgrade: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MysqlIndexPrefixRequirementMap = Record<string, Record<string, boolean>>;
|
||||||
|
|
||||||
type Dialect = 'mysql' | 'postgres';
|
type Dialect = 'mysql' | 'postgres';
|
||||||
type SqlDialect = 'sqlite' | Dialect;
|
type SqlDialect = 'sqlite' | Dialect;
|
||||||
|
type SqlGenerationOptions = {
|
||||||
|
mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap;
|
||||||
|
};
|
||||||
|
|
||||||
function resolveDbDir(): string {
|
function resolveDbDir(): string {
|
||||||
return dirname(fileURLToPath(import.meta.url));
|
return dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -36,6 +41,21 @@ function escapeMysqlTextPrefix(columnType: LogicalColumnType): string {
|
|||||||
return columnType === 'text' ? '(191)' : '';
|
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 {
|
function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string {
|
||||||
if (dialect === 'sqlite') {
|
if (dialect === 'sqlite') {
|
||||||
switch (column.logicalType) {
|
switch (column.logicalType) {
|
||||||
@@ -177,22 +197,34 @@ function buildCreateTableStatement(
|
|||||||
return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`;
|
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
|
const columns = uniqueIndex.columns
|
||||||
.map((columnName) => {
|
.map((columnName) => {
|
||||||
const column = contract.tables[uniqueIndex.table]?.columns[columnName];
|
const suffix = dialect === 'mysql'
|
||||||
const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : '';
|
? resolveMysqlIndexPrefix(uniqueIndex.table, columnName, contract, options)
|
||||||
|
: '';
|
||||||
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
|
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
|
||||||
})
|
})
|
||||||
.join(', ');
|
.join(', ');
|
||||||
return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`;
|
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
|
const columns = index.columns
|
||||||
.map((columnName) => {
|
.map((columnName) => {
|
||||||
const column = contract.tables[index.table]?.columns[columnName];
|
const suffix = dialect === 'mysql'
|
||||||
const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : '';
|
? resolveMysqlIndexPrefix(index.table, columnName, contract, options)
|
||||||
|
: '';
|
||||||
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
|
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
|
||||||
})
|
})
|
||||||
.join(', ');
|
.join(', ');
|
||||||
@@ -317,6 +349,7 @@ export function generateUpgradeSql(
|
|||||||
dialect: SqlDialect,
|
dialect: SqlDialect,
|
||||||
currentContract: SchemaContract,
|
currentContract: SchemaContract,
|
||||||
previousContract?: SchemaContract | null,
|
previousContract?: SchemaContract | null,
|
||||||
|
options?: SqlGenerationOptions,
|
||||||
): string {
|
): string {
|
||||||
if (!previousContract) {
|
if (!previousContract) {
|
||||||
return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`;
|
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))
|
.filter((unique) => !previousUniqueNames.has(unique.name))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
.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 currentUniqueNames = new Set(currentContract.uniques.map((unique) => unique.name));
|
||||||
const previousIndexNames = new Set(previousContract.indexes.map((index) => index.name));
|
const previousIndexNames = new Set(previousContract.indexes.map((index) => index.name));
|
||||||
@@ -366,7 +399,7 @@ export function generateUpgradeSql(
|
|||||||
.filter((index) => !previousIndexNames.has(index.name))
|
.filter((index) => !previousIndexNames.has(index.name))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
.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];
|
const statements = [...addedTableStatements, ...addColumnStatements, ...uniqueStatements, ...indexStatements];
|
||||||
if (statements.length === 0) {
|
if (statements.length === 0) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { SchemaContract } from './schemaContract.js';
|
import type { SchemaContract } from './schemaContract.js';
|
||||||
|
import { SHARED_INDEX_COMPATIBILITY_SPECS } from './sharedIndexSchemaCompatibility.js';
|
||||||
|
|
||||||
const dbDir = dirname(fileURLToPath(import.meta.url));
|
const dbDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const generatedDir = resolve(dbDir, 'generated');
|
const generatedDir = resolve(dbDir, 'generated');
|
||||||
@@ -63,4 +64,18 @@ describe('database schema parity', () => {
|
|||||||
expect(unknownTables).toEqual([]);
|
expect(unknownTables).toEqual([]);
|
||||||
expect(unknownIndexes).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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,33 +25,10 @@ function createInspector(
|
|||||||
|
|
||||||
describe('ensureSharedIndexSchemaCompatibility', () => {
|
describe('ensureSharedIndexSchemaCompatibility', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{
|
'sqlite',
|
||||||
dialect: 'sqlite' as const,
|
'mysql',
|
||||||
expectedSql: [
|
'postgres',
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);',
|
] as const)('is a no-op for %s now that contract-defined indexes come from runtime bootstrap', async (dialect) => {
|
||||||
'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 }) => {
|
|
||||||
const { inspector, executedSql } = createInspector(dialect, {
|
const { inspector, executedSql } = createInspector(dialect, {
|
||||||
existingTables: [
|
existingTables: [
|
||||||
'sites',
|
'sites',
|
||||||
@@ -70,10 +47,7 @@ describe('ensureSharedIndexSchemaCompatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await ensureSharedIndexSchemaCompatibility(inspector);
|
await ensureSharedIndexSchemaCompatibility(inspector);
|
||||||
|
expect(executedSql).toEqual([]);
|
||||||
for (const sqlText of expectedSql) {
|
|
||||||
expect(executedSql).toContain(sqlText);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips indexes for tables that do not exist', async () => {
|
it('skips indexes for tables that do not exist', async () => {
|
||||||
@@ -83,8 +57,6 @@ describe('ensureSharedIndexSchemaCompatibility', () => {
|
|||||||
|
|
||||||
await ensureSharedIndexSchemaCompatibility(inspector);
|
await ensureSharedIndexSchemaCompatibility(inspector);
|
||||||
|
|
||||||
expect(executedSql).toEqual([
|
expect(executedSql).toEqual([]);
|
||||||
'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,341 +12,10 @@ type SharedIndexCompatibilitySpec = {
|
|||||||
createSql: Record<SharedIndexSchemaDialect, string>;
|
createSql: Record<SharedIndexSchemaDialect, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = [
|
// Contract-defined indexes are owned by generated schema artifacts and
|
||||||
{
|
// runtimeSchemaBootstrap. This legacy hook is intentionally empty so startup
|
||||||
table: 'sites',
|
// does not maintain a second static source of index SQL.
|
||||||
indexName: 'sites_status_idx',
|
export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = [];
|
||||||
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")',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||||
if (typeof error === 'object' && error && 'message' in error) {
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
ensureProxyLogDownstreamApiKeyIdColumn,
|
ensureProxyLogDownstreamApiKeyIdColumn,
|
||||||
ensureProxyLogBillingDetailsColumn,
|
ensureProxyLogBillingDetailsColumn,
|
||||||
ensureRouteGroupingCompatibilityColumns,
|
ensureRouteGroupingCompatibilityColumns,
|
||||||
ensureSharedIndexCompatibility,
|
|
||||||
ensureSiteCompatibilityColumns,
|
ensureSiteCompatibilityColumns,
|
||||||
runtimeDbDialect,
|
runtimeDbDialect,
|
||||||
schema,
|
schema,
|
||||||
@@ -267,7 +266,6 @@ try {
|
|||||||
await ensureRouteGroupingCompatibilityColumns();
|
await ensureRouteGroupingCompatibilityColumns();
|
||||||
await ensureProxyFileCompatibilityColumns();
|
await ensureProxyFileCompatibilityColumns();
|
||||||
await ensureProxyLogDownstreamApiKeyIdColumn();
|
await ensureProxyLogDownstreamApiKeyIdColumn();
|
||||||
await ensureSharedIndexCompatibility();
|
|
||||||
const finalRows = await db.select().from(schema.settings).all();
|
const finalRows = await db.select().from(schema.settings).all();
|
||||||
const finalMap = toSettingsMap(finalRows);
|
const finalMap = toSettingsMap(finalRows);
|
||||||
applyRuntimeSettings(finalMap);
|
applyRuntimeSettings(finalMap);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,11 @@ import {
|
|||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.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 = {
|
type AccountWithSiteRow = {
|
||||||
accounts: typeof schema.accounts.$inferSelect;
|
accounts: typeof schema.accounts.$inferSelect;
|
||||||
@@ -39,6 +43,25 @@ type SyncExecutionResult = {
|
|||||||
defaultTokenId?: number | null;
|
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<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
const TOKEN_SYNC_TIMEOUT_MS = 15_000;
|
const TOKEN_SYNC_TIMEOUT_MS = 15_000;
|
||||||
const SYNC_ALL_BATCH_SIZE = 3;
|
const SYNC_ALL_BATCH_SIZE = 3;
|
||||||
|
|
||||||
@@ -365,13 +388,62 @@ async function refreshCoverageForAccounts(accountIds: number[]) {
|
|||||||
return { refresh: [], rebuild: null };
|
return { refresh: [], rebuild: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const refresh = await Promise.all(
|
const refresh: CoverageRefreshItem[] = [];
|
||||||
uniqueAccountIds.map(async (accountId) => refreshModelsForAccount(accountId)),
|
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)),
|
||||||
);
|
);
|
||||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
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 };
|
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) {
|
export async function accountTokensRoutes(app: FastifyInstance) {
|
||||||
app.get<{ Querystring: { accountId?: string } }>('/api/account-tokens', async (request) => {
|
app.get<{ Querystring: { accountId?: string } }>('/api/account-tokens', async (request) => {
|
||||||
const accountId = request.query.accountId ? Number.parseInt(request.query.accountId, 10) : undefined;
|
const accountId = request.query.accountId ? Number.parseInt(request.query.accountId, 10) : undefined;
|
||||||
|
|||||||
@@ -12,6 +12,61 @@ const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
|||||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||||
|
|
||||||
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
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 {
|
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
|
||||||
const lowered = message.toLowerCase();
|
const lowered = message.toLowerCase();
|
||||||
@@ -64,13 +119,7 @@ async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshModelsForAccount(accountId: number) {
|
function buildAccountNotFoundRefreshResult(accountId: number): ModelRefreshAccountNotFoundResult {
|
||||||
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 {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
refreshed: false,
|
refreshed: false,
|
||||||
@@ -83,6 +132,77 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<ModelRefreshResult> {
|
||||||
|
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 buildAccountNotFoundRefreshResult(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
const account = row.accounts;
|
const account = row.accounts;
|
||||||
const site = row.sites;
|
const site = row.sites;
|
||||||
const adapter = getAdapter(site.platform);
|
const adapter = getAdapter(site.platform);
|
||||||
@@ -103,29 +223,11 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isSiteDisabled(site.status)) {
|
if (isSiteDisabled(site.status)) {
|
||||||
return {
|
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
|
||||||
accountId,
|
|
||||||
refreshed: false,
|
|
||||||
status: 'skipped',
|
|
||||||
errorCode: 'site_disabled',
|
|
||||||
errorMessage: '站点已禁用',
|
|
||||||
modelCount: 0,
|
|
||||||
modelsPreview: [],
|
|
||||||
reason: 'site_disabled',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adapter || account.status !== 'active') {
|
if (!adapter || account.status !== 'active') {
|
||||||
return {
|
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
||||||
accountId,
|
|
||||||
refreshed: false,
|
|
||||||
status: 'skipped',
|
|
||||||
errorCode: 'adapter_or_status',
|
|
||||||
errorMessage: '平台不可用或账号未激活',
|
|
||||||
modelCount: 0,
|
|
||||||
modelsPreview: [],
|
|
||||||
reason: 'adapter_or_status',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||||
@@ -266,18 +368,14 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
source: 'model-discovery',
|
source: 'model-discovery',
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
return {
|
return buildFailedRefreshResult({
|
||||||
accountId,
|
accountId,
|
||||||
refreshed: true,
|
|
||||||
status: 'failed',
|
|
||||||
errorCode,
|
errorCode,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
modelCount: 0,
|
|
||||||
modelsPreview: [],
|
|
||||||
tokenScanned: scannedTokenCount,
|
tokenScanned: scannedTokenCount,
|
||||||
discoveredByCredential,
|
discoveredByCredential,
|
||||||
discoveredApiToken: !!discoveredApiToken,
|
discoveredApiToken: !!discoveredApiToken,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = new Date().toISOString();
|
||||||
@@ -299,26 +397,22 @@ export async function refreshModelsForAccount(accountId: number) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const modelsPreview = Array.from(accountModels).slice(0, 10);
|
const modelsPreview = Array.from(accountModels).slice(0, 10);
|
||||||
return {
|
return buildSuccessfulRefreshResult({
|
||||||
accountId,
|
accountId,
|
||||||
refreshed: true,
|
|
||||||
status: 'success',
|
|
||||||
errorCode: null,
|
|
||||||
errorMessage: '',
|
|
||||||
modelCount: accountModels.size,
|
modelCount: accountModels.size,
|
||||||
modelsPreview,
|
modelsPreview,
|
||||||
tokenScanned: scannedTokenCount,
|
tokenScanned: scannedTokenCount,
|
||||||
discoveredByCredential,
|
discoveredByCredential,
|
||||||
discoveredApiToken: !!discoveredApiToken,
|
discoveredApiToken: !!discoveredApiToken,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshModelsForAllActiveAccounts() {
|
async function refreshModelsForAllActiveAccounts(): Promise<ModelRefreshResult[]> {
|
||||||
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
|
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
|
||||||
.where(eq(schema.accounts.status, 'active'))
|
.where(eq(schema.accounts.status, 'active'))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const results: any[] = [];
|
const results: ModelRefreshResult[] = [];
|
||||||
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
|
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
|
||||||
const batch = accounts.slice(offset, 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)));
|
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
|
||||||
|
|||||||
Reference in New Issue
Block a user