[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:
@@ -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) {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user