[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
+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);
+17 -10
View File
@@ -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,16 +27,22 @@ export async function ensureOauthProviderSite(definition: OAuthProviderDefinitio
if (existing) return existing;
try {
return await db.insert(schema.sites).values({
name: definition.site.name,
url: definition.site.url,
platform: definition.site.platform,
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: await getNextSiteSortOrder(),
}).returning().get();
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,
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: await getNextSiteSortOrder(),
},
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(
+21 -14
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,20 +256,26 @@ async function upsertOauthAccount(input: {
};
}
const created = await db.insert(schema.accounts).values({
siteId: site.id,
username,
accessToken: input.exchange.accessToken,
apiToken: null,
checkinEnabled: false,
status: 'active',
oauthProvider: input.definition.metadata.provider,
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
oauthProjectId: oauth.projectId || null,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
}).returning().get();
const created = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: site.id,
username,
accessToken: input.exchange.accessToken,
apiToken: null,
checkinEnabled: false,
status: 'active',
oauthProvider: input.definition.metadata.provider,
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
oauthProjectId: oauth.projectId || null,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
},
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,26 +59,26 @@ export async function migrateSiteApiKeysToAccounts(): Promise<SiteApiKeyMigratio
if (targetAccount) {
summary.deduped += 1;
} else {
const inserted = await db.insert(schema.accounts).values({
siteId: site.id,
username: null,
accessToken: '',
apiToken: siteApiKey,
checkinEnabled: false,
status: 'active',
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) {
accounts.push(targetAccount);
nextSortOrder += 1;
summary.migrated += 1;
}
}
targetAccount = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: site.id,
username: null,
accessToken: '',
apiToken: siteApiKey,
checkinEnabled: false,
status: 'active',
extraConfig: mergeAccountExtraConfig(undefined, { credentialMode: 'apikey' }),
isPinned: false,
sortOrder: nextSortOrder,
},
insertErrorMessage: 'failed to create migrated site account',
loadErrorMessage: 'failed to create migrated site account',
});
accounts.push(targetAccount);
nextSortOrder += 1;
summary.migrated += 1;
}
if (targetAccount) {