/** * apps/worker — search doc builders (TASK-0096 §2–3, search-architecture §3). * * One `SearchDoc` (camelCase, the `@factory/search` wire form) per * (type, refId), for all seven indexed types: page, block, database, record, * comment, file, user. Shared by the stream consumer, the reindex job and the * contentHash reconcile sweep. Builders return `null` when the source row is * gone or fully trashed-and-purged (→ the caller deletes the doc). * * Body composition (TASK-0096 §3): * - page: title + description + all block text (depth-first, capped at * 1000 blocks) * - block: the block's own text (blocks with no text are not indexed as * separate docs — a page body already covers them) * - database: title + description + property names * - record: title (the isTitle property) + rendered property values * (relations → linked-record titles, batched + capped) * - comment: comment text * - file: name + mime type (mime/size may lag the S3 finalization — * converges via resync/reconcile; search §0 single-writer note) * - user: display name + email * * `url` is the client-side deep link (`/w//` — the web * resolver accepts a workspace slug OR id, so the workspaceId is safe to use). */ import { createHash } from "node:crypto"; import type { PrismaClient } from "@prisma/client"; import { CONTENT_INDEX_ALIAS, CHUNK_DOC_TYPE, DEFAULT_CHUNK_OPTIONS, chunkText, type GrantSnapshot, type IndexDocInput, type SearchDoc, type SearchDocType, type SearchType, } from "@factory/search"; import { buildGrantSnapshot, pageScopes, type GrantContext, type GrantPrisma } from "./grants.js"; const BODY_CAP = 64 * 1024; // 64k chars max in `body` const BLOCK_CAP = 1_000; // depth-first block cap per page (TASK-0096 §3) const RELATION_TITLE_CAP = 50; // linked-record titles per record body /** * A page/record `body` longer than this is ALSO emitted as chunk docs (the * kNN leg's target, TASK-0099 §3). 1600 = 2 × the chunker's 800-char target, * so only genuinely multi-chunk bodies produce chunks — short bodies index as * the whole doc alone (no `#c` docs), keeping the common case lean. */ export const CHUNK_BODY_THRESHOLD = 1600; export interface IndexerDeps { prisma: PrismaClient; /** Injectable clock (tests). */ now?: () => Date; } type D = IndexerDeps["prisma"]; function cap(body: string): string { return body.length > BODY_CAP ? body.slice(0, BODY_CAP) : body; } function is(v: unknown): v is string { return typeof v === "string" && v.length > 0; } /** Extract plain text from a block `content` Json (text / parts[] / …). */ export function blockText(content: unknown): string { if (!content || typeof content !== "object") return ""; const c = content as Record; if (is(c.text)) return c.text; if (Array.isArray(c.parts)) { return (c.parts as unknown[]) .map((p) => (p && typeof p === "object" ? (p as Record).text : null)) .filter(is) .join(""); } if (is(c.title)) return c.title; return ""; } /** Render a record property value to text (relations resolved in `relTitles`). */ export function renderPropertyValue( type: string, value: unknown, relTitles: Map = new Map() ): string { if (value === null || value === undefined) return ""; const one = (v: unknown): string => { if (v === null || v === undefined) return ""; if (typeof v === "string") { // relation: value is a recordId (or ids) — render as the linked title. if (type === "RELATION") return relTitles.get(v) ?? v; return v; } if (typeof v === "number" || typeof v === "boolean") return String(v); if (Array.isArray(v)) return v.map(one).filter(Boolean).join(", "); if (typeof v === "object") { const o = v as Record; if (is(o.id) && relTitles.has(o.id)) return relTitles.get(o.id) ?? o.id; if (is(o.title)) return o.title; if (is(o.name)) return o.name; if (is(o.email)) return o.email; if (is(o.text)) return o.text; if (is(o.value)) return o.value; return ""; } return ""; }; if (type === "RELATION" && Array.isArray(value)) { return value.map((v) => (is(v) ? (relTitles.get(v) ?? v) : one(v))).filter(Boolean).join(", "); } return one(value); } /** Collect the referenced record ids from a record's property values. */ function relationRecordIds(values: { type: string; value: unknown }[]): string[] { const ids = new Set(); for (const v of values) { if (v.type !== "RELATION" || !Array.isArray(v.value)) continue; for (const item of v.value) if (typeof item === "string") ids.add(item); } return [...ids]; } /** * Expand one whole-doc into its indexable form, adding CHUNK docs for * page/record bodies long enough to warrant them (TASK-0099 §3, recorded * decision — chunking is page + record only; comment/file/database/blocks are * deferred to Phase 12). * * A whole doc is ALWAYS returned first (keyword search stays whole-doc). When * the body exceeds {@link CHUNK_BODY_THRESHOLD} and splits into ≥ 2 chunks, the * chunk docs follow — one per `chunkText` chunk, each carrying the parent's * full grants/facet snapshot (the grant snapshot is per resource, not per * chunk) and a `parentDocId` so the query layer can de-dup to the parent. * * Chunk doc conventions (TASK-0099, recorded reconciliation — NOT the * spec's `:chunk:` form): * docId = `#c` (e.g. `page:pg1#c2`) * type = "chunk" (index-internal, `CHUNK_DOC_TYPE`) * chunkId = `c` (the chunk's own stable id) * chunkIndex = n (0-based) * parentDocId = `` * body = the chunk's text * Chunk docs have NO `search_documents` mirror row (they are derived from the * parent); deletion is parent-driven via `SearchClient.deleteChunksOf`. * * Returns {@link SearchDoc}s (the stored wire form): chunk docs need * `chunkIndex`/`parentDocId`, which live on `SearchDoc`, not `IndexDocInput` * (the input has no such fields — recorded: we deliberately do NOT widen * `IndexDocInput` to avoid churn in the sibling's in-flight union edits). * Whole docs pass through unchanged (an `IndexDocInput` is a valid `SearchDoc`). */ export function expandChunkDocs(doc: IndexDocInput): SearchDoc[] { if (doc.type !== "page" && doc.type !== "record") return [doc]; if (doc.body.length <= CHUNK_BODY_THRESHOLD) return [doc]; const chunks = chunkText(doc.body, DEFAULT_CHUNK_OPTIONS); if (chunks.length < 2) return [doc]; const out: SearchDoc[] = [doc]; for (const c of chunks) { out.push({ ...doc, docId: `${doc.docId}#c${c.chunkIndex}`, type: CHUNK_DOC_TYPE, body: c.text, chunkId: `c${c.chunkIndex}`, chunkIndex: c.chunkIndex, parentDocId: doc.docId, }); } return out; } // --------------------------------------------------------------------------- // Pages (+ their blocks) // --------------------------------------------------------------------------- interface PageWithBlocks { id: string; workspaceId: string; teamId: string | null; title: string; icon: string | null; description: string | null; isPrivate: boolean; archivedAt: Date | null; trashedAt: Date | null; deletedAt: Date | null; createdBy: string | null; createdAt: Date; updatedAt: Date; blocks: { id: string; type: string; content: unknown; properties: unknown; position: string; deletedAt: Date | null; createdBy: string | null; createdAt: Date; updatedAt: Date; }[]; } async function loadPage(prisma: D, pageId: string): Promise { const page = await prisma.page.findUnique({ where: { id: pageId }, select: { id: true, workspaceId: true, teamId: true, title: true, icon: true, description: true, isPrivate: true, archivedAt: true, trashedAt: true, deletedAt: true, createdBy: true, createdAt: true, updatedAt: true, blocks: { where: { deletedAt: null }, orderBy: { position: "asc" }, take: BLOCK_CAP, select: { id: true, type: true, content: true, properties: true, position: true, deletedAt: true, createdBy: true, createdAt: true, updatedAt: true, }, }, }, }); // Purged (hard-deleted) pages leave no doc. Trashed pages DO keep a doc but // are marked archived (hidden by default, searchable via opt-in) until // purge. return page; } function pageGrantsCtx(page: { workspaceId: string; teamId: string | null; id: string; isPrivate: boolean; createdBy: string | null; }): GrantContext { return { workspaceId: page.workspaceId, teamId: page.teamId, scopes: pageScopes(page.workspaceId, page.id, page.teamId), isPrivate: page.isPrivate, shares: [], // TASK-0100 S5: the page creator is an implicit viewer of their own page // (and its block docs) — see GrantContext.creatorId. creatorId: page.createdBy, }; } async function loadPageShares(prisma: D, workspaceId: string, pageId: string) { return prisma.share.findMany({ where: { workspaceId, pageId }, select: { scope: true, allowSearchIndexing: true, expiresAt: true }, }); } /** * The PAGE doc (one doc; blocks with text > 0 get their own `block` doc too). * Returns null when the page is trashed OR purged — trashed = deleted from the * index (search must not surface trashed content; arch §1.1: "trashed = deleted * from index"), so a trashed page leaves no doc and its mirror row is dropped. * Restore re-adds it via the reconciliation sweep (source → index direction). */ export async function buildPageDoc(deps: IndexerDeps, pageId: string): Promise { const { prisma, now } = deps; const page = await loadPage(prisma, pageId); if (!page || page.deletedAt !== null || page.trashedAt !== null) return null; const n = new Date((now ?? Date.now)()); const shares = await loadPageShares(prisma, page.workspaceId, page.id); const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, { ...pageGrantsCtx(page), shares, }, n); const parts: string[] = []; if (page.title) parts.push(page.title); if (page.description) parts.push(page.description); for (const b of page.blocks) { const t = blockText(b.content); if (t) parts.push(t); } const body = cap(parts.filter(Boolean).join("\n")); return { docId: `page:${page.id}`, type: "page", id: page.id, workspaceId: page.workspaceId, ...(page.teamId ? { teamId: page.teamId } : {}), title: page.title || "(untitled page)", body, grants, archived: Boolean(page.archivedAt), pageId: page.id, ...(page.createdBy ? { createdBy: page.createdBy } : {}), ...(page.icon ? { icon: page.icon } : {}), url: `/w/${page.workspaceId}/page/${page.id}`, createdAt: page.createdAt.toISOString(), updatedAt: page.updatedAt.toISOString(), }; } /** The BLOCK docs of a page (only blocks with non-empty text get a doc). Trashed page → []. */ export async function buildBlockDocs(deps: IndexerDeps, pageId: string): Promise { const { prisma, now } = deps; const page = await loadPage(prisma, pageId); if (!page || page.deletedAt !== null || page.trashedAt !== null) return []; const n = new Date((now ?? Date.now)()); const shares = await loadPageShares(prisma, page.workspaceId, page.id); const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, { ...pageGrantsCtx(page), shares, }, n); const docs: IndexDocInput[] = []; for (const b of page.blocks) { const body = cap(blockText(b.content)); if (!body) continue; // no text → not a standalone doc (page body covers it) const title = body.length > 80 ? body.slice(0, 80) + "…" : body; const doc: IndexDocInput = { docId: `block:${b.id}`, type: "block", id: b.id, workspaceId: page.workspaceId, title, body, grants, archived: Boolean(page.trashedAt) || Boolean(page.archivedAt), pageId: page.id, blockId: b.id, url: `/w/${page.workspaceId}/page/${page.id}/${b.id}`, createdAt: b.createdAt.toISOString(), updatedAt: b.updatedAt.toISOString(), }; if (page.teamId) doc.teamId = page.teamId; if (b.createdBy) doc.createdBy = b.createdBy; docs.push(doc); } return docs; } // --------------------------------------------------------------------------- // Databases // --------------------------------------------------------------------------- /** DATABASE doc. Null when the row is gone (no trashed flag — archived = isArchived). */ export async function buildDatabaseDoc(deps: IndexerDeps, databaseId: string): Promise { const { prisma, now } = deps; const db = await prisma.database.findUnique({ where: { id: databaseId }, select: { id: true, workspaceId: true, pageId: true, title: true, icon: true, description: true, isArchived: true, createdBy: true, createdAt: true, updatedAt: true, properties: { where: { deletedAt: null }, orderBy: { position: "asc" }, select: { name: true, isTitle: true }, }, }, }); if (!db) return null; const n = new Date((now ?? Date.now)()); // A database shares its page's privacy + grants when page-backed (the page // guard scopes resources to the database; the database IS the page's content). const page = db.pageId ? await prisma.page.findUnique({ where: { id: db.pageId }, select: { teamId: true, isPrivate: true }, }) : null; const scopes = [ { resourceType: "workspace", resourceId: db.workspaceId }, ...(page?.teamId ? [{ resourceType: "teamspace", resourceId: page.teamId }] : []), ...(db.pageId ? [{ resourceType: "page", resourceId: db.pageId }] : []), { resourceType: "database", resourceId: db.id }, ]; // Database links are stored with the database id in `Share.recordId` // (share.service.ts — recorded quirk: there is no databaseId column on Share). const shares = await prisma.share.findMany({ where: { workspaceId: db.workspaceId, recordId: db.id }, select: { scope: true, allowSearchIndexing: true, expiresAt: true }, }); const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, { workspaceId: db.workspaceId, teamId: page?.teamId ?? null, scopes, isPrivate: page?.isPrivate ?? false, shares, creatorId: db.createdBy, // TASK-0100 S5: the database creator views their own doc }, n); const parts = [db.title, db.description ?? ""]; const names = db.properties.map((p) => p.name).filter((x) => !db.properties.find((q) => q.name === x)?.isTitle); if (names.length) parts.push(names.join(", ")); return { docId: `database:${db.id}`, type: "database", id: db.id, workspaceId: db.workspaceId, ...(page?.teamId ? { teamId: page.teamId } : {}), title: db.title || "(untitled database)", body: cap(parts.filter(Boolean).join("\n")), grants, archived: db.isArchived, databaseId: db.id, ...(db.pageId ? { pageId: db.pageId } : {}), ...(db.createdBy ? { createdBy: db.createdBy } : {}), ...(db.icon ? { icon: db.icon } : {}), url: `/w/${db.workspaceId}/databases/${db.id}`, createdAt: db.createdAt.toISOString(), updatedAt: db.updatedAt.toISOString(), }; } // --------------------------------------------------------------------------- // Records // --------------------------------------------------------------------------- /** RECORD doc. Null when purged (archived records keep a doc, marked archived). */ export async function buildRecordDoc(deps: IndexerDeps, recordId: string): Promise { const { prisma, now } = deps; const record = await prisma.databaseRecord.findUnique({ where: { id: recordId }, select: { id: true, workspaceId: true, databaseId: true, pageId: true, icon: true, archivedAt: true, createdBy: true, createdAt: true, updatedAt: true, // A record IS a page: its title lives on the linked Page row, NOT in // `DatabaseRecordProperty` (createRecord/updateRecord write the title // to `page`, never as a record property — the `isTitle` property row is // never written). Without this join every record doc indexed as // "(untitled record)" and was unfindable by title in global search. page: { select: { title: true } }, properties: { select: { value: true, property: { select: { name: true, isTitle: true, type: true } }, }, }, }, }); if (!record) return null; const n = new Date((now ?? Date.now)()); // Relation values → linked-record titles (batched, capped). const relIds = relationRecordIds( record.properties.map((rp) => ({ type: rp.property.type as string, value: rp.value })) ).slice(0, RELATION_TITLE_CAP); const relTitles = new Map(); if (relIds.length) { const linked = await prisma.databaseRecord.findMany({ where: { id: { in: relIds } }, select: { id: true, page: { select: { title: true } } }, }); // Linked-record titles also live on the Page row (records are pages); a // missing/empty title falls back to the record id. for (const r of linked) { const t = r.page?.title ?? ""; relTitles.set(r.id, t ? t : r.id); } } let title = record.page?.title ?? ""; const lines: string[] = []; for (const rp of record.properties) { const v = renderPropertyValue(rp.property.type as string, rp.value, relTitles); if (!v) continue; // Prefer the Page title (the canonical record title); the `isTitle` // property row is a legacy fallback (it is not written by the API). if (rp.property.isTitle && !title) title = v; else lines.push(rp.property.isTitle ? "" : `${rp.property.name}: ${v}`); } const body = cap([title, ...lines].filter(Boolean).join("\n")); // A record inherits its database's page privacy/grants (it IS a page row). const db = await prisma.database.findUnique({ where: { id: record.databaseId }, select: { workspaceId: true, pageId: true }, }); const page = db?.pageId ? await prisma.page.findUnique({ where: { id: db.pageId }, select: { teamId: true, isPrivate: true, deletedAt: true }, }) : null; const scopes = [ { resourceType: "workspace", resourceId: record.workspaceId }, ...(page?.teamId ? [{ resourceType: "teamspace", resourceId: page.teamId }] : []), ...(db?.pageId ? [{ resourceType: "page", resourceId: db.pageId }] : []), { resourceType: "database", resourceId: record.databaseId }, { resourceType: "record", resourceId: record.id }, ]; const shares = await prisma.share.findMany({ where: { workspaceId: record.workspaceId, recordId: record.id }, select: { scope: true, allowSearchIndexing: true, expiresAt: true }, }); const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, { workspaceId: record.workspaceId, teamId: page?.teamId ?? null, scopes, isPrivate: page?.isPrivate ?? false, shares, creatorId: record.createdBy ?? null, // TASK-0100 S5: the record creator views their own doc }, n); return { docId: `record:${record.id}`, type: "record", id: record.id, workspaceId: record.workspaceId, ...(page?.teamId ? { teamId: page.teamId } : {}), title: title || "(untitled record)", body, grants, archived: Boolean(record.archivedAt), databaseId: record.databaseId, recordId: record.id, ...(record.pageId ? { pageId: record.pageId } : {}), ...(record.createdBy ? { createdBy: record.createdBy } : {}), ...(record.icon ? { icon: record.icon } : {}), // Records deep-link to their database view (no per-record row route). url: `/w/${record.workspaceId}/databases/${record.databaseId}`, createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), }; } // --------------------------------------------------------------------------- // Comments // --------------------------------------------------------------------------- /** COMMENT doc. Null when the comment (or its page) is gone. */ export async function buildCommentDoc(deps: IndexerDeps, commentId: string): Promise { const { prisma, now } = deps; const c = await prisma.comment.findUnique({ where: { id: commentId }, select: { id: true, workspaceId: true, teamId: true, pageId: true, blockId: true, recordId: true, body: true, createdBy: true, createdAt: true, updatedAt: true, page: { select: { teamId: true, isPrivate: true, deletedAt: true, trashedAt: true } }, }, }); if (!c || !c.pageId || !c.page || c.page.deletedAt || c.page.trashedAt) return null; const n = new Date((now ?? Date.now)()); const body = cap(c.body); const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, { workspaceId: c.workspaceId, teamId: c.page.teamId, scopes: pageScopes(c.workspaceId, c.pageId, c.page.teamId), isPrivate: c.page.isPrivate, shares: [], creatorId: c.createdBy, // TASK-0100 S5: the comment author views their own comment }, n); const doc: IndexDocInput = { docId: `comment:${c.id}`, type: "comment", id: c.id, workspaceId: c.workspaceId, title: body.length > 80 ? body.slice(0, 80) + "…" : body, body, grants, archived: false, pageId: c.pageId, ...(c.blockId ? { blockId: c.blockId } : {}), ...(c.recordId ? { recordId: c.recordId } : {}), createdBy: c.createdBy, url: c.blockId ? `/w/${c.workspaceId}/page/${c.pageId}/${c.blockId}` : `/w/${c.workspaceId}/page/${c.pageId}`, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), }; if (c.teamId) doc.teamId = c.teamId; return doc; } // --------------------------------------------------------------------------- // Files // --------------------------------------------------------------------------- /** FILE doc. Null when purged. mime/size may lag the S3 finalization (search §0). */ export async function buildFileDoc(deps: IndexerDeps, fileId: string): Promise { const { prisma, now } = deps; const f = await prisma.file.findUnique({ where: { id: fileId }, select: { id: true, workspaceId: true, name: true, mimeType: true, size: true, pageId: true, uploadedBy: true, deletedAt: true, createdAt: true, updatedAt: true, }, }); if (!f || f.deletedAt !== null) return null; const n = new Date((now ?? Date.now)()); // The file read guard scopes to the WORKSPACE only (no resource-level file // guard in the API), so the file doc inherits the workspace floor. const shares = await prisma.share.findMany({ where: { workspaceId: f.workspaceId, ...(f.pageId ? { pageId: f.pageId } : {}), }, select: { scope: true, allowSearchIndexing: true, expiresAt: true }, }); const page = f.pageId ? await prisma.page.findUnique({ where: { id: f.pageId }, select: { id: true, teamId: true, isPrivate: true }, }) : null; const grants = await buildGrantSnapshot(prisma as unknown as GrantPrisma, { workspaceId: f.workspaceId, teamId: page?.teamId ?? null, scopes: [ { resourceType: "workspace", resourceId: f.workspaceId }, ...(page?.teamId ? [{ resourceType: "teamspace", resourceId: page.teamId }] : []), ...(page ? [{ resourceType: "page", resourceId: page.id }] : []), ], isPrivate: page?.isPrivate ?? false, shares, creatorId: f.uploadedBy, // TASK-0100 S5: the uploader views their own file }, n); const doc: IndexDocInput = { docId: `file:${f.id}`, type: "file", id: f.id, workspaceId: f.workspaceId, title: f.name, body: cap(`${f.name}${f.mimeType ? ` (${f.mimeType})` : ""}`), grants, archived: false, ...(f.pageId ? { pageId: f.pageId } : {}), ...(f.uploadedBy ? { createdBy: f.uploadedBy } : {}), ...(f.mimeType ? { mimeType: f.mimeType } : {}), ...(f.size ? { size: f.size } : {}), url: f.pageId ? `/w/${f.workspaceId}/page/${f.pageId}` : `/w/${f.workspaceId}`, createdAt: f.createdAt.toISOString(), updatedAt: f.updatedAt.toISOString(), }; if (page?.teamId) doc.teamId = page.teamId; return doc; } // --------------------------------------------------------------------------- // Users // --------------------------------------------------------------------------- /** * USER doc (one PER WORKSPACE — the single index shares one `_id` space, so a * user's doc is stamped per workspace: docId `user::`, refId * `:` to satisfy the mirror's unique `[type, refId]`). The tenant * filter then scopes each doc to its workspace; the result's `id` stays the * plain user id (deep link). Null when the account is gone or suspended. */ export async function buildUserDoc(deps: IndexerDeps, userId: string, workspaceId: string): Promise { const { prisma, now } = deps; const u = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, name: true, displayName: true, email: true, status: true, updatedAt: true }, }); if (!u || u.status !== "ACTIVE") return null; const n = new Date((now ?? Date.now)()); const title = u.displayName || u.name || u.email; return { docId: `user:${workspaceId}:${u.id}`, type: "user", id: u.id, workspaceId, title, body: cap(`${title}\n${u.email}`), // User docs are visible to every member of the workspace they belong to // (you must see your collaborators to @mention / assign them). grants: { public: false, workspace: true, user: false, teams: [], users: [] }, archived: false, createdBy: u.id, createdByName: title, url: `/w/${workspaceId}`, createdAt: n.toISOString(), updatedAt: u.updatedAt.toISOString(), }; } // --------------------------------------------------------------------------- // Mirror + contentHash // --------------------------------------------------------------------------- /** * The reconciliation anchor (search §1.3/§7.3): SHA-1 of the SOURCE content * (title + body + grants + the facet fields the doc carries) — recomputed by * the reconcile sweep from the source rows and compared to the mirror row. */ export function contentHash(doc: { title: string; body: string; grants: GrantSnapshot; teamId?: string; archived: boolean; icon?: string; size?: number; mimeType?: string; }): string { const canonical = JSON.stringify({ t: doc.title, b: doc.body, g: doc.grants, teamId: doc.teamId ?? null, archived: doc.archived, icon: doc.icon ?? null, size: doc.size ?? null, mimeType: doc.mimeType ?? null, }); return createHash("sha1").update(canonical).digest("hex"); } /** * Upsert the `search_documents` mirror row for a doc. `permissions` stores the * grant snapshot (audit), `metadata` the deep-link facets + contentHash. */ export async function upsertMirror( prisma: D, doc: IndexDocInput ): Promise<{ id: string; contentHash: string }> { const hash = contentHash(doc); const refId = mirrorRefId(doc); const row = await prisma.searchDocument.upsert({ where: { type_refId: { type: doc.type, refId } }, create: { workspaceId: doc.workspaceId, type: doc.type, refId, title: doc.title, body: doc.body || null, permissions: doc.grants as never, metadata: { contentHash: hash, teamId: doc.teamId ?? null, archived: doc.archived, pageId: doc.pageId ?? null, blockId: doc.blockId ?? null, databaseId: doc.databaseId ?? null, recordId: doc.recordId ?? null, createdBy: doc.createdBy ?? null, icon: doc.icon ?? null, size: doc.size ?? null, mimeType: doc.mimeType ?? null, url: doc.url, createdAt: doc.createdAt, updatedAt: doc.updatedAt, } as never, indexedAt: new Date(), contentHash: hash, }, update: { workspaceId: doc.workspaceId, title: doc.title, body: doc.body || null, permissions: doc.grants as never, metadata: { contentHash: hash, teamId: doc.teamId ?? null, archived: doc.archived, pageId: doc.pageId ?? null, blockId: doc.blockId ?? null, databaseId: doc.databaseId ?? null, recordId: doc.recordId ?? null, createdBy: doc.createdBy ?? null, icon: doc.icon ?? null, size: doc.size ?? null, mimeType: doc.mimeType ?? null, url: doc.url, createdAt: doc.createdAt, updatedAt: doc.updatedAt, } as never, indexedAt: new Date(), contentHash: hash, }, }); return { id: row.id, contentHash: hash }; } /** Delete the mirror row for a doc (purge path). */ export async function deleteMirror(prisma: D, type: SearchType, refId: string, workspaceId?: string): Promise { // Plain (type, refId) — the compound-unique `type_refId` key is only on the // unique-input types (upsert); deleteMany takes the non-unique WhereInput. await prisma.searchDocument.deleteMany({ where: { type, refId: mirrorRefId({ type, id: refId, workspaceId }) }, }); } /** * The mirror's `refId` (unique with `type`). All types except `user` use the * plain row id; user docs are workspace-scoped (`:`) because the * mirror's unique `[type, refId]` is global across workspaces. * * Takes `SearchDocType` (not `SearchType`) because callers pass a full * {@link IndexDocInput} (whose `type` is `SearchDocType`, TASK-0099). Chunk * docs have NO mirror row (the worker skips the mirror for them), so this is * only ever invoked with whole docs at runtime. */ export function mirrorRefId(doc: { type: SearchDocType; id: string; workspaceId: string | null | undefined }): string { return doc.type === "user" && doc.workspaceId ? `${doc.workspaceId}:${doc.id}` : doc.id; } /** The mirror rows of a workspace (reconcile sweep input). */ export async function mirrorRowsForWorkspace(prisma: D, workspaceId: string) { return prisma.searchDocument.findMany({ where: { workspaceId }, select: { type: true, refId: true, contentHash: true, indexedAt: true }, }); } /** A docId in the index (for deletion). */ export function docIdFor(type: SearchType, refId: string): string { return `${type}:${refId}`; } export { CONTENT_INDEX_ALIAS };