import { Prisma } from "@prisma/client"; import { prisma } from "./db.js"; export const WORKSPACE_SEARCH_LIMIT = 100; // Treat input as literal Unicode words, never FTS operators/column expressions. // Prefix matching supports type-ahead, including short terms such as "AI". export function workspaceSearchExpression(query: string): string | null { const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu); return words?.map((word) => `"${word}"*`).join(" AND ") ?? null; } export type WorkspaceSearchHit = { type: "chat" | "search"; id: string; searchSnippet: string | null; }; export function workspaceSearchSQL(expression: string) { // Batch excerpts after limiting matches. Unary + prevents SQLite from using // the IN clause as repeated rowid lookups (and restarting FTS for every row). // FTS still uses MATCH and the rowid bounds; IN filters that single scan. return Prisma.sql` WITH chat_hits AS ( SELECT c.id, NULL AS message_rowid FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid WHERE "ChatSearch" MATCH ${expression} UNION ALL SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid WHERE "MessageSearch" MATCH ${expression} GROUP BY m.chatId ), matches AS ( SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid, NULL AS search_rowid FROM chat_hits h JOIN "Chat" c ON c.id = h.id GROUP BY c.id UNION ALL SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid WHERE "SavedSearchSearch" MATCH ${expression} ), limited AS MATERIALIZED ( SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1} ), message_excerpts AS MATERIALIZED ( SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet FROM "MessageSearch" WHERE "MessageSearch" MATCH ${expression} AND rowid >= (SELECT MIN(message_rowid) FROM limited) AND rowid <= (SELECT MAX(message_rowid) FROM limited) AND +rowid IN (SELECT message_rowid FROM limited) ), search_excerpts AS MATERIALIZED ( SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet FROM "SavedSearchSearch" WHERE "SavedSearchSearch" MATCH ${expression} AND rowid >= (SELECT MIN(search_rowid) FROM limited) AND rowid <= (SELECT MAX(search_rowid) FROM limited) AND +rowid IN (SELECT search_rowid FROM limited) ) SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet FROM limited LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid ORDER BY updatedAt DESC, type, id `; } export async function searchWorkspace(query: string) { const expression = workspaceSearchExpression(query); if (!expression) return { hits: [], hasMore: false }; // Match via the inverted indexes first. Hydrate summaries and generate excerpts // only for the bounded result set, never fetch full transcripts into JS. const hits = await prisma.$queryRaw(workspaceSearchSQL(expression)); return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT }; }