/** * @factory/search — shared types for the search layer (ARCHITECTURE.md §6, * docs/architecture/search-architecture.md §3/§6). * * Pure types + constants only: the indexer (worker), the query API * (`apps/api/modules/search`) and the web client all import from here. * * Phase-8 reconciliation (recorded decision, see docs/architecture/search.md * § "Layout decision"): the stored document is **camelCase** and lives in a * **single** index (alias `factory-content`) whose docs are tenant-filtered by * the mandatory `workspaceId` clause at query time. The earlier Phase-0 * scaffold used a per-workspace index (`factory-search-`) and a mix of * snake_case / `grants.*` / `title.raw` field paths that did not match the * stored doc; this file is now the single source of truth for the doc shape * so the mapping, the query builders and the client all agree on one wire form. */ /** Searchable document kinds (mirrors `searchTypeSchema` in `@factory/shared/dto`). */ export type SearchType = "page" | "block" | "database" | "record" | "comment" | "file" | "user"; /** * Every `type` a doc stored in the index can carry. TASK-0099 adds * `"chunk"` (one doc per embedded chunk of a page/record body). Chunk docs * are **index-internal**: the public `types` filter (`SEARCH_TYPES` / the * API's zod `searchTypeSchema`) intentionally stays the 7 public kinds, so * keyword search can never request or return chunk docs — whole-doc `body` * keyword behavior is unchanged (recorded reconciliation, arch doc §0). * The mapping's `type` enum accepts `"chunk"` (`TYPE_ENUM`, mapping.ts). */ export type SearchDocType = SearchType | "chunk"; export const SEARCH_TYPES: readonly SearchType[] = [ "page", "block", "database", "record", "comment", "file", "user", ] as const; /** The index-internal chunk doc `type` (TASK-0099) — never a public filter value. */ export const CHUNK_DOC_TYPE: "chunk" = "chunk"; /** * Map a stored doc's `type` (which may be the index-internal `"chunk"`) to * the PUBLIC display `SearchType` the query API returns. Chunk docs surface * as their parent's kind, derived from the `parentDocId` prefix * (`"page:pg1"` → `"page"`); a chunk doc without a resolvable parent falls * back to `"page"`. Whole docs map to themselves. (TASK-0099.) */ export function displayType(doc: { type: SearchDocType; parentDocId?: string }): SearchType { if (doc.type !== CHUNK_DOC_TYPE) return doc.type; const prefix = doc.parentDocId?.split(":")[0]; return (SEARCH_TYPES as readonly string[]).includes(prefix ?? "") ? (prefix as SearchType) : "page"; } /** * Visibility snapshot stamped into every indexed document at index time * (search-architecture §5.1). Query-time permission filtering compares the * viewer's identity against this snapshot — the index never exposes a doc the * viewer may not see. It is a *denormalized closure of VIEW-or-better grants*; * it is data, not policy — the guard (`effectiveLevel`) is the source of * truth and is never made stale by a missing re-stamp (worst case a grant * change is visible to search for one reconciliation sweep, and opening the * doc still enforces the guard). */ export interface GrantSnapshot { /** A Share with scope PUBLIC exists (and search indexing is allowed). */ public: boolean; /** Workspace-visible (default for non-private workspace content). */ workspace: boolean; /** True when at least one explicit user grant (direct) is present. */ user: boolean; /** Team ids with an explicit grant on this resource. */ teams: string[]; /** User ids with an explicit grant on this resource. */ users: string[]; } /** Permission context of the viewer, used to build the query-time filter. */ export interface ViewerContext { userId: string; /** * The workspaces the query is scoped to (always ≥ 1; for a "search * everything" call this is every workspace the caller belongs to). The * single-index model filters on this clause — it is mandatory, never a * single implicit workspace. */ workspaceIds: string[]; /** Team ids the viewer belongs to (across the scoped workspaces). */ teamIds: string[]; } /** A document as stored in the OpenSearch index (search-architecture §3). */ export interface SearchDoc { /** `:` — the fusion key and the citation `docId` (chunks: `:#c`). */ docId: string; /** The 7 public kinds for whole docs; `"chunk"` for chunk docs (TASK-0099). */ type: SearchDocType; /** Source row id (the plain ref — for a chunk doc, the PARENT's id). */ id: string; workspaceId: string; teamId?: string; title: string; /** Plain text (or chunk text for chunked docs). */ body: string; /** * The chunk's own stable id (`c` for chunk n, TASK-0099) — absent on * whole docs. Lets a consumer address a chunk without knowing the parent's * full docId. */ chunkId?: string; /** Chunk index within the parent document (0 for non-chunked docs). */ chunkIndex?: number; /** Parent doc id when this doc is a chunk (`:` of the parent). */ parentDocId?: string; /** Embedding model that produced `vector` (migration guard, search §7.3). */ vectorModel?: string; vector?: number[]; grants: GrantSnapshot; archived: boolean; // Deep-link / facet metadata (search-architecture §3 + TASK-0097 response). pageId?: string; blockId?: string; databaseId?: string; recordId?: string; createdBy?: string; createdByName?: string; icon?: string; /** Client-side route the result links to (e.g. `/w//page/`). */ url: string; /** Files only. */ size?: number; mimeType?: string; createdAt: string; updatedAt: string; } /** A single result returned by the query API (search-architecture §6). */ export interface SearchResult { docId: string; type: SearchType; id: string; title: string; snippet?: string; url: string; icon?: string; createdByName?: string; updatedAt: string; // Deep-link metadata surfaced to the client for the per-type target. pageId?: string; blockId?: string; databaseId?: string; recordId?: string; workspaceId: string; /** RRF-fused score (hybrid) or OpenSearch score (full-text). */ score: number; /** Which retrieval paths produced this hit: "bm25", "vec", "bm25+vec". */ source: "bm25" | "vec" | "bm25+vec"; } /** * A prefix-autocomplete suggestion (search-architecture §4.2). The minimal * shape a search box needs to render + navigate to a hit: the source row `id` * (plain ref), the doc `type`, and the `title`. No highlight/score — a * suggestion is a prefix match, not a ranked full-text hit. */ export interface Suggestion { id: string; type: SearchType; title: string; } /** A RAG citation attached to a generated answer. */ export interface AnswerCitation { docId: string; title: string; url: string; quote: string; relevance: number; } /** The `answer` block of a hybrid `ask=true` response. */ export interface SearchAnswer { text: string; citations: AnswerCitation[]; model: string; tokensIn: number; tokensOut: number; latencyMs: number; } /** Query-time filter (the UI filter chips, search-architecture §4.4). */ export interface SearchQueryFilters { teams?: string[]; createdBy?: string; updatedAfter?: string; updatedBefore?: string; databases?: string[]; archived?: boolean; } /** * The result ordering (spec §10: "Sorting by relevance, creation date, and * modification date"). `relevance` (the default, `undefined`) is the existing * `_score`-driven path and is left untouched — the two date modes are * ADDITIVE branches with their own cursor contract (T-155). Both date fields * are already `type: "date"` in the index (mapping.ts), so sorting on them * needs no migration and no reindex. */ export type SearchSort = "relevance" | "createdAt" | "updatedAt"; /** * The search query contract (search-architecture §6). Mirrors `SearchQuery` * from the doc; `mode` defaults to `"fulltext"`. */ export interface SearchQuery { q: string; mode?: "fulltext" | "hybrid"; types?: SearchType[]; filters?: SearchQueryFilters; /** * Result ordering. `undefined`/`"relevance"` = the existing `_score` path * (byte-for-byte unchanged); `"createdAt"`/`"updatedAt"` = newest-first on * the stored date field (additive branch, its own `(field, id)` cursor). */ sortBy?: SearchSort; /** Opaque cursor (= base64 search_after tuple). */ page?: string; /** 1–100, default 25. */ pageSize?: number; /** Hybrid only: append an LLM answer with citations (RAG). */ ask?: boolean; } /** Full response shape of `POST /v1/search` (search-architecture §6). */ export interface SearchResponse { results: SearchResult[]; facets?: { types: { type: SearchType; count: number }[]; teams?: { teamId: string; count: number }[]; }; cursor?: string; total: number; answer?: SearchAnswer; latencyMs: number; } /** Indexer document input: what the worker hands to {@link buildIndexDocs}. */ export interface IndexDocInput { docId: string; type: SearchDocType; id: string; workspaceId: string; teamId?: string; title: string; body: string; /** * Chunk's own stable id (TASK-0099) — only set on chunk docs, which the * worker emits directly (whole-doc inputs never carry it). Optional + * exactOptionalPropertyTypes-safe: the property is simply absent otherwise. */ chunkId?: string; vectorModel?: string; vector?: number[]; grants: GrantSnapshot; archived: boolean; pageId?: string; blockId?: string; databaseId?: string; recordId?: string; createdBy?: string; createdByName?: string; icon?: string; url: string; size?: number; mimeType?: string; createdAt: string; updatedAt: string; /** Chunking settings for chunked types (page/block). */ chunk?: { maxChars?: number; overlapChars?: number }; } /** A chunk produced by the chunker, ready to index. */ export interface Chunk { chunkIndex: number; text: string; charStart: number; charEnd: number; tokens: number; } /** Chunking options (see `chunking.ts`). */ export interface ChunkOptions { /** Max chars per chunk (default 800). */ maxChars?: number; /** Overlap chars between consecutive chunks (default 120). */ overlapChars?: number; } /** An embedding provider (ARCHITECTURE.md §7) — see `embeddings.ts`. */ export interface EmbeddingProvider { readonly model: string; readonly dimension: number; embed(texts: string[]): Promise; }