fix(server): session unique index conflict (#12865)

This commit is contained in:
DarkSky
2025-06-20 08:36:22 +08:00
committed by GitHub
parent 7376926553
commit dfaf69475b
9 changed files with 83 additions and 16 deletions
@@ -5,14 +5,16 @@ ALTER TABLE "ai_sessions_metadata" ALTER COLUMN "doc_id" DROP NOT NULL;
ALTER TABLE "ai_sessions_metadata" ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false; ALTER TABLE "ai_sessions_metadata" ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable -- AlterTable
CREATE UNIQUE INDEX idx_ai_session_unique_pinned ALTER TABLE "ai_sessions_metadata" ADD COLUMN "prompt_action" VARCHAR(32) DEFAULT '';
ON ai_sessions_metadata (user_id, workspace_id)
WHERE pinned = true AND deleted_at IS NULL;
-- AlterTable -- CreateIndex
CREATE UNIQUE INDEX idx_ai_session_unique_doc_root CREATE UNIQUE INDEX "ai_session_unique_pinned_idx" ON "ai_sessions_metadata" (user_id, workspace_id) WHERE pinned = true AND deleted_at IS NULL;
ON ai_sessions_metadata (user_id, workspace_id, doc_id)
WHERE parent_session_id IS NULL AND doc_id IS NOT NULL AND deleted_at IS NULL; -- CreateIndex
CREATE UNIQUE INDEX "ai_session_unique_doc_session_idx" ON "ai_sessions_metadata" (user_id, workspace_id, doc_id) WHERE prompt_action IS NULL AND parent_session_id IS NULL AND doc_id IS NOT NULL AND deleted_at IS NULL;
-- CreateIndex
CREATE INDEX "ai_sessions_metadata_prompt_name_idx" ON "ai_sessions_metadata"("prompt_name");
-- DropIndex -- DropIndex
DROP INDEX "ai_sessions_metadata_user_id_workspace_id_idx"; DROP INDEX "ai_sessions_metadata_user_id_workspace_id_idx";
+7
View File
@@ -436,6 +436,7 @@ model AiSession {
workspaceId String @map("workspace_id") @db.VarChar workspaceId String @map("workspace_id") @db.VarChar
docId String? @map("doc_id") @db.VarChar docId String? @map("doc_id") @db.VarChar
promptName String @map("prompt_name") @db.VarChar(32) promptName String @map("prompt_name") @db.VarChar(32)
promptAction String? @default("") @map("prompt_action") @db.VarChar(32)
pinned Boolean @default(false) pinned Boolean @default(false)
// the session id of the parent session if this session is a forked session // the session id of the parent session if this session is a forked session
parentSessionId String? @map("parent_session_id") @db.VarChar parentSessionId String? @map("parent_session_id") @db.VarChar
@@ -449,6 +450,12 @@ model AiSession {
messages AiSessionMessage[] messages AiSessionMessage[]
context AiContext[] context AiContext[]
//NOTE:
// unrecorded index:
// @@index([userId, workspaceId]) where pinned = true and deleted_at is null
// @@index([userId, workspaceId, docId]) where prompt_action is null and parent_session_id is null and doc_id is not null and deleted_at is null
// since prisma does not support partial indexes, those indexes are only exists in migration files.
@@index([promptName])
@@index([userId]) @@index([userId])
@@index([userId, workspaceId, docId]) @@index([userId, workspaceId, docId])
@@map("ai_sessions_metadata") @@map("ai_sessions_metadata")
@@ -25,6 +25,12 @@ Generated by [AVA](https://avajs.dev).
{ {
docId: 'doc-id-1', docId: 'doc-id-1',
pinned: false, pinned: false,
promptName: 'action-prompt',
},
{
docId: 'doc-id-1',
pinned: false,
promptName: 'test-prompt',
}, },
] ]
@@ -59,6 +59,7 @@ test.beforeEach(async t => {
docId, docId,
userId: user.id, userId: user.id,
promptName: 'prompt-name', promptName: 'prompt-name',
promptAction: null,
}); });
}); });
@@ -11,6 +11,7 @@ import {
WorkspaceModel, WorkspaceModel,
} from '../../models'; } from '../../models';
import { createTestingModule, type TestingModule } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
import { cleanObject } from '../utils/copilot';
interface Context { interface Context {
module: TestingModule; module: TestingModule;
@@ -65,6 +66,7 @@ const createTestSession = async (
docId: string | null; docId: string | null;
pinned: boolean; pinned: boolean;
promptName: string; promptName: string;
promptAction: string | null;
}> = {} }> = {}
) => { ) => {
const sessionData = { const sessionData = {
@@ -74,6 +76,7 @@ const createTestSession = async (
docId: null, docId: null,
pinned: false, pinned: false,
promptName: 'test-prompt', promptName: 'test-prompt',
promptAction: null,
...overrides, ...overrides,
}; };
@@ -98,6 +101,12 @@ test('should list and filter session type', async t => {
await createTestSession(t, { sessionId: randomUUID() }); await createTestSession(t, { sessionId: randomUUID() });
await createTestSession(t, { sessionId: randomUUID(), pinned: true }); await createTestSession(t, { sessionId: randomUUID(), pinned: true });
await createTestSession(t, { sessionId: randomUUID(), docId }); await createTestSession(t, { sessionId: randomUUID(), docId });
await createTestSession(t, {
sessionId: randomUUID(),
docId,
promptName: 'action-prompt',
promptAction: 'action',
});
// should list sessions // should list sessions
{ {
@@ -113,7 +122,13 @@ test('should list and filter session type', async t => {
const docSessions = await copilotSession.list(user.id, workspace.id, docId); const docSessions = await copilotSession.list(user.id, workspace.id, docId);
t.snapshot( t.snapshot(
docSessions.map(s => ({ docId: s.docId, pinned: s.pinned })), cleanObject(docSessions, [
'id',
'userId',
'createdAt',
'messages',
'tokenCost',
]),
'doc sessions should only include sessions with matching docId' 'doc sessions should only include sessions with matching docId'
); );
} }
@@ -206,6 +221,7 @@ test('should pin and unpin sessions', async t => {
workspaceId: workspace.id, workspaceId: workspace.id,
docId: null, docId: null,
promptName: 'test-prompt', promptName: 'test-prompt',
promptAction: null,
pinned: true, pinned: true,
}); });
@@ -220,6 +236,7 @@ test('should pin and unpin sessions', async t => {
workspaceId: workspace.id, workspaceId: workspace.id,
docId: null, docId: null,
promptName: 'test-prompt', promptName: 'test-prompt',
promptAction: null,
pinned: true, pinned: true,
}); });
@@ -336,6 +353,15 @@ test('session updates and type conversions', async t => {
await convertSession('workspace_to_pinned', { pinned: true }); // Workspace → Pinned session await convertSession('workspace_to_pinned', { pinned: true }); // Workspace → Pinned session
} }
// not allow convert to action prompt
{
await t.throwsAsync(
copilotSession.update(user.id, sessionId, {
promptName: 'action-prompt',
})
);
}
t.snapshot(conversionSteps, 'session type conversion steps'); t.snapshot(conversionSteps, 'session type conversion steps');
} }
}); });
@@ -47,6 +47,7 @@ type ChatSession = {
// connect ids // connect ids
userId: string; userId: string;
promptName: string; promptName: string;
promptAction: string | null;
parentSessionId?: string | null; parentSessionId?: string | null;
}; };
@@ -95,9 +96,9 @@ export class CopilotSessionModel extends BaseModel {
} }
// NOTE: just for test, remove it after copilot prompt model is ready // NOTE: just for test, remove it after copilot prompt model is ready
async createPrompt(name: string, model: string) { async createPrompt(name: string, model: string, action?: string) {
await this.db.aiPrompt.create({ await this.db.aiPrompt.create({
data: { name, model }, data: { name, model, action: action ?? null },
}); });
} }
@@ -116,6 +117,7 @@ export class CopilotSessionModel extends BaseModel {
// connect // connect
userId: state.userId, userId: state.userId,
promptName: state.promptName, promptName: state.promptName,
promptAction: state.promptAction,
parentSessionId: state.parentSessionId, parentSessionId: state.parentSessionId,
}, },
}); });
@@ -134,7 +136,9 @@ export class CopilotSessionModel extends BaseModel {
} }
@Transactional() @Transactional()
async getChatSessionId(state: Omit<ChatSession, 'promptName'>) { async getChatSessionId(
state: Omit<ChatSession, 'promptName' | 'promptAction'>
) {
const extraCondition: Record<string, any> = {}; const extraCondition: Record<string, any> = {};
if (state.parentSessionId) { if (state.parentSessionId) {
// also check session id if provided session is forked session // also check session id if provided session is forked session
@@ -284,10 +288,21 @@ export class CopilotSessionModel extends BaseModel {
if (!session) { if (!session) {
throw new CopilotSessionNotFound(); throw new CopilotSessionNotFound();
} }
if (data.promptName && session.prompt.action) { if (data.promptName) {
throw new CopilotSessionInvalidInput( if (session.prompt.action) {
`Cannot update prompt for action: ${session.id}` throw new CopilotSessionInvalidInput(
); `Cannot update prompt for action: ${session.id}`
);
}
const prompt = await this.db.aiPrompt.findFirst({
where: { name: data.promptName },
});
// always not allow to update to action prompt
if (!prompt || prompt.action) {
throw new CopilotSessionInvalidInput(
`Prompt ${data.promptName} not found or not available for session ${sessionId}`
);
}
} }
if (data.pinned && data.pinned !== session.pinned) { if (data.pinned && data.pinned !== session.pinned) {
// if pin the session, unpin exists session in the workspace // if pin the session, unpin exists session in the workspace
@@ -1880,5 +1880,14 @@ export async function refreshPrompts(db: PrismaClient) {
}, },
}, },
}); });
await db.aiSession.updateMany({
where: {
promptName: prompt.name,
},
data: {
promptAction: prompt.action ?? null,
},
});
} }
} }
@@ -257,6 +257,7 @@ export class ChatSessionService {
...state, ...state,
sessionId, sessionId,
promptName: state.prompt.name, promptName: state.prompt.name,
promptAction: state.prompt.action ?? null,
}); });
} }
@@ -615,7 +616,7 @@ export class ChatSessionService {
await tx.aiSession.updateMany({ await tx.aiSession.updateMany({
where: { id: { in: actionIds } }, where: { id: { in: actionIds } },
data: { deletedAt: new Date() }, data: { pinned: false, deletedAt: new Date() },
}); });
return [...sessionIds, ...actionIds]; return [...sessionIds, ...actionIds];