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

View File

@@ -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;
-- AlterTable
CREATE UNIQUE INDEX idx_ai_session_unique_pinned
ON ai_sessions_metadata (user_id, workspace_id)
WHERE pinned = true AND deleted_at IS NULL;
ALTER TABLE "ai_sessions_metadata" ADD COLUMN "prompt_action" VARCHAR(32) DEFAULT '';
-- AlterTable
CREATE UNIQUE INDEX idx_ai_session_unique_doc_root
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_pinned_idx" ON "ai_sessions_metadata" (user_id, workspace_id) WHERE pinned = true 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
DROP INDEX "ai_sessions_metadata_user_id_workspace_id_idx";

View File

@@ -436,6 +436,7 @@ model AiSession {
workspaceId String @map("workspace_id") @db.VarChar
docId String? @map("doc_id") @db.VarChar
promptName String @map("prompt_name") @db.VarChar(32)
promptAction String? @default("") @map("prompt_action") @db.VarChar(32)
pinned Boolean @default(false)
// the session id of the parent session if this session is a forked session
parentSessionId String? @map("parent_session_id") @db.VarChar
@@ -449,6 +450,12 @@ model AiSession {
messages AiSessionMessage[]
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, workspaceId, docId])
@@map("ai_sessions_metadata")

View File

@@ -25,6 +25,12 @@ Generated by [AVA](https://avajs.dev).
{
docId: 'doc-id-1',
pinned: false,
promptName: 'action-prompt',
},
{
docId: 'doc-id-1',
pinned: false,
promptName: 'test-prompt',
},
]

View File

@@ -59,6 +59,7 @@ test.beforeEach(async t => {
docId,
userId: user.id,
promptName: 'prompt-name',
promptAction: null,
});
});

View File

@@ -11,6 +11,7 @@ import {
WorkspaceModel,
} from '../../models';
import { createTestingModule, type TestingModule } from '../utils';
import { cleanObject } from '../utils/copilot';
interface Context {
module: TestingModule;
@@ -65,6 +66,7 @@ const createTestSession = async (
docId: string | null;
pinned: boolean;
promptName: string;
promptAction: string | null;
}> = {}
) => {
const sessionData = {
@@ -74,6 +76,7 @@ const createTestSession = async (
docId: null,
pinned: false,
promptName: 'test-prompt',
promptAction: null,
...overrides,
};
@@ -98,6 +101,12 @@ test('should list and filter session type', async t => {
await createTestSession(t, { sessionId: randomUUID() });
await createTestSession(t, { sessionId: randomUUID(), pinned: true });
await createTestSession(t, { sessionId: randomUUID(), docId });
await createTestSession(t, {
sessionId: randomUUID(),
docId,
promptName: 'action-prompt',
promptAction: 'action',
});
// 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);
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'
);
}
@@ -206,6 +221,7 @@ test('should pin and unpin sessions', async t => {
workspaceId: workspace.id,
docId: null,
promptName: 'test-prompt',
promptAction: null,
pinned: true,
});
@@ -220,6 +236,7 @@ test('should pin and unpin sessions', async t => {
workspaceId: workspace.id,
docId: null,
promptName: 'test-prompt',
promptAction: null,
pinned: true,
});
@@ -336,6 +353,15 @@ test('session updates and type conversions', async t => {
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');
}
});

View File

@@ -47,6 +47,7 @@ type ChatSession = {
// connect ids
userId: string;
promptName: string;
promptAction: 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
async createPrompt(name: string, model: string) {
async createPrompt(name: string, model: string, action?: string) {
await this.db.aiPrompt.create({
data: { name, model },
data: { name, model, action: action ?? null },
});
}
@@ -116,6 +117,7 @@ export class CopilotSessionModel extends BaseModel {
// connect
userId: state.userId,
promptName: state.promptName,
promptAction: state.promptAction,
parentSessionId: state.parentSessionId,
},
});
@@ -134,7 +136,9 @@ export class CopilotSessionModel extends BaseModel {
}
@Transactional()
async getChatSessionId(state: Omit<ChatSession, 'promptName'>) {
async getChatSessionId(
state: Omit<ChatSession, 'promptName' | 'promptAction'>
) {
const extraCondition: Record<string, any> = {};
if (state.parentSessionId) {
// also check session id if provided session is forked session
@@ -284,10 +288,21 @@ export class CopilotSessionModel extends BaseModel {
if (!session) {
throw new CopilotSessionNotFound();
}
if (data.promptName && session.prompt.action) {
throw new CopilotSessionInvalidInput(
`Cannot update prompt for action: ${session.id}`
);
if (data.promptName) {
if (session.prompt.action) {
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 pin the session, unpin exists session in the workspace

View File

@@ -1880,5 +1880,14 @@ export async function refreshPrompts(db: PrismaClient) {
},
},
});
await db.aiSession.updateMany({
where: {
promptName: prompt.name,
},
data: {
promptAction: prompt.action ?? null,
},
});
}
}

View File

@@ -257,6 +257,7 @@ export class ChatSessionService {
...state,
sessionId,
promptName: state.prompt.name,
promptAction: state.prompt.action ?? null,
});
}
@@ -615,7 +616,7 @@ export class ChatSessionService {
await tx.aiSession.updateMany({
where: { id: { in: actionIds } },
data: { deletedAt: new Date() },
data: { pinned: false, deletedAt: new Date() },
});
return [...sessionIds, ...actionIds];