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:
ciat-777
2026-03-15 23:13:37 +08:00
parent 1a2268cedb
commit b15da138d1
12 changed files with 628 additions and 443 deletions
@@ -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');
});
});
+77 -5
View File
@@ -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<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
| { 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;