1
26import { createHash } from "node:crypto";
27import type { PrismaClient } from "@prisma/client";
28import {
29 CONTENT_INDEX_ALIAS,
30 CHUNK_DOC_TYPE,
31 DEFAULT_CHUNK_OPTIONS,
32 chunkText,
33 type GrantSnapshot,
34 type IndexDocInput,
35 type SearchDoc,
36 type SearchDocType,
37 type SearchType,
38} from "@factory/search";
39import { buildGrantSnapshot, pageScopes, type GrantContext, type GrantPrisma } from "./grants.js";
40
41const BODY_CAP = 64 * 1024;
42const BLOCK_CAP = 1_000;
43const RELATION_TITLE_CAP = 50;
44
50export const CHUNK_BODY_THRESHOLD = 1600;
51
52export interface IndexerDeps {
53 prisma: PrismaClient;
54
55 now?: () => Date;
56}
57
58type D = IndexerDeps["prisma"];
59
60function cap(body: string): string {
61 return body.length > BODY_CAP ? body.slice(0, BODY_CAP) : body;
62}
63
64function is(v: unknown): v is string {
65 return typeof v === "string" && v.length > 0;
66}
67
68
69export function blockText(content: unknown): string {
70 if (!content || typeof content !== "object") return "";
71 const c = content as Record<string, unknown>;
72 if (is(c.text)) return c.text;
73 if (Array.isArray(c.parts)) {
74 return (c.parts as unknown[])
75 .map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).text : null))
76 .filter(is)
77 .join("");
78 }
79 if (is(c.title)) return c.title;
80 return "";
81}
82
83
84export function renderPropertyValue(
85 type: string,
86 value: unknown,
87 relTitles: Map<string, string> = new Map()
88): string {
89 if (value === null || value === undefined) return "";
90 const one = (v: unknown): string => {
91 if (v === null || v === undefined) return "";
92 if (typeof v === "string") {
93
94 if (type === "RELATION") return relTitles.get(v) ?? v;
95 return v;
96 }
97 if (typeof v === "number" || typeof v === "boolean") return String(v);
98 if (Array.isArray(v)) return v.map(one).filter(Boolean).join(", ");
99 if (typeof v === "object") {
100 const o = v as Record<string, unknown>;
101 if (is(o.id) && relTitles.has(o.id)) return relTitles.get(o.id) ?? o.id;
102 if (is(o.title)) return o.title;
103 if (is(o.name)) return o.name;
104 if (is(o.email)) return o.email;
105 if (is(o.text)) return o.text;
106 if (is(o.value)) return o.value;
107 return "";
108 }
109 return "";
110 };
111 if (type === "RELATION" && Array.isArray(value)) {
112 return value.map((v) => (is(v) ? (relTitles.get(v) ?? v) : one(v))).filter(Boolean).join(", ");
113 }
114 return one(value);
115}
116
117
118function relationRecordIds(values: { type: string; value: unknown }[]): string[] {
119 const ids = new Set<string>();
120 for (const v of values) {
121 if (v.type !== "RELATION" || !Array.isArray(v.value)) continue;
122 for (const item of v.value) if (typeof item === "string") ids.add(item);
123 }
124 return [...ids];
125}
126
127
156export function expandChunkDocs(doc: IndexDocInput): SearchDoc[] {
157 if (doc.type !== "page" && doc.type !== "record") return [doc];
158 if (doc.body.length <= CHUNK_BODY_THRESHOLD) return [doc];
159 const chunks = chunkText(doc.body, DEFAULT_CHUNK_OPTIONS);
160 if (chunks.length < 2) return [doc];
161 const out: SearchDoc[] = [doc];
162 for (const c of chunks) {
163 out.push({
164 ...doc,
165 docId: `${doc.docId}#c${c.chunkIndex}`,
166 type: CHUNK_DOC_TYPE,
167 body: c.text,
168 chunkId: `c${c.chunkIndex}`,
169 chunkIndex: c.chunkIndex,
170 parentDocId: doc.docId,
171 });
172 }
173 return out;
174}
175
176
177
178
179
180interface PageWithBlocks {
181 id: string;
182 workspaceId: string;
183 teamId: string | null;
184 title: string;
185 icon: string | null;
186 description: string | null;
187 isPrivate: boolean;
188 archivedAt: Date | null;
189 trashedAt: Date | null;
190 deletedAt: Date | null;
191 createdBy: string | null;
192 createdAt: Date;
193 updatedAt: Date;
194 blocks: {
195 id: string;
196 type: string;
197 content: unknown;
198 properties: unknown;
199 position: string;
200 deletedAt: Date | null;
201 createdBy: string | null;
202 createdAt: Date;
203 updatedAt: Date;
204 }[];
205}
206
207async function loadPage(prisma: D, pageId: string): Promise<PageWithBlocks | null> {
208 const page = await prisma.page.findUnique({
209 where: { id: pageId },
210 select: {
211 id: true,
212 workspaceId: true,
213 teamId: true,
214 title: true,
215 icon: true,
216 description: true,
217 isPrivate: true,
218 archivedAt: true,
219 trashedAt: true,
220 deletedAt: true,
221 createdBy: true,
222 createdAt: true,
223 updatedAt: true,
224 blocks: {
225 where: { deletedAt: null },
226 orderBy: { position: "asc" },
227 take: BLOCK_CAP,
228 select: {
229 id: true,
230 type: true,
231 content: true,
232 properties: true,
233 position: true,
234 deletedAt: true,
235 createdBy: true,
236 createdAt: true,
237 updatedAt: true,
238 },
239 },
240 },
241 });
242
243
244
245 return page;
246}
247
248function pageGrantsCtx(page: {
249 workspaceId: string;
250 teamId: string | null;
251 id: string;
252 isPrivate: boolean;
253 createdBy: string | null;
254}): GrantContext {
255 return {
256 workspaceId: page.workspaceId,
257 teamId: page.teamId,
258 scopes: pageScopes(page.workspaceId, page.id, page.teamId),
259 isPrivate: page.isPrivate,
260 shares: [],
261
262
263 creatorId: page.createdBy,
264 };
265}
266
267async function loadPageShares(prisma: D, workspaceId: string, pageId: string) {
268 return prisma.share.findMany({
269 where: { workspaceId, pageId },
270 select: { scope: true, allowSearchIndexing: true, expiresAt: true },
271 });
272}
273
274
281export async function buildPageDoc(deps: IndexerDeps, pageId: string): Promise<IndexDocInput | null> {
282 const { prisma, now } = deps;
283 const page = await loadPage(prisma, pageId);
284 if (!page || page.deletedAt !== null || page.trashedAt !== null) return null;
285 const n = new Date((now ?? Date.now)());
286 const shares = await loadPageShares(prisma, page.workspaceId, page.id);
287 const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, {
288 ...pageGrantsCtx(page),
289 shares,
290 }, n);
291 const parts: string[] = [];
292 if (page.title) parts.push(page.title);
293 if (page.description) parts.push(page.description);
294 for (const b of page.blocks) {
295 const t = blockText(b.content);
296 if (t) parts.push(t);
297 }
298 const body = cap(parts.filter(Boolean).join("\n"));
299 return {
300 docId: `page:${page.id}`,
301 type: "page",
302 id: page.id,
303 workspaceId: page.workspaceId,
304 ...(page.teamId ? { teamId: page.teamId } : {}),
305 title: page.title || "(untitled page)",
306 body,
307 grants,
308 archived: Boolean(page.archivedAt),
309 pageId: page.id,
310 ...(page.createdBy ? { createdBy: page.createdBy } : {}),
311 ...(page.icon ? { icon: page.icon } : {}),
312 url: `/w/${page.workspaceId}/page/${page.id}`,
313 createdAt: page.createdAt.toISOString(),
314 updatedAt: page.updatedAt.toISOString(),
315 };
316}
317
318
319export async function buildBlockDocs(deps: IndexerDeps, pageId: string): Promise<IndexDocInput[]> {
320 const { prisma, now } = deps;
321 const page = await loadPage(prisma, pageId);
322 if (!page || page.deletedAt !== null || page.trashedAt !== null) return [];
323 const n = new Date((now ?? Date.now)());
324 const shares = await loadPageShares(prisma, page.workspaceId, page.id);
325 const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, {
326 ...pageGrantsCtx(page),
327 shares,
328 }, n);
329 const docs: IndexDocInput[] = [];
330 for (const b of page.blocks) {
331 const body = cap(blockText(b.content));
332 if (!body) continue;
333 const title = body.length > 80 ? body.slice(0, 80) + "…" : body;
334 const doc: IndexDocInput = {
335 docId: `block:${b.id}`,
336 type: "block",
337 id: b.id,
338 workspaceId: page.workspaceId,
339 title,
340 body,
341 grants,
342 archived: Boolean(page.trashedAt) || Boolean(page.archivedAt),
343 pageId: page.id,
344 blockId: b.id,
345 url: `/w/${page.workspaceId}/page/${page.id}/${b.id}`,
346 createdAt: b.createdAt.toISOString(),
347 updatedAt: b.updatedAt.toISOString(),
348 };
349 if (page.teamId) doc.teamId = page.teamId;
350 if (b.createdBy) doc.createdBy = b.createdBy;
351 docs.push(doc);
352 }
353 return docs;
354}
355
356
357
358
359
360
361export async function buildDatabaseDoc(deps: IndexerDeps, databaseId: string): Promise<IndexDocInput | null> {
362 const { prisma, now } = deps;
363 const db = await prisma.database.findUnique({
364 where: { id: databaseId },
365 select: {
366 id: true,
367 workspaceId: true,
368 pageId: true,
369 title: true,
370 icon: true,
371 description: true,
372 isArchived: true,
373 createdBy: true,
374 createdAt: true,
375 updatedAt: true,
376 properties: {
377 where: { deletedAt: null },
378 orderBy: { position: "asc" },
379 select: { name: true, isTitle: true },
380 },
381 },
382 });
383 if (!db) return null;
384 const n = new Date((now ?? Date.now)());
385
386
387 const page = db.pageId
388 ? await prisma.page.findUnique({
389 where: { id: db.pageId },
390 select: { teamId: true, isPrivate: true },
391 })
392 : null;
393 const scopes = [
394 { resourceType: "workspace", resourceId: db.workspaceId },
395 ...(page?.teamId ? [{ resourceType: "teamspace", resourceId: page.teamId }] : []),
396 ...(db.pageId ? [{ resourceType: "page", resourceId: db.pageId }] : []),
397 { resourceType: "database", resourceId: db.id },
398 ];
399
400
401 const shares = await prisma.share.findMany({
402 where: { workspaceId: db.workspaceId, recordId: db.id },
403 select: { scope: true, allowSearchIndexing: true, expiresAt: true },
404 });
405 const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, {
406 workspaceId: db.workspaceId,
407 teamId: page?.teamId ?? null,
408 scopes,
409 isPrivate: page?.isPrivate ?? false,
410 shares,
411 creatorId: db.createdBy,
412 }, n);
413 const parts = [db.title, db.description ?? ""];
414 const names = db.properties.map((p) => p.name).filter((x) => !db.properties.find((q) => q.name === x)?.isTitle);
415 if (names.length) parts.push(names.join(", "));
416 return {
417 docId: `database:${db.id}`,
418 type: "database",
419 id: db.id,
420 workspaceId: db.workspaceId,
421 ...(page?.teamId ? { teamId: page.teamId } : {}),
422 title: db.title || "(untitled database)",
423 body: cap(parts.filter(Boolean).join("\n")),
424 grants,
425 archived: db.isArchived,
426 databaseId: db.id,
427 ...(db.pageId ? { pageId: db.pageId } : {}),
428 ...(db.createdBy ? { createdBy: db.createdBy } : {}),
429 ...(db.icon ? { icon: db.icon } : {}),
430 url: `/w/${db.workspaceId}/databases/${db.id}`,
431 createdAt: db.createdAt.toISOString(),
432 updatedAt: db.updatedAt.toISOString(),
433 };
434}
435
436
437
438
439
440
441export async function buildRecordDoc(deps: IndexerDeps, recordId: string): Promise<IndexDocInput | null> {
442 const { prisma, now } = deps;
443 const record = await prisma.databaseRecord.findUnique({
444 where: { id: recordId },
445 select: {
446 id: true,
447 workspaceId: true,
448 databaseId: true,
449 pageId: true,
450 icon: true,
451 archivedAt: true,
452 createdBy: true,
453 createdAt: true,
454 updatedAt: true,
455
456
457
458
459
460 page: { select: { title: true } },
461 properties: {
462 select: {
463 value: true,
464 property: { select: { name: true, isTitle: true, type: true } },
465 },
466 },
467 },
468 });
469 if (!record) return null;
470 const n = new Date((now ?? Date.now)());
471
472
473 const relIds = relationRecordIds(
474 record.properties.map((rp) => ({ type: rp.property.type as string, value: rp.value }))
475 ).slice(0, RELATION_TITLE_CAP);
476 const relTitles = new Map<string, string>();
477 if (relIds.length) {
478 const linked = await prisma.databaseRecord.findMany({
479 where: { id: { in: relIds } },
480 select: { id: true, page: { select: { title: true } } },
481 });
482
483
484 for (const r of linked) {
485 const t = r.page?.title ?? "";
486 relTitles.set(r.id, t ? t : r.id);
487 }
488 }
489
490 let title = record.page?.title ?? "";
491 const lines: string[] = [];
492 for (const rp of record.properties) {
493 const v = renderPropertyValue(rp.property.type as string, rp.value, relTitles);
494 if (!v) continue;
495
496
497 if (rp.property.isTitle && !title) title = v;
498 else lines.push(rp.property.isTitle ? "" : `${rp.property.name}: ${v}`);
499 }
500 const body = cap([title, ...lines].filter(Boolean).join("\n"));
501
502
503 const db = await prisma.database.findUnique({
504 where: { id: record.databaseId },
505 select: { workspaceId: true, pageId: true },
506 });
507 const page = db?.pageId
508 ? await prisma.page.findUnique({
509 where: { id: db.pageId },
510 select: { teamId: true, isPrivate: true, deletedAt: true },
511 })
512 : null;
513 const scopes = [
514 { resourceType: "workspace", resourceId: record.workspaceId },
515 ...(page?.teamId ? [{ resourceType: "teamspace", resourceId: page.teamId }] : []),
516 ...(db?.pageId ? [{ resourceType: "page", resourceId: db.pageId }] : []),
517 { resourceType: "database", resourceId: record.databaseId },
518 { resourceType: "record", resourceId: record.id },
519 ];
520 const shares = await prisma.share.findMany({
521 where: { workspaceId: record.workspaceId, recordId: record.id },
522 select: { scope: true, allowSearchIndexing: true, expiresAt: true },
523 });
524 const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, {
525 workspaceId: record.workspaceId,
526 teamId: page?.teamId ?? null,
527 scopes,
528 isPrivate: page?.isPrivate ?? false,
529 shares,
530 creatorId: record.createdBy ?? null,
531 }, n);
532 return {
533 docId: `record:${record.id}`,
534 type: "record",
535 id: record.id,
536 workspaceId: record.workspaceId,
537 ...(page?.teamId ? { teamId: page.teamId } : {}),
538 title: title || "(untitled record)",
539 body,
540 grants,
541 archived: Boolean(record.archivedAt),
542 databaseId: record.databaseId,
543 recordId: record.id,
544 ...(record.pageId ? { pageId: record.pageId } : {}),
545 ...(record.createdBy ? { createdBy: record.createdBy } : {}),
546 ...(record.icon ? { icon: record.icon } : {}),
547
548 url: `/w/${record.workspaceId}/databases/${record.databaseId}`,
549 createdAt: record.createdAt.toISOString(),
550 updatedAt: record.updatedAt.toISOString(),
551 };
552}
553
554
555
556
557
558
559export async function buildCommentDoc(deps: IndexerDeps, commentId: string): Promise<IndexDocInput | null> {
560 const { prisma, now } = deps;
561 const c = await prisma.comment.findUnique({
562 where: { id: commentId },
563 select: {
564 id: true,
565 workspaceId: true,
566 teamId: true,
567 pageId: true,
568 blockId: true,
569 recordId: true,
570 body: true,
571 createdBy: true,
572 createdAt: true,
573 updatedAt: true,
574 page: { select: { teamId: true, isPrivate: true, deletedAt: true, trashedAt: true } },
575 },
576 });
577 if (!c || !c.pageId || !c.page || c.page.deletedAt || c.page.trashedAt) return null;
578 const n = new Date((now ?? Date.now)());
579 const body = cap(c.body);
580 const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, {
581 workspaceId: c.workspaceId,
582 teamId: c.page.teamId,
583 scopes: pageScopes(c.workspaceId, c.pageId, c.page.teamId),
584 isPrivate: c.page.isPrivate,
585 shares: [],
586 creatorId: c.createdBy,
587 }, n);
588 const doc: IndexDocInput = {
589 docId: `comment:${c.id}`,
590 type: "comment",
591 id: c.id,
592 workspaceId: c.workspaceId,
593 title: body.length > 80 ? body.slice(0, 80) + "…" : body,
594 body,
595 grants,
596 archived: false,
597 pageId: c.pageId,
598 ...(c.blockId ? { blockId: c.blockId } : {}),
599 ...(c.recordId ? { recordId: c.recordId } : {}),
600 createdBy: c.createdBy,
601 url: c.blockId
602 ? `/w/${c.workspaceId}/page/${c.pageId}/${c.blockId}`
603 : `/w/${c.workspaceId}/page/${c.pageId}`,
604 createdAt: c.createdAt.toISOString(),
605 updatedAt: c.updatedAt.toISOString(),
606 };
607 if (c.teamId) doc.teamId = c.teamId;
608 return doc;
609}
610
611
612
613
614
615
616export async function buildFileDoc(deps: IndexerDeps, fileId: string): Promise<IndexDocInput | null> {
617 const { prisma, now } = deps;
618 const f = await prisma.file.findUnique({
619 where: { id: fileId },
620 select: {
621 id: true,
622 workspaceId: true,
623 name: true,
624 mimeType: true,
625 size: true,
626 pageId: true,
627 uploadedBy: true,
628 deletedAt: true,
629 createdAt: true,
630 updatedAt: true,
631 },
632 });
633 if (!f || f.deletedAt !== null) return null;
634 const n = new Date((now ?? Date.now)());
635
636
637 const shares = await prisma.share.findMany({
638 where: {
639 workspaceId: f.workspaceId,
640 ...(f.pageId ? { pageId: f.pageId } : {}),
641 },
642 select: { scope: true, allowSearchIndexing: true, expiresAt: true },
643 });
644 const page = f.pageId
645 ? await prisma.page.findUnique({
646 where: { id: f.pageId },
647 select: { id: true, teamId: true, isPrivate: true },
648 })
649 : null;
650 const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, {
651 workspaceId: f.workspaceId,
652 teamId: page?.teamId ?? null,
653 scopes: [
654 { resourceType: "workspace", resourceId: f.workspaceId },
655 ...(page?.teamId ? [{ resourceType: "teamspace", resourceId: page.teamId }] : []),
656 ...(page ? [{ resourceType: "page", resourceId: page.id }] : []),
657 ],
658 isPrivate: page?.isPrivate ?? false,
659 shares,
660 creatorId: f.uploadedBy,
661 }, n);
662 const doc: IndexDocInput = {
663 docId: `file:${f.id}`,
664 type: "file",
665 id: f.id,
666 workspaceId: f.workspaceId,
667 title: f.name,
668 body: cap(`${f.name}${f.mimeType ? ` (${f.mimeType})` : ""}`),
669 grants,
670 archived: false,
671 ...(f.pageId ? { pageId: f.pageId } : {}),
672 ...(f.uploadedBy ? { createdBy: f.uploadedBy } : {}),
673 ...(f.mimeType ? { mimeType: f.mimeType } : {}),
674 ...(f.size ? { size: f.size } : {}),
675 url: f.pageId ? `/w/${f.workspaceId}/page/${f.pageId}` : `/w/${f.workspaceId}`,
676 createdAt: f.createdAt.toISOString(),
677 updatedAt: f.updatedAt.toISOString(),
678 };
679 if (page?.teamId) doc.teamId = page.teamId;
680 return doc;
681}
682
683
684
685
686
687
694export async function buildUserDoc(deps: IndexerDeps, userId: string, workspaceId: string): Promise<IndexDocInput | null> {
695 const { prisma, now } = deps;
696 const u = await prisma.user.findUnique({
697 where: { id: userId },
698 select: { id: true, name: true, displayName: true, email: true, status: true, updatedAt: true },
699 });
700 if (!u || u.status !== "ACTIVE") return null;
701 const n = new Date((now ?? Date.now)());
702 const title = u.displayName || u.name || u.email;
703 return {
704 docId: `user:${workspaceId}:${u.id}`,
705 type: "user",
706 id: u.id,
707 workspaceId,
708 title,
709 body: cap(`${title}\n${u.email}`),
710
711
712 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
713 archived: false,
714 createdBy: u.id,
715 createdByName: title,
716 url: `/w/${workspaceId}`,
717 createdAt: n.toISOString(),
718 updatedAt: u.updatedAt.toISOString(),
719 };
720}
721
722
723
724
725
726
731export function contentHash(doc: {
732 title: string;
733 body: string;
734 grants: GrantSnapshot;
735 teamId?: string;
736 archived: boolean;
737 icon?: string;
738 size?: number;
739 mimeType?: string;
740}): string {
741 const canonical = JSON.stringify({
742 t: doc.title,
743 b: doc.body,
744 g: doc.grants,
745 teamId: doc.teamId ?? null,
746 archived: doc.archived,
747 icon: doc.icon ?? null,
748 size: doc.size ?? null,
749 mimeType: doc.mimeType ?? null,
750 });
751 return createHash("sha1").update(canonical).digest("hex");
752}
753
754
758export async function upsertMirror(
759 prisma: D,
760 doc: IndexDocInput
761): Promise<{ id: string; contentHash: string }> {
762 const hash = contentHash(doc);
763 const refId = mirrorRefId(doc);
764 const row = await prisma.searchDocument.upsert({
765 where: { type_refId: { type: doc.type, refId } },
766 create: {
767 workspaceId: doc.workspaceId,
768 type: doc.type,
769 refId,
770 title: doc.title,
771 body: doc.body || null,
772 permissions: doc.grants as never,
773 metadata: {
774 contentHash: hash,
775 teamId: doc.teamId ?? null,
776 archived: doc.archived,
777 pageId: doc.pageId ?? null,
778 blockId: doc.blockId ?? null,
779 databaseId: doc.databaseId ?? null,
780 recordId: doc.recordId ?? null,
781 createdBy: doc.createdBy ?? null,
782 icon: doc.icon ?? null,
783 size: doc.size ?? null,
784 mimeType: doc.mimeType ?? null,
785 url: doc.url,
786 createdAt: doc.createdAt,
787 updatedAt: doc.updatedAt,
788 } as never,
789 indexedAt: new Date(),
790 contentHash: hash,
791 },
792 update: {
793 workspaceId: doc.workspaceId,
794 title: doc.title,
795 body: doc.body || null,
796 permissions: doc.grants as never,
797 metadata: {
798 contentHash: hash,
799 teamId: doc.teamId ?? null,
800 archived: doc.archived,
801 pageId: doc.pageId ?? null,
802 blockId: doc.blockId ?? null,
803 databaseId: doc.databaseId ?? null,
804 recordId: doc.recordId ?? null,
805 createdBy: doc.createdBy ?? null,
806 icon: doc.icon ?? null,
807 size: doc.size ?? null,
808 mimeType: doc.mimeType ?? null,
809 url: doc.url,
810 createdAt: doc.createdAt,
811 updatedAt: doc.updatedAt,
812 } as never,
813 indexedAt: new Date(),
814 contentHash: hash,
815 },
816 });
817 return { id: row.id, contentHash: hash };
818}
819
820
821export async function deleteMirror(prisma: D, type: SearchType, refId: string, workspaceId?: string): Promise<void> {
822
823
824 await prisma.searchDocument.deleteMany({
825 where: { type, refId: mirrorRefId({ type, id: refId, workspaceId }) },
826 });
827}
828
829
839export function mirrorRefId(doc: { type: SearchDocType; id: string; workspaceId: string | null | undefined }): string {
840 return doc.type === "user" && doc.workspaceId ? `${doc.workspaceId}:${doc.id}` : doc.id;
841}
842
843
844export async function mirrorRowsForWorkspace(prisma: D, workspaceId: string) {
845 return prisma.searchDocument.findMany({
846 where: { workspaceId },
847 select: { type: true, refId: true, contentHash: true, indexedAt: true },
848 });
849}
850
851
852export function docIdFor(type: SearchType, refId: string): string {
853 return `${type}:${refId}`;
854}
855
856export { CONTENT_INDEX_ALIAS };
857