From 126ecfa45af751952d3ed1be3afe95f439bc39b7 Mon Sep 17 00:00:00 2001 From: duyet Date: Fri, 14 Aug 2026 11:06:04 +0700 Subject: [PATCH 1/2] fix(api): invalidate analytics cache on MCP conversation writes MCP store_conversation writes the same conversations/messages tables as REST but skipped the invalidateAnalyticsCache() helper added in #370. Dashboard analytics could stay stale for the full cache TTL. Call the helper from mcp-conversations create/delete, matching REST, and cover the live MCP write path in the existing analytics suite. Closes #372 Co-Authored-By: Duyet Le Co-Authored-By: duyetbot --- packages/api/src/routes/mcp/tools.ts | 19 +++++--- .../api/src/services/mcp-conversations.ts | 12 +++++ packages/api/test/analytics.test.ts | 45 +++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/api/src/routes/mcp/tools.ts b/packages/api/src/routes/mcp/tools.ts index e2a99dae..6aca10e2 100644 --- a/packages/api/src/routes/mcp/tools.ts +++ b/packages/api/src/routes/mcp/tools.ts @@ -252,13 +252,18 @@ export const TOOLS: ToolDefinition[] = [ zodSchema: storeConversationSchema, inputSchema: jsonSchema(storeConversationSchema), handler: async (c, args: z.infer) => { - const result = await conversationsService.createConversation(c.get("db"), { - projectId: c.get("projectId"), - externalId: args.external_id ?? null, - title: args.title ?? null, - metadata: args.metadata ?? null, - inputMessages: args.messages, - }); + const result = await conversationsService.createConversation( + c.get("db"), + { + projectId: c.get("projectId"), + externalId: args.external_id ?? null, + title: args.title ?? null, + metadata: args.metadata ?? null, + inputMessages: args.messages, + }, + c.executionCtx, + c.env.AUTH_CACHE, + ); if (result.error) throw new ToolError(result.error.code, result.error.message); return { id: result.conversationId, diff --git a/packages/api/src/services/mcp-conversations.ts b/packages/api/src/services/mcp-conversations.ts index 320156e2..67a9d9b7 100644 --- a/packages/api/src/services/mcp-conversations.ts +++ b/packages/api/src/services/mcp-conversations.ts @@ -10,7 +10,10 @@ import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; +// Hono's type, not the workers-types v5 global — see services/conversations.ts. +import type { ExecutionContext } from "hono"; import { conversations, conversationTags, messages } from "../db/schema"; +import { invalidateAnalyticsCache } from "../lib/analytics-cache"; import { CACHE_COUNT_TTL_S } from "../lib/config"; import { generateId } from "../lib/id"; import { serializeMetadata } from "../lib/serialization"; @@ -118,6 +121,8 @@ export function validateCursor( export async function createConversation( db: DrizzleD1Database, input: CreateConversationInput, + executionCtx: ExecutionContext, + cache?: KVNamespace, ): Promise { const { projectId, externalId, title, metadata, inputMessages } = input; const now = Date.now(); @@ -188,6 +193,8 @@ export async function createConversation( await db.insert(messages).values(rows); } + invalidateAnalyticsCache(cache, executionCtx, projectId); + return { conversationId, projectId, @@ -340,6 +347,9 @@ export async function updateConversation( export async function deleteConversation( db: DrizzleD1Database, conversationId: string, + projectId: string, + executionCtx: ExecutionContext, + cache?: KVNamespace, ): Promise { // Batch delete: tags → messages → conversation (order respects FK constraints) await db.batch([ @@ -347,4 +357,6 @@ export async function deleteConversation( db.delete(messages).where(eq(messages.conversationId, conversationId)), db.delete(conversations).where(eq(conversations.id, conversationId)), ]); + + invalidateAnalyticsCache(cache, executionCtx, projectId); } diff --git a/packages/api/test/analytics.test.ts b/packages/api/test/analytics.test.ts index 9825a7cd..ca364a6d 100644 --- a/packages/api/test/analytics.test.ts +++ b/packages/api/test/analytics.test.ts @@ -161,6 +161,51 @@ describe("Analytics", () => { expect(recentIds).toContain(created.id); }); + it("counts match after MCP store_conversation, with no stale cache (issue #372)", async () => { + const baselineRes = await fetchAnalytics(); + const baseline = await baselineRes.json(); + const baseConvs = baseline.summary.total_conversations; + const baseMsgs = baseline.summary.total_messages; + const baseTokens = baseline.summary.total_tokens; + + const store = await SELF.fetch("http://localhost/api/mcp", { + method: "POST", + headers: { + ...authHeaders(), + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "store_conversation", + arguments: { + title: "MCP Analytics Cache Test", + messages: [ + { role: "user", content: "Hello", token_count: 4 }, + { role: "assistant", content: "Hi", token_count: 6 }, + ], + }, + }, + }), + }); + expect(store.status).toBe(200); + const storeJson = (await store.json()) as { + result?: { isError?: boolean; content: Array<{ text: string }> }; + }; + expect(storeJson.result?.isError).toBeUndefined(); + const created = JSON.parse(storeJson.result?.content[0].text ?? "{}") as { id?: string }; + expect(created.id).toBeTruthy(); + + const afterRes = await fetchAnalytics(); + const after = await afterRes.json(); + + expect(after.summary.total_conversations).toBe(baseConvs + 1); + expect(after.summary.total_messages).toBe(baseMsgs + 2); + expect(after.summary.total_tokens).toBe(baseTokens + 10); + }); + it("reflects deleted conversations immediately, with no stale cache (issue #352)", async () => { const createRes = await createConversation({ title: "To Be Deleted", From 9a8f32c363042d4bbfaa11497c4d673800f3d318 Mon Sep 17 00:00:00 2001 From: duyet Date: Fri, 14 Aug 2026 11:10:35 +0700 Subject: [PATCH 2/2] test(api): derive MCP analytics token delta from payload Address Sourcery review on the #372 cache-invalidation test. Co-Authored-By: Duyet Le Co-Authored-By: duyetbot --- packages/api/test/analytics.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/api/test/analytics.test.ts b/packages/api/test/analytics.test.ts index ca364a6d..70c5b27b 100644 --- a/packages/api/test/analytics.test.ts +++ b/packages/api/test/analytics.test.ts @@ -167,6 +167,10 @@ describe("Analytics", () => { const baseConvs = baseline.summary.total_conversations; const baseMsgs = baseline.summary.total_messages; const baseTokens = baseline.summary.total_tokens; + const mcpMessages = [ + { role: "user", content: "Hello", token_count: 4 }, + { role: "assistant", content: "Hi", token_count: 6 }, + ]; const store = await SELF.fetch("http://localhost/api/mcp", { method: "POST", @@ -182,10 +186,7 @@ describe("Analytics", () => { name: "store_conversation", arguments: { title: "MCP Analytics Cache Test", - messages: [ - { role: "user", content: "Hello", token_count: 4 }, - { role: "assistant", content: "Hi", token_count: 6 }, - ], + messages: mcpMessages, }, }, }), @@ -202,8 +203,12 @@ describe("Analytics", () => { const after = await afterRes.json(); expect(after.summary.total_conversations).toBe(baseConvs + 1); - expect(after.summary.total_messages).toBe(baseMsgs + 2); - expect(after.summary.total_tokens).toBe(baseTokens + 10); + expect(after.summary.total_messages).toBe(baseMsgs + mcpMessages.length); + const expectedTokenDelta = mcpMessages.reduce( + (sum, message) => sum + (message.token_count ?? 0), + 0, + ); + expect(after.summary.total_tokens).toBe(baseTokens + expectedTokenDelta); }); it("reflects deleted conversations immediately, with no stale cache (issue #352)", async () => {