From dfaf69475b4fc13d91f92b49e9eb10d710bfdf77 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Fri, 20 Jun 2025 08:36:22 +0800 Subject: [PATCH] fix(server): session unique index conflict (#12865) --- .../migration.sql | 16 +++++----- packages/backend/server/schema.prisma | 7 +++++ .../__snapshots__/copilot-session.spec.ts.md | 6 ++++ .../copilot-session.spec.ts.snap | Bin 1185 -> 1218 bytes .../__tests__/models/copilot-context.spec.ts | 1 + .../__tests__/models/copilot-session.spec.ts | 28 ++++++++++++++++- .../server/src/models/copilot-session.ts | 29 +++++++++++++----- .../src/plugins/copilot/prompt/prompts.ts | 9 ++++++ .../server/src/plugins/copilot/session.ts | 3 +- 9 files changed, 83 insertions(+), 16 deletions(-) diff --git a/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql b/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql index 6def2babd..32306b793 100644 --- a/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql +++ b/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql @@ -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"; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 51e666f94..de3ad5f44 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -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") diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md index 301b6c87c..3886e838d 100644 --- a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md +++ b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md @@ -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', }, ] diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap index 7c87d7102de6a1b76b7a1f519869369db8a37dcb..89544e837c3caf46340813c3c895c70eeda00dee 100644 GIT binary patch literal 1218 zcmV;z1U>sfRzV5!{n!#OaUhTJB7bF`&tHZue=R&(`-ZK zx>B8gI3J4$00000000BUSWS%EL=^s>$JtFAmL%G8pip5^kU$Xex2;gcuhaw3YKz3B zEfU3RPsp%d+x1L#vmAOUNN_+<3Gs76TM40Zp%<>83JDJ7REY~nh)NYm95`?&CnT`P zcCz-iPRJH=$vE@;Ui`jqzV{^?-6bz@mgVWQTnXjz!Cu)5`krffLMqGlU8^fR#Uo2{ zDMjF0YCYsa`gSOLfs&`sx~{G4V{`|@EaDf{i9HBlKY*tI%+pMpYEMUzy-t*^n$0F? zC&&Obhz{u+8$_$|J0&?b0DKDI>nz92k)>C;qsYjYtyU`u{TaY701PUGvdy7$Nx>%O z$=F2dwv0`4JTk$~muwzF&Zt=6Wx8#o=| zb{@KJ2Me1!!zfq@)uO$^vybL%M~T4i=$-$ta6@sa(y}SgkGp|Wf;jNK^&IDXb4{q8 zwPGu$C;V5e(D!9!AWRb)DUJ7@E=&N|C>F`^;WC@XO zA>cLw7K(_L&)jhWUM66@7;2V6KPKQ40=_JU&Xhtg6Ywj=N4XGM-|8gaOk^2AhQe&x z(E?s(tXSE_j{J0uBssJlNmeIJl5%V$!^VUem>W1r*S<4=O9t?Jv1_c<;06QQ4Ct^z zXss0b90Q(bfL9EiErq_tfOi=1MKN^8_6cz1?X%O}KBfGRttP2H>>Zt_mTt5Qr0S%7=m@tO|9@GjEx8l;?wF;TDa4Ot5KCn%E-kwo`zR;kwu*&b=PV3(jo{%N zKLZ(IIw=VvuKJO0b!|^_Yput9%MXULL2;-~fPw0FG3#fh8O`p0nYN z;qW-04v#vq9|2qja8vcfxR-zj2zarA5Orb$0!|Tdu41@saU`eX$91wz#)0ZDr+Ok9 zJ6p*B$JW5GCuZ0R}<2Awfs1NzsjR)smYr+QceD> zR+C)>>?UCU)LygVjB6-#cdE|#G^KX)fsnBVbacuIS+KCBuEdeCtaLJ7XclCpG?+&> zM)e@Z@i72L0lZ$sf&QPyW)Hb%(?gCD+s~ionEBbZ`r{9W68G)UA79P=v3WNE_YrWA zfRmMqXPzP80|L$y@I$3fHvb}k8NlrZaJXpYN?zHF3}9dYXAIy%wO?k5$GkFz$CQeP zxlH9&_k;>gB!;ufkmj|TY_eK&Zs5dKGGjcRch}*t-mPp8*$&7P~w>e`3Iue;j$!tn~Qzo4~^+uviQ&d;Db+IB5cJ7egoV_$ka| gYQeT literal 1185 zcmV;S1YY|=RzV7bO19TtA z0L>8{jxWv;t?2I*L+7J{4J?qc zofxRrqM92xuei}CHlKSo(-XeWv-C!Zmhzr2E=40GzH zw~QrR?kmv|j;-|1LN5EBlIfz_naD>^_>offeSJ#xV~Z7ab}B3C-E2jf7Xf?+;8y@Q zO4pRRK)^!;>?go3UsbHaR1A^f-Ry`^s&;}yTe#)(zspK3$(_J=$1K%IM!YW_u~fF= z(y}|+T{t1Pl@|IqXKwAq6Fid5&p=9;c0z)XtA6NP9ov)KTJ3V*@`K?nQ9;a(lnFnN zFu_~|a1g*D07uK%z#{!0^TekM4i}xfU^W# zELkpFypogg;||%XaiIK{Q(X~`oh{+Oe%td3recZ5BiAg7DHiKP(BokwC#j4)mh!RR zb7MmKZY)1e;IHuTztrS|d#NTj%hhB%0XqmdK)~`nQ6QVACddZfbIr*nZZ{ar4`$ z`r|K$68Fo{AK%FQvH3Uwy9qc%z?o9TGtU$71pylbTq*U*<}Cu40X$*=NAgy#;FZnL z00std-T*F@`(+k+%xhzKOsRM;lc~(=o>alf$Z(b!(yUg4RjW1c29BB%%#UsP z { docId, userId: user.id, promptName: 'prompt-name', + promptAction: null, }); }); diff --git a/packages/backend/server/src/__tests__/models/copilot-session.spec.ts b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts index 36cb4cc04..237fc77c4 100644 --- a/packages/backend/server/src/__tests__/models/copilot-session.spec.ts +++ b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts @@ -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'); } }); diff --git a/packages/backend/server/src/models/copilot-session.ts b/packages/backend/server/src/models/copilot-session.ts index c335ae364..07990886c 100644 --- a/packages/backend/server/src/models/copilot-session.ts +++ b/packages/backend/server/src/models/copilot-session.ts @@ -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) { + async getChatSessionId( + state: Omit + ) { const extraCondition: Record = {}; 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 diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index 39c9e5400..8695be510 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -1880,5 +1880,14 @@ export async function refreshPrompts(db: PrismaClient) { }, }, }); + + await db.aiSession.updateMany({ + where: { + promptName: prompt.name, + }, + data: { + promptAction: prompt.action ?? null, + }, + }); } } diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index 05b164b94..88f1efb2f 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -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];