75 lines
3.4 KiB
TypeScript
75 lines
3.4 KiB
TypeScript
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<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
|
||
|
|
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
|
||
|
|
}
|