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:
@@ -58,6 +58,29 @@ describe('db proxy query wrapper', () => {
|
|||||||
expect(testUtils.shouldWrapObject(Promise.resolve())).toBe(false);
|
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', () => {
|
it('builds mysql pool options with jsonStrings enabled', () => {
|
||||||
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', false)).toMatchObject({
|
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', false)).toMatchObject({
|
||||||
uri: 'mysql://root:pass@db.example.com:3306/metapi',
|
uri: 'mysql://root:pass@db.example.com:3306/metapi',
|
||||||
|
|||||||
@@ -1398,6 +1398,7 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
|
|||||||
export const __dbProxyTestUtils = {
|
export const __dbProxyTestUtils = {
|
||||||
wrapQueryLike,
|
wrapQueryLike,
|
||||||
shouldWrapObject,
|
shouldWrapObject,
|
||||||
|
pgProxyQuery,
|
||||||
resolveSqlitePath,
|
resolveSqlitePath,
|
||||||
resolveVitestSqlitePath,
|
resolveVitestSqlitePath,
|
||||||
buildMysqlPoolOptions,
|
buildMysqlPoolOptions,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
|
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
|
||||||
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||||||
@@ -511,26 +512,24 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
|||||||
? (body.isDefault ?? false)
|
? (body.isDefault ?? false)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const inserted = await db.insert(schema.accountTokens).values({
|
let created = await insertAndGetById<typeof schema.accountTokens.$inferSelect>({
|
||||||
accountId: body.accountId,
|
table: schema.accountTokens,
|
||||||
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
|
idColumn: schema.accountTokens.id,
|
||||||
token: tokenValue,
|
values: {
|
||||||
tokenGroup: (body.group || '').trim() || null,
|
accountId: body.accountId,
|
||||||
valueStatus,
|
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
|
||||||
source: body.source || 'manual',
|
token: tokenValue,
|
||||||
enabled,
|
tokenGroup: (body.group || '').trim() || null,
|
||||||
isDefault,
|
valueStatus,
|
||||||
createdAt: now,
|
source: body.source || 'manual',
|
||||||
updatedAt: now,
|
enabled,
|
||||||
}).run();
|
isDefault,
|
||||||
const createdId = Number(inserted.lastInsertRowid || 0);
|
createdAt: now,
|
||||||
if (createdId <= 0) {
|
updatedAt: now,
|
||||||
return reply.code(500).send({ success: false, message: '创建令牌失败' });
|
},
|
||||||
}
|
insertErrorMessage: '创建令牌失败',
|
||||||
let created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get();
|
loadErrorMessage: '创建令牌失败',
|
||||||
if (!created) {
|
});
|
||||||
return reply.code(500).send({ success: false, message: '创建令牌失败' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) {
|
if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) {
|
||||||
await setDefaultToken(created.id);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema, runtimeDbDialect } from '../../db/index.js';
|
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 { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
import { refreshBalance } from '../../services/balanceService.js';
|
import { refreshBalance } from '../../services/balanceService.js';
|
||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
@@ -602,18 +603,23 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}).where(eq(schema.accounts.id, existing.id)).run();
|
}).where(eq(schema.accounts.id, existing.id)).run();
|
||||||
} else {
|
} else {
|
||||||
const inserted = await db.insert(schema.accounts).values({
|
const created = await insertAndGetById<typeof schema.accounts.$inferSelect>({
|
||||||
siteId,
|
table: schema.accounts,
|
||||||
username,
|
idColumn: schema.accounts.id,
|
||||||
accessToken: loginResult.accessToken,
|
values: {
|
||||||
apiToken: preferredApiToken || undefined,
|
siteId,
|
||||||
checkinEnabled: true,
|
username,
|
||||||
extraConfig,
|
accessToken: loginResult.accessToken,
|
||||||
isPinned: false,
|
apiToken: preferredApiToken || undefined,
|
||||||
sortOrder: await getNextAccountSortOrder(),
|
checkinEnabled: true,
|
||||||
}).run();
|
extraConfig,
|
||||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
isPinned: false,
|
||||||
accountId = insertedId > 0 ? insertedId : undefined;
|
sortOrder: await getNextAccountSortOrder(),
|
||||||
|
},
|
||||||
|
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();
|
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
|
||||||
@@ -1196,24 +1202,22 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
|
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
|
||||||
|
|
||||||
const inserted = await db.insert(schema.accounts).values({
|
const result = await insertAndGetById<typeof schema.accounts.$inferSelect>({
|
||||||
siteId: body.siteId,
|
table: schema.accounts,
|
||||||
username: username || undefined,
|
idColumn: schema.accounts.id,
|
||||||
accessToken,
|
values: {
|
||||||
apiToken: apiToken || undefined,
|
siteId: body.siteId,
|
||||||
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
|
username: username || undefined,
|
||||||
extraConfig,
|
accessToken,
|
||||||
isPinned: false,
|
apiToken: apiToken || undefined,
|
||||||
sortOrder: await getNextAccountSortOrder(),
|
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
|
||||||
}).run();
|
extraConfig,
|
||||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
isPinned: false,
|
||||||
if (insertedId <= 0) {
|
sortOrder: await getNextAccountSortOrder(),
|
||||||
return reply.code(500).send({ success: false, message: '创建账号失败' });
|
},
|
||||||
}
|
insertErrorMessage: '创建账号失败',
|
||||||
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, insertedId)).get();
|
loadErrorMessage: '创建账号失败',
|
||||||
if (!result) {
|
});
|
||||||
return reply.code(500).send({ success: false, message: '创建账号失败' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
|
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
|
||||||
let queuedTaskId: string | undefined;
|
let queuedTaskId: string | undefined;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
|
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
|
||||||
|
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||||
import {
|
import {
|
||||||
getDownstreamApiKeyById,
|
getDownstreamApiKeyById,
|
||||||
listDownstreamApiKeys,
|
listDownstreamApiKeys,
|
||||||
@@ -477,34 +478,30 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
|||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const insertedResult = await db.insert(schema.downstreamApiKeys).values({
|
const inserted = await insertAndGetById<typeof schema.downstreamApiKeys.$inferSelect>({
|
||||||
name: normalized.name,
|
table: schema.downstreamApiKeys,
|
||||||
key: normalized.key,
|
idColumn: schema.downstreamApiKeys.id,
|
||||||
description: normalized.description,
|
values: {
|
||||||
groupName: normalized.groupName,
|
name: normalized.name,
|
||||||
tags: toPersistenceJson(normalized.tags),
|
key: normalized.key,
|
||||||
enabled: normalized.enabled,
|
description: normalized.description,
|
||||||
expiresAt: normalized.expiresAt,
|
groupName: normalized.groupName,
|
||||||
maxCost: normalized.maxCost,
|
tags: toPersistenceJson(normalized.tags),
|
||||||
usedCost: 0,
|
enabled: normalized.enabled,
|
||||||
maxRequests: normalized.maxRequests,
|
expiresAt: normalized.expiresAt,
|
||||||
usedRequests: 0,
|
maxCost: normalized.maxCost,
|
||||||
supportedModels: toPersistenceJson(normalized.supportedModels),
|
usedCost: 0,
|
||||||
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
|
maxRequests: normalized.maxRequests,
|
||||||
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
|
usedRequests: 0,
|
||||||
createdAt: nowIso,
|
supportedModels: toPersistenceJson(normalized.supportedModels),
|
||||||
updatedAt: nowIso,
|
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
|
||||||
}).run();
|
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
|
||||||
const insertedId = Number(insertedResult.lastInsertRowid || 0);
|
createdAt: nowIso,
|
||||||
if (insertedId <= 0) {
|
updatedAt: nowIso,
|
||||||
return reply.code(500).send({ success: false, message: '创建失败' });
|
},
|
||||||
}
|
insertErrorMessage: '创建失败',
|
||||||
const inserted = await db.select().from(schema.downstreamApiKeys)
|
loadErrorMessage: '创建失败',
|
||||||
.where(eq(schema.downstreamApiKeys.id, insertedId))
|
});
|
||||||
.get();
|
|
||||||
if (!inserted) {
|
|
||||||
return reply.code(500).send({ success: false, message: '创建失败' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { detectSite } from '../../services/siteDetector.js';
|
import { detectSite } from '../../services/siteDetector.js';
|
||||||
import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||||
@@ -379,36 +380,34 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
let inserted;
|
let inserted;
|
||||||
try {
|
try {
|
||||||
inserted = await db.insert(schema.sites).values({
|
inserted = await insertAndGetById<typeof schema.sites.$inferSelect>({
|
||||||
name,
|
table: schema.sites,
|
||||||
url: canonicalUrl,
|
idColumn: schema.sites.id,
|
||||||
platform: detectedPlatform,
|
values: {
|
||||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
name,
|
||||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
url: canonicalUrl,
|
||||||
customHeaders: normalizedCustomHeaders.customHeaders,
|
platform: detectedPlatform,
|
||||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||||
status: normalizedStatus ?? 'active',
|
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||||
isPinned: normalizedPinned ?? false,
|
customHeaders: normalizedCustomHeaders.customHeaders,
|
||||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||||
globalWeight: normalizedGlobalWeight ?? 1,
|
status: normalizedStatus ?? 'active',
|
||||||
}).run();
|
isPinned: normalizedPinned ?? false,
|
||||||
|
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
||||||
|
globalWeight: normalizedGlobalWeight ?? 1,
|
||||||
|
},
|
||||||
|
insertErrorMessage: 'Create site failed',
|
||||||
|
loadErrorMessage: 'Create site failed',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isSitesPlatformUrlConflict(error)) {
|
if (isSitesPlatformUrlConflict(error)) {
|
||||||
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||||
}
|
}
|
||||||
throw error;
|
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();
|
invalidateSiteCaches();
|
||||||
return {
|
return {
|
||||||
...result,
|
...inserted,
|
||||||
...(responseInitializationPresetId ? { initializationPresetId: responseInitializationPresetId } : {}),
|
...(responseInitializationPresetId ? { initializationPresetId: responseInitializationPresetId } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { requireInsertedRowId } from '../../db/insertHelpers.js';
|
||||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||||||
@@ -996,10 +997,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
routingStrategy: normalizedRoutingStrategy,
|
routingStrategy: normalizedRoutingStrategy,
|
||||||
enabled: body.enabled ?? true,
|
enabled: body.enabled ?? true,
|
||||||
}).run();
|
}).run();
|
||||||
const routeId = Number(insertedRoute.lastInsertRowid || 0);
|
const routeId = requireInsertedRowId(insertedRoute, '创建路由失败');
|
||||||
if (routeId <= 0) {
|
|
||||||
return { success: false, message: '创建路由失败' };
|
|
||||||
}
|
|
||||||
const route = await getRouteWithSources(routeId);
|
const route = await getRouteWithSources(routeId);
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return { success: false, message: '创建路由失败' };
|
return { success: false, message: '创建路由失败' };
|
||||||
@@ -1220,13 +1218,10 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
priority: body.priority ?? 0,
|
priority: body.priority ?? 0,
|
||||||
weight: body.weight ?? 10,
|
weight: body.weight ?? 10,
|
||||||
}).run();
|
}).run();
|
||||||
const channelId = Number(insertedChannel.lastInsertRowid || 0);
|
const channelId = requireInsertedRowId(insertedChannel, '创建通道失败');
|
||||||
if (channelId <= 0) {
|
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||||
return reply.code(500).send({ success: false, message: '创建通道失败' });
|
if (!created) {
|
||||||
}
|
return reply.code(500).send({ success: false, message: '创建通道失败' });
|
||||||
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: '创建通道失败' });
|
|
||||||
}
|
}
|
||||||
await clearRouteDecisionSnapshot(routeId);
|
await clearRouteDecisionSnapshot(routeId);
|
||||||
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
|
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { and, eq, ne } from 'drizzle-orm';
|
import { and, eq, ne } from 'drizzle-orm';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { getInsertedRowId } from '../db/insertHelpers.js';
|
||||||
import { getCredentialModeFromExtraConfig } from './accountExtraConfig.js';
|
import { getCredentialModeFromExtraConfig } from './accountExtraConfig.js';
|
||||||
|
|
||||||
type UpstreamApiToken = {
|
type UpstreamApiToken = {
|
||||||
@@ -215,8 +216,8 @@ export async function ensureDefaultTokenForAccount(
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
const insertedId = getInsertedRowId(inserted);
|
||||||
target = insertedId > 0
|
target = insertedId != null
|
||||||
? (await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get()) ?? null
|
? (await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get()) ?? null
|
||||||
: null;
|
: null;
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
@@ -448,8 +449,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
const insertedId = getInsertedRowId(inserted);
|
||||||
if (insertedId <= 0) continue;
|
if (insertedId == null) continue;
|
||||||
const createdRow = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get();
|
const createdRow = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, insertedId)).get();
|
||||||
if (!createdRow) continue;
|
if (!createdRow) continue;
|
||||||
|
|
||||||
@@ -506,4 +507,3 @@ export async function listTokensWithRelations(accountId?: number) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { asc, eq } from 'drizzle-orm';
|
import { asc, eq } from 'drizzle-orm';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { requireInsertedRowId } from '../db/insertHelpers.js';
|
||||||
import { upsertSetting } from '../db/upsertSetting.js';
|
import { upsertSetting } from '../db/upsertSetting.js';
|
||||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||||
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||||
@@ -1738,8 +1739,12 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
|
|||||||
allowedRouteIds: row.allowedRouteIds ?? null,
|
allowedRouteIds: row.allowedRouteIds ?? null,
|
||||||
siteWeightMultipliers: row.siteWeightMultipliers ?? null,
|
siteWeightMultipliers: row.siteWeightMultipliers ?? null,
|
||||||
lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null,
|
lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null,
|
||||||
}).returning({ id: schema.downstreamApiKeys.id }).get();
|
}).run();
|
||||||
downstreamApiKeyIdByKey.set(normalizedKey, insertedKey.id);
|
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 { and, eq } from 'drizzle-orm';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { getInsertedRowId } from '../db/insertHelpers.js';
|
||||||
import { getAdapter } from './platforms/index.js';
|
import { getAdapter } from './platforms/index.js';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||||||
@@ -1032,8 +1033,8 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
modelPattern: modelName,
|
modelPattern: modelName,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}).run();
|
}).run();
|
||||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
const insertedId = getInsertedRowId(inserted);
|
||||||
route = insertedId > 0
|
route = insertedId != null
|
||||||
? await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, insertedId)).get()
|
? await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, insertedId)).get()
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!route) continue;
|
if (!route) continue;
|
||||||
@@ -1060,8 +1061,8 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
manualOverride: false,
|
manualOverride: false,
|
||||||
}).run();
|
}).run();
|
||||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
const insertedId = getInsertedRowId(inserted);
|
||||||
if (insertedId <= 0) continue;
|
if (insertedId == null) continue;
|
||||||
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedId)).get();
|
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedId)).get();
|
||||||
if (!created) continue;
|
if (!created) continue;
|
||||||
channels.push(created);
|
channels.push(created);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||||
import { listOAuthProviderDefinitions, type OAuthProviderDefinition } from './providers.js';
|
import { listOAuthProviderDefinitions, type OAuthProviderDefinition } from './providers.js';
|
||||||
|
|
||||||
function isUniqueConstraintError(error: unknown): boolean {
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
@@ -26,16 +27,22 @@ export async function ensureOauthProviderSite(definition: OAuthProviderDefinitio
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await db.insert(schema.sites).values({
|
return await insertAndGetById<typeof schema.sites.$inferSelect>({
|
||||||
name: definition.site.name,
|
table: schema.sites,
|
||||||
url: definition.site.url,
|
idColumn: schema.sites.id,
|
||||||
platform: definition.site.platform,
|
values: {
|
||||||
status: 'active',
|
name: definition.site.name,
|
||||||
useSystemProxy: false,
|
url: definition.site.url,
|
||||||
isPinned: false,
|
platform: definition.site.platform,
|
||||||
globalWeight: 1,
|
status: 'active',
|
||||||
sortOrder: await getNextSiteSortOrder(),
|
useSystemProxy: false,
|
||||||
}).returning().get();
|
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) {
|
} catch (error) {
|
||||||
if (!isUniqueConstraintError(error)) throw error;
|
if (!isUniqueConstraintError(error)) throw error;
|
||||||
const recovered = await db.select().from(schema.sites).where(and(
|
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 { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||||
import { getProxyUrlFromExtraConfig, mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
import { getProxyUrlFromExtraConfig, mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
||||||
import { refreshModelsForAccount } from '../modelService.js';
|
import { refreshModelsForAccount } from '../modelService.js';
|
||||||
import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js';
|
import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js';
|
||||||
@@ -255,20 +256,26 @@ async function upsertOauthAccount(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await db.insert(schema.accounts).values({
|
const created = await insertAndGetById<typeof schema.accounts.$inferSelect>({
|
||||||
siteId: site.id,
|
table: schema.accounts,
|
||||||
username,
|
idColumn: schema.accounts.id,
|
||||||
accessToken: input.exchange.accessToken,
|
values: {
|
||||||
apiToken: null,
|
siteId: site.id,
|
||||||
checkinEnabled: false,
|
username,
|
||||||
status: 'active',
|
accessToken: input.exchange.accessToken,
|
||||||
oauthProvider: input.definition.metadata.provider,
|
apiToken: null,
|
||||||
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
checkinEnabled: false,
|
||||||
oauthProjectId: oauth.projectId || null,
|
status: 'active',
|
||||||
extraConfig,
|
oauthProvider: input.definition.metadata.provider,
|
||||||
isPinned: false,
|
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
||||||
sortOrder: await getNextAccountSortOrder(),
|
oauthProjectId: oauth.projectId || null,
|
||||||
}).returning().get();
|
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 };
|
return { account: created, site, created: true, previousAccount: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, asc, desc, eq, lt } from 'drizzle-orm';
|
import { and, asc, desc, eq, lt } from 'drizzle-orm';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { requireInsertedRowId } from '../db/insertHelpers.js';
|
||||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
|
|
||||||
type HeadersLike = Headers | Record<string, unknown> | null | undefined;
|
type HeadersLike = Headers | Record<string, unknown> | null | undefined;
|
||||||
@@ -193,7 +194,7 @@ export async function createProxyDebugTrace(input: {
|
|||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(inserted.lastInsertRowid || 0),
|
id: requireInsertedRowId(inserted, 'failed to create proxy debug trace'),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -309,7 +310,7 @@ export async function insertProxyDebugAttempt(input: {
|
|||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(inserted.lastInsertRowid || 0),
|
id: requireInsertedRowId(inserted, 'failed to create proxy debug attempt'),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { and, asc, eq } from 'drizzle-orm';
|
import { and, asc, eq } from 'drizzle-orm';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { requireInsertedRowId } from '../db/insertHelpers.js';
|
||||||
import { getAdapter } from './platforms/index.js';
|
import { getAdapter } from './platforms/index.js';
|
||||||
import { sendNotification } from './notifyService.js';
|
import { sendNotification } from './notifyService.js';
|
||||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
@@ -130,7 +131,11 @@ export async function syncSiteAnnouncements(options?: { siteId?: number | null }
|
|||||||
sourceKey: announcement.sourceKey,
|
sourceKey: announcement.sourceKey,
|
||||||
firstSeenAt: seenAt,
|
firstSeenAt: seenAt,
|
||||||
...patch,
|
...patch,
|
||||||
}).returning().get();
|
}).run();
|
||||||
|
const announcementId = requireInsertedRowId(
|
||||||
|
inserted,
|
||||||
|
`failed to create site announcement for site ${site.id}`,
|
||||||
|
);
|
||||||
result.inserted += 1;
|
result.inserted += 1;
|
||||||
|
|
||||||
const title = `站点公告:${site.name}`;
|
const title = `站点公告:${site.name}`;
|
||||||
@@ -140,7 +145,7 @@ export async function syncSiteAnnouncements(options?: { siteId?: number | null }
|
|||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
level: announcement.level,
|
level: announcement.level,
|
||||||
relatedId: inserted.id,
|
relatedId: announcementId,
|
||||||
relatedType: 'site_announcement',
|
relatedType: 'site_announcement',
|
||||||
createdAt: seenAt,
|
createdAt: seenAt,
|
||||||
}).run();
|
}).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 { eq } from 'drizzle-orm';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { insertAndGetById } from '../db/insertHelpers.js';
|
||||||
import { getCredentialModeFromExtraConfig, mergeAccountExtraConfig } from './accountExtraConfig.js';
|
import { getCredentialModeFromExtraConfig, mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||||
|
|
||||||
export type SiteApiKeyMigrationSummary = {
|
export type SiteApiKeyMigrationSummary = {
|
||||||
@@ -58,26 +59,26 @@ export async function migrateSiteApiKeysToAccounts(): Promise<SiteApiKeyMigratio
|
|||||||
if (targetAccount) {
|
if (targetAccount) {
|
||||||
summary.deduped += 1;
|
summary.deduped += 1;
|
||||||
} else {
|
} else {
|
||||||
const inserted = await db.insert(schema.accounts).values({
|
targetAccount = await insertAndGetById<typeof schema.accounts.$inferSelect>({
|
||||||
siteId: site.id,
|
table: schema.accounts,
|
||||||
username: null,
|
idColumn: schema.accounts.id,
|
||||||
accessToken: '',
|
values: {
|
||||||
apiToken: siteApiKey,
|
siteId: site.id,
|
||||||
checkinEnabled: false,
|
username: null,
|
||||||
status: 'active',
|
accessToken: '',
|
||||||
extraConfig: mergeAccountExtraConfig(undefined, { credentialMode: 'apikey' }),
|
apiToken: siteApiKey,
|
||||||
isPinned: false,
|
checkinEnabled: false,
|
||||||
sortOrder: nextSortOrder,
|
status: 'active',
|
||||||
}).run();
|
extraConfig: mergeAccountExtraConfig(undefined, { credentialMode: 'apikey' }),
|
||||||
const accountId = Number(inserted.lastInsertRowid || 0);
|
isPinned: false,
|
||||||
if (accountId > 0) {
|
sortOrder: nextSortOrder,
|
||||||
targetAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get() || null;
|
},
|
||||||
if (targetAccount) {
|
insertErrorMessage: 'failed to create migrated site account',
|
||||||
accounts.push(targetAccount);
|
loadErrorMessage: 'failed to create migrated site account',
|
||||||
nextSortOrder += 1;
|
});
|
||||||
summary.migrated += 1;
|
accounts.push(targetAccount);
|
||||||
}
|
nextSortOrder += 1;
|
||||||
}
|
summary.migrated += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetAccount) {
|
if (targetAccount) {
|
||||||
|
|||||||
Reference in New Issue
Block a user