Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s

This commit is contained in:
2026-10-07 22:05:49 -07:00
parent 3f9c1409fe
commit dd6cd1ea7b
23 changed files with 1174 additions and 53 deletions
+74
View File
@@ -0,0 +1,74 @@
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 };
}