1
0
forked from boos/BoosAPI

[codex] Fix mysql insert boundary handling (#364)

* fix mysql insert boundary handling

* fix review comment follow-ups

* fix account insert boundary regressions
This commit is contained in:
Cita
2026-04-01 14:33:45 +08:00
committed by GitHub
parent aac1307470
commit 1e0dffe543
20 changed files with 544 additions and 171 deletions
+23
View File
@@ -58,6 +58,29 @@ describe('db proxy query wrapper', () => {
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('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',
+1
View File
@@ -1398,6 +1398,7 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
export const __dbProxyTestUtils = {
wrapQueryLike,
shouldWrapObject,
pgProxyQuery,
resolveSqlitePath,
resolveVitestSqlitePath,
buildMysqlPoolOptions,
+72
View File
@@ -0,0 +1,72 @@
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',
});
});
});
+38
View File
@@ -0,0 +1,38 @@
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;
}
@@ -0,0 +1,48 @@
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([]);
});
});
+9 -10
View File
@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
ACCOUNT_TOKEN_VALUE_STATUS_READY,
@@ -511,7 +512,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
? (body.isDefault ?? false)
: false;
const inserted = await db.insert(schema.accountTokens).values({
let created = await insertAndGetById<typeof schema.accountTokens.$inferSelect>({
table: schema.accountTokens,
idColumn: schema.accountTokens.id,
values: {
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
@@ -522,15 +526,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
isDefault,
createdAt: now,
updatedAt: now,
}).run();
const createdId = Number(inserted.lastInsertRowid || 0);
if (createdId <= 0) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
let created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
},
insertErrorMessage: '创建令牌失败',
loadErrorMessage: '创建令牌失败',
});
if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) {
await setDefaultToken(created.id);
@@ -0,0 +1,105 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const loginMock = vi.fn();
const getApiTokenMock = vi.fn();
const getApiTokensMock = vi.fn();
const convergeAccountMutationMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
login: (...args: unknown[]) => loginMock(...args),
getApiToken: (...args: unknown[]) => getApiTokenMock(...args),
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
}),
}));
vi.mock('../../services/accountMutationWorkflow.js', () => ({
convergeAccountMutation: (...args: unknown[]) => convergeAccountMutationMock(...args),
rebuildRoutesBestEffort: vi.fn(),
}));
vi.mock('../../db/insertHelpers.js', async () => {
const actual = await vi.importActual<typeof import('../../db/insertHelpers.js')>('../../db/insertHelpers.js');
return {
...actual,
insertAndGetById: vi.fn(async () => {
throw new Error('account create failed');
}),
};
});
type DbModule = typeof import('../../db/index.js');
describe('accounts login insert boundary', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-login-insert-boundary-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
loginMock.mockReset();
getApiTokenMock.mockReset();
getApiTokensMock.mockReset();
convergeAccountMutationMock.mockReset();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
if (dataDir) {
rmSync(dataDir, { recursive: true, force: true });
}
delete process.env.DATA_DIR;
});
it('fails fast when login account creation cannot complete through the shared insert helper', async () => {
loginMock.mockResolvedValue({
success: true,
accessToken: 'session-token',
});
getApiTokenMock.mockResolvedValue(null);
getApiTokensMock.mockResolvedValue([]);
convergeAccountMutationMock.mockResolvedValue(undefined);
const site = await db.insert(schema.sites).values({
name: 'Login Site',
url: 'https://login.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/login',
payload: {
siteId: site.id,
username: 'demo-user',
password: 'demo-password',
},
});
expect(response.statusCode).toBe(500);
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
expect(convergeAccountMutationMock).not.toHaveBeenCalled();
});
});
+18 -14
View File
@@ -1,5 +1,6 @@
import { FastifyInstance } from 'fastify';
import { db, schema, runtimeDbDialect } from '../../db/index.js';
import { getInsertedRowId, insertAndGetById } from '../../db/insertHelpers.js';
import { and, eq, gte, lt, sql } from 'drizzle-orm';
import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
@@ -602,7 +603,10 @@ export async function accountsRoutes(app: FastifyInstance) {
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, existing.id)).run();
} else {
const inserted = await db.insert(schema.accounts).values({
const created = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId,
username,
accessToken: loginResult.accessToken,
@@ -611,9 +615,11 @@ export async function accountsRoutes(app: FastifyInstance) {
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
accountId = insertedId > 0 ? insertedId : undefined;
},
insertErrorMessage: 'account create failed',
loadErrorMessage: 'account create failed',
});
accountId = created.id;
}
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
@@ -1196,7 +1202,10 @@ export async function accountsRoutes(app: FastifyInstance) {
}
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
const inserted = await db.insert(schema.accounts).values({
const result = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: body.siteId,
username: username || undefined,
accessToken,
@@ -1205,15 +1214,10 @@ export async function accountsRoutes(app: FastifyInstance) {
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
if (insertedId <= 0) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, insertedId)).get();
if (!result) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
},
insertErrorMessage: '创建账号失败',
loadErrorMessage: '创建账号失败',
});
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
let queuedTaskId: string | undefined;
+9 -12
View File
@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import {
getDownstreamApiKeyById,
listDownstreamApiKeys,
@@ -477,7 +478,10 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const nowIso = new Date().toISOString();
try {
const insertedResult = await db.insert(schema.downstreamApiKeys).values({
const inserted = await insertAndGetById<typeof schema.downstreamApiKeys.$inferSelect>({
table: schema.downstreamApiKeys,
idColumn: schema.downstreamApiKeys.id,
values: {
name: normalized.name,
key: normalized.key,
description: normalized.description,
@@ -494,17 +498,10 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
createdAt: nowIso,
updatedAt: nowIso,
}).run();
const insertedId = Number(insertedResult.lastInsertRowid || 0);
if (insertedId <= 0) {
return reply.code(500).send({ success: false, message: '创建失败' });
}
const inserted = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, insertedId))
.get();
if (!inserted) {
return reply.code(500).send({ success: false, message: '创建失败' });
}
},
insertErrorMessage: '创建失败',
loadErrorMessage: '创建失败',
});
return {
success: true,
+10 -11
View File
@@ -1,5 +1,6 @@
import { FastifyInstance, FastifyReply } from 'fastify';
import { db, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
@@ -379,7 +380,10 @@ export async function sitesRoutes(app: FastifyInstance) {
let inserted;
try {
inserted = await db.insert(schema.sites).values({
inserted = await insertAndGetById<typeof schema.sites.$inferSelect>({
table: schema.sites,
idColumn: schema.sites.id,
values: {
name,
url: canonicalUrl,
platform: detectedPlatform,
@@ -391,24 +395,19 @@ export async function sitesRoutes(app: FastifyInstance) {
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
globalWeight: normalizedGlobalWeight ?? 1,
}).run();
},
insertErrorMessage: 'Create site failed',
loadErrorMessage: 'Create site failed',
});
} catch (error) {
if (isSitesPlatformUrlConflict(error)) {
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
}
throw error;
}
const siteId = Number(inserted.lastInsertRowid || 0);
if (siteId <= 0) {
return reply.code(500).send({ error: 'Create site failed' });
}
const result = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!result) {
return reply.code(500).send({ error: 'Create site failed' });
}
invalidateSiteCaches();
return {
...result,
...inserted,
...(responseInitializationPresetId ? { initializationPresetId: responseInitializationPresetId } : {}),
};
});
+3 -8
View File
@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { requireInsertedRowId } from '../../db/insertHelpers.js';
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
@@ -996,10 +997,7 @@ export async function tokensRoutes(app: FastifyInstance) {
routingStrategy: normalizedRoutingStrategy,
enabled: body.enabled ?? true,
}).run();
const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const routeId = requireInsertedRowId(insertedRoute, '创建路由失败');
const route = await getRouteWithSources(routeId);
if (!route) {
return { success: false, message: '创建路由失败' };
@@ -1220,10 +1218,7 @@ export async function tokensRoutes(app: FastifyInstance) {
priority: body.priority ?? 0,
weight: body.weight ?? 10,
}).run();
const channelId = Number(insertedChannel.lastInsertRowid || 0);
if (channelId <= 0) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
const channelId = requireInsertedRowId(insertedChannel, '创建通道失败');
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
+5 -5
View File
@@ -1,5 +1,6 @@
import { and, eq, ne } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { getInsertedRowId } from '../db/insertHelpers.js';
import { getCredentialModeFromExtraConfig } from './accountExtraConfig.js';
type UpstreamApiToken = {
@@ -215,8 +216,8 @@ export async function ensureDefaultTokenForAccount(
updatedAt: now,
})
.run();
const insertedId = Number(inserted.lastInsertRowid || 0);
target = insertedId > 0
const insertedId = getInsertedRowId(inserted);
target = insertedId != null
? (await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get()) ?? null
: null;
if (!target) return null;
@@ -448,8 +449,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
updatedAt: now,
})
.run();
const insertedId = Number(inserted.lastInsertRowid || 0);
if (insertedId <= 0) continue;
const insertedId = getInsertedRowId(inserted);
if (insertedId == null) continue;
const createdRow = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get();
if (!createdRow) continue;
@@ -506,4 +507,3 @@ export async function listTokensWithRelations(accountId?: number) {
};
});
}
+7 -2
View File
@@ -1,6 +1,7 @@
import { asc, eq } from 'drizzle-orm';
import cron from 'node-cron';
import { db, schema } from '../db/index.js';
import { requireInsertedRowId } from '../db/insertHelpers.js';
import { upsertSetting } from '../db/upsertSetting.js';
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
@@ -1738,8 +1739,12 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
allowedRouteIds: row.allowedRouteIds ?? null,
siteWeightMultipliers: row.siteWeightMultipliers ?? null,
lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null,
}).returning({ id: schema.downstreamApiKeys.id }).get();
downstreamApiKeyIdByKey.set(normalizedKey, insertedKey.id);
}).run();
const downstreamApiKeyId = requireInsertedRowId(
insertedKey,
`failed to import downstream api key: ${maskSecret(normalizedKey)}`,
);
downstreamApiKeyIdByKey.set(normalizedKey, downstreamApiKeyId);
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { getInsertedRowId } from '../db/insertHelpers.js';
import { getAdapter } from './platforms/index.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
@@ -1032,8 +1033,8 @@ export async function rebuildTokenRoutesFromAvailability() {
modelPattern: modelName,
enabled: true,
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
route = insertedId > 0
const insertedId = getInsertedRowId(inserted);
route = insertedId != null
? await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, insertedId)).get()
: undefined;
if (!route) continue;
@@ -1060,8 +1061,8 @@ export async function rebuildTokenRoutesFromAvailability() {
enabled: true,
manualOverride: false,
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
if (insertedId <= 0) continue;
const insertedId = getInsertedRowId(inserted);
if (insertedId == null) continue;
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedId)).get();
if (!created) continue;
channels.push(created);
@@ -1,5 +1,6 @@
import { and, eq, sql } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import { listOAuthProviderDefinitions, type OAuthProviderDefinition } from './providers.js';
function isUniqueConstraintError(error: unknown): boolean {
@@ -26,7 +27,10 @@ export async function ensureOauthProviderSite(definition: OAuthProviderDefinitio
if (existing) return existing;
try {
return await db.insert(schema.sites).values({
return await insertAndGetById<typeof schema.sites.$inferSelect>({
table: schema.sites,
idColumn: schema.sites.id,
values: {
name: definition.site.name,
url: definition.site.url,
platform: definition.site.platform,
@@ -35,7 +39,10 @@ export async function ensureOauthProviderSite(definition: OAuthProviderDefinitio
isPinned: false,
globalWeight: 1,
sortOrder: await getNextSiteSortOrder(),
}).returning().get();
},
insertErrorMessage: `failed to create oauth provider site: ${definition.site.platform}`,
loadErrorMessage: `failed to load created oauth provider site: ${definition.site.platform}`,
});
} catch (error) {
if (!isUniqueConstraintError(error)) throw error;
const recovered = await db.select().from(schema.sites).where(and(
+9 -2
View File
@@ -1,5 +1,6 @@
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import { getProxyUrlFromExtraConfig, mergeAccountExtraConfig } from '../accountExtraConfig.js';
import { refreshModelsForAccount } from '../modelService.js';
import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js';
@@ -255,7 +256,10 @@ async function upsertOauthAccount(input: {
};
}
const created = await db.insert(schema.accounts).values({
const created = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: site.id,
username,
accessToken: input.exchange.accessToken,
@@ -268,7 +272,10 @@ async function upsertOauthAccount(input: {
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
}).returning().get();
},
insertErrorMessage: `failed to create oauth account: ${input.definition.metadata.provider}`,
loadErrorMessage: `failed to load created oauth account: ${input.definition.metadata.provider}`,
});
return { account: created, site, created: true, previousAccount: null };
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { and, asc, desc, eq, lt } from 'drizzle-orm';
import { config } from '../config.js';
import { db, schema } from '../db/index.js';
import { requireInsertedRowId } from '../db/insertHelpers.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
type HeadersLike = Headers | Record<string, unknown> | null | undefined;
@@ -193,7 +194,7 @@ export async function createProxyDebugTrace(input: {
}).run();
return {
id: Number(inserted.lastInsertRowid || 0),
id: requireInsertedRowId(inserted, 'failed to create proxy debug trace'),
createdAt: now,
};
}
@@ -309,7 +310,7 @@ export async function insertProxyDebugAttempt(input: {
}).run();
return {
id: Number(inserted.lastInsertRowid || 0),
id: requireInsertedRowId(inserted, 'failed to create proxy debug attempt'),
createdAt: now,
};
}
@@ -1,5 +1,6 @@
import { and, asc, eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { requireInsertedRowId } from '../db/insertHelpers.js';
import { getAdapter } from './platforms/index.js';
import { sendNotification } from './notifyService.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
@@ -130,7 +131,11 @@ export async function syncSiteAnnouncements(options?: { siteId?: number | null }
sourceKey: announcement.sourceKey,
firstSeenAt: seenAt,
...patch,
}).returning().get();
}).run();
const announcementId = requireInsertedRowId(
inserted,
`failed to create site announcement for site ${site.id}`,
);
result.inserted += 1;
const title = `站点公告:${site.name}`;
@@ -140,7 +145,7 @@ export async function syncSiteAnnouncements(options?: { siteId?: number | null }
title,
message,
level: announcement.level,
relatedId: inserted.id,
relatedId: announcementId,
relatedType: 'site_announcement',
createdAt: seenAt,
}).run();
@@ -0,0 +1,65 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
vi.mock('../db/insertHelpers.js', async () => {
const actual = await vi.importActual<typeof import('../db/insertHelpers.js')>('../db/insertHelpers.js');
return {
...actual,
insertAndGetById: vi.fn(async () => {
throw new Error('failed to create migrated site account');
}),
};
});
type DbModule = typeof import('../db/index.js');
type MigrationModule = typeof import('./siteApiKeyMigrationService.js');
describe('siteApiKeyMigrationService insert boundary', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let migrateSiteApiKeysToAccounts: MigrationModule['migrateSiteApiKeysToAccounts'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-api-key-migration-boundary-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const migrationModule = await import('./siteApiKeyMigrationService.js');
db = dbModule.db;
schema = dbModule.schema;
migrateSiteApiKeysToAccounts = migrationModule.migrateSiteApiKeysToAccounts;
});
beforeEach(async () => {
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
if (dataDir) {
rmSync(dataDir, { recursive: true, force: true });
}
delete process.env.DATA_DIR;
});
it('does not clear the site apiKey when creating the replacement account cannot complete', async () => {
const site = await db.insert(schema.sites).values({
name: 'boundary-site',
url: 'https://boundary.example.com',
platform: 'new-api',
apiKey: 'sk-boundary-site-token',
}).returning().get();
await expect(migrateSiteApiKeysToAccounts()).rejects.toThrow('failed to create migrated site account');
const persistedSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
expect(persistedSite?.apiKey).toBe('sk-boundary-site-token');
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
});
});
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { insertAndGetById } from '../db/insertHelpers.js';
import { getCredentialModeFromExtraConfig, mergeAccountExtraConfig } from './accountExtraConfig.js';
export type SiteApiKeyMigrationSummary = {
@@ -58,7 +59,10 @@ export async function migrateSiteApiKeysToAccounts(): Promise<SiteApiKeyMigratio
if (targetAccount) {
summary.deduped += 1;
} else {
const inserted = await db.insert(schema.accounts).values({
targetAccount = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: site.id,
username: null,
accessToken: '',
@@ -68,17 +72,14 @@ export async function migrateSiteApiKeysToAccounts(): Promise<SiteApiKeyMigratio
extraConfig: mergeAccountExtraConfig(undefined, { credentialMode: 'apikey' }),
isPinned: false,
sortOrder: nextSortOrder,
}).run();
const accountId = Number(inserted.lastInsertRowid || 0);
if (accountId > 0) {
targetAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get() || null;
if (targetAccount) {
},
insertErrorMessage: 'failed to create migrated site account',
loadErrorMessage: 'failed to create migrated site account',
});
accounts.push(targetAccount);
nextSortOrder += 1;
summary.migrated += 1;
}
}
}
if (targetAccount) {
const childTokens = await db.select()