1/** 2 * @factory/search — shared types for the search layer (ARCHITECTURE.md §6, 3 * docs/architecture/search-architecture.md §3/§6). 4 * 5 * Pure types + constants only: the indexer (worker), the query API 6 * (`apps/api/modules/search`) and the web client all import from here. 7 * 8 * Phase-8 reconciliation (recorded decision, see docs/architecture/search.md 9 * § "Layout decision"): the stored document is **camelCase** and lives in a 10 * **single** index (alias `factory-content`) whose docs are tenant-filtered by 11 * the mandatory `workspaceId` clause at query time. The earlier Phase-0 12 * scaffold used a per-workspace index (`factory-search-<wid>`) and a mix of 13 * snake_case / `grants.*` / `title.raw` field paths that did not match the 14 * stored doc; this file is now the single source of truth for the doc shape 15 * so the mapping, the query builders and the client all agree on one wire form. 16 */ 17 18/** Searchable document kinds (mirrors `searchTypeSchema` in `@factory/shared/dto`). */ 19export type SearchType = "page" | "block" | "database" | "record" | "comment" | "file" | "user"; 20 21/** 22 * Every `type` a doc stored in the index can carry. TASK-0099 adds 23 * `"chunk"` (one doc per embedded chunk of a page/record body). Chunk docs 24 * are **index-internal**: the public `types` filter (`SEARCH_TYPES` / the 25 * API's zod `searchTypeSchema`) intentionally stays the 7 public kinds, so 26 * keyword search can never request or return chunk docs — whole-doc `body` 27 * keyword behavior is unchanged (recorded reconciliation, arch doc §0). 28 * The mapping's `type` enum accepts `"chunk"` (`TYPE_ENUM`, mapping.ts). 29 */ 30export type SearchDocType = SearchType | "chunk"; 31 32export const SEARCH_TYPES: readonly SearchType[] = [ 33 "page", 34 "block", 35 "database", 36 "record", 37 "comment", 38 "file", 39 "user", 40] as const; 41 42/** The index-internal chunk doc `type` (TASK-0099) — never a public filter value. */ 43export const CHUNK_DOC_TYPE: "chunk" = "chunk"; 44 45/** 46 * Map a stored doc's `type` (which may be the index-internal `"chunk"`) to 47 * the PUBLIC display `SearchType` the query API returns. Chunk docs surface 48 * as their parent's kind, derived from the `parentDocId` prefix 49 * (`"page:pg1"` → `"page"`); a chunk doc without a resolvable parent falls 50 * back to `"page"`. Whole docs map to themselves. (TASK-0099.) 51 */ 52export function displayType(doc: { type: SearchDocType; parentDocId?: string }): SearchType { 53 if (doc.type !== CHUNK_DOC_TYPE) return doc.type; 54 const prefix = doc.parentDocId?.split(":")[0]; 55 return (SEARCH_TYPES as readonly string[]).includes(prefix ?? "") 56 ? (prefix as SearchType) 57 : "page"; 58} 59 60/** 61 * Visibility snapshot stamped into every indexed document at index time 62 * (search-architecture §5.1). Query-time permission filtering compares the 63 * viewer's identity against this snapshot — the index never exposes a doc the 64 * viewer may not see. It is a *denormalized closure of VIEW-or-better grants*; 65 * it is data, not policy — the guard (`effectiveLevel`) is the source of 66 * truth and is never made stale by a missing re-stamp (worst case a grant 67 * change is visible to search for one reconciliation sweep, and opening the 68 * doc still enforces the guard). 69 */ 70export interface GrantSnapshot { 71 /** A Share with scope PUBLIC exists (and search indexing is allowed). */ 72 public: boolean; 73 /** Workspace-visible (default for non-private workspace content). */ 74 workspace: boolean; 75 /** True when at least one explicit user grant (direct) is present. */ 76 user: boolean; 77 /** Team ids with an explicit grant on this resource. */ 78 teams: string[]; 79 /** User ids with an explicit grant on this resource. */ 80 users: string[]; 81} 82 83/** Permission context of the viewer, used to build the query-time filter. */ 84export interface ViewerContext { 85 userId: string; 86 /** 87 * The workspaces the query is scoped to (always ≥ 1; for a "search 88 * everything" call this is every workspace the caller belongs to). The 89 * single-index model filters on this clause — it is mandatory, never a 90 * single implicit workspace. 91 */ 92 workspaceIds: string[]; 93 /** Team ids the viewer belongs to (across the scoped workspaces). */ 94 teamIds: string[]; 95} 96 97/** A document as stored in the OpenSearch index (search-architecture §3). */ 98export interface SearchDoc { 99 /** `<type>:<id>` — the fusion key and the citation `docId` (chunks: `<type>:<id>#c<n>`). */ 100 docId: string; 101 /** The 7 public kinds for whole docs; `"chunk"` for chunk docs (TASK-0099). */ 102 type: SearchDocType; 103 /** Source row id (the plain ref — for a chunk doc, the PARENT's id). */ 104 id: string; 105 workspaceId: string; 106 teamId?: string; 107 title: string; 108 /** Plain text (or chunk text for chunked docs). */ 109 body: string; 110 /** 111 * The chunk's own stable id (`c<n>` for chunk n, TASK-0099) — absent on 112 * whole docs. Lets a consumer address a chunk without knowing the parent's 113 * full docId. 114 */ 115 chunkId?: string; 116 /** Chunk index within the parent document (0 for non-chunked docs). */ 117 chunkIndex?: number; 118 /** Parent doc id when this doc is a chunk (`<type>:<id>` of the parent). */ 119 parentDocId?: string; 120 /** Embedding model that produced `vector` (migration guard, search §7.3). */ 121 vectorModel?: string; 122 vector?: number[]; 123 grants: GrantSnapshot; 124 archived: boolean; 125 // Deep-link / facet metadata (search-architecture §3 + TASK-0097 response). 126 pageId?: string; 127 blockId?: string; 128 databaseId?: string; 129 recordId?: string; 130 createdBy?: string; 131 createdByName?: string; 132 icon?: string; 133 /** Client-side route the result links to (e.g. `/w/<ws>/page/<id>`). */ 134 url: string; 135 /** Files only. */ 136 size?: number; 137 mimeType?: string; 138 createdAt: string; 139 updatedAt: string; 140} 141 142/** A single result returned by the query API (search-architecture §6). */ 143export interface SearchResult { 144 docId: string; 145 type: SearchType; 146 id: string; 147 title: string; 148 snippet?: string; 149 url: string; 150 icon?: string; 151 createdByName?: string; 152 updatedAt: string; 153 // Deep-link metadata surfaced to the client for the per-type target. 154 pageId?: string; 155 blockId?: string; 156 databaseId?: string; 157 recordId?: string; 158 workspaceId: string; 159 /** RRF-fused score (hybrid) or OpenSearch score (full-text). */ 160 score: number; 161 /** Which retrieval paths produced this hit: "bm25", "vec", "bm25+vec". */ 162 source: "bm25" | "vec" | "bm25+vec"; 163} 164 165/** 166 * A prefix-autocomplete suggestion (search-architecture §4.2). The minimal 167 * shape a search box needs to render + navigate to a hit: the source row `id` 168 * (plain ref), the doc `type`, and the `title`. No highlight/score — a 169 * suggestion is a prefix match, not a ranked full-text hit. 170 */ 171export interface Suggestion { 172 id: string; 173 type: SearchType; 174 title: string; 175} 176 177/** A RAG citation attached to a generated answer. */ 178export interface AnswerCitation { 179 docId: string; 180 title: string; 181 url: string; 182 quote: string; 183 relevance: number; 184} 185 186/** The `answer` block of a hybrid `ask=true` response. */ 187export interface SearchAnswer { 188 text: string; 189 citations: AnswerCitation[]; 190 model: string; 191 tokensIn: number; 192 tokensOut: number; 193 latencyMs: number; 194} 195 196/** Query-time filter (the UI filter chips, search-architecture §4.4). */ 197export interface SearchQueryFilters { 198 teams?: string[]; 199 createdBy?: string; 200 updatedAfter?: string; 201 updatedBefore?: string; 202 databases?: string[]; 203 archived?: boolean; 204} 205 206/** 207 * The result ordering (spec §10: "Sorting by relevance, creation date, and 208 * modification date"). `relevance` (the default, `undefined`) is the existing 209 * `_score`-driven path and is left untouched — the two date modes are 210 * ADDITIVE branches with their own cursor contract (T-155). Both date fields 211 * are already `type: "date"` in the index (mapping.ts), so sorting on them 212 * needs no migration and no reindex. 213 */ 214export type SearchSort = "relevance" | "createdAt" | "updatedAt"; 215 216/** 217 * The search query contract (search-architecture §6). Mirrors `SearchQuery` 218 * from the doc; `mode` defaults to `"fulltext"`. 219 */ 220export interface SearchQuery { 221 q: string; 222 mode?: "fulltext" | "hybrid"; 223 types?: SearchType[]; 224 filters?: SearchQueryFilters; 225 /** 226 * Result ordering. `undefined`/`"relevance"` = the existing `_score` path 227 * (byte-for-byte unchanged); `"createdAt"`/`"updatedAt"` = newest-first on 228 * the stored date field (additive branch, its own `(field, id)` cursor). 229 */ 230 sortBy?: SearchSort; 231 /** Opaque cursor (= base64 search_after tuple). */ 232 page?: string; 233 /** 1–100, default 25. */ 234 pageSize?: number; 235 /** Hybrid only: append an LLM answer with citations (RAG). */ 236 ask?: boolean; 237} 238 239/** Full response shape of `POST /v1/search` (search-architecture §6). */ 240export interface SearchResponse { 241 results: SearchResult[]; 242 facets?: { 243 types: { type: SearchType; count: number }[]; 244 teams?: { teamId: string; count: number }[]; 245 }; 246 cursor?: string; 247 total: number; 248 answer?: SearchAnswer; 249 latencyMs: number; 250} 251 252/** Indexer document input: what the worker hands to {@link buildIndexDocs}. */ 253export interface IndexDocInput { 254 docId: string; 255 type: SearchDocType; 256 id: string; 257 workspaceId: string; 258 teamId?: string; 259 title: string; 260 body: string; 261 /** 262 * Chunk's own stable id (TASK-0099) — only set on chunk docs, which the 263 * worker emits directly (whole-doc inputs never carry it). Optional + 264 * exactOptionalPropertyTypes-safe: the property is simply absent otherwise. 265 */ 266 chunkId?: string; 267 vectorModel?: string; 268 vector?: number[]; 269 grants: GrantSnapshot; 270 archived: boolean; 271 pageId?: string; 272 blockId?: string; 273 databaseId?: string; 274 recordId?: string; 275 createdBy?: string; 276 createdByName?: string; 277 icon?: string; 278 url: string; 279 size?: number; 280 mimeType?: string; 281 createdAt: string; 282 updatedAt: string; 283 /** Chunking settings for chunked types (page/block). */ 284 chunk?: { maxChars?: number; overlapChars?: number }; 285} 286 287/** A chunk produced by the chunker, ready to index. */ 288export interface Chunk { 289 chunkIndex: number; 290 text: string; 291 charStart: number; 292 charEnd: number; 293 tokens: number; 294} 295 296/** Chunking options (see `chunking.ts`). */ 297export interface ChunkOptions { 298 /** Max chars per chunk (default 800). */ 299 maxChars?: number; 300 /** Overlap chars between consecutive chunks (default 120). */ 301 overlapChars?: number; 302} 303 304/** An embedding provider (ARCHITECTURE.md §7) — see `embeddings.ts`. */ 305export interface EmbeddingProvider { 306 readonly model: string; 307 readonly dimension: number; 308 embed(texts: string[]): Promise<number[][]>; 309} 310