forked from boos/BoosAPI
refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root - Deleted all Node.js source (src/server/, src/web/, scripts/) - Deleted node_modules, package.json, tsconfig, vite.config - Updated .gitignore and AGENTS.md for Rust-only stack - Fixed static file path for frontend dist/web/ - Dockerfile stays the same (already Rust-only) - Kept dist/web/ for Rust static serving
This commit is contained in:
@@ -1,109 +0,0 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildConfig, buildFastifyOptions } from './config.js';
|
||||
|
||||
describe('buildConfig', () => {
|
||||
it('defaults to external listen host for server deployments', () => {
|
||||
const config = buildConfig({});
|
||||
|
||||
expect(config.listenHost).toBe('0.0.0.0');
|
||||
expect(config.port).toBe(4000);
|
||||
expect(config.dataDir).toBe('./data');
|
||||
});
|
||||
|
||||
it('aligns desktop deployments with server deployments for listen host', () => {
|
||||
const config = buildConfig({
|
||||
HOST: '0.0.0.0',
|
||||
METAPI_DESKTOP: '1',
|
||||
PORT: '4312',
|
||||
DATA_DIR: '/tmp/metapi-data',
|
||||
});
|
||||
|
||||
expect(config.listenHost).toBe('0.0.0.0');
|
||||
expect(config.port).toBe(4312);
|
||||
expect(config.dataDir).toBe('/tmp/metapi-data');
|
||||
});
|
||||
|
||||
it('honors explicit loopback host outside desktop mode', () => {
|
||||
const config = buildConfig({
|
||||
HOST: '127.0.0.1',
|
||||
});
|
||||
|
||||
expect(config.listenHost).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('defaults telegram api base url to the official endpoint', () => {
|
||||
const config = buildConfig({});
|
||||
|
||||
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
|
||||
expect(config.telegramMessageThreadId).toBe('');
|
||||
});
|
||||
|
||||
it('accepts telegram message thread id from environment', () => {
|
||||
const config = buildConfig({
|
||||
TELEGRAM_MESSAGE_THREAD_ID: '77',
|
||||
});
|
||||
|
||||
expect(config.telegramMessageThreadId).toBe('77');
|
||||
});
|
||||
|
||||
it('ships CLI-aligned OAuth defaults', () => {
|
||||
const config = buildConfig({});
|
||||
|
||||
expect(config.codexClientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann');
|
||||
expect(config.codexResponsesWebsocketBeta).toBe('responses_websockets=2026-02-06');
|
||||
expect(config.claudeClientId).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e');
|
||||
expect(config.claudeClientSecret).toBe('');
|
||||
expect(config.geminiCliClientId).toBe('681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com');
|
||||
expect(config.geminiCliClientSecret).toBe('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');
|
||||
});
|
||||
|
||||
it('allows overriding the codex websocket beta gate from environment', () => {
|
||||
const config = buildConfig({
|
||||
CODEX_RESPONSES_WEBSOCKET_BETA: 'responses_websockets=2099-01-01',
|
||||
});
|
||||
|
||||
expect(config.codexResponsesWebsocketBeta).toBe('responses_websockets=2099-01-01');
|
||||
});
|
||||
|
||||
it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => {
|
||||
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
||||
const largeText = 'a'.repeat(2 * 1024 * 1024);
|
||||
|
||||
app.post('/echo', async (request) => {
|
||||
const body = request.body as { text?: string };
|
||||
return { textLength: body.text?.length ?? 0 };
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/echo',
|
||||
payload: { text: largeText },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ textLength: largeText.length });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('trusts forwarded client IP headers for reverse-proxy deployments', async () => {
|
||||
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
||||
|
||||
app.get('/ip', async (request) => ({
|
||||
ip: request.ip,
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/ip',
|
||||
remoteAddress: '10.0.0.8',
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.5, 10.0.0.8',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ip: '203.0.113.5' });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import type { FastifyServerOptions } from 'fastify';
|
||||
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
|
||||
|
||||
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
|
||||
const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
const DEFAULT_CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||
const DEFAULT_GEMINI_CLI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
||||
const DEFAULT_GEMINI_CLI_CLIENT_SECRET = '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';
|
||||
export const TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING = 30 * 24 * 60 * 60;
|
||||
|
||||
function parseBoolean(value: string | undefined, fallback = false): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function parseNumber(value: string | undefined, fallback: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCsvList(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function parseOptionalSecret(value: string | undefined): string {
|
||||
return (value || '').trim();
|
||||
}
|
||||
|
||||
function parseJsonValue(value: string | undefined): unknown {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
|
||||
const normalized = (value || 'sqlite').trim().toLowerCase();
|
||||
if (normalized === 'mysql') return 'mysql';
|
||||
if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres';
|
||||
return 'sqlite';
|
||||
}
|
||||
|
||||
export function normalizeTokenRouterFailureCooldownMaxSec(value: unknown): number | null {
|
||||
const normalized = Number(value);
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) return null;
|
||||
return Math.min(
|
||||
TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING,
|
||||
Math.max(1, Math.trunc(normalized)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseListenHost(env: NodeJS.ProcessEnv): string {
|
||||
return (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
|
||||
}
|
||||
|
||||
export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
const dataDir = env.DATA_DIR || './data';
|
||||
|
||||
return {
|
||||
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
deployHelperToken: parseOptionalSecret(env.DEPLOY_HELPER_TOKEN || env.UPDATE_CENTER_HELPER_TOKEN),
|
||||
codexClientId: parseOptionalSecret(env.CODEX_CLIENT_ID) || DEFAULT_CODEX_CLIENT_ID,
|
||||
claudeClientId: parseOptionalSecret(env.CLAUDE_CLIENT_ID) || DEFAULT_CLAUDE_CLIENT_ID,
|
||||
claudeClientSecret: parseOptionalSecret(env.CLAUDE_CLIENT_SECRET),
|
||||
geminiCliClientId: parseOptionalSecret(env.GEMINI_CLI_CLIENT_ID) || DEFAULT_GEMINI_CLI_CLIENT_ID,
|
||||
geminiCliClientSecret: parseOptionalSecret(env.GEMINI_CLI_CLIENT_SECRET) || DEFAULT_GEMINI_CLI_CLIENT_SECRET,
|
||||
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
|
||||
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
||||
checkinScheduleMode: (env.CHECKIN_SCHEDULE_MODE || 'cron').trim().toLowerCase() === 'interval'
|
||||
? 'interval' as const
|
||||
: 'cron' as const,
|
||||
checkinIntervalHours: Math.min(24, Math.max(1, Math.trunc(parseNumber(env.CHECKIN_INTERVAL_HOURS, 6)))),
|
||||
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *',
|
||||
logCleanupConfigured: false,
|
||||
logCleanupUsageLogsEnabled: parseBoolean(env.LOG_CLEANUP_USAGE_LOGS_ENABLED, false),
|
||||
logCleanupProgramLogsEnabled: parseBoolean(env.LOG_CLEANUP_PROGRAM_LOGS_ENABLED, false),
|
||||
logCleanupRetentionDays: Math.max(1, Math.trunc(parseNumber(env.LOG_CLEANUP_RETENTION_DAYS, 30))),
|
||||
webhookUrl: env.WEBHOOK_URL || '',
|
||||
barkUrl: env.BARK_URL || '',
|
||||
webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true),
|
||||
barkEnabled: parseBoolean(env.BARK_ENABLED, true),
|
||||
serverChanEnabled: parseBoolean(env.SERVERCHAN_ENABLED, true),
|
||||
serverChanKey: env.SERVERCHAN_KEY || '',
|
||||
telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, false),
|
||||
telegramApiBaseUrl: 'https://api.telegram.org',
|
||||
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
|
||||
telegramChatId: env.TELEGRAM_CHAT_ID || '',
|
||||
telegramUseSystemProxy: parseBoolean(env.TELEGRAM_USE_SYSTEM_PROXY, false),
|
||||
telegramMessageThreadId: (env.TELEGRAM_MESSAGE_THREAD_ID || '').trim(),
|
||||
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
|
||||
smtpHost: env.SMTP_HOST || '',
|
||||
smtpPort: parseInt(env.SMTP_PORT || '587'),
|
||||
smtpSecure: parseBoolean(env.SMTP_SECURE, false),
|
||||
smtpUser: env.SMTP_USER || '',
|
||||
smtpPass: env.SMTP_PASS || '',
|
||||
smtpFrom: env.SMTP_FROM || '',
|
||||
smtpTo: env.SMTP_TO || '',
|
||||
notifyCooldownSec: Math.max(0, Math.trunc(parseNumber(env.NOTIFY_COOLDOWN_SEC, 300))),
|
||||
adminIpAllowlist: parseCsvList(env.ADMIN_IP_ALLOWLIST),
|
||||
port: Math.trunc(parseNumber(env.PORT, 4000)),
|
||||
listenHost: parseListenHost(env),
|
||||
dataDir,
|
||||
dbType: parseDbType(env.DB_TYPE),
|
||||
dbUrl: (env.DB_URL || '').trim(),
|
||||
dbSsl: parseBoolean(env.DB_SSL, false),
|
||||
requestBodyLimit: DEFAULT_REQUEST_BODY_LIMIT,
|
||||
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
||||
proxyFirstByteTimeoutSec: Math.max(0, Math.trunc(parseNumber(env.PROXY_FIRST_BYTE_TIMEOUT_SEC, 0))),
|
||||
tokenRouterFailureCooldownMaxSec: normalizeTokenRouterFailureCooldownMaxSec(
|
||||
parseNumber(env.TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC, TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING),
|
||||
) ?? TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING,
|
||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||
proxyMaxChannelAttempts: Math.max(1, Math.trunc(parseNumber(env.PROXY_MAX_CHANNEL_ATTEMPTS, 3))),
|
||||
proxyStickySessionEnabled: parseBoolean(env.PROXY_STICKY_SESSION_ENABLED, true),
|
||||
proxyStickySessionTtlMs: Math.max(30_000, Math.trunc(parseNumber(env.PROXY_STICKY_SESSION_TTL_MS, 30 * 60 * 1000))),
|
||||
proxySessionChannelConcurrencyLimit: Math.max(0, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_CONCURRENCY_LIMIT, 2))),
|
||||
proxySessionChannelQueueWaitMs: Math.max(0, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_QUEUE_WAIT_MS, 1_500))),
|
||||
proxySessionChannelLeaseTtlMs: Math.max(5_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_TTL_MS, 90_000))),
|
||||
proxySessionChannelLeaseKeepaliveMs: Math.max(1_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_KEEPALIVE_MS, 15_000))),
|
||||
codexUpstreamWebsocketEnabled: parseBoolean(env.CODEX_UPSTREAM_WEBSOCKET_ENABLED, false),
|
||||
responsesCompactFallbackToResponsesEnabled: parseBoolean(env.RESPONSES_COMPACT_FALLBACK_TO_RESPONSES_ENABLED, false),
|
||||
disableCrossProtocolFallback: parseBoolean(env.DISABLE_CROSS_PROTOCOL_FALLBACK, false),
|
||||
proxyDebugTraceEnabled: parseBoolean(env.PROXY_DEBUG_TRACE_ENABLED, false),
|
||||
proxyDebugCaptureHeaders: parseBoolean(env.PROXY_DEBUG_CAPTURE_HEADERS, true),
|
||||
proxyDebugCaptureBodies: parseBoolean(env.PROXY_DEBUG_CAPTURE_BODIES, false),
|
||||
proxyDebugCaptureStreamChunks: parseBoolean(env.PROXY_DEBUG_CAPTURE_STREAM_CHUNKS, false),
|
||||
proxyDebugTargetSessionId: (env.PROXY_DEBUG_TARGET_SESSION_ID || '').trim(),
|
||||
proxyDebugTargetClientKind: (env.PROXY_DEBUG_TARGET_CLIENT_KIND || '').trim(),
|
||||
proxyDebugTargetModel: (env.PROXY_DEBUG_TARGET_MODEL || '').trim(),
|
||||
proxyDebugRetentionHours: Math.max(1, Math.trunc(parseNumber(env.PROXY_DEBUG_RETENTION_HOURS, 24))),
|
||||
proxyDebugMaxBodyBytes: Math.max(1024, Math.trunc(parseNumber(env.PROXY_DEBUG_MAX_BODY_BYTES, 262_144))),
|
||||
openAiServiceTierRules: parseJsonValue(env.OPENAI_SERVICE_TIER_RULES_JSON || env.OPENAI_SERVICE_TIER_RULES),
|
||||
modelAvailabilityProbeEnabled: parseBoolean(env.MODEL_AVAILABILITY_PROBE_ENABLED, false),
|
||||
modelAvailabilityProbeIntervalMs: Math.max(60_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_INTERVAL_MS, 30 * 60 * 1000))),
|
||||
modelAvailabilityProbeTimeoutMs: Math.max(3_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_TIMEOUT_MS, 15_000))),
|
||||
modelAvailabilityProbeConcurrency: Math.max(1, Math.min(16, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_CONCURRENCY, 1)))),
|
||||
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
|
||||
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
|
||||
proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))),
|
||||
proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
|
||||
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
|
||||
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
||||
globalBlockedBrands: [] as string[],
|
||||
globalAllowedModels: [] as string[],
|
||||
codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06',
|
||||
codexHeaderDefaults: {
|
||||
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
|
||||
betaFeatures: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_BETA_FEATURES),
|
||||
},
|
||||
payloadRules: normalizePayloadRulesConfig(parseJsonValue(env.PAYLOAD_RULES_JSON || env.PAYLOAD_RULES)),
|
||||
routingWeights: {
|
||||
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
|
||||
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
|
||||
costWeight: parseNumber(env.COST_WEIGHT, 0.4),
|
||||
balanceWeight: parseNumber(env.BALANCE_WEIGHT, 0.3),
|
||||
usageWeight: parseNumber(env.USAGE_WEIGHT, 0.3),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const config = buildConfig(process.env);
|
||||
|
||||
export function buildFastifyOptions(
|
||||
appConfig: ReturnType<typeof buildConfig>,
|
||||
): FastifyServerOptions {
|
||||
return {
|
||||
logger: true,
|
||||
trustProxy: true,
|
||||
bodyLimit: appConfig.requestBodyLimit,
|
||||
};
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const accountTokenCreatePayloadSchema = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
name: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
source: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
unlimitedQuota: z.boolean().optional(),
|
||||
remainQuota: z.union([z.number(), z.string()]).optional(),
|
||||
expiredTime: z.union([z.number(), z.string()]).optional(),
|
||||
allowIps: z.string().optional(),
|
||||
modelLimitsEnabled: z.boolean().optional(),
|
||||
modelLimits: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenUpdatePayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
source: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenSyncAllPayloadSchema = z.object({
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AccountTokenBatchPayload = z.output<typeof accountTokenBatchPayloadSchema>;
|
||||
export type AccountTokenCreatePayload = z.output<typeof accountTokenCreatePayloadSchema>;
|
||||
export type AccountTokenSyncAllPayload = z.output<typeof accountTokenSyncAllPayloadSchema>;
|
||||
export type AccountTokenUpdatePayload = z.output<typeof accountTokenUpdatePayloadSchema>;
|
||||
|
||||
function normalizeAccountTokenPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatAccountTokenPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'token') {
|
||||
return 'Invalid token. Expected string.';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'isDefault') {
|
||||
return 'Invalid isDefault. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'source') {
|
||||
return 'Invalid source. Expected string.';
|
||||
}
|
||||
if (firstPath === 'group') {
|
||||
return 'Invalid group. Expected string.';
|
||||
}
|
||||
if (firstPath === 'unlimitedQuota') {
|
||||
return 'Invalid unlimitedQuota. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'modelLimitsEnabled') {
|
||||
return 'Invalid modelLimitsEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
return 'Invalid account token payload.';
|
||||
}
|
||||
|
||||
export function parseAccountTokenCreatePayload(input: unknown):
|
||||
{ success: true; data: AccountTokenCreatePayload } | { success: false; error: string } {
|
||||
const result = accountTokenCreatePayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenBatchPayload(input: unknown):
|
||||
{ success: true; data: AccountTokenBatchPayload } | { success: false; error: string } {
|
||||
const result = accountTokenBatchPayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenUpdatePayload(input: unknown):
|
||||
{ success: true; data: AccountTokenUpdatePayload } | { success: false; error: string } {
|
||||
const result = accountTokenUpdatePayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenSyncAllPayload(input: unknown):
|
||||
{ success: true; data: AccountTokenSyncAllPayload } | { success: false; error: string } {
|
||||
const result = accountTokenSyncAllPayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const accountCredentialModeSchema = z.enum(['auto', 'session', 'apikey']);
|
||||
|
||||
const accountCreatePayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
username: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
accessTokens: z.array(z.string()).optional(),
|
||||
apiToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
checkinEnabled: z.boolean().optional(),
|
||||
credentialMode: accountCredentialModeSchema.optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string()]).optional(),
|
||||
skipModelFetch: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountUpdatePayloadSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
apiToken: z.union([z.string(), z.null()]).optional(),
|
||||
status: z.string().optional(),
|
||||
checkinEnabled: z.boolean().optional(),
|
||||
unitCost: z.union([z.number(), z.null()]).optional(),
|
||||
extraConfig: z.union([z.string(), z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
refreshToken: z.union([z.string(), z.null()]).optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountRebindSessionPayloadSchema = z.object({
|
||||
accessToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountHealthRefreshPayloadSchema = z.object({
|
||||
accountId: z.number().int().positive().optional(),
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountLoginPayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
const accountVerifyTokenPayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
accessToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
credentialMode: accountCredentialModeSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountManualModelsPayloadSchema = z.object({
|
||||
models: z.array(z.string()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AccountBatchPayload = z.output<typeof accountBatchPayloadSchema>;
|
||||
export type AccountCreatePayload = z.output<typeof accountCreatePayloadSchema>;
|
||||
export type AccountHealthRefreshPayload = z.output<typeof accountHealthRefreshPayloadSchema>;
|
||||
export type AccountLoginPayload = z.output<typeof accountLoginPayloadSchema>;
|
||||
export type AccountManualModelsPayload = z.output<typeof accountManualModelsPayloadSchema>;
|
||||
export type AccountRebindSessionPayload = z.output<typeof accountRebindSessionPayloadSchema>;
|
||||
export type AccountUpdatePayload = z.output<typeof accountUpdatePayloadSchema>;
|
||||
export type AccountVerifyTokenPayload = z.output<typeof accountVerifyTokenPayloadSchema>;
|
||||
|
||||
function normalizeAccountsPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatAccountsPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'siteId') {
|
||||
return 'Invalid siteId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'accessToken') {
|
||||
return 'Invalid accessToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'username') {
|
||||
return 'Invalid username. Expected string.';
|
||||
}
|
||||
if (firstPath === 'password') {
|
||||
return 'Invalid password. Expected string.';
|
||||
}
|
||||
if (firstPath === 'apiToken') {
|
||||
return 'Invalid apiToken. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'accessTokens') {
|
||||
return 'Invalid accessTokens. Expected string[].';
|
||||
}
|
||||
if (firstPath === 'checkinEnabled') {
|
||||
return 'Invalid checkinEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'unitCost') {
|
||||
return 'Invalid unitCost. Expected number or null.';
|
||||
}
|
||||
if (firstPath === 'credentialMode') {
|
||||
return 'Invalid credentialMode. Expected auto/session/apikey.';
|
||||
}
|
||||
if (firstPath === 'skipModelFetch') {
|
||||
return 'Invalid skipModelFetch. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'isPinned') {
|
||||
return 'Invalid isPinned. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'sortOrder') {
|
||||
return 'Invalid sortOrder. Expected non-negative integer.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return 'Invalid proxyUrl. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'platformUserId') {
|
||||
return 'Invalid platformUserId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'refreshToken') {
|
||||
return 'Invalid refreshToken. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'tokenExpiresAt') {
|
||||
return 'Invalid tokenExpiresAt. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return '账号 ID 无效';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'models') {
|
||||
return 'Invalid models. Expected string[].';
|
||||
}
|
||||
return 'Invalid account payload.';
|
||||
}
|
||||
|
||||
function parseAccountsPayload<T>(schema: z.ZodType<T>, input: unknown):
|
||||
{ success: true; data: T } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeAccountsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountCreatePayload(input: unknown):
|
||||
{ success: true; data: AccountCreatePayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountUpdatePayload(input: unknown):
|
||||
{ success: true; data: AccountUpdatePayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountBatchPayload(input: unknown):
|
||||
{ success: true; data: AccountBatchPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountBatchPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountRebindSessionPayload(input: unknown):
|
||||
{ success: true; data: AccountRebindSessionPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountRebindSessionPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountHealthRefreshPayload(input: unknown):
|
||||
{ success: true; data: AccountHealthRefreshPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountHealthRefreshPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountLoginPayload(input: unknown):
|
||||
{ success: true; data: AccountLoginPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountLoginPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountVerifyTokenPayload(input: unknown):
|
||||
{ success: true; data: AccountVerifyTokenPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountVerifyTokenPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountManualModelsPayload(input: unknown):
|
||||
{ success: true; data: AccountManualModelsPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountManualModelsPayloadSchema, input);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const downstreamExcludedCredentialRefSchema = z.union([
|
||||
z.object({
|
||||
kind: z.literal('account_token'),
|
||||
siteId: z.union([z.number(), z.string()]),
|
||||
accountId: z.union([z.number(), z.string()]),
|
||||
tokenId: z.union([z.number(), z.string()]),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('default_api_key'),
|
||||
siteId: z.union([z.number(), z.string()]),
|
||||
accountId: z.union([z.number(), z.string()]),
|
||||
}),
|
||||
]);
|
||||
|
||||
const downstreamApiKeyPayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
description: z.union([z.string(), z.null()]).optional(),
|
||||
groupName: z.union([z.string(), z.null()]).optional(),
|
||||
tags: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
expiresAt: z.union([z.string(), z.null()]).optional(),
|
||||
maxCost: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
maxRequests: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
supportedModels: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
allowedRouteIds: z.union([z.string(), z.array(z.union([z.number(), z.string()]))]).optional(),
|
||||
siteWeightMultipliers: z.union([
|
||||
z.string(),
|
||||
z.record(z.string(), z.union([z.number(), z.string()])),
|
||||
]).optional(),
|
||||
excludedSiteIds: z.union([z.string(), z.array(z.union([z.number(), z.string()]))]).optional(),
|
||||
excludedCredentialRefs: z.union([z.string(), z.array(downstreamExcludedCredentialRefSchema)]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const downstreamApiKeyBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
groupOperation: z.string().optional(),
|
||||
groupName: z.union([z.string(), z.null()]).optional(),
|
||||
tagOperation: z.string().optional(),
|
||||
tags: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type DownstreamApiKeyBatchPayload = z.output<typeof downstreamApiKeyBatchPayloadSchema>;
|
||||
export type DownstreamApiKeyPayload = z.output<typeof downstreamApiKeyPayloadSchema>;
|
||||
|
||||
function normalizeDownstreamApiKeyPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatDownstreamApiKeyPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '参数无效:请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'key') {
|
||||
return 'Invalid key. Expected string.';
|
||||
}
|
||||
if (firstPath === 'description') {
|
||||
return 'Invalid description. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'groupName') {
|
||||
return 'Invalid groupName. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'tags') {
|
||||
return 'Invalid tags. Expected string or string[].';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'expiresAt') {
|
||||
return 'Invalid expiresAt. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'maxCost') {
|
||||
return 'Invalid maxCost. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'maxRequests') {
|
||||
return 'Invalid maxRequests. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'supportedModels') {
|
||||
return 'Invalid supportedModels. Expected string or string[].';
|
||||
}
|
||||
if (firstPath === 'allowedRouteIds') {
|
||||
return 'Invalid allowedRouteIds. Expected string or array.';
|
||||
}
|
||||
if (firstPath === 'siteWeightMultipliers') {
|
||||
return 'Invalid siteWeightMultipliers. Expected JSON object or string.';
|
||||
}
|
||||
if (firstPath === 'excludedSiteIds') {
|
||||
return 'Invalid excludedSiteIds. Expected string or array.';
|
||||
}
|
||||
if (firstPath === 'excludedCredentialRefs') {
|
||||
return 'Invalid excludedCredentialRefs. Expected JSON string or array.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'groupOperation') {
|
||||
return 'Invalid groupOperation. Expected string.';
|
||||
}
|
||||
if (firstPath === 'tagOperation') {
|
||||
return 'Invalid tagOperation. Expected string.';
|
||||
}
|
||||
return '参数无效';
|
||||
}
|
||||
|
||||
function parseDownstreamApiKeyRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeDownstreamApiKeyPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatDownstreamApiKeyPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDownstreamApiKeyPayload(input: unknown):
|
||||
{ success: true; data: DownstreamApiKeyPayload } | { success: false; error: string } {
|
||||
return parseDownstreamApiKeyRoutePayload(downstreamApiKeyPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseDownstreamApiKeyBatchPayload(input: unknown):
|
||||
{ success: true; data: DownstreamApiKeyBatchPayload } | { success: false; error: string } {
|
||||
return parseDownstreamApiKeyRoutePayload(downstreamApiKeyBatchPayloadSchema, input);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const backupExportTypeSchema = z.enum(['all', 'accounts', 'preferences']);
|
||||
const migrationDialectSchema = z.enum(['sqlite', 'mysql', 'postgres']);
|
||||
|
||||
const runtimeSettingsPayloadSchema = z.object({
|
||||
modelAvailabilityProbeEnabled: z.boolean().optional(),
|
||||
webhookEnabled: z.boolean().optional(),
|
||||
barkEnabled: z.boolean().optional(),
|
||||
serverChanEnabled: z.boolean().optional(),
|
||||
telegramEnabled: z.boolean().optional(),
|
||||
telegramUseSystemProxy: z.boolean().optional(),
|
||||
smtpEnabled: z.boolean().optional(),
|
||||
smtpSecure: z.boolean().optional(),
|
||||
logCleanupUsageLogsEnabled: z.boolean().optional(),
|
||||
logCleanupProgramLogsEnabled: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const systemProxyTestPayloadSchema = z.object({
|
||||
proxyUrl: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const databaseMigrationPayloadSchema = z.object({
|
||||
dialect: migrationDialectSchema,
|
||||
connectionString: z.string().trim().min(1),
|
||||
overwrite: z.boolean().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupWebdavConfigPayloadSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
fileUrl: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
clearPassword: z.boolean().optional(),
|
||||
exportType: backupExportTypeSchema.optional(),
|
||||
autoSyncEnabled: z.boolean().optional(),
|
||||
autoSyncCron: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupWebdavExportPayloadSchema = z.object({
|
||||
type: backupExportTypeSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupImportPayloadSchema = z.object({
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
}).passthrough();
|
||||
|
||||
export type BackupWebdavConfigPayload = z.output<typeof backupWebdavConfigPayloadSchema>;
|
||||
export type BackupWebdavExportPayload = z.output<typeof backupWebdavExportPayloadSchema>;
|
||||
export type BackupImportPayload = z.output<typeof backupImportPayloadSchema>;
|
||||
export type DatabaseMigrationPayload = z.output<typeof databaseMigrationPayloadSchema>;
|
||||
export type RuntimeSettingsPayload = z.output<typeof runtimeSettingsPayloadSchema>;
|
||||
export type SystemProxyTestPayload = z.output<typeof systemProxyTestPayloadSchema>;
|
||||
|
||||
function normalizeSettingsPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatSettingsPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'exportType') {
|
||||
return 'Invalid exportType. Expected all/accounts/preferences.';
|
||||
}
|
||||
if (firstPath === 'type') {
|
||||
return 'Invalid type. Expected all/accounts/preferences.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return '系统代理地址格式无效:需要 string';
|
||||
}
|
||||
if (firstPath === 'dialect') {
|
||||
return 'Invalid dialect. Expected sqlite/mysql/postgres.';
|
||||
}
|
||||
if (firstPath === 'connectionString') {
|
||||
return 'Invalid connectionString. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'overwrite') {
|
||||
return 'Invalid overwrite. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'ssl') {
|
||||
return 'Invalid ssl. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'data') {
|
||||
return '导入数据格式错误:需要 JSON 对象';
|
||||
}
|
||||
if (firstPath === 'webhookEnabled') {
|
||||
return 'Webhook 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'modelAvailabilityProbeEnabled') {
|
||||
return '批量测活开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'barkEnabled') {
|
||||
return 'Bark 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'serverChanEnabled') {
|
||||
return 'Server 酱开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'telegramEnabled') {
|
||||
return 'Telegram 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'telegramUseSystemProxy') {
|
||||
return 'Telegram 使用系统代理格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'smtpEnabled') {
|
||||
return 'SMTP 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'smtpSecure') {
|
||||
return 'SMTP 安全连接格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'logCleanupUsageLogsEnabled') {
|
||||
return '自动清理使用日志格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'logCleanupProgramLogsEnabled') {
|
||||
return '自动清理程序日志格式无效:需要 boolean';
|
||||
}
|
||||
return 'Invalid settings payload.';
|
||||
}
|
||||
|
||||
export function parseRuntimeSettingsPayload(input: unknown):
|
||||
{ success: true; data: RuntimeSettingsPayload } | { success: false; error: string } {
|
||||
const result = runtimeSettingsPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSystemProxyTestPayload(input: unknown):
|
||||
{ success: true; data: SystemProxyTestPayload } | { success: false; error: string } {
|
||||
const result = systemProxyTestPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDatabaseMigrationPayload(input: unknown):
|
||||
{ success: true; data: DatabaseMigrationPayload } | { success: false; error: string } {
|
||||
const result = databaseMigrationPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupWebdavConfigPayload(input: unknown):
|
||||
{ success: true; data: BackupWebdavConfigPayload } | { success: false; error: string } {
|
||||
const result = backupWebdavConfigPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupImportPayload(input: unknown):
|
||||
{ success: true; data: BackupImportPayload } | { success: false; error: string } {
|
||||
const result = backupImportPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupWebdavExportPayload(input: unknown):
|
||||
{ success: true; data: BackupWebdavExportPayload } | { success: false; error: string } {
|
||||
const result = backupWebdavExportPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const requiredTrimmedString = z.string().trim().min(1);
|
||||
const unknownField = z.unknown().optional();
|
||||
|
||||
const siteCreatePayloadSchema = z.object({
|
||||
name: requiredTrimmedString,
|
||||
url: requiredTrimmedString,
|
||||
platform: z.string().trim().optional(),
|
||||
initializationPresetId: z.union([z.string(), z.null()]).optional(),
|
||||
proxyUrl: unknownField,
|
||||
useSystemProxy: unknownField,
|
||||
customHeaders: unknownField,
|
||||
externalCheckinUrl: unknownField,
|
||||
status: unknownField,
|
||||
isPinned: unknownField,
|
||||
sortOrder: unknownField,
|
||||
globalWeight: unknownField,
|
||||
}).passthrough();
|
||||
|
||||
const siteUpdatePayloadSchema = z.object({
|
||||
name: requiredTrimmedString.optional(),
|
||||
url: requiredTrimmedString.optional(),
|
||||
platform: requiredTrimmedString.optional(),
|
||||
proxyUrl: unknownField,
|
||||
useSystemProxy: unknownField,
|
||||
customHeaders: unknownField,
|
||||
externalCheckinUrl: unknownField,
|
||||
status: unknownField,
|
||||
isPinned: unknownField,
|
||||
sortOrder: unknownField,
|
||||
globalWeight: unknownField,
|
||||
}).passthrough();
|
||||
|
||||
const siteBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const siteDisabledModelsPayloadSchema = z.object({
|
||||
models: z.array(z.string()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const siteDetectPayloadSchema = z.object({
|
||||
url: requiredTrimmedString,
|
||||
}).passthrough();
|
||||
|
||||
export type SiteBatchPayload = z.output<typeof siteBatchPayloadSchema>;
|
||||
export type SiteCreatePayload = z.output<typeof siteCreatePayloadSchema>;
|
||||
export type SiteDetectPayload = z.output<typeof siteDetectPayloadSchema>;
|
||||
export type SiteDisabledModelsPayload = z.output<typeof siteDisabledModelsPayloadSchema>;
|
||||
export type SiteUpdatePayload = z.output<typeof siteUpdatePayloadSchema>;
|
||||
|
||||
function normalizeSitePayloadInput(input: unknown): unknown {
|
||||
if (input === undefined) return {};
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) return input;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatSitePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'url') {
|
||||
return 'Invalid url. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'platform') {
|
||||
return 'Invalid platform. Expected string.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'models') {
|
||||
return 'Invalid models. Expected string[].';
|
||||
}
|
||||
return 'Invalid site payload.';
|
||||
}
|
||||
|
||||
export function parseSiteCreatePayload(input: unknown):
|
||||
{ success: true; data: SiteCreatePayload } | { success: false; error: string } {
|
||||
const result = siteCreatePayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteUpdatePayload(input: unknown):
|
||||
{ success: true; data: SiteUpdatePayload } | { success: false; error: string } {
|
||||
const result = siteUpdatePayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteBatchPayload(input: unknown):
|
||||
{ success: true; data: SiteBatchPayload } | { success: false; error: string } {
|
||||
const result = siteBatchPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteDisabledModelsPayload(input: unknown):
|
||||
{ success: true; data: SiteDisabledModelsPayload } | { success: false; error: string } {
|
||||
const result = siteDisabledModelsPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteDetectPayload(input: unknown):
|
||||
{ success: true; data: SiteDetectPayload } | { success: false; error: string } {
|
||||
const result = siteDetectPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateCenterVersionSourceSchema = z.enum(['docker-hub-tag', 'github-release']);
|
||||
|
||||
const authChangePayloadSchema = z.object({
|
||||
oldToken: z.string().optional(),
|
||||
newToken: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const monitorConfigPayloadSchema = z.object({
|
||||
ldohCookie: z.union([z.string(), z.null()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthStartPayloadSchema = z.object({
|
||||
accountId: z.number().int().positive().optional(),
|
||||
projectId: z.string().optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthManualCallbackPayloadSchema = z.object({
|
||||
callbackUrl: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthConnectionRebindPayloadSchema = z.object({
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthConnectionProxyUpdatePayloadSchema = z.object({
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthQuotaBatchRefreshPayloadSchema = z.object({
|
||||
accountIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthImportPayloadSchema = z.object({
|
||||
data: z.unknown().optional(),
|
||||
items: z.array(z.object({}).passthrough()).optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthRouteUnitStrategySchema = z.preprocess((value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value.trim().toLowerCase();
|
||||
}, z.enum(['round_robin', 'stick_until_unavailable']));
|
||||
|
||||
const oauthRouteUnitCreatePayloadSchema = z.object({
|
||||
accountIds: z.array(z.number().int().positive()).optional(),
|
||||
name: z.string().optional(),
|
||||
strategy: oauthRouteUnitStrategySchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthRouteUnitUpdatePayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
strategy: oauthRouteUnitStrategySchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterConfigPayloadSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
helperBaseUrl: z.string().optional(),
|
||||
namespace: z.string().optional(),
|
||||
releaseName: z.string().optional(),
|
||||
chartRef: z.string().optional(),
|
||||
imageRepository: z.string().optional(),
|
||||
githubReleasesEnabled: z.boolean().optional(),
|
||||
dockerHubTagsEnabled: z.boolean().optional(),
|
||||
defaultDeploySource: updateCenterVersionSourceSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterDeployPayloadSchema = z.object({
|
||||
source: updateCenterVersionSourceSchema.optional(),
|
||||
targetVersion: z.string().optional(),
|
||||
targetTag: z.string().optional(),
|
||||
targetDigest: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterRollbackPayloadSchema = z.object({
|
||||
targetRevision: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AuthChangePayload = z.output<typeof authChangePayloadSchema>;
|
||||
export type MonitorConfigPayload = z.output<typeof monitorConfigPayloadSchema>;
|
||||
export type OauthConnectionRebindPayload = z.output<typeof oauthConnectionRebindPayloadSchema>;
|
||||
export type OauthConnectionProxyUpdatePayload = z.output<typeof oauthConnectionProxyUpdatePayloadSchema>;
|
||||
export type OauthImportPayload = z.output<typeof oauthImportPayloadSchema>;
|
||||
export type OauthManualCallbackPayload = z.output<typeof oauthManualCallbackPayloadSchema>;
|
||||
export type OauthQuotaBatchRefreshPayload = z.output<typeof oauthQuotaBatchRefreshPayloadSchema>;
|
||||
export type OauthRouteUnitCreatePayload = z.output<typeof oauthRouteUnitCreatePayloadSchema>;
|
||||
export type OauthRouteUnitUpdatePayload = z.output<typeof oauthRouteUnitUpdatePayloadSchema>;
|
||||
export type OauthStartPayload = z.output<typeof oauthStartPayloadSchema>;
|
||||
export type UpdateCenterConfigPayload = z.output<typeof updateCenterConfigPayloadSchema>;
|
||||
export type UpdateCenterDeployPayload = z.output<typeof updateCenterDeployPayloadSchema>;
|
||||
export type UpdateCenterRollbackPayload = z.output<typeof updateCenterRollbackPayloadSchema>;
|
||||
|
||||
function normalizeSupportRoutePayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatSupportRoutePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'oldToken') {
|
||||
return 'Invalid oldToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'newToken') {
|
||||
return 'Invalid newToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'ldohCookie') {
|
||||
return 'Invalid ldohCookie. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'projectId') {
|
||||
return 'Invalid projectId. Expected string.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return 'Invalid proxyUrl. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'useSystemProxy') {
|
||||
return 'Invalid useSystemProxy. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'accountIds') {
|
||||
return 'Invalid accountIds. Expected positive number array.';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'strategy') {
|
||||
return 'Invalid strategy. Expected round_robin/stick_until_unavailable.';
|
||||
}
|
||||
if (firstPath === 'callbackUrl') {
|
||||
return 'Invalid callbackUrl. Expected string.';
|
||||
}
|
||||
if (firstPath === 'data') {
|
||||
return 'Invalid data. Expected object.';
|
||||
}
|
||||
if (firstPath === 'items') {
|
||||
return 'Invalid items. Expected object array.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'helperBaseUrl') {
|
||||
return 'Invalid helperBaseUrl. Expected string.';
|
||||
}
|
||||
if (firstPath === 'namespace') {
|
||||
return 'Invalid namespace. Expected string.';
|
||||
}
|
||||
if (firstPath === 'releaseName') {
|
||||
return 'Invalid releaseName. Expected string.';
|
||||
}
|
||||
if (firstPath === 'chartRef') {
|
||||
return 'Invalid chartRef. Expected string.';
|
||||
}
|
||||
if (firstPath === 'imageRepository') {
|
||||
return 'Invalid imageRepository. Expected string.';
|
||||
}
|
||||
if (firstPath === 'githubReleasesEnabled') {
|
||||
return 'Invalid githubReleasesEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'dockerHubTagsEnabled') {
|
||||
return 'Invalid dockerHubTagsEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'defaultDeploySource') {
|
||||
return 'Invalid defaultDeploySource. Expected docker-hub-tag/github-release.';
|
||||
}
|
||||
if (firstPath === 'source') {
|
||||
return 'Invalid source. Expected docker-hub-tag/github-release.';
|
||||
}
|
||||
if (firstPath === 'targetVersion') {
|
||||
return 'Invalid targetVersion. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetTag') {
|
||||
return 'Invalid targetTag. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetDigest') {
|
||||
return 'Invalid targetDigest. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetRevision') {
|
||||
return 'Invalid targetRevision. Expected string.';
|
||||
}
|
||||
return 'Invalid support route payload.';
|
||||
}
|
||||
|
||||
function parseSupportRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeSupportRoutePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSupportRoutePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAuthChangePayload(input: unknown):
|
||||
{ success: true; data: AuthChangePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(authChangePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseMonitorConfigPayload(input: unknown):
|
||||
{ success: true; data: MonitorConfigPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(monitorConfigPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthStartPayload(input: unknown):
|
||||
{ success: true; data: OauthStartPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthStartPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthManualCallbackPayload(input: unknown):
|
||||
{ success: true; data: OauthManualCallbackPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthManualCallbackPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthConnectionRebindPayload(input: unknown):
|
||||
{ success: true; data: OauthConnectionRebindPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthConnectionRebindPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthConnectionProxyUpdatePayload(input: unknown):
|
||||
{ success: true; data: OauthConnectionProxyUpdatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthConnectionProxyUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthQuotaBatchRefreshPayload(input: unknown):
|
||||
{ success: true; data: OauthQuotaBatchRefreshPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthQuotaBatchRefreshPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthImportPayload(input: unknown):
|
||||
{ success: true; data: OauthImportPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthImportPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthRouteUnitCreatePayload(input: unknown):
|
||||
{ success: true; data: OauthRouteUnitCreatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthRouteUnitCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthRouteUnitUpdatePayload(input: unknown):
|
||||
{ success: true; data: OauthRouteUnitUpdatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthRouteUnitUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterConfigPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterConfigPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterConfigPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterDeployPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterDeployPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterDeployPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterRollbackPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterRollbackPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterRollbackPayloadSchema, input);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const routeChannelCreatePayloadSchema = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.string().optional(),
|
||||
priority: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const routeChannelBatchCreatePayloadSchema = z.object({
|
||||
channels: z.array(z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.string().optional(),
|
||||
}).passthrough()).min(1),
|
||||
}).passthrough();
|
||||
|
||||
const routeChannelUpdatePayloadSchema = z.object({
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.union([z.string(), z.null()]).optional(),
|
||||
priority: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteCreatePayloadSchema = z.object({
|
||||
routeMode: z.string().optional(),
|
||||
modelPattern: z.string().optional(),
|
||||
displayName: z.union([z.string(), z.null()]).optional(),
|
||||
displayIcon: z.union([z.string(), z.null()]).optional(),
|
||||
modelMapping: z.union([z.string(), z.null()]).optional(),
|
||||
routingStrategy: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sourceRouteIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteUpdatePayloadSchema = z.object({
|
||||
routeMode: z.string().optional(),
|
||||
modelPattern: z.string().optional(),
|
||||
displayName: z.union([z.string(), z.null()]).optional(),
|
||||
displayIcon: z.union([z.string(), z.null()]).optional(),
|
||||
modelMapping: z.union([z.string(), z.null()]).optional(),
|
||||
routingStrategy: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sourceRouteIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const routeRebuildPayloadSchema = z.object({
|
||||
refreshModels: z.boolean().optional(),
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type RouteChannelBatchCreatePayload = z.output<typeof routeChannelBatchCreatePayloadSchema>;
|
||||
export type RouteChannelCreatePayload = z.output<typeof routeChannelCreatePayloadSchema>;
|
||||
export type RouteChannelUpdatePayload = z.output<typeof routeChannelUpdatePayloadSchema>;
|
||||
export type RouteRebuildPayload = z.output<typeof routeRebuildPayloadSchema>;
|
||||
export type TokenRouteBatchPayload = z.output<typeof tokenRouteBatchPayloadSchema>;
|
||||
export type TokenRouteCreatePayload = z.output<typeof tokenRouteCreatePayloadSchema>;
|
||||
export type TokenRouteUpdatePayload = z.output<typeof tokenRouteUpdatePayloadSchema>;
|
||||
|
||||
function normalizeTokenRoutePayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatTokenRoutePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath, secondPath, thirdPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'routeMode') {
|
||||
return 'Invalid routeMode. Expected string.';
|
||||
}
|
||||
if (firstPath === 'modelPattern') {
|
||||
return 'Invalid modelPattern. Expected string.';
|
||||
}
|
||||
if (firstPath === 'displayName') {
|
||||
return 'Invalid displayName. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'displayIcon') {
|
||||
return 'Invalid displayIcon. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'modelMapping') {
|
||||
return 'Invalid modelMapping. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'routingStrategy') {
|
||||
return 'Invalid routingStrategy. Expected string.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'sourceRouteIds') {
|
||||
return 'Invalid sourceRouteIds. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'tokenId') {
|
||||
return 'Invalid tokenId. Expected positive number or null.';
|
||||
}
|
||||
if (firstPath === 'sourceModel') {
|
||||
return 'Invalid sourceModel. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'priority') {
|
||||
return 'Invalid priority. Expected number.';
|
||||
}
|
||||
if (firstPath === 'weight') {
|
||||
return 'Invalid weight. Expected number.';
|
||||
}
|
||||
if (firstPath === 'refreshModels') {
|
||||
return 'Invalid refreshModels. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'accountId') {
|
||||
return 'Invalid channels[].accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'tokenId') {
|
||||
return 'Invalid channels[].tokenId. Expected positive number or null.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'sourceModel') {
|
||||
return 'Invalid channels[].sourceModel. Expected string.';
|
||||
}
|
||||
if (firstPath === 'channels') {
|
||||
return 'Invalid channels. Expected channel array.';
|
||||
}
|
||||
return 'Invalid token route payload.';
|
||||
}
|
||||
|
||||
function parseTokenRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeTokenRoutePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatTokenRoutePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTokenRouteCreatePayload(input: unknown):
|
||||
{ success: true; data: TokenRouteCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseTokenRouteUpdatePayload(input: unknown):
|
||||
{ success: true; data: TokenRouteUpdatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseTokenRouteBatchPayload(input: unknown):
|
||||
{ success: true; data: TokenRouteBatchPayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteBatchPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelCreatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelBatchCreatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelBatchCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelBatchCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelUpdatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelUpdatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteRebuildPayload(input: unknown):
|
||||
{ success: true; data: RouteRebuildPayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeRebuildPayloadSchema, input);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(6).max(128),
|
||||
});
|
||||
|
||||
export const loginUserSchema = z.object({
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64).optional(),
|
||||
role: z.enum(['admin', 'user']).optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
});
|
||||
|
||||
export const updatePasswordSchema = z.object({
|
||||
oldPassword: z.string().min(1).max(128),
|
||||
newPassword: z.string().min(6).max(128),
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
export type AccountTokenSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface AccountTokenSchemaInspector {
|
||||
dialect: AccountTokenSchemaDialect;
|
||||
tableExists(table: string): Promise<boolean>;
|
||||
columnExists(table: string, column: string): Promise<boolean>;
|
||||
execute(sqlText: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type AccountTokenColumnCompatibilitySpec = {
|
||||
table: 'account_tokens';
|
||||
column: string;
|
||||
addSql: Record<AccountTokenSchemaDialect, string>;
|
||||
};
|
||||
|
||||
export const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [
|
||||
{
|
||||
table: 'account_tokens',
|
||||
column: 'token_group',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE account_tokens ADD COLUMN token_group text;',
|
||||
mysql: 'ALTER TABLE `account_tokens` ADD COLUMN `token_group` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'account_tokens',
|
||||
column: 'value_status',
|
||||
addSql: {
|
||||
sqlite: "ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';",
|
||||
mysql: "ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready'",
|
||||
postgres: "ALTER TABLE \"account_tokens\" ADD COLUMN \"value_status\" TEXT NOT NULL DEFAULT 'ready'",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
}
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column name');
|
||||
}
|
||||
|
||||
async function executeAddColumn(inspector: AccountTokenSchemaInspector, sqlText: string): Promise<void> {
|
||||
try {
|
||||
await inspector.execute(sqlText);
|
||||
} catch (error) {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAccountTokenSchemaCompatibility(inspector: AccountTokenSchemaInspector): Promise<void> {
|
||||
for (const spec of ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS) {
|
||||
const hasTable = await inspector.tableExists(spec.table);
|
||||
if (!hasTable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasColumn = await inspector.columnExists(spec.table, spec.column);
|
||||
if (!hasColumn) {
|
||||
await executeAddColumn(inspector, spec.addSql[inspector.dialect]);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1, `post_refresh_probe_enabled` BOOLEAN DEFAULT false, `post_refresh_probe_model` VARCHAR(191) DEFAULT '', `post_refresh_probe_scope` VARCHAR(191) DEFAULT 'single', `post_refresh_probe_latency_threshold_ms` INT DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `admin_snapshots` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `namespace` TEXT NOT NULL, `snapshot_key` TEXT NOT NULL, `payload` TEXT NOT NULL, `generated_at` VARCHAR(191) NOT NULL, `expires_at` VARCHAR(191) NOT NULL, `stale_until` VARCHAR(191) NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
|
||||
CREATE TABLE IF NOT EXISTS `analytics_projection_checkpoints` (`projector_key` VARCHAR(191) NOT NULL PRIMARY KEY, `time_zone` VARCHAR(191) NOT NULL DEFAULT 'Local', `last_proxy_log_id` INT NOT NULL DEFAULT 0, `watermark_created_at` VARCHAR(191), `recompute_from_id` INT, `recompute_requested_at` VARCHAR(191), `recompute_reason` TEXT, `recompute_started_at` VARCHAR(191), `recompute_completed_at` VARCHAR(191), `last_projected_at` VARCHAR(191), `last_successful_at` VARCHAR(191), `last_error` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `lease_owner` TEXT, `lease_token` TEXT, `lease_expires_at` VARCHAR(191));
|
||||
CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` VARCHAR(191), `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `group_name` TEXT, `tags` TEXT, `excluded_site_ids` JSON, `excluded_credential_refs` JSON);
|
||||
CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
|
||||
CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `model_day_usage` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `local_day` TEXT NOT NULL, `site_id` INT NOT NULL, `model` TEXT NOT NULL, `total_calls` INT NOT NULL DEFAULT 0, `success_calls` INT NOT NULL DEFAULT 0, `failed_calls` INT NOT NULL DEFAULT 0, `total_tokens` INT NOT NULL DEFAULT 0, `total_spend` DOUBLE NOT NULL DEFAULT 0, `total_latency_ms` INT NOT NULL DEFAULT 0, `latency_count` INT NOT NULL DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `oauth_route_units` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `provider` TEXT NOT NULL, `name` TEXT NOT NULL, `strategy` VARCHAR(191) NOT NULL DEFAULT 'round_robin', `enabled` BOOLEAN DEFAULT true, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `oauth_route_unit_members` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `unit_id` INT NOT NULL, `account_id` INT NOT NULL, `sort_order` INT DEFAULT 0, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`unit_id`) REFERENCES `oauth_route_units`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_debug_traces` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `downstream_path` TEXT NOT NULL, `client_kind` TEXT, `session_id` TEXT, `trace_hint` TEXT, `requested_model` TEXT, `downstream_api_key_id` INT, `request_headers_json` JSON, `request_body_json` JSON, `sticky_session_key` TEXT, `sticky_hit_channel_id` INT, `selected_channel_id` INT, `selected_route_id` INT, `selected_account_id` INT, `selected_site_id` INT, `selected_site_platform` TEXT, `endpoint_candidates_json` JSON, `endpoint_runtime_state_json` JSON, `decision_summary_json` JSON, `final_status` TEXT, `final_http_status` INT, `final_upstream_path` TEXT, `final_response_headers_json` JSON, `final_response_body_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
|
||||
CREATE TABLE IF NOT EXISTS `proxy_debug_attempts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `trace_id` INT NOT NULL, `attempt_index` INT NOT NULL, `endpoint` TEXT NOT NULL, `request_path` TEXT NOT NULL, `target_url` TEXT NOT NULL, `runtime_executor` TEXT, `request_headers_json` JSON, `request_body_json` JSON, `response_status` INT, `response_headers_json` JSON, `response_body_json` JSON, `raw_error_text` TEXT, `recover_applied` BOOLEAN DEFAULT false, `downgrade_decision` BOOLEAN DEFAULT false, `downgrade_reason` TEXT, `memory_write_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`trace_id`) REFERENCES `proxy_debug_traces`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `deleted_at` VARCHAR(191));
|
||||
CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `billing_details` JSON, `downstream_api_key_id` INT, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT, `is_stream` BOOLEAN, `first_byte_latency_ms` INT);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
|
||||
CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` VARCHAR(191), `routing_strategy` VARCHAR(191) DEFAULT 'weighted', `route_mode` VARCHAR(191) DEFAULT 'pattern');
|
||||
CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), `oauth_route_unit_id` INT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT);
|
||||
CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` VARCHAR(191), `ends_at` VARCHAR(191), `upstream_created_at` VARCHAR(191), `upstream_updated_at` VARCHAR(191), `first_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `last_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `read_at` VARCHAR(191), `dismissed_at` VARCHAR(191), `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `site_api_endpoints` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `url` TEXT NOT NULL, `enabled` BOOLEAN DEFAULT true, `sort_order` INT DEFAULT 0, `cooldown_until` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_failed_at` VARCHAR(191), `last_failure_reason` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `site_day_usage` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `local_day` TEXT NOT NULL, `site_id` INT NOT NULL, `total_calls` INT NOT NULL DEFAULT 0, `success_calls` INT NOT NULL DEFAULT 0, `failed_calls` INT NOT NULL DEFAULT 0, `total_tokens` INT NOT NULL DEFAULT 0, `total_summary_spend` DOUBLE NOT NULL DEFAULT 0, `total_site_spend` DOUBLE NOT NULL DEFAULT 0, `total_latency_ms` INT NOT NULL DEFAULT 0, `latency_count` INT NOT NULL DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `site_hour_usage` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `bucket_start_utc` TEXT NOT NULL, `site_id` INT NOT NULL, `total_calls` INT NOT NULL DEFAULT 0, `success_calls` INT NOT NULL DEFAULT 0, `failed_calls` INT NOT NULL DEFAULT 0, `total_tokens` INT NOT NULL DEFAULT 0, `total_summary_spend` DOUBLE NOT NULL DEFAULT 0, `total_site_spend` DOUBLE NOT NULL DEFAULT 0, `total_latency_ms` INT NOT NULL DEFAULT 0, `latency_count` INT NOT NULL DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX `admin_snapshots_namespace_key_unique` ON `admin_snapshots` (`namespace`(191), `snapshot_key`(191));
|
||||
CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191));
|
||||
CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191));
|
||||
CREATE UNIQUE INDEX `model_day_usage_day_site_model_unique` ON `model_day_usage` (`local_day`(191), `site_id`, `model`(191));
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_account_unique` ON `oauth_route_unit_members` (`account_id`);
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_unit_account_unique` ON `oauth_route_unit_members` (`unit_id`, `account_id`);
|
||||
CREATE UNIQUE INDEX `proxy_debug_attempts_trace_attempt_unique` ON `proxy_debug_attempts` (`trace_id`, `attempt_index`);
|
||||
CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191));
|
||||
CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191));
|
||||
CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`);
|
||||
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191));
|
||||
CREATE UNIQUE INDEX `site_api_endpoints_site_url_unique` ON `site_api_endpoints` (`site_id`, `url`(191));
|
||||
CREATE UNIQUE INDEX `site_day_usage_day_site_unique` ON `site_day_usage` (`local_day`(191), `site_id`);
|
||||
CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191));
|
||||
CREATE UNIQUE INDEX `site_hour_usage_hour_site_unique` ON `site_hour_usage` (`bucket_start_utc`(191), `site_id`);
|
||||
CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191));
|
||||
CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191));
|
||||
CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`);
|
||||
CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`);
|
||||
CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`);
|
||||
CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`(191), `oauth_account_key`(191), `oauth_project_id`(191));
|
||||
CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`(191));
|
||||
CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`);
|
||||
CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191));
|
||||
CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191));
|
||||
CREATE INDEX `admin_snapshots_expires_at_idx` ON `admin_snapshots` (`expires_at`);
|
||||
CREATE INDEX `admin_snapshots_stale_until_idx` ON `admin_snapshots` (`stale_until`);
|
||||
CREATE INDEX `analytics_projection_checkpoints_lease_expires_at_idx` ON `analytics_projection_checkpoints` (`lease_expires_at`);
|
||||
CREATE INDEX `analytics_projection_checkpoints_recompute_from_id_idx` ON `analytics_projection_checkpoints` (`recompute_from_id`);
|
||||
CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`);
|
||||
CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`);
|
||||
CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`(191));
|
||||
CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`);
|
||||
CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`);
|
||||
CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191));
|
||||
CREATE INDEX `events_created_at_idx` ON `events` (`created_at`);
|
||||
CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`);
|
||||
CREATE INDEX `events_type_created_at_idx` ON `events` (`type`(191), `created_at`);
|
||||
CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`);
|
||||
CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191));
|
||||
CREATE INDEX `model_day_usage_day_idx` ON `model_day_usage` (`local_day`(191));
|
||||
CREATE INDEX `model_day_usage_model_idx` ON `model_day_usage` (`model`(191));
|
||||
CREATE INDEX `model_day_usage_site_id_idx` ON `model_day_usage` (`site_id`);
|
||||
CREATE INDEX `oauth_route_unit_members_unit_cooldown_idx` ON `oauth_route_unit_members` (`unit_id`, `cooldown_until`);
|
||||
CREATE INDEX `oauth_route_unit_members_unit_sort_idx` ON `oauth_route_unit_members` (`unit_id`, `sort_order`);
|
||||
CREATE INDEX `oauth_route_units_enabled_idx` ON `oauth_route_units` (`enabled`);
|
||||
CREATE INDEX `oauth_route_units_site_provider_idx` ON `oauth_route_units` (`site_id`, `provider`(191));
|
||||
CREATE INDEX `proxy_debug_attempts_trace_created_at_idx` ON `proxy_debug_attempts` (`trace_id`, `created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_created_at_idx` ON `proxy_debug_traces` (`created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_final_status_created_at_idx` ON `proxy_debug_traces` (`final_status`(191), `created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_model_created_at_idx` ON `proxy_debug_traces` (`requested_model`(191), `created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_session_created_at_idx` ON `proxy_debug_traces` (`session_id`(191), `created_at`);
|
||||
CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`);
|
||||
CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`);
|
||||
CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`);
|
||||
CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`);
|
||||
CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`);
|
||||
CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`);
|
||||
CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`);
|
||||
CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191), `created_at`);
|
||||
CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`);
|
||||
CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191));
|
||||
CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`);
|
||||
CREATE INDEX `route_channels_oauth_route_unit_id_idx` ON `route_channels` (`oauth_route_unit_id`);
|
||||
CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`);
|
||||
CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`);
|
||||
CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`);
|
||||
CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`);
|
||||
CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`);
|
||||
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
|
||||
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`);
|
||||
CREATE INDEX `site_api_endpoints_site_cooldown_idx` ON `site_api_endpoints` (`site_id`, `cooldown_until`);
|
||||
CREATE INDEX `site_api_endpoints_site_enabled_sort_idx` ON `site_api_endpoints` (`site_id`, `enabled`, `sort_order`);
|
||||
CREATE INDEX `site_day_usage_day_idx` ON `site_day_usage` (`local_day`(191));
|
||||
CREATE INDEX `site_day_usage_site_id_idx` ON `site_day_usage` (`site_id`);
|
||||
CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`);
|
||||
CREATE INDEX `site_hour_usage_hour_idx` ON `site_hour_usage` (`bucket_start_utc`(191));
|
||||
CREATE INDEX `site_hour_usage_site_id_idx` ON `site_hour_usage` (`site_id`);
|
||||
CREATE INDEX `sites_status_idx` ON `sites` (`status`(191));
|
||||
CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`);
|
||||
CREATE INDEX `token_model_availability_model_name_idx` ON `token_model_availability` (`model_name`(191));
|
||||
CREATE INDEX `token_model_availability_token_available_idx` ON `token_model_availability` (`token_id`, `available`);
|
||||
CREATE INDEX `token_routes_enabled_idx` ON `token_routes` (`enabled`);
|
||||
CREATE INDEX `token_routes_model_pattern_idx` ON `token_routes` (`model_pattern`(191));
|
||||
@@ -1 +0,0 @@
|
||||
-- no schema changes detected for mysql
|
||||
@@ -1,111 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1, "post_refresh_probe_enabled" BOOLEAN DEFAULT false, "post_refresh_probe_model" TEXT DEFAULT '', "post_refresh_probe_scope" TEXT DEFAULT 'single', "post_refresh_probe_latency_threshold_ms" INTEGER DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "admin_snapshots" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "namespace" TEXT NOT NULL, "snapshot_key" TEXT NOT NULL, "payload" TEXT NOT NULL, "generated_at" TEXT NOT NULL, "expires_at" TEXT NOT NULL, "stale_until" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
|
||||
CREATE TABLE IF NOT EXISTS "analytics_projection_checkpoints" ("projector_key" TEXT NOT NULL PRIMARY KEY, "time_zone" TEXT NOT NULL DEFAULT 'Local', "last_proxy_log_id" INTEGER NOT NULL DEFAULT 0, "watermark_created_at" TEXT, "recompute_from_id" INTEGER, "recompute_requested_at" TEXT, "recompute_reason" TEXT, "recompute_started_at" TEXT, "recompute_completed_at" TEXT, "last_projected_at" TEXT, "last_successful_at" TEXT, "last_error" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "lease_owner" TEXT, "lease_token" TEXT, "lease_expires_at" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "group_name" TEXT, "tags" TEXT, "excluded_site_ids" JSONB, "excluded_credential_refs" JSONB);
|
||||
CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
|
||||
CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "model_day_usage" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "local_day" TEXT NOT NULL, "site_id" INTEGER NOT NULL, "model" TEXT NOT NULL, "total_calls" INTEGER NOT NULL DEFAULT 0, "success_calls" INTEGER NOT NULL DEFAULT 0, "failed_calls" INTEGER NOT NULL DEFAULT 0, "total_tokens" INTEGER NOT NULL DEFAULT 0, "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_latency_ms" INTEGER NOT NULL DEFAULT 0, "latency_count" INTEGER NOT NULL DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "oauth_route_units" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "provider" TEXT NOT NULL, "name" TEXT NOT NULL, "strategy" TEXT NOT NULL DEFAULT 'round_robin', "enabled" BOOLEAN DEFAULT true, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "oauth_route_unit_members" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "unit_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "sort_order" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("unit_id") REFERENCES "oauth_route_units"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_debug_traces" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "downstream_path" TEXT NOT NULL, "client_kind" TEXT, "session_id" TEXT, "trace_hint" TEXT, "requested_model" TEXT, "downstream_api_key_id" INTEGER, "request_headers_json" JSONB, "request_body_json" JSONB, "sticky_session_key" TEXT, "sticky_hit_channel_id" INTEGER, "selected_channel_id" INTEGER, "selected_route_id" INTEGER, "selected_account_id" INTEGER, "selected_site_id" INTEGER, "selected_site_platform" TEXT, "endpoint_candidates_json" JSONB, "endpoint_runtime_state_json" JSONB, "decision_summary_json" JSONB, "final_status" TEXT, "final_http_status" INTEGER, "final_upstream_path" TEXT, "final_response_headers_json" JSONB, "final_response_body_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
|
||||
CREATE TABLE IF NOT EXISTS "proxy_debug_attempts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "trace_id" INTEGER NOT NULL, "attempt_index" INTEGER NOT NULL, "endpoint" TEXT NOT NULL, "request_path" TEXT NOT NULL, "target_url" TEXT NOT NULL, "runtime_executor" TEXT, "request_headers_json" JSONB, "request_body_json" JSONB, "response_status" INTEGER, "response_headers_json" JSONB, "response_body_json" JSONB, "raw_error_text" TEXT, "recover_applied" BOOLEAN DEFAULT false, "downgrade_decision" BOOLEAN DEFAULT false, "downgrade_reason" TEXT, "memory_write_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("trace_id") REFERENCES "proxy_debug_traces"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "deleted_at" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "billing_details" JSONB, "downstream_api_key_id" INTEGER, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT, "is_stream" BOOLEAN, "first_byte_latency_ms" INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
|
||||
CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "route_mode" TEXT DEFAULT 'pattern');
|
||||
CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, "oauth_route_unit_id" INTEGER, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TEXT, "ends_at" TEXT, "upstream_created_at" TEXT, "upstream_updated_at" TEXT, "first_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "last_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "read_at" TEXT, "dismissed_at" TEXT, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "site_api_endpoints" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "url" TEXT NOT NULL, "enabled" BOOLEAN DEFAULT true, "sort_order" INTEGER DEFAULT 0, "cooldown_until" TEXT, "last_selected_at" TEXT, "last_failed_at" TEXT, "last_failure_reason" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "site_day_usage" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "local_day" TEXT NOT NULL, "site_id" INTEGER NOT NULL, "total_calls" INTEGER NOT NULL DEFAULT 0, "success_calls" INTEGER NOT NULL DEFAULT 0, "failed_calls" INTEGER NOT NULL DEFAULT 0, "total_tokens" INTEGER NOT NULL DEFAULT 0, "total_summary_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_site_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_latency_ms" INTEGER NOT NULL DEFAULT 0, "latency_count" INTEGER NOT NULL DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "site_hour_usage" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "bucket_start_utc" TEXT NOT NULL, "site_id" INTEGER NOT NULL, "total_calls" INTEGER NOT NULL DEFAULT 0, "success_calls" INTEGER NOT NULL DEFAULT 0, "failed_calls" INTEGER NOT NULL DEFAULT 0, "total_tokens" INTEGER NOT NULL DEFAULT 0, "total_summary_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_site_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_latency_ms" INTEGER NOT NULL DEFAULT 0, "latency_count" INTEGER NOT NULL DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "admin_snapshots_namespace_key_unique" ON "admin_snapshots" ("namespace", "snapshot_key");
|
||||
CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key");
|
||||
CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name");
|
||||
CREATE UNIQUE INDEX "model_day_usage_day_site_model_unique" ON "model_day_usage" ("local_day", "site_id", "model");
|
||||
CREATE UNIQUE INDEX "oauth_route_unit_members_account_unique" ON "oauth_route_unit_members" ("account_id");
|
||||
CREATE UNIQUE INDEX "oauth_route_unit_members_unit_account_unique" ON "oauth_route_unit_members" ("unit_id", "account_id");
|
||||
CREATE UNIQUE INDEX "proxy_debug_attempts_trace_attempt_unique" ON "proxy_debug_attempts" ("trace_id", "attempt_index");
|
||||
CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id");
|
||||
CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id");
|
||||
CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id");
|
||||
CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key");
|
||||
CREATE UNIQUE INDEX "site_api_endpoints_site_url_unique" ON "site_api_endpoints" ("site_id", "url");
|
||||
CREATE UNIQUE INDEX "site_day_usage_day_site_unique" ON "site_day_usage" ("local_day", "site_id");
|
||||
CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name");
|
||||
CREATE UNIQUE INDEX "site_hour_usage_hour_site_unique" ON "site_hour_usage" ("bucket_start_utc", "site_id");
|
||||
CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url");
|
||||
CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name");
|
||||
CREATE INDEX "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled");
|
||||
CREATE INDEX "account_tokens_account_id_idx" ON "account_tokens" ("account_id");
|
||||
CREATE INDEX "account_tokens_enabled_idx" ON "account_tokens" ("enabled");
|
||||
CREATE INDEX "accounts_oauth_identity_idx" ON "accounts" ("oauth_provider", "oauth_account_key", "oauth_project_id");
|
||||
CREATE INDEX "accounts_oauth_provider_idx" ON "accounts" ("oauth_provider");
|
||||
CREATE INDEX "accounts_site_id_idx" ON "accounts" ("site_id");
|
||||
CREATE INDEX "accounts_site_status_idx" ON "accounts" ("site_id", "status");
|
||||
CREATE INDEX "accounts_status_idx" ON "accounts" ("status");
|
||||
CREATE INDEX "admin_snapshots_expires_at_idx" ON "admin_snapshots" ("expires_at");
|
||||
CREATE INDEX "admin_snapshots_stale_until_idx" ON "admin_snapshots" ("stale_until");
|
||||
CREATE INDEX "analytics_projection_checkpoints_lease_expires_at_idx" ON "analytics_projection_checkpoints" ("lease_expires_at");
|
||||
CREATE INDEX "analytics_projection_checkpoints_recompute_from_id_idx" ON "analytics_projection_checkpoints" ("recompute_from_id");
|
||||
CREATE INDEX "checkin_logs_account_created_at_idx" ON "checkin_logs" ("account_id", "created_at");
|
||||
CREATE INDEX "checkin_logs_created_at_idx" ON "checkin_logs" ("created_at");
|
||||
CREATE INDEX "checkin_logs_status_idx" ON "checkin_logs" ("status");
|
||||
CREATE INDEX "downstream_api_keys_enabled_idx" ON "downstream_api_keys" ("enabled");
|
||||
CREATE INDEX "downstream_api_keys_expires_at_idx" ON "downstream_api_keys" ("expires_at");
|
||||
CREATE INDEX "downstream_api_keys_name_idx" ON "downstream_api_keys" ("name");
|
||||
CREATE INDEX "events_created_at_idx" ON "events" ("created_at");
|
||||
CREATE INDEX "events_read_created_at_idx" ON "events" ("read", "created_at");
|
||||
CREATE INDEX "events_type_created_at_idx" ON "events" ("type", "created_at");
|
||||
CREATE INDEX "model_availability_account_available_idx" ON "model_availability" ("account_id", "available");
|
||||
CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model_name");
|
||||
CREATE INDEX "model_day_usage_day_idx" ON "model_day_usage" ("local_day");
|
||||
CREATE INDEX "model_day_usage_model_idx" ON "model_day_usage" ("model");
|
||||
CREATE INDEX "model_day_usage_site_id_idx" ON "model_day_usage" ("site_id");
|
||||
CREATE INDEX "oauth_route_unit_members_unit_cooldown_idx" ON "oauth_route_unit_members" ("unit_id", "cooldown_until");
|
||||
CREATE INDEX "oauth_route_unit_members_unit_sort_idx" ON "oauth_route_unit_members" ("unit_id", "sort_order");
|
||||
CREATE INDEX "oauth_route_units_enabled_idx" ON "oauth_route_units" ("enabled");
|
||||
CREATE INDEX "oauth_route_units_site_provider_idx" ON "oauth_route_units" ("site_id", "provider");
|
||||
CREATE INDEX "proxy_debug_attempts_trace_created_at_idx" ON "proxy_debug_attempts" ("trace_id", "created_at");
|
||||
CREATE INDEX "proxy_debug_traces_created_at_idx" ON "proxy_debug_traces" ("created_at");
|
||||
CREATE INDEX "proxy_debug_traces_final_status_created_at_idx" ON "proxy_debug_traces" ("final_status", "created_at");
|
||||
CREATE INDEX "proxy_debug_traces_model_created_at_idx" ON "proxy_debug_traces" ("requested_model", "created_at");
|
||||
CREATE INDEX "proxy_debug_traces_session_created_at_idx" ON "proxy_debug_traces" ("session_id", "created_at");
|
||||
CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at");
|
||||
CREATE INDEX "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at");
|
||||
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
|
||||
CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at");
|
||||
CREATE INDEX "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at");
|
||||
CREATE INDEX "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at");
|
||||
CREATE INDEX "proxy_video_tasks_upstream_video_id_idx" ON "proxy_video_tasks" ("upstream_video_id");
|
||||
CREATE INDEX "route_channels_account_id_idx" ON "route_channels" ("account_id");
|
||||
CREATE INDEX "route_channels_oauth_route_unit_id_idx" ON "route_channels" ("oauth_route_unit_id");
|
||||
CREATE INDEX "route_channels_route_enabled_idx" ON "route_channels" ("route_id", "enabled");
|
||||
CREATE INDEX "route_channels_route_id_idx" ON "route_channels" ("route_id");
|
||||
CREATE INDEX "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id");
|
||||
CREATE INDEX "route_channels_token_id_idx" ON "route_channels" ("token_id");
|
||||
CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id");
|
||||
CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at");
|
||||
CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at");
|
||||
CREATE INDEX "site_api_endpoints_site_cooldown_idx" ON "site_api_endpoints" ("site_id", "cooldown_until");
|
||||
CREATE INDEX "site_api_endpoints_site_enabled_sort_idx" ON "site_api_endpoints" ("site_id", "enabled", "sort_order");
|
||||
CREATE INDEX "site_day_usage_day_idx" ON "site_day_usage" ("local_day");
|
||||
CREATE INDEX "site_day_usage_site_id_idx" ON "site_day_usage" ("site_id");
|
||||
CREATE INDEX "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id");
|
||||
CREATE INDEX "site_hour_usage_hour_idx" ON "site_hour_usage" ("bucket_start_utc");
|
||||
CREATE INDEX "site_hour_usage_site_id_idx" ON "site_hour_usage" ("site_id");
|
||||
CREATE INDEX "sites_status_idx" ON "sites" ("status");
|
||||
CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available");
|
||||
CREATE INDEX "token_model_availability_model_name_idx" ON "token_model_availability" ("model_name");
|
||||
CREATE INDEX "token_model_availability_token_available_idx" ON "token_model_availability" ("token_id", "available");
|
||||
CREATE INDEX "token_routes_enabled_idx" ON "token_routes" ("enabled");
|
||||
CREATE INDEX "token_routes_model_pattern_idx" ON "token_routes" ("model_pattern");
|
||||
@@ -1 +0,0 @@
|
||||
-- no schema changes detected for postgres
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('./index.js');
|
||||
|
||||
describe('sqlite default path resolution', () => {
|
||||
let dbModule: DbModule | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (dbModule) {
|
||||
await dbModule.closeDbConnections();
|
||||
dbModule = null;
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('uses an isolated temp sqlite path under vitest when no db env is configured', async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
|
||||
dbModule = await import('./index.js');
|
||||
const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath();
|
||||
const sharedRepoPath = resolve('./data/hub.db');
|
||||
|
||||
expect(sqlitePath).not.toBe(sharedRepoPath);
|
||||
expect(sqlitePath).toContain(tmpdir());
|
||||
expect(sqlitePath).toContain('metapi-vitest');
|
||||
});
|
||||
|
||||
it('still honors explicit DATA_DIR when provided', async () => {
|
||||
process.env.DATA_DIR = resolve(tmpdir(), 'metapi-explicit-data-dir');
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
|
||||
dbModule = await import('./index.js');
|
||||
const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath();
|
||||
|
||||
expect(sqlitePath).toBe(resolve(process.env.DATA_DIR, 'hub.db'));
|
||||
});
|
||||
|
||||
it('ignores the default repo DATA_DIR under vitest and still isolates sqlite', async () => {
|
||||
process.env.DATA_DIR = './data';
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
|
||||
dbModule = await import('./index.js');
|
||||
const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath();
|
||||
const sharedRepoPath = resolve('./data/hub.db');
|
||||
|
||||
expect(sqlitePath).not.toBe(sharedRepoPath);
|
||||
expect(sqlitePath).toContain(tmpdir());
|
||||
expect(sqlitePath).toContain('metapi-vitest');
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type DbModule = typeof import('./index.js');
|
||||
|
||||
describe('db proxy query wrapper', () => {
|
||||
let testUtils: DbModule['__dbProxyTestUtils'];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'metapi-db-proxy-wrap-'));
|
||||
const dbModule = await import('./index.js');
|
||||
testUtils = dbModule.__dbProxyTestUtils;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testUtils.resetPostgresJsonTextParsersInstallStateForTests();
|
||||
});
|
||||
|
||||
it('wraps thenable query builders and provides all/get shims', async () => {
|
||||
const execute = vi.fn(async () => [{ id: 1, name: 'demo' }]);
|
||||
const queryLike = {
|
||||
execute,
|
||||
where() {
|
||||
return this;
|
||||
},
|
||||
then(onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) {
|
||||
return Promise.resolve(execute()).then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
|
||||
expect(testUtils.shouldWrapObject(queryLike)).toBe(true);
|
||||
const wrapped = testUtils.wrapQueryLike(queryLike as any);
|
||||
|
||||
const rows = await wrapped.where().all();
|
||||
const row = await wrapped.where().get();
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(rows).toEqual([{ id: 1, name: 'demo' }]);
|
||||
expect(row).toEqual({ id: 1, name: 'demo' });
|
||||
});
|
||||
|
||||
it('provides run shim for thenable query builders', async () => {
|
||||
const execute = vi.fn(async () => [{ changes: 3, lastInsertRowid: 9 }]);
|
||||
const queryLike = {
|
||||
execute,
|
||||
then(onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) {
|
||||
return Promise.resolve(execute()).then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
|
||||
const wrapped = testUtils.wrapQueryLike(queryLike as any);
|
||||
const runResult = await wrapped.run();
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(runResult).toEqual({ changes: 3, lastInsertRowid: 9 });
|
||||
expect(testUtils.shouldWrapObject(Promise.resolve())).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes postgres inserts with returning ids for numeric-id tables', async () => {
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{ id: 42 }],
|
||||
rowCount: 1,
|
||||
}));
|
||||
const executor = { query };
|
||||
|
||||
const result = await testUtils.pgProxyQuery(
|
||||
executor as any,
|
||||
'insert into "sites" ("name") values ($1)',
|
||||
['demo'],
|
||||
'execute',
|
||||
);
|
||||
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
text: 'insert into "sites" ("name") values ($1) returning id',
|
||||
values: ['demo'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
rows: [{ changes: 1, lastInsertRowid: 42 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes postgres inserts with returning ids for oauth route units', async () => {
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{ id: 7 }],
|
||||
rowCount: 1,
|
||||
}));
|
||||
const executor = { query };
|
||||
|
||||
const result = await testUtils.pgProxyQuery(
|
||||
executor as any,
|
||||
'insert into "oauth_route_units" ("site_id", "provider", "name") values ($1, $2, $3)',
|
||||
[1, 'codex', 'pool-a'],
|
||||
'execute',
|
||||
);
|
||||
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
text: 'insert into "oauth_route_units" ("site_id", "provider", "name") values ($1, $2, $3) returning id',
|
||||
values: [1, 'codex', 'pool-a'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
rows: [{ changes: 1, lastInsertRowid: 7 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds mysql pool options with jsonStrings enabled', () => {
|
||||
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', false)).toMatchObject({
|
||||
uri: 'mysql://root:pass@db.example.com:3306/metapi',
|
||||
jsonStrings: true,
|
||||
});
|
||||
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', true)).toMatchObject({
|
||||
uri: 'mysql://root:pass@db.example.com:3306/metapi',
|
||||
jsonStrings: true,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('builds postgres pool options with ssl when requested', () => {
|
||||
expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', false)).toEqual({
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
|
||||
});
|
||||
expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', true)).toMatchObject({
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('installs postgres JSON text parsers idempotently', () => {
|
||||
const setTypeParser = vi.fn();
|
||||
const fakeTypes = {
|
||||
builtins: {
|
||||
JSON: 114,
|
||||
JSONB: 3802,
|
||||
},
|
||||
setTypeParser,
|
||||
};
|
||||
|
||||
testUtils.installPostgresJsonTextParsers(fakeTypes as any);
|
||||
testUtils.installPostgresJsonTextParsers(fakeTypes as any);
|
||||
|
||||
expect(setTypeParser).toHaveBeenCalledTimes(2);
|
||||
expect(setTypeParser).toHaveBeenNthCalledWith(1, 114, 'text', expect.any(Function));
|
||||
expect(setTypeParser).toHaveBeenNthCalledWith(2, 3802, 'text', expect.any(Function));
|
||||
const parser = setTypeParser.mock.calls[0][2] as (value: string) => string;
|
||||
expect(parser('{"ok":true}')).toBe('{"ok":true}');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('./index.js');
|
||||
type InsertHelpersModule = typeof import('./insertHelpers.js');
|
||||
|
||||
describe('insert helpers', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let insertHelpers: InsertHelpersModule;
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-insert-helpers-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
await import('./migrate.js');
|
||||
const dbModule = await import('./index.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
insertHelpers = await import('./insertHelpers.js');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
if (dataDir) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts positive inserted ids from run results', () => {
|
||||
expect(insertHelpers.getInsertedRowId({ changes: 1, lastInsertRowid: 12 })).toBe(12);
|
||||
expect(insertHelpers.getInsertedRowId({ changes: 1, lastInsertRowid: 0 })).toBeNull();
|
||||
expect(insertHelpers.getInsertedRowId(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('throws when a required inserted id is missing', () => {
|
||||
expect(() => insertHelpers.requireInsertedRowId({ changes: 1, lastInsertRowid: 0 }, 'missing insert id'))
|
||||
.toThrow('missing insert id');
|
||||
});
|
||||
|
||||
it('inserts and reloads rows through the shared helper', async () => {
|
||||
const created = await insertHelpers.insertAndGetById({
|
||||
table: schema.sites,
|
||||
idColumn: schema.sites.id,
|
||||
values: {
|
||||
name: 'Shared helper site',
|
||||
url: 'https://shared-helper.example.com',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
isPinned: false,
|
||||
globalWeight: 1,
|
||||
sortOrder: 0,
|
||||
},
|
||||
insertErrorMessage: 'insert site failed',
|
||||
loadErrorMessage: 'load site failed',
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
name: 'Shared helper site',
|
||||
url: 'https://shared-helper.example.com',
|
||||
platform: 'codex',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index.js';
|
||||
|
||||
type InsertRunResult = {
|
||||
changes?: number;
|
||||
lastInsertRowid?: number;
|
||||
} | null | undefined;
|
||||
|
||||
export function getInsertedRowId(result: InsertRunResult): number | null {
|
||||
const insertedId = Number(result?.lastInsertRowid || 0);
|
||||
return insertedId > 0 ? insertedId : null;
|
||||
}
|
||||
|
||||
export function requireInsertedRowId(result: InsertRunResult, errorMessage: string): number {
|
||||
const insertedId = getInsertedRowId(result);
|
||||
if (insertedId == null) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return insertedId;
|
||||
}
|
||||
|
||||
export async function insertAndGetById<T>(input: {
|
||||
txDb?: typeof db;
|
||||
table: any;
|
||||
idColumn: any;
|
||||
values: Record<string, unknown>;
|
||||
insertErrorMessage: string;
|
||||
loadErrorMessage?: string;
|
||||
}): Promise<T> {
|
||||
const txDb = input.txDb ?? db;
|
||||
const inserted = await txDb.insert(input.table).values(input.values).run();
|
||||
const insertedId = requireInsertedRowId(inserted, input.insertErrorMessage);
|
||||
const created = await txDb.select().from(input.table).where(eq(input.idColumn, insertedId)).get();
|
||||
if (!created) {
|
||||
throw new Error(input.loadErrorMessage ?? input.insertErrorMessage);
|
||||
}
|
||||
return created as T;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
describe('legacySchemaCompat architecture boundaries', () => {
|
||||
it('derives feature-owned legacy mutations from compatibility specs instead of hardcoding a second full whitelist', () => {
|
||||
const source = readSource('./legacySchemaCompat.ts');
|
||||
|
||||
expect(source).toContain('ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS');
|
||||
expect(source).toContain('PROXY_FILE_COLUMN_COMPATIBILITY_SPECS');
|
||||
expect(source).toContain('ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS');
|
||||
expect(source).toContain('SITE_COLUMN_COMPATIBILITY_SPECS');
|
||||
expect(source).toContain('SITE_TABLE_COMPATIBILITY_SPECS');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
|
||||
|
||||
describe('legacy schema compat boundary', () => {
|
||||
it('allows only explicitly registered legacy upgrade shims', () => {
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN billing_details text;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN is_stream integer;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN first_byte_latency_ms integer;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('CREATE INDEX proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE sites ADD COLUMN brand_new_column text;')).toBe('forbidden');
|
||||
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden');
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS,
|
||||
ensureAccountTokenSchemaCompatibility,
|
||||
type AccountTokenSchemaInspector,
|
||||
} from './accountTokenSchemaCompatibility.js';
|
||||
import {
|
||||
ensureProxyFileSchemaCompatibility,
|
||||
PROXY_FILE_COLUMN_COMPATIBILITY_SPECS,
|
||||
PROXY_FILE_INDEX_COMPATIBILITY_SPECS,
|
||||
PROXY_FILE_TABLE_COMPATIBILITY_SPECS,
|
||||
type ProxyFileSchemaInspector,
|
||||
} from './proxyFileSchemaCompatibility.js';
|
||||
import {
|
||||
ensureRouteGroupingSchemaCompatibility,
|
||||
ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS,
|
||||
ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS,
|
||||
type RouteGroupingSchemaInspector,
|
||||
} from './routeGroupingSchemaCompatibility.js';
|
||||
import {
|
||||
ensureSharedIndexSchemaCompatibility,
|
||||
SHARED_INDEX_COMPATIBILITY_SPECS,
|
||||
type SharedIndexSchemaInspector,
|
||||
} from './sharedIndexSchemaCompatibility.js';
|
||||
import {
|
||||
ensureSiteSchemaCompatibility,
|
||||
SITE_COLUMN_COMPATIBILITY_SPECS,
|
||||
SITE_TABLE_COMPATIBILITY_SPECS,
|
||||
type SiteSchemaInspector,
|
||||
} from './siteSchemaCompatibility.js';
|
||||
|
||||
export type LegacySchemaCompatClassification = 'legacy' | 'forbidden';
|
||||
|
||||
export interface LegacySchemaCompatInspector extends
|
||||
SiteSchemaInspector,
|
||||
RouteGroupingSchemaInspector,
|
||||
ProxyFileSchemaInspector,
|
||||
AccountTokenSchemaInspector,
|
||||
SharedIndexSchemaInspector {}
|
||||
|
||||
const BOOTSTRAP_OWNED_LEGACY_TABLES = [
|
||||
'account_tokens',
|
||||
'token_model_availability',
|
||||
'proxy_video_tasks',
|
||||
'downstream_api_keys',
|
||||
];
|
||||
|
||||
const BOOTSTRAP_OWNED_LEGACY_COLUMNS = [
|
||||
'sites.status',
|
||||
'route_channels.token_id',
|
||||
'proxy_video_tasks.status_snapshot',
|
||||
'proxy_video_tasks.upstream_response_meta',
|
||||
'proxy_video_tasks.last_upstream_status',
|
||||
'proxy_video_tasks.last_polled_at',
|
||||
'downstream_api_keys.group_name',
|
||||
'downstream_api_keys.tags',
|
||||
'proxy_logs.billing_details',
|
||||
'proxy_logs.is_stream',
|
||||
'proxy_logs.first_byte_latency_ms',
|
||||
'proxy_logs.client_family',
|
||||
'proxy_logs.client_app_id',
|
||||
'proxy_logs.client_app_name',
|
||||
'proxy_logs.client_confidence',
|
||||
'proxy_logs.downstream_api_key_id',
|
||||
];
|
||||
|
||||
const BOOTSTRAP_OWNED_LEGACY_INDEXES = [
|
||||
'token_model_availability_token_model_unique',
|
||||
'proxy_video_tasks_public_id_unique',
|
||||
'proxy_video_tasks_upstream_video_id_idx',
|
||||
'downstream_api_keys_key_unique',
|
||||
'downstream_api_keys_name_idx',
|
||||
'downstream_api_keys_enabled_idx',
|
||||
'downstream_api_keys_expires_at_idx',
|
||||
'proxy_logs_client_app_id_created_at_idx',
|
||||
'proxy_logs_client_family_created_at_idx',
|
||||
'proxy_logs_downstream_api_key_created_at_idx',
|
||||
];
|
||||
|
||||
function normalizeSqlText(sqlText: string): string {
|
||||
return sqlText.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
function extractIndexName(sqlText: string): string | null {
|
||||
const match = normalizeSqlText(sqlText).match(
|
||||
/^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i,
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
const LEGACY_COMPAT_TABLES = new Set([
|
||||
...SITE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||
...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||
...PROXY_FILE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||
...BOOTSTRAP_OWNED_LEGACY_TABLES,
|
||||
]);
|
||||
|
||||
const LEGACY_COMPAT_COLUMNS = new Set([
|
||||
...SITE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `sites.${spec.column}`),
|
||||
...ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||
...ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||
...PROXY_FILE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||
...BOOTSTRAP_OWNED_LEGACY_COLUMNS,
|
||||
]);
|
||||
|
||||
const LEGACY_COMPAT_INDEXES = new Set([
|
||||
...SITE_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => spec.postCreateSql ? Object.values(spec.postCreateSql) : [])
|
||||
.flat()
|
||||
.map((sqlText) => extractIndexName(sqlText))
|
||||
.filter((indexName): indexName is string => Boolean(indexName)),
|
||||
...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => Object.values(spec.createSql))
|
||||
.flat()
|
||||
.map((sqlText) => extractIndexName(sqlText))
|
||||
.filter((indexName): indexName is string => Boolean(indexName)),
|
||||
...PROXY_FILE_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
|
||||
...BOOTSTRAP_OWNED_LEGACY_INDEXES,
|
||||
...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
|
||||
]);
|
||||
|
||||
const LEGACY_COMPAT_UPDATES = new Set(
|
||||
SITE_COLUMN_COMPATIBILITY_SPECS
|
||||
.flatMap((spec) => spec.normalizeSql ? Object.values(spec.normalizeSql) : [])
|
||||
.map((sqlText) => normalizeSqlText(sqlText)),
|
||||
);
|
||||
|
||||
export function classifyLegacyCompatMutation(sqlText: string): LegacySchemaCompatClassification {
|
||||
const normalized = normalizeSqlText(sqlText);
|
||||
|
||||
if (LEGACY_COMPAT_UPDATES.has(normalized)) {
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
const createTableMatch = normalized.match(/^create table if not exists [`"]?([a-z0-9_]+)[`"]?/i);
|
||||
if (createTableMatch) {
|
||||
return LEGACY_COMPAT_TABLES.has(createTableMatch[1]) ? 'legacy' : 'forbidden';
|
||||
}
|
||||
|
||||
const alterTableMatch = normalized.match(
|
||||
/^alter table [`"]?([a-z0-9_]+)[`"]? add column [`"]?([a-z0-9_]+)[`"]?/i,
|
||||
);
|
||||
if (alterTableMatch) {
|
||||
const [, tableName, columnName] = alterTableMatch;
|
||||
return LEGACY_COMPAT_COLUMNS.has(`${tableName}.${columnName}`) ? 'legacy' : 'forbidden';
|
||||
}
|
||||
|
||||
const createIndexMatch = normalized.match(
|
||||
/^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i,
|
||||
);
|
||||
if (createIndexMatch) {
|
||||
return LEGACY_COMPAT_INDEXES.has(createIndexMatch[1]) ? 'legacy' : 'forbidden';
|
||||
}
|
||||
|
||||
return 'forbidden';
|
||||
}
|
||||
|
||||
function assertLegacyCompatMutation(sqlText: string): void {
|
||||
if (classifyLegacyCompatMutation(sqlText) === 'forbidden') {
|
||||
throw new Error(`Forbidden legacy schema mutation: ${sqlText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeLegacyCompat(
|
||||
execute: (sqlText: string) => Promise<void>,
|
||||
sqlText: string,
|
||||
): Promise<void> {
|
||||
assertLegacyCompatMutation(sqlText);
|
||||
await execute(sqlText);
|
||||
}
|
||||
|
||||
export function executeLegacyCompatSync(
|
||||
execute: (sqlText: string) => void,
|
||||
sqlText: string,
|
||||
): void {
|
||||
assertLegacyCompatMutation(sqlText);
|
||||
execute(sqlText);
|
||||
}
|
||||
|
||||
function wrapLegacyCompatInspector(inspector: LegacySchemaCompatInspector): LegacySchemaCompatInspector {
|
||||
return {
|
||||
...inspector,
|
||||
execute: async (sqlText: string) => {
|
||||
await executeLegacyCompat((statement) => inspector.execute(statement), sqlText);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureLegacySchemaCompatibility(inspector: LegacySchemaCompatInspector): Promise<void> {
|
||||
const wrappedInspector = wrapLegacyCompatInspector(inspector);
|
||||
await ensureSiteSchemaCompatibility(wrappedInspector);
|
||||
await ensureRouteGroupingSchemaCompatibility(wrappedInspector);
|
||||
await ensureProxyFileSchemaCompatibility(wrappedInspector);
|
||||
await ensureAccountTokenSchemaCompatibility(wrappedInspector);
|
||||
await ensureSharedIndexSchemaCompatibility(wrappedInspector);
|
||||
}
|
||||
@@ -1,633 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type MigrationJournalEntry = {
|
||||
tag: string;
|
||||
when: number;
|
||||
};
|
||||
|
||||
const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
|
||||
|
||||
function readMigrationJournalEntries(): MigrationJournalEntry[] {
|
||||
const journalPath = join(migrationsDir, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries?: MigrationJournalEntry[] };
|
||||
return journal.entries ?? [];
|
||||
}
|
||||
|
||||
function applyMigrationSql(sqlite: Database.Database, sqlText: string) {
|
||||
const statements = sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
}
|
||||
|
||||
function recordAppliedMigrations(
|
||||
sqlite: Database.Database,
|
||||
journalEntries: MigrationJournalEntry[],
|
||||
) {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
|
||||
const insert = sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)');
|
||||
for (const entry of journalEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
const hash = createHash('sha256').update(sqlText).digest('hex');
|
||||
insert.run(hash, entry.when);
|
||||
}
|
||||
}
|
||||
|
||||
const STALE_JOURNAL_TIMESTAMP_DRIFT_MS = 4_196_930;
|
||||
|
||||
describe('sqlite migrate bootstrap', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('accepts an already-synced sqlite schema with an empty drizzle journal', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
|
||||
for (const entry of journalEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const appliedRows = verified
|
||||
.prepare('select created_at from __drizzle_migrations order by created_at asc')
|
||||
.all() as Array<{ created_at: number }>;
|
||||
|
||||
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
|
||||
journalEntries.map((entry) => entry.when),
|
||||
);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('recovers from duplicate-column errors for single-statement migrations', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE account_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
token_group text
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0007_account_token_group',
|
||||
when: 1772500000000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0007_account_token_group.sql'),
|
||||
'ALTER TABLE `account_tokens` ADD `token_group` text;\n',
|
||||
);
|
||||
|
||||
const duplicateColumnError = new Error(
|
||||
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group",
|
||||
);
|
||||
|
||||
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite,
|
||||
tempMigrationsDir,
|
||||
duplicateColumnError,
|
||||
);
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const applied = sqlite
|
||||
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||
.all() as Array<{ hash: string; created_at: number }>;
|
||||
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(Number(applied[0].created_at)).toBe(1772500000000);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('recovers when duplicate-column message appears only in error cause', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-cause-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE account_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
token_group text
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-cause-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0007_account_token_group',
|
||||
when: 1772500000001,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0007_account_token_group.sql'),
|
||||
'ALTER TABLE `account_tokens` ADD `token_group` text;\n',
|
||||
);
|
||||
|
||||
const drizzleLikeError = {
|
||||
message: "DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n'",
|
||||
cause: {
|
||||
message: 'SqliteError: duplicate column name: token_group',
|
||||
},
|
||||
};
|
||||
|
||||
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite,
|
||||
tempMigrationsDir,
|
||||
drizzleLikeError,
|
||||
);
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const applied = sqlite
|
||||
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||
.all() as Array<{ hash: string; created_at: number }>;
|
||||
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(Number(applied[0].created_at)).toBe(1772500000001);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('updates only the latest matching migration record when reconciling stale timestamps', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-rowid-reconcile-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
);
|
||||
`);
|
||||
sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)').run('same-hash', 10);
|
||||
sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)').run('same-hash', 20);
|
||||
|
||||
const changed = __migrateTestUtils.markMigrationRecordIfMissing(sqlite, {
|
||||
hash: 'same-hash',
|
||||
createdAt: 30,
|
||||
});
|
||||
|
||||
const records = sqlite
|
||||
.prepare('SELECT rowid, hash, created_at FROM "__drizzle_migrations" ORDER BY rowid ASC')
|
||||
.all() as Array<{ rowid: number; hash: string; created_at: number }>;
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(records).toEqual([
|
||||
{ rowid: 1, hash: 'same-hash', created_at: 10 },
|
||||
{ rowid: 2, hash: 'same-hash', created_at: 30 },
|
||||
]);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('recovers duplicate-column errors when the failed SQL contains quoted literals', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-quoted-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE account_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
value_status text DEFAULT 'ready' NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-quoted-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0012_account_token_value_status',
|
||||
when: 1773665311013,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0012_account_token_value_status.sql'),
|
||||
"ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n",
|
||||
);
|
||||
|
||||
const duplicateColumnError = new Error(
|
||||
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n' duplicate column name: value_status",
|
||||
);
|
||||
|
||||
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite,
|
||||
tempMigrationsDir,
|
||||
duplicateColumnError,
|
||||
);
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const applied = sqlite
|
||||
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||
.all() as Array<{ hash: string; created_at: number }>;
|
||||
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(Number(applied[0].created_at)).toBe(1773665311013);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('recovers duplicate-column errors inside multi-statement migrations by replaying the full migration', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-multi-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE account_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
token_group text
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-multi-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0009_model_availability_is_manual',
|
||||
when: 1772600000000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0009_model_availability_is_manual.sql'),
|
||||
[
|
||||
'CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL);',
|
||||
'ALTER TABLE `account_tokens` ADD `token_group` text;',
|
||||
].join('\n--> statement-breakpoint\n'),
|
||||
);
|
||||
|
||||
const duplicateColumnError = new Error(
|
||||
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group",
|
||||
);
|
||||
|
||||
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite,
|
||||
tempMigrationsDir,
|
||||
duplicateColumnError,
|
||||
);
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const createdTable = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'downstream_api_keys'")
|
||||
.get() as { name?: string } | undefined;
|
||||
const applied = sqlite
|
||||
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||
.all() as Array<{ hash: string; created_at: number }>;
|
||||
|
||||
expect(createdTable?.name).toBe('downstream_api_keys');
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(Number(applied[0].created_at)).toBe(1772600000000);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('replays missing migrations before marking a duplicate-column migration as applied', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-partial-journal-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
const missingTags = new Set([
|
||||
'0006_site_disabled_models',
|
||||
'0007_account_token_group',
|
||||
'0008_sqlite_schema_backfill',
|
||||
'0009_model_availability_is_manual',
|
||||
'0010_proxy_logs_downstream_api_key',
|
||||
'0011_downstream_api_key_metadata',
|
||||
'0012_account_token_value_status',
|
||||
'0013_oauth_multi_provider',
|
||||
// 0008 creates downstream_api_keys, so later table-dependent migrations
|
||||
// must stay missing in this partial-journal fixture too.
|
||||
'0020_downstream_api_key_exclusions',
|
||||
]);
|
||||
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
|
||||
|
||||
for (const entry of appliedEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
recordAppliedMigrations(sqlite, appliedEntries);
|
||||
|
||||
// Simulate legacy compatibility code adding token_group before the formal 0007 migration ran.
|
||||
sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;');
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const appliedRows = verified
|
||||
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at ASC')
|
||||
.all() as Array<{ created_at: number }>;
|
||||
const disabledModelsTable = verified
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'site_disabled_models'")
|
||||
.get() as { name?: string } | undefined;
|
||||
const downstreamApiKeysTable = verified
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'downstream_api_keys'")
|
||||
.get() as { name?: string } | undefined;
|
||||
|
||||
expect(disabledModelsTable?.name).toBe('site_disabled_models');
|
||||
expect(downstreamApiKeysTable?.name).toBe('downstream_api_keys');
|
||||
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
|
||||
journalEntries.map((entry) => entry.when),
|
||||
);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('recovers sequential duplicate-column migrations when a legacy sqlite schema predates the drizzle journal', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-legacy-schema-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0019_proxy_logs_stream_timing');
|
||||
|
||||
for (const entry of appliedEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
|
||||
// Simulate SQLite legacy compatibility code partially adding the latest proxy log columns
|
||||
// before drizzle creates its own migration journal.
|
||||
sqlite.exec('ALTER TABLE proxy_logs ADD COLUMN is_stream integer;');
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const appliedRows = verified
|
||||
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at ASC')
|
||||
.all() as Array<{ created_at: number }>;
|
||||
const proxyLogColumns = verified
|
||||
.prepare('PRAGMA table_info("proxy_logs")')
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
|
||||
journalEntries.map((entry) => entry.when),
|
||||
);
|
||||
expect(proxyLogColumns.some((column) => column.name === 'is_stream')).toBe(true);
|
||||
expect(proxyLogColumns.some((column) => column.name === 'first_byte_latency_ms')).toBe(true);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('reconciles stale migration timestamps when the latest migration hash already exists', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-stale-timestamp-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
|
||||
for (const entry of journalEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
recordAppliedMigrations(sqlite, journalEntries);
|
||||
|
||||
const latestEntry = journalEntries.at(-1);
|
||||
const latestSqlText = latestEntry
|
||||
? readFileSync(join(migrationsDir, `${latestEntry.tag}.sql`), 'utf8')
|
||||
: '';
|
||||
const latestHash = createHash('sha256').update(latestSqlText).digest('hex');
|
||||
// Introduce about 70 minutes of timestamp drift so journal reconciliation has work to do.
|
||||
sqlite
|
||||
.prepare('UPDATE __drizzle_migrations SET created_at = ? WHERE hash = ?')
|
||||
.run((latestEntry?.when ?? 0) - STALE_JOURNAL_TIMESTAMP_DRIFT_MS, latestHash);
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const latestApplied = verified
|
||||
.prepare('SELECT created_at FROM __drizzle_migrations WHERE hash = ? LIMIT 1')
|
||||
.get(latestHash) as { created_at: number } | undefined;
|
||||
|
||||
expect(Number(latestApplied?.created_at)).toBe(latestEntry?.when);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('deduplicates legacy duplicate sites before applying the oauth site unique index', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-duplicate-sites-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0013_oauth_multi_provider');
|
||||
|
||||
for (const entry of appliedEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
recordAppliedMigrations(sqlite, appliedEntries);
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO sites (id, name, url, platform, status, is_pinned, sort_order, global_weight)
|
||||
VALUES
|
||||
(101, 'Primary Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'active', 0, 0, 1),
|
||||
(202, 'Duplicate Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'disabled', 1, 9, 3);
|
||||
|
||||
INSERT INTO accounts (site_id, username, access_token, status, checkin_enabled)
|
||||
VALUES
|
||||
(101, 'first@example.com', 'token-a', 'active', 0),
|
||||
(202, 'second@example.com', 'token-b', 'disabled', 0);
|
||||
|
||||
INSERT INTO site_disabled_models (site_id, model_name)
|
||||
VALUES
|
||||
(101, 'gpt-5'),
|
||||
(202, 'gpt-5'),
|
||||
(202, 'gpt-5-mini');
|
||||
`);
|
||||
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const sites = verified
|
||||
.prepare('SELECT id, name, url, platform, status, is_pinned, sort_order, global_weight FROM sites ORDER BY id ASC')
|
||||
.all() as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
platform: string;
|
||||
status: string;
|
||||
is_pinned: number;
|
||||
sort_order: number;
|
||||
global_weight: number;
|
||||
}>;
|
||||
const accounts = verified
|
||||
.prepare('SELECT username, site_id FROM accounts ORDER BY username ASC')
|
||||
.all() as Array<{ username: string; site_id: number }>;
|
||||
const disabledModels = verified
|
||||
.prepare('SELECT site_id, model_name FROM site_disabled_models ORDER BY site_id ASC, model_name ASC')
|
||||
.all() as Array<{ site_id: number; model_name: string }>;
|
||||
|
||||
expect(sites).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 101,
|
||||
name: 'Primary Codex',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
}),
|
||||
]);
|
||||
expect(accounts).toEqual([
|
||||
{ username: 'first@example.com', site_id: 101 },
|
||||
{ username: 'second@example.com', site_id: 101 },
|
||||
]);
|
||||
expect(disabledModels).toEqual([
|
||||
{ site_id: 101, model_name: 'gpt-5' },
|
||||
{ site_id: 101, model_name: 'gpt-5-mini' },
|
||||
]);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('fails fast when duplicate-column recovery exceeds the retry budget', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-retry-budget-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule as {
|
||||
__migrateTestUtils: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const runSqliteMigrationRecoveryLoop = __migrateTestUtils
|
||||
.runSqliteMigrationRecoveryLoop as ((input: {
|
||||
runMigrate: () => void;
|
||||
recoverDuplicateColumnMigrationError: (error: unknown) => { tag: string; recoveredCount: number } | null;
|
||||
isSitesPlatformUrlUniqueConflictError: (error: unknown) => boolean;
|
||||
deduplicateLegacySitesForUniqueIndex: () => boolean;
|
||||
closeSqlite: () => void;
|
||||
}) => void) | undefined;
|
||||
const retryBudget = __migrateTestUtils.sqliteMigrationRecoveryRetryBudget as number | undefined;
|
||||
|
||||
const runMigrate = vi.fn(() => {
|
||||
throw new Error('duplicate column name: token_group');
|
||||
});
|
||||
const closeSqlite = vi.fn();
|
||||
|
||||
expect(runSqliteMigrationRecoveryLoop).toBeTypeOf('function');
|
||||
expect(typeof retryBudget).toBe('number');
|
||||
|
||||
expect(() => runSqliteMigrationRecoveryLoop!({
|
||||
runMigrate,
|
||||
recoverDuplicateColumnMigrationError: () => ({
|
||||
tag: '0007_account_token_group',
|
||||
recoveredCount: 1,
|
||||
}),
|
||||
isSitesPlatformUrlUniqueConflictError: () => false,
|
||||
deduplicateLegacySitesForUniqueIndex: () => false,
|
||||
closeSqlite,
|
||||
})).toThrow(/retry budget/i);
|
||||
|
||||
expect(runMigrate).toHaveBeenCalledTimes((retryBudget ?? 0) + 1);
|
||||
expect(closeSqlite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,708 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { config } from '../config.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type MigrationJournalEntry = {
|
||||
tag: string;
|
||||
when: number;
|
||||
};
|
||||
|
||||
type MigrationJournalFile = {
|
||||
entries?: MigrationJournalEntry[];
|
||||
};
|
||||
|
||||
type SchemaMarker = {
|
||||
table: string;
|
||||
column?: string;
|
||||
};
|
||||
|
||||
type MigrationRecord = {
|
||||
createdAt: number;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type RecoveryMigrationRecord = MigrationRecord & {
|
||||
tag: string;
|
||||
};
|
||||
|
||||
type RecoveryMigration = RecoveryMigrationRecord & {
|
||||
statements: string[];
|
||||
};
|
||||
|
||||
type SqliteMigrationRecoveryLoopInput = {
|
||||
runMigrate: () => void;
|
||||
recoverDuplicateColumnMigrationError: (error: unknown) => DuplicateColumnRecoveryResult | null;
|
||||
isSitesPlatformUrlUniqueConflictError: (error: unknown) => boolean;
|
||||
deduplicateLegacySitesForUniqueIndex: () => boolean;
|
||||
closeSqlite: () => void;
|
||||
retryBudget?: number;
|
||||
};
|
||||
|
||||
type LegacySiteRow = {
|
||||
id: number;
|
||||
platform: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const VERIFIED_BOOTSTRAP_TAG = '0012_account_token_value_status';
|
||||
const SQLITE_MIGRATION_RECOVERY_RETRY_BUDGET = 64;
|
||||
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
||||
{ table: 'sites' },
|
||||
{ table: 'settings' },
|
||||
{ table: 'accounts' },
|
||||
{ table: 'checkin_logs' },
|
||||
{ table: 'model_availability' },
|
||||
{ table: 'proxy_logs' },
|
||||
{ table: 'token_routes' },
|
||||
{ table: 'route_channels', column: 'token_id' },
|
||||
{ table: 'account_tokens' },
|
||||
{ table: 'token_model_availability' },
|
||||
{ table: 'events' },
|
||||
{ table: 'sites', column: 'is_pinned' },
|
||||
{ table: 'sites', column: 'sort_order' },
|
||||
{ table: 'accounts', column: 'is_pinned' },
|
||||
{ table: 'accounts', column: 'sort_order' },
|
||||
// 0006: site_disabled_models table
|
||||
{ table: 'site_disabled_models' },
|
||||
// 0007: token_group column on account_tokens
|
||||
{ table: 'account_tokens', column: 'token_group' },
|
||||
// 0009: is_manual column on model_availability
|
||||
{ table: 'model_availability', column: 'is_manual' },
|
||||
// 0010: downstream_api_key_id column on proxy_logs
|
||||
{ table: 'proxy_logs', column: 'downstream_api_key_id' },
|
||||
// 0011: downstream key metadata columns
|
||||
{ table: 'downstream_api_keys', column: 'group_name' },
|
||||
{ table: 'downstream_api_keys', column: 'tags' },
|
||||
// 0012: value_status column on account_tokens
|
||||
{ table: 'account_tokens', column: 'value_status' },
|
||||
// 0019: proxy log stream/timing columns
|
||||
{ table: 'proxy_logs', column: 'is_stream' },
|
||||
{ table: 'proxy_logs', column: 'first_byte_latency_ms' },
|
||||
];
|
||||
|
||||
|
||||
function resolveSqliteDbPath(): string {
|
||||
const raw = (config.dbUrl || '').trim();
|
||||
if (!raw) return resolve(`${config.dataDir}/hub.db`);
|
||||
if (raw === ':memory:') return raw;
|
||||
if (raw.startsWith('file://')) {
|
||||
const parsed = new URL(raw);
|
||||
return decodeURIComponent(parsed.pathname);
|
||||
}
|
||||
if (raw.startsWith('sqlite://')) {
|
||||
return resolve(raw.slice('sqlite://'.length).trim());
|
||||
}
|
||||
return resolve(raw);
|
||||
}
|
||||
|
||||
function resolveMigrationsFolder(): string {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
|
||||
}
|
||||
|
||||
function tableExists(sqlite: Database.Database, table: string): boolean {
|
||||
const row = sqlite.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(table);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function columnExists(sqlite: Database.Database, table: string, column: string): boolean {
|
||||
if (!tableExists(sqlite, table)) return false;
|
||||
const rows = sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name?: string }>;
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
function hasRecordedDrizzleMigrations(sqlite: Database.Database): boolean {
|
||||
if (!tableExists(sqlite, '__drizzle_migrations')) return false;
|
||||
const row = sqlite.prepare('SELECT 1 FROM __drizzle_migrations LIMIT 1').get();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function hasVerifiedLegacySchema(sqlite: Database.Database): boolean {
|
||||
return VERIFIED_SCHEMA_MARKERS.every((marker) => (
|
||||
marker.column
|
||||
? columnExists(sqlite, marker.table, marker.column)
|
||||
: tableExists(sqlite, marker.table)
|
||||
));
|
||||
}
|
||||
|
||||
function readVerifiedMigrationRecords(migrationsFolder: string): MigrationRecord[] {
|
||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||
const records: MigrationRecord[] = [];
|
||||
|
||||
for (const entry of journal.entries ?? []) {
|
||||
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
|
||||
records.push({
|
||||
createdAt: Number(entry.when),
|
||||
hash: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
});
|
||||
|
||||
if (entry.tag === VERIFIED_BOOTSTRAP_TAG) {
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function splitMigrationStatements(sqlText: string): string[] {
|
||||
return sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function normalizeSqlForMatch(sqlText: string): string {
|
||||
return sqlText
|
||||
.replace(/[\n\r\t]+/g, ' ')
|
||||
.replace(/["`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/;+$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function extractFailedSqlFromError(error: unknown): string | null {
|
||||
const message = normalizeSchemaErrorMessage(error);
|
||||
const matched = message.match(/Failed to run the query '([\s\S]*?)'/i);
|
||||
const sqlText = matched?.[1]?.trim();
|
||||
return sqlText && sqlText.length > 0 ? sqlText : null;
|
||||
}
|
||||
|
||||
function findMatchingSingleStatementMigration(
|
||||
migrationsFolder: string,
|
||||
failedSqlText: string,
|
||||
): RecoveryMigrationRecord | null {
|
||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||
const normalizedFailedSql = normalizeSqlForMatch(failedSqlText);
|
||||
|
||||
for (const entry of journal.entries ?? []) {
|
||||
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
|
||||
const statements = splitMigrationStatements(migrationSql);
|
||||
if (statements.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeSqlForMatch(statements[0]) !== normalizedFailedSql) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: entry.tag,
|
||||
createdAt: Number(entry.when),
|
||||
hash: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingMigrationByStatement(
|
||||
migrationsFolder: string,
|
||||
failedSqlText: string,
|
||||
): RecoveryMigrationRecord | null {
|
||||
const normalizedFailedSql = normalizeSqlForMatch(failedSqlText);
|
||||
const migrations = readRecoveryMigrations(migrationsFolder);
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (!migration.statements.some((statement) => normalizeSqlForMatch(statement) === normalizedFailedSql)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: migration.tag,
|
||||
createdAt: migration.createdAt,
|
||||
hash: migration.hash,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingMigrationByErrorMessage(
|
||||
migrationsFolder: string,
|
||||
error: unknown,
|
||||
): RecoveryMigrationRecord | null {
|
||||
const normalizedErrorMessage = normalizeSqlForMatch(normalizeSchemaErrorMessage(error));
|
||||
const migrations = readRecoveryMigrations(migrationsFolder);
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (!migration.statements.some((statement) => normalizedErrorMessage.includes(normalizeSqlForMatch(statement)))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: migration.tag,
|
||||
createdAt: migration.createdAt,
|
||||
hash: migration.hash,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readRecoveryMigrations(migrationsFolder: string): RecoveryMigration[] {
|
||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||
|
||||
return (journal.entries ?? []).map((entry) => {
|
||||
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
|
||||
return {
|
||||
tag: entry.tag,
|
||||
createdAt: Number(entry.when),
|
||||
hash: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
statements: splitMigrationStatements(migrationSql),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function ensureDrizzleMigrationsTable(sqlite: Database.Database): void {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function markMigrationRecordIfMissing(sqlite: Database.Database, record: MigrationRecord): boolean {
|
||||
ensureDrizzleMigrationsTable(sqlite);
|
||||
const existing = sqlite
|
||||
.prepare('SELECT rowid, "created_at" FROM "__drizzle_migrations" WHERE "hash" = ? ORDER BY "created_at" DESC LIMIT 1')
|
||||
.get(record.hash) as { rowid?: number; created_at?: number } | undefined;
|
||||
if (existing) {
|
||||
if (Number(existing.created_at) === record.createdAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite
|
||||
.prepare('UPDATE "__drizzle_migrations" SET "created_at" = ? WHERE rowid = ?')
|
||||
.run(record.createdAt, existing.rowid);
|
||||
return true;
|
||||
}
|
||||
|
||||
sqlite
|
||||
.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)')
|
||||
.run(record.hash, record.createdAt);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasMigrationRecord(sqlite: Database.Database, record: MigrationRecord): boolean {
|
||||
if (!tableExists(sqlite, '__drizzle_migrations')) return false;
|
||||
const row = sqlite
|
||||
.prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1')
|
||||
.get(record.hash);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
const collected: string[] = [];
|
||||
let cursor: unknown = error;
|
||||
let depth = 0;
|
||||
|
||||
while (cursor && typeof cursor === 'object' && depth < 8) {
|
||||
const current = cursor as { message?: unknown; cause?: unknown };
|
||||
if (current.message !== undefined && current.message !== null) {
|
||||
const text = String(current.message).trim();
|
||||
if (text.length > 0) {
|
||||
collected.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
cursor = current.cause;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
if (collected.length > 0) {
|
||||
return collected.join(' | ');
|
||||
}
|
||||
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column name');
|
||||
}
|
||||
|
||||
function isRecoverableSchemaConflictError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('duplicate column name')
|
||||
|| lowered.includes('already exists');
|
||||
}
|
||||
|
||||
function isSitesPlatformUrlUniqueConflictError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
if (!lowered.includes('unique constraint failed: sites.platform, sites.url')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const failedSqlText = extractFailedSqlFromError(error);
|
||||
if (!failedSqlText) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizeSqlForMatch(failedSqlText)
|
||||
=== normalizeSqlForMatch('CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);');
|
||||
}
|
||||
|
||||
function replayMigrationStatements(sqlite: Database.Database, statements: string[]): void {
|
||||
for (const statement of statements) {
|
||||
try {
|
||||
sqlite.exec(statement);
|
||||
} catch (error) {
|
||||
if (isRecoverableSchemaConflictError(error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSitesPlatformUrlUniqueConflictError(error) && deduplicateLegacySitesForUniqueIndex(sqlite)) {
|
||||
try {
|
||||
sqlite.exec(statement);
|
||||
continue;
|
||||
} catch (retryError) {
|
||||
if (isRecoverableSchemaConflictError(retryError)) {
|
||||
continue;
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recoverMigrationSequence(
|
||||
sqlite: Database.Database,
|
||||
migrationsFolder: string,
|
||||
failedMigrationTag: string,
|
||||
): number {
|
||||
const migrations = readRecoveryMigrations(migrationsFolder);
|
||||
const failedMigrationIndex = migrations.findIndex((migration) => migration.tag === failedMigrationTag);
|
||||
if (failedMigrationIndex < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let recoveredCount = 0;
|
||||
for (const migration of migrations.slice(0, failedMigrationIndex + 1)) {
|
||||
if (hasMigrationRecord(sqlite, migration)) {
|
||||
if (markMigrationRecordIfMissing(sqlite, migration)) {
|
||||
recoveredCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
replayMigrationStatements(sqlite, migration.statements);
|
||||
if (markMigrationRecordIfMissing(sqlite, migration)) {
|
||||
recoveredCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return recoveredCount;
|
||||
}
|
||||
|
||||
function backfillMissingRecordedMigrations(sqlite: Database.Database, migrationsFolder: string): number {
|
||||
if (!tableExists(sqlite, '__drizzle_migrations')) return 0;
|
||||
|
||||
let recoveredCount = 0;
|
||||
for (const migration of readRecoveryMigrations(migrationsFolder)) {
|
||||
if (hasMigrationRecord(sqlite, migration)) {
|
||||
if (markMigrationRecordIfMissing(sqlite, migration)) {
|
||||
recoveredCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
replayMigrationStatements(sqlite, migration.statements);
|
||||
if (markMigrationRecordIfMissing(sqlite, migration)) {
|
||||
recoveredCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (recoveredCount > 0) {
|
||||
console.warn(`[db] Backfilled ${recoveredCount} missing drizzle migration record(s).`);
|
||||
}
|
||||
|
||||
return recoveredCount;
|
||||
}
|
||||
|
||||
type DuplicateColumnRecoveryResult = {
|
||||
tag: string;
|
||||
recoveredCount: number;
|
||||
};
|
||||
|
||||
function recoverDuplicateColumnMigrationError(
|
||||
sqlite: Database.Database,
|
||||
migrationsFolder: string,
|
||||
error: unknown,
|
||||
): DuplicateColumnRecoveryResult | null {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedSqlText = extractFailedSqlFromError(error);
|
||||
const matchedMigration = failedSqlText
|
||||
? findMatchingMigrationByStatement(migrationsFolder, failedSqlText)
|
||||
?? findMatchingMigrationByErrorMessage(migrationsFolder, error)
|
||||
: findMatchingMigrationByErrorMessage(migrationsFolder, error);
|
||||
if (!matchedMigration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recoveredCount = recoverMigrationSequence(sqlite, migrationsFolder, matchedMigration.tag);
|
||||
if (recoveredCount > 0) {
|
||||
console.warn(`[db] Recovered duplicate-column migration sequence through ${matchedMigration.tag}.`);
|
||||
}
|
||||
return {
|
||||
tag: matchedMigration.tag,
|
||||
recoveredCount,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSqliteMigrationRetryBudgetError(error: unknown, retryBudget: number): Error {
|
||||
const detail = normalizeSchemaErrorMessage(error);
|
||||
return new Error(
|
||||
detail
|
||||
? `[db] Migration recovery exceeded retry budget (${retryBudget} attempts): ${detail}`
|
||||
: `[db] Migration recovery exceeded retry budget (${retryBudget} attempts).`,
|
||||
);
|
||||
}
|
||||
|
||||
function runSqliteMigrationRecoveryLoop(input: SqliteMigrationRecoveryLoopInput): void {
|
||||
const retryBudget = Math.max(1, Math.trunc(input.retryBudget ?? SQLITE_MIGRATION_RECOVERY_RETRY_BUDGET));
|
||||
let recoveryRetries = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
input.runMigrate();
|
||||
return;
|
||||
} catch (error) {
|
||||
const duplicateColumnRecovery = input.recoverDuplicateColumnMigrationError(error);
|
||||
if (duplicateColumnRecovery && duplicateColumnRecovery.recoveredCount > 0) {
|
||||
recoveryRetries += 1;
|
||||
if (recoveryRetries > retryBudget) {
|
||||
input.closeSqlite();
|
||||
throw buildSqliteMigrationRetryBudgetError(error, retryBudget);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (duplicateColumnRecovery) {
|
||||
input.closeSqlite();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recoveredDuplicateSites = (
|
||||
input.isSitesPlatformUrlUniqueConflictError(error)
|
||||
&& input.deduplicateLegacySitesForUniqueIndex()
|
||||
);
|
||||
if (recoveredDuplicateSites) {
|
||||
recoveryRetries += 1;
|
||||
if (recoveryRetries > retryBudget) {
|
||||
input.closeSqlite();
|
||||
throw buildSqliteMigrationRetryBudgetError(error, retryBudget);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
input.closeSqlite();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite: Database.Database,
|
||||
migrationsFolder: string,
|
||||
error: unknown,
|
||||
): boolean {
|
||||
const recovery = recoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error);
|
||||
return (recovery?.recoveredCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
function rewriteDownstreamSiteWeightMultipliers(
|
||||
sqlite: Database.Database,
|
||||
siteIdMapping: Map<number, number>,
|
||||
): void {
|
||||
if (siteIdMapping.size <= 0) return;
|
||||
if (!tableExists(sqlite, 'downstream_api_keys')) return;
|
||||
if (!columnExists(sqlite, 'downstream_api_keys', 'site_weight_multipliers')) return;
|
||||
|
||||
const rows = sqlite.prepare(`
|
||||
SELECT id, site_weight_multipliers
|
||||
FROM downstream_api_keys
|
||||
WHERE site_weight_multipliers IS NOT NULL
|
||||
AND TRIM(site_weight_multipliers) <> ''
|
||||
`).all() as Array<{ id: number; site_weight_multipliers: string | null }>;
|
||||
|
||||
const update = sqlite.prepare('UPDATE downstream_api_keys SET site_weight_multipliers = ? WHERE id = ?');
|
||||
for (const row of rows) {
|
||||
if (!row.site_weight_multipliers) continue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(row.site_weight_multipliers);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
|
||||
const nextValue = { ...(parsed as Record<string, unknown>) };
|
||||
let changed = false;
|
||||
|
||||
for (const [fromSiteId, toSiteId] of siteIdMapping.entries()) {
|
||||
const fromKey = String(fromSiteId);
|
||||
const toKey = String(toSiteId);
|
||||
if (!(fromKey in nextValue)) continue;
|
||||
if (!(toKey in nextValue)) {
|
||||
nextValue[toKey] = nextValue[fromKey];
|
||||
}
|
||||
delete nextValue[fromKey];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) continue;
|
||||
update.run(JSON.stringify(nextValue), row.id);
|
||||
}
|
||||
}
|
||||
|
||||
function deduplicateLegacySitesForUniqueIndex(sqlite: Database.Database): boolean {
|
||||
const duplicateGroups = sqlite.prepare(`
|
||||
SELECT platform, url
|
||||
FROM sites
|
||||
GROUP BY platform, url
|
||||
HAVING COUNT(*) > 1
|
||||
`).all() as Array<{ platform: string; url: string }>;
|
||||
|
||||
if (duplicateGroups.length <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectSitesByIdentity = sqlite.prepare(`
|
||||
SELECT id, platform, url
|
||||
FROM sites
|
||||
WHERE platform = ? AND url = ?
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
const rebindAccounts = sqlite.prepare('UPDATE accounts SET site_id = ? WHERE site_id = ?');
|
||||
const mergeDisabledModels = sqlite.prepare(`
|
||||
INSERT OR IGNORE INTO site_disabled_models (site_id, model_name, created_at)
|
||||
SELECT ?, model_name, created_at
|
||||
FROM site_disabled_models
|
||||
WHERE site_id = ?
|
||||
`);
|
||||
const deleteDisabledModels = sqlite.prepare('DELETE FROM site_disabled_models WHERE site_id = ?');
|
||||
const deleteSite = sqlite.prepare('DELETE FROM sites WHERE id = ?');
|
||||
|
||||
const siteIdMapping = new Map<number, number>();
|
||||
|
||||
const transaction = sqlite.transaction(() => {
|
||||
for (const group of duplicateGroups) {
|
||||
const sites = selectSitesByIdentity.all(group.platform, group.url) as LegacySiteRow[];
|
||||
if (sites.length <= 1) continue;
|
||||
|
||||
const canonicalSiteId = sites[0]!.id;
|
||||
for (const site of sites.slice(1)) {
|
||||
mergeDisabledModels.run(canonicalSiteId, site.id);
|
||||
deleteDisabledModels.run(site.id);
|
||||
rebindAccounts.run(canonicalSiteId, site.id);
|
||||
siteIdMapping.set(site.id, canonicalSiteId);
|
||||
deleteSite.run(site.id);
|
||||
}
|
||||
}
|
||||
|
||||
rewriteDownstreamSiteWeightMultipliers(sqlite, siteIdMapping);
|
||||
});
|
||||
|
||||
transaction();
|
||||
if (siteIdMapping.size > 0) {
|
||||
console.warn(`[db] Deduplicated ${siteIdMapping.size} legacy site entries before applying sites_platform_url_unique.`);
|
||||
}
|
||||
return siteIdMapping.size > 0;
|
||||
}
|
||||
|
||||
export const __migrateTestUtils = {
|
||||
splitMigrationStatements,
|
||||
normalizeSqlForMatch,
|
||||
extractFailedSqlFromError,
|
||||
findMatchingSingleStatementMigration,
|
||||
findMatchingMigrationByStatement,
|
||||
findMatchingMigrationByErrorMessage,
|
||||
readRecoveryMigrations,
|
||||
markMigrationRecordIfMissing,
|
||||
recoverMigrationSequence,
|
||||
tryRecoverDuplicateColumnMigrationError,
|
||||
isSitesPlatformUrlUniqueConflictError,
|
||||
deduplicateLegacySitesForUniqueIndex,
|
||||
runSqliteMigrationRecoveryLoop,
|
||||
sqliteMigrationRecoveryRetryBudget: SQLITE_MIGRATION_RECOVERY_RETRY_BUDGET,
|
||||
};
|
||||
|
||||
function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean {
|
||||
if (hasRecordedDrizzleMigrations(sqlite)) return false;
|
||||
if (!hasVerifiedLegacySchema(sqlite)) return false;
|
||||
|
||||
const records = readVerifiedMigrationRecords(migrationsFolder);
|
||||
if (records.length === 0) return false;
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
|
||||
const insert = sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)');
|
||||
const applyBootstrap = sqlite.transaction((migrations: MigrationRecord[]) => {
|
||||
for (const migrationRecord of migrations) {
|
||||
insert.run(migrationRecord.hash, migrationRecord.createdAt);
|
||||
}
|
||||
});
|
||||
|
||||
applyBootstrap(records);
|
||||
console.log('[db] Bootstrapped drizzle migration journal for existing SQLite schema.');
|
||||
return true;
|
||||
}
|
||||
|
||||
export function runSqliteMigrations(): void {
|
||||
const dbPath = resolveSqliteDbPath();
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
if (dbPath !== ':memory:') {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
|
||||
backfillMissingRecordedMigrations(sqlite, migrationsFolder);
|
||||
|
||||
runSqliteMigrationRecoveryLoop({
|
||||
runMigrate: () => {
|
||||
migrate(drizzle(sqlite), { migrationsFolder });
|
||||
},
|
||||
recoverDuplicateColumnMigrationError: (error) => (
|
||||
recoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error)
|
||||
),
|
||||
isSitesPlatformUrlUniqueConflictError,
|
||||
deduplicateLegacySitesForUniqueIndex: () => deduplicateLegacySitesForUniqueIndex(sqlite),
|
||||
closeSqlite: () => sqlite.close(),
|
||||
});
|
||||
|
||||
sqlite.close();
|
||||
console.log('Migration complete.');
|
||||
}
|
||||
|
||||
runSqliteMigrations();
|
||||
@@ -1,23 +0,0 @@
|
||||
import pg from 'pg';
|
||||
|
||||
export type PgTypesLike = {
|
||||
builtins: {
|
||||
JSON: number;
|
||||
JSONB: number;
|
||||
};
|
||||
setTypeParser(oid: number, format: 'text', parser: (value: string) => string): void;
|
||||
};
|
||||
|
||||
let postgresJsonTextParsersInstalled = false;
|
||||
|
||||
export function installPostgresJsonTextParsers(typesRegistry: PgTypesLike = pg.types as PgTypesLike): void {
|
||||
if (postgresJsonTextParsersInstalled) return;
|
||||
const identity = (value: string) => value;
|
||||
typesRegistry.setTypeParser(typesRegistry.builtins.JSON, 'text', identity);
|
||||
typesRegistry.setTypeParser(typesRegistry.builtins.JSONB, 'text', identity);
|
||||
postgresJsonTextParsersInstalled = true;
|
||||
}
|
||||
|
||||
export function resetPostgresJsonTextParsersInstallStateForTests(): void {
|
||||
postgresJsonTextParsersInstalled = false;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureProxyFileSchemaCompatibility,
|
||||
type ProxyFileSchemaInspector,
|
||||
} from './proxyFileSchemaCompatibility.js';
|
||||
|
||||
function createInspector(
|
||||
dialect: ProxyFileSchemaInspector['dialect'],
|
||||
options?: {
|
||||
hasTable?: boolean;
|
||||
existingColumns?: string[];
|
||||
},
|
||||
) {
|
||||
const executedSql: string[] = [];
|
||||
const hasTable = options?.hasTable ?? false;
|
||||
const existingColumns = new Set(options?.existingColumns ?? []);
|
||||
|
||||
const inspector: ProxyFileSchemaInspector = {
|
||||
dialect,
|
||||
async tableExists(table) {
|
||||
return table === 'proxy_files' && hasTable;
|
||||
},
|
||||
async columnExists(table, column) {
|
||||
return table === 'proxy_files' && existingColumns.has(column);
|
||||
},
|
||||
async execute(sqlText) {
|
||||
executedSql.push(sqlText);
|
||||
},
|
||||
};
|
||||
|
||||
return { inspector, executedSql };
|
||||
}
|
||||
|
||||
describe('ensureProxyFileSchemaCompatibility', () => {
|
||||
it.each([
|
||||
{
|
||||
dialect: 'postgres' as const,
|
||||
createPattern: /create table/i,
|
||||
uniqueIndexPattern: /create unique index/i,
|
||||
ownerIndexPattern: /owner_lookup_idx/i,
|
||||
},
|
||||
{
|
||||
dialect: 'mysql' as const,
|
||||
createPattern: /create table/i,
|
||||
uniqueIndexPattern: /create unique index/i,
|
||||
ownerIndexPattern: /owner_lookup_idx/i,
|
||||
},
|
||||
{
|
||||
dialect: 'sqlite' as const,
|
||||
createPattern: /create table/i,
|
||||
uniqueIndexPattern: /create unique index/i,
|
||||
ownerIndexPattern: /owner_lookup_idx/i,
|
||||
},
|
||||
])('creates proxy_files table and indexes for $dialect', async ({ dialect, createPattern, uniqueIndexPattern, ownerIndexPattern }) => {
|
||||
const { inspector, executedSql } = createInspector(dialect);
|
||||
|
||||
await ensureProxyFileSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql.some((sqlText) => createPattern.test(sqlText) && /proxy_files/i.test(sqlText))).toBe(true);
|
||||
expect(executedSql.some((sqlText) => uniqueIndexPattern.test(sqlText) && /proxy_files_public_id_unique/i.test(sqlText))).toBe(true);
|
||||
expect(executedSql.some((sqlText) => ownerIndexPattern.test(sqlText))).toBe(true);
|
||||
});
|
||||
|
||||
it('emits MySQL indexes compatible with generated bootstrap SQL', async () => {
|
||||
const { inspector, executedSql } = createInspector('mysql');
|
||||
|
||||
await ensureProxyFileSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql).toContain(
|
||||
'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
|
||||
);
|
||||
expect(executedSql).toContain(
|
||||
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds missing columns on existing table before ensuring indexes', async () => {
|
||||
const { inspector, executedSql } = createInspector('postgres', {
|
||||
hasTable: true,
|
||||
existingColumns: ['public_id', 'owner_type', 'owner_id'],
|
||||
});
|
||||
|
||||
await ensureProxyFileSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql.some((sqlText) => sqlText.includes('ADD COLUMN "purpose"'))).toBe(true);
|
||||
expect(executedSql.some((sqlText) => sqlText.includes('ADD COLUMN "filename"'))).toBe(true);
|
||||
expect(executedSql.some((sqlText) => sqlText.includes('ADD COLUMN "deleted_at"'))).toBe(true);
|
||||
expect(executedSql.some((sqlText) => sqlText.includes('proxy_files_public_id_unique'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
export type ProxyFileSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface ProxyFileSchemaInspector {
|
||||
dialect: ProxyFileSchemaDialect;
|
||||
tableExists(table: string): Promise<boolean>;
|
||||
columnExists(table: string, column: string): Promise<boolean>;
|
||||
execute(sqlText: string): Promise<void>;
|
||||
}
|
||||
|
||||
type ProxyFileColumnCompatibilitySpec = {
|
||||
table: 'proxy_files';
|
||||
column: string;
|
||||
addSql: Record<ProxyFileSchemaDialect, string>;
|
||||
};
|
||||
|
||||
export type ProxyFileTableCompatibilitySpec = {
|
||||
table: 'proxy_files';
|
||||
createSql: Record<ProxyFileSchemaDialect, string>;
|
||||
};
|
||||
|
||||
export const PROXY_FILE_TABLE_COMPATIBILITY_SPECS: ProxyFileTableCompatibilitySpec[] = [
|
||||
{
|
||||
table: 'proxy_files',
|
||||
createSql: {
|
||||
sqlite: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)',
|
||||
postgres: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" SERIAL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)',
|
||||
mysql: 'CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, `public_id` VARCHAR(191) NOT NULL, `owner_type` VARCHAR(64) NOT NULL, `owner_id` VARCHAR(191) NOT NULL, `filename` TEXT NOT NULL, `mime_type` VARCHAR(191) NOT NULL, `purpose` TEXT NULL, `byte_size` INTEGER NOT NULL, `sha256` VARCHAR(191) NOT NULL, `content_base64` LONGTEXT NOT NULL, `created_at` TEXT NULL, `updated_at` TEXT NULL, `deleted_at` TEXT NULL)',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const PROXY_FILE_COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'public_id',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `public_id` VARCHAR(191) NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'owner_type',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `owner_type` VARCHAR(64) NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'owner_id',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `owner_id` VARCHAR(191) NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'filename',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "filename" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "filename" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `filename` TEXT NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'mime_type',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `mime_type` VARCHAR(191) NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'purpose',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "purpose" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "purpose" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `purpose` TEXT NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'byte_size',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "byte_size" INTEGER',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "byte_size" INTEGER',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `byte_size` INTEGER NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'sha256',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `sha256` VARCHAR(191) NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'content_base64',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "content_base64" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "content_base64" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `content_base64` LONGTEXT NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'created_at',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "created_at" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "created_at" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `created_at` TEXT NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'updated_at',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "updated_at" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "updated_at" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `updated_at` TEXT NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
column: 'deleted_at',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "deleted_at" TEXT',
|
||||
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "deleted_at" TEXT',
|
||||
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `deleted_at` TEXT NULL',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export type ProxyFileIndexCompatibilitySpec = {
|
||||
table: 'proxy_files';
|
||||
indexName: string;
|
||||
createSql: Record<ProxyFileSchemaDialect, string>;
|
||||
};
|
||||
|
||||
export const PROXY_FILE_INDEX_COMPATIBILITY_SPECS: ProxyFileIndexCompatibilitySpec[] = [
|
||||
{
|
||||
table: 'proxy_files',
|
||||
indexName: 'proxy_files_public_id_unique',
|
||||
createSql: {
|
||||
sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")',
|
||||
postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")',
|
||||
mysql: 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'proxy_files',
|
||||
indexName: 'proxy_files_owner_lookup_idx',
|
||||
createSql: {
|
||||
sqlite: 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")',
|
||||
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")',
|
||||
mysql: 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
}
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateSchemaError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('already exists') || lowered.includes('duplicate') || lowered.includes('relation');
|
||||
}
|
||||
|
||||
async function executeIgnoreDuplicate(inspector: ProxyFileSchemaInspector, sqlText: string): Promise<void> {
|
||||
try {
|
||||
await inspector.execute(sqlText);
|
||||
} catch (error) {
|
||||
if (!isDuplicateSchemaError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureProxyFileSchemaCompatibility(inspector: ProxyFileSchemaInspector): Promise<void> {
|
||||
const [tableSpec] = PROXY_FILE_TABLE_COMPATIBILITY_SPECS;
|
||||
if (!await inspector.tableExists(tableSpec.table)) {
|
||||
await executeIgnoreDuplicate(inspector, tableSpec.createSql[inspector.dialect]);
|
||||
} else {
|
||||
for (const spec of PROXY_FILE_COLUMN_COMPATIBILITY_SPECS) {
|
||||
if (await inspector.columnExists(spec.table, spec.column)) continue;
|
||||
await executeIgnoreDuplicate(inspector, spec.addSql[inspector.dialect]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const spec of PROXY_FILE_INDEX_COMPATIBILITY_SPECS) {
|
||||
await executeIgnoreDuplicate(inspector, spec.createSql[inspector.dialect]);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const SERVER_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||||
const FORBIDDEN_PATTERN = '.returning(';
|
||||
const INSERT_ID_PATTERN = 'lastInsertRowid';
|
||||
|
||||
function collectProductionServerFiles(rootDir: string): string[] {
|
||||
const entries = readdirSync(rootDir).sort();
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = join(rootDir, entry);
|
||||
const stats = statSync(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
files.push(...collectProductionServerFiles(absolutePath));
|
||||
continue;
|
||||
}
|
||||
if (!entry.endsWith('.ts')) continue;
|
||||
if (entry.endsWith('.test.ts')) continue;
|
||||
files.push(absolutePath);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('server database dialect architecture boundaries', () => {
|
||||
it('keeps production server code free of drizzle builder returning calls', () => {
|
||||
const rootDir = SERVER_ROOT;
|
||||
const offenders = collectProductionServerFiles(rootDir)
|
||||
.filter((filePath) => readFileSync(filePath, 'utf8').includes(FORBIDDEN_PATTERN))
|
||||
.map((filePath) => relative(rootDir, filePath));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps lastInsertRowid handling inside the db layer', () => {
|
||||
const rootDir = SERVER_ROOT;
|
||||
const offenders = collectProductionServerFiles(rootDir)
|
||||
.filter((filePath) => !relative(rootDir, filePath).split(sep).join('/').startsWith('db/'))
|
||||
.filter((filePath) => readFileSync(filePath, 'utf8').includes(INSERT_ID_PATTERN))
|
||||
.map((filePath) => relative(rootDir, filePath));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ensureRouteGroupingSchemaCompatibility,
|
||||
type RouteGroupingSchemaInspector,
|
||||
} from './routeGroupingSchemaCompatibility.js';
|
||||
|
||||
function createInspector(
|
||||
dialect: RouteGroupingSchemaInspector['dialect'],
|
||||
options?: {
|
||||
existingColumnsByTable?: Partial<Record<'token_routes' | 'route_channels', string[]>>;
|
||||
existingTables?: Array<'token_routes' | 'route_channels' | 'route_group_sources'>;
|
||||
},
|
||||
) {
|
||||
const executedSql: string[] = [];
|
||||
const existingTables = new Set(options?.existingTables ?? ['token_routes', 'route_channels']);
|
||||
const existingColumnsByTable = {
|
||||
token_routes: new Set(options?.existingColumnsByTable?.token_routes ?? []),
|
||||
route_channels: new Set(options?.existingColumnsByTable?.route_channels ?? []),
|
||||
};
|
||||
|
||||
const inspector: RouteGroupingSchemaInspector = {
|
||||
dialect,
|
||||
async tableExists(table) {
|
||||
return existingTables.has(table as 'token_routes' | 'route_channels' | 'route_group_sources');
|
||||
},
|
||||
async columnExists(table, column) {
|
||||
if (table === 'token_routes' || table === 'route_channels') {
|
||||
return existingColumnsByTable[table].has(column);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async execute(sqlText) {
|
||||
executedSql.push(sqlText);
|
||||
},
|
||||
};
|
||||
|
||||
return { inspector, executedSql };
|
||||
}
|
||||
|
||||
describe('ensureRouteGroupingSchemaCompatibility', () => {
|
||||
it.each([
|
||||
{
|
||||
dialect: 'sqlite' as const,
|
||||
expectedSql: [
|
||||
'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);',
|
||||
'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);',
|
||||
'ALTER TABLE token_routes ADD COLUMN display_name text;',
|
||||
'ALTER TABLE token_routes ADD COLUMN display_icon text;',
|
||||
'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';',
|
||||
'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;',
|
||||
'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;',
|
||||
'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';',
|
||||
'ALTER TABLE route_channels ADD COLUMN source_model text;',
|
||||
'ALTER TABLE route_channels ADD COLUMN last_selected_at text;',
|
||||
'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;',
|
||||
'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;',
|
||||
],
|
||||
},
|
||||
{
|
||||
dialect: 'postgres' as const,
|
||||
expectedSql: [
|
||||
'CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")',
|
||||
'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")',
|
||||
'ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT',
|
||||
'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT',
|
||||
'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'',
|
||||
'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT',
|
||||
'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT',
|
||||
'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0',
|
||||
],
|
||||
},
|
||||
{
|
||||
dialect: 'mysql' as const,
|
||||
expectedSql: [
|
||||
'CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` int AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` int NOT NULL, `source_route_id` int NOT NULL, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)',
|
||||
'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)',
|
||||
'ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT NULL',
|
||||
'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL',
|
||||
'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'',
|
||||
'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL',
|
||||
'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL',
|
||||
'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'',
|
||||
'ALTER TABLE `route_channels` ADD COLUMN `source_model` TEXT NULL',
|
||||
'ALTER TABLE `route_channels` ADD COLUMN `last_selected_at` TEXT NULL',
|
||||
'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0',
|
||||
],
|
||||
},
|
||||
])('adds missing route grouping columns for $dialect', async ({ dialect, expectedSql }) => {
|
||||
const { inspector, executedSql } = createInspector(dialect);
|
||||
|
||||
await ensureRouteGroupingSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql).toEqual(expectedSql);
|
||||
});
|
||||
|
||||
it('skips existing columns', async () => {
|
||||
const { inspector, executedSql } = createInspector('postgres', {
|
||||
existingColumnsByTable: {
|
||||
token_routes: ['display_name', 'display_icon', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'],
|
||||
route_channels: ['source_model', 'last_selected_at', 'consecutive_fail_count', 'cooldown_level'],
|
||||
},
|
||||
existingTables: ['token_routes', 'route_channels', 'route_group_sources'],
|
||||
});
|
||||
|
||||
await ensureRouteGroupingSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
export type RouteGroupingSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface RouteGroupingSchemaInspector {
|
||||
dialect: RouteGroupingSchemaDialect;
|
||||
tableExists(table: string): Promise<boolean>;
|
||||
columnExists(table: string, column: string): Promise<boolean>;
|
||||
execute(sqlText: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type RouteGroupingColumnCompatibilitySpec = {
|
||||
table: 'token_routes' | 'route_channels';
|
||||
column: string;
|
||||
addSql: Record<RouteGroupingSchemaDialect, string>;
|
||||
};
|
||||
|
||||
export type RouteGroupingTableCompatibilitySpec = {
|
||||
table: 'route_group_sources';
|
||||
createSql: Record<RouteGroupingSchemaDialect, string[]>;
|
||||
};
|
||||
|
||||
export const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [
|
||||
{
|
||||
table: 'token_routes',
|
||||
column: 'display_name',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE token_routes ADD COLUMN display_name text;',
|
||||
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'token_routes',
|
||||
column: 'display_icon',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE token_routes ADD COLUMN display_icon text;',
|
||||
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'token_routes',
|
||||
column: 'route_mode',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';',
|
||||
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'',
|
||||
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'token_routes',
|
||||
column: 'decision_snapshot',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;',
|
||||
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'token_routes',
|
||||
column: 'decision_refreshed_at',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;',
|
||||
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'token_routes',
|
||||
column: 'routing_strategy',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';',
|
||||
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'',
|
||||
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'route_channels',
|
||||
column: 'source_model',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN source_model text;',
|
||||
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `source_model` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'route_channels',
|
||||
column: 'last_selected_at',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN last_selected_at text;',
|
||||
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `last_selected_at` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'route_channels',
|
||||
column: 'consecutive_fail_count',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;',
|
||||
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0',
|
||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'route_channels',
|
||||
column: 'cooldown_level',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;',
|
||||
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0',
|
||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [
|
||||
{
|
||||
table: 'route_group_sources',
|
||||
createSql: {
|
||||
sqlite: [
|
||||
'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);',
|
||||
'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);',
|
||||
],
|
||||
mysql: [
|
||||
'CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` int AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` int NOT NULL, `source_route_id` int NOT NULL, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)',
|
||||
'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)',
|
||||
],
|
||||
postgres: [
|
||||
'CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")',
|
||||
'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")',
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
}
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column name');
|
||||
}
|
||||
|
||||
async function executeAddColumn(inspector: RouteGroupingSchemaInspector, sqlText: string): Promise<void> {
|
||||
try {
|
||||
await inspector.execute(sqlText);
|
||||
} catch (error) {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureRouteGroupingSchemaCompatibility(inspector: RouteGroupingSchemaInspector): Promise<void> {
|
||||
const tableExistsCache = new Map<string, boolean>();
|
||||
|
||||
for (const spec of ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS) {
|
||||
const hasTable = await inspector.tableExists(spec.table);
|
||||
tableExistsCache.set(spec.table, hasTable);
|
||||
if (hasTable) {
|
||||
continue;
|
||||
}
|
||||
for (const sqlText of spec.createSql[inspector.dialect]) {
|
||||
await executeAddColumn(inspector, sqlText);
|
||||
}
|
||||
tableExistsCache.set(spec.table, true);
|
||||
}
|
||||
|
||||
for (const spec of ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS) {
|
||||
let hasTable = tableExistsCache.get(spec.table);
|
||||
if (hasTable === undefined) {
|
||||
hasTable = await inspector.tableExists(spec.table);
|
||||
tableExistsCache.set(spec.table, hasTable);
|
||||
}
|
||||
if (!hasTable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasColumn = await inspector.columnExists(spec.table, spec.column);
|
||||
if (!hasColumn) {
|
||||
await executeAddColumn(inspector, spec.addSql[inspector.dialect]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' };
|
||||
import currentContract from './generated/schemaContract.json' with { type: 'json' };
|
||||
import mysql from 'mysql2/promise';
|
||||
import pg from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateBootstrapSql } from './schemaArtifactGenerator.js';
|
||||
import { __schemaIntrospectionTestUtils, introspectLiveSchema } from './schemaIntrospection.js';
|
||||
import { bootstrapRuntimeDatabaseSchema } from './runtimeSchemaBootstrap.js';
|
||||
|
||||
const mysqlRuntime = process.env.DB_PARITY_MYSQL_URL ? it : it.skip;
|
||||
const postgresRuntime = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip;
|
||||
|
||||
async function resetMySqlSchema(connectionString: string): Promise<void> {
|
||||
const connection = await mysql.createConnection({ uri: connectionString });
|
||||
try {
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
const [rows] = await connection.query(`
|
||||
SELECT table_name AS table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_type = 'BASE TABLE'
|
||||
`);
|
||||
for (const row of rows as Array<Record<string, unknown>>) {
|
||||
const tableName = String(row.table_name || '');
|
||||
if (!tableName) continue;
|
||||
await connection.query(`DROP TABLE IF EXISTS \`${tableName}\``);
|
||||
}
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMySqlStatements(connectionString: string, statements: string[]): Promise<void> {
|
||||
const connection = await mysql.createConnection({ uri: connectionString });
|
||||
try {
|
||||
for (const statement of statements) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPostgresSchema(connectionString: string): Promise<void> {
|
||||
const client = new pg.Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(`
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
ORDER BY tablename ASC
|
||||
`);
|
||||
for (const row of result.rows as Array<{ tablename: string }>) {
|
||||
await client.query(`DROP TABLE IF EXISTS "${row.tablename}" CASCADE`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPostgresStatements(connectionString: string, statements: string[]): Promise<void> {
|
||||
const client = new pg.Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
for (const statement of statements) {
|
||||
await client.query(statement);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
describe('runtime schema bootstrap live upgrade path', () => {
|
||||
mysqlRuntime('upgrades mysql runtime schemas from an older live contract', async () => {
|
||||
const connectionString = process.env.DB_PARITY_MYSQL_URL!;
|
||||
const baselineStatements = __schemaIntrospectionTestUtils.splitSqlStatements(
|
||||
generateBootstrapSql('mysql', baselineContract),
|
||||
);
|
||||
|
||||
await resetMySqlSchema(connectionString);
|
||||
await applyMySqlStatements(connectionString, baselineStatements);
|
||||
|
||||
await bootstrapRuntimeDatabaseSchema({
|
||||
dialect: 'mysql',
|
||||
connectionString,
|
||||
});
|
||||
|
||||
const live = await introspectLiveSchema({ dialect: 'mysql', connectionString });
|
||||
expect(live).toEqual(currentContract);
|
||||
});
|
||||
|
||||
postgresRuntime('upgrades postgres runtime schemas from an older live contract', async () => {
|
||||
const connectionString = process.env.DB_PARITY_POSTGRES_URL!;
|
||||
const baselineStatements = __schemaIntrospectionTestUtils.splitSqlStatements(
|
||||
generateBootstrapSql('postgres', baselineContract),
|
||||
);
|
||||
|
||||
await resetPostgresSchema(connectionString);
|
||||
await applyPostgresStatements(connectionString, baselineStatements);
|
||||
|
||||
await bootstrapRuntimeDatabaseSchema({
|
||||
dialect: 'postgres',
|
||||
connectionString,
|
||||
});
|
||||
|
||||
const live = await introspectLiveSchema({ dialect: 'postgres', connectionString });
|
||||
expect(live).toEqual(currentContract);
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' };
|
||||
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,
|
||||
ensureRuntimeDatabaseSchema,
|
||||
type RuntimeSchemaClient,
|
||||
type RuntimeSchemaDialect,
|
||||
} from './runtimeSchemaBootstrap.js';
|
||||
|
||||
function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): RuntimeSchemaClient {
|
||||
return {
|
||||
dialect,
|
||||
begin: async () => {},
|
||||
commit: async () => {},
|
||||
rollback: async () => {},
|
||||
execute: async (sqlText: string) => {
|
||||
if (sqlText.trim().toLowerCase().startsWith('select')) {
|
||||
return [];
|
||||
}
|
||||
executedSql.push(sqlText);
|
||||
return [];
|
||||
},
|
||||
queryScalar: async (sqlText: string, params: unknown[] = []) => {
|
||||
if (sqlText.includes('information_schema') || sqlText.includes('sqlite_master') || sqlText.includes('pragma_table_info')) {
|
||||
return 1;
|
||||
}
|
||||
if (params.length > 0) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeColumn(overrides: Partial<SchemaContractColumn> = {}): 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[] = [];
|
||||
const expectedUpgradeSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
||||
generateUpgradeSql(dialect, currentContract, baselineContract),
|
||||
);
|
||||
|
||||
await ensureRuntimeDatabaseSchema(createStubClient(dialect, executedSql), {
|
||||
currentContract,
|
||||
liveContract: baselineContract,
|
||||
});
|
||||
|
||||
expect(executedSql.slice(0, expectedUpgradeSql.length)).toEqual(expectedUpgradeSql);
|
||||
});
|
||||
|
||||
it('skips external schema execution when live schema already matches the current contract', async () => {
|
||||
const executedSql: string[] = [];
|
||||
const expectedUpgradeSql = __runtimeSchemaBootstrapTestUtils.buildExternalUpgradeStatements(
|
||||
'mysql',
|
||||
currentContract,
|
||||
currentContract,
|
||||
);
|
||||
|
||||
await ensureRuntimeDatabaseSchema(createStubClient('mysql', executedSql), {
|
||||
currentContract,
|
||||
liveContract: currentContract,
|
||||
});
|
||||
|
||||
expect(expectedUpgradeSql).toEqual([]);
|
||||
expect(executedSql.every((sqlText) => classifyLegacyCompatMutation(sqlText) === 'legacy')).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates non-additive live-schema drift and still emits additive runtime patch statements', () => {
|
||||
const driftedLiveContract = __runtimeSchemaBootstrapTestUtils.cloneContract(currentContract);
|
||||
|
||||
delete driftedLiveContract.tables.model_availability?.columns.is_manual;
|
||||
if (driftedLiveContract.tables.sites?.columns.status) {
|
||||
driftedLiveContract.tables.sites.columns.status.defaultValue = null;
|
||||
}
|
||||
driftedLiveContract.indexes = driftedLiveContract.indexes.filter((index) => index.name !== 'accounts_site_id_idx');
|
||||
driftedLiveContract.uniques = driftedLiveContract.uniques.filter((unique) => unique.name !== 'proxy_files_public_id_unique');
|
||||
|
||||
const statements = __runtimeSchemaBootstrapTestUtils.buildExternalUpgradeStatements(
|
||||
'mysql',
|
||||
currentContract,
|
||||
driftedLiveContract,
|
||||
);
|
||||
|
||||
expect(statements.some((sqlText) => sqlText.includes('ALTER TABLE `model_availability` ADD COLUMN `is_manual`'))).toBe(true);
|
||||
expect(statements.some((sqlText) => sqlText.includes('CREATE INDEX `accounts_site_id_idx`'))).toBe(true);
|
||||
expect(statements.some((sqlText) => sqlText.includes('CREATE UNIQUE INDEX `proxy_files_public_id_unique`'))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores duplicate mysql index and column errors when replaying additive schema statements', async () => {
|
||||
const executedSql: string[] = [];
|
||||
const duplicateColumnSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
||||
generateUpgradeSql('mysql', currentContract, baselineContract),
|
||||
).find((sqlText) => sqlText.includes('ALTER TABLE `model_availability` ADD COLUMN `is_manual`'));
|
||||
const duplicateIndexSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
||||
generateUpgradeSql('mysql', currentContract, baselineContract),
|
||||
).find((sqlText) => sqlText.includes('proxy_files_public_id_unique'));
|
||||
|
||||
expect(duplicateColumnSql).toBeDefined();
|
||||
expect(duplicateIndexSql).toBeDefined();
|
||||
|
||||
await ensureRuntimeDatabaseSchema({
|
||||
...createStubClient('mysql', executedSql),
|
||||
execute: async (sqlText: string) => {
|
||||
executedSql.push(sqlText);
|
||||
if (sqlText === duplicateColumnSql) {
|
||||
const error = new Error("Duplicate column name 'is_manual'") as Error & { code?: string };
|
||||
error.code = 'ER_DUP_FIELDNAME';
|
||||
throw error;
|
||||
}
|
||||
if (sqlText === duplicateIndexSql) {
|
||||
const error = new Error("Duplicate key name 'model_availability_account_model_unique'") as Error & { code?: string };
|
||||
error.code = 'ER_DUP_KEYNAME';
|
||||
throw error;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}, {
|
||||
currentContract,
|
||||
liveContract: baselineContract,
|
||||
});
|
||||
|
||||
expect(executedSql).toContain(duplicateColumnSql);
|
||||
expect(executedSql).toContain(duplicateIndexSql);
|
||||
});
|
||||
|
||||
it('ignores postgres relation-already-exists errors when replaying additive schema statements', async () => {
|
||||
const executedSql: string[] = [];
|
||||
const targetSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
||||
generateUpgradeSql('postgres', currentContract, baselineContract),
|
||||
).find((sqlText) => sqlText.includes('proxy_files_public_id_unique'));
|
||||
|
||||
expect(targetSql).toBeDefined();
|
||||
|
||||
await ensureRuntimeDatabaseSchema({
|
||||
...createStubClient('postgres', executedSql),
|
||||
execute: async (sqlText: string) => {
|
||||
executedSql.push(sqlText);
|
||||
if (sqlText === targetSql) {
|
||||
const error = new Error('relation "model_availability_account_model_unique" already exists') as Error & { code?: string };
|
||||
error.code = '42P07';
|
||||
throw error;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}, {
|
||||
currentContract,
|
||||
liveContract: baselineContract,
|
||||
});
|
||||
|
||||
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))',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add mysql text prefixes when live indexed text columns are varchar-backed', async () => {
|
||||
const executedSql: string[] = [];
|
||||
const minimalContract: SchemaContract = {
|
||||
tables: {
|
||||
sites: {
|
||||
columns: {
|
||||
platform: makeColumn({ logicalType: 'text', notNull: true }),
|
||||
url: makeColumn({ logicalType: 'text', notNull: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
indexes: [],
|
||||
uniques: [
|
||||
{
|
||||
name: 'sites_platform_url_unique',
|
||||
table: 'sites',
|
||||
columns: ['platform', 'url'],
|
||||
},
|
||||
],
|
||||
foreignKeys: [],
|
||||
};
|
||||
|
||||
await ensureRuntimeDatabaseSchema({
|
||||
...createStubClient('mysql', executedSql),
|
||||
execute: async (sqlText: string) => {
|
||||
if (sqlText.includes('FROM information_schema.columns')) {
|
||||
return [[
|
||||
{
|
||||
table_name: 'sites',
|
||||
column_name: 'platform',
|
||||
data_type: 'varchar',
|
||||
column_type: 'varchar(32)',
|
||||
},
|
||||
{
|
||||
table_name: 'sites',
|
||||
column_name: 'url',
|
||||
data_type: 'varchar',
|
||||
column_type: 'varchar(255)',
|
||||
},
|
||||
]];
|
||||
}
|
||||
|
||||
executedSql.push(sqlText);
|
||||
return [];
|
||||
},
|
||||
queryScalar: async (sqlText: string, params: unknown[] = []) => {
|
||||
if (sqlText.includes('information_schema.tables')) {
|
||||
return params[0] === 'sites' ? 1 : 0;
|
||||
}
|
||||
if (sqlText.includes('information_schema.columns')) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
}, {
|
||||
currentContract: minimalContract,
|
||||
liveContract: {
|
||||
...minimalContract,
|
||||
uniques: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(executedSql).toContain(
|
||||
'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`, `url`)',
|
||||
);
|
||||
expect(executedSql).not.toContain(
|
||||
'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191))',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,489 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import mysql from 'mysql2/promise';
|
||||
import pg from 'pg';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import {
|
||||
ensureLegacySchemaCompatibility,
|
||||
type LegacySchemaCompatInspector,
|
||||
} from './legacySchemaCompat.js';
|
||||
import {
|
||||
generateBootstrapSql,
|
||||
generateUpgradeSql,
|
||||
type MysqlIndexPrefixRequirementMap,
|
||||
} from './schemaArtifactGenerator.js';
|
||||
import { installPostgresJsonTextParsers } from './postgresJsonTextParsers.js';
|
||||
import { introspectLiveSchema } from './schemaIntrospection.js';
|
||||
import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js';
|
||||
|
||||
export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface RuntimeSchemaClient {
|
||||
dialect: RuntimeSchemaDialect;
|
||||
connectionString: string;
|
||||
ssl: boolean;
|
||||
begin(): Promise<void>;
|
||||
commit(): Promise<void>;
|
||||
rollback(): Promise<void>;
|
||||
execute(sqlText: string, params?: unknown[]): Promise<unknown>;
|
||||
queryScalar(sqlText: string, params?: unknown[]): Promise<number>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RuntimeSchemaConnectionInput {
|
||||
dialect: RuntimeSchemaDialect;
|
||||
connectionString: string;
|
||||
ssl?: boolean;
|
||||
}
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
}
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isExistingSchemaObjectError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
const code = typeof error === 'object' && error && 'code' in error
|
||||
? String((error as { code?: unknown }).code || '')
|
||||
: '';
|
||||
|
||||
return code === 'ER_DUP_KEYNAME'
|
||||
|| code === 'ER_DUP_FIELDNAME'
|
||||
|| code === 'ER_TABLE_EXISTS_ERROR'
|
||||
|| code === '42P07'
|
||||
|| code === '42701'
|
||||
|| code === '42710'
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column')
|
||||
|| lowered.includes('duplicate key name')
|
||||
|| lowered.includes('relation') && lowered.includes('already exists');
|
||||
}
|
||||
|
||||
async function executeBootstrapStatement(client: RuntimeSchemaClient, sqlText: string): Promise<void> {
|
||||
try {
|
||||
await client.execute(sqlText);
|
||||
} catch (error) {
|
||||
if (!isExistingSchemaObjectError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateIdentifier(identifier: string): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) {
|
||||
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
function createLegacySchemaInspector(client: RuntimeSchemaClient): LegacySchemaCompatInspector {
|
||||
if (client.dialect === 'sqlite') {
|
||||
return {
|
||||
dialect: 'sqlite',
|
||||
tableExists: async (table) => {
|
||||
const normalizedTable = validateIdentifier(table);
|
||||
return (await client.queryScalar(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`,
|
||||
)) > 0;
|
||||
},
|
||||
columnExists: async (table, column) => {
|
||||
const normalizedTable = validateIdentifier(table);
|
||||
const normalizedColumn = validateIdentifier(column);
|
||||
return (await client.queryScalar(
|
||||
`SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`,
|
||||
)) > 0;
|
||||
},
|
||||
execute: async (sqlText) => {
|
||||
await client.execute(sqlText);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (client.dialect === 'mysql') {
|
||||
return {
|
||||
dialect: 'mysql',
|
||||
tableExists: async (table) => {
|
||||
return (await client.queryScalar(
|
||||
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?',
|
||||
[table],
|
||||
)) > 0;
|
||||
},
|
||||
columnExists: async (table, column) => {
|
||||
return (await client.queryScalar(
|
||||
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?',
|
||||
[table, column],
|
||||
)) > 0;
|
||||
},
|
||||
execute: async (sqlText) => {
|
||||
await client.execute(sqlText);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dialect: 'postgres',
|
||||
tableExists: async (table) => {
|
||||
return (await client.queryScalar(
|
||||
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1',
|
||||
[table],
|
||||
)) > 0;
|
||||
},
|
||||
columnExists: async (table, column) => {
|
||||
return (await client.queryScalar(
|
||||
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2',
|
||||
[table, column],
|
||||
)) > 0;
|
||||
},
|
||||
execute: async (sqlText) => {
|
||||
await client.execute(sqlText);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitSqlStatements(sqlText: string): string[] {
|
||||
const withoutCommentLines = sqlText
|
||||
.split(/\r?\n/g)
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
|
||||
return withoutCommentLines
|
||||
.split(/;\s*(?:\r?\n|$)/g)
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function readSchemaContract(): SchemaContract {
|
||||
return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as SchemaContract;
|
||||
}
|
||||
|
||||
function cloneContract(contract: SchemaContract): SchemaContract {
|
||||
return JSON.parse(JSON.stringify(contract)) as SchemaContract;
|
||||
}
|
||||
|
||||
function serializeColumn(column: SchemaContract['tables'][string]['columns'][string]): string {
|
||||
return [
|
||||
column.logicalType,
|
||||
column.notNull ? 'not-null' : 'nullable',
|
||||
column.defaultValue ?? 'default:null',
|
||||
column.primaryKey ? 'pk' : 'non-pk',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function serializeIndex(index: SchemaContract['indexes'][number]): string {
|
||||
return [index.table, index.columns.join(','), index.unique ? 'unique' : 'non-unique'].join('|');
|
||||
}
|
||||
|
||||
function serializeUnique(unique: SchemaContract['uniques'][number]): string {
|
||||
return [unique.table, unique.columns.join(',')].join('|');
|
||||
}
|
||||
|
||||
function serializeForeignKey(foreignKey: SchemaContract['foreignKeys'][number]): string {
|
||||
return [
|
||||
foreignKey.table,
|
||||
foreignKey.columns.join(','),
|
||||
foreignKey.referencedTable,
|
||||
foreignKey.referencedColumns.join(','),
|
||||
foreignKey.onDelete ?? 'null',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function buildCompatibleRuntimeBaseline(
|
||||
currentContract: SchemaContract,
|
||||
liveContract: SchemaContract,
|
||||
): SchemaContract {
|
||||
const baseline: SchemaContract = {
|
||||
tables: {},
|
||||
indexes: [],
|
||||
uniques: [],
|
||||
foreignKeys: [],
|
||||
};
|
||||
|
||||
for (const [tableName, liveTable] of Object.entries(liveContract.tables)) {
|
||||
const currentTable = currentContract.tables[tableName];
|
||||
if (!currentTable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const compatibleColumns = Object.fromEntries(
|
||||
Object.entries(liveTable.columns)
|
||||
.filter(([columnName, liveColumn]) => {
|
||||
const currentColumn = currentTable.columns[columnName];
|
||||
return currentColumn && serializeColumn(currentColumn) === serializeColumn(liveColumn);
|
||||
}),
|
||||
);
|
||||
|
||||
baseline.tables[tableName] = { columns: compatibleColumns };
|
||||
}
|
||||
|
||||
const currentIndexes = new Map(currentContract.indexes.map((index) => [index.name, index]));
|
||||
baseline.indexes = liveContract.indexes
|
||||
.filter((index) => {
|
||||
const currentIndex = currentIndexes.get(index.name);
|
||||
return currentIndex && serializeIndex(currentIndex) === serializeIndex(index);
|
||||
});
|
||||
|
||||
const currentUniques = new Map(currentContract.uniques.map((unique) => [unique.name, unique]));
|
||||
baseline.uniques = liveContract.uniques
|
||||
.filter((unique) => {
|
||||
const currentUnique = currentUniques.get(unique.name);
|
||||
return currentUnique && serializeUnique(currentUnique) === serializeUnique(unique);
|
||||
});
|
||||
|
||||
const currentForeignKeys = new Set(currentContract.foreignKeys.map(serializeForeignKey));
|
||||
baseline.foreignKeys = liveContract.foreignKeys
|
||||
.filter((foreignKey) => currentForeignKeys.has(serializeForeignKey(foreignKey)));
|
||||
|
||||
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 || '');
|
||||
requirements[tableName] ??= {};
|
||||
requirements[tableName][columnName] = requiresMysqlIndexPrefixForColumnType(declaredType);
|
||||
}
|
||||
|
||||
return requirements;
|
||||
}
|
||||
|
||||
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
|
||||
const clientOptions: pg.ClientConfig = { connectionString };
|
||||
if (ssl) {
|
||||
clientOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
installPostgresJsonTextParsers();
|
||||
const client = new pg.Client(clientOptions);
|
||||
await client.connect();
|
||||
|
||||
return {
|
||||
dialect: 'postgres',
|
||||
connectionString,
|
||||
ssl,
|
||||
begin: async () => { await client.query('BEGIN'); },
|
||||
commit: async () => { await client.query('COMMIT'); },
|
||||
rollback: async () => { await client.query('ROLLBACK'); },
|
||||
execute: async (sqlText, params = []) => client.query(sqlText, params),
|
||||
queryScalar: async (sqlText, params = []) => {
|
||||
const result = await client.query(sqlText, params);
|
||||
const row = result.rows[0] as Record<string, unknown> | undefined;
|
||||
if (!row) return 0;
|
||||
return Number(Object.values(row)[0]) || 0;
|
||||
},
|
||||
close: async () => { await client.end(); },
|
||||
};
|
||||
}
|
||||
|
||||
async function createMySqlClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
|
||||
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
|
||||
if (ssl) {
|
||||
connectionOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const connection = await mysql.createConnection(connectionOptions);
|
||||
|
||||
return {
|
||||
dialect: 'mysql',
|
||||
connectionString,
|
||||
ssl,
|
||||
begin: async () => { await connection.beginTransaction(); },
|
||||
commit: async () => { await connection.commit(); },
|
||||
rollback: async () => { await connection.rollback(); },
|
||||
execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]),
|
||||
queryScalar: async (sqlText, params = []) => {
|
||||
const [rows] = await connection.query(sqlText, params as any[]);
|
||||
if (!Array.isArray(rows) || rows.length === 0) return 0;
|
||||
const row = rows[0] as Record<string, unknown>;
|
||||
return Number(Object.values(row)[0]) || 0;
|
||||
},
|
||||
close: async () => { await connection.end(); },
|
||||
};
|
||||
}
|
||||
|
||||
async function createSqliteClient(connectionString: string): Promise<RuntimeSchemaClient> {
|
||||
const filePath = connectionString === ':memory:' ? ':memory:' : resolve(connectionString);
|
||||
if (filePath !== ':memory:') {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
}
|
||||
const sqlite = new Database(filePath);
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
return {
|
||||
dialect: 'sqlite',
|
||||
connectionString,
|
||||
ssl: false,
|
||||
begin: async () => { sqlite.exec('BEGIN'); },
|
||||
commit: async () => { sqlite.exec('COMMIT'); },
|
||||
rollback: async () => { sqlite.exec('ROLLBACK'); },
|
||||
execute: async (sqlText, params = []) => {
|
||||
const lowered = sqlText.trim().toLowerCase();
|
||||
const statement = sqlite.prepare(sqlText);
|
||||
if (lowered.startsWith('select')) return statement.all(...params);
|
||||
return statement.run(...params);
|
||||
},
|
||||
queryScalar: async (sqlText, params = []) => {
|
||||
const row = sqlite.prepare(sqlText).get(...params) as Record<string, unknown> | undefined;
|
||||
if (!row) return 0;
|
||||
return Number(Object.values(row)[0]) || 0;
|
||||
},
|
||||
close: async () => { sqlite.close(); },
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRuntimeSchemaClient(input: RuntimeSchemaConnectionInput): Promise<RuntimeSchemaClient> {
|
||||
if (input.dialect === 'postgres') {
|
||||
return createPostgresClient(input.connectionString, !!input.ssl);
|
||||
}
|
||||
if (input.dialect === 'mysql') {
|
||||
return createMySqlClient(input.connectionString, !!input.ssl);
|
||||
}
|
||||
return createSqliteClient(input.connectionString);
|
||||
}
|
||||
|
||||
type EnsureRuntimeDatabaseSchemaOptions = {
|
||||
currentContract?: SchemaContract;
|
||||
liveContract?: SchemaContract;
|
||||
};
|
||||
|
||||
async function resolveLiveContract(client: RuntimeSchemaClient, liveContract?: SchemaContract): Promise<SchemaContract> {
|
||||
if (liveContract) {
|
||||
return liveContract;
|
||||
}
|
||||
|
||||
return introspectLiveSchema({
|
||||
dialect: client.dialect,
|
||||
connectionString: client.connectionString,
|
||||
ssl: client.ssl,
|
||||
});
|
||||
}
|
||||
|
||||
function buildExternalUpgradeStatements(
|
||||
dialect: Exclude<RuntimeSchemaDialect, 'sqlite'>,
|
||||
currentContract: SchemaContract,
|
||||
liveContract: SchemaContract,
|
||||
mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap,
|
||||
): string[] {
|
||||
const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract);
|
||||
return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline, {
|
||||
mysqlIndexPrefixRequirements,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function ensureRuntimeDatabaseSchema(
|
||||
client: RuntimeSchemaClient,
|
||||
options: EnsureRuntimeDatabaseSchemaOptions = {},
|
||||
): Promise<void> {
|
||||
const currentContract = options.currentContract ?? readSchemaContract();
|
||||
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,
|
||||
liveContract,
|
||||
mysqlIndexPrefixRequirements,
|
||||
);
|
||||
}
|
||||
|
||||
for (const sqlText of statements) {
|
||||
await executeBootstrapStatement(client, sqlText);
|
||||
}
|
||||
|
||||
await ensureLegacySchemaCompatibility(createLegacySchemaInspector(client));
|
||||
}
|
||||
|
||||
export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise<void> {
|
||||
const client = await createRuntimeSchemaClient(input);
|
||||
try {
|
||||
await ensureRuntimeDatabaseSchema(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const __runtimeSchemaBootstrapTestUtils = {
|
||||
buildCompatibleRuntimeBaseline,
|
||||
cloneContract,
|
||||
splitSqlStatements,
|
||||
buildExternalUpgradeStatements,
|
||||
};
|
||||
@@ -1,627 +0,0 @@
|
||||
import { sqliteTable, text, integer, real, uniqueIndex, index, check } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const sites = sqliteTable('sites', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
externalCheckinUrl: text('external_checkin_url'),
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli' | 'antigravity'
|
||||
proxyUrl: text('proxy_url'),
|
||||
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
||||
customHeaders: text('custom_headers'),
|
||||
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
globalWeight: real('global_weight').default(1),
|
||||
apiKey: text('api_key'),
|
||||
postRefreshProbeEnabled: integer('post_refresh_probe_enabled', { mode: 'boolean' }).default(false),
|
||||
postRefreshProbeModel: text('post_refresh_probe_model').default(''),
|
||||
postRefreshProbeScope: text('post_refresh_probe_scope').default('single'),
|
||||
postRefreshProbeLatencyThresholdMs: integer('post_refresh_probe_latency_threshold_ms').default(0),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
statusIdx: index('sites_status_idx').on(table.status),
|
||||
platformUrlUnique: uniqueIndex('sites_platform_url_unique').on(table.platform, table.url),
|
||||
}));
|
||||
|
||||
export const siteApiEndpoints = sqliteTable('site_api_endpoints', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
url: text('url').notNull(),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
cooldownUntil: text('cooldown_until'),
|
||||
lastSelectedAt: text('last_selected_at'),
|
||||
lastFailedAt: text('last_failed_at'),
|
||||
lastFailureReason: text('last_failure_reason'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
siteUrlUnique: uniqueIndex('site_api_endpoints_site_url_unique').on(table.siteId, table.url),
|
||||
siteEnabledSortIdx: index('site_api_endpoints_site_enabled_sort_idx').on(table.siteId, table.enabled, table.sortOrder),
|
||||
siteCooldownIdx: index('site_api_endpoints_site_cooldown_idx').on(table.siteId, table.cooldownUntil),
|
||||
}));
|
||||
|
||||
export const siteDisabledModels = sqliteTable('site_disabled_models', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
modelName: text('model_name').notNull(),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
siteModelUnique: uniqueIndex('site_disabled_models_site_model_unique').on(table.siteId, table.modelName),
|
||||
siteIdIdx: index('site_disabled_models_site_id_idx').on(table.siteId),
|
||||
}));
|
||||
|
||||
export const accounts = sqliteTable('accounts', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
username: text('username'),
|
||||
accessToken: text('access_token').notNull(),
|
||||
apiToken: text('api_token'),
|
||||
balance: real('balance').default(0),
|
||||
balanceUsed: real('balance_used').default(0),
|
||||
quota: real('quota').default(0),
|
||||
unitCost: real('unit_cost'),
|
||||
valueScore: real('value_score').default(0),
|
||||
status: text('status').default('active'), // 'active' | 'disabled' | 'expired'
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true),
|
||||
lastCheckinAt: text('last_checkin_at'),
|
||||
lastBalanceRefresh: text('last_balance_refresh'),
|
||||
oauthProvider: text('oauth_provider'),
|
||||
oauthAccountKey: text('oauth_account_key'),
|
||||
oauthProjectId: text('oauth_project_id'),
|
||||
extraConfig: text('extra_config'), // JSON string
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
siteIdIdx: index('accounts_site_id_idx').on(table.siteId),
|
||||
statusIdx: index('accounts_status_idx').on(table.status),
|
||||
siteStatusIdx: index('accounts_site_status_idx').on(table.siteId, table.status),
|
||||
oauthProviderIdx: index('accounts_oauth_provider_idx').on(table.oauthProvider),
|
||||
oauthIdentityIdx: index('accounts_oauth_identity_idx').on(table.oauthProvider, table.oauthAccountKey, table.oauthProjectId),
|
||||
}));
|
||||
|
||||
export const accountTokens = sqliteTable('account_tokens', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
token: text('token').notNull(),
|
||||
tokenGroup: text('token_group'),
|
||||
valueStatus: text('value_status').notNull().default('ready'),
|
||||
source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy'
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
isDefault: integer('is_default', { mode: 'boolean' }).default(false),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
accountIdIdx: index('account_tokens_account_id_idx').on(table.accountId),
|
||||
accountEnabledIdx: index('account_tokens_account_enabled_idx').on(table.accountId, table.enabled),
|
||||
enabledIdx: index('account_tokens_enabled_idx').on(table.enabled),
|
||||
}));
|
||||
|
||||
export const checkinLogs = sqliteTable('checkin_logs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull(), // 'success' | 'failed' | 'skipped'
|
||||
message: text('message'),
|
||||
reward: text('reward'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
accountCreatedIdx: index('checkin_logs_account_created_at_idx').on(table.accountId, table.createdAt),
|
||||
createdAtIdx: index('checkin_logs_created_at_idx').on(table.createdAt),
|
||||
statusIdx: index('checkin_logs_status_idx').on(table.status),
|
||||
}));
|
||||
|
||||
export const modelAvailability = sqliteTable('model_availability', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
modelName: text('model_name').notNull(),
|
||||
available: integer('available', { mode: 'boolean' }),
|
||||
isManual: integer('is_manual', { mode: 'boolean' }).default(false),
|
||||
latencyMs: integer('latency_ms'),
|
||||
checkedAt: text('checked_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
accountModelUnique: uniqueIndex('model_availability_account_model_unique').on(table.accountId, table.modelName),
|
||||
accountAvailableIdx: index('model_availability_account_available_idx').on(table.accountId, table.available),
|
||||
modelNameIdx: index('model_availability_model_name_idx').on(table.modelName),
|
||||
}));
|
||||
|
||||
export const tokenModelAvailability = sqliteTable('token_model_availability', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
tokenId: integer('token_id').notNull().references(() => accountTokens.id, { onDelete: 'cascade' }),
|
||||
modelName: text('model_name').notNull(),
|
||||
available: integer('available', { mode: 'boolean' }),
|
||||
latencyMs: integer('latency_ms'),
|
||||
checkedAt: text('checked_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
tokenModelUnique: uniqueIndex('token_model_availability_token_model_unique').on(table.tokenId, table.modelName),
|
||||
tokenAvailableIdx: index('token_model_availability_token_available_idx').on(table.tokenId, table.available),
|
||||
modelNameIdx: index('token_model_availability_model_name_idx').on(table.modelName),
|
||||
availableIdx: index('token_model_availability_available_idx').on(table.available),
|
||||
}));
|
||||
|
||||
export const tokenRoutes = sqliteTable('token_routes', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
modelPattern: text('model_pattern').notNull(),
|
||||
displayName: text('display_name'),
|
||||
displayIcon: text('display_icon'),
|
||||
routeMode: text('route_mode').default('pattern'),
|
||||
modelMapping: text('model_mapping'), // JSON
|
||||
decisionSnapshot: text('decision_snapshot'), // JSON
|
||||
decisionRefreshedAt: text('decision_refreshed_at'),
|
||||
routingStrategy: text('routing_strategy').default('weighted'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
modelPatternIdx: index('token_routes_model_pattern_idx').on(table.modelPattern),
|
||||
enabledIdx: index('token_routes_enabled_idx').on(table.enabled),
|
||||
}));
|
||||
|
||||
export const routeGroupSources = sqliteTable('route_group_sources', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
groupRouteId: integer('group_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
|
||||
sourceRouteId: integer('source_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
|
||||
}, (table) => ({
|
||||
groupSourceUnique: uniqueIndex('route_group_sources_group_source_unique').on(table.groupRouteId, table.sourceRouteId),
|
||||
sourceRouteIdx: index('route_group_sources_source_route_id_idx').on(table.sourceRouteId),
|
||||
}));
|
||||
|
||||
export const oauthRouteUnits = sqliteTable('oauth_route_units', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
provider: text('provider').notNull(),
|
||||
name: text('name').notNull(),
|
||||
strategy: text('strategy').notNull().default('round_robin'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
siteProviderIdx: index('oauth_route_units_site_provider_idx').on(table.siteId, table.provider),
|
||||
enabledIdx: index('oauth_route_units_enabled_idx').on(table.enabled),
|
||||
}));
|
||||
|
||||
export const oauthRouteUnitMembers = sqliteTable('oauth_route_unit_members', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
unitId: integer('unit_id').notNull().references(() => oauthRouteUnits.id, { onDelete: 'cascade' }),
|
||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
successCount: integer('success_count').default(0),
|
||||
failCount: integer('fail_count').default(0),
|
||||
totalLatencyMs: integer('total_latency_ms').default(0),
|
||||
totalCost: real('total_cost').default(0),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
lastSelectedAt: text('last_selected_at'),
|
||||
lastFailAt: text('last_fail_at'),
|
||||
consecutiveFailCount: integer('consecutive_fail_count').notNull().default(0),
|
||||
cooldownLevel: integer('cooldown_level').notNull().default(0),
|
||||
cooldownUntil: text('cooldown_until'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
unitAccountUnique: uniqueIndex('oauth_route_unit_members_unit_account_unique').on(table.unitId, table.accountId),
|
||||
accountUnique: uniqueIndex('oauth_route_unit_members_account_unique').on(table.accountId),
|
||||
unitSortIdx: index('oauth_route_unit_members_unit_sort_idx').on(table.unitId, table.sortOrder),
|
||||
unitCooldownIdx: index('oauth_route_unit_members_unit_cooldown_idx').on(table.unitId, table.cooldownUntil),
|
||||
}));
|
||||
|
||||
export const routeChannels = sqliteTable('route_channels', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
|
||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
tokenId: integer('token_id').references(() => accountTokens.id, { onDelete: 'set null' }),
|
||||
oauthRouteUnitId: integer('oauth_route_unit_id'),
|
||||
sourceModel: text('source_model'),
|
||||
priority: integer('priority').default(0),
|
||||
weight: integer('weight').default(10),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
manualOverride: integer('manual_override', { mode: 'boolean' }).default(false),
|
||||
successCount: integer('success_count').default(0),
|
||||
failCount: integer('fail_count').default(0),
|
||||
totalLatencyMs: integer('total_latency_ms').default(0),
|
||||
totalCost: real('total_cost').default(0),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
lastSelectedAt: text('last_selected_at'),
|
||||
lastFailAt: text('last_fail_at'),
|
||||
consecutiveFailCount: integer('consecutive_fail_count').notNull().default(0),
|
||||
cooldownLevel: integer('cooldown_level').notNull().default(0),
|
||||
cooldownUntil: text('cooldown_until'),
|
||||
}, (table) => ({
|
||||
routeIdIdx: index('route_channels_route_id_idx').on(table.routeId),
|
||||
accountIdIdx: index('route_channels_account_id_idx').on(table.accountId),
|
||||
tokenIdIdx: index('route_channels_token_id_idx').on(table.tokenId),
|
||||
oauthRouteUnitIdx: index('route_channels_oauth_route_unit_id_idx').on(table.oauthRouteUnitId),
|
||||
routeEnabledIdx: index('route_channels_route_enabled_idx').on(table.routeId, table.enabled),
|
||||
routeTokenIdx: index('route_channels_route_token_idx').on(table.routeId, table.tokenId),
|
||||
}));
|
||||
|
||||
export const proxyLogs = sqliteTable('proxy_logs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
routeId: integer('route_id'),
|
||||
channelId: integer('channel_id'),
|
||||
accountId: integer('account_id'),
|
||||
downstreamApiKeyId: integer('downstream_api_key_id'),
|
||||
modelRequested: text('model_requested'),
|
||||
modelActual: text('model_actual'),
|
||||
status: text('status'), // 'success' | 'failed' | 'retried'
|
||||
httpStatus: integer('http_status'),
|
||||
isStream: integer('is_stream', { mode: 'boolean' }),
|
||||
firstByteLatencyMs: integer('first_byte_latency_ms'),
|
||||
latencyMs: integer('latency_ms'),
|
||||
promptTokens: integer('prompt_tokens'),
|
||||
completionTokens: integer('completion_tokens'),
|
||||
totalTokens: integer('total_tokens'),
|
||||
estimatedCost: real('estimated_cost'),
|
||||
billingDetails: text('billing_details'),
|
||||
clientFamily: text('client_family'),
|
||||
clientAppId: text('client_app_id'),
|
||||
clientAppName: text('client_app_name'),
|
||||
clientConfidence: text('client_confidence'),
|
||||
errorMessage: text('error_message'),
|
||||
retryCount: integer('retry_count').default(0),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
createdAtIdx: index('proxy_logs_created_at_idx').on(table.createdAt),
|
||||
accountCreatedIdx: index('proxy_logs_account_created_at_idx').on(table.accountId, table.createdAt),
|
||||
statusCreatedIdx: index('proxy_logs_status_created_at_idx').on(table.status, table.createdAt),
|
||||
modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt),
|
||||
downstreamKeyCreatedIdx: index('proxy_logs_downstream_api_key_created_at_idx').on(table.downstreamApiKeyId, table.createdAt),
|
||||
clientAppCreatedIdx: index('proxy_logs_client_app_id_created_at_idx').on(table.clientAppId, table.createdAt),
|
||||
clientFamilyCreatedIdx: index('proxy_logs_client_family_created_at_idx').on(table.clientFamily, table.createdAt),
|
||||
}));
|
||||
|
||||
export const proxyDebugTraces = sqliteTable('proxy_debug_traces', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
downstreamPath: text('downstream_path').notNull(),
|
||||
clientKind: text('client_kind'),
|
||||
sessionId: text('session_id'),
|
||||
traceHint: text('trace_hint'),
|
||||
requestedModel: text('requested_model'),
|
||||
downstreamApiKeyId: integer('downstream_api_key_id'),
|
||||
requestHeadersJson: text('request_headers_json'),
|
||||
requestBodyJson: text('request_body_json'),
|
||||
stickySessionKey: text('sticky_session_key'),
|
||||
stickyHitChannelId: integer('sticky_hit_channel_id'),
|
||||
selectedChannelId: integer('selected_channel_id'),
|
||||
selectedRouteId: integer('selected_route_id'),
|
||||
selectedAccountId: integer('selected_account_id'),
|
||||
selectedSiteId: integer('selected_site_id'),
|
||||
selectedSitePlatform: text('selected_site_platform'),
|
||||
endpointCandidatesJson: text('endpoint_candidates_json'),
|
||||
endpointRuntimeStateJson: text('endpoint_runtime_state_json'),
|
||||
decisionSummaryJson: text('decision_summary_json'),
|
||||
finalStatus: text('final_status'),
|
||||
finalHttpStatus: integer('final_http_status'),
|
||||
finalUpstreamPath: text('final_upstream_path'),
|
||||
finalResponseHeadersJson: text('final_response_headers_json'),
|
||||
finalResponseBodyJson: text('final_response_body_json'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
createdAtIdx: index('proxy_debug_traces_created_at_idx').on(table.createdAt),
|
||||
sessionCreatedIdx: index('proxy_debug_traces_session_created_at_idx').on(table.sessionId, table.createdAt),
|
||||
modelCreatedIdx: index('proxy_debug_traces_model_created_at_idx').on(table.requestedModel, table.createdAt),
|
||||
finalStatusCreatedIdx: index('proxy_debug_traces_final_status_created_at_idx').on(table.finalStatus, table.createdAt),
|
||||
}));
|
||||
|
||||
export const proxyDebugAttempts = sqliteTable('proxy_debug_attempts', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
traceId: integer('trace_id').notNull().references(() => proxyDebugTraces.id, { onDelete: 'cascade' }),
|
||||
attemptIndex: integer('attempt_index').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
requestPath: text('request_path').notNull(),
|
||||
targetUrl: text('target_url').notNull(),
|
||||
runtimeExecutor: text('runtime_executor'),
|
||||
requestHeadersJson: text('request_headers_json'),
|
||||
requestBodyJson: text('request_body_json'),
|
||||
responseStatus: integer('response_status'),
|
||||
responseHeadersJson: text('response_headers_json'),
|
||||
responseBodyJson: text('response_body_json'),
|
||||
rawErrorText: text('raw_error_text'),
|
||||
recoverApplied: integer('recover_applied', { mode: 'boolean' }).default(false),
|
||||
downgradeDecision: integer('downgrade_decision', { mode: 'boolean' }).default(false),
|
||||
downgradeReason: text('downgrade_reason'),
|
||||
memoryWriteJson: text('memory_write_json'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
traceAttemptUnique: uniqueIndex('proxy_debug_attempts_trace_attempt_unique').on(table.traceId, table.attemptIndex),
|
||||
traceCreatedIdx: index('proxy_debug_attempts_trace_created_at_idx').on(table.traceId, table.createdAt),
|
||||
}));
|
||||
|
||||
export const proxyVideoTasks = sqliteTable('proxy_video_tasks', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
publicId: text('public_id').notNull(),
|
||||
upstreamVideoId: text('upstream_video_id').notNull(),
|
||||
siteUrl: text('site_url').notNull(),
|
||||
tokenValue: text('token_value').notNull(),
|
||||
requestedModel: text('requested_model'),
|
||||
actualModel: text('actual_model'),
|
||||
channelId: integer('channel_id'),
|
||||
accountId: integer('account_id'),
|
||||
statusSnapshot: text('status_snapshot'),
|
||||
upstreamResponseMeta: text('upstream_response_meta'),
|
||||
lastUpstreamStatus: integer('last_upstream_status'),
|
||||
lastPolledAt: text('last_polled_at'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
publicIdUnique: uniqueIndex('proxy_video_tasks_public_id_unique').on(table.publicId),
|
||||
upstreamVideoIdIdx: index('proxy_video_tasks_upstream_video_id_idx').on(table.upstreamVideoId),
|
||||
createdAtIdx: index('proxy_video_tasks_created_at_idx').on(table.createdAt),
|
||||
}));
|
||||
|
||||
export const proxyFiles = sqliteTable('proxy_files', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
publicId: text('public_id').notNull(),
|
||||
ownerType: text('owner_type').notNull(),
|
||||
ownerId: text('owner_id').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
purpose: text('purpose'),
|
||||
byteSize: integer('byte_size').notNull(),
|
||||
sha256: text('sha256').notNull(),
|
||||
contentBase64: text('content_base64').notNull(),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
deletedAt: text('deleted_at'),
|
||||
}, (table) => ({
|
||||
publicIdUnique: uniqueIndex('proxy_files_public_id_unique').on(table.publicId),
|
||||
ownerLookupIdx: index('proxy_files_owner_lookup_idx').on(table.ownerType, table.ownerId, table.deletedAt),
|
||||
}));
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'), // JSON
|
||||
});
|
||||
|
||||
export const adminSnapshots = sqliteTable('admin_snapshots', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
namespace: text('namespace').notNull(),
|
||||
snapshotKey: text('snapshot_key').notNull(),
|
||||
payload: text('payload').notNull(),
|
||||
generatedAt: text('generated_at').notNull(),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
staleUntil: text('stale_until').notNull(),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
namespaceKeyUnique: uniqueIndex('admin_snapshots_namespace_key_unique').on(table.namespace, table.snapshotKey),
|
||||
expiresAtIdx: index('admin_snapshots_expires_at_idx').on(table.expiresAt),
|
||||
staleUntilIdx: index('admin_snapshots_stale_until_idx').on(table.staleUntil),
|
||||
}));
|
||||
|
||||
export const analyticsProjectionCheckpoints = sqliteTable('analytics_projection_checkpoints', {
|
||||
projectorKey: text('projector_key').primaryKey(),
|
||||
timeZone: text('time_zone').notNull().default('Local'),
|
||||
lastProxyLogId: integer('last_proxy_log_id').notNull().default(0),
|
||||
watermarkCreatedAt: text('watermark_created_at'),
|
||||
leaseOwner: text('lease_owner'),
|
||||
leaseToken: text('lease_token'),
|
||||
leaseExpiresAt: text('lease_expires_at'),
|
||||
recomputeFromId: integer('recompute_from_id'),
|
||||
recomputeRequestedAt: text('recompute_requested_at'),
|
||||
recomputeReason: text('recompute_reason'),
|
||||
recomputeStartedAt: text('recompute_started_at'),
|
||||
recomputeCompletedAt: text('recompute_completed_at'),
|
||||
lastProjectedAt: text('last_projected_at'),
|
||||
lastSuccessfulAt: text('last_successful_at'),
|
||||
lastError: text('last_error'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
recomputeFromIdIdx: index('analytics_projection_checkpoints_recompute_from_id_idx').on(table.recomputeFromId),
|
||||
leaseExpiresAtIdx: index('analytics_projection_checkpoints_lease_expires_at_idx').on(table.leaseExpiresAt),
|
||||
}));
|
||||
|
||||
export const siteDayUsage = sqliteTable('site_day_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
localDay: text('local_day').notNull(),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
totalCalls: integer('total_calls').notNull().default(0),
|
||||
successCalls: integer('success_calls').notNull().default(0),
|
||||
failedCalls: integer('failed_calls').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
totalSummarySpend: real('total_summary_spend').notNull().default(0),
|
||||
totalSiteSpend: real('total_site_spend').notNull().default(0),
|
||||
totalLatencyMs: integer('total_latency_ms').notNull().default(0),
|
||||
latencyCount: integer('latency_count').notNull().default(0),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
daySiteUnique: uniqueIndex('site_day_usage_day_site_unique').on(table.localDay, table.siteId),
|
||||
dayIdx: index('site_day_usage_day_idx').on(table.localDay),
|
||||
siteIdx: index('site_day_usage_site_id_idx').on(table.siteId),
|
||||
nonNegative: check(
|
||||
'site_day_usage_non_negative',
|
||||
sql`${table.totalCalls} >= 0 and ${table.successCalls} >= 0 and ${table.failedCalls} >= 0 and ${table.totalTokens} >= 0 and ${table.totalSummarySpend} >= 0 and ${table.totalSiteSpend} >= 0 and ${table.totalLatencyMs} >= 0 and ${table.latencyCount} >= 0`,
|
||||
),
|
||||
}));
|
||||
|
||||
export const siteHourUsage = sqliteTable('site_hour_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
bucketStartUtc: text('bucket_start_utc').notNull(),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
totalCalls: integer('total_calls').notNull().default(0),
|
||||
successCalls: integer('success_calls').notNull().default(0),
|
||||
failedCalls: integer('failed_calls').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
totalSummarySpend: real('total_summary_spend').notNull().default(0),
|
||||
totalSiteSpend: real('total_site_spend').notNull().default(0),
|
||||
totalLatencyMs: integer('total_latency_ms').notNull().default(0),
|
||||
latencyCount: integer('latency_count').notNull().default(0),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
hourSiteUnique: uniqueIndex('site_hour_usage_hour_site_unique').on(table.bucketStartUtc, table.siteId),
|
||||
hourIdx: index('site_hour_usage_hour_idx').on(table.bucketStartUtc),
|
||||
siteIdx: index('site_hour_usage_site_id_idx').on(table.siteId),
|
||||
nonNegative: check(
|
||||
'site_hour_usage_non_negative',
|
||||
sql`${table.totalCalls} >= 0 and ${table.successCalls} >= 0 and ${table.failedCalls} >= 0 and ${table.totalTokens} >= 0 and ${table.totalSummarySpend} >= 0 and ${table.totalSiteSpend} >= 0 and ${table.totalLatencyMs} >= 0 and ${table.latencyCount} >= 0`,
|
||||
),
|
||||
}));
|
||||
|
||||
export const modelDayUsage = sqliteTable('model_day_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
localDay: text('local_day').notNull(),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
model: text('model').notNull(),
|
||||
totalCalls: integer('total_calls').notNull().default(0),
|
||||
successCalls: integer('success_calls').notNull().default(0),
|
||||
failedCalls: integer('failed_calls').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
totalSpend: real('total_spend').notNull().default(0),
|
||||
totalLatencyMs: integer('total_latency_ms').notNull().default(0),
|
||||
latencyCount: integer('latency_count').notNull().default(0),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
daySiteModelUnique: uniqueIndex('model_day_usage_day_site_model_unique').on(table.localDay, table.siteId, table.model),
|
||||
dayIdx: index('model_day_usage_day_idx').on(table.localDay),
|
||||
siteIdx: index('model_day_usage_site_id_idx').on(table.siteId),
|
||||
modelIdx: index('model_day_usage_model_idx').on(table.model),
|
||||
nonNegative: check(
|
||||
'model_day_usage_non_negative',
|
||||
sql`${table.totalCalls} >= 0 and ${table.successCalls} >= 0 and ${table.failedCalls} >= 0 and ${table.totalTokens} >= 0 and ${table.totalSpend} >= 0 and ${table.totalLatencyMs} >= 0 and ${table.latencyCount} >= 0`,
|
||||
),
|
||||
}));
|
||||
|
||||
export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
key: text('key').notNull(),
|
||||
description: text('description'),
|
||||
groupName: text('group_name'),
|
||||
tags: text('tags'), // JSON array<string>
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
expiresAt: text('expires_at'),
|
||||
maxCost: real('max_cost'),
|
||||
usedCost: real('used_cost').default(0),
|
||||
maxRequests: integer('max_requests'),
|
||||
usedRequests: integer('used_requests').default(0),
|
||||
supportedModels: text('supported_models'), // JSON array<string>
|
||||
allowedRouteIds: text('allowed_route_ids'), // JSON array<number>
|
||||
siteWeightMultipliers: text('site_weight_multipliers'), // JSON object { [siteId]: multiplier }
|
||||
excludedSiteIds: text('excluded_site_ids'), // JSON array<number>
|
||||
excludedCredentialRefs: text('excluded_credential_refs'), // JSON array<DownstreamExcludedCredentialRef>
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
keyUnique: uniqueIndex('downstream_api_keys_key_unique').on(table.key),
|
||||
nameIdx: index('downstream_api_keys_name_idx').on(table.name),
|
||||
enabledIdx: index('downstream_api_keys_enabled_idx').on(table.enabled),
|
||||
expiresAtIdx: index('downstream_api_keys_expires_at_idx').on(table.expiresAt),
|
||||
}));
|
||||
|
||||
export const siteAnnouncements = sqliteTable('site_announcements', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
platform: text('platform').notNull(),
|
||||
sourceKey: text('source_key').notNull(),
|
||||
title: text('title').notNull(),
|
||||
content: text('content').notNull(),
|
||||
level: text('level').notNull().default('info'),
|
||||
sourceUrl: text('source_url'),
|
||||
startsAt: text('starts_at'),
|
||||
endsAt: text('ends_at'),
|
||||
upstreamCreatedAt: text('upstream_created_at'),
|
||||
upstreamUpdatedAt: text('upstream_updated_at'),
|
||||
firstSeenAt: text('first_seen_at').default(sql`(datetime('now'))`),
|
||||
lastSeenAt: text('last_seen_at').default(sql`(datetime('now'))`),
|
||||
readAt: text('read_at'),
|
||||
dismissedAt: text('dismissed_at'),
|
||||
rawPayload: text('raw_payload'),
|
||||
}, (table) => ({
|
||||
siteSourceKeyUnique: uniqueIndex('site_announcements_site_source_key_unique').on(table.siteId, table.sourceKey),
|
||||
siteIdFirstSeenAtIdx: index('site_announcements_site_id_first_seen_at_idx').on(table.siteId, table.firstSeenAt),
|
||||
readAtIdx: index('site_announcements_read_at_idx').on(table.readAt),
|
||||
}));
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
username: text('username').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
role: text('role').notNull().default('user'),
|
||||
status: text('status').notNull().default('active'),
|
||||
emailVerified: integer('email_verified').default(0),
|
||||
avatarUrl: text('avatar_url'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
emailUnique: uniqueIndex('users_email_unique').on(table.email),
|
||||
statusIdx: index('users_status_idx').on(table.status),
|
||||
}));
|
||||
|
||||
export const events = sqliteTable('events', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
||||
title: text('title').notNull(),
|
||||
message: text('message'),
|
||||
level: text('level').notNull().default('info'), // 'info' | 'warning' | 'error'
|
||||
read: integer('read', { mode: 'boolean' }).default(false),
|
||||
relatedId: integer('related_id'),
|
||||
relatedType: text('related_type'), // 'account' | 'site' | 'route'
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
readCreatedIdx: index('events_read_created_at_idx').on(table.read, table.createdAt),
|
||||
typeCreatedIdx: index('events_type_created_at_idx').on(table.type, table.createdAt),
|
||||
createdAtIdx: index('events_created_at_idx').on(table.createdAt),
|
||||
}));
|
||||
|
||||
// SaaS Multi-Tenant Tables
|
||||
export const organizations = sqliteTable('organizations', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
plan: text('plan').notNull().default('free'),
|
||||
status: text('status').notNull().default('active'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
slugIdx: uniqueIndex('org_slug_idx').on(table.slug),
|
||||
planIdx: index('org_plan_idx').on(table.plan),
|
||||
}));
|
||||
|
||||
export const organizationMembers = sqliteTable('organization_members', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
orgId: integer('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
role: text('role').notNull().default('member'),
|
||||
status: text('status').notNull().default('active'),
|
||||
joinedAt: text('joined_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
orgMemberUnique: uniqueIndex('org_member_unique').on(table.orgId, table.userId),
|
||||
orgIdIdx: index('org_members_org_id_idx').on(table.orgId),
|
||||
userIdIdx: index('org_members_user_id_idx').on(table.userId),
|
||||
}));
|
||||
|
||||
export const invitations = sqliteTable('invitations', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
orgId: integer('org_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
|
||||
email: text('email').notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
role: text('role').notNull().default('member'),
|
||||
invitedBy: integer('invited_by').references(() => users.id),
|
||||
status: text('status').notNull().default('pending'),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
tokenUnique: uniqueIndex('invitations_token_unique').on(table.token),
|
||||
statusIdx: index('invitations_status_idx').on(table.status),
|
||||
}));
|
||||
|
||||
export const dataPlans = sqliteTable('data_plans', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull().unique(),
|
||||
maxMembers: integer('max_members').notNull(),
|
||||
maxApiKeys: integer('max_api_keys').notNull(),
|
||||
maxRequestsPerDay: integer('max_requests_per_day').notNull(),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateDialectArtifacts, generateUpgradeSql, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js';
|
||||
import type { SchemaContract, SchemaContractColumn } from './schemaContract.js';
|
||||
|
||||
const dbDir = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json');
|
||||
|
||||
function readSchemaContract(): SchemaContract {
|
||||
return JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
|
||||
}
|
||||
|
||||
function makeColumn(overrides: Partial<SchemaContractColumn> = {}): SchemaContractColumn {
|
||||
return {
|
||||
logicalType: 'text',
|
||||
notNull: false,
|
||||
defaultValue: null,
|
||||
primaryKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('schema artifact generator', () => {
|
||||
it('generates bootstrap sql for mysql and postgres from the contract', () => {
|
||||
const artifacts = generateDialectArtifacts(readSchemaContract());
|
||||
|
||||
expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `sites`');
|
||||
expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `site_disabled_models`');
|
||||
expect(artifacts.postgresBootstrap).toContain('CREATE TABLE IF NOT EXISTS "account_tokens"');
|
||||
expect(artifacts.mysqlBootstrap).toContain('`snapshot_key` TEXT NOT NULL');
|
||||
expect(artifacts.postgresBootstrap).toContain('"snapshot_key" TEXT NOT NULL');
|
||||
expect(artifacts.postgresBootstrap).toContain('"token_group"');
|
||||
});
|
||||
|
||||
it('generates additive upgrade sql for newly added tables, columns, indexes, and uniques', () => {
|
||||
const previousContract: SchemaContract = {
|
||||
tables: {
|
||||
sites: {
|
||||
columns: {
|
||||
id: makeColumn({ logicalType: 'integer', notNull: true, primaryKey: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
indexes: [],
|
||||
uniques: [],
|
||||
foreignKeys: [],
|
||||
};
|
||||
const currentContract: SchemaContract = {
|
||||
tables: {
|
||||
sites: {
|
||||
columns: {
|
||||
id: makeColumn({ logicalType: 'integer', notNull: true, primaryKey: true }),
|
||||
status: makeColumn({ notNull: true, defaultValue: "'active'" }),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
columns: {
|
||||
id: makeColumn({ logicalType: 'integer', notNull: true, primaryKey: true }),
|
||||
site_id: makeColumn({ logicalType: 'integer', notNull: true }),
|
||||
email: makeColumn({ notNull: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
indexes: [
|
||||
{ name: 'sites_status_idx', table: 'sites', columns: ['status'], unique: false },
|
||||
],
|
||||
uniques: [
|
||||
{ name: 'accounts_site_email_unique', table: 'accounts', columns: ['site_id', 'email'] },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
table: 'accounts',
|
||||
columns: ['site_id'],
|
||||
referencedTable: 'sites',
|
||||
referencedColumns: ['id'],
|
||||
onDelete: 'cascade',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const artifacts: GeneratedDialectArtifacts = generateDialectArtifacts(currentContract, previousContract);
|
||||
|
||||
expect(artifacts.mysqlUpgrade).toContain('CREATE TABLE IF NOT EXISTS `accounts`');
|
||||
expect(artifacts.mysqlUpgrade).toContain('ALTER TABLE `sites` ADD COLUMN `status`');
|
||||
expect(artifacts.mysqlUpgrade).toContain('CREATE INDEX `sites_status_idx` ON `sites`');
|
||||
expect(artifacts.mysqlUpgrade).toContain('CREATE UNIQUE INDEX `accounts_site_email_unique` ON `accounts`');
|
||||
expect(artifacts.postgresUpgrade).toContain('CREATE TABLE IF NOT EXISTS "accounts"');
|
||||
expect(artifacts.postgresUpgrade).toContain('ALTER TABLE "sites" ADD COLUMN "status"');
|
||||
expect(artifacts.postgresUpgrade).toContain('CREATE INDEX "sites_status_idx" ON "sites"');
|
||||
expect(artifacts.postgresUpgrade).toContain('CREATE UNIQUE INDEX "accounts_site_email_unique" ON "accounts"');
|
||||
});
|
||||
|
||||
it('orders dependent tables safely and emits executable datetime defaults for external dialects', () => {
|
||||
const artifacts = generateDialectArtifacts(readSchemaContract());
|
||||
|
||||
expect(
|
||||
artifacts.mysqlBootstrap.indexOf('CREATE TABLE IF NOT EXISTS `accounts`'),
|
||||
).toBeLessThan(
|
||||
artifacts.mysqlBootstrap.indexOf('CREATE TABLE IF NOT EXISTS `account_tokens`'),
|
||||
);
|
||||
expect(
|
||||
artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "accounts"'),
|
||||
).toBeLessThan(
|
||||
artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "account_tokens"'),
|
||||
);
|
||||
expect(artifacts.mysqlBootstrap).toContain("`created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))");
|
||||
expect(artifacts.postgresBootstrap).toContain(`"created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')`);
|
||||
});
|
||||
|
||||
it('uses varchar for mysql text primary keys so bootstrap ddl stays executable', () => {
|
||||
const artifacts = generateDialectArtifacts(readSchemaContract());
|
||||
|
||||
expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT)');
|
||||
expect(artifacts.mysqlBootstrap).not.toContain('CREATE TABLE IF NOT EXISTS `settings` (`key` TEXT NOT NULL PRIMARY KEY, `value` TEXT)');
|
||||
});
|
||||
|
||||
it('does not add mysql text prefixes to non-text index columns', () => {
|
||||
const artifacts = generateDialectArtifacts(readSchemaContract());
|
||||
|
||||
expect(artifacts.mysqlBootstrap).toContain('CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`)');
|
||||
expect(artifacts.mysqlBootstrap).toContain('CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`)');
|
||||
expect(artifacts.mysqlBootstrap).not.toContain('`created_at`(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', () => {
|
||||
const current = readSchemaContract();
|
||||
const previous = structuredClone(current);
|
||||
|
||||
delete current.tables.sites.columns.status;
|
||||
|
||||
expect(() => generateDialectArtifacts(current, previous)).toThrow(/non-additive schema diff/i);
|
||||
});
|
||||
});
|
||||
@@ -1,446 +0,0 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type {
|
||||
LogicalColumnType,
|
||||
SchemaContract,
|
||||
SchemaContractColumn,
|
||||
SchemaContractForeignKey,
|
||||
SchemaContractIndex,
|
||||
SchemaContractUnique,
|
||||
} from './schemaContract.js';
|
||||
|
||||
export interface GeneratedDialectArtifacts {
|
||||
mysqlBootstrap: string;
|
||||
postgresBootstrap: string;
|
||||
mysqlUpgrade: string;
|
||||
postgresUpgrade: string;
|
||||
}
|
||||
|
||||
export type MysqlIndexPrefixRequirementMap = Record<string, Record<string, boolean>>;
|
||||
|
||||
type Dialect = 'mysql' | 'postgres';
|
||||
type SqlDialect = 'sqlite' | Dialect;
|
||||
type SqlGenerationOptions = {
|
||||
mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap;
|
||||
};
|
||||
|
||||
function resolveDbDir(): string {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
export function resolveGeneratedArtifactPath(filename: string): string {
|
||||
return resolve(resolveDbDir(), 'generated', filename);
|
||||
}
|
||||
|
||||
function quoteIdentifier(dialect: SqlDialect, identifier: string): string {
|
||||
return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`;
|
||||
}
|
||||
|
||||
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) {
|
||||
case 'boolean':
|
||||
case 'integer':
|
||||
return 'INTEGER';
|
||||
case 'real':
|
||||
return 'REAL';
|
||||
case 'datetime':
|
||||
case 'json':
|
||||
case 'text':
|
||||
default:
|
||||
return 'TEXT';
|
||||
}
|
||||
}
|
||||
|
||||
if (dialect === 'mysql') {
|
||||
switch (column.logicalType) {
|
||||
case 'boolean':
|
||||
return 'BOOLEAN';
|
||||
case 'integer':
|
||||
return column.primaryKey && columnName === 'id' ? 'INT AUTO_INCREMENT' : 'INT';
|
||||
case 'real':
|
||||
return 'DOUBLE';
|
||||
case 'datetime':
|
||||
return 'VARCHAR(191)';
|
||||
case 'json':
|
||||
return 'JSON';
|
||||
case 'text':
|
||||
return column.primaryKey || column.defaultValue != null ? 'VARCHAR(191)' : 'TEXT';
|
||||
default:
|
||||
return 'TEXT';
|
||||
}
|
||||
}
|
||||
|
||||
switch (column.logicalType) {
|
||||
case 'boolean':
|
||||
return 'BOOLEAN';
|
||||
case 'integer':
|
||||
return column.primaryKey && columnName === 'id' ? 'INTEGER GENERATED BY DEFAULT AS IDENTITY' : 'INTEGER';
|
||||
case 'real':
|
||||
return 'DOUBLE PRECISION';
|
||||
case 'datetime':
|
||||
return 'TEXT';
|
||||
case 'json':
|
||||
return 'JSONB';
|
||||
case 'text':
|
||||
default:
|
||||
return 'TEXT';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDefaultValue(dialect: SqlDialect, column: SchemaContractColumn): string {
|
||||
if (column.defaultValue == null || column.primaryKey) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (column.logicalType === 'datetime') {
|
||||
if (dialect === 'sqlite') {
|
||||
return " DEFAULT (datetime('now'))";
|
||||
}
|
||||
if (dialect === 'mysql') {
|
||||
return " DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))";
|
||||
}
|
||||
return " DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')";
|
||||
}
|
||||
|
||||
if (column.logicalType === 'boolean') {
|
||||
return ` DEFAULT ${column.defaultValue.toLowerCase()}`;
|
||||
}
|
||||
|
||||
return ` DEFAULT ${column.defaultValue}`;
|
||||
}
|
||||
|
||||
function buildColumnDefinition(
|
||||
dialect: SqlDialect,
|
||||
columnName: string,
|
||||
column: SchemaContractColumn,
|
||||
): string {
|
||||
const sqlType = mapColumnType(dialect, columnName, column);
|
||||
const notNull = column.notNull ? ' NOT NULL' : '';
|
||||
const defaultValue = formatDefaultValue(dialect, column);
|
||||
const primaryKey = column.primaryKey ? ' PRIMARY KEY' : '';
|
||||
return `${quoteIdentifier(dialect, columnName)} ${sqlType}${notNull}${defaultValue}${primaryKey}`;
|
||||
}
|
||||
|
||||
function buildForeignKeyClause(dialect: SqlDialect, foreignKey: SchemaContractForeignKey): string {
|
||||
const sourceColumns = foreignKey.columns.map((column) => quoteIdentifier(dialect, column)).join(', ');
|
||||
const targetColumns = foreignKey.referencedColumns.map((column) => quoteIdentifier(dialect, column)).join(', ');
|
||||
const onDelete = foreignKey.onDelete ? ` ON DELETE ${foreignKey.onDelete.toUpperCase()}` : '';
|
||||
return `FOREIGN KEY (${sourceColumns}) REFERENCES ${quoteIdentifier(dialect, foreignKey.referencedTable)}(${targetColumns})${onDelete}`;
|
||||
}
|
||||
|
||||
function sortTableNamesForCreation(contract: SchemaContract): string[] {
|
||||
const ordered: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>();
|
||||
const tableNames = Object.keys(contract.tables).sort((left, right) => left.localeCompare(right, 'en'));
|
||||
|
||||
const dependencies = new Map<string, string[]>();
|
||||
for (const tableName of tableNames) {
|
||||
const referencedTables = contract.foreignKeys
|
||||
.filter((foreignKey) => foreignKey.table === tableName)
|
||||
.map((foreignKey) => foreignKey.referencedTable)
|
||||
.filter((referencedTable) => referencedTable !== tableName && tableNames.includes(referencedTable))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'));
|
||||
dependencies.set(tableName, [...new Set(referencedTables)]);
|
||||
}
|
||||
|
||||
function visit(tableName: string): void {
|
||||
if (visited.has(tableName) || visiting.has(tableName)) {
|
||||
return;
|
||||
}
|
||||
visiting.add(tableName);
|
||||
for (const dependency of dependencies.get(tableName) ?? []) {
|
||||
visit(dependency);
|
||||
}
|
||||
visiting.delete(tableName);
|
||||
visited.add(tableName);
|
||||
ordered.push(tableName);
|
||||
}
|
||||
|
||||
for (const tableName of tableNames) {
|
||||
visit(tableName);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function buildCreateTableStatement(
|
||||
dialect: SqlDialect,
|
||||
tableName: string,
|
||||
contract: SchemaContract,
|
||||
): string {
|
||||
const table = contract.tables[tableName];
|
||||
const columnEntries = Object.entries(table.columns);
|
||||
const foreignKeys = contract.foreignKeys.filter((foreignKey) => foreignKey.table === tableName);
|
||||
const parts = [
|
||||
...columnEntries.map(([columnName, column]) => buildColumnDefinition(dialect, columnName, column)),
|
||||
...foreignKeys.map((foreignKey) => buildForeignKeyClause(dialect, foreignKey)),
|
||||
];
|
||||
return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`;
|
||||
}
|
||||
|
||||
function buildUniqueIndexStatement(
|
||||
dialect: SqlDialect,
|
||||
uniqueIndex: SchemaContractUnique,
|
||||
contract: SchemaContract,
|
||||
options?: SqlGenerationOptions,
|
||||
): string {
|
||||
const columns = uniqueIndex.columns
|
||||
.map((columnName) => {
|
||||
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,
|
||||
options?: SqlGenerationOptions,
|
||||
): string {
|
||||
const columns = index.columns
|
||||
.map((columnName) => {
|
||||
const suffix = dialect === 'mysql'
|
||||
? resolveMysqlIndexPrefix(index.table, columnName, contract, options)
|
||||
: '';
|
||||
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
|
||||
})
|
||||
.join(', ');
|
||||
return `CREATE INDEX ${quoteIdentifier(dialect, index.name)} ON ${quoteIdentifier(dialect, index.table)} (${columns})`;
|
||||
}
|
||||
|
||||
function serializeColumn(column: SchemaContractColumn): string {
|
||||
return [
|
||||
column.logicalType,
|
||||
column.notNull ? 'not-null' : 'nullable',
|
||||
column.defaultValue ?? 'default:null',
|
||||
column.primaryKey ? 'pk' : 'non-pk',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function serializeIndex(index: SchemaContractIndex): string {
|
||||
return [index.table, index.columns.join(','), index.unique ? 'unique' : 'non-unique'].join('|');
|
||||
}
|
||||
|
||||
function serializeUnique(unique: SchemaContractUnique): string {
|
||||
return [unique.table, unique.columns.join(',')].join('|');
|
||||
}
|
||||
|
||||
function serializeForeignKey(foreignKey: SchemaContractForeignKey): string {
|
||||
return [
|
||||
foreignKey.table,
|
||||
foreignKey.columns.join(','),
|
||||
foreignKey.referencedTable,
|
||||
foreignKey.referencedColumns.join(','),
|
||||
foreignKey.onDelete ?? 'null',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function assertAdditiveSchemaDiff(currentContract: SchemaContract, previousContract: SchemaContract): void {
|
||||
const violations: string[] = [];
|
||||
|
||||
for (const [tableName, previousTable] of Object.entries(previousContract.tables)) {
|
||||
const currentTable = currentContract.tables[tableName];
|
||||
if (!currentTable) {
|
||||
violations.push(`removed table ${tableName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [columnName, previousColumn] of Object.entries(previousTable.columns)) {
|
||||
const currentColumn = currentTable.columns[columnName];
|
||||
if (!currentColumn) {
|
||||
violations.push(`removed column ${tableName}.${columnName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (serializeColumn(currentColumn) !== serializeColumn(previousColumn)) {
|
||||
violations.push(`changed column ${tableName}.${columnName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentIndexes = new Map(currentContract.indexes.map((index) => [index.name, index]));
|
||||
for (const previousIndex of previousContract.indexes) {
|
||||
const currentIndex = currentIndexes.get(previousIndex.name);
|
||||
if (!currentIndex) {
|
||||
violations.push(`removed index ${previousIndex.name}`);
|
||||
continue;
|
||||
}
|
||||
if (serializeIndex(currentIndex) !== serializeIndex(previousIndex)) {
|
||||
violations.push(`changed index ${previousIndex.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const currentUniques = new Map(currentContract.uniques.map((unique) => [unique.name, unique]));
|
||||
for (const previousUnique of previousContract.uniques) {
|
||||
const currentUnique = currentUniques.get(previousUnique.name);
|
||||
if (!currentUnique) {
|
||||
violations.push(`removed unique ${previousUnique.name}`);
|
||||
continue;
|
||||
}
|
||||
if (serializeUnique(currentUnique) !== serializeUnique(previousUnique)) {
|
||||
violations.push(`changed unique ${previousUnique.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const currentForeignKeys = new Set(currentContract.foreignKeys.map(serializeForeignKey));
|
||||
for (const previousForeignKey of previousContract.foreignKeys) {
|
||||
if (!currentForeignKeys.has(serializeForeignKey(previousForeignKey))) {
|
||||
violations.push(`removed foreign key ${previousForeignKey.table}(${previousForeignKey.columns.join(',')})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Non-additive schema diff detected:\n- ${violations.join('\n- ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function generateBootstrapSql(dialect: SqlDialect, contract: SchemaContract): string {
|
||||
const tableStatements = sortTableNamesForCreation(contract)
|
||||
.map((tableName) => buildCreateTableStatement(dialect, tableName, contract));
|
||||
|
||||
const uniqueStatements = contract.uniques
|
||||
.slice()
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
.map((uniqueIndex) => buildUniqueIndexStatement(dialect, uniqueIndex, contract));
|
||||
|
||||
const knownUniqueNames = new Set(contract.uniques.map((uniqueIndex) => uniqueIndex.name));
|
||||
const indexStatements = contract.indexes
|
||||
.filter((index) => !knownUniqueNames.has(index.name))
|
||||
.slice()
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
.map((index) => buildIndexStatement(dialect, index, contract));
|
||||
|
||||
return `${[...tableStatements, ...uniqueStatements, ...indexStatements].join(';\n')};\n`;
|
||||
}
|
||||
|
||||
function buildAddColumnStatement(
|
||||
dialect: SqlDialect,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
column: SchemaContractColumn,
|
||||
): string {
|
||||
return `ALTER TABLE ${quoteIdentifier(dialect, tableName)} ADD COLUMN ${buildColumnDefinition(dialect, columnName, column)}`;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
assertAdditiveSchemaDiff(currentContract, previousContract);
|
||||
|
||||
const previousTableNames = new Set(Object.keys(previousContract.tables));
|
||||
const currentTableNames = Object.keys(currentContract.tables).sort((left, right) => left.localeCompare(right, 'en'));
|
||||
const addedTableNames = currentTableNames.filter((tableName) => !previousTableNames.has(tableName));
|
||||
const addedTablesContract: SchemaContract = {
|
||||
tables: Object.fromEntries(addedTableNames.map((tableName) => [tableName, currentContract.tables[tableName]])),
|
||||
indexes: currentContract.indexes.filter((index) => addedTableNames.includes(index.table)),
|
||||
uniques: currentContract.uniques.filter((unique) => addedTableNames.includes(unique.table)),
|
||||
foreignKeys: currentContract.foreignKeys.filter((foreignKey) => addedTableNames.includes(foreignKey.table)),
|
||||
};
|
||||
const addedTableStatements = sortTableNamesForCreation(addedTablesContract)
|
||||
.map((tableName) => buildCreateTableStatement(dialect, tableName, currentContract));
|
||||
|
||||
const addColumnStatements: string[] = [];
|
||||
for (const tableName of currentTableNames) {
|
||||
if (!previousTableNames.has(tableName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousColumns = previousContract.tables[tableName]?.columns ?? {};
|
||||
const currentColumns = currentContract.tables[tableName]?.columns ?? {};
|
||||
for (const [columnName, column] of Object.entries(currentColumns)) {
|
||||
if (previousColumns[columnName]) {
|
||||
continue;
|
||||
}
|
||||
addColumnStatements.push(buildAddColumnStatement(dialect, tableName, columnName, column));
|
||||
}
|
||||
}
|
||||
|
||||
const previousUniqueNames = new Set(previousContract.uniques.map((unique) => unique.name));
|
||||
const uniqueStatements = currentContract.uniques
|
||||
.filter((unique) => !previousUniqueNames.has(unique.name))
|
||||
.slice()
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
.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));
|
||||
const indexStatements = currentContract.indexes
|
||||
.filter((index) => !currentUniqueNames.has(index.name))
|
||||
.filter((index) => !previousIndexNames.has(index.name))
|
||||
.slice()
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
.map((index) => buildIndexStatement(dialect, index, currentContract, options));
|
||||
|
||||
const statements = [...addedTableStatements, ...addColumnStatements, ...uniqueStatements, ...indexStatements];
|
||||
if (statements.length === 0) {
|
||||
return `-- no schema changes detected for ${dialect}\n`;
|
||||
}
|
||||
|
||||
return `${statements.join(';\n')};\n`;
|
||||
}
|
||||
|
||||
export function generateDialectArtifacts(
|
||||
contract: SchemaContract,
|
||||
previousContract?: SchemaContract | null,
|
||||
): GeneratedDialectArtifacts {
|
||||
return {
|
||||
mysqlBootstrap: generateBootstrapSql('mysql', contract),
|
||||
postgresBootstrap: generateBootstrapSql('postgres', contract),
|
||||
mysqlUpgrade: generateUpgradeSql('mysql', contract, previousContract),
|
||||
postgresUpgrade: generateUpgradeSql('postgres', contract, previousContract),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeDialectArtifactFiles(
|
||||
contract: SchemaContract,
|
||||
previousContract?: SchemaContract | null,
|
||||
): GeneratedDialectArtifacts {
|
||||
const artifacts = generateDialectArtifacts(contract, previousContract);
|
||||
const artifactEntries = [
|
||||
['mysql.bootstrap.sql', artifacts.mysqlBootstrap],
|
||||
['postgres.bootstrap.sql', artifacts.postgresBootstrap],
|
||||
['mysql.upgrade.sql', artifacts.mysqlUpgrade],
|
||||
['postgres.upgrade.sql', artifacts.postgresUpgrade],
|
||||
] as const;
|
||||
|
||||
for (const [filename, content] of artifactEntries) {
|
||||
const outputPath = resolveGeneratedArtifactPath(filename);
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, content, 'utf8');
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSchemaContractFromSqliteMigrations } from './schemaContract.js';
|
||||
|
||||
describe('schema contract generation', () => {
|
||||
it('captures the current schema shape from sqlite migrations', () => {
|
||||
const contract = buildSchemaContractFromSqliteMigrations();
|
||||
|
||||
expect(contract.tables.sites.columns.status).toMatchObject({
|
||||
logicalType: 'text',
|
||||
notNull: true,
|
||||
primaryKey: false,
|
||||
});
|
||||
expect(contract.tables.account_tokens.columns.token_group).toBeDefined();
|
||||
expect(contract.tables.account_tokens.columns.value_status).toMatchObject({
|
||||
logicalType: 'text',
|
||||
notNull: true,
|
||||
defaultValue: "'ready'",
|
||||
});
|
||||
expect(contract.tables.site_disabled_models).toBeDefined();
|
||||
expect(contract.tables.downstream_api_keys).toBeDefined();
|
||||
expect(contract.tables.proxy_files).toBeDefined();
|
||||
expect(contract.tables.admin_snapshots.columns.snapshot_key).toMatchObject({
|
||||
logicalType: 'text',
|
||||
notNull: true,
|
||||
primaryKey: false,
|
||||
});
|
||||
expect(contract.tables.proxy_video_tasks).toBeDefined();
|
||||
expect(contract.tables.route_channels.columns.source_model).toBeDefined();
|
||||
expect(contract.tables.route_channels.columns.last_selected_at).toBeDefined();
|
||||
expect(contract.tables.route_channels.columns.consecutive_fail_count).toMatchObject({
|
||||
logicalType: 'integer',
|
||||
notNull: true,
|
||||
defaultValue: '0',
|
||||
});
|
||||
expect(contract.tables.sites.columns.use_system_proxy).toMatchObject({
|
||||
logicalType: 'boolean',
|
||||
defaultValue: 'false',
|
||||
});
|
||||
expect(contract.tables.token_routes.columns.routing_strategy).toMatchObject({
|
||||
logicalType: 'text',
|
||||
defaultValue: "'weighted'",
|
||||
});
|
||||
expect(contract.indexes).toContainEqual(
|
||||
expect.objectContaining({ name: 'sites_status_idx', table: 'sites', unique: false }),
|
||||
);
|
||||
expect(contract.uniques).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'site_disabled_models_site_model_unique',
|
||||
table: 'site_disabled_models',
|
||||
columns: ['site_id', 'model_name'],
|
||||
}),
|
||||
);
|
||||
expect(contract.uniques).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'model_availability_account_model_unique',
|
||||
table: 'model_availability',
|
||||
columns: ['account_id', 'model_name'],
|
||||
}),
|
||||
);
|
||||
expect(contract.foreignKeys).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: 'site_disabled_models',
|
||||
columns: ['site_id'],
|
||||
referencedTable: 'sites',
|
||||
referencedColumns: ['id'],
|
||||
}),
|
||||
);
|
||||
expect(contract.foreignKeys).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: 'route_channels',
|
||||
columns: ['token_id'],
|
||||
referencedTable: 'account_tokens',
|
||||
referencedColumns: ['id'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
normalizeLogicalColumnType,
|
||||
normalizeSchemaMetadataDefaultValue,
|
||||
type LogicalColumnType,
|
||||
} from './schemaMetadata.js';
|
||||
|
||||
export type { LogicalColumnType } from './schemaMetadata.js';
|
||||
|
||||
export interface SchemaContractColumn {
|
||||
logicalType: LogicalColumnType;
|
||||
notNull: boolean;
|
||||
defaultValue: string | null;
|
||||
primaryKey: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaContractTable {
|
||||
columns: Record<string, SchemaContractColumn>;
|
||||
}
|
||||
|
||||
export interface SchemaContractIndex {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: string[];
|
||||
unique: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaContractUnique {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
export interface SchemaContractForeignKey {
|
||||
table: string;
|
||||
columns: string[];
|
||||
referencedTable: string;
|
||||
referencedColumns: string[];
|
||||
onDelete: string | null;
|
||||
}
|
||||
|
||||
export interface SchemaContract {
|
||||
tables: Record<string, SchemaContractTable>;
|
||||
indexes: SchemaContractIndex[];
|
||||
uniques: SchemaContractUnique[];
|
||||
foreignKeys: SchemaContractForeignKey[];
|
||||
}
|
||||
|
||||
type TableInfoRow = {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
type IndexListRow = {
|
||||
name: string;
|
||||
unique: number;
|
||||
origin: string;
|
||||
partial: number;
|
||||
};
|
||||
|
||||
type IndexInfoRow = {
|
||||
seqno: number;
|
||||
cid: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ForeignKeyRow = {
|
||||
id: number;
|
||||
seq: number;
|
||||
table: string;
|
||||
from: string;
|
||||
to: string;
|
||||
on_update: string;
|
||||
on_delete: string;
|
||||
match: string;
|
||||
};
|
||||
|
||||
function resolveDbDir(): string {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
export function resolveMigrationsFolder(): string {
|
||||
return resolve(resolveDbDir(), '../../../drizzle');
|
||||
}
|
||||
|
||||
export function resolveGeneratedSchemaContractPath(): string {
|
||||
return resolve(resolveDbDir(), 'generated/schemaContract.json');
|
||||
}
|
||||
|
||||
function resolveMigrationFiles(migrationsFolder: string): string[] {
|
||||
return readdirSync(migrationsFolder)
|
||||
.filter((entry) => entry.endsWith('.sql'))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'));
|
||||
}
|
||||
|
||||
function splitMigrationStatements(sqlText: string): string[] {
|
||||
return sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function applySqliteMigrations(sqlite: Database.Database, migrationsFolder: string): void {
|
||||
for (const migrationFile of resolveMigrationFiles(migrationsFolder)) {
|
||||
const sqlText = readFileSync(join(migrationsFolder, migrationFile), 'utf8');
|
||||
for (const statement of splitMigrationStatements(sqlText)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDefaultValue(defaultValue: string | null): string | null {
|
||||
return normalizeSchemaMetadataDefaultValue(defaultValue);
|
||||
}
|
||||
|
||||
function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType {
|
||||
return normalizeLogicalColumnType({
|
||||
columnName,
|
||||
declaredType,
|
||||
defaultValue,
|
||||
dialect: 'sqlite',
|
||||
});
|
||||
}
|
||||
|
||||
function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
||||
const tables = sqlite.prepare(`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name ASC
|
||||
`).all() as Array<{ name: string }>;
|
||||
|
||||
const result: Record<string, SchemaContractTable> = {};
|
||||
|
||||
for (const table of tables) {
|
||||
const rows = sqlite.prepare(`PRAGMA table_info("${table.name}")`).all() as TableInfoRow[];
|
||||
const columns = Object.fromEntries(rows.map((row) => {
|
||||
const defaultValue = normalizeDefaultValue(row.dflt_value);
|
||||
return [row.name, {
|
||||
logicalType: normalizeLogicalType(row.name, row.type, defaultValue),
|
||||
notNull: row.notnull === 1,
|
||||
defaultValue,
|
||||
primaryKey: row.pk > 0,
|
||||
} satisfies SchemaContractColumn];
|
||||
}));
|
||||
|
||||
result[table.name] = { columns };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function readIndexes(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): {
|
||||
indexes: SchemaContractIndex[];
|
||||
uniques: SchemaContractUnique[];
|
||||
} {
|
||||
const indexes: SchemaContractIndex[] = [];
|
||||
const uniques: SchemaContractUnique[] = [];
|
||||
|
||||
for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) {
|
||||
const rows = sqlite.prepare(`PRAGMA index_list("${tableName}")`).all() as IndexListRow[];
|
||||
for (const row of rows) {
|
||||
if (!row.name || row.name.startsWith('sqlite_autoindex')) {
|
||||
continue;
|
||||
}
|
||||
const indexColumns = (sqlite.prepare(`PRAGMA index_info("${row.name}")`).all() as IndexInfoRow[])
|
||||
.sort((left, right) => left.seqno - right.seqno)
|
||||
.map((item) => item.name)
|
||||
.filter((name) => !!name);
|
||||
|
||||
const index: SchemaContractIndex = {
|
||||
name: row.name,
|
||||
table: tableName,
|
||||
columns: indexColumns,
|
||||
unique: row.unique === 1,
|
||||
};
|
||||
indexes.push(index);
|
||||
|
||||
if (row.unique === 1) {
|
||||
uniques.push({
|
||||
name: row.name,
|
||||
table: tableName,
|
||||
columns: indexColumns,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexes.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
uniques.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
|
||||
return { indexes, uniques };
|
||||
}
|
||||
|
||||
function readForeignKeys(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): SchemaContractForeignKey[] {
|
||||
const foreignKeys: SchemaContractForeignKey[] = [];
|
||||
|
||||
for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) {
|
||||
const rows = sqlite.prepare(`PRAGMA foreign_key_list("${tableName}")`).all() as ForeignKeyRow[];
|
||||
const grouped = new Map<number, SchemaContractForeignKey>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.id);
|
||||
if (existing) {
|
||||
existing.columns.push(row.from);
|
||||
existing.referencedColumns.push(row.to);
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.set(row.id, {
|
||||
table: tableName,
|
||||
columns: [row.from],
|
||||
referencedTable: row.table,
|
||||
referencedColumns: [row.to],
|
||||
onDelete: row.on_delete || null,
|
||||
});
|
||||
}
|
||||
|
||||
foreignKeys.push(...grouped.values());
|
||||
}
|
||||
|
||||
foreignKeys.sort((left, right) => {
|
||||
const leftKey = `${left.table}:${left.columns.join(',')}`;
|
||||
const rightKey = `${right.table}:${right.columns.join(',')}`;
|
||||
return leftKey.localeCompare(rightKey, 'en');
|
||||
});
|
||||
|
||||
return foreignKeys;
|
||||
}
|
||||
|
||||
export function buildSchemaContractFromSqliteMigrations(migrationsFolder = resolveMigrationsFolder()): SchemaContract {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
try {
|
||||
applySqliteMigrations(sqlite, migrationsFolder);
|
||||
const tables = readTables(sqlite);
|
||||
const { indexes, uniques } = readIndexes(sqlite, tables);
|
||||
const foreignKeys = readForeignKeys(sqlite, tables);
|
||||
|
||||
return {
|
||||
tables,
|
||||
indexes,
|
||||
uniques,
|
||||
foreignKeys,
|
||||
};
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSchemaContractFile(outputPath = resolveGeneratedSchemaContractPath()): SchemaContract {
|
||||
const contract = buildSchemaContractFromSqliteMigrations();
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
|
||||
return contract;
|
||||
}
|
||||
|
||||
export const __schemaContractTestUtils = {
|
||||
splitMigrationStatements,
|
||||
normalizeDefaultValue,
|
||||
normalizeLogicalType,
|
||||
resolveMigrationsFolder,
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeDefaultValue, normalizeSqlType, readMySqlField } from './schemaIntrospection.js';
|
||||
|
||||
describe('schema introspection normalization', () => {
|
||||
it('normalizes booleans consistently across dialects', () => {
|
||||
expect(normalizeSqlType('sqlite', 'INTEGER', 'use_system_proxy')).toBe('boolean');
|
||||
expect(normalizeSqlType('mysql', 'tinyint', 'use_system_proxy')).toBe('boolean');
|
||||
expect(normalizeSqlType('postgres', 'boolean', 'use_system_proxy')).toBe('boolean');
|
||||
});
|
||||
|
||||
it('normalizes common default values', () => {
|
||||
expect(normalizeDefaultValue("DEFAULT 'active'")).toBe("'active'");
|
||||
expect(normalizeDefaultValue('DEFAULT FALSE')).toBe('false');
|
||||
expect(normalizeDefaultValue("datetime('now')")).toBe("datetime('now')");
|
||||
});
|
||||
|
||||
it('reads mysql information_schema fields regardless of casing', () => {
|
||||
expect(readMySqlField({ COLUMN_TYPE: 'varchar(191)' }, 'column_type')).toBe('varchar(191)');
|
||||
expect(readMySqlField({ column_type: 'text' }, 'column_type')).toBe('text');
|
||||
expect(readMySqlField({ Table_Name: 'settings' }, 'table_name')).toBe('settings');
|
||||
});
|
||||
});
|
||||
@@ -1,880 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { tmpdir } from 'node:os';
|
||||
import pg from 'pg';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
generateBootstrapSql,
|
||||
generateUpgradeSql,
|
||||
resolveGeneratedArtifactPath,
|
||||
} from './schemaArtifactGenerator.js';
|
||||
import type {
|
||||
SchemaContract,
|
||||
SchemaContractColumn,
|
||||
SchemaContractForeignKey,
|
||||
SchemaContractIndex,
|
||||
SchemaContractTable,
|
||||
SchemaContractUnique,
|
||||
} from './schemaContract.js';
|
||||
import { resolveMigrationsFolder } from './schemaContract.js';
|
||||
import { installPostgresJsonTextParsers } from './postgresJsonTextParsers.js';
|
||||
import {
|
||||
normalizeLogicalColumnType,
|
||||
type LogicalColumnType,
|
||||
} from './schemaMetadata.js';
|
||||
|
||||
export type SchemaIntrospectionDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface SchemaIntrospectionInput {
|
||||
dialect: SchemaIntrospectionDialect;
|
||||
connectionString: string;
|
||||
ssl?: boolean;
|
||||
}
|
||||
|
||||
export interface MaterializeFreshSchemaOptions {
|
||||
connectionString?: string;
|
||||
ssl?: boolean;
|
||||
}
|
||||
|
||||
export interface ApplyContractFixtureThenUpgradeOptions extends MaterializeFreshSchemaOptions {}
|
||||
|
||||
type SqliteTableInfoRow = {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
type SqliteIndexListRow = {
|
||||
name: string;
|
||||
unique: number;
|
||||
};
|
||||
|
||||
type SqliteIndexInfoRow = {
|
||||
seqno: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type SqliteForeignKeyRow = {
|
||||
id: number;
|
||||
table: string;
|
||||
from: string;
|
||||
to: string;
|
||||
on_delete: string;
|
||||
};
|
||||
|
||||
type MySqlColumnRow = {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
column_type: string;
|
||||
is_nullable: 'YES' | 'NO';
|
||||
column_default: string | null;
|
||||
};
|
||||
|
||||
type MySqlPrimaryKeyRow = {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
};
|
||||
|
||||
type MySqlIndexRow = {
|
||||
table_name: string;
|
||||
index_name: string;
|
||||
column_name: string;
|
||||
non_unique: number;
|
||||
};
|
||||
|
||||
type MySqlForeignKeyRow = {
|
||||
table_name: string;
|
||||
constraint_name: string;
|
||||
column_name: string;
|
||||
referenced_table_name: string;
|
||||
referenced_column_name: string;
|
||||
delete_rule: string | null;
|
||||
};
|
||||
|
||||
type PostgresColumnRow = {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
udt_name: string;
|
||||
is_nullable: 'YES' | 'NO';
|
||||
column_default: string | null;
|
||||
};
|
||||
|
||||
type PostgresPrimaryKeyRow = {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
};
|
||||
|
||||
type PostgresIndexRow = {
|
||||
table_name: string;
|
||||
index_name: string;
|
||||
is_unique: boolean;
|
||||
column_name: string;
|
||||
};
|
||||
|
||||
type PostgresForeignKeyRow = {
|
||||
table_name: string;
|
||||
constraint_name: string;
|
||||
column_name: string;
|
||||
referenced_table_name: string;
|
||||
referenced_column_name: string;
|
||||
delete_rule: string | null;
|
||||
};
|
||||
|
||||
export function readMySqlField<T>(row: Record<string, unknown>, field: string): T | undefined {
|
||||
const exact = row[field];
|
||||
if (exact !== undefined) return exact as T;
|
||||
|
||||
const upper = row[field.toUpperCase()];
|
||||
if (upper !== undefined) return upper as T;
|
||||
|
||||
const lower = row[field.toLowerCase()];
|
||||
if (lower !== undefined) return lower as T;
|
||||
|
||||
const matchedKey = Object.keys(row).find((key) => key.toLowerCase() === field.toLowerCase());
|
||||
return matchedKey ? row[matchedKey] as T : undefined;
|
||||
}
|
||||
|
||||
function splitMigrationStatements(sqlText: string): string[] {
|
||||
return sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function splitSqlStatements(sqlText: string): string[] {
|
||||
const withoutCommentLines = sqlText
|
||||
.split(/\r?\n/g)
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
|
||||
return withoutCommentLines
|
||||
.split(/;\s*(?:\r?\n|$)/g)
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function hasBalancedParentheses(value: string): boolean {
|
||||
let depth = 0;
|
||||
for (const char of value) {
|
||||
if (char === '(') depth += 1;
|
||||
if (char === ')') depth -= 1;
|
||||
if (depth < 0) return false;
|
||||
}
|
||||
return depth === 0;
|
||||
}
|
||||
|
||||
function unwrapSurroundingParentheses(value: string): string {
|
||||
let normalized = value.trim();
|
||||
while (normalized.startsWith('(') && normalized.endsWith(')')) {
|
||||
const inner = normalized.slice(1, -1).trim();
|
||||
if (!hasBalancedParentheses(inner)) {
|
||||
break;
|
||||
}
|
||||
normalized = inner;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeDeleteRule(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
export function normalizeSqlType(
|
||||
dialect: SchemaIntrospectionDialect,
|
||||
declaredType: string,
|
||||
columnName: string,
|
||||
rawDefaultValue: string | null = null,
|
||||
): LogicalColumnType {
|
||||
return normalizeLogicalColumnType({
|
||||
dialect,
|
||||
declaredType,
|
||||
columnName,
|
||||
defaultValue: normalizeDefaultValue(rawDefaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDefaultValueForColumn(
|
||||
rawDefaultValue: string | null | undefined,
|
||||
logicalType: LogicalColumnType | null,
|
||||
): string | null {
|
||||
if (rawDefaultValue == null) return null;
|
||||
|
||||
let normalized = String(rawDefaultValue).trim();
|
||||
// MySQL stores DEFAULT '' as an empty string in information_schema.COLUMNS.COLUMN_DEFAULT.
|
||||
// An empty string here is a valid empty-string literal default, not "no default".
|
||||
// Encode it as two single-quotes so it round-trips through schemaContract correctly.
|
||||
if (!normalized) {
|
||||
return (logicalType === 'text' || logicalType === 'json') ? "''" : null;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/^default\s+/i, '').trim();
|
||||
normalized = unwrapSurroundingParentheses(normalized);
|
||||
normalized = normalized.replace(/::[\w\s.\[\]"]+/g, '').trim();
|
||||
normalized = unwrapSurroundingParentheses(normalized);
|
||||
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered === 'null') return null;
|
||||
|
||||
if (logicalType === 'datetime' || lowered === 'current_timestamp' || lowered === 'current_timestamp()' || lowered === 'now()' || lowered.includes("datetime('now')")) {
|
||||
return "datetime('now')";
|
||||
}
|
||||
|
||||
if (logicalType === 'boolean') {
|
||||
if (lowered === '1' || lowered === 'true' || lowered === "b'1'") return 'true';
|
||||
if (lowered === '0' || lowered === 'false' || lowered === "b'0'") return 'false';
|
||||
}
|
||||
|
||||
if (lowered === 'true' || lowered === 'false') return lowered;
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(normalized)) return normalized;
|
||||
if (/^'.*'$/.test(normalized)) return normalized;
|
||||
if (/^[a-z_][a-z0-9_]*$/i.test(normalized)) return `'${normalized}'`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeDefaultValue(rawDefaultValue: string | null | undefined): string | null {
|
||||
return normalizeDefaultValueForColumn(rawDefaultValue, null);
|
||||
}
|
||||
|
||||
function sortForeignKeys(foreignKeys: SchemaContractForeignKey[]): SchemaContractForeignKey[] {
|
||||
return foreignKeys.sort((left, right) => {
|
||||
const leftKey = `${left.table}:${left.columns.join(',')}`;
|
||||
const rightKey = `${right.table}:${right.columns.join(',')}`;
|
||||
return leftKey.localeCompare(rightKey, 'en');
|
||||
});
|
||||
}
|
||||
|
||||
function buildSqliteTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
||||
const tableRows = sqlite.prepare(`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name ASC
|
||||
`).all() as Array<{ name: string }>;
|
||||
|
||||
const tables: Record<string, SchemaContractTable> = {};
|
||||
for (const { name: tableName } of tableRows) {
|
||||
const rows = sqlite.prepare(`PRAGMA table_info("${tableName}")`).all() as SqliteTableInfoRow[];
|
||||
const columns = Object.fromEntries(rows.map((row) => {
|
||||
const logicalType = normalizeSqlType('sqlite', row.type, row.name, row.dflt_value);
|
||||
return [row.name, {
|
||||
logicalType,
|
||||
notNull: row.notnull === 1,
|
||||
defaultValue: normalizeDefaultValueForColumn(row.dflt_value, logicalType),
|
||||
primaryKey: row.pk > 0,
|
||||
} satisfies SchemaContractColumn];
|
||||
}));
|
||||
tables[tableName] = { columns };
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
function buildSqliteIndexes(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): {
|
||||
indexes: SchemaContractIndex[];
|
||||
uniques: SchemaContractUnique[];
|
||||
} {
|
||||
const indexes: SchemaContractIndex[] = [];
|
||||
const uniques: SchemaContractUnique[] = [];
|
||||
|
||||
for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) {
|
||||
const rows = sqlite.prepare(`PRAGMA index_list("${tableName}")`).all() as SqliteIndexListRow[];
|
||||
for (const row of rows) {
|
||||
if (!row.name || row.name.startsWith('sqlite_autoindex')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const columns = (sqlite.prepare(`PRAGMA index_info("${row.name}")`).all() as SqliteIndexInfoRow[])
|
||||
.sort((left, right) => left.seqno - right.seqno)
|
||||
.map((item) => item.name)
|
||||
.filter(Boolean);
|
||||
|
||||
indexes.push({
|
||||
name: row.name,
|
||||
table: tableName,
|
||||
columns,
|
||||
unique: row.unique === 1,
|
||||
});
|
||||
|
||||
if (row.unique === 1) {
|
||||
uniques.push({
|
||||
name: row.name,
|
||||
table: tableName,
|
||||
columns,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexes.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
uniques.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
return { indexes, uniques };
|
||||
}
|
||||
|
||||
function buildSqliteForeignKeys(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): SchemaContractForeignKey[] {
|
||||
const foreignKeys: SchemaContractForeignKey[] = [];
|
||||
|
||||
for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) {
|
||||
const rows = sqlite.prepare(`PRAGMA foreign_key_list("${tableName}")`).all() as SqliteForeignKeyRow[];
|
||||
const grouped = new Map<number, SchemaContractForeignKey>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.id);
|
||||
if (existing) {
|
||||
existing.columns.push(row.from);
|
||||
existing.referencedColumns.push(row.to);
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.set(row.id, {
|
||||
table: tableName,
|
||||
columns: [row.from],
|
||||
referencedTable: row.table,
|
||||
referencedColumns: [row.to],
|
||||
onDelete: normalizeDeleteRule(row.on_delete),
|
||||
});
|
||||
}
|
||||
|
||||
foreignKeys.push(...grouped.values());
|
||||
}
|
||||
|
||||
return sortForeignKeys(foreignKeys);
|
||||
}
|
||||
|
||||
async function introspectSqliteSchema(connectionString: string): Promise<SchemaContract> {
|
||||
const sqlitePath = connectionString === ':memory:' ? ':memory:' : resolve(connectionString);
|
||||
const sqlite = new Database(sqlitePath, { readonly: true });
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
try {
|
||||
const tables = buildSqliteTables(sqlite);
|
||||
const { indexes, uniques } = buildSqliteIndexes(sqlite, tables);
|
||||
const foreignKeys = buildSqliteForeignKeys(sqlite, tables);
|
||||
return { tables, indexes, uniques, foreignKeys };
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> {
|
||||
const connectionOptions: mysql.ConnectionOptions = { uri: input.connectionString };
|
||||
if (input.ssl) {
|
||||
connectionOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const connection = await mysql.createConnection(connectionOptions);
|
||||
|
||||
try {
|
||||
const [tableRows] = await connection.query(`
|
||||
SELECT table_name AS table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name ASC
|
||||
`);
|
||||
const tableNames = (tableRows as Array<Record<string, unknown>>)
|
||||
.map((row) => readMySqlField<string>(row, 'table_name'))
|
||||
.filter((tableName): tableName is string => typeof tableName === 'string');
|
||||
|
||||
const [primaryKeyRows] = await connection.query(`
|
||||
SELECT
|
||||
kcu.table_name AS table_name,
|
||||
kcu.column_name AS column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_name = kcu.table_name
|
||||
WHERE tc.table_schema = DATABASE()
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
`);
|
||||
const primaryKeys = new Set((primaryKeyRows as Array<Record<string, unknown>>).map((row) => {
|
||||
const tableName = readMySqlField<string>(row, 'table_name');
|
||||
const columnName = readMySqlField<string>(row, 'column_name');
|
||||
return tableName && columnName ? `${tableName}.${columnName}` : null;
|
||||
}).filter((value): value is string => value !== null));
|
||||
|
||||
const [columnRows] = await connection.query(`
|
||||
SELECT
|
||||
table_name AS table_name,
|
||||
column_name AS column_name,
|
||||
data_type AS data_type,
|
||||
column_type AS column_type,
|
||||
is_nullable AS is_nullable,
|
||||
column_default AS column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
ORDER BY table_name ASC, ordinal_position ASC
|
||||
`);
|
||||
|
||||
const tableMap = new Map<string, SchemaContractTable>();
|
||||
for (const tableName of tableNames) {
|
||||
tableMap.set(tableName, { columns: {} });
|
||||
}
|
||||
|
||||
for (const row of columnRows as Array<Record<string, unknown>>) {
|
||||
const tableName = readMySqlField<string>(row, 'table_name');
|
||||
const columnName = readMySqlField<string>(row, 'column_name');
|
||||
const declaredType = readMySqlField<string>(row, 'column_type') || readMySqlField<string>(row, 'data_type');
|
||||
const isNullable = readMySqlField<string>(row, 'is_nullable');
|
||||
const columnDefault = readMySqlField<string | null>(row, 'column_default') ?? null;
|
||||
if (!tableName || !columnName || !declaredType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const logicalType = normalizeSqlType('mysql', declaredType, columnName, columnDefault);
|
||||
tableMap.get(tableName)!.columns[columnName] = {
|
||||
logicalType,
|
||||
notNull: isNullable === 'NO',
|
||||
defaultValue: primaryKeys.has(`${tableName}.${columnName}`)
|
||||
? null
|
||||
: normalizeDefaultValueForColumn(columnDefault, logicalType),
|
||||
primaryKey: primaryKeys.has(`${tableName}.${columnName}`),
|
||||
};
|
||||
}
|
||||
|
||||
const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!]));
|
||||
|
||||
const [indexRows] = await connection.query(`
|
||||
SELECT
|
||||
table_name AS table_name,
|
||||
index_name AS index_name,
|
||||
column_name AS column_name,
|
||||
non_unique AS non_unique
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND index_name <> 'PRIMARY'
|
||||
ORDER BY table_name ASC, index_name ASC, seq_in_index ASC
|
||||
`);
|
||||
const indexGroups = new Map<string, SchemaContractIndex>();
|
||||
for (const row of indexRows as Array<Record<string, unknown>>) {
|
||||
const tableName = readMySqlField<string>(row, 'table_name');
|
||||
const indexName = readMySqlField<string>(row, 'index_name');
|
||||
const columnName = readMySqlField<string>(row, 'column_name');
|
||||
const nonUnique = readMySqlField<number>(row, 'non_unique');
|
||||
if (!tableName || !indexName || !columnName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${tableName}:${indexName}`;
|
||||
const existing = indexGroups.get(key);
|
||||
if (existing) {
|
||||
existing.columns.push(columnName);
|
||||
continue;
|
||||
}
|
||||
indexGroups.set(key, {
|
||||
name: indexName,
|
||||
table: tableName,
|
||||
columns: [columnName],
|
||||
unique: Number(nonUnique) === 0,
|
||||
});
|
||||
}
|
||||
|
||||
const indexes = [...indexGroups.values()].sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
const uniques = indexes
|
||||
.filter((index) => index.unique)
|
||||
.map((index) => ({ name: index.name, table: index.table, columns: [...index.columns] }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
|
||||
const [foreignKeyRows] = await connection.query(`
|
||||
SELECT
|
||||
kcu.table_name AS table_name,
|
||||
kcu.constraint_name AS constraint_name,
|
||||
kcu.column_name AS column_name,
|
||||
kcu.referenced_table_name AS referenced_table_name,
|
||||
kcu.referenced_column_name AS referenced_column_name,
|
||||
rc.delete_rule AS delete_rule
|
||||
FROM information_schema.key_column_usage kcu
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON rc.constraint_schema = kcu.constraint_schema
|
||||
AND rc.constraint_name = kcu.constraint_name
|
||||
WHERE kcu.table_schema = DATABASE()
|
||||
AND kcu.referenced_table_name IS NOT NULL
|
||||
ORDER BY kcu.table_name ASC, kcu.constraint_name ASC, kcu.ordinal_position ASC
|
||||
`);
|
||||
const foreignKeyGroups = new Map<string, SchemaContractForeignKey>();
|
||||
for (const row of foreignKeyRows as Array<Record<string, unknown>>) {
|
||||
const tableName = readMySqlField<string>(row, 'table_name');
|
||||
const constraintName = readMySqlField<string>(row, 'constraint_name');
|
||||
const columnName = readMySqlField<string>(row, 'column_name');
|
||||
const referencedTableName = readMySqlField<string>(row, 'referenced_table_name');
|
||||
const referencedColumnName = readMySqlField<string>(row, 'referenced_column_name');
|
||||
const deleteRule = readMySqlField<string | null>(row, 'delete_rule') ?? null;
|
||||
if (!tableName || !constraintName || !columnName || !referencedTableName || !referencedColumnName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${tableName}:${constraintName}`;
|
||||
const existing = foreignKeyGroups.get(key);
|
||||
if (existing) {
|
||||
existing.columns.push(columnName);
|
||||
existing.referencedColumns.push(referencedColumnName);
|
||||
continue;
|
||||
}
|
||||
foreignKeyGroups.set(key, {
|
||||
table: tableName,
|
||||
columns: [columnName],
|
||||
referencedTable: referencedTableName,
|
||||
referencedColumns: [referencedColumnName],
|
||||
onDelete: normalizeDeleteRule(deleteRule),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tables,
|
||||
indexes,
|
||||
uniques,
|
||||
foreignKeys: sortForeignKeys([...foreignKeyGroups.values()]),
|
||||
};
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function introspectPostgresSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> {
|
||||
const clientOptions: pg.ClientConfig = { connectionString: input.connectionString };
|
||||
if (input.ssl) {
|
||||
clientOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
installPostgresJsonTextParsers();
|
||||
const client = new pg.Client(clientOptions);
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
const tableResult = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name ASC
|
||||
`);
|
||||
const tableNames = tableResult.rows.map((row) => String(row.table_name));
|
||||
|
||||
const primaryKeyResult = await client.query(`
|
||||
SELECT kcu.table_name, kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_name = kcu.table_name
|
||||
WHERE tc.table_schema = current_schema()
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
`);
|
||||
const primaryKeys = new Set((primaryKeyResult.rows as PostgresPrimaryKeyRow[]).map((row) => `${row.table_name}.${row.column_name}`));
|
||||
|
||||
const columnResult = await client.query(`
|
||||
SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
ORDER BY table_name ASC, ordinal_position ASC
|
||||
`);
|
||||
const tableMap = new Map<string, SchemaContractTable>();
|
||||
for (const tableName of tableNames) {
|
||||
tableMap.set(tableName, { columns: {} });
|
||||
}
|
||||
|
||||
for (const row of columnResult.rows as PostgresColumnRow[]) {
|
||||
const logicalType = normalizeSqlType('postgres', `${row.data_type} ${row.udt_name}`, row.column_name, row.column_default);
|
||||
tableMap.get(row.table_name)!.columns[row.column_name] = {
|
||||
logicalType,
|
||||
notNull: row.is_nullable === 'NO',
|
||||
defaultValue: primaryKeys.has(`${row.table_name}.${row.column_name}`)
|
||||
? null
|
||||
: normalizeDefaultValueForColumn(row.column_default, logicalType),
|
||||
primaryKey: primaryKeys.has(`${row.table_name}.${row.column_name}`),
|
||||
};
|
||||
}
|
||||
|
||||
const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!]));
|
||||
|
||||
const indexResult = await client.query(`
|
||||
SELECT
|
||||
t.relname AS table_name,
|
||||
i.relname AS index_name,
|
||||
ix.indisunique AS is_unique,
|
||||
a.attname AS column_name
|
||||
FROM pg_class t
|
||||
JOIN pg_namespace ns ON ns.oid = t.relnamespace
|
||||
JOIN pg_index ix ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, n) ON TRUE
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum
|
||||
WHERE ns.nspname = current_schema()
|
||||
AND t.relkind = 'r'
|
||||
AND NOT ix.indisprimary
|
||||
ORDER BY t.relname ASC, i.relname ASC, ord.n ASC
|
||||
`);
|
||||
const indexGroups = new Map<string, SchemaContractIndex>();
|
||||
for (const row of indexResult.rows as PostgresIndexRow[]) {
|
||||
const key = `${row.table_name}:${row.index_name}`;
|
||||
const existing = indexGroups.get(key);
|
||||
if (existing) {
|
||||
existing.columns.push(row.column_name);
|
||||
continue;
|
||||
}
|
||||
indexGroups.set(key, {
|
||||
name: row.index_name,
|
||||
table: row.table_name,
|
||||
columns: [row.column_name],
|
||||
unique: !!row.is_unique,
|
||||
});
|
||||
}
|
||||
|
||||
const indexes = [...indexGroups.values()].sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
const uniques = indexes
|
||||
.filter((index) => index.unique)
|
||||
.map((index) => ({ name: index.name, table: index.table, columns: [...index.columns] }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
|
||||
const foreignKeyResult = await client.query(`
|
||||
SELECT
|
||||
tc.table_name,
|
||||
tc.constraint_name,
|
||||
kcu.column_name,
|
||||
ccu.table_name AS referenced_table_name,
|
||||
ccu.column_name AS referenced_column_name,
|
||||
rc.delete_rule
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_name = kcu.table_name
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON tc.constraint_schema = rc.constraint_schema
|
||||
AND tc.constraint_name = rc.constraint_name
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON rc.unique_constraint_schema = ccu.constraint_schema
|
||||
AND rc.unique_constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_schema = current_schema()
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
ORDER BY tc.table_name ASC, tc.constraint_name ASC, kcu.ordinal_position ASC
|
||||
`);
|
||||
const foreignKeyGroups = new Map<string, SchemaContractForeignKey>();
|
||||
for (const row of foreignKeyResult.rows as PostgresForeignKeyRow[]) {
|
||||
const key = `${row.table_name}:${row.constraint_name}`;
|
||||
const existing = foreignKeyGroups.get(key);
|
||||
if (existing) {
|
||||
existing.columns.push(row.column_name);
|
||||
existing.referencedColumns.push(row.referenced_column_name);
|
||||
continue;
|
||||
}
|
||||
foreignKeyGroups.set(key, {
|
||||
table: row.table_name,
|
||||
columns: [row.column_name],
|
||||
referencedTable: row.referenced_table_name,
|
||||
referencedColumns: [row.referenced_column_name],
|
||||
onDelete: normalizeDeleteRule(row.delete_rule),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tables,
|
||||
indexes,
|
||||
uniques,
|
||||
foreignKeys: sortForeignKeys([...foreignKeyGroups.values()]),
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function introspectLiveSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> {
|
||||
if (input.dialect === 'sqlite') {
|
||||
return introspectSqliteSchema(input.connectionString);
|
||||
}
|
||||
if (input.dialect === 'mysql') {
|
||||
return introspectMySqlSchema(input);
|
||||
}
|
||||
return introspectPostgresSchema(input);
|
||||
}
|
||||
|
||||
function readBootstrapSql(dialect: Exclude<SchemaIntrospectionDialect, 'sqlite'>): string {
|
||||
const filename = dialect === 'mysql' ? 'mysql.bootstrap.sql' : 'postgres.bootstrap.sql';
|
||||
return readFileSync(resolveGeneratedArtifactPath(filename), 'utf8');
|
||||
}
|
||||
|
||||
async function resetMySqlSchema(connection: mysql.Connection): Promise<void> {
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
const [rows] = await connection.query(`
|
||||
SELECT table_name AS table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_type = 'BASE TABLE'
|
||||
`);
|
||||
for (const row of rows as Array<Record<string, unknown>>) {
|
||||
const tableName = readMySqlField<string>(row, 'table_name');
|
||||
if (!tableName) {
|
||||
continue;
|
||||
}
|
||||
await connection.query(`DROP TABLE IF EXISTS \`${tableName}\``);
|
||||
}
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
|
||||
async function resetPostgresSchema(client: pg.Client): Promise<void> {
|
||||
const result = await client.query(`
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
ORDER BY tablename ASC
|
||||
`);
|
||||
for (const row of result.rows as Array<{ tablename: string }>) {
|
||||
await client.query(`DROP TABLE IF EXISTS "${row.tablename}" CASCADE`);
|
||||
}
|
||||
}
|
||||
|
||||
function applySqliteMigrations(sqlite: Database.Database): void {
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
const migrationFiles = readdirSync(migrationsFolder)
|
||||
.filter((entry) => entry.endsWith('.sql'))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'));
|
||||
|
||||
for (const migrationFile of migrationFiles) {
|
||||
const sqlText = readFileSync(join(migrationsFolder, migrationFile), 'utf8');
|
||||
for (const statement of splitMigrationStatements(sqlText)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTemporarySqlitePath(): string {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'boosapi-schema-parity-'));
|
||||
return resolve(tempDir, `${randomUUID()}.db`);
|
||||
}
|
||||
|
||||
function applySqliteStatements(sqlitePath: string, statements: string[]): void {
|
||||
const sqlite = new Database(sqlitePath);
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
try {
|
||||
for (const statement of statements) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMySqlStatements(
|
||||
connectionString: string,
|
||||
ssl: boolean | undefined,
|
||||
statements: string[],
|
||||
resetSchema = false,
|
||||
): Promise<void> {
|
||||
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
|
||||
if (ssl) {
|
||||
connectionOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const connection = await mysql.createConnection(connectionOptions);
|
||||
try {
|
||||
if (resetSchema) {
|
||||
await resetMySqlSchema(connection);
|
||||
}
|
||||
for (const statement of statements) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPostgresStatements(
|
||||
connectionString: string,
|
||||
ssl: boolean | undefined,
|
||||
statements: string[],
|
||||
resetSchema = false,
|
||||
): Promise<void> {
|
||||
const clientOptions: pg.ClientConfig = { connectionString };
|
||||
if (ssl) {
|
||||
clientOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
installPostgresJsonTextParsers();
|
||||
const client = new pg.Client(clientOptions);
|
||||
await client.connect();
|
||||
try {
|
||||
if (resetSchema) {
|
||||
await resetPostgresSchema(client);
|
||||
}
|
||||
for (const statement of statements) {
|
||||
await client.query(statement);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeFreshSchema(
|
||||
dialect: SchemaIntrospectionDialect,
|
||||
options: MaterializeFreshSchemaOptions = {},
|
||||
): Promise<string> {
|
||||
if (dialect === 'sqlite') {
|
||||
const sqlitePath = createTemporarySqlitePath();
|
||||
const sqlite = new Database(sqlitePath);
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
try {
|
||||
applySqliteMigrations(sqlite);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
return sqlitePath;
|
||||
}
|
||||
|
||||
if (!options.connectionString) {
|
||||
throw new Error(`connectionString is required to materialize ${dialect} parity schema`);
|
||||
}
|
||||
|
||||
const bootstrapStatements = splitSqlStatements(readBootstrapSql(dialect));
|
||||
if (dialect === 'mysql') {
|
||||
await applyMySqlStatements(options.connectionString, options.ssl, bootstrapStatements, true);
|
||||
return options.connectionString;
|
||||
}
|
||||
|
||||
await applyPostgresStatements(options.connectionString, options.ssl, bootstrapStatements, true);
|
||||
return options.connectionString;
|
||||
}
|
||||
|
||||
export async function applyContractFixtureThenUpgrade(
|
||||
dialect: SchemaIntrospectionDialect,
|
||||
baselineContract: SchemaContract,
|
||||
currentContract: SchemaContract,
|
||||
options: ApplyContractFixtureThenUpgradeOptions = {},
|
||||
): Promise<string> {
|
||||
const bootstrapStatements = splitSqlStatements(generateBootstrapSql(dialect, baselineContract));
|
||||
const upgradeStatements = splitSqlStatements(generateUpgradeSql(dialect, currentContract, baselineContract));
|
||||
|
||||
if (dialect === 'sqlite') {
|
||||
const sqlitePath = createTemporarySqlitePath();
|
||||
applySqliteStatements(sqlitePath, bootstrapStatements);
|
||||
if (upgradeStatements.length > 0) {
|
||||
applySqliteStatements(sqlitePath, upgradeStatements);
|
||||
}
|
||||
return sqlitePath;
|
||||
}
|
||||
|
||||
if (!options.connectionString) {
|
||||
throw new Error(`connectionString is required to upgrade ${dialect} parity schema`);
|
||||
}
|
||||
|
||||
if (dialect === 'mysql') {
|
||||
await applyMySqlStatements(options.connectionString, options.ssl, bootstrapStatements, true);
|
||||
if (upgradeStatements.length > 0) {
|
||||
await applyMySqlStatements(options.connectionString, options.ssl, upgradeStatements, false);
|
||||
}
|
||||
return options.connectionString;
|
||||
}
|
||||
|
||||
await applyPostgresStatements(options.connectionString, options.ssl, bootstrapStatements, true);
|
||||
if (upgradeStatements.length > 0) {
|
||||
await applyPostgresStatements(options.connectionString, options.ssl, upgradeStatements, false);
|
||||
}
|
||||
return options.connectionString;
|
||||
}
|
||||
|
||||
export const __schemaIntrospectionTestUtils = {
|
||||
splitMigrationStatements,
|
||||
splitSqlStatements,
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
describe('schema metadata architecture boundaries', () => {
|
||||
it('keeps logical column type heuristics in a shared schemaMetadata helper', () => {
|
||||
const contractSource = readSource('./schemaContract.ts');
|
||||
const introspectionSource = readSource('./schemaIntrospection.ts');
|
||||
|
||||
expect(contractSource).toContain("from './schemaMetadata.js'");
|
||||
expect(introspectionSource).toContain("from './schemaMetadata.js'");
|
||||
|
||||
for (const source of [contractSource, introspectionSource]) {
|
||||
expect(source).not.toContain('function isBooleanLikeColumn');
|
||||
expect(source).not.toContain('function isDateTimeLikeColumn');
|
||||
expect(source).not.toContain('function isJsonLikeColumn');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
export type LogicalColumnType =
|
||||
| 'integer'
|
||||
| 'real'
|
||||
| 'text'
|
||||
| 'boolean'
|
||||
| 'datetime'
|
||||
| 'json';
|
||||
|
||||
export type SchemaMetadataDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
||||
const normalizedColumn = columnName.toLowerCase();
|
||||
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
|
||||
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
|
||||
return true;
|
||||
}
|
||||
return normalizedColumn.startsWith('is_')
|
||||
|| normalizedColumn.startsWith('use_')
|
||||
|| normalizedColumn.startsWith('has_')
|
||||
|| normalizedColumn.endsWith('_enabled')
|
||||
|| normalizedColumn.endsWith('_available')
|
||||
|| normalizedColumn === 'read'
|
||||
|| normalizedColumn === 'enabled'
|
||||
|| normalizedColumn === 'available'
|
||||
|| normalizedColumn === 'manual_override';
|
||||
}
|
||||
|
||||
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
||||
const normalizedColumn = columnName.toLowerCase();
|
||||
const normalizedDefault = (defaultValue || '').toLowerCase();
|
||||
return normalizedColumn.endsWith('_at')
|
||||
|| normalizedColumn.endsWith('_until')
|
||||
|| normalizedColumn.endsWith('_refresh')
|
||||
|| normalizedDefault.includes('datetime(')
|
||||
|| normalizedDefault.includes('current_timestamp')
|
||||
|| normalizedDefault.includes('now()');
|
||||
}
|
||||
|
||||
function isJsonLikeColumn(columnName: string): boolean {
|
||||
const normalizedColumn = columnName.toLowerCase();
|
||||
return normalizedColumn.endsWith('_json')
|
||||
|| normalizedColumn.endsWith('_snapshot')
|
||||
|| normalizedColumn.includes('mapping')
|
||||
|| normalizedColumn.includes('headers')
|
||||
|| normalizedColumn.includes('config')
|
||||
|| normalizedColumn.endsWith('_site_ids')
|
||||
|| normalizedColumn.includes('credential_refs')
|
||||
|| normalizedColumn.includes('details')
|
||||
|| normalizedColumn.includes('meta')
|
||||
|| normalizedColumn.includes('models')
|
||||
|| normalizedColumn.includes('route_ids')
|
||||
|| normalizedColumn.includes('multipliers');
|
||||
}
|
||||
|
||||
export function normalizeSchemaMetadataDefaultValue(defaultValue: string | null | undefined): string | null {
|
||||
if (defaultValue == null) return null;
|
||||
return String(defaultValue).trim() || null;
|
||||
}
|
||||
|
||||
export function normalizeLogicalColumnType(input: {
|
||||
declaredType: string;
|
||||
columnName: string;
|
||||
defaultValue?: string | null;
|
||||
dialect?: SchemaMetadataDialect;
|
||||
}): LogicalColumnType {
|
||||
const normalizedType = input.declaredType.trim().toLowerCase();
|
||||
const normalizedDefault = normalizeSchemaMetadataDefaultValue(input.defaultValue);
|
||||
const dialect = input.dialect;
|
||||
|
||||
if (dialect === 'mysql') {
|
||||
if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) {
|
||||
return 'boolean';
|
||||
}
|
||||
if (normalizedType.includes('json')) {
|
||||
return 'json';
|
||||
}
|
||||
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
||||
return 'datetime';
|
||||
}
|
||||
if (normalizedType.includes('int')) {
|
||||
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||
}
|
||||
if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) {
|
||||
return 'real';
|
||||
}
|
||||
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
||||
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
if (dialect === 'postgres') {
|
||||
if (normalizedType.includes('bool')) {
|
||||
return 'boolean';
|
||||
}
|
||||
if (normalizedType.includes('json')) {
|
||||
return 'json';
|
||||
}
|
||||
if (normalizedType.includes('timestamp') || normalizedType === 'date') {
|
||||
return 'datetime';
|
||||
}
|
||||
if (normalizedType.includes('int')) {
|
||||
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||
}
|
||||
if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) {
|
||||
return 'real';
|
||||
}
|
||||
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
||||
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedType.includes('int')) {
|
||||
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||
}
|
||||
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) {
|
||||
return 'real';
|
||||
}
|
||||
if (normalizedType.includes('json')) {
|
||||
return 'json';
|
||||
}
|
||||
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
||||
return 'datetime';
|
||||
}
|
||||
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
||||
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||
return 'text';
|
||||
}
|
||||
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
export const __schemaMetadataTestUtils = {
|
||||
isBooleanLikeColumn,
|
||||
isDateTimeLikeColumn,
|
||||
isJsonLikeColumn,
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SchemaContract } from './schemaContract.js';
|
||||
import { introspectLiveSchema, materializeFreshSchema } from './schemaIntrospection.js';
|
||||
|
||||
const dbDir = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json');
|
||||
const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
|
||||
|
||||
const skipLiveSchema = process.env.DB_PARITY_SKIP_LIVE_SCHEMA === 'true';
|
||||
const sqliteParity = !skipLiveSchema && process.env.DB_PARITY_SQLITE !== 'false' ? it : it.skip;
|
||||
const mysqlParity = process.env.DB_PARITY_MYSQL_URL ? it : it.skip;
|
||||
const postgresParity = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip;
|
||||
|
||||
describe('live schema parity', () => {
|
||||
sqliteParity('matches the contract for sqlite', async () => {
|
||||
const sqliteUrl = await materializeFreshSchema('sqlite');
|
||||
const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl });
|
||||
expect(live).toEqual(contract);
|
||||
});
|
||||
|
||||
mysqlParity('matches the contract for mysql', async () => {
|
||||
const mysqlUrl = await materializeFreshSchema('mysql', {
|
||||
connectionString: process.env.DB_PARITY_MYSQL_URL!,
|
||||
});
|
||||
const live = await introspectLiveSchema({ dialect: 'mysql', connectionString: mysqlUrl });
|
||||
expect(live).toEqual(contract);
|
||||
});
|
||||
|
||||
postgresParity('matches the contract for postgres', async () => {
|
||||
const postgresUrl = await materializeFreshSchema('postgres', {
|
||||
connectionString: process.env.DB_PARITY_POSTGRES_URL!,
|
||||
});
|
||||
const live = await introspectLiveSchema({ dialect: 'postgres', connectionString: postgresUrl });
|
||||
expect(live).toEqual(contract);
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
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');
|
||||
const supportPaths = [
|
||||
resolve(dbDir, 'runtimeSchemaBootstrap.ts'),
|
||||
resolve(dbDir, 'siteSchemaCompatibility.ts'),
|
||||
resolve(dbDir, 'routeGroupingSchemaCompatibility.ts'),
|
||||
resolve(dbDir, 'proxyFileSchemaCompatibility.ts'),
|
||||
resolve(dbDir, 'accountTokenSchemaCompatibility.ts'),
|
||||
resolve(dbDir, 'sharedIndexSchemaCompatibility.ts'),
|
||||
];
|
||||
const schemaContractPath = resolve(generatedDir, 'schemaContract.json');
|
||||
|
||||
function extractAllMatches(content: string, pattern: RegExp): string[] {
|
||||
return Array.from(content.matchAll(pattern), (match) => match[1]);
|
||||
}
|
||||
|
||||
describe('database schema parity', () => {
|
||||
it('keeps generated schema artifacts present', () => {
|
||||
const artifactPaths = [
|
||||
schemaContractPath,
|
||||
resolve(generatedDir, 'mysql.bootstrap.sql'),
|
||||
resolve(generatedDir, 'mysql.upgrade.sql'),
|
||||
resolve(generatedDir, 'postgres.bootstrap.sql'),
|
||||
resolve(generatedDir, 'postgres.upgrade.sql'),
|
||||
];
|
||||
|
||||
for (const artifactPath of artifactPaths) {
|
||||
expect(existsSync(artifactPath), artifactPath).toBe(true);
|
||||
expect(readFileSync(artifactPath, 'utf8').trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps runtime support modules scoped to contract-defined tables and indexes', () => {
|
||||
const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
|
||||
const supportContent = supportPaths
|
||||
.map((filePath) => readFileSync(filePath, 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
const knownTables = new Set(Object.keys(contract.tables));
|
||||
const knownIndexes = new Set([
|
||||
...contract.indexes.map((index) => index.name),
|
||||
...contract.uniques.map((unique) => unique.name),
|
||||
]);
|
||||
|
||||
const supportTables = extractAllMatches(
|
||||
supportContent,
|
||||
/(?:CREATE TABLE IF NOT EXISTS|ALTER TABLE|INSERT INTO)\s+["`]?([a-z_][a-z0-9_]*)["`]?/gi,
|
||||
);
|
||||
const supportIndexes = extractAllMatches(
|
||||
supportContent,
|
||||
/(?:CREATE UNIQUE INDEX(?: IF NOT EXISTS)?|CREATE INDEX(?: IF NOT EXISTS)?|indexName:\s*')["`]?([a-z_][a-z0-9_]*)/gi,
|
||||
);
|
||||
|
||||
const unknownTables = [...new Set(supportTables)].filter((tableName) => !knownTables.has(tableName)).sort();
|
||||
const unknownIndexes = [...new Set(supportIndexes)].filter((indexName) => !knownIndexes.has(indexName)).sort();
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
it('keeps proxy_logs downstream api key schema in the generated contract artifacts', () => {
|
||||
const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
|
||||
const mysqlBootstrap = readFileSync(resolve(generatedDir, 'mysql.bootstrap.sql'), 'utf8');
|
||||
const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8');
|
||||
|
||||
expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer');
|
||||
expect(contract.tables.proxy_logs?.columns.is_stream?.logicalType).toBe('boolean');
|
||||
expect(contract.tables.proxy_logs?.columns.first_byte_latency_ms?.logicalType).toBe('integer');
|
||||
expect(contract.tables.proxy_logs?.columns.client_app_id?.logicalType).toBe('text');
|
||||
expect(contract.tables.proxy_logs?.columns.client_family?.logicalType).toBe('text');
|
||||
expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true);
|
||||
expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_app_id_created_at_idx')).toBe(true);
|
||||
expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_family_created_at_idx')).toBe(true);
|
||||
expect(mysqlBootstrap).toContain('`downstream_api_key_id`');
|
||||
expect(mysqlBootstrap).toContain('`is_stream`');
|
||||
expect(mysqlBootstrap).toContain('`first_byte_latency_ms`');
|
||||
expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`');
|
||||
expect(mysqlBootstrap).toContain('`client_app_id`');
|
||||
expect(mysqlBootstrap).toContain('`proxy_logs_client_app_id_created_at_idx`');
|
||||
expect(postgresBootstrap).toContain('"downstream_api_key_id"');
|
||||
expect(postgresBootstrap).toContain('"is_stream"');
|
||||
expect(postgresBootstrap).toContain('"first_byte_latency_ms"');
|
||||
expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"');
|
||||
expect(postgresBootstrap).toContain('"client_app_id"');
|
||||
expect(postgresBootstrap).toContain('"proxy_logs_client_app_id_created_at_idx"');
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' };
|
||||
import currentContract from './generated/schemaContract.json' with { type: 'json' };
|
||||
import { applyContractFixtureThenUpgrade, introspectLiveSchema } from './schemaIntrospection.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const skipLiveSchema = process.env.DB_PARITY_SKIP_LIVE_SCHEMA === 'true';
|
||||
const sqliteUpgrade = !skipLiveSchema && process.env.DB_PARITY_SQLITE !== 'false' ? it : it.skip;
|
||||
const mysqlUpgrade = process.env.DB_PARITY_MYSQL_URL ? it : it.skip;
|
||||
const postgresUpgrade = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip;
|
||||
|
||||
describe('schema upgrade parity', () => {
|
||||
sqliteUpgrade('upgrades sqlite to the current contract', async () => {
|
||||
const sqliteUrl = await applyContractFixtureThenUpgrade('sqlite', baselineContract, currentContract);
|
||||
const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl });
|
||||
expect(live).toEqual(currentContract);
|
||||
});
|
||||
|
||||
mysqlUpgrade('upgrades mysql to the current contract', async () => {
|
||||
const mysqlUrl = await applyContractFixtureThenUpgrade('mysql', baselineContract, currentContract, {
|
||||
connectionString: process.env.DB_PARITY_MYSQL_URL!,
|
||||
});
|
||||
const live = await introspectLiveSchema({ dialect: 'mysql', connectionString: mysqlUrl });
|
||||
expect(live).toEqual(currentContract);
|
||||
});
|
||||
|
||||
postgresUpgrade('upgrades postgres to the current contract', async () => {
|
||||
const postgresUrl = await applyContractFixtureThenUpgrade('postgres', baselineContract, currentContract, {
|
||||
connectionString: process.env.DB_PARITY_POSTGRES_URL!,
|
||||
});
|
||||
const live = await introspectLiveSchema({ dialect: 'postgres', connectionString: postgresUrl });
|
||||
expect(live).toEqual(currentContract);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ensureSharedIndexSchemaCompatibility, type SharedIndexSchemaInspector } from './sharedIndexSchemaCompatibility.js';
|
||||
|
||||
function createInspector(
|
||||
dialect: SharedIndexSchemaInspector['dialect'],
|
||||
options?: {
|
||||
existingTables?: string[];
|
||||
},
|
||||
) {
|
||||
const executedSql: string[] = [];
|
||||
const existingTables = new Set(options?.existingTables ?? []);
|
||||
|
||||
const inspector: SharedIndexSchemaInspector = {
|
||||
dialect,
|
||||
async tableExists(table) {
|
||||
return existingTables.has(table);
|
||||
},
|
||||
async execute(sqlText) {
|
||||
executedSql.push(sqlText);
|
||||
},
|
||||
};
|
||||
|
||||
return { inspector, executedSql };
|
||||
}
|
||||
|
||||
describe('ensureSharedIndexSchemaCompatibility', () => {
|
||||
it.each([
|
||||
'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',
|
||||
'accounts',
|
||||
'account_tokens',
|
||||
'checkin_logs',
|
||||
'model_availability',
|
||||
'token_model_availability',
|
||||
'token_routes',
|
||||
'route_channels',
|
||||
'proxy_logs',
|
||||
'proxy_video_tasks',
|
||||
'downstream_api_keys',
|
||||
'events',
|
||||
],
|
||||
});
|
||||
|
||||
await ensureSharedIndexSchemaCompatibility(inspector);
|
||||
expect(executedSql).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips indexes for tables that do not exist', async () => {
|
||||
const { inspector, executedSql } = createInspector('postgres', {
|
||||
existingTables: ['sites'],
|
||||
});
|
||||
|
||||
await ensureSharedIndexSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
export type SharedIndexSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface SharedIndexSchemaInspector {
|
||||
dialect: SharedIndexSchemaDialect;
|
||||
tableExists(table: string): Promise<boolean>;
|
||||
execute(sqlText: string): Promise<void>;
|
||||
}
|
||||
|
||||
type SharedIndexCompatibilitySpec = {
|
||||
table: string;
|
||||
indexName: string;
|
||||
createSql: Record<SharedIndexSchemaDialect, string>;
|
||||
};
|
||||
|
||||
// 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) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
}
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateSchemaError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate')
|
||||
|| lowered.includes('relation') && lowered.includes('already exists');
|
||||
}
|
||||
|
||||
async function executeIgnoreDuplicate(inspector: SharedIndexSchemaInspector, sqlText: string): Promise<void> {
|
||||
try {
|
||||
await inspector.execute(sqlText);
|
||||
} catch (error) {
|
||||
if (!isDuplicateSchemaError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSharedIndexSchemaCompatibility(inspector: SharedIndexSchemaInspector): Promise<void> {
|
||||
const tableExistsCache = new Map<string, boolean>();
|
||||
|
||||
for (const spec of SHARED_INDEX_COMPATIBILITY_SPECS) {
|
||||
let hasTable = tableExistsCache.get(spec.table);
|
||||
if (hasTable === undefined) {
|
||||
hasTable = await inspector.tableExists(spec.table);
|
||||
tableExistsCache.set(spec.table, hasTable);
|
||||
}
|
||||
if (!hasTable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await executeIgnoreDuplicate(inspector, spec.createSql[inspector.dialect]);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateDialectArtifacts } from './schemaArtifactGenerator.js';
|
||||
import type { SchemaContract } from './schemaContract.js';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
const dbDir = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json');
|
||||
|
||||
function readSchemaContract(): SchemaContract {
|
||||
return JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
|
||||
}
|
||||
|
||||
describe('site announcements schema', () => {
|
||||
it('declares the site_announcements table in the Drizzle schema', () => {
|
||||
expect(schema.siteAnnouncements).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps site_announcements in the generated schema contract', () => {
|
||||
const contract = readSchemaContract();
|
||||
const table = contract.tables.site_announcements;
|
||||
|
||||
expect(table).toBeDefined();
|
||||
expect(table?.columns.site_id?.logicalType).toBe('integer');
|
||||
expect(table?.columns.source_key?.logicalType).toBe('text');
|
||||
expect(table?.columns.first_seen_at?.logicalType).toBe('datetime');
|
||||
expect(table?.columns.last_seen_at?.logicalType).toBe('datetime');
|
||||
expect(table?.columns.read_at?.logicalType).toBe('datetime');
|
||||
expect(table?.columns.raw_payload?.logicalType).toBe('text');
|
||||
expect(contract.indexes.some((index) => index.name === 'site_announcements_site_id_first_seen_at_idx')).toBe(true);
|
||||
expect(contract.indexes.some((index) => index.name === 'site_announcements_read_at_idx')).toBe(true);
|
||||
expect(contract.uniques.some((unique) => unique.name === 'site_announcements_site_source_key_unique')).toBe(true);
|
||||
});
|
||||
|
||||
it('emits bootstrap sql for the site_announcements table', () => {
|
||||
const artifacts = generateDialectArtifacts(readSchemaContract());
|
||||
|
||||
expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `site_announcements`');
|
||||
expect(artifacts.mysqlBootstrap).toContain("`first_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))");
|
||||
expect(artifacts.mysqlBootstrap).toContain('`upstream_created_at` VARCHAR(191)');
|
||||
expect(artifacts.mysqlBootstrap).toContain('`site_announcements_site_source_key_unique`');
|
||||
expect(artifacts.postgresBootstrap).toContain('CREATE TABLE IF NOT EXISTS "site_announcements"');
|
||||
expect(artifacts.postgresBootstrap).toContain(`"first_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')`);
|
||||
expect(artifacts.postgresBootstrap).toContain('"upstream_created_at" TEXT');
|
||||
expect(artifacts.postgresBootstrap).toContain('"site_announcements_site_source_key_unique"');
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
|
||||
|
||||
function createInspector(
|
||||
dialect: SiteSchemaInspector['dialect'],
|
||||
options?: {
|
||||
hasSitesTable?: boolean;
|
||||
existingTables?: string[];
|
||||
existingColumns?: string[];
|
||||
},
|
||||
) {
|
||||
const executedSql: string[] = [];
|
||||
const hasSitesTable = options?.hasSitesTable ?? true;
|
||||
const existingTables = new Set(options?.existingTables ?? []);
|
||||
const existingColumns = new Set(options?.existingColumns ?? []);
|
||||
|
||||
const inspector: SiteSchemaInspector = {
|
||||
dialect,
|
||||
async tableExists(table) {
|
||||
if (table === 'sites') return hasSitesTable;
|
||||
return existingTables.has(table);
|
||||
},
|
||||
async columnExists(table, column) {
|
||||
return table === 'sites' && existingColumns.has(column);
|
||||
},
|
||||
async execute(sqlText) {
|
||||
executedSql.push(sqlText);
|
||||
},
|
||||
};
|
||||
|
||||
return { inspector, executedSql };
|
||||
}
|
||||
|
||||
describe('ensureSiteSchemaCompatibility', () => {
|
||||
it.each([
|
||||
{
|
||||
dialect: 'sqlite' as const,
|
||||
expectedSql: [
|
||||
'ALTER TABLE sites ADD COLUMN proxy_url text;',
|
||||
'ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;',
|
||||
'UPDATE sites SET use_system_proxy = 0 WHERE use_system_proxy IS NULL;',
|
||||
'ALTER TABLE sites ADD COLUMN custom_headers text;',
|
||||
'ALTER TABLE sites ADD COLUMN external_checkin_url text;',
|
||||
'ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;',
|
||||
'UPDATE sites SET global_weight = 1 WHERE global_weight IS NULL OR global_weight <= 0;',
|
||||
'CREATE TABLE IF NOT EXISTS site_api_endpoints (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, site_id integer NOT NULL REFERENCES sites(id) ON DELETE cascade, url text NOT NULL, enabled integer DEFAULT 1, sort_order integer DEFAULT 0, cooldown_until text, last_selected_at text, last_failed_at text, last_failure_reason text, created_at text DEFAULT (datetime(\'now\')), updated_at text DEFAULT (datetime(\'now\')));',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique ON site_api_endpoints (site_id, url);',
|
||||
'CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx ON site_api_endpoints (site_id, enabled, sort_order);',
|
||||
'CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx ON site_api_endpoints (site_id, cooldown_until);',
|
||||
'CREATE TABLE IF NOT EXISTS site_disabled_models (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, site_id integer NOT NULL REFERENCES sites(id) ON DELETE cascade, model_name text NOT NULL, created_at text DEFAULT (datetime(\'now\')));',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique ON site_disabled_models (site_id, model_name);',
|
||||
'CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx ON site_disabled_models (site_id);',
|
||||
],
|
||||
},
|
||||
{
|
||||
dialect: 'postgres' as const,
|
||||
expectedSql: [
|
||||
'ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT',
|
||||
'ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT FALSE',
|
||||
'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
||||
'ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT',
|
||||
'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT',
|
||||
'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1',
|
||||
'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
|
||||
'CREATE TABLE IF NOT EXISTS "site_api_endpoints" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "url" TEXT NOT NULL, "enabled" BOOLEAN DEFAULT TRUE, "sort_order" INTEGER DEFAULT 0, "cooldown_until" TEXT, "last_selected_at" TEXT, "last_failed_at" TEXT, "last_failure_reason" TEXT, "created_at" TEXT DEFAULT to_char(timezone(\'UTC\', CURRENT_TIMESTAMP), \'YYYY-MM-DD HH24:MI:SS\'), "updated_at" TEXT DEFAULT to_char(timezone(\'UTC\', CURRENT_TIMESTAMP), \'YYYY-MM-DD HH24:MI:SS\'))',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS "site_api_endpoints_site_url_unique" ON "site_api_endpoints" ("site_id", "url")',
|
||||
'CREATE INDEX IF NOT EXISTS "site_api_endpoints_site_enabled_sort_idx" ON "site_api_endpoints" ("site_id", "enabled", "sort_order")',
|
||||
'CREATE INDEX IF NOT EXISTS "site_api_endpoints_site_cooldown_idx" ON "site_api_endpoints" ("site_id", "cooldown_until")',
|
||||
'CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "created_at" TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name")',
|
||||
'CREATE INDEX IF NOT EXISTS "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id")',
|
||||
],
|
||||
},
|
||||
{
|
||||
dialect: 'mysql' as const,
|
||||
expectedSql: [
|
||||
'ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT NULL',
|
||||
'ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT FALSE',
|
||||
'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
|
||||
'ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT NULL',
|
||||
'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL',
|
||||
'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1',
|
||||
'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
|
||||
'CREATE TABLE IF NOT EXISTS `site_api_endpoints` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `url` TEXT NOT NULL, `enabled` BOOLEAN DEFAULT TRUE, `sort_order` INT DEFAULT 0, `cooldown_until` VARCHAR(191) NULL, `last_selected_at` VARCHAR(191) NULL, `last_failed_at` VARCHAR(191) NULL, `last_failure_reason` TEXT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), \'%Y-%m-%d %H:%i:%s\')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), \'%Y-%m-%d %H:%i:%s\')), CONSTRAINT `site_api_endpoints_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)',
|
||||
'CREATE UNIQUE INDEX `site_api_endpoints_site_url_unique` ON `site_api_endpoints` (`site_id`, `url`(191))',
|
||||
'CREATE INDEX `site_api_endpoints_site_enabled_sort_idx` ON `site_api_endpoints` (`site_id`, `enabled`, `sort_order`)',
|
||||
'CREATE INDEX `site_api_endpoints_site_cooldown_idx` ON `site_api_endpoints` (`site_id`, `cooldown_until`(191))',
|
||||
'CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `model_name` VARCHAR(191) NOT NULL, `created_at` TEXT NULL, CONSTRAINT `site_disabled_models_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)',
|
||||
'CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191))',
|
||||
'CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`)',
|
||||
],
|
||||
},
|
||||
])('adds missing site proxy columns for $dialect', async ({ dialect, expectedSql }) => {
|
||||
const { inspector, executedSql } = createInspector(dialect);
|
||||
|
||||
await ensureSiteSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql).toEqual(expectedSql);
|
||||
});
|
||||
|
||||
it('skips schema changes when sites table does not exist', async () => {
|
||||
const { inspector, executedSql } = createInspector('postgres', { hasSitesTable: false });
|
||||
|
||||
await ensureSiteSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not recreate site_disabled_models when it already exists', async () => {
|
||||
const { inspector, executedSql } = createInspector('mysql', {
|
||||
existingTables: ['site_disabled_models'],
|
||||
});
|
||||
|
||||
await ensureSiteSchemaCompatibility(inspector);
|
||||
|
||||
expect(executedSql).not.toContain('CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `model_name` VARCHAR(191) NOT NULL, `created_at` TEXT NULL, CONSTRAINT `site_disabled_models_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)');
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
export type SiteSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface SiteSchemaInspector {
|
||||
dialect: SiteSchemaDialect;
|
||||
tableExists(table: string): Promise<boolean>;
|
||||
columnExists(table: string, column: string): Promise<boolean>;
|
||||
execute(sqlText: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type SiteColumnCompatibilitySpec = {
|
||||
column: string;
|
||||
addSql: Record<SiteSchemaDialect, string>;
|
||||
normalizeSql?: Record<SiteSchemaDialect, string>;
|
||||
};
|
||||
|
||||
export type SiteTableCompatibilitySpec = {
|
||||
table: string;
|
||||
createSql: Record<SiteSchemaDialect, string>;
|
||||
postCreateSql?: Record<SiteSchemaDialect, string[]>;
|
||||
};
|
||||
|
||||
export const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
|
||||
{
|
||||
column: 'proxy_url',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE sites ADD COLUMN proxy_url text;',
|
||||
mysql: 'ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
column: 'use_system_proxy',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;',
|
||||
mysql: 'ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT FALSE',
|
||||
postgres: 'ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT FALSE',
|
||||
},
|
||||
normalizeSql: {
|
||||
sqlite: 'UPDATE sites SET use_system_proxy = 0 WHERE use_system_proxy IS NULL;',
|
||||
mysql: 'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
|
||||
postgres: 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
||||
},
|
||||
},
|
||||
{
|
||||
column: 'custom_headers',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE sites ADD COLUMN custom_headers text;',
|
||||
mysql: 'ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
column: 'external_checkin_url',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE sites ADD COLUMN external_checkin_url text;',
|
||||
mysql: 'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
column: 'global_weight',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;',
|
||||
mysql: 'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1',
|
||||
postgres: 'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1',
|
||||
},
|
||||
normalizeSql: {
|
||||
sqlite: 'UPDATE sites SET global_weight = 1 WHERE global_weight IS NULL OR global_weight <= 0;',
|
||||
mysql: 'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
|
||||
postgres: 'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const SITE_TABLE_COMPATIBILITY_SPECS: SiteTableCompatibilitySpec[] = [
|
||||
{
|
||||
table: 'site_api_endpoints',
|
||||
createSql: {
|
||||
sqlite: 'CREATE TABLE IF NOT EXISTS site_api_endpoints (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, site_id integer NOT NULL REFERENCES sites(id) ON DELETE cascade, url text NOT NULL, enabled integer DEFAULT 1, sort_order integer DEFAULT 0, cooldown_until text, last_selected_at text, last_failed_at text, last_failure_reason text, created_at text DEFAULT (datetime(\'now\')), updated_at text DEFAULT (datetime(\'now\')));',
|
||||
mysql: 'CREATE TABLE IF NOT EXISTS `site_api_endpoints` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `url` TEXT NOT NULL, `enabled` BOOLEAN DEFAULT TRUE, `sort_order` INT DEFAULT 0, `cooldown_until` VARCHAR(191) NULL, `last_selected_at` VARCHAR(191) NULL, `last_failed_at` VARCHAR(191) NULL, `last_failure_reason` TEXT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), \'%Y-%m-%d %H:%i:%s\')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), \'%Y-%m-%d %H:%i:%s\')), CONSTRAINT `site_api_endpoints_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)',
|
||||
postgres: 'CREATE TABLE IF NOT EXISTS "site_api_endpoints" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "url" TEXT NOT NULL, "enabled" BOOLEAN DEFAULT TRUE, "sort_order" INTEGER DEFAULT 0, "cooldown_until" TEXT, "last_selected_at" TEXT, "last_failed_at" TEXT, "last_failure_reason" TEXT, "created_at" TEXT DEFAULT to_char(timezone(\'UTC\', CURRENT_TIMESTAMP), \'YYYY-MM-DD HH24:MI:SS\'), "updated_at" TEXT DEFAULT to_char(timezone(\'UTC\', CURRENT_TIMESTAMP), \'YYYY-MM-DD HH24:MI:SS\'))',
|
||||
},
|
||||
postCreateSql: {
|
||||
sqlite: [
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique ON site_api_endpoints (site_id, url);',
|
||||
'CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx ON site_api_endpoints (site_id, enabled, sort_order);',
|
||||
'CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx ON site_api_endpoints (site_id, cooldown_until);',
|
||||
],
|
||||
mysql: [
|
||||
'CREATE UNIQUE INDEX `site_api_endpoints_site_url_unique` ON `site_api_endpoints` (`site_id`, `url`(191))',
|
||||
'CREATE INDEX `site_api_endpoints_site_enabled_sort_idx` ON `site_api_endpoints` (`site_id`, `enabled`, `sort_order`)',
|
||||
'CREATE INDEX `site_api_endpoints_site_cooldown_idx` ON `site_api_endpoints` (`site_id`, `cooldown_until`(191))',
|
||||
],
|
||||
postgres: [
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS "site_api_endpoints_site_url_unique" ON "site_api_endpoints" ("site_id", "url")',
|
||||
'CREATE INDEX IF NOT EXISTS "site_api_endpoints_site_enabled_sort_idx" ON "site_api_endpoints" ("site_id", "enabled", "sort_order")',
|
||||
'CREATE INDEX IF NOT EXISTS "site_api_endpoints_site_cooldown_idx" ON "site_api_endpoints" ("site_id", "cooldown_until")',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'site_disabled_models',
|
||||
createSql: {
|
||||
sqlite: 'CREATE TABLE IF NOT EXISTS site_disabled_models (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, site_id integer NOT NULL REFERENCES sites(id) ON DELETE cascade, model_name text NOT NULL, created_at text DEFAULT (datetime(\'now\')));',
|
||||
mysql: 'CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT PRIMARY KEY, `site_id` INT NOT NULL, `model_name` VARCHAR(191) NOT NULL, `created_at` TEXT NULL, CONSTRAINT `site_disabled_models_site_fk` FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE)',
|
||||
postgres: 'CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "created_at" TEXT)',
|
||||
},
|
||||
postCreateSql: {
|
||||
sqlite: [
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique ON site_disabled_models (site_id, model_name);',
|
||||
'CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx ON site_disabled_models (site_id);',
|
||||
],
|
||||
mysql: [
|
||||
'CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191))',
|
||||
'CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`)',
|
||||
],
|
||||
postgres: [
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name")',
|
||||
'CREATE INDEX IF NOT EXISTS "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id")',
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
}
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column name');
|
||||
}
|
||||
|
||||
function isExistingSchemaObjectError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column name')
|
||||
|| lowered.includes('duplicate key name')
|
||||
|| (lowered.includes('relation') && lowered.includes('already exists'));
|
||||
}
|
||||
|
||||
async function executeAddColumn(inspector: SiteSchemaInspector, sqlText: string): Promise<void> {
|
||||
try {
|
||||
await inspector.execute(sqlText);
|
||||
} catch (error) {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCreateSchemaObject(inspector: SiteSchemaInspector, sqlText: string): Promise<void> {
|
||||
try {
|
||||
await inspector.execute(sqlText);
|
||||
} catch (error) {
|
||||
if (!isExistingSchemaObjectError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSiteSchemaCompatibility(inspector: SiteSchemaInspector): Promise<void> {
|
||||
const hasSitesTable = await inspector.tableExists('sites');
|
||||
if (!hasSitesTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const spec of SITE_COLUMN_COMPATIBILITY_SPECS) {
|
||||
const hasColumn = await inspector.columnExists('sites', spec.column);
|
||||
if (!hasColumn) {
|
||||
await executeAddColumn(inspector, spec.addSql[inspector.dialect]);
|
||||
}
|
||||
|
||||
if (spec.normalizeSql) {
|
||||
await inspector.execute(spec.normalizeSql[inspector.dialect]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const spec of SITE_TABLE_COMPATIBILITY_SPECS) {
|
||||
const hasTable = await inspector.tableExists(spec.table);
|
||||
if (!hasTable) {
|
||||
await executeCreateSchemaObject(inspector, spec.createSql[inspector.dialect]);
|
||||
}
|
||||
|
||||
for (const sqlText of spec.postCreateSql?.[inspector.dialect] ?? []) {
|
||||
await executeCreateSchemaObject(inspector, sqlText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, runtimeDbDialect, schema } from './index.js';
|
||||
|
||||
/**
|
||||
* Dialect-aware upsert for the `settings` table.
|
||||
*
|
||||
* SQLite / PostgreSQL use `onConflictDoUpdate` while MySQL uses
|
||||
* `onDuplicateKeyUpdate`. Drizzle exposes different builder types for each
|
||||
* dialect, so we branch at runtime.
|
||||
*/
|
||||
export async function upsertSetting(key: string, value: unknown, txDb: typeof db = db): Promise<void> {
|
||||
const jsonValue = JSON.stringify(value);
|
||||
|
||||
if (runtimeDbDialect === 'mysql') {
|
||||
// MySQL path – try update first, insert if not exists
|
||||
const existing = await txDb.select({ key: schema.settings.key })
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, key))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
await txDb.update(schema.settings)
|
||||
.set({ value: jsonValue })
|
||||
.where(eq(schema.settings.key, key))
|
||||
.run();
|
||||
} else {
|
||||
await txDb.insert(schema.settings)
|
||||
.values({ key, value: jsonValue })
|
||||
.run();
|
||||
}
|
||||
} else {
|
||||
// SQLite / PostgreSQL path
|
||||
await (txDb.insert(schema.settings)
|
||||
.values({ key, value: jsonValue }) as any)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.settings.key,
|
||||
set: { value: jsonValue },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
|
||||
describe('desktop server routes', () => {
|
||||
it('marks only the desktop health route as public', () => {
|
||||
expect(isPublicApiRoute('/api/desktop/health')).toBe(true);
|
||||
expect(isPublicApiRoute('/api/stats/dashboard')).toBe(false);
|
||||
});
|
||||
|
||||
it('registers a public desktop health probe', async () => {
|
||||
const app = Fastify();
|
||||
await registerDesktopRoutes(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/desktop/health',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ok: true });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
|
||||
|
||||
export function isPublicApiRoute(url: string): boolean {
|
||||
return url === DESKTOP_HEALTH_ROUTE
|
||||
|| url.startsWith('/api/oauth/callback/')
|
||||
|| url.startsWith('/api/comfyui/')
|
||||
// User SaaS routes (JWT auth, not admin token)
|
||||
|| url === '/api/users/register'
|
||||
|| url === '/api/users/login'
|
||||
|| url.startsWith('/api/users/')
|
||||
|| url.startsWith('/api/user-api-keys/')
|
||||
|| url.startsWith('/api/organizations')
|
||||
|| url.startsWith('/api/invitations/');
|
||||
}
|
||||
|
||||
export async function registerDesktopRoutes(app: FastifyInstance) {
|
||||
app.get(DESKTOP_HEALTH_ROUTE, async () => ({ ok: true }));
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import {
|
||||
buildFastifyOptions,
|
||||
config,
|
||||
} from './config.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { sitesRoutes } from './routes/api/sites.js';
|
||||
import { accountsRoutes } from './routes/api/accounts.js';
|
||||
import { checkinRoutes } from './routes/api/checkin.js';
|
||||
import { tokensRoutes } from './routes/api/tokens.js';
|
||||
import { statsRoutes } from './routes/api/stats.js';
|
||||
import { authRoutes } from './routes/api/auth.js';
|
||||
import { settingsRoutes } from './routes/api/settings.js';
|
||||
import { accountTokensRoutes } from './routes/api/accountTokens.js';
|
||||
import { searchRoutes } from './routes/api/search.js';
|
||||
import { eventsRoutes } from './routes/api/events.js';
|
||||
import { taskRoutes } from './routes/api/tasks.js';
|
||||
import { testRoutes } from './routes/api/test.js';
|
||||
import { monitorRoutes } from './routes/api/monitor.js';
|
||||
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||
import { usersRoutes } from './routes/api/users.js';
|
||||
import { userApiKeysRoutes } from './routes/api/userApiKeys.js';
|
||||
import { organizationsRoutes } from './routes/api/organizations.js';
|
||||
import { invitationRoutes } from './routes/api/invitations.js';
|
||||
import { oauthRoutes } from './routes/api/oauth.js';
|
||||
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||
import { updateCenterRoutes } from './routes/api/updateCenter.js';
|
||||
import { comfyuiRoutes } from './routes/api/comfyui.js';
|
||||
import { videoAgentRoutes } from './routes/api/videoAgent.js';
|
||||
import { adminUsersRoutes } from './routes/api/adminUsers.js';
|
||||
import { comfyuiAgentRoutes } from './routes/api/comfyuiAgent.js';
|
||||
import { proxyRoutes } from './routes/proxy/router.js';
|
||||
import { startScheduler } from './services/checkinScheduler.js';
|
||||
import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js';
|
||||
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
|
||||
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
|
||||
import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js';
|
||||
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
||||
import { startSiteAnnouncementPolling, stopSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
|
||||
import {
|
||||
startModelAvailabilityProbeScheduler,
|
||||
stopModelAvailabilityProbeScheduler,
|
||||
} from './services/modelAvailabilityProbeService.js';
|
||||
import {
|
||||
startChannelRecoveryProbeScheduler,
|
||||
stopChannelRecoveryProbeScheduler,
|
||||
} from './services/channelRecoveryProbeService.js';
|
||||
import {
|
||||
startSub2ApiManagedRefreshScheduler,
|
||||
stopSub2ApiManagedRefreshScheduler,
|
||||
} from './services/sub2apiRefreshScheduler.js';
|
||||
import { startUpdateCenterPolling, stopUpdateCenterPolling } from './services/updateCenterPollingService.js';
|
||||
import {
|
||||
startAdminSnapshotWarmScheduler,
|
||||
stopAdminSnapshotWarmScheduler,
|
||||
} from './services/adminSnapshotWarmService.js';
|
||||
import {
|
||||
startUsageAggregationProjectorScheduler,
|
||||
stopUsageAggregationProjectorScheduler,
|
||||
} from './services/usageAggregationService.js';
|
||||
import { reloadBackupWebdavScheduler } from './services/backupService.js';
|
||||
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, normalize, resolve, sep } from 'path';
|
||||
import {
|
||||
applyRuntimeSettings,
|
||||
parseSettingFromMap,
|
||||
} from './runtimeSettingsHydration.js';
|
||||
import { normalizeLogCleanupRetentionDays } from './shared/logCleanupRetentionDays.js';
|
||||
import {
|
||||
db,
|
||||
ensureProxyFileCompatibilityColumns,
|
||||
ensureProxyLogClientColumns,
|
||||
ensureProxyLogDownstreamApiKeyIdColumn,
|
||||
ensureProxyLogBillingDetailsColumn,
|
||||
ensureProxyLogStreamTimingColumns,
|
||||
ensureRouteGroupingCompatibilityColumns,
|
||||
ensureSiteCompatibilityColumns,
|
||||
runtimeDbDialect,
|
||||
schema,
|
||||
switchRuntimeDatabase,
|
||||
type RuntimeDbDialect,
|
||||
} from './db/index.js';
|
||||
|
||||
function toSettingsMap(rows: Array<{ key: string; value: string }>) {
|
||||
return new Map(rows.map((row) => [row.key, row.value]));
|
||||
}
|
||||
|
||||
function normalizeSavedDbType(value: unknown): RuntimeDbDialect | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'sqlite') return 'sqlite';
|
||||
if (normalized === 'mysql') return 'mysql';
|
||||
if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres';
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateSavedDbUrl(dialect: RuntimeDbDialect, value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return null;
|
||||
if (dialect === 'sqlite') return normalized;
|
||||
if (dialect === 'mysql' && normalized.startsWith('mysql://')) return normalized;
|
||||
if (dialect === 'postgres' && (normalized.startsWith('postgres://') || normalized.startsWith('postgresql://'))) return normalized;
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string; ssl: boolean } | null {
|
||||
const rawType = parseSettingFromMap<unknown>(settingsMap, 'db_type');
|
||||
const rawUrl = parseSettingFromMap<unknown>(settingsMap, 'db_url');
|
||||
const rawSsl = parseSettingFromMap<boolean>(settingsMap, 'db_ssl');
|
||||
const dialect = normalizeSavedDbType(rawType);
|
||||
if (!dialect) return null;
|
||||
const dbUrl = validateSavedDbUrl(dialect, rawUrl);
|
||||
if (!dbUrl) return null;
|
||||
return {
|
||||
dialect,
|
||||
dbUrl,
|
||||
ssl: typeof rawSsl === 'boolean' ? rawSsl : false,
|
||||
};
|
||||
}
|
||||
|
||||
const LOG_CLEANUP_SETTING_KEYS = [
|
||||
'log_cleanup_cron',
|
||||
'log_cleanup_usage_logs_enabled',
|
||||
'log_cleanup_program_logs_enabled',
|
||||
'log_cleanup_retention_days',
|
||||
] as const;
|
||||
|
||||
function hasExplicitLogCleanupSettings(settingsMap: Map<string, string>): boolean {
|
||||
return LOG_CLEANUP_SETTING_KEYS.some((key) => settingsMap.has(key));
|
||||
}
|
||||
|
||||
// Ensure the current runtime database is bootstrapped before reading settings.
|
||||
await ensureRuntimeDatabaseReady({
|
||||
dialect: runtimeDbDialect,
|
||||
connectionString: config.dbUrl,
|
||||
ssl: config.dbSsl,
|
||||
});
|
||||
|
||||
// Load runtime config overrides from settings
|
||||
try {
|
||||
const initialRows = await db.select().from(schema.settings).all();
|
||||
const initialMap = toSettingsMap(initialRows);
|
||||
const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap);
|
||||
const activeDbUrl = (config.dbUrl || '').trim();
|
||||
const originalRuntimeConfig = {
|
||||
dialect: runtimeDbDialect,
|
||||
dbUrl: activeDbUrl,
|
||||
ssl: config.dbSsl,
|
||||
};
|
||||
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) {
|
||||
try {
|
||||
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl);
|
||||
console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`);
|
||||
} catch (error) {
|
||||
const currentDbUrl = (config.dbUrl || '').trim();
|
||||
const switchedAway = runtimeDbDialect !== originalRuntimeConfig.dialect
|
||||
|| currentDbUrl !== originalRuntimeConfig.dbUrl
|
||||
|| config.dbSsl !== originalRuntimeConfig.ssl;
|
||||
if (switchedAway) {
|
||||
await switchRuntimeDatabase(
|
||||
originalRuntimeConfig.dialect,
|
||||
originalRuntimeConfig.dbUrl,
|
||||
originalRuntimeConfig.ssl,
|
||||
);
|
||||
}
|
||||
console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureSiteCompatibilityColumns();
|
||||
await ensureRouteGroupingCompatibilityColumns();
|
||||
await ensureProxyFileCompatibilityColumns();
|
||||
await ensureProxyLogStreamTimingColumns();
|
||||
await ensureProxyLogClientColumns();
|
||||
await ensureProxyLogDownstreamApiKeyIdColumn();
|
||||
const finalRows = await db.select().from(schema.settings).all();
|
||||
const finalMap = toSettingsMap(finalRows);
|
||||
applyRuntimeSettings(finalMap);
|
||||
config.logCleanupConfigured = hasExplicitLogCleanupSettings(finalMap);
|
||||
if (!config.logCleanupConfigured && config.proxyLogRetentionDays > 0) {
|
||||
config.logCleanupUsageLogsEnabled = true;
|
||||
config.logCleanupProgramLogsEnabled = false;
|
||||
config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(config.proxyLogRetentionDays);
|
||||
}
|
||||
await ensureProxyLogBillingDetailsColumn();
|
||||
await repairStoredCreatedAtValues();
|
||||
await migrateSiteApiKeysToAccounts();
|
||||
await ensureDefaultSitesSeeded();
|
||||
await ensureOauthIdentityBackfill();
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
|
||||
console.log('Loaded runtime settings overrides');
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
await ensureOauthProviderSitesExist();
|
||||
|
||||
const app = Fastify(buildFastifyOptions(config));
|
||||
|
||||
await app.register(cors);
|
||||
|
||||
// Auth middleware for /api routes
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (request.url.startsWith('/api/') && !isPublicApiRoute(request.url)) {
|
||||
await authMiddleware(request, reply);
|
||||
}
|
||||
});
|
||||
|
||||
// Register API routes
|
||||
await app.register(registerDesktopRoutes);
|
||||
await app.register(sitesRoutes);
|
||||
await app.register(accountsRoutes);
|
||||
await app.register(checkinRoutes);
|
||||
await app.register(tokensRoutes);
|
||||
await app.register(statsRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(accountTokensRoutes);
|
||||
await app.register(searchRoutes);
|
||||
await app.register(eventsRoutes);
|
||||
await app.register(siteAnnouncementsRoutes);
|
||||
await app.register(updateCenterRoutes);
|
||||
await app.register(taskRoutes);
|
||||
await app.register(testRoutes);
|
||||
await app.register(monitorRoutes);
|
||||
await app.register(downstreamApiKeysRoutes);
|
||||
await app.register(adminUsersRoutes);
|
||||
await app.register(usersRoutes);
|
||||
await app.register(userApiKeysRoutes);
|
||||
await app.register(invitationRoutes);
|
||||
await app.register(organizationsRoutes);
|
||||
await app.register(comfyuiRoutes);
|
||||
await app.register(videoAgentRoutes);
|
||||
await app.register(comfyuiAgentRoutes);
|
||||
await app.register(oauthRoutes);
|
||||
|
||||
// Register OpenAI-compatible proxy routes
|
||||
await app.register(proxyRoutes);
|
||||
|
||||
// Serve static web frontend in production
|
||||
const webDir = resolve(dirname(fileURLToPath(import.meta.url)), '../web');
|
||||
if (existsSync(webDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: webDir,
|
||||
prefix: '/',
|
||||
wildcard: false,
|
||||
setHeaders: (res, filePath) => {
|
||||
const normalizedPath = normalize(filePath);
|
||||
if (normalizedPath.includes(`${sep}assets${sep}`)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
return;
|
||||
}
|
||||
if (normalizedPath.endsWith(`${sep}index.html`)) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
},
|
||||
});
|
||||
// SPA fallback
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
if (!request.url.startsWith('/api/') && !request.url.startsWith('/v1/')) {
|
||||
return reply.sendFile('index.html');
|
||||
}
|
||||
reply.code(404).send({ error: 'Not found' });
|
||||
});
|
||||
}
|
||||
|
||||
// Start scheduler
|
||||
await startScheduler();
|
||||
await reloadBackupWebdavScheduler();
|
||||
startSiteAnnouncementPolling();
|
||||
startModelAvailabilityProbeScheduler();
|
||||
startChannelRecoveryProbeScheduler();
|
||||
startSub2ApiManagedRefreshScheduler();
|
||||
startUpdateCenterPolling();
|
||||
startUsageAggregationProjectorScheduler();
|
||||
startAdminSnapshotWarmScheduler();
|
||||
try {
|
||||
await startOAuthLoopbackCallbackServers();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
|
||||
}
|
||||
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
||||
startProxyFileRetentionService();
|
||||
app.addHook('onClose', async () => {
|
||||
stopSiteAnnouncementPolling();
|
||||
stopUpdateCenterPolling();
|
||||
stopProxyFileRetentionService();
|
||||
stopProxyLogRetentionService();
|
||||
stopModelAvailabilityProbeScheduler();
|
||||
stopChannelRecoveryProbeScheduler();
|
||||
await stopUsageAggregationProjectorScheduler();
|
||||
await stopAdminSnapshotWarmScheduler();
|
||||
await stopSub2ApiManagedRefreshScheduler();
|
||||
await stopOAuthLoopbackCallbackServers();
|
||||
});
|
||||
|
||||
// Start server
|
||||
try {
|
||||
// Seed default plans for SaaS
|
||||
try {
|
||||
const { seedDefaultPlans } = await import('./services/planService.js');
|
||||
await seedDefaultPlans();
|
||||
} catch { /* plan table may not exist yet */ }
|
||||
|
||||
await app.listen({ port: config.port, host: config.listenHost });
|
||||
const summaryLines = buildStartupSummaryLines({
|
||||
port: config.port,
|
||||
host: config.listenHost,
|
||||
authToken: config.authToken,
|
||||
proxyToken: config.proxyToken,
|
||||
});
|
||||
for (const line of summaryLines) {
|
||||
console.log(line);
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const authorizeDownstreamTokenMock = vi.fn();
|
||||
const consumeManagedKeyRequestMock = vi.fn();
|
||||
|
||||
vi.mock('../services/downstreamApiKeyService.js', () => ({
|
||||
authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args),
|
||||
consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args),
|
||||
}));
|
||||
|
||||
describe('proxyAuthMiddleware', () => {
|
||||
beforeEach(() => {
|
||||
authorizeDownstreamTokenMock.mockReset();
|
||||
consumeManagedKeyRequestMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('rejects missing proxy credentials', async () => {
|
||||
const { proxyAuthMiddleware } = await import('./auth.js');
|
||||
const app = Fastify();
|
||||
app.addHook('onRequest', proxyAuthMiddleware);
|
||||
app.get('/v1/ping', async () => ({ ok: true }));
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/v1/ping' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toMatchObject({ error: expect.stringContaining('Missing Authorization') });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('stores managed key context and consumes request usage', async () => {
|
||||
authorizeDownstreamTokenMock.mockResolvedValue({
|
||||
ok: true,
|
||||
source: 'managed',
|
||||
token: 'sk-managed-001',
|
||||
key: { id: 12, name: 'project-key' },
|
||||
policy: { supportedModels: ['gpt-5.2'], allowedRouteIds: [3], siteWeightMultipliers: { 1: 1.2 } },
|
||||
});
|
||||
consumeManagedKeyRequestMock.mockResolvedValue(undefined);
|
||||
|
||||
const { proxyAuthMiddleware, getProxyAuthContext, getProxyResourceOwner } = await import('./auth.js');
|
||||
const app = Fastify();
|
||||
app.addHook('onRequest', proxyAuthMiddleware);
|
||||
app.get('/v1/ping', async (request) => ({
|
||||
auth: getProxyAuthContext(request),
|
||||
owner: getProxyResourceOwner(request),
|
||||
}));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/ping',
|
||||
headers: { Authorization: 'Bearer sk-managed-001' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(authorizeDownstreamTokenMock).toHaveBeenCalledWith('sk-managed-001');
|
||||
expect(consumeManagedKeyRequestMock).toHaveBeenCalledWith(12);
|
||||
expect(res.json()).toMatchObject({
|
||||
auth: {
|
||||
source: 'managed',
|
||||
keyId: 12,
|
||||
keyName: 'project-key',
|
||||
policy: {
|
||||
supportedModels: ['gpt-5.2'],
|
||||
allowedRouteIds: [3],
|
||||
siteWeightMultipliers: { 1: 1.2 },
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
ownerType: 'managed_key',
|
||||
ownerId: '12',
|
||||
},
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractClientIp, isIpAllowed } from './auth.js';
|
||||
|
||||
describe('auth middleware IP helpers', () => {
|
||||
it('extracts first forwarded IP and normalizes ipv4-mapped address', () => {
|
||||
const ip = extractClientIp('::ffff:10.0.0.1', '198.51.100.7, 203.0.113.2');
|
||||
expect(ip).toBe('198.51.100.7');
|
||||
});
|
||||
|
||||
it('allows request when allowlist is empty', () => {
|
||||
expect(isIpAllowed('203.0.113.8', [])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-allowlisted IP when allowlist is configured', () => {
|
||||
expect(isIpAllowed('203.0.113.8', ['203.0.113.9'])).toBe(false);
|
||||
expect(isIpAllowed('203.0.113.9', ['203.0.113.9'])).toBe(true);
|
||||
});
|
||||
|
||||
it('matches ipv4 CIDR ranges in the allowlist', () => {
|
||||
expect(isIpAllowed('8.8.8.8', ['8.8.8.0/24'])).toBe(true);
|
||||
expect(isIpAllowed('8.8.9.8', ['8.8.8.0/24'])).toBe(false);
|
||||
expect(isIpAllowed('8.8.8.8', ['8.8.0.0/16'])).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores malformed CIDR entries instead of matching unexpectedly', () => {
|
||||
expect(isIpAllowed('8.8.8.8', ['8.8.8.0/99'])).toBe(false);
|
||||
expect(isIpAllowed('8.8.8.8', ['not-an-ip/24'])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
import { isIP } from 'node:net';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { config } from '../config.js';
|
||||
import { authorizeDownstreamToken, consumeManagedKeyRequest } from '../services/downstreamApiKeyService.js';
|
||||
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../services/downstreamPolicyTypes.js';
|
||||
import { verifyJwt, type JwtPayload } from '../services/userService.js';
|
||||
|
||||
export interface ProxyAuthContext {
|
||||
token: string;
|
||||
source: 'managed' | 'global';
|
||||
keyId: number | null;
|
||||
keyName: string;
|
||||
policy: DownstreamRoutingPolicy;
|
||||
}
|
||||
|
||||
export interface ProxyResourceOwner {
|
||||
ownerType: 'managed_key' | 'global_proxy_token';
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface UserAuthContext {
|
||||
userId: number;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
const proxyAuthContextByRequest = new WeakMap<FastifyRequest, ProxyAuthContext>();
|
||||
const userAuthContextByRequest = new WeakMap<FastifyRequest, UserAuthContext>();
|
||||
|
||||
type ParsedAllowlistEntry =
|
||||
| { kind: 'exact'; normalizedIp: string }
|
||||
| { kind: 'cidr'; network: number; mask: number };
|
||||
|
||||
function normalizeIp(rawIp: string | null | undefined): string {
|
||||
const ip = (rawIp || '').trim();
|
||||
if (!ip) return '';
|
||||
if (ip.startsWith('::ffff:')) return ip.slice('::ffff:'.length).trim();
|
||||
if (ip === '::1') return '127.0.0.1';
|
||||
return ip;
|
||||
}
|
||||
|
||||
function parseIpv4Value(rawIp: string): number | null {
|
||||
const normalizedIp = normalizeIp(rawIp);
|
||||
if (isIP(normalizedIp) !== 4) return null;
|
||||
|
||||
let value = 0;
|
||||
for (const part of normalizedIp.split('.')) {
|
||||
value = (value << 8) + Number(part);
|
||||
}
|
||||
|
||||
return value >>> 0;
|
||||
}
|
||||
|
||||
function parseAllowlistEntry(rawEntry: string): ParsedAllowlistEntry | null {
|
||||
const entry = (rawEntry || '').trim();
|
||||
if (!entry) return null;
|
||||
|
||||
const slashIndex = entry.indexOf('/');
|
||||
if (slashIndex === -1) {
|
||||
const normalizedIp = normalizeIp(entry);
|
||||
return isIP(normalizedIp) > 0
|
||||
? { kind: 'exact', normalizedIp }
|
||||
: null;
|
||||
}
|
||||
|
||||
if (entry.indexOf('/', slashIndex + 1) !== -1) return null;
|
||||
|
||||
const networkIp = normalizeIp(entry.slice(0, slashIndex));
|
||||
const prefixText = entry.slice(slashIndex + 1).trim();
|
||||
if (isIP(networkIp) !== 4 || !/^\d+$/.test(prefixText)) return null;
|
||||
|
||||
const prefix = Number(prefixText);
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return null;
|
||||
|
||||
const networkValue = parseIpv4Value(networkIp);
|
||||
if (networkValue === null) return null;
|
||||
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
return {
|
||||
kind: 'cidr',
|
||||
network: networkValue & mask,
|
||||
mask,
|
||||
};
|
||||
}
|
||||
|
||||
export function findInvalidIpAllowlistEntries(allowlist: string[]): string[] {
|
||||
return allowlist.filter((item) => parseAllowlistEntry(item) === null);
|
||||
}
|
||||
|
||||
export function extractClientIp(remoteIp: string | null | undefined, xForwardedFor?: string | string[] | undefined): string {
|
||||
if (Array.isArray(xForwardedFor)) {
|
||||
const first = xForwardedFor.find((item) => item && item.trim().length > 0);
|
||||
if (first) {
|
||||
return normalizeIp(first.split(',')[0]);
|
||||
}
|
||||
} else if (typeof xForwardedFor === 'string' && xForwardedFor.trim().length > 0) {
|
||||
return normalizeIp(xForwardedFor.split(',')[0]);
|
||||
}
|
||||
return normalizeIp(remoteIp);
|
||||
}
|
||||
|
||||
export function isIpAllowed(clientIp: string, allowlist: string[]): boolean {
|
||||
if (!allowlist || allowlist.length === 0) return true;
|
||||
const normalizedClientIp = normalizeIp(clientIp);
|
||||
if (!normalizedClientIp) return false;
|
||||
const clientIpv4Value = parseIpv4Value(normalizedClientIp);
|
||||
|
||||
return allowlist.some((item) => {
|
||||
const entry = parseAllowlistEntry(item);
|
||||
if (!entry) return false;
|
||||
if (entry.kind === 'exact') return entry.normalizedIp === normalizedClientIp;
|
||||
if (clientIpv4Value === null) return false;
|
||||
return (clientIpv4Value & entry.mask) === entry.network;
|
||||
});
|
||||
}
|
||||
|
||||
export async function authMiddleware(request: FastifyRequest, reply: FastifyReply) {
|
||||
const clientIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
||||
if (!isIpAllowed(clientIp, config.adminIpAllowlist)) {
|
||||
reply.code(403).send({ error: 'IP not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth) {
|
||||
reply.code(401).send({ error: 'Missing Authorization header' });
|
||||
return;
|
||||
}
|
||||
const token = auth.replace('Bearer ', '');
|
||||
if (token !== config.authToken) {
|
||||
reply.code(403).send({ error: 'Invalid token' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function proxyAuthMiddleware(request: FastifyRequest, reply: FastifyReply) {
|
||||
const auth = typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: '';
|
||||
const apiKeyHeader = typeof request.headers['x-api-key'] === 'string'
|
||||
? request.headers['x-api-key']
|
||||
: '';
|
||||
const googApiKeyHeader = typeof request.headers['x-goog-api-key'] === 'string'
|
||||
? request.headers['x-goog-api-key']
|
||||
: '';
|
||||
const queryKey = (
|
||||
request.query
|
||||
&& typeof request.query === 'object'
|
||||
&& typeof (request.query as Record<string, unknown>).key === 'string'
|
||||
)
|
||||
? String((request.query as Record<string, unknown>).key).trim()
|
||||
: '';
|
||||
const token = auth
|
||||
? auth.replace(/^Bearer\s+/i, '').trim()
|
||||
: (apiKeyHeader.trim() || googApiKeyHeader.trim() || queryKey);
|
||||
|
||||
if (!token) {
|
||||
reply.code(401).send({ error: 'Missing Authorization, x-api-key, x-goog-api-key, or key query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await authorizeDownstreamToken(token);
|
||||
if (!authResult.ok) {
|
||||
reply.code(authResult.statusCode).send({ error: authResult.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (authResult.source === 'managed' && authResult.key) {
|
||||
await consumeManagedKeyRequest(authResult.key.id);
|
||||
}
|
||||
|
||||
proxyAuthContextByRequest.set(request, {
|
||||
token: authResult.token,
|
||||
source: authResult.source,
|
||||
keyId: authResult.key?.id ?? null,
|
||||
keyName: authResult.key?.name || 'global',
|
||||
policy: authResult.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
});
|
||||
}
|
||||
|
||||
export function getProxyAuthContext(request: FastifyRequest): ProxyAuthContext | null {
|
||||
return proxyAuthContextByRequest.get(request) || null;
|
||||
}
|
||||
|
||||
export function getProxyResourceOwner(request: FastifyRequest): ProxyResourceOwner | null {
|
||||
const auth = getProxyAuthContext(request);
|
||||
if (!auth) return null;
|
||||
|
||||
if (auth.source === 'managed') {
|
||||
return {
|
||||
ownerType: 'managed_key',
|
||||
ownerId: auth.keyId === null ? auth.token : String(auth.keyId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
};
|
||||
}
|
||||
|
||||
export async function userAuthMiddleware(request: FastifyRequest, reply: FastifyReply) {
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
reply.code(401).send({ error: 'Missing or invalid Authorization header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = auth.slice('Bearer '.length).trim();
|
||||
const payload = verifyJwt(token);
|
||||
if (!payload) {
|
||||
reply.code(401).send({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
|
||||
userAuthContextByRequest.set(request, {
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
});
|
||||
}
|
||||
|
||||
export function getUserAuthContext(request: FastifyRequest): UserAuthContext | null {
|
||||
return userAuthContextByRequest.get(request) || null;
|
||||
}
|
||||
|
||||
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
|
||||
const user = getUserAuthContext(request);
|
||||
if (!user || user.role !== 'admin') {
|
||||
reply.code(403).send({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
type RateLimitOptions = {
|
||||
bucket: string;
|
||||
max: number;
|
||||
windowMs: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type RateLimitEntry = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const DEFAULT_MESSAGE = '请求过于频繁,请稍后再试';
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
function normalizeIp(rawIp: string | null | undefined): string {
|
||||
const ip = (rawIp || '').trim();
|
||||
if (!ip) return 'unknown';
|
||||
if (ip.startsWith('::ffff:')) return ip.slice('::ffff:'.length).trim() || 'unknown';
|
||||
if (ip === '::1') return '127.0.0.1';
|
||||
return ip;
|
||||
}
|
||||
|
||||
function extractClientIp(request: FastifyRequest): string {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (Array.isArray(forwarded)) {
|
||||
const first = forwarded.find((value) => typeof value === 'string' && value.trim().length > 0);
|
||||
if (first) return normalizeIp(first.split(',')[0]);
|
||||
}
|
||||
|
||||
if (typeof forwarded === 'string' && forwarded.trim().length > 0) {
|
||||
return normalizeIp(forwarded.split(',')[0]);
|
||||
}
|
||||
|
||||
return normalizeIp(request.ip);
|
||||
}
|
||||
|
||||
function getRateLimitKey(bucket: string, request: FastifyRequest): string {
|
||||
return `${bucket}:${extractClientIp(request)}`;
|
||||
}
|
||||
|
||||
function pruneExpiredEntries(nowMs: number): void {
|
||||
for (const [key, entry] of rateLimitStore.entries()) {
|
||||
if (entry.resetAt > nowMs) continue;
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRequestRateLimitStore(): void {
|
||||
rateLimitStore.clear();
|
||||
}
|
||||
|
||||
export function createRateLimitGuard(options: RateLimitOptions) {
|
||||
const message = options.message || DEFAULT_MESSAGE;
|
||||
return async function rateLimitGuard(request: FastifyRequest, reply: FastifyReply) {
|
||||
const nowMs = Date.now();
|
||||
pruneExpiredEntries(nowMs);
|
||||
|
||||
const key = getRateLimitKey(options.bucket, request);
|
||||
const current = rateLimitStore.get(key);
|
||||
|
||||
if (!current || current.resetAt <= nowMs) {
|
||||
rateLimitStore.set(key, {
|
||||
count: 1,
|
||||
resetAt: nowMs + options.windowMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.count >= options.max) {
|
||||
const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - nowMs) / 1000));
|
||||
reply
|
||||
.code(429)
|
||||
.header('retry-after', String(retryAfterSec))
|
||||
.send({ success: false, message });
|
||||
return;
|
||||
}
|
||||
|
||||
current.count += 1;
|
||||
rateLimitStore.set(key, current);
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBetterSqliteRebuildCommand,
|
||||
isNodeModuleVersionMismatch,
|
||||
} from './nativeModuleGuard.js';
|
||||
|
||||
describe('nativeModuleGuard', () => {
|
||||
it('detects better-sqlite3 ABI mismatch errors', () => {
|
||||
const error = Object.assign(
|
||||
new Error('NODE_MODULE_VERSION 133. This version of Node.js requires NODE_MODULE_VERSION 137.'),
|
||||
{ code: 'ERR_DLOPEN_FAILED' },
|
||||
);
|
||||
|
||||
expect(isNodeModuleVersionMismatch(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unrelated native module errors', () => {
|
||||
const error = Object.assign(
|
||||
new Error('Cannot find module'),
|
||||
{ code: 'MODULE_NOT_FOUND' },
|
||||
);
|
||||
|
||||
expect(isNodeModuleVersionMismatch(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a rebuild command for the current npm client', () => {
|
||||
const command = buildBetterSqliteRebuildCommand({
|
||||
npmExecPath: '/tmp/npm-cli.js',
|
||||
});
|
||||
|
||||
expect(command.command).toBe(process.execPath);
|
||||
expect(command.args).toEqual([
|
||||
'/tmp/npm-cli.js',
|
||||
'rebuild',
|
||||
'better-sqlite3',
|
||||
'--no-audit',
|
||||
'--fund=false',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
type RebuildCommandInput = {
|
||||
npmExecPath?: string;
|
||||
};
|
||||
|
||||
type RebuildCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export function isNodeModuleVersionMismatch(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = 'code' in error ? String((error as { code?: unknown }).code || '') : '';
|
||||
const message = 'message' in error ? String((error as { message?: unknown }).message || '') : '';
|
||||
return code === 'ERR_DLOPEN_FAILED' && message.includes('NODE_MODULE_VERSION');
|
||||
}
|
||||
|
||||
export function buildBetterSqliteRebuildCommand(input: RebuildCommandInput = {}): RebuildCommand {
|
||||
const npmExecPath = input.npmExecPath || process.env.npm_execpath;
|
||||
if (npmExecPath && npmExecPath.length > 0) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
npmExecPath,
|
||||
'rebuild',
|
||||
'better-sqlite3',
|
||||
'--no-audit',
|
||||
'--fund=false',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
return {
|
||||
command: npmCommand,
|
||||
args: ['rebuild', 'better-sqlite3', '--no-audit', '--fund=false'],
|
||||
};
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
rankConversationFileEndpoints,
|
||||
resolveConversationFileEndpointCapability,
|
||||
summarizeConversationFileInputsInOpenAiBody,
|
||||
} from './conversationFileCapabilities.js';
|
||||
|
||||
describe('conversationFileCapabilities', () => {
|
||||
it('summarizes image, audio, document, and remote file inputs from OpenAI bodies', () => {
|
||||
const summary = summarizeConversationFileInputsInOpenAiBody({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
|
||||
{ type: 'input_audio', input_audio: { data: 'UklGRg==', format: 'wav' } },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'brief.pdf',
|
||||
file_data: 'JVBERi0xLjc=',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'remote.pdf',
|
||||
file_url: 'https://example.com/remote.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(summary).toEqual({
|
||||
hasImage: true,
|
||||
hasAudio: true,
|
||||
hasDocument: true,
|
||||
hasRemoteDocumentUrl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('describes native remote-document support for claude messages and inline-only support for gemini chat paths', () => {
|
||||
expect(resolveConversationFileEndpointCapability({
|
||||
sitePlatform: 'claude',
|
||||
endpoint: 'messages',
|
||||
})).toMatchObject({
|
||||
image: 'native',
|
||||
audio: 'unsupported',
|
||||
document: 'native',
|
||||
preservesRemoteDocumentUrl: true,
|
||||
});
|
||||
|
||||
expect(resolveConversationFileEndpointCapability({
|
||||
sitePlatform: 'gemini-cli',
|
||||
endpoint: 'chat',
|
||||
})).toMatchObject({
|
||||
image: 'native',
|
||||
audio: 'native',
|
||||
document: 'inline_only',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
});
|
||||
|
||||
expect(resolveConversationFileEndpointCapability({
|
||||
sitePlatform: 'new-api',
|
||||
endpoint: 'messages',
|
||||
})).toMatchObject({
|
||||
image: 'native',
|
||||
audio: 'unsupported',
|
||||
document: 'inline_only',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('ranks document-capable endpoints ahead of lossy fallbacks', () => {
|
||||
const ranked = rankConversationFileEndpoints({
|
||||
sitePlatform: 'new-api',
|
||||
requestedOrder: ['chat', 'messages', 'responses'],
|
||||
summary: {
|
||||
hasImage: false,
|
||||
hasAudio: false,
|
||||
hasDocument: true,
|
||||
hasRemoteDocumentUrl: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(ranked).toEqual(['responses', 'messages', 'chat']);
|
||||
});
|
||||
});
|
||||
@@ -1,268 +0,0 @@
|
||||
import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||
import { classifyConversationFileMimeType } from '../../../shared/conversationFileTypes.js';
|
||||
|
||||
export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native';
|
||||
export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses';
|
||||
|
||||
export type ConversationFileInputSummary = {
|
||||
hasImage: boolean;
|
||||
hasAudio: boolean;
|
||||
hasDocument: boolean;
|
||||
hasRemoteDocumentUrl: boolean;
|
||||
};
|
||||
|
||||
export type ConversationFileEndpointCapability = {
|
||||
image: ConversationFileTransport;
|
||||
audio: ConversationFileTransport;
|
||||
document: ConversationFileTransport;
|
||||
preservesRemoteDocumentUrl: boolean;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function appendConversationFileSummary(
|
||||
summary: ConversationFileInputSummary,
|
||||
item: Record<string, unknown>,
|
||||
): void {
|
||||
const normalizedFile = normalizeInputFileBlock(item);
|
||||
if (normalizedFile) {
|
||||
const mimeType = inferInputFileMimeType(normalizedFile);
|
||||
const family = classifyConversationFileMimeType(mimeType);
|
||||
if (family === 'image') {
|
||||
summary.hasImage = true;
|
||||
return;
|
||||
}
|
||||
if (family === 'audio') {
|
||||
summary.hasAudio = true;
|
||||
return;
|
||||
}
|
||||
summary.hasDocument = true;
|
||||
if (normalizedFile.fileUrl) {
|
||||
summary.hasRemoteDocumentUrl = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'image_url' || type === 'input_image') {
|
||||
summary.hasImage = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'input_audio') {
|
||||
summary.hasAudio = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(item.content)) {
|
||||
for (const nested of item.content) {
|
||||
if (isRecord(nested)) {
|
||||
appendConversationFileSummary(summary, nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptySummary(): ConversationFileInputSummary {
|
||||
return {
|
||||
hasImage: false,
|
||||
hasAudio: false,
|
||||
hasDocument: false,
|
||||
hasRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeConversationFileInputsInOpenAiBody(
|
||||
body: Record<string, unknown>,
|
||||
): ConversationFileInputSummary {
|
||||
const summary = createEmptySummary();
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message)) continue;
|
||||
const content = message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (isRecord(item)) appendConversationFileSummary(summary, item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isRecord(content)) {
|
||||
appendConversationFileSummary(summary, content);
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function summarizeConversationFileInputsInResponsesBody(
|
||||
body: Record<string, unknown>,
|
||||
): ConversationFileInputSummary {
|
||||
const summary = createEmptySummary();
|
||||
const input = body.input;
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
if (isRecord(item)) appendConversationFileSummary(summary, item);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
if (isRecord(input)) {
|
||||
appendConversationFileSummary(summary, input);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function resolveConversationFileEndpointCapability(input: {
|
||||
sitePlatform?: string;
|
||||
endpoint: ConversationFileEndpoint;
|
||||
}): ConversationFileEndpointCapability {
|
||||
const platform = asTrimmedString(input.sitePlatform).toLowerCase();
|
||||
const endpoint = input.endpoint;
|
||||
|
||||
if (platform === 'codex') {
|
||||
if (endpoint === 'responses') {
|
||||
return {
|
||||
image: 'native',
|
||||
audio: 'native',
|
||||
document: 'native',
|
||||
preservesRemoteDocumentUrl: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
image: 'unsupported',
|
||||
audio: 'unsupported',
|
||||
document: 'unsupported',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === 'claude') {
|
||||
if (endpoint === 'messages') {
|
||||
return {
|
||||
image: 'native',
|
||||
audio: 'unsupported',
|
||||
document: 'native',
|
||||
preservesRemoteDocumentUrl: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
image: 'unsupported',
|
||||
audio: 'unsupported',
|
||||
document: 'unsupported',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === 'gemini-cli' || platform === 'antigravity') {
|
||||
if (endpoint === 'chat') {
|
||||
return {
|
||||
image: 'native',
|
||||
audio: 'native',
|
||||
document: 'inline_only',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
image: 'unsupported',
|
||||
audio: 'unsupported',
|
||||
document: 'unsupported',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === 'gemini') {
|
||||
if (endpoint === 'responses') {
|
||||
return {
|
||||
image: 'native',
|
||||
audio: 'native',
|
||||
document: 'native',
|
||||
preservesRemoteDocumentUrl: true,
|
||||
};
|
||||
}
|
||||
if (endpoint === 'chat') {
|
||||
return {
|
||||
image: 'native',
|
||||
audio: 'native',
|
||||
document: 'inline_only',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
image: 'unsupported',
|
||||
audio: 'unsupported',
|
||||
document: 'unsupported',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === 'responses') {
|
||||
return {
|
||||
image: 'native',
|
||||
audio: 'native',
|
||||
document: 'native',
|
||||
preservesRemoteDocumentUrl: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === 'messages') {
|
||||
return {
|
||||
image: 'native',
|
||||
audio: 'unsupported',
|
||||
document: 'inline_only',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
image: 'native',
|
||||
audio: 'native',
|
||||
document: 'inline_only',
|
||||
preservesRemoteDocumentUrl: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function rankConversationFileEndpoints(input: {
|
||||
sitePlatform?: string;
|
||||
requestedOrder: ConversationFileEndpoint[];
|
||||
summary: ConversationFileInputSummary;
|
||||
preferMessagesForClaudeModel?: boolean;
|
||||
}): ConversationFileEndpoint[] {
|
||||
if (!input.summary.hasDocument) {
|
||||
return [...input.requestedOrder];
|
||||
}
|
||||
|
||||
const isDocumentCompatible = (endpoint: ConversationFileEndpoint) => {
|
||||
const capability = resolveConversationFileEndpointCapability({
|
||||
sitePlatform: input.sitePlatform,
|
||||
endpoint,
|
||||
});
|
||||
if (capability.document === 'unsupported') return false;
|
||||
if (input.summary.hasRemoteDocumentUrl && !capability.preservesRemoteDocumentUrl) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const preferredDocumentOrder = input.preferMessagesForClaudeModel === true
|
||||
? ['messages', 'responses', 'chat']
|
||||
: ['responses', 'messages', 'chat'];
|
||||
|
||||
const supportedDocumentEndpoints = preferredDocumentOrder.filter((endpoint) => {
|
||||
if (!input.requestedOrder.includes(endpoint as ConversationFileEndpoint)) return false;
|
||||
return isDocumentCompatible(endpoint as ConversationFileEndpoint);
|
||||
}) as ConversationFileEndpoint[];
|
||||
|
||||
if (supportedDocumentEndpoints.length <= 0) {
|
||||
return [...input.requestedOrder];
|
||||
}
|
||||
|
||||
return [
|
||||
...supportedDocumentEndpoints,
|
||||
...input.requestedOrder.filter((endpoint) => (
|
||||
!supportedDocumentEndpoints.includes(endpoint)
|
||||
&& isDocumentCompatible(endpoint)
|
||||
)),
|
||||
];
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureCompactResponsesJsonAcceptHeader,
|
||||
sanitizeCompactResponsesRequestBody,
|
||||
shouldForceResponsesUpstreamStream,
|
||||
} from './responsesCompact.js';
|
||||
|
||||
describe('sanitizeCompactResponsesRequestBody', () => {
|
||||
it('removes stream fields and store from codex compact requests', () => {
|
||||
expect(sanitizeCompactResponsesRequestBody({
|
||||
stream: true,
|
||||
stream_options: { include_obfuscation: true },
|
||||
store: false,
|
||||
input: 'hello',
|
||||
}, {
|
||||
sitePlatform: 'codex',
|
||||
})).toEqual({
|
||||
input: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes store from sub2api compact requests', () => {
|
||||
expect(sanitizeCompactResponsesRequestBody({
|
||||
stream: true,
|
||||
stream_options: { include_obfuscation: true },
|
||||
store: false,
|
||||
input: 'hello',
|
||||
}, {
|
||||
sitePlatform: 'sub2api',
|
||||
})).toEqual({
|
||||
input: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps store for unrelated compact platforms while still removing stream fields', () => {
|
||||
expect(sanitizeCompactResponsesRequestBody({
|
||||
stream: true,
|
||||
stream_options: { include_obfuscation: true },
|
||||
store: false,
|
||||
input: 'hello',
|
||||
}, {
|
||||
sitePlatform: 'openai',
|
||||
})).toEqual({
|
||||
store: false,
|
||||
input: 'hello',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldForceResponsesUpstreamStream', () => {
|
||||
it('forces non-compact responses streaming for codex and sub2api', () => {
|
||||
expect(shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: 'codex',
|
||||
isCompactRequest: false,
|
||||
})).toBe(true);
|
||||
expect(shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: 'sub2api',
|
||||
isCompactRequest: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('does not force compact or unrelated platforms', () => {
|
||||
expect(shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: 'codex',
|
||||
isCompactRequest: true,
|
||||
})).toBe(false);
|
||||
expect(shouldForceResponsesUpstreamStream({
|
||||
sitePlatform: 'openai',
|
||||
isCompactRequest: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCompactResponsesJsonAcceptHeader', () => {
|
||||
it('adds application/json accept for codex and sub2api compact requests when missing', () => {
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({}, {
|
||||
sitePlatform: 'codex',
|
||||
})).toEqual({
|
||||
accept: 'application/json',
|
||||
});
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({}, {
|
||||
sitePlatform: 'sub2api',
|
||||
})).toEqual({
|
||||
accept: 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('forces compact requests back to application/json even when callers asked for SSE', () => {
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({
|
||||
Accept: 'text/event-stream',
|
||||
}, {
|
||||
sitePlatform: 'codex',
|
||||
})).toEqual({
|
||||
accept: 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unrelated platforms', () => {
|
||||
expect(ensureCompactResponsesJsonAcceptHeader({}, {
|
||||
sitePlatform: 'openai',
|
||||
})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function shouldStripCompactResponsesStore(sitePlatform?: string): boolean {
|
||||
const normalized = asTrimmedString(sitePlatform).toLowerCase();
|
||||
return normalized === 'codex' || normalized === 'sub2api';
|
||||
}
|
||||
|
||||
function shouldForceCompactResponsesJsonAccept(sitePlatform?: string): boolean {
|
||||
return shouldStripCompactResponsesStore(sitePlatform);
|
||||
}
|
||||
|
||||
export function shouldForceResponsesUpstreamStream(input: {
|
||||
sitePlatform?: string;
|
||||
isCompactRequest?: boolean;
|
||||
}): boolean {
|
||||
if (input.isCompactRequest) return false;
|
||||
const sitePlatform = asTrimmedString(input.sitePlatform).toLowerCase();
|
||||
return sitePlatform === 'codex' || sitePlatform === 'sub2api';
|
||||
}
|
||||
|
||||
export function sanitizeCompactResponsesRequestBody(
|
||||
body: Record<string, unknown>,
|
||||
options?: {
|
||||
sitePlatform?: string;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
const next = { ...body };
|
||||
delete next.stream;
|
||||
delete next.stream_options;
|
||||
if (shouldStripCompactResponsesStore(options?.sitePlatform)) {
|
||||
delete next.store;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function ensureCompactResponsesJsonAcceptHeader(
|
||||
headers: Record<string, string>,
|
||||
options?: {
|
||||
sitePlatform?: string;
|
||||
},
|
||||
): Record<string, string> {
|
||||
if (!shouldForceCompactResponsesJsonAccept(options?.sitePlatform)) return headers;
|
||||
const nextHeaders = { ...headers };
|
||||
delete (nextHeaders as Record<string, unknown>).Accept;
|
||||
delete (nextHeaders as Record<string, unknown>).accept;
|
||||
return {
|
||||
...nextHeaders,
|
||||
accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldFallbackCompactResponsesToResponses(input: {
|
||||
status?: number;
|
||||
rawErrText?: string;
|
||||
requestPath?: string;
|
||||
}): boolean {
|
||||
const status = Number.isFinite(Number(input.status)) ? Number(input.status) : 0;
|
||||
const compact = asTrimmedString(input.rawErrText).toLowerCase();
|
||||
const requestPath = asTrimmedString(input.requestPath).toLowerCase();
|
||||
const hasRawCompactHint = (
|
||||
compact.includes('/responses/compact')
|
||||
|| compact.includes('responses/compact')
|
||||
|| compact.includes('compact endpoint')
|
||||
|| /(^|[^a-z])compact([^a-z]|$)/.test(compact)
|
||||
);
|
||||
const hasCompactRequestPathHint = (
|
||||
requestPath.endsWith('/responses/compact')
|
||||
|| requestPath.endsWith('/v1/responses/compact')
|
||||
);
|
||||
const isInvalidCompactUrl = compact.includes('invalid url') && hasRawCompactHint;
|
||||
|
||||
if (status === 404 || status === 405 || status === 501) return true;
|
||||
|
||||
return (
|
||||
(compact.includes("unknown parameter: 'stream'") && (hasRawCompactHint || hasCompactRequestPathHint))
|
||||
|| isInvalidCompactUrl
|
||||
|| (
|
||||
hasRawCompactHint
|
||||
&& (
|
||||
compact.includes('not supported')
|
||||
|| compact.includes('unsupported')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getTesterForcedChannelId,
|
||||
normalizeForcedChannelId,
|
||||
TESTER_FORCED_CHANNEL_HEADER,
|
||||
TESTER_REQUEST_HEADER,
|
||||
} from './channelSelection.js';
|
||||
|
||||
describe('normalizeForcedChannelId', () => {
|
||||
it('accepts positive integer ids and rejects fractional or unsafe values', () => {
|
||||
expect(normalizeForcedChannelId(77)).toBe(77);
|
||||
expect(normalizeForcedChannelId('78')).toBe(78);
|
||||
expect(normalizeForcedChannelId(77.9)).toBeNull();
|
||||
expect(normalizeForcedChannelId('78.5')).toBeNull();
|
||||
expect(normalizeForcedChannelId('9007199254740993')).toBeNull();
|
||||
expect(normalizeForcedChannelId(0)).toBeNull();
|
||||
expect(normalizeForcedChannelId(-1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTesterForcedChannelId', () => {
|
||||
it('ignores forged forced-channel headers without the trusted tester bridge marker', () => {
|
||||
expect(getTesterForcedChannelId({
|
||||
headers: {
|
||||
[TESTER_FORCED_CHANNEL_HEADER]: '77',
|
||||
},
|
||||
clientIp: '127.0.0.1',
|
||||
})).toBeNull();
|
||||
|
||||
expect(getTesterForcedChannelId({
|
||||
headers: {
|
||||
[TESTER_REQUEST_HEADER]: '1',
|
||||
[TESTER_FORCED_CHANNEL_HEADER]: '77',
|
||||
},
|
||||
clientIp: '203.0.113.10',
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the forced channel id only for loopback tester bridge traffic', () => {
|
||||
expect(getTesterForcedChannelId({
|
||||
headers: {
|
||||
[TESTER_REQUEST_HEADER]: '1',
|
||||
[TESTER_FORCED_CHANNEL_HEADER]: '77',
|
||||
},
|
||||
clientIp: '::1',
|
||||
})).toBe(77);
|
||||
|
||||
expect(getTesterForcedChannelId({
|
||||
headers: {
|
||||
[TESTER_REQUEST_HEADER]: '1',
|
||||
[TESTER_FORCED_CHANNEL_HEADER]: '78',
|
||||
},
|
||||
clientIp: '::ffff:127.0.0.1',
|
||||
})).toBe(78);
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import * as routeRefreshWorkflow from '../services/routeRefreshWorkflow.js';
|
||||
import { proxyChannelCoordinator } from '../services/proxyChannelCoordinator.js';
|
||||
import { canRetryProxyChannel } from '../services/proxyChannelRetry.js';
|
||||
import type { DownstreamRoutingPolicy } from '../services/downstreamPolicyTypes.js';
|
||||
import { tokenRouter } from '../services/tokenRouter.js';
|
||||
|
||||
type SelectedChannel = Awaited<ReturnType<typeof tokenRouter.selectChannel>>;
|
||||
|
||||
export const TESTER_FORCED_CHANNEL_HEADER = 'x-boosapi-tester-forced-channel-id';
|
||||
export const TESTER_REQUEST_HEADER = 'x-boosapi-tester-request';
|
||||
|
||||
function headerValueEquals(
|
||||
headers: Record<string, unknown> | undefined,
|
||||
expectedKey: string,
|
||||
expectedValue: string,
|
||||
): boolean {
|
||||
if (!headers) return false;
|
||||
const normalizedExpectedKey = expectedKey.trim().toLowerCase();
|
||||
const normalizedExpectedValue = expectedValue.trim().toLowerCase();
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== normalizedExpectedKey) continue;
|
||||
if (typeof rawValue === 'string' && rawValue.trim().toLowerCase() === normalizedExpectedValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLoopbackClientIp(value: string | null | undefined): boolean {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === '::1' || trimmed === '127.0.0.1') return true;
|
||||
if (trimmed.startsWith('::ffff:')) {
|
||||
return trimmed.slice('::ffff:'.length).trim() === '127.0.0.1';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function normalizeForcedChannelId(value: unknown): number | null {
|
||||
const numeric = typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && value.trim()
|
||||
? Number(value.trim())
|
||||
: NaN;
|
||||
if (!Number.isSafeInteger(numeric) || numeric <= 0) return null;
|
||||
return numeric;
|
||||
}
|
||||
|
||||
type TesterRequestInput = {
|
||||
headers?: Record<string, unknown>;
|
||||
clientIp?: string | null;
|
||||
};
|
||||
|
||||
export function isTrustedTesterRequest(input?: TesterRequestInput): boolean {
|
||||
if (!input) return false;
|
||||
if (!isLoopbackClientIp(input.clientIp)) return false;
|
||||
return headerValueEquals(input.headers, TESTER_REQUEST_HEADER, '1');
|
||||
}
|
||||
|
||||
export function getTesterForcedChannelId(input?: TesterRequestInput): number | null {
|
||||
if (!isTrustedTesterRequest(input)) return null;
|
||||
const headers = input?.headers;
|
||||
if (!headers) return null;
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== TESTER_FORCED_CHANNEL_HEADER) continue;
|
||||
return normalizeForcedChannelId(rawValue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildForcedChannelUnavailableMessage(forcedChannelId?: number | null): string {
|
||||
const normalizedForcedChannelId = normalizeForcedChannelId(forcedChannelId);
|
||||
if (normalizedForcedChannelId === null) {
|
||||
return 'No available channels for this model';
|
||||
}
|
||||
return `指定通道 #${normalizedForcedChannelId} 当前不可用,固定通道模式不会自动切换其他通道`;
|
||||
}
|
||||
|
||||
export function canRetryChannelSelection(retryCount: number, forcedChannelId?: number | null): boolean {
|
||||
if (normalizeForcedChannelId(forcedChannelId) !== null) return false;
|
||||
return canRetryProxyChannel(retryCount);
|
||||
}
|
||||
|
||||
export async function selectProxyChannelForAttempt(input: {
|
||||
requestedModel: string;
|
||||
downstreamPolicy: DownstreamRoutingPolicy;
|
||||
excludeChannelIds: number[];
|
||||
retryCount: number;
|
||||
stickySessionKey?: string | null;
|
||||
forcedChannelId?: number | null;
|
||||
}): Promise<SelectedChannel> {
|
||||
const normalizedForcedChannelId = normalizeForcedChannelId(input.forcedChannelId);
|
||||
if (normalizedForcedChannelId !== null) {
|
||||
if (input.retryCount > 0) return null;
|
||||
return await tokenRouter.selectPreferredChannel(
|
||||
input.requestedModel,
|
||||
normalizedForcedChannelId,
|
||||
input.downstreamPolicy,
|
||||
input.excludeChannelIds,
|
||||
);
|
||||
}
|
||||
|
||||
let selected: SelectedChannel = null;
|
||||
let refreshedRoutes = false;
|
||||
|
||||
const refreshRoutesForFirstAttempt = async (): Promise<boolean> => {
|
||||
if (input.retryCount > 0 || refreshedRoutes) return false;
|
||||
refreshedRoutes = true;
|
||||
try {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[proxy/surface] failed to refresh routes after empty selection', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (input.retryCount === 0 && input.stickySessionKey) {
|
||||
const preferredChannelId = proxyChannelCoordinator.getStickyChannelId(input.stickySessionKey);
|
||||
if (preferredChannelId && !input.excludeChannelIds.includes(preferredChannelId)) {
|
||||
selected = await tokenRouter.selectPreferredChannel(
|
||||
input.requestedModel,
|
||||
preferredChannelId,
|
||||
input.downstreamPolicy,
|
||||
input.excludeChannelIds,
|
||||
);
|
||||
if (!selected) {
|
||||
const refreshSucceeded = await refreshRoutesForFirstAttempt();
|
||||
selected = await tokenRouter.selectPreferredChannel(
|
||||
input.requestedModel,
|
||||
preferredChannelId,
|
||||
input.downstreamPolicy,
|
||||
input.excludeChannelIds,
|
||||
);
|
||||
if (!selected && refreshSucceeded) {
|
||||
proxyChannelCoordinator.clearStickyChannel(input.stickySessionKey, preferredChannelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
selected = input.retryCount === 0
|
||||
? await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(
|
||||
input.requestedModel,
|
||||
input.excludeChannelIds,
|
||||
input.downstreamPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
if (!selected && input.retryCount === 0 && !refreshedRoutes) {
|
||||
await refreshRoutesForFirstAttempt();
|
||||
selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { CliProfileDefinition } from './types.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const claudeCodeUserAgentPattern = /^claude-cli\/\d+\.\d+\.\d+/i;
|
||||
|
||||
function isClaudeSurface(path: string): boolean {
|
||||
const normalizedPath = path.trim().toLowerCase();
|
||||
return normalizedPath === '/v1/messages'
|
||||
|| normalizedPath === '/anthropic/v1/messages'
|
||||
|| normalizedPath === '/v1/messages/count_tokens';
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
|
||||
if (!headers) return null;
|
||||
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
|
||||
if (typeof rawValue === 'string') {
|
||||
const trimmed = rawValue.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (!Array.isArray(rawValue)) continue;
|
||||
for (const item of rawValue) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasClaudeCodeHeaderFingerprint(headers: Record<string, unknown> | undefined): boolean {
|
||||
const userAgent = getHeaderValue(headers, 'user-agent') || '';
|
||||
if (!claudeCodeUserAgentPattern.test(userAgent)) return false;
|
||||
if (!getHeaderValue(headers, 'anthropic-beta')) return false;
|
||||
if (!getHeaderValue(headers, 'anthropic-version')) return false;
|
||||
return (getHeaderValue(headers, 'x-app') || '').trim().toLowerCase() === 'cli';
|
||||
}
|
||||
|
||||
export function extractClaudeCodeSessionId(userId: string): string | null {
|
||||
const trimmed = userId.trim();
|
||||
if (!claudeCodeUserIdPattern.test(trimmed)) return null;
|
||||
|
||||
const sessionPrefix = '__session_';
|
||||
const sessionIndex = trimmed.lastIndexOf(sessionPrefix);
|
||||
if (sessionIndex === -1) return null;
|
||||
|
||||
const sessionId = trimmed.slice(sessionIndex + sessionPrefix.length).trim();
|
||||
return sessionId || null;
|
||||
}
|
||||
|
||||
export const claudeCodeCliProfile: CliProfileDefinition = {
|
||||
id: 'claude_code',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
detect(input) {
|
||||
if (!isClaudeSurface(input.downstreamPath)) return null;
|
||||
const userId = isRecord(input.body) && isRecord(input.body.metadata) && typeof input.body.metadata.user_id === 'string'
|
||||
? input.body.metadata.user_id.trim()
|
||||
: '';
|
||||
const sessionId = userId ? extractClaudeCodeSessionId(userId) : null;
|
||||
if (!sessionId && !hasClaudeCodeHeaderFingerprint(input.headers)) return null;
|
||||
|
||||
return {
|
||||
id: 'claude_code',
|
||||
...(sessionId ? { sessionId, traceHint: sessionId } : {}),
|
||||
clientAppId: 'claude_code',
|
||||
clientAppName: 'Claude Code',
|
||||
clientConfidence: 'exact',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { detectCodexOfficialClientApp } from './codexProfile.js';
|
||||
|
||||
describe('detectCodexOfficialClientApp', () => {
|
||||
it('returns null for missing or empty headers', () => {
|
||||
expect(detectCodexOfficialClientApp()).toBeNull();
|
||||
expect(detectCodexOfficialClientApp({})).toBeNull();
|
||||
});
|
||||
|
||||
it('detects official Codex clients from originator prefixes', () => {
|
||||
expect(detectCodexOfficialClientApp({
|
||||
originator: 'codex_exec',
|
||||
})).toEqual({
|
||||
clientAppId: 'codex_exec',
|
||||
clientAppName: 'Codex Exec',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects official Codex clients from user-agent prefixes', () => {
|
||||
expect(detectCodexOfficialClientApp({
|
||||
'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3',
|
||||
})).toEqual({
|
||||
clientAppId: 'codex_chatgpt_desktop',
|
||||
clientAppName: 'Codex Desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches headers case-insensitively and from header arrays', () => {
|
||||
expect(detectCodexOfficialClientApp({
|
||||
originator: 'CODEX_EXEC',
|
||||
})).toEqual({
|
||||
clientAppId: 'codex_exec',
|
||||
clientAppName: 'Codex Exec',
|
||||
});
|
||||
expect(detectCodexOfficialClientApp({
|
||||
'user-agent': 'Mozilla/5.0 CODEX_chatgpt_desktop/1.2',
|
||||
})).toEqual({
|
||||
clientAppId: 'codex_chatgpt_desktop',
|
||||
clientAppName: 'Codex Desktop',
|
||||
});
|
||||
expect(detectCodexOfficialClientApp({
|
||||
'user-agent': ['Mozilla/5.0', 'codex_chatgpt_desktop/1.2'],
|
||||
})).toEqual({
|
||||
clientAppId: 'codex_chatgpt_desktop',
|
||||
clientAppName: 'Codex Desktop',
|
||||
});
|
||||
expect(detectCodexOfficialClientApp({
|
||||
originator: ['ignored', 'other-client'],
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-official Codex clients', () => {
|
||||
expect(detectCodexOfficialClientApp({
|
||||
'user-agent': 'OpenClaw/1.0',
|
||||
})).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { CliProfileDefinition, DetectCliProfileInput } from './types.js';
|
||||
import {
|
||||
detectCodexOfficialClientApp as detectCodexOfficialClientAppFromHeaders,
|
||||
isCodexOfficialClientHeaders,
|
||||
} from '../../shared/codexClientFamily.js';
|
||||
|
||||
type CodexOfficialClientApp = {
|
||||
clientAppId: string;
|
||||
clientAppName: string;
|
||||
};
|
||||
|
||||
function headerValueToStrings(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const values: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) values.push(trimmed);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
return headerValueToStrings(value)[0] || null;
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
|
||||
return getHeaderValues(headers, targetKey)[0] || null;
|
||||
}
|
||||
|
||||
function getHeaderValues(headers: Record<string, unknown> | undefined, targetKey: string): string[] {
|
||||
if (!headers) return [];
|
||||
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||
const values: string[] = [];
|
||||
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
|
||||
values.push(...headerValueToStrings(rawValue));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function hasHeaderPrefix(headers: Record<string, unknown> | undefined, prefix: string): boolean {
|
||||
if (!headers) return false;
|
||||
const normalizedPrefix = prefix.trim().toLowerCase();
|
||||
return Object.entries(headers).some(([rawKey, rawValue]) => {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
return key.startsWith(normalizedPrefix) && !!headerValueToString(rawValue);
|
||||
});
|
||||
}
|
||||
|
||||
function isCodexPath(path: string): boolean {
|
||||
const normalizedPath = path.trim().toLowerCase();
|
||||
return normalizedPath === '/v1/responses'
|
||||
|| normalizedPath.startsWith('/v1/responses/')
|
||||
|| normalizedPath === '/v1/chat/completions';
|
||||
}
|
||||
|
||||
export function detectCodexOfficialClientApp(
|
||||
headers?: Record<string, unknown>,
|
||||
): CodexOfficialClientApp | null {
|
||||
const detected = detectCodexOfficialClientAppFromHeaders(headers);
|
||||
return detected
|
||||
? {
|
||||
clientAppId: detected.clientAppId,
|
||||
clientAppName: detected.clientAppName,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||
return isCodexRequest({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCodexSessionId(headers?: Record<string, unknown>): string | null {
|
||||
return getHeaderValue(headers, 'session_id')
|
||||
|| getHeaderValue(headers, 'session-id')
|
||||
|| getHeaderValue(headers, 'conversation_id')
|
||||
|| getHeaderValue(headers, 'conversation-id');
|
||||
}
|
||||
|
||||
export function isCodexRequest(input: DetectCliProfileInput): boolean {
|
||||
if (!isCodexPath(input.downstreamPath)) return false;
|
||||
const headers = input.headers;
|
||||
if (!headers) return false;
|
||||
|
||||
if (isCodexOfficialClientHeaders(headers)) return true;
|
||||
if (getHeaderValue(headers, 'openai-beta')) return true;
|
||||
if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
|
||||
if (getCodexSessionId(headers)) return true;
|
||||
if (getHeaderValue(headers, 'x-codex-turn-state')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const codexCliProfile: CliProfileDefinition = {
|
||||
id: 'codex',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: true,
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
detect(input) {
|
||||
if (!isCodexRequest(input)) return null;
|
||||
|
||||
const sessionId = getCodexSessionId(input.headers) || undefined;
|
||||
const clientApp = detectCodexOfficialClientApp(input.headers);
|
||||
return {
|
||||
id: 'codex',
|
||||
...(sessionId ? { sessionId, traceHint: sessionId } : {}),
|
||||
...(clientApp
|
||||
? {
|
||||
clientAppId: clientApp.clientAppId,
|
||||
clientAppName: clientApp.clientAppName,
|
||||
clientConfidence: 'exact' as const,
|
||||
}
|
||||
: {
|
||||
clientAppId: 'codex',
|
||||
clientAppName: 'Codex',
|
||||
clientConfidence: 'heuristic' as const,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { CliProfileDefinition } from './types.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isGeminiCliPath(path: string): boolean {
|
||||
const normalizedPath = path.trim().toLowerCase();
|
||||
return normalizedPath === '/v1internal:generatecontent'
|
||||
|| normalizedPath === '/v1internal:streamgeneratecontent'
|
||||
|| normalizedPath === '/v1internal:counttokens';
|
||||
}
|
||||
|
||||
function hasGeminiCliBodyShape(body: unknown): boolean {
|
||||
if (!isRecord(body)) return false;
|
||||
return typeof body.model === 'string'
|
||||
|| Array.isArray(body.contents)
|
||||
|| (isRecord(body.request) && (Array.isArray(body.request.contents) || typeof body.request.model === 'string'));
|
||||
}
|
||||
|
||||
export const geminiCliProfile: CliProfileDefinition = {
|
||||
id: 'gemini_cli',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
detect(input) {
|
||||
if (!isGeminiCliPath(input.downstreamPath)) return null;
|
||||
if (input.body !== undefined && !hasGeminiCliBodyShape(input.body)) return null;
|
||||
return {
|
||||
id: 'gemini_cli',
|
||||
clientAppId: 'gemini_cli',
|
||||
clientAppName: 'Gemini CLI',
|
||||
clientConfidence: 'exact',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { CliProfileDefinition } from './types.js';
|
||||
|
||||
export const genericCliProfile: CliProfileDefinition = {
|
||||
id: 'generic',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
detect() {
|
||||
return {
|
||||
id: 'generic',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectCliProfile } from './registry.js';
|
||||
|
||||
describe('detectCliProfile', () => {
|
||||
it('detects Codex responses requests and exposes Codex capability flags', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers: {
|
||||
originator: 'codex_cli_rs',
|
||||
Session_id: 'codex-session-123',
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'codex',
|
||||
sessionId: 'codex-session-123',
|
||||
traceHint: 'codex-session-123',
|
||||
clientAppId: 'codex_cli_rs',
|
||||
clientAppName: 'Codex CLI',
|
||||
clientConfidence: 'exact',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: true,
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats x-codex-turn-state as a Codex marker even when session_id is absent', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers: {
|
||||
'x-codex-turn-state': 'turn-state-123',
|
||||
},
|
||||
})).toMatchObject({
|
||||
id: 'codex',
|
||||
capabilities: {
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats conversation_id as a Codex continuation marker when session_id is absent', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers: {
|
||||
conversation_id: 'codex-conversation-123',
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'codex',
|
||||
sessionId: 'codex-conversation-123',
|
||||
traceHint: 'codex-conversation-123',
|
||||
clientAppId: 'codex',
|
||||
clientAppName: 'Codex',
|
||||
clientConfidence: 'heuristic',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: true,
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('detects broader Codex official-client headers from user-agent and originator prefixes', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers: {
|
||||
'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3',
|
||||
},
|
||||
})).toMatchObject({
|
||||
id: 'codex',
|
||||
capabilities: {
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers: {
|
||||
originator: 'codex_exec',
|
||||
},
|
||||
})).toMatchObject({
|
||||
id: 'codex',
|
||||
capabilities: {
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not classify non-responses siblings as Codex requests', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responsesfoo',
|
||||
headers: {
|
||||
'openai-beta': 'responses-2025-03-11',
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'generic',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/messages/count_tokens',
|
||||
body: {
|
||||
metadata: {
|
||||
user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'claude_code',
|
||||
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||
clientAppId: 'claude_code',
|
||||
clientAppName: 'Claude Code',
|
||||
clientConfidence: 'exact',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Claude Code messages requests from claude-cli headers even when metadata.user_id is unavailable', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/messages',
|
||||
headers: {
|
||||
'user-agent': 'claude-cli/2.1.63 (external, cli)',
|
||||
'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'x-app': 'cli',
|
||||
'x-stainless-lang': 'js',
|
||||
},
|
||||
body: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'claude_code',
|
||||
clientAppId: 'claude_code',
|
||||
clientAppName: 'Claude Code',
|
||||
clientConfidence: 'exact',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Gemini CLI internal routes and exposes Gemini CLI capability flags', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1internal:countTokens',
|
||||
body: {
|
||||
model: 'gpt-4.1',
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'gemini_cli',
|
||||
clientAppId: 'gemini_cli',
|
||||
clientAppName: 'Gemini CLI',
|
||||
clientConfidence: 'exact',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to generic for native Gemini routes', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1beta/models/gemini-2.5-flash:generateContent',
|
||||
body: {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'generic',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { claudeCodeCliProfile } from './claudeCodeProfile.js';
|
||||
import { codexCliProfile } from './codexProfile.js';
|
||||
import { geminiCliProfile } from './geminiCliProfile.js';
|
||||
import { genericCliProfile } from './genericProfile.js';
|
||||
import type { CliProfileDefinition, CliProfileId, DetectCliProfileInput, DetectedCliProfile } from './types.js';
|
||||
|
||||
const orderedProfiles: CliProfileDefinition[] = [
|
||||
claudeCodeCliProfile,
|
||||
codexCliProfile,
|
||||
geminiCliProfile,
|
||||
];
|
||||
|
||||
export const cliProfileRegistry: Record<CliProfileId, CliProfileDefinition> = {
|
||||
generic: genericCliProfile,
|
||||
codex: codexCliProfile,
|
||||
claude_code: claudeCodeCliProfile,
|
||||
gemini_cli: geminiCliProfile,
|
||||
};
|
||||
|
||||
export function getCliProfileDefinition(id: CliProfileId): CliProfileDefinition {
|
||||
return cliProfileRegistry[id];
|
||||
}
|
||||
|
||||
export function detectCliProfile(input: DetectCliProfileInput): DetectedCliProfile {
|
||||
for (const profile of orderedProfiles) {
|
||||
const detected = profile.detect(input);
|
||||
if (!detected) continue;
|
||||
return {
|
||||
...detected,
|
||||
capabilities: profile.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: genericCliProfile.id,
|
||||
capabilities: genericCliProfile.capabilities,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export type CliProfileId =
|
||||
| 'generic'
|
||||
| 'codex'
|
||||
| 'claude_code'
|
||||
| 'gemini_cli';
|
||||
|
||||
export type CliProfileCapabilities = {
|
||||
supportsResponsesCompact: boolean;
|
||||
supportsResponsesWebsocketIncremental: boolean;
|
||||
preservesContinuation: boolean;
|
||||
supportsCountTokens: boolean;
|
||||
echoesTurnState: boolean;
|
||||
};
|
||||
|
||||
export type CliProfileClientConfidence = 'exact' | 'heuristic';
|
||||
|
||||
export type DetectCliProfileInput = {
|
||||
downstreamPath: string;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export type DetectedCliProfile = {
|
||||
id: CliProfileId;
|
||||
sessionId?: string;
|
||||
traceHint?: string;
|
||||
clientAppId?: string;
|
||||
clientAppName?: string;
|
||||
clientConfidence?: CliProfileClientConfidence;
|
||||
capabilities: CliProfileCapabilities;
|
||||
};
|
||||
|
||||
export type CliProfileDefinition = {
|
||||
id: CliProfileId;
|
||||
capabilities: CliProfileCapabilities;
|
||||
detect(input: DetectCliProfileInput): Omit<DetectedCliProfile, 'capabilities'> | null;
|
||||
};
|
||||
@@ -1,241 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DefaultProxyConductor } from './DefaultProxyConductor.js';
|
||||
import { terminalStreamFailure } from './streamTermination.js';
|
||||
|
||||
const baseSelectedChannel = {
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
};
|
||||
|
||||
describe('DefaultProxyConductor', () => {
|
||||
it('returns the first selected channel when the first attempt succeeds', async () => {
|
||||
const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel);
|
||||
const selectNextChannel = vi.fn();
|
||||
const recordSuccess = vi.fn().mockResolvedValue(undefined);
|
||||
const recordFailure = vi.fn().mockResolvedValue(undefined);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel,
|
||||
selectNextChannel,
|
||||
recordSuccess,
|
||||
recordFailure,
|
||||
});
|
||||
const attempt = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
response: new Response('ok', { status: 200 }),
|
||||
latencyMs: 12,
|
||||
cost: 0.25,
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
selected: baseSelectedChannel,
|
||||
attempts: 1,
|
||||
});
|
||||
expect(selectChannel).toHaveBeenCalledWith('gpt-5.4', undefined);
|
||||
expect(selectNextChannel).not.toHaveBeenCalled();
|
||||
expect(recordFailure).not.toHaveBeenCalled();
|
||||
expect(recordSuccess).toHaveBeenCalledWith(11, {
|
||||
latencyMs: 12,
|
||||
cost: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it('retries on the same channel when the attempt asks for a same-channel retry', async () => {
|
||||
const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel);
|
||||
const selectNextChannel = vi.fn();
|
||||
const recordSuccess = vi.fn().mockResolvedValue(undefined);
|
||||
const recordFailure = vi.fn().mockResolvedValue(undefined);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel,
|
||||
selectNextChannel,
|
||||
recordSuccess,
|
||||
recordFailure,
|
||||
});
|
||||
const attempt = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
action: 'retry_same_channel',
|
||||
status: 429,
|
||||
rawErrorText: 'rate limited',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
response: new Response('ok', { status: 200 }),
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
attempts: 2,
|
||||
});
|
||||
expect(selectNextChannel).not.toHaveBeenCalled();
|
||||
expect(attempt).toHaveBeenCalledTimes(2);
|
||||
expect(recordFailure).toHaveBeenCalledWith(11, {
|
||||
status: 429,
|
||||
rawErrorText: 'rate limited',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails over to the next channel when the attempt asks for failover', async () => {
|
||||
const nextSelectedChannel = {
|
||||
...baseSelectedChannel,
|
||||
channel: { id: 12, routeId: 22 },
|
||||
tokenValue: 'sk-next',
|
||||
};
|
||||
const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel);
|
||||
const selectNextChannel = vi.fn().mockResolvedValue(nextSelectedChannel);
|
||||
const recordSuccess = vi.fn().mockResolvedValue(undefined);
|
||||
const recordFailure = vi.fn().mockResolvedValue(undefined);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel,
|
||||
selectNextChannel,
|
||||
recordSuccess,
|
||||
recordFailure,
|
||||
});
|
||||
const attempt = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
action: 'failover',
|
||||
status: 503,
|
||||
rawErrorText: 'upstream unavailable',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
response: new Response('ok', { status: 200 }),
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
selected: nextSelectedChannel,
|
||||
attempts: 2,
|
||||
});
|
||||
expect(selectNextChannel).toHaveBeenCalledWith('gpt-5.4', [11], undefined);
|
||||
expect(recordFailure).toHaveBeenCalledWith(11, {
|
||||
status: 503,
|
||||
rawErrorText: 'upstream unavailable',
|
||||
});
|
||||
expect(recordSuccess).toHaveBeenCalledWith(12, {
|
||||
latencyMs: null,
|
||||
cost: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes auth on 401 and retries the same channel with the refreshed selection', async () => {
|
||||
const refreshedChannel = {
|
||||
...baseSelectedChannel,
|
||||
tokenValue: 'sk-refreshed',
|
||||
};
|
||||
const refreshAuth = vi.fn().mockResolvedValue(refreshedChannel);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel: vi.fn().mockResolvedValue(baseSelectedChannel),
|
||||
selectNextChannel: vi.fn(),
|
||||
recordSuccess: vi.fn().mockResolvedValue(undefined),
|
||||
recordFailure: vi.fn().mockResolvedValue(undefined),
|
||||
refreshAuth,
|
||||
});
|
||||
const attempt = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
action: 'refresh_auth',
|
||||
status: 401,
|
||||
rawErrorText: 'expired token',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
response: new Response('ok', { status: 200 }),
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
selected: refreshedChannel,
|
||||
attempts: 2,
|
||||
});
|
||||
expect(refreshAuth).toHaveBeenCalledWith(baseSelectedChannel, {
|
||||
status: 401,
|
||||
rawErrorText: 'expired token',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a no_channel result when no channel is available', async () => {
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel: vi.fn().mockResolvedValue(null),
|
||||
selectNextChannel: vi.fn(),
|
||||
recordSuccess: vi.fn(),
|
||||
recordFailure: vi.fn(),
|
||||
previewSelectedChannel: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
expect(await conductor.previewSelectedChannel('gpt-5.4')).toBe(null);
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'no_channel',
|
||||
attempts: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates terminal stream failures and calls the terminal failure hook', async () => {
|
||||
const onTerminalFailure = vi.fn().mockResolvedValue(undefined);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel: vi.fn().mockResolvedValue(baseSelectedChannel),
|
||||
selectNextChannel: vi.fn(),
|
||||
recordSuccess: vi.fn(),
|
||||
recordFailure: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
const attempt = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
...terminalStreamFailure({
|
||||
status: 502,
|
||||
rawErrorText: 'stream disconnected before completion',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
onTerminalFailure,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'terminal',
|
||||
selected: baseSelectedChannel,
|
||||
status: 502,
|
||||
rawErrorText: 'stream disconnected before completion',
|
||||
attempts: 1,
|
||||
});
|
||||
expect(onTerminalFailure).toHaveBeenCalledWith(baseSelectedChannel, {
|
||||
status: 502,
|
||||
rawErrorText: 'stream disconnected before completion',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
failureActionOf,
|
||||
isTerminalFailure,
|
||||
shouldFailover,
|
||||
shouldRefreshAuth,
|
||||
shouldRetrySameChannel,
|
||||
} from './retryPolicy.js';
|
||||
import type { ExecuteInput, ExecuteResult, ProxyConductorDependencies, SelectedChannelLike } from './types.js';
|
||||
import { recordFailedAttempt, recordSuccessfulAttempt } from './usageHooks.js';
|
||||
|
||||
export class DefaultProxyConductor {
|
||||
constructor(private readonly deps: ProxyConductorDependencies) {}
|
||||
|
||||
async previewSelectedChannel(requestedModel: string, downstreamPolicy?: unknown): Promise<SelectedChannelLike | null> {
|
||||
if (this.deps.previewSelectedChannel) {
|
||||
return this.deps.previewSelectedChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
return this.deps.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<ExecuteResult> {
|
||||
const excludeChannelIds: number[] = [];
|
||||
let attempts = 0;
|
||||
let selected = await this.deps.selectChannel(input.requestedModel, input.downstreamPolicy);
|
||||
if (!selected) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no_channel',
|
||||
attempts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
while (selected) {
|
||||
const result = await input.attempt({
|
||||
selected,
|
||||
attemptIndex: attempts,
|
||||
excludeChannelIds: [...excludeChannelIds],
|
||||
});
|
||||
attempts += 1;
|
||||
|
||||
if (result.ok) {
|
||||
await recordSuccessfulAttempt(this.deps, selected.channel.id, {
|
||||
latencyMs: result.latencyMs ?? null,
|
||||
cost: result.cost ?? null,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
selected,
|
||||
response: result.response,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
const action = failureActionOf(result);
|
||||
await recordFailedAttempt(this.deps, selected.channel.id, {
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
});
|
||||
|
||||
if (isTerminalFailure(action)) {
|
||||
await input.onTerminalFailure?.(selected, {
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'terminal',
|
||||
selected,
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldRetrySameChannel(action)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldRefreshAuth(action) && this.deps.refreshAuth) {
|
||||
const refreshed = await this.deps.refreshAuth(selected, {
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
});
|
||||
if (refreshed) {
|
||||
selected = refreshed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFailover(action)) {
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
const next = await this.deps.selectNextChannel(
|
||||
input.requestedModel,
|
||||
excludeChannelIds,
|
||||
input.downstreamPolicy,
|
||||
);
|
||||
if (!next) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'failed',
|
||||
selected,
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
selected = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'failed',
|
||||
selected,
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'failed',
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { AttemptFailure, AttemptFailureAction } from './types.js';
|
||||
|
||||
export function failureActionOf(result: AttemptFailure): AttemptFailureAction {
|
||||
return result.action;
|
||||
}
|
||||
|
||||
export function shouldRetrySameChannel(action: AttemptFailureAction): boolean {
|
||||
return action === 'retry_same_channel';
|
||||
}
|
||||
|
||||
export function shouldRefreshAuth(action: AttemptFailureAction): boolean {
|
||||
return action === 'refresh_auth';
|
||||
}
|
||||
|
||||
export function shouldFailover(action: AttemptFailureAction): boolean {
|
||||
return action === 'failover' || action === 'refresh_auth';
|
||||
}
|
||||
|
||||
export function isTerminalFailure(action: AttemptFailureAction): boolean {
|
||||
return action === 'terminal';
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export function terminalStreamFailure(input: {
|
||||
status?: number;
|
||||
rawErrorText?: string;
|
||||
}) {
|
||||
return {
|
||||
action: 'terminal' as const,
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.rawErrorText ? { rawErrorText: input.rawErrorText } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
export type SelectedChannelLike = {
|
||||
channel: { id: number; routeId?: number };
|
||||
site: Record<string, unknown>;
|
||||
account: Record<string, unknown>;
|
||||
tokenName?: string;
|
||||
tokenValue?: string;
|
||||
actualModel?: string;
|
||||
};
|
||||
|
||||
export type AttemptSuccess = {
|
||||
ok: true;
|
||||
response: Response;
|
||||
latencyMs?: number | null;
|
||||
cost?: number | null;
|
||||
};
|
||||
|
||||
export type AttemptFailureAction =
|
||||
| 'retry_same_channel'
|
||||
| 'refresh_auth'
|
||||
| 'failover'
|
||||
| 'terminal'
|
||||
| 'stop';
|
||||
|
||||
export type AttemptFailure = {
|
||||
ok: false;
|
||||
action: AttemptFailureAction;
|
||||
status?: number;
|
||||
rawErrorText?: string;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type AttemptResult = AttemptSuccess | AttemptFailure;
|
||||
|
||||
export type ExecuteAttemptContext = {
|
||||
selected: SelectedChannelLike;
|
||||
attemptIndex: number;
|
||||
excludeChannelIds: number[];
|
||||
};
|
||||
|
||||
export type ProxyConductorDependencies = {
|
||||
selectChannel: (requestedModel: string, downstreamPolicy?: unknown) => Promise<SelectedChannelLike | null>;
|
||||
previewSelectedChannel?: (requestedModel: string, downstreamPolicy?: unknown) => Promise<SelectedChannelLike | null>;
|
||||
selectNextChannel: (
|
||||
requestedModel: string,
|
||||
excludeChannelIds: number[],
|
||||
downstreamPolicy?: unknown,
|
||||
) => Promise<SelectedChannelLike | null>;
|
||||
recordSuccess?: (channelId: number, metrics: { latencyMs: number | null; cost: number | null }) => Promise<void> | void;
|
||||
recordFailure?: (channelId: number, failure: { status?: number; rawErrorText?: string }) => Promise<void> | void;
|
||||
refreshAuth?: (
|
||||
selected: SelectedChannelLike,
|
||||
failure: { status?: number; rawErrorText?: string },
|
||||
) => Promise<SelectedChannelLike | null>;
|
||||
};
|
||||
|
||||
export type ExecuteInput = {
|
||||
requestedModel: string;
|
||||
downstreamPolicy?: unknown;
|
||||
attempt: (context: ExecuteAttemptContext) => Promise<AttemptResult>;
|
||||
onTerminalFailure?: (
|
||||
selected: SelectedChannelLike,
|
||||
failure: { status?: number; rawErrorText?: string },
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ExecuteResult =
|
||||
| {
|
||||
ok: true;
|
||||
selected: SelectedChannelLike;
|
||||
response: Response;
|
||||
attempts: number;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: 'no_channel' | 'failed' | 'terminal';
|
||||
selected?: SelectedChannelLike;
|
||||
status?: number;
|
||||
rawErrorText?: string;
|
||||
attempts: number;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ProxyConductorDependencies } from './types.js';
|
||||
|
||||
export async function recordSuccessfulAttempt(
|
||||
deps: ProxyConductorDependencies,
|
||||
channelId: number,
|
||||
metrics: { latencyMs?: number | null; cost?: number | null },
|
||||
): Promise<void> {
|
||||
await deps.recordSuccess?.(channelId, {
|
||||
latencyMs: metrics.latencyMs ?? null,
|
||||
cost: metrics.cost ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordFailedAttempt(
|
||||
deps: ProxyConductorDependencies,
|
||||
channelId: number,
|
||||
failure: { status?: number; rawErrorText?: string },
|
||||
): Promise<void> {
|
||||
await deps.recordFailure?.(channelId, failure);
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import { detectCliProfile } from './cliProfiles/registry.js';
|
||||
import type {
|
||||
CliProfileClientConfidence,
|
||||
CliProfileId,
|
||||
} from './cliProfiles/types.js';
|
||||
|
||||
export type DownstreamClientKind = CliProfileId;
|
||||
export type DownstreamClientConfidence = CliProfileClientConfidence;
|
||||
|
||||
export type DownstreamClientContext = {
|
||||
clientKind: DownstreamClientKind;
|
||||
sessionId?: string;
|
||||
traceHint?: string;
|
||||
clientAppId?: string;
|
||||
clientAppName?: string;
|
||||
clientConfidence?: DownstreamClientConfidence;
|
||||
};
|
||||
|
||||
type NormalizedClientHeaders = Record<string, string[]>;
|
||||
|
||||
type DownstreamClientBodySummary = {
|
||||
topLevelKeys: string[];
|
||||
metadataUserId: string | null;
|
||||
hasOpenCodeSystemPrompt: boolean;
|
||||
};
|
||||
|
||||
type DownstreamClientFingerprintInput = {
|
||||
downstreamPath: string;
|
||||
headers: NormalizedClientHeaders;
|
||||
bodySummary: DownstreamClientBodySummary;
|
||||
};
|
||||
|
||||
type DownstreamClientFingerprintRule = {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null;
|
||||
};
|
||||
|
||||
type DownstreamResolvedClientApp = {
|
||||
clientAppId: string;
|
||||
clientAppName: string;
|
||||
clientConfidence: DownstreamClientConfidence;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeHeaderValues(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers?: Record<string, unknown>): NormalizedClientHeaders {
|
||||
if (!headers) return {};
|
||||
|
||||
const normalized: NormalizedClientHeaders = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const values = normalizeHeaderValues(rawValue);
|
||||
if (values.length === 0) continue;
|
||||
normalized[key] = normalized[key]
|
||||
? [...normalized[key], ...values]
|
||||
: values;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function headerEquals(headers: NormalizedClientHeaders, key: string, expected: string): boolean {
|
||||
const normalizedExpected = expected.trim().toLowerCase();
|
||||
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase() === normalizedExpected);
|
||||
}
|
||||
|
||||
function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedFragment: string): boolean {
|
||||
const normalizedExpected = expectedFragment.trim().toLowerCase();
|
||||
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected));
|
||||
}
|
||||
|
||||
function normalizeClientDisplayName(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length <= 120 ? trimmed : trimmed.slice(0, 120).trim() || null;
|
||||
}
|
||||
|
||||
function normalizeClientAppId(value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function parseExplicitClientSelfReportValue(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (isRecord(parsed)) {
|
||||
for (const key of ['client', 'name', 'app']) {
|
||||
const raw = parsed[key];
|
||||
if (typeof raw !== 'string') continue;
|
||||
const normalized = normalizeClientDisplayName(raw);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return normalizeClientDisplayName(trimmed);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildBodySummary(body: unknown): DownstreamClientBodySummary {
|
||||
if (!isRecord(body)) {
|
||||
return {
|
||||
topLevelKeys: [],
|
||||
metadataUserId: null,
|
||||
hasOpenCodeSystemPrompt: false,
|
||||
};
|
||||
}
|
||||
|
||||
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
|
||||
? body.metadata.user_id.trim() || null
|
||||
: null;
|
||||
const systemPromptTexts = (
|
||||
typeof body.system === 'string'
|
||||
? [body.system]
|
||||
: Array.isArray(body.system)
|
||||
? body.system.map((entry) => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
if (!isRecord(entry)) return '';
|
||||
return typeof entry.text === 'string' ? entry.text : '';
|
||||
})
|
||||
: []
|
||||
).filter((entry) => entry.trim().length > 0);
|
||||
const normalizedSystemPrompt = systemPromptTexts
|
||||
.join('\n')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const hasOpenCodeSystemPrompt = normalizedSystemPrompt.includes('you are opencode, an interactive cli tool')
|
||||
|| normalizedSystemPrompt.includes('file called opencode.md');
|
||||
|
||||
return {
|
||||
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
|
||||
metadataUserId,
|
||||
hasOpenCodeSystemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
priority: 110,
|
||||
match(input) {
|
||||
const hasTitle = headerIncludes(input.headers, 'x-title', 'opencode');
|
||||
const hasReferer = headerIncludes(input.headers, 'http-referer', 'opencode.ai')
|
||||
|| headerIncludes(input.headers, 'referer', 'opencode.ai')
|
||||
|| headerIncludes(input.headers, 'origin', 'opencode.ai');
|
||||
const hasUserAgent = headerIncludes(input.headers, 'user-agent', 'opencode/');
|
||||
|
||||
if (hasTitle || hasReferer || hasUserAgent) {
|
||||
return 'exact';
|
||||
}
|
||||
|
||||
return input.bodySummary.hasOpenCodeSystemPrompt ? 'heuristic' : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cherry_studio',
|
||||
name: 'Cherry Studio',
|
||||
priority: 100,
|
||||
match(input) {
|
||||
const hasTitle = headerEquals(input.headers, 'x-title', 'Cherry Studio');
|
||||
const hasReferer = headerEquals(input.headers, 'http-referer', 'https://cherry-ai.com')
|
||||
|| headerEquals(input.headers, 'referer', 'https://cherry-ai.com');
|
||||
|
||||
if (hasTitle && hasReferer) {
|
||||
return 'exact';
|
||||
}
|
||||
|
||||
const weakSignals = [
|
||||
headerIncludes(input.headers, 'user-agent', 'cherrystudio'),
|
||||
headerIncludes(input.headers, 'x-title', 'cherry studio'),
|
||||
headerIncludes(input.headers, 'http-referer', 'cherry-ai.com'),
|
||||
headerIncludes(input.headers, 'referer', 'cherry-ai.com'),
|
||||
];
|
||||
|
||||
return weakSignals.some(Boolean) ? 'heuristic' : null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function detectDownstreamClientFingerprint(input: {
|
||||
downstreamPath: string;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
}) {
|
||||
const fingerprintInput: DownstreamClientFingerprintInput = {
|
||||
downstreamPath: input.downstreamPath,
|
||||
headers: normalizeHeaders(input.headers),
|
||||
bodySummary: buildBodySummary(input.body),
|
||||
};
|
||||
|
||||
let matchedRule: DownstreamClientFingerprintRule | null = null;
|
||||
let matchedConfidence: DownstreamClientConfidence | null = null;
|
||||
|
||||
for (const rule of appFingerprintRegistry) {
|
||||
const confidence = rule.match(fingerprintInput);
|
||||
if (!confidence) continue;
|
||||
if (!matchedRule || rule.priority > matchedRule.priority) {
|
||||
matchedRule = rule;
|
||||
matchedConfidence = confidence;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedRule || !matchedConfidence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clientAppId: matchedRule.id,
|
||||
clientAppName: matchedRule.name,
|
||||
clientConfidence: matchedConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): DownstreamResolvedClientApp | null {
|
||||
for (const value of headers['x-openai-client-user-agent'] || []) {
|
||||
const clientAppName = parseExplicitClientSelfReportValue(value);
|
||||
if (!clientAppName) continue;
|
||||
return {
|
||||
clientAppId: normalizeClientAppId(clientAppName) || 'self_reported_client',
|
||||
clientAppName,
|
||||
clientConfidence: 'exact',
|
||||
};
|
||||
}
|
||||
|
||||
for (const value of headers['user-agent'] || []) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized.startsWith('openclaw/')) continue;
|
||||
return {
|
||||
clientAppId: 'openclaw',
|
||||
clientAppName: 'OpenClaw',
|
||||
clientConfidence: 'exact',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectDownstreamClientContext(input: {
|
||||
downstreamPath: string;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
}): DownstreamClientContext {
|
||||
const detected = detectCliProfile(input);
|
||||
const normalizedHeaders = normalizeHeaders(input.headers);
|
||||
const explicitSelfReport = detectExplicitClientSelfReport(normalizedHeaders);
|
||||
const fingerprint = detectDownstreamClientFingerprint(input);
|
||||
const profileClientApp = fingerprint || explicitSelfReport
|
||||
? null
|
||||
: (
|
||||
detected.clientAppId && detected.clientAppName
|
||||
? {
|
||||
clientAppId: detected.clientAppId,
|
||||
clientAppName: detected.clientAppName,
|
||||
...(detected.clientConfidence ? { clientConfidence: detected.clientConfidence } : {}),
|
||||
}
|
||||
: null
|
||||
);
|
||||
return {
|
||||
clientKind: detected.id,
|
||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
||||
...(explicitSelfReport || fingerprint || profileClientApp || {}),
|
||||
};
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Response } from 'undici';
|
||||
import { geminiGenerateContentTransformer } from '../../transformers/gemini/generate-content/index.js';
|
||||
import type { RuntimeDispatchInput, RuntimeExecutor, RuntimeResponse } from './types.js';
|
||||
import {
|
||||
asTrimmedString,
|
||||
materializeErrorResponse,
|
||||
performFetch,
|
||||
readRuntimeResponseText,
|
||||
withRequestBody,
|
||||
} from './types.js';
|
||||
|
||||
const ANTIGRAVITY_RUNTIME_BASE_URLS = [
|
||||
'https://daily-cloudcode-pa.googleapis.com',
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
] as const;
|
||||
|
||||
function antigravityRequestType(modelName: string): 'image_gen' | 'agent' {
|
||||
return modelName.includes('image') ? 'image_gen' : 'agent';
|
||||
}
|
||||
|
||||
function generateAntigravityProjectId(): string {
|
||||
const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold'];
|
||||
const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge'];
|
||||
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful';
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal';
|
||||
const suffix = Math.floor(100000 + Math.random() * 900000);
|
||||
return `${adjective}-${noun}-${suffix}`;
|
||||
}
|
||||
|
||||
function extractFirstUserText(value: unknown): string {
|
||||
if (!Array.isArray(value)) return '';
|
||||
for (const content of value) {
|
||||
if (!content || typeof content !== 'object' || Array.isArray(content)) continue;
|
||||
const record = content as Record<string, unknown>;
|
||||
if (asTrimmedString(record.role) !== 'user') continue;
|
||||
const parts = Array.isArray(record.parts) ? record.parts : [];
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== 'object' || Array.isArray(part)) continue;
|
||||
const text = asTrimmedString((part as Record<string, unknown>).text);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function generateStableAntigravitySessionId(payload: Record<string, unknown>): string {
|
||||
const firstUserText = extractFirstUserText(
|
||||
payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request)
|
||||
? (payload.request as Record<string, unknown>).contents
|
||||
: undefined,
|
||||
);
|
||||
if (!firstUserText) {
|
||||
return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`;
|
||||
}
|
||||
const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16);
|
||||
const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff');
|
||||
return `-${bigint.toString()}`;
|
||||
}
|
||||
|
||||
function renameParametersJsonSchema(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => renameParametersJsonSchema(item));
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(input)) {
|
||||
const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key;
|
||||
output[nextKey] = renameParametersJsonSchema(entry);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function deleteNestedMaxOutputTokens(payload: Record<string, unknown>): void {
|
||||
const request = payload.request;
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) return;
|
||||
const generationConfig = (request as Record<string, unknown>).generationConfig;
|
||||
if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return;
|
||||
delete (generationConfig as Record<string, unknown>).maxOutputTokens;
|
||||
}
|
||||
|
||||
function buildAntigravityRuntimeBody(
|
||||
originalBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
action?: NonNullable<RuntimeDispatchInput['request']['runtime']>['action'],
|
||||
): Record<string, unknown> {
|
||||
const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record<string, unknown>;
|
||||
if (action === 'countTokens') {
|
||||
return payload;
|
||||
}
|
||||
const requestType = antigravityRequestType(modelName);
|
||||
const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId();
|
||||
|
||||
payload.model = modelName;
|
||||
payload.project = projectId;
|
||||
payload.userAgent = 'antigravity';
|
||||
payload.requestType = requestType;
|
||||
payload.requestId = requestType === 'image_gen'
|
||||
? `image_gen/${Date.now()}/${randomUUID()}/12`
|
||||
: `agent-${randomUUID()}`;
|
||||
|
||||
const request = payload.request;
|
||||
if (request && typeof request === 'object' && !Array.isArray(request)) {
|
||||
delete (request as Record<string, unknown>).safetySettings;
|
||||
if (requestType !== 'image_gen') {
|
||||
(request as Record<string, unknown>).sessionId = generateStableAntigravitySessionId(payload);
|
||||
}
|
||||
if (modelName.includes('claude')) {
|
||||
const toolConfig = (
|
||||
(request as Record<string, unknown>).toolConfig
|
||||
&& typeof (request as Record<string, unknown>).toolConfig === 'object'
|
||||
&& !Array.isArray((request as Record<string, unknown>).toolConfig)
|
||||
)
|
||||
? (request as Record<string, unknown>).toolConfig as Record<string, unknown>
|
||||
: (((request as Record<string, unknown>).toolConfig = {}) as Record<string, unknown>);
|
||||
toolConfig.functionCallingConfig = { mode: 'VALIDATED' };
|
||||
} else {
|
||||
deleteNestedMaxOutputTokens(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function antigravityShouldRetryNoCapacity(
|
||||
status: number,
|
||||
responseText: string,
|
||||
): boolean {
|
||||
return status === 503 && responseText.toLowerCase().includes('no capacity available');
|
||||
}
|
||||
|
||||
function antigravityNoCapacityRetryDelay(attempt: number): number {
|
||||
return Math.min((attempt + 1) * 250, 2000);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function unwrapAntigravityAggregatePayload(payload: unknown): unknown {
|
||||
if (!isRecord(payload)) return payload;
|
||||
return payload.response !== undefined ? payload.response : payload;
|
||||
}
|
||||
|
||||
function antigravityUsesStreamEndpoint(request: RuntimeDispatchInput['request']): boolean {
|
||||
return request.runtime?.action === 'streamGenerateContent'
|
||||
|| request.path.includes(':streamGenerateContent');
|
||||
}
|
||||
|
||||
function antigravityShouldAggregateStreamResponse(request: RuntimeDispatchInput['request']): boolean {
|
||||
return antigravityUsesStreamEndpoint(request) && request.runtime?.stream !== true;
|
||||
}
|
||||
|
||||
async function materializeAntigravitySuccessResponse(
|
||||
response: RuntimeResponse,
|
||||
aggregateStreamResponse: boolean,
|
||||
): Promise<RuntimeResponse> {
|
||||
if (!aggregateStreamResponse) return response;
|
||||
|
||||
const rawText = await readRuntimeResponseText(response);
|
||||
const contentType = response.headers.get('content-type');
|
||||
const parsed = geminiGenerateContentTransformer.stream.parseGeminiStreamPayload(rawText, contentType);
|
||||
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||
|
||||
for (const event of parsed.events) {
|
||||
geminiGenerateContentTransformer.stream.applyAggregate(
|
||||
aggregateState,
|
||||
unwrapAntigravityAggregatePayload(event),
|
||||
);
|
||||
}
|
||||
if (parsed.rest.trim()) {
|
||||
const trailingPayload = geminiGenerateContentTransformer.stream.parseGeminiStreamPayload(
|
||||
`${parsed.rest}\n\n`,
|
||||
'text/event-stream',
|
||||
);
|
||||
for (const event of trailingPayload.events) {
|
||||
geminiGenerateContentTransformer.stream.applyAggregate(
|
||||
aggregateState,
|
||||
unwrapAntigravityAggregatePayload(event),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('content-type', 'application/json; charset=utf-8');
|
||||
headers.delete('content-encoding');
|
||||
headers.delete('content-length');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(geminiGenerateContentTransformer.stream.serializeAggregateJsonPayload(aggregateState)),
|
||||
{
|
||||
status: response.status,
|
||||
headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function waitMs(ms: number): Promise<void> {
|
||||
if (ms <= 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const antigravityExecutor: RuntimeExecutor = {
|
||||
async dispatch(input: RuntimeDispatchInput) {
|
||||
const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
|
||||
const runtimeBody = buildAntigravityRuntimeBody(
|
||||
input.request.body,
|
||||
modelName,
|
||||
input.request.runtime?.action,
|
||||
);
|
||||
const baseAttempts = 3;
|
||||
const useStreamEndpoint = antigravityUsesStreamEndpoint(input.request);
|
||||
const aggregateStreamResponse = antigravityShouldAggregateStreamResponse(input.request);
|
||||
let lastResponse: RuntimeResponse | null = null;
|
||||
|
||||
attemptLoop:
|
||||
for (let attempt = 0; attempt < baseAttempts; attempt += 1) {
|
||||
for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) {
|
||||
const requestUrl = `${baseUrl}${input.request.path}`;
|
||||
const minimalHeaders: Record<string, string> = {
|
||||
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: useStreamEndpoint ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
};
|
||||
let response: RuntimeResponse;
|
||||
try {
|
||||
response = await performFetch(
|
||||
input,
|
||||
withRequestBody(input.request, runtimeBody, minimalHeaders),
|
||||
requestUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
|
||||
continue;
|
||||
}
|
||||
if (attempt + 1 < baseAttempts) {
|
||||
continue attemptLoop;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (response.ok) {
|
||||
return materializeAntigravitySuccessResponse(response, aggregateStreamResponse);
|
||||
}
|
||||
|
||||
const errorResponse = await materializeErrorResponse(response);
|
||||
const errorText = await errorResponse.text().catch(() => '');
|
||||
lastResponse = new Response(errorText, {
|
||||
status: errorResponse.status,
|
||||
headers: errorResponse.headers,
|
||||
});
|
||||
|
||||
if (errorResponse.status === 429) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) {
|
||||
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
|
||||
continue;
|
||||
}
|
||||
if (attempt + 1 < baseAttempts) {
|
||||
await waitMs(antigravityNoCapacityRetryDelay(attempt));
|
||||
continue attemptLoop;
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResponse) return lastResponse;
|
||||
|
||||
const fallbackResponse = await performFetch(input, withRequestBody(input.request, runtimeBody, {
|
||||
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: useStreamEndpoint ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
}));
|
||||
if (fallbackResponse.ok) {
|
||||
return materializeAntigravitySuccessResponse(fallbackResponse, aggregateStreamResponse);
|
||||
}
|
||||
return fallbackResponse;
|
||||
},
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { RuntimeDispatchInput, RuntimeExecutor } from './types.js';
|
||||
import { performFetch } from './types.js';
|
||||
|
||||
export const claudeExecutor: RuntimeExecutor = {
|
||||
async dispatch(input: RuntimeDispatchInput) {
|
||||
return performFetch(input, input.request);
|
||||
},
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { RuntimeDispatchInput, RuntimeExecutor } from './types.js';
|
||||
import { performFetch } from './types.js';
|
||||
|
||||
export const codexExecutor: RuntimeExecutor = {
|
||||
async dispatch(input: RuntimeDispatchInput) {
|
||||
return performFetch(input, input.request);
|
||||
},
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { RuntimeDispatchInput, RuntimeExecutor, RuntimeResponse } from './types.js';
|
||||
import {
|
||||
asTrimmedString,
|
||||
materializeErrorResponse,
|
||||
performFetch,
|
||||
withRequestBody,
|
||||
} from './types.js';
|
||||
|
||||
function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined {
|
||||
const raw = asTrimmedString(userAgent);
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `);
|
||||
}
|
||||
|
||||
function buildGeminiCliAttemptRequest(
|
||||
request: RuntimeDispatchInput['request'],
|
||||
modelName: string,
|
||||
) {
|
||||
const body = structuredClone(request.body);
|
||||
const action = request.runtime?.action;
|
||||
if (action === 'countTokens') {
|
||||
delete body.model;
|
||||
delete body.project;
|
||||
} else {
|
||||
body.model = modelName;
|
||||
}
|
||||
const headers = { ...request.headers };
|
||||
const nextUserAgent = replaceGeminiCliModelInUserAgent(
|
||||
headers['User-Agent'] || headers['user-agent'],
|
||||
modelName,
|
||||
);
|
||||
if (nextUserAgent) {
|
||||
headers['User-Agent'] = nextUserAgent;
|
||||
delete headers['user-agent'];
|
||||
}
|
||||
return withRequestBody(request, body, headers);
|
||||
}
|
||||
|
||||
export const geminiCliExecutor: RuntimeExecutor = {
|
||||
async dispatch(input: RuntimeDispatchInput) {
|
||||
const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
|
||||
const models = [baseModel].filter(Boolean);
|
||||
let lastResponse: RuntimeResponse | null = null;
|
||||
|
||||
for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) {
|
||||
const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName);
|
||||
const response = await performFetch(input, attemptRequest);
|
||||
if (response.ok) return response;
|
||||
|
||||
if (response.status === 429) {
|
||||
lastResponse = await materializeErrorResponse(response);
|
||||
continue;
|
||||
}
|
||||
|
||||
return materializeErrorResponse(response);
|
||||
}
|
||||
|
||||
return lastResponse || performFetch(input, input.request);
|
||||
},
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import { gzipSync, zstdCompressSync } from 'node:zlib';
|
||||
import { Response } from 'undici';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getRuntimeResponseReader, materializeErrorResponse, readRuntimeResponseText } from './types.js';
|
||||
|
||||
describe('readRuntimeResponseText', () => {
|
||||
it('decompresses zstd responses before reading the body text', async () => {
|
||||
const payload = JSON.stringify({ ok: true, text: 'hello zstd' });
|
||||
const response = new Response(zstdCompressSync(Buffer.from(payload)), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readRuntimeResponseText(response)).resolves.toBe(payload);
|
||||
});
|
||||
|
||||
it('decompresses stacked content-encodings in reverse order', async () => {
|
||||
const payload = JSON.stringify({ ok: true, text: 'stacked' });
|
||||
const response = new Response(
|
||||
zstdCompressSync(gzipSync(Buffer.from(payload))),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'gzip, zstd',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(readRuntimeResponseText(response)).resolves.toBe(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('materializeErrorResponse', () => {
|
||||
it('decodes compressed error bodies and strips compression headers', async () => {
|
||||
const payload = JSON.stringify({ error: { message: 'upstream failed' } });
|
||||
const response = new Response(zstdCompressSync(Buffer.from(payload)), {
|
||||
status: 503,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-length': '999',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
const materialized = await materializeErrorResponse(response);
|
||||
|
||||
await expect(materialized.text()).resolves.toBe(payload);
|
||||
expect(materialized.headers.get('content-encoding')).toBeNull();
|
||||
expect(materialized.headers.get('content-length')).toBeNull();
|
||||
expect(materialized.headers.get('content-type')).toBe('application/json; charset=utf-8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuntimeResponseReader', () => {
|
||||
it('falls back to the original stream when the body is already decompressed despite a zstd header', async () => {
|
||||
const payload = 'data: {"ok":true}\n\n';
|
||||
const response = new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
const reader = getRuntimeResponseReader(response);
|
||||
|
||||
expect(reader).toBeDefined();
|
||||
const firstChunk = await reader?.read();
|
||||
|
||||
expect(firstChunk?.done).toBe(false);
|
||||
expect(Buffer.from(firstChunk?.value ?? []).toString('utf8')).toBe(payload);
|
||||
});
|
||||
|
||||
it('decompresses stacked streaming encodings when zstd is not the outermost layer', async () => {
|
||||
const payload = 'data: {"ok":true,"kind":"stacked"}\n\n';
|
||||
const response = new Response(gzipSync(zstdCompressSync(Buffer.from(payload))), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd, gzip',
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
const reader = getRuntimeResponseReader(response);
|
||||
|
||||
expect(reader).toBeDefined();
|
||||
const firstChunk = await reader?.read();
|
||||
|
||||
expect(firstChunk?.done).toBe(false);
|
||||
expect(Buffer.from(firstChunk?.value ?? []).toString('utf8')).toBe(payload);
|
||||
});
|
||||
});
|
||||
@@ -1,323 +0,0 @@
|
||||
import {
|
||||
brotliDecompressSync,
|
||||
createBrotliDecompress,
|
||||
createGunzip,
|
||||
createInflate,
|
||||
createZstdDecompress,
|
||||
gunzipSync,
|
||||
inflateSync,
|
||||
zstdDecompressSync,
|
||||
} from 'node:zlib';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
|
||||
import {
|
||||
Response,
|
||||
fetch,
|
||||
type RequestInit as UndiciRequestInit,
|
||||
type Response as UndiciResponse,
|
||||
} from 'undici';
|
||||
|
||||
export type ProxyRuntimeRequest = {
|
||||
endpoint: 'chat' | 'messages' | 'responses';
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
runtime?: {
|
||||
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
|
||||
modelName?: string;
|
||||
stream?: boolean;
|
||||
oauthProjectId?: string | null;
|
||||
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
|
||||
};
|
||||
};
|
||||
|
||||
export type RuntimeDispatchInput = {
|
||||
siteUrl: string;
|
||||
request: ProxyRuntimeRequest;
|
||||
targetUrl?: string;
|
||||
signal?: AbortSignal;
|
||||
buildInit: (requestUrl: string, request: ProxyRuntimeRequest) => Promise<UndiciRequestInit> | UndiciRequestInit;
|
||||
};
|
||||
|
||||
export type RuntimeResponse = UndiciResponse;
|
||||
|
||||
export type RuntimeExecutor = {
|
||||
dispatch(input: RuntimeDispatchInput): Promise<RuntimeResponse>;
|
||||
};
|
||||
|
||||
export function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function withRequestBody(
|
||||
request: ProxyRuntimeRequest,
|
||||
body: Record<string, unknown>,
|
||||
headers?: Record<string, string>,
|
||||
): ProxyRuntimeRequest {
|
||||
return {
|
||||
...request,
|
||||
headers: headers ? { ...headers } : { ...request.headers },
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUpstreamUrl(siteUrl: string, path: string): string {
|
||||
const normalizedBase = siteUrl.replace(/\/+$/, '');
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export async function performFetch(
|
||||
input: RuntimeDispatchInput,
|
||||
request: ProxyRuntimeRequest,
|
||||
requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path),
|
||||
): Promise<RuntimeResponse> {
|
||||
const init = await input.buildInit(requestUrl, request);
|
||||
const combinedSignal = input.signal && init.signal
|
||||
? AbortSignal.any([input.signal, init.signal as AbortSignal])
|
||||
: (input.signal ?? init.signal);
|
||||
return fetch(requestUrl, {
|
||||
...init,
|
||||
signal: combinedSignal,
|
||||
});
|
||||
}
|
||||
|
||||
function hasZstdContentEncoding(contentEncoding: string | null): boolean {
|
||||
return getContentEncodings(contentEncoding).some((encoding) => encoding === 'zstd');
|
||||
}
|
||||
|
||||
function getContentEncodings(contentEncoding: string | null): string[] {
|
||||
if (!contentEncoding) return [];
|
||||
return contentEncoding
|
||||
.split(',')
|
||||
.map((encoding) => encoding.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getOutermostContentEncoding(contentEncoding: string | null): string | null {
|
||||
const encodings = getContentEncodings(contentEncoding);
|
||||
return encodings.length > 0 ? encodings[encodings.length - 1] : null;
|
||||
}
|
||||
|
||||
function looksLikeZstdFrame(buffer: Buffer): boolean {
|
||||
return buffer.length >= 4
|
||||
&& buffer[0] === 0x28
|
||||
&& buffer[1] === 0xb5
|
||||
&& buffer[2] === 0x2f
|
||||
&& buffer[3] === 0xfd;
|
||||
}
|
||||
|
||||
function decodeRuntimeResponseBuffer(buffer: Buffer, contentEncoding: string | null): Buffer {
|
||||
if (!contentEncoding) return buffer;
|
||||
|
||||
let decoded = buffer;
|
||||
const encodings = getContentEncodings(contentEncoding).reverse();
|
||||
|
||||
for (const encoding of encodings) {
|
||||
if (encoding === 'zstd') {
|
||||
decoded = zstdDecompressSync(decoded);
|
||||
continue;
|
||||
}
|
||||
if (encoding === 'br') {
|
||||
decoded = brotliDecompressSync(decoded);
|
||||
continue;
|
||||
}
|
||||
if (encoding === 'gzip' || encoding === 'x-gzip') {
|
||||
decoded = gunzipSync(decoded);
|
||||
continue;
|
||||
}
|
||||
if (encoding === 'deflate') {
|
||||
decoded = inflateSync(decoded);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function decodeRuntimeResponseStream(
|
||||
stream: Readable,
|
||||
contentEncoding: string | null,
|
||||
): Readable {
|
||||
if (!contentEncoding) return stream;
|
||||
|
||||
let decoded = stream;
|
||||
const encodings = getContentEncodings(contentEncoding).reverse();
|
||||
|
||||
for (const encoding of encodings) {
|
||||
if (encoding === 'zstd') {
|
||||
decoded = decoded.pipe(createZstdDecompress()) as Readable;
|
||||
continue;
|
||||
}
|
||||
if (encoding === 'br') {
|
||||
decoded = decoded.pipe(createBrotliDecompress()) as Readable;
|
||||
continue;
|
||||
}
|
||||
if (encoding === 'gzip' || encoding === 'x-gzip') {
|
||||
decoded = decoded.pipe(createGunzip()) as Readable;
|
||||
continue;
|
||||
}
|
||||
if (encoding === 'deflate') {
|
||||
decoded = decoded.pipe(createInflate()) as Readable;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export async function readRuntimeResponseText(
|
||||
response: RuntimeResponse,
|
||||
): Promise<string> {
|
||||
const contentEncoding = typeof response.headers?.get === 'function'
|
||||
? response.headers.get('content-encoding')
|
||||
: null;
|
||||
if (!hasZstdContentEncoding(contentEncoding)) {
|
||||
return typeof response.text === 'function'
|
||||
? response.text().catch(() => '')
|
||||
: '';
|
||||
}
|
||||
|
||||
const rawBuffer = Buffer.from(await response.arrayBuffer());
|
||||
try {
|
||||
return decodeRuntimeResponseBuffer(rawBuffer, contentEncoding).toString('utf8');
|
||||
} catch {
|
||||
return looksLikeZstdFrame(rawBuffer) ? '' : rawBuffer.toString('utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function asNodeReadableStream(
|
||||
stream: globalThis.ReadableStream<Uint8Array>,
|
||||
): NodeReadableStream<any> {
|
||||
return stream as unknown as NodeReadableStream<any>;
|
||||
}
|
||||
|
||||
function asWebReadableStream(
|
||||
stream: NodeReadableStream<any>,
|
||||
): globalThis.ReadableStream<Uint8Array> {
|
||||
return stream as unknown as globalThis.ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
function prependReadableStreamChunks(
|
||||
initialChunks: Uint8Array[],
|
||||
sourceReader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): globalThis.ReadableStream<Uint8Array> {
|
||||
const pendingChunks = [...initialChunks];
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
const nextPendingChunk = pendingChunks.shift();
|
||||
if (nextPendingChunk) {
|
||||
controller.enqueue(nextPendingChunk);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChunk = await sourceReader.read();
|
||||
if (nextChunk.done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(nextChunk.value);
|
||||
},
|
||||
cancel(reason) {
|
||||
return sourceReader.cancel(reason);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRuntimeResponseReader(
|
||||
sourceReader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
contentEncoding: string | null,
|
||||
): Promise<ReadableStreamDefaultReader<Uint8Array>> {
|
||||
const initialChunks: Uint8Array[] = [];
|
||||
let probeBuffer = Buffer.alloc(0);
|
||||
|
||||
while (probeBuffer.length < 4) {
|
||||
const nextChunk = await sourceReader.read();
|
||||
if (nextChunk.done) {
|
||||
break;
|
||||
}
|
||||
if (!nextChunk.value || nextChunk.value.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
initialChunks.push(nextChunk.value);
|
||||
probeBuffer = Buffer.concat([probeBuffer, Buffer.from(nextChunk.value)]);
|
||||
}
|
||||
|
||||
if (initialChunks.length === 0) {
|
||||
return sourceReader;
|
||||
}
|
||||
|
||||
const reconstructedBody = prependReadableStreamChunks(initialChunks, sourceReader);
|
||||
const outermostEncoding = getOutermostContentEncoding(contentEncoding);
|
||||
if (outermostEncoding === 'zstd' && !looksLikeZstdFrame(probeBuffer)) {
|
||||
return reconstructedBody.getReader();
|
||||
}
|
||||
|
||||
const decoded = decodeRuntimeResponseStream(
|
||||
Readable.fromWeb(asNodeReadableStream(reconstructedBody)),
|
||||
contentEncoding,
|
||||
);
|
||||
return asWebReadableStream(Readable.toWeb(decoded)).getReader();
|
||||
}
|
||||
|
||||
export function getRuntimeResponseReader(
|
||||
response: RuntimeResponse,
|
||||
): ReadableStreamDefaultReader<Uint8Array> | undefined {
|
||||
const body = response.body as globalThis.ReadableStream<Uint8Array> | null | undefined;
|
||||
if (!body) return undefined;
|
||||
|
||||
const contentEncoding = typeof response.headers?.get === 'function'
|
||||
? response.headers.get('content-encoding')
|
||||
: null;
|
||||
if (!hasZstdContentEncoding(contentEncoding)) {
|
||||
return body.getReader();
|
||||
}
|
||||
|
||||
const sourceReader = body.getReader();
|
||||
let resolvedReaderPromise: Promise<ReadableStreamDefaultReader<Uint8Array>> | null = null;
|
||||
const ensureResolvedReader = () => {
|
||||
if (!resolvedReaderPromise) {
|
||||
resolvedReaderPromise = resolveRuntimeResponseReader(sourceReader, contentEncoding);
|
||||
}
|
||||
return resolvedReaderPromise;
|
||||
};
|
||||
|
||||
// Keep the public API synchronous while delaying the zstd probe until the first read.
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
const reader = await ensureResolvedReader();
|
||||
const nextChunk = await reader.read();
|
||||
if (nextChunk.done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(nextChunk.value);
|
||||
},
|
||||
async cancel(reason) {
|
||||
if (!resolvedReaderPromise) {
|
||||
await sourceReader.cancel(reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = await resolvedReaderPromise.catch(() => sourceReader);
|
||||
await reader.cancel(reason);
|
||||
},
|
||||
}).getReader();
|
||||
}
|
||||
|
||||
export async function materializeErrorResponse(
|
||||
response: RuntimeResponse,
|
||||
): Promise<RuntimeResponse> {
|
||||
if (response.ok) return response;
|
||||
const text = await readRuntimeResponseText(response);
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete('content-encoding');
|
||||
headers.delete('content-length');
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
fetchWithObservedFirstByte,
|
||||
getObservedResponseMeta,
|
||||
isObservedFirstByteTimeoutResponse,
|
||||
} from './firstByteTimeout.js';
|
||||
|
||||
function buildDelayedResponse(bodyText: string, delayMs: number, status = 200): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
setTimeout(() => {
|
||||
controller.enqueue(encoder.encode(bodyText));
|
||||
controller.close();
|
||||
}, delayMs);
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('fetchWithObservedFirstByte', () => {
|
||||
it('replays the first chunk and records first-byte latency when upstream responds in time', async () => {
|
||||
const response = await fetchWithObservedFirstByte(
|
||||
async () => buildDelayedResponse('hello world', 5),
|
||||
{
|
||||
firstByteTimeoutMs: 100,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('hello world');
|
||||
|
||||
const meta = getObservedResponseMeta(response);
|
||||
expect(meta?.timedOutBeforeFirstByte).toBe(false);
|
||||
expect(meta?.firstByteLatencyMs).not.toBeNull();
|
||||
expect((meta?.firstByteLatencyMs || 0)).toBeGreaterThanOrEqual(0);
|
||||
expect((meta?.firstByteLatencyMs || 0)).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('returns a synthetic timeout response when upstream sends no first byte before the deadline', async () => {
|
||||
const response = await fetchWithObservedFirstByte(
|
||||
async () => buildDelayedResponse('too late', 80),
|
||||
{
|
||||
firstByteTimeoutMs: 10,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(408);
|
||||
expect(await response.text()).toContain('first byte timeout');
|
||||
expect(isObservedFirstByteTimeoutResponse(response)).toBe(true);
|
||||
|
||||
const meta = getObservedResponseMeta(response);
|
||||
expect(meta?.timedOutBeforeFirstByte).toBe(true);
|
||||
expect(meta?.firstByteLatencyMs).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Headers, Response } from 'undici';
|
||||
|
||||
type ObservedResponseMeta = {
|
||||
firstByteLatencyMs: number | null;
|
||||
timedOutBeforeFirstByte: boolean;
|
||||
};
|
||||
|
||||
const observedResponseMeta = new WeakMap<Response, ObservedResponseMeta>();
|
||||
|
||||
function setObservedResponseMeta<T extends Response>(response: T, meta: ObservedResponseMeta): T {
|
||||
observedResponseMeta.set(response, meta);
|
||||
return response;
|
||||
}
|
||||
|
||||
function clearTimer(timer: ReturnType<typeof setTimeout> | null) {
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
function buildFirstByteTimeoutMessage(timeoutMs: number): string {
|
||||
const seconds = Math.max(1, Math.round(timeoutMs / 1000));
|
||||
return `first byte timeout (${seconds}s)`;
|
||||
}
|
||||
|
||||
function buildObservedTimeoutResponse(timeoutMs: number): Response {
|
||||
return setObservedResponseMeta(new Response(buildFirstByteTimeoutMessage(timeoutMs), {
|
||||
status: 408,
|
||||
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
||||
}), {
|
||||
firstByteLatencyMs: null,
|
||||
timedOutBeforeFirstByte: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelReaderQuietly(reader: ReadableStreamDefaultReader<Uint8Array> | null) {
|
||||
if (!reader) return;
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// Ignore cancellation errors from already-closed streams.
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Ignore release errors from already-released readers.
|
||||
}
|
||||
}
|
||||
|
||||
function buildReplayResponse<T extends Response>(
|
||||
response: T,
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
firstChunk: ReadableStreamReadResult<Uint8Array>,
|
||||
): T {
|
||||
let firstChunkDelivered = false;
|
||||
let readerReleased = false;
|
||||
const releaseReader = () => {
|
||||
if (readerReleased) return;
|
||||
readerReleased = true;
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Ignore release failures from cancelled/closed readers.
|
||||
}
|
||||
};
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (!firstChunkDelivered) {
|
||||
firstChunkDelivered = true;
|
||||
if (firstChunk.done) {
|
||||
controller.close();
|
||||
releaseReader();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(firstChunk.value);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
controller.close();
|
||||
releaseReader();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(next.value);
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
releaseReader();
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
try {
|
||||
await reader.cancel(reason);
|
||||
} catch {
|
||||
// Ignore cancellation failures from already-closed streams.
|
||||
}
|
||||
releaseReader();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: new Headers(response.headers),
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export async function fetchWithObservedFirstByte<T extends Response>(
|
||||
dispatch: (signal?: AbortSignal) => Promise<T>,
|
||||
options: {
|
||||
firstByteTimeoutMs?: number;
|
||||
startedAtMs?: number;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const timeoutMs = Math.max(0, Math.trunc(options.firstByteTimeoutMs ?? 0));
|
||||
const startedAtMs = options.startedAtMs ?? Date.now();
|
||||
const controller = timeoutMs > 0 ? new AbortController() : null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let timedOutBeforeFirstByte = false;
|
||||
const timeoutSentinel = Symbol('first-byte-timeout');
|
||||
const timeoutPromise = timeoutMs > 0
|
||||
? new Promise<typeof timeoutSentinel>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
timedOutBeforeFirstByte = true;
|
||||
controller?.abort(new Error(buildFirstByteTimeoutMessage(timeoutMs)));
|
||||
resolve(timeoutSentinel);
|
||||
}, timeoutMs);
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
const dispatched = timeoutPromise
|
||||
? await Promise.race([dispatch(controller?.signal), timeoutPromise])
|
||||
: await dispatch(controller?.signal);
|
||||
if (dispatched === timeoutSentinel) {
|
||||
return buildObservedTimeoutResponse(timeoutMs) as T;
|
||||
}
|
||||
const response = dispatched as T;
|
||||
if (!response.body) {
|
||||
clearTimer(timer);
|
||||
return setObservedResponseMeta(response, {
|
||||
firstByteLatencyMs: Math.max(0, Date.now() - startedAtMs),
|
||||
timedOutBeforeFirstByte: false,
|
||||
});
|
||||
}
|
||||
|
||||
const reader = response.body.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
const firstChunk = timeoutPromise
|
||||
? await Promise.race([reader.read(), timeoutPromise])
|
||||
: await reader.read();
|
||||
if (firstChunk === timeoutSentinel) {
|
||||
await cancelReaderQuietly(reader);
|
||||
return buildObservedTimeoutResponse(timeoutMs) as T;
|
||||
}
|
||||
clearTimer(timer);
|
||||
return setObservedResponseMeta(buildReplayResponse(response, reader, firstChunk), {
|
||||
firstByteLatencyMs: Math.max(0, Date.now() - startedAtMs),
|
||||
timedOutBeforeFirstByte: false,
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimer(timer);
|
||||
if (timedOutBeforeFirstByte && timeoutMs > 0) {
|
||||
return buildObservedTimeoutResponse(timeoutMs) as T;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimer(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function getObservedResponseMeta(response: Response | null | undefined): ObservedResponseMeta | null {
|
||||
if (!response) return null;
|
||||
return observedResponseMeta.get(response) ?? null;
|
||||
}
|
||||
|
||||
export function isObservedFirstByteTimeoutResponse(response: Response | null | undefined): boolean {
|
||||
return getObservedResponseMeta(response)?.timedOutBeforeFirstByte === true;
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { fetch } from 'undici';
|
||||
import { readRuntimeResponseText } from '../executors/types.js';
|
||||
import { fetchWithObservedFirstByte, isObservedFirstByteTimeoutResponse } from '../firstByteTimeout.js';
|
||||
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import {
|
||||
buildUpstreamUrl,
|
||||
summarizeUpstreamError,
|
||||
type UpstreamEndpoint,
|
||||
} from './upstreamRequest.js';
|
||||
|
||||
export type BuiltEndpointRequest = {
|
||||
endpoint: UpstreamEndpoint;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
runtime?: {
|
||||
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
|
||||
modelName?: string;
|
||||
stream?: boolean;
|
||||
oauthProjectId?: string | null;
|
||||
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
|
||||
};
|
||||
};
|
||||
|
||||
export type EndpointAttemptContext = {
|
||||
endpointIndex: number;
|
||||
endpointCount: number;
|
||||
request: BuiltEndpointRequest;
|
||||
targetUrl: string;
|
||||
response: Awaited<ReturnType<typeof fetch>>;
|
||||
rawErrText: string;
|
||||
recoverApplied?: boolean;
|
||||
};
|
||||
|
||||
export type EndpointAttemptSuccessContext = {
|
||||
endpointIndex: number;
|
||||
endpointCount: number;
|
||||
request: BuiltEndpointRequest;
|
||||
targetUrl: string;
|
||||
response: Awaited<ReturnType<typeof fetch>>;
|
||||
recoverApplied?: boolean;
|
||||
};
|
||||
|
||||
export type EndpointRecoverResult = {
|
||||
upstream: Awaited<ReturnType<typeof fetch>>;
|
||||
upstreamPath: string;
|
||||
request?: BuiltEndpointRequest;
|
||||
targetUrl?: string;
|
||||
} | null;
|
||||
|
||||
export type EndpointFlowResult =
|
||||
| {
|
||||
ok: true;
|
||||
upstream: Awaited<ReturnType<typeof fetch>>;
|
||||
upstreamPath: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
status: number;
|
||||
errText: string;
|
||||
rawErrText?: string;
|
||||
};
|
||||
|
||||
export type ExecuteEndpointFlowInput = {
|
||||
siteUrl: string;
|
||||
proxyUrl?: string | null;
|
||||
disableCrossProtocolFallback?: boolean;
|
||||
endpointCandidates: UpstreamEndpoint[];
|
||||
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
||||
dispatchRequest?: (
|
||||
request: BuiltEndpointRequest,
|
||||
targetUrl: string,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Awaited<ReturnType<typeof fetch>>>;
|
||||
firstByteTimeoutMs?: number;
|
||||
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
|
||||
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
|
||||
shouldAbortRemainingEndpoints?: (ctx: EndpointAttemptContext & { errText: string }) => boolean;
|
||||
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
||||
onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
||||
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function withUpstreamPath(path: string, message: string): string {
|
||||
return `[upstream:${path}] ${message}`;
|
||||
}
|
||||
|
||||
async function runEndpointFlowHook<T>(
|
||||
hook: ((ctx: T) => void | Promise<void>) | undefined,
|
||||
ctx: T,
|
||||
hookName: string,
|
||||
): Promise<void> {
|
||||
if (!hook) return;
|
||||
try {
|
||||
await hook(ctx);
|
||||
} catch (error) {
|
||||
console.error(`endpointFlow ${hookName} hook failed`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
|
||||
const endpointCount = input.endpointCandidates.length;
|
||||
if (endpointCount <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
errText: 'Upstream request failed',
|
||||
};
|
||||
}
|
||||
|
||||
let finalStatus = 0;
|
||||
let finalErrText = 'unknown error';
|
||||
let finalRawErrText: string | undefined;
|
||||
|
||||
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
|
||||
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
|
||||
const request = input.buildRequest(endpoint, endpointIndex);
|
||||
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
|
||||
const targetUrl = input.proxyUrl
|
||||
? buildUpstreamUrl(input.proxyUrl, request.path)
|
||||
: defaultTarget;
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
let response = await fetchWithObservedFirstByte(
|
||||
async (signal) => (
|
||||
input.dispatchRequest
|
||||
? await input.dispatchRequest(request, targetUrl, signal)
|
||||
: await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(request.body),
|
||||
signal,
|
||||
}))
|
||||
),
|
||||
{
|
||||
firstByteTimeoutMs: input.firstByteTimeoutMs,
|
||||
startedAtMs: attemptStartedAtMs,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
await runEndpointFlowHook(input.onAttemptSuccess, {
|
||||
endpointIndex,
|
||||
endpointCount,
|
||||
request,
|
||||
targetUrl,
|
||||
response,
|
||||
recoverApplied: false,
|
||||
}, 'onAttemptSuccess');
|
||||
return {
|
||||
ok: true,
|
||||
upstream: response,
|
||||
upstreamPath: request.path,
|
||||
};
|
||||
}
|
||||
|
||||
let rawErrText = await readRuntimeResponseText(response).catch(() => 'unknown error');
|
||||
const baseContext: EndpointAttemptContext = {
|
||||
endpointIndex,
|
||||
endpointCount,
|
||||
request,
|
||||
targetUrl,
|
||||
response,
|
||||
rawErrText,
|
||||
recoverApplied: false,
|
||||
};
|
||||
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
||||
|
||||
if (isObservedFirstByteTimeoutResponse(response) && !isLastEndpoint) {
|
||||
const errText = rawErrText.trim() || 'first byte timeout';
|
||||
const timeoutContext = {
|
||||
...baseContext,
|
||||
errText,
|
||||
};
|
||||
await runEndpointFlowHook(input.onAttemptFailure, timeoutContext, 'onAttemptFailure');
|
||||
finalStatus = response.status || 408;
|
||||
finalErrText = errText;
|
||||
finalRawErrText = rawErrText;
|
||||
if (input.disableCrossProtocolFallback) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.tryRecover) {
|
||||
const recovered = await input.tryRecover(baseContext);
|
||||
baseContext.recoverApplied = recovered !== null
|
||||
|| baseContext.request !== request
|
||||
|| baseContext.response !== response
|
||||
|| baseContext.rawErrText !== rawErrText;
|
||||
if (recovered?.upstream?.ok) {
|
||||
const recoveredRequest = recovered.request ?? baseContext.request;
|
||||
const recoveredTargetUrl = recovered.targetUrl ?? (
|
||||
input.proxyUrl
|
||||
? buildUpstreamUrl(input.proxyUrl, recovered.upstreamPath)
|
||||
: buildUpstreamUrl(input.siteUrl, recovered.upstreamPath)
|
||||
);
|
||||
await runEndpointFlowHook(input.onAttemptSuccess, {
|
||||
endpointIndex,
|
||||
endpointCount,
|
||||
request: recoveredRequest,
|
||||
targetUrl: recoveredTargetUrl,
|
||||
response: recovered.upstream,
|
||||
recoverApplied: true,
|
||||
}, 'onAttemptSuccess');
|
||||
return {
|
||||
ok: true,
|
||||
upstream: recovered.upstream,
|
||||
upstreamPath: recovered.upstreamPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
rawErrText = baseContext.rawErrText;
|
||||
response = baseContext.response;
|
||||
const errText = withUpstreamPath(
|
||||
baseContext.request.path,
|
||||
summarizeUpstreamError(response.status, rawErrText),
|
||||
);
|
||||
await runEndpointFlowHook(input.onAttemptFailure, {
|
||||
...baseContext,
|
||||
errText,
|
||||
}, 'onAttemptFailure');
|
||||
|
||||
if (input.disableCrossProtocolFallback && !isLastEndpoint) {
|
||||
finalStatus = response.status;
|
||||
finalErrText = errText;
|
||||
finalRawErrText = rawErrText;
|
||||
break;
|
||||
}
|
||||
const shouldAbortRemainingEndpoints = !isLastEndpoint && !!input.shouldAbortRemainingEndpoints?.({
|
||||
...baseContext,
|
||||
errText,
|
||||
});
|
||||
if (shouldAbortRemainingEndpoints) {
|
||||
finalStatus = response.status;
|
||||
finalErrText = errText;
|
||||
finalRawErrText = rawErrText;
|
||||
break;
|
||||
}
|
||||
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
||||
if (shouldDowngrade) {
|
||||
await runEndpointFlowHook(input.onDowngrade, {
|
||||
...baseContext,
|
||||
errText,
|
||||
}, 'onDowngrade');
|
||||
continue;
|
||||
}
|
||||
|
||||
finalStatus = response.status;
|
||||
finalErrText = errText;
|
||||
finalRawErrText = rawErrText;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: finalStatus || 502,
|
||||
errText: finalErrText || 'unknown error',
|
||||
rawErrText: finalRawErrText,
|
||||
};
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function collapseWhitespace(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function extractJsonErrorMessage(rawText: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as unknown;
|
||||
const root = isRecord(parsed) ? parsed : null;
|
||||
const error = (root && isRecord(root.error)) ? root.error : root;
|
||||
if (!error) return '';
|
||||
|
||||
const message = typeof error.message === 'string' ? collapseWhitespace(error.message) : '';
|
||||
if (message) return message;
|
||||
|
||||
const code = typeof error.code === 'string' ? collapseWhitespace(error.code) : '';
|
||||
const type = typeof error.type === 'string' ? collapseWhitespace(error.type) : '';
|
||||
return [type, code].filter((part) => part.length > 0).join('/');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function extractHtmlTitle(rawText: string): string {
|
||||
const match = rawText.match(/<title[^>]*>([^<>]*)<\/title>/i);
|
||||
if (!match?.[1]) return '';
|
||||
return collapseWhitespace(match[1]);
|
||||
}
|
||||
|
||||
function extractCloudflareHtmlSummary(rawText: string, status: number): string {
|
||||
if (!/cloudflare/i.test(rawText)) return '';
|
||||
const title = extractHtmlTitle(rawText);
|
||||
const codeMatch = (
|
||||
title.match(/\b(\d{3,4})\s*:\s*([^\|<]+)/i)
|
||||
|| rawText.match(/Error code\s*(\d{3,4})/i)
|
||||
);
|
||||
const code = codeMatch?.[1] || (status > 0 ? String(status) : '');
|
||||
const reason = collapseWhitespace(
|
||||
(typeof codeMatch?.[2] === 'string' ? codeMatch[2] : '')
|
||||
|| (status >= 500 ? 'origin host error' : 'request blocked')
|
||||
);
|
||||
if (code) return `Cloudflare ${code}: ${reason}`;
|
||||
return `Cloudflare: ${reason}`;
|
||||
}
|
||||
|
||||
function extractHtmlSummary(rawText: string, status: number): string {
|
||||
if (!/(<!doctype|<html)/i.test(rawText)) return '';
|
||||
|
||||
const cloudflareSummary = extractCloudflareHtmlSummary(rawText, status);
|
||||
if (cloudflareSummary) return cloudflareSummary;
|
||||
|
||||
const title = extractHtmlTitle(rawText);
|
||||
if (title) return title;
|
||||
|
||||
const heading = rawText.match(/<h1[^>]*>([^<>]*)<\/h1>/i)?.[1] || '';
|
||||
return collapseWhitespace(heading);
|
||||
}
|
||||
|
||||
function formatUrlOrigin(url: URL): string {
|
||||
const username = url.username ? encodeURIComponent(url.username) : '';
|
||||
const password = url.password ? encodeURIComponent(url.password) : '';
|
||||
const auth = username
|
||||
? `${username}${password ? `:${password}` : ''}@`
|
||||
: '';
|
||||
|
||||
return `${url.protocol}//${auth}${url.host}`;
|
||||
}
|
||||
|
||||
function joinPath(basePath: string, requestPath: string): string {
|
||||
const base = basePath.replace(/\/+$/, '');
|
||||
const path = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
|
||||
|
||||
if (!base || base === '/') return path || '/';
|
||||
if (!path || path === '/') return base;
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
export function summarizeUpstreamError(status: number, rawErrorText: string): string {
|
||||
const statusPrefix = status > 0
|
||||
? `Upstream returned HTTP ${status}`
|
||||
: 'Upstream request failed';
|
||||
|
||||
const raw = typeof rawErrorText === 'string' ? rawErrorText.trim() : '';
|
||||
if (!raw) return statusPrefix;
|
||||
|
||||
const jsonMessage = extractJsonErrorMessage(raw);
|
||||
if (jsonMessage) return `${statusPrefix}: ${jsonMessage}`;
|
||||
|
||||
const htmlMessage = extractHtmlSummary(raw, status);
|
||||
if (htmlMessage) return `${statusPrefix}: ${htmlMessage}`;
|
||||
|
||||
const compact = collapseWhitespace(raw);
|
||||
if (!compact) return statusPrefix;
|
||||
if (compact.length <= 400) return `${statusPrefix}: ${compact}`;
|
||||
return `${statusPrefix}: ${compact.slice(0, 400)}...(truncated)`;
|
||||
}
|
||||
|
||||
export function buildUpstreamUrl(siteUrl: string, requestPath: string): string {
|
||||
const baseRaw = typeof siteUrl === 'string' ? siteUrl.trim() : '';
|
||||
const pathRaw = typeof requestPath === 'string' ? requestPath.trim() : '';
|
||||
const fallbackBase = baseRaw.replace(/\/+$/, '');
|
||||
let path = pathRaw.startsWith('/') ? pathRaw : `/${pathRaw}`;
|
||||
|
||||
if (!fallbackBase) return path || '/';
|
||||
if (!path || path === '/') return fallbackBase;
|
||||
|
||||
try {
|
||||
const parsed = new URL(baseRaw);
|
||||
const basePath = parsed.pathname.replace(/\/+$/, '');
|
||||
const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(basePath);
|
||||
if (baseHasVersionSuffix) {
|
||||
if (path === '/v1') {
|
||||
path = '/';
|
||||
} else if (path.startsWith('/v1/')) {
|
||||
path = path.slice('/v1'.length) || '/';
|
||||
}
|
||||
}
|
||||
|
||||
const joinedPath = joinPath(basePath, path);
|
||||
return `${formatUrlOrigin(parsed)}${joinedPath}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(fallbackBase);
|
||||
if (baseHasVersionSuffix) {
|
||||
if (path === '/v1') {
|
||||
path = '/';
|
||||
} else if (path.startsWith('/v1/')) {
|
||||
path = path.slice('/v1'.length) || '/';
|
||||
}
|
||||
}
|
||||
|
||||
return `${fallbackBase}${path}`;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js';
|
||||
import { resolveAntigravityProviderAction } from './antigravityRuntime.js';
|
||||
|
||||
const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function resolvePath(action: ProviderAction): string {
|
||||
if (action === 'countTokens') return '/v1internal:countTokens';
|
||||
if (action === 'streamGenerateContent') return '/v1internal:streamGenerateContent?alt=sse';
|
||||
return '/v1internal:generateContent';
|
||||
}
|
||||
|
||||
export const antigravityProviderProfile: ProviderProfile = {
|
||||
id: 'antigravity',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const action = resolveAntigravityProviderAction(input.action, input.stream, input.modelName);
|
||||
const projectId = asTrimmedString(input.oauthProjectId);
|
||||
return {
|
||||
path: resolvePath(action),
|
||||
headers: {
|
||||
Authorization: input.baseHeaders.Authorization,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: action === 'streamGenerateContent' ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT,
|
||||
},
|
||||
body: {
|
||||
project: projectId,
|
||||
model: input.modelName,
|
||||
request: input.body,
|
||||
},
|
||||
runtime: {
|
||||
executor: 'antigravity',
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
oauthProjectId: projectId,
|
||||
action,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
resolveAntigravityProviderAction,
|
||||
shouldUseAntigravityStreamAction,
|
||||
} from './antigravityRuntime.js';
|
||||
|
||||
describe('shouldUseAntigravityStreamAction', () => {
|
||||
it('returns true for Claude-family model names', () => {
|
||||
expect(shouldUseAntigravityStreamAction('claude')).toBe(true);
|
||||
expect(shouldUseAntigravityStreamAction('claude-2')).toBe(true);
|
||||
expect(shouldUseAntigravityStreamAction('claude-instant')).toBe(true);
|
||||
expect(shouldUseAntigravityStreamAction('CLAUDE-OPUS-4-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for Gemini special-model names', () => {
|
||||
expect(shouldUseAntigravityStreamAction('gemini-3-pro')).toBe(true);
|
||||
expect(shouldUseAntigravityStreamAction('gemini-3-pro-latest')).toBe(true);
|
||||
expect(shouldUseAntigravityStreamAction('gemini-3.1-flash-image')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for models outside the Claude-family heuristic', () => {
|
||||
expect(shouldUseAntigravityStreamAction('gemini-2.5-pro')).toBe(false);
|
||||
expect(shouldUseAntigravityStreamAction('gpt-5')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAntigravityProviderAction', () => {
|
||||
it('routes Claude-family non-stream requests through streamGenerateContent', () => {
|
||||
expect(resolveAntigravityProviderAction('generateContent', false, 'claude-opus-4-1'))
|
||||
.toBe('streamGenerateContent');
|
||||
});
|
||||
|
||||
it('routes Gemini special-model non-stream requests through streamGenerateContent', () => {
|
||||
expect(resolveAntigravityProviderAction('generateContent', false, 'gemini-3-pro-latest'))
|
||||
.toBe('streamGenerateContent');
|
||||
expect(resolveAntigravityProviderAction('generateContent', false, 'gemini-3.1-flash-image'))
|
||||
.toBe('streamGenerateContent');
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { ProviderAction } from './types.js';
|
||||
|
||||
export function shouldUseAntigravityStreamAction(modelName: string): boolean {
|
||||
const normalizedModel = modelName.toLowerCase();
|
||||
return normalizedModel.includes('claude')
|
||||
|| normalizedModel.includes('gemini-3-pro')
|
||||
|| normalizedModel.includes('gemini-3.1-flash-image');
|
||||
}
|
||||
|
||||
export function resolveAntigravityProviderAction(
|
||||
action: ProviderAction | undefined,
|
||||
stream: boolean,
|
||||
modelName: string,
|
||||
): ProviderAction {
|
||||
if (action === 'countTokens') return action;
|
||||
if (action === 'streamGenerateContent') return action;
|
||||
if (stream || shouldUseAntigravityStreamAction(modelName)) {
|
||||
return 'streamGenerateContent';
|
||||
}
|
||||
return action || 'generateContent';
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
||||
import {
|
||||
buildClaudeRuntimeHeaders,
|
||||
CLAUDE_API_KEY_DEFAULT_BETA_HEADER,
|
||||
CLAUDE_DEFAULT_BETA_HEADER,
|
||||
CLAUDE_TOKEN_COUNTING_BETA,
|
||||
} from './headerUtils.js';
|
||||
|
||||
export const claudeProviderProfile: ProviderProfile = {
|
||||
id: 'claude',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const anthropicVersion = (
|
||||
input.claudeHeaders?.['anthropic-version']
|
||||
|| input.baseHeaders['anthropic-version']
|
||||
|| '2023-06-01'
|
||||
);
|
||||
const isClaudeOauthUpstream = input.sitePlatform?.trim().toLowerCase() === 'claude'
|
||||
&& input.oauthProvider === 'claude';
|
||||
const isCountTokens = input.action === 'countTokens';
|
||||
|
||||
return {
|
||||
path: isCountTokens ? '/v1/messages/count_tokens?beta=true' : '/v1/messages',
|
||||
headers: buildClaudeRuntimeHeaders({
|
||||
baseHeaders: input.baseHeaders,
|
||||
claudeHeaders: input.claudeHeaders ?? {},
|
||||
anthropicVersion,
|
||||
stream: isCountTokens ? false : input.stream,
|
||||
isClaudeOauthUpstream,
|
||||
tokenValue: input.tokenValue,
|
||||
defaultBetaHeader: isClaudeOauthUpstream
|
||||
? CLAUDE_DEFAULT_BETA_HEADER
|
||||
: CLAUDE_API_KEY_DEFAULT_BETA_HEADER,
|
||||
...(isCountTokens ? { extraBetas: [CLAUDE_TOKEN_COUNTING_BETA] } : {}),
|
||||
}),
|
||||
body: input.body,
|
||||
runtime: {
|
||||
executor: 'claude',
|
||||
modelName: input.modelName,
|
||||
stream: isCountTokens ? false : input.stream,
|
||||
oauthProjectId: null,
|
||||
...(isCountTokens ? { action: 'countTokens' } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
||||
import { config } from '../../config.js';
|
||||
import { buildCodexRuntimeHeaders, getInputHeader } from './headerUtils.js';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export const codexProviderProfile: ProviderProfile = {
|
||||
id: 'codex',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex';
|
||||
const websocketTransport = input.responsesWebsocketTransport === true;
|
||||
const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : '';
|
||||
const configuredBetaFeatures = (
|
||||
isCodexOauth && websocketTransport
|
||||
? asTrimmedString(config.codexHeaderDefaults.betaFeatures)
|
||||
: ''
|
||||
);
|
||||
const headers = buildCodexRuntimeHeaders({
|
||||
baseHeaders: input.baseHeaders,
|
||||
providerHeaders: input.providerHeaders,
|
||||
stream: input.stream,
|
||||
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
||||
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
|
||||
userAgentOverride: configuredUserAgent || null,
|
||||
codexBetaFeatures: getInputHeader(input.baseHeaders, 'x-codex-beta-features') || configuredBetaFeatures,
|
||||
codexTurnState: getInputHeader(input.baseHeaders, 'x-codex-turn-state'),
|
||||
codexTurnMetadata: getInputHeader(input.baseHeaders, 'x-codex-turn-metadata'),
|
||||
timingMetrics: getInputHeader(input.baseHeaders, 'x-responsesapi-include-timing-metrics'),
|
||||
openAiBeta: getInputHeader(input.baseHeaders, 'openai-beta')
|
||||
|| (websocketTransport ? asTrimmedString(config.codexResponsesWebsocketBeta) : null),
|
||||
});
|
||||
|
||||
return {
|
||||
path: '/responses',
|
||||
headers,
|
||||
body: input.body,
|
||||
runtime: {
|
||||
executor: 'codex',
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
oauthProjectId: asTrimmedString(input.oauthProjectId) || null,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js';
|
||||
import { buildGeminiCliRuntimeHeaders } from './headerUtils.js';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction {
|
||||
if (action) return action;
|
||||
return stream ? 'streamGenerateContent' : 'generateContent';
|
||||
}
|
||||
|
||||
function resolvePath(action: ProviderAction): string {
|
||||
if (action === 'countTokens') return '/v1internal:countTokens';
|
||||
if (action === 'streamGenerateContent') return '/v1internal:streamGenerateContent?alt=sse';
|
||||
return '/v1internal:generateContent';
|
||||
}
|
||||
|
||||
export const geminiCliProviderProfile: ProviderProfile = {
|
||||
id: 'gemini-cli',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const projectId = asTrimmedString(input.oauthProjectId);
|
||||
if (!projectId) {
|
||||
throw new Error('gemini-cli oauth project id missing');
|
||||
}
|
||||
const action = resolveAction(input.action, input.stream);
|
||||
const headers = buildGeminiCliRuntimeHeaders({
|
||||
baseHeaders: input.baseHeaders,
|
||||
providerHeaders: input.providerHeaders,
|
||||
modelName: input.modelName,
|
||||
stream: action === 'streamGenerateContent',
|
||||
});
|
||||
return {
|
||||
path: resolvePath(action),
|
||||
headers,
|
||||
body: {
|
||||
project: projectId,
|
||||
model: input.modelName,
|
||||
request: input.body,
|
||||
},
|
||||
runtime: {
|
||||
executor: 'gemini-cli',
|
||||
modelName: input.modelName,
|
||||
stream: action === 'streamGenerateContent',
|
||||
oauthProjectId: projectId,
|
||||
action,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user