From 1766ffa3d2b190bcaa860a4b9a30afc9512fa7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:27:28 +0200 Subject: [PATCH] feat(search): search the record from one endpoint --- docs/api/openapi.yaml | 194 +++- .../migration.sql | 16 + prisma/schema.prisma | 6 + src/__tests__/sharing-surface-guard.test.ts | 6 +- src/app/api/search/route.ts | 101 ++ .../__tests__/surface-module-registry.test.ts | 11 + src/lib/modules/surface.ts | 18 + src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/search.ts | 40 + src/lib/search/contract.ts | 82 ++ src/lib/search/search-records.ts | 949 ++++++++++++++++++ src/lib/search/sources.ts | 72 ++ src/lib/search/wire-schemas.ts | 76 ++ 14 files changed, 1564 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/0397_measurements_noted_live_idx/migration.sql create mode 100644 src/app/api/search/route.ts create mode 100644 src/lib/openapi/routes/search.ts create mode 100644 src/lib/search/contract.ts create mode 100644 src/lib/search/search-records.ts create mode 100644 src/lib/search/sources.ts create mode 100644 src/lib/search/wire-schemas.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 734d96df3..505b99fae 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23677,6 +23677,51 @@ paths: "404": *a69 "422": *a4 "429": *a5 + /api/search: + get: + tags: + - Records + summary: Search the record + description: "One search over the record: visits and procedures, conditions, documents (title, file name and indexed + text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the + notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each + carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed + as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source + newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query is + never logged. Rate limited per acting account (60 a minute). A grant scoped to some sections of the record does + not open it, like the timeline." + parameters: + - in: query + name: q + schema: + type: string + minLength: 2 + maxLength: 100 + description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue + or ss. + required: true + description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue + or ss. + - in: query + name: limit + schema: + default: 5 + description: Most hits per group. + type: integer + minimum: 1 + maximum: 20 + description: Most hits per group. + responses: + "200": + description: The hits, grouped by kind. + content: + application/json: + schema: + $ref: "#/components/schemas/SearchEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/bugreport: post: security: [] @@ -34384,15 +34429,15 @@ components: profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.taken; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, - report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link - — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, - share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, - share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — - sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, - sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, - sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, - sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, - sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; search — + search.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound, + share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, + share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; + sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, + sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, + sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.refused, + sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, + sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; @@ -82204,6 +82249,137 @@ components: - data - error additionalProperties: false + SearchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SearchResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SearchResponse: + type: object + properties: + groups: + type: array + items: + type: object + properties: + kind: + type: string + enum: + - visit + - condition + - document + - vaccination + - lab + - medication + - symptom + - workout + - note + hits: + type: array + items: + $ref: "#/components/schemas/SearchHit" + more: + type: boolean + required: + - kind + - hits + - more + additionalProperties: false + truncated: + type: boolean + description: The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries + may match and are not listed. + required: + - groups + - truncated + additionalProperties: false + SearchHit: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - visit + - condition + - document + - vaccination + - lab + - medication + - symptom + - workout + - note + title: + anyOf: + - $ref: "#/components/schemas/SearchMarkedText" + - type: "null" + context: + type: array + items: + $ref: "#/components/schemas/SearchMarkedText" + snippet: + anyOf: + - $ref: "#/components/schemas/SearchMarkedText" + - type: "null" + day: + anyOf: + - type: string + - type: "null" + path: + type: string + description: In-app path that opens the record, never an API path. + source: + anyOf: + - type: string + enum: + - mood + - measurement + - illness + - type: "null" + required: + - id + - kind + - title + - context + - snippet + - day + - path + - source + additionalProperties: false + SearchMarkedText: + type: object + properties: + text: + type: string + marks: + type: array + items: + type: array + prefixItems: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Half-open [start, end) character ranges of `text` a query word matched. + required: + - text + - marks + additionalProperties: false __schema0: anyOf: - type: string diff --git a/prisma/migrations/0397_measurements_noted_live_idx/migration.sql b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql new file mode 100644 index 000000000..20711fc33 --- /dev/null +++ b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql @@ -0,0 +1,16 @@ +-- v1.42.2 — the record search reads the notes on readings newest first. +-- +-- A record can hold millions of readings and a handful of notes. Without an +-- index that holds only the noted rows, the search walks the account's whole +-- `(user_id, measured_at)` range to find them. This partial index holds the +-- live rows that carry a note (the encrypted column, or the legacy plaintext +-- one the boot backfill has not migrated yet), so the read is the handful. +-- +-- Additive only, no table rewrite, no data change. Non-concurrent for the +-- reason migration 0243 gives: Prisma Migrate runs a migration file in a +-- transaction, and the entrypoint applies migrations before the app serves +-- traffic. The predicate is tiny, so the build is short. +CREATE INDEX IF NOT EXISTS "measurements_noted_live_idx" + ON "measurements" ("user_id", "measured_at" DESC, "id" DESC) + WHERE "deleted_at" IS NULL + AND ("notes_encrypted" IS NOT NULL OR "notes" IS NOT NULL); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99eb867c2..9741a7925 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2095,6 +2095,12 @@ model Measurement { // without this one the purge planned a sequential scan and could not finish // inside statement_timeout. Raw SQL only (partial predicate not expressible // in Prisma). + // `measurements_noted_live_idx` is a partial index over `(user_id, + // measured_at DESC, id DESC) WHERE deleted_at IS NULL AND (notes_encrypted + // IS NOT NULL OR notes IS NOT NULL)` — see migration 0397 (v1.42.2). The + // record search reads the notes on readings newest first; without it a + // record of millions of readings with a few notes walks every reading. + // Raw SQL only (partial predicate not expressible in Prisma). @@map("measurements") } diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b7b768b4b..50035b43b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1045,6 +1045,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.", }, + "app/api/search/route.ts": { + domain: "record", + why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.", + }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -2094,7 +2098,7 @@ const ACTOR_ROUTES: Record = { * literal (a score's daily course, MANAGE like the derived score itself). * 252 -> 254. */ -const FROZEN_ENTRY_COUNT = 254; +const FROZEN_ENTRY_COUNT = 255; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 000000000..855c9007c --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,101 @@ +/** + * `GET /api/search?q=&limit=` — one search over the record (Discussion #1117): + * visits and procedures, conditions, documents (title and indexed text), + * vaccinations, lab results, medications, symptoms, workouts and the notes on + * mood entries, readings and illness days. Each hit carries the in-app path + * that opens it. The work is `searchRecords` in `src/lib/search/`. + * + * Admission declares the whole record, like the timeline: the search lays + * every section side by side, so a grant scoped to some of them does not open + * it. The sources are narrowed to the grant's sections all the same + * (`actingDomainVisibility`), and to the record's module switches, so a + * switched-off module is not read at all. + * + * The query is health data ("Schulter", a drug, a diagnosis). It never + * reaches a log: the annotation carries its length and the counts only, and a + * refused query is answered without echoing it. + * + * Rate limited per actor: the palette asks once per pause in typing, and a + * manager searching someone's record spends their own allowance. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { searchRecords } from "@/lib/search/search-records"; +import { searchQuerySchema } from "@/lib/search/wire-schemas"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +/** Searches per actor per minute: a few per typed word, with room to spare. */ +const SEARCH_RATE_LIMIT = 60; +const SEARCH_RATE_WINDOW_MS = 60_000; + +function displayLocale(value: string | null | undefined): Locale { + return locales.includes(value as Locale) ? (value as Locale) : defaultLocale; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user, actor, grantId } = await requireRecordAuth("read", "record"); + + const rl = await checkRateLimit( + `search:${actor.id}`, + SEARCH_RATE_LIMIT, + SEARCH_RATE_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many searches. Please retry shortly.", 429); + } + + const params = new URL(request.url).searchParams; + const parsed = searchQuerySchema.safeParse({ + q: params.get("q") ?? undefined, + limit: params.get("limit") ?? undefined, + }); + if (!parsed.success) { + annotate({ + action: { name: "search.query.invalid" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "search.invalid", + }); + } + + const [modules, domainVisible, timezone] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const result = await searchRecords({ + recordId: user.id, + query: parsed.data.q, + limit: parsed.data.limit, + modules, + domainVisible, + displayLocale: displayLocale(actor.locale), + timezone, + }); + + annotate({ + action: { name: "search.query.run" }, + meta: { + query_length: parsed.data.q.length, + limit: parsed.data.limit, + sources: result.stats.sources, + rows_scanned: result.stats.rows, + matches: result.stats.candidates, + groups: result.groups.length, + hits: result.groups.reduce((n, g) => n + g.hits.length, 0), + truncated: result.truncated, + elapsed_ms: result.stats.elapsedMs, + delegated: grantId !== null, + }, + }); + return apiSuccess({ groups: result.groups, truncated: result.truncated }); +}); diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index a73018ea0..a4eaa2f23 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -68,6 +68,7 @@ import { import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select"; import { SYMPTOM_CHANNEL_PREFIX } from "@/lib/symptoms/shared"; import { DAY_SECTION_KEYS, TIMELINE_LANE_KEYS } from "@/lib/day/contract"; +import { SEARCH_SOURCE_KEYS } from "@/lib/search/sources"; import { SURFACE_KINDS, SURFACE_MODULE, @@ -171,6 +172,13 @@ describe("every surface id names a surface that exists", () => { } }); + it("search: a source the record search reads", () => { + const keys = new Set(SEARCH_SOURCE_KEYS); + for (const id of localIds("search")) { + expect(keys.has(id), id).toBe(true); + } + }); + it("correlation: a discovery channel, a weather field or the lab pass", () => { const channels = new Set([ ...DISCOVERY_BEHAVIOURS, @@ -289,6 +297,9 @@ describe("every surface that shows a module's data is owned by it", () => { ["day-section:lifeEvents", "timeline"], ["day-section:symptoms", "illness"], ["timeline-lane:documents", "inboundDocuments"], + ["search:document", "inboundDocuments"], + ["search:moodNote", "mood"], + ["search:symptomNote", "illness"], ])("%s is owned by %s", (id, owner) => { expect(surfaceModule(id)).toBe(owner); }); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 81df1d95f..21eb5a660 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -52,6 +52,8 @@ import type { ModuleKey } from "@/lib/modules/registry"; * day loader blanks a section whose owner is off, and the views drop it. * - `timeline-lane`: a lane of the timeline, by its `TimelineLaneKey`; the * readiness inventory leaves it out the same way. + * - `search`: a source the in-app record search reads, by its + * `SearchSourceKey`. A source whose owner is off is not read at all. */ export const SURFACE_KINDS = [ "nav", @@ -68,6 +70,7 @@ export const SURFACE_KINDS = [ "score-ring", "day-section", "timeline-lane", + "search", ] as const; export type SurfaceKind = (typeof SURFACE_KINDS)[number]; @@ -247,6 +250,21 @@ const STATIC_SURFACE_MODULE = { "timeline-lane:labs": "labs", "timeline-lane:documents": "inboundDocuments", "timeline-lane:cycle": "cycle", + + // ── Record search sources (v1.42.2) ── + // Visits are core. Notes on readings follow the owner of the reading's + // type and are filtered item by item, like the timeline's `life` lane. + "search:condition": "illness", + "search:document": "inboundDocuments", + "search:vaccination": "vaccinations", + "search:labAnalyte": "labs", + "search:labNote": "labs", + "search:medication": "medications", + "search:symptom": "illness", + "search:symptomNote": "illness", + "search:workout": "workouts", + "search:moodNote": "mood", + "search:illnessNote": "illness", } as const satisfies Record; /** A surface id the map declares literally. */ diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index e0ae588ad..8948915f3 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -342,6 +342,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "report-selection.leaves.unknown", ], route: ["route.retired"], + search: ["search.invalid"], "share-link": [ "share-link.document.decryptFailed", "share-link.document.notFound", diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 03be40829..6f870b2ff 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -49,6 +49,7 @@ import { dailyPaths } from "./daily"; import { cyclePaths } from "./cycle"; import { dashboardWidgetPaths } from "./dashboard"; import { dayPaths } from "./day"; +import { searchPaths } from "./search"; import { biomarkerPaths } from "./biomarkers"; import { inboundDocumentPaths } from "./documents"; import { documentSourcePaths } from "./document-sources"; @@ -185,6 +186,7 @@ export const openApiPaths: NonNullable = { // v1.42 — the day view, the timeline and life events (appended, spread // order is load-bearing). ...dayPaths, + ...searchPaths, // The environmental-context overview. Its own module because nothing else // owns the surface (appended, spread order is load-bearing). // diff --git a/src/lib/openapi/routes/search.ts b/src/lib/openapi/routes/search.ts new file mode 100644 index 000000000..4de58c029 --- /dev/null +++ b/src/lib/openapi/routes/search.ts @@ -0,0 +1,40 @@ +/** + * OpenAPI route table for the in-app record search (`/api/search`, v1.42.2, + * Discussion #1117). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The query and + * response schemas are the runtime ones from `@/lib/search/wire-schemas`, so + * the published contract is the parser's own. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + searchQuerySchema, + searchResponseSchema, +} from "@/lib/search/wire-schemas"; + +import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; + +export const searchPaths: NonNullable = { + "/api/search": { + get: { + tags: ["Records"], + summary: "Search the record", + description: + "One search over the record: visits and procedures, conditions, documents (title, file name and indexed text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query is never logged. Rate limited per acting account (60 a minute). A grant scoped to some sections of the record does not open it, like the timeline.", + requestParams: { query: searchQuerySchema }, + responses: { + "200": { + description: "The hits, grouped by kind.", + content: { + "application/json": { + schema: dataEnvelope(searchResponseSchema, "SearchEnvelope"), + }, + }, + }, + ...stdResponses, + ...recordRefusal(), + }, + }, + }, +}; diff --git a/src/lib/search/contract.ts b/src/lib/search/contract.ts new file mode 100644 index 000000000..40be764de --- /dev/null +++ b/src/lib/search/contract.ts @@ -0,0 +1,82 @@ +/** + * The in-app record search (`GET /api/search`): its vocabulary and wire + * types. Pure constants and types, no validator, so the command palette + * imports this file and nothing server-side. The Zod schemas the route parses + * with and the OpenAPI table publishes live in `./wire-schemas.ts`. + */ + +/** + * The groups a hit is listed under, in the order the palette falls back to + * when two groups' best hits tie. + */ +export const SEARCH_HIT_KINDS = [ + "visit", + "condition", + "document", + "vaccination", + "lab", + "medication", + "symptom", + "workout", + "note", +] as const; + +export type SearchHitKind = (typeof SEARCH_HIT_KINDS)[number]; + +/** The entry a free-text note was written on. */ +export const SEARCH_NOTE_SOURCES = ["mood", "measurement", "illness"] as const; + +export type SearchNoteSource = (typeof SEARCH_NOTE_SOURCES)[number]; + +/** Shortest query the server searches for. */ +export const SEARCH_QUERY_MIN = 2; +/** Longest query the server accepts. */ +export const SEARCH_QUERY_MAX = 100; +/** Hits per group when the caller names no limit. */ +export const SEARCH_LIMIT_DEFAULT = 5; +/** Most hits per group a caller can ask for. */ +export const SEARCH_LIMIT_MAX = 20; + +/** A half-open `[start, end)` character range of `text`. */ +export type SearchMarkRange = [number, number]; + +/** Text with the ranges a query word matched. Plain text, never markup. */ +export interface SearchMarkedText { + text: string; + marks: SearchMarkRange[]; +} + +export interface SearchHit { + /** Unique within one answer: `:`. */ + id: string; + kind: SearchHitKind; + /** The record's own name, or null when the kind's label names it. */ + title: SearchMarkedText | null; + /** Short context values: a practitioner, a body site, a dose. */ + context: SearchMarkedText[]; + /** The part of the record's free text the query hit, or null. */ + snippet: SearchMarkedText | null; + /** The record's calendar day, `YYYY-MM-DD`, or null. */ + day: string | null; + /** In-app path that opens the record. */ + path: string; + /** For a note: the entry it was written on. Null for every other kind. */ + source: SearchNoteSource | null; +} + +export interface SearchGroup { + kind: SearchHitKind; + hits: SearchHit[]; + /** More hits of this kind matched than the limit lets through. */ + more: boolean; +} + +export interface SearchResponse { + /** Groups with at least one hit, the best group first. */ + groups: SearchGroup[]; + /** + * The search stopped before it had read every entry (the per-kind row cap + * or the time budget), so older entries may match and are not listed. + */ + truncated: boolean; +} diff --git a/src/lib/search/search-records.ts b/src/lib/search/search-records.ts new file mode 100644 index 000000000..6bfecc0dc --- /dev/null +++ b/src/lib/search/search-records.ts @@ -0,0 +1,949 @@ +/** + * The in-app record search behind `GET /api/search` (Discussion #1117): one + * query over visits, conditions, documents, vaccinations, lab results, + * medications, symptoms, workouts and the free-text notes on mood entries, + * readings and illness days, each hit with the in-app path that opens it. + * + * Most of the record is ciphertext at rest, so matching cannot run in SQL. + * The search reads the record's own rows newest first, decrypts them here and + * matches them with the shared matcher (`./match.ts`, the one the MCP + * `search` tool uses). The four clinical kinds come from the MCP module's own + * reads (`loadRecordCandidates`); the rest are read here in batches. + * + * Bounds, so a large record cannot take the heap or the pool down: + * + * - every batched source reads at most `MAX_SCAN_ROWS_PER_SOURCE` rows, in + * batches of `SCAN_BATCH`, and stops as soon as it holds more hits than + * the caller's limit (newest first, so those are the newest matches); + * - one search shares a time budget (`SEARCH_TIME_BUDGET_MS`); a source + * checks it before every batch and stops when it has run out; + * - at most `SOURCE_CONCURRENCY` sources read at once, so one search holds + * a few pool connections, not one per table; + * - only matching rows are kept: a batch's decrypted text is dropped once + * the batch is matched. + * + * When a cap or the budget cut a source short the answer says `truncated`. + * + * Plaintext columns that are indexable (a lab's analyte, a medication's name, + * a workout's sport) are read whole and matched here as well, so the folding + * is the same for every kind: "Schulter", "schulter" and "Schülter" are one + * word to the search whichever column holds it. + * + * Nothing here logs the query or any record text. The route annotates the + * query's length and the counts only. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { readNote } from "@/lib/crypto/note-cipher"; +import { prisma } from "@/lib/db"; +import { + decryptIndexText, + decryptVerbatimText, +} from "@/lib/documents/content-index"; +import { normalizeSearchText } from "@/lib/encounters/procedures"; +import type { Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { + loadRecordCandidates, + MAX_ROWS_PER_KIND, + type RecordKind, +} from "@/lib/mcp/record-search"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; +import type { ShareScope } from "@/lib/sharing/scope"; +import { userDayKey } from "@/lib/tz/format"; + +import { + SEARCH_HIT_KINDS, + type SearchGroup, + type SearchHit, + type SearchHitKind, + type SearchMarkedText, + type SearchNoteSource, + type SearchResponse, +} from "./contract"; +import { + isHit, + marked, + queryWords, + scoreCandidate, + snippetOf, + WEIGHT, + type SearchCandidate, +} from "./match"; +import { + SEARCH_SOURCE_DOMAIN, + SEARCH_SOURCE_KEYS, + SEARCH_SOURCE_KIND, + type SearchSourceKey, +} from "./sources"; + +// ── Bounds ─────────────────────────────────────────────────────────── + +/** Rows one batched source reads per round trip. */ +export const SCAN_BATCH = 250; +/** Rows one batched source reads at most, per search. */ +export const MAX_SCAN_ROWS_PER_SOURCE = 2000; +/** Wall-clock budget one search shares across its sources. */ +export const SEARCH_TIME_BUDGET_MS = 1500; +/** Sources that read at the same time. */ +export const SOURCE_CONCURRENCY = 4; +/** Plaintext name rows (analytes, medications, symptom names) read at most. */ +const MAX_NAME_ROWS = 1000; +/** Documents whose indexed text is opened for a snippet, per answer. */ +const MAX_DOCUMENT_SNIPPETS = 5; + +// ── The scan ───────────────────────────────────────────────────────── + +/** Shared by every source of one search. */ +export interface ScanBudget { + readonly deadline: number; + readonly now: () => number; + /** A cap or the deadline stopped a source before it read everything. */ + truncated: boolean; + /** Rows read, for the annotation. */ + rows: number; +} + +/** + * Read a source newest first in batches until it is exhausted, holds more + * than `wanted` hits, has read `maxRows` rows or the budget has run out. + * `read` pages by the last row's id (Prisma cursor over a stable order); + * `match` turns a row into a candidate that is a hit, or null. + */ +export async function scanNewest(args: { + budget: ScanBudget; + wanted: number; + maxRows?: number; + batch?: number; + read: (cursor: string | null, take: number) => Promise; + match: (row: Row) => SearchCandidate | null; +}): Promise { + const { budget, wanted, read, match } = args; + const maxRows = args.maxRows ?? MAX_SCAN_ROWS_PER_SOURCE; + const batch = args.batch ?? SCAN_BATCH; + const out: SearchCandidate[] = []; + let cursor: string | null = null; + let scanned = 0; + for (;;) { + if (budget.now() >= budget.deadline) { + budget.truncated = true; + break; + } + const take = Math.min(batch, maxRows - scanned); + const rows = await read(cursor, take); + scanned += rows.length; + budget.rows += rows.length; + for (const row of rows) { + const candidate = match(row); + if (candidate) out.push(candidate); + } + // A short batch is the end of the source. + if (rows.length < take) break; + // One more than the limit: the caller learns there is more. + if (out.length > wanted) break; + if (scanned >= maxRows) { + budget.truncated = true; + break; + } + cursor = rows[rows.length - 1].id; + } + return out; +} + +/** Prisma's keyset arguments for `scanNewest`. */ +function after(cursor: string | null): { + cursor?: { id: string }; + skip?: number; +} { + return cursor ? { cursor: { id: cursor }, skip: 1 } : {}; +} + +/** Run `tasks` with at most `limit` in flight. */ +async function inPool( + tasks: ReadonlyArray<() => Promise>, + limit: number, +): Promise { + let next = 0; + const worker = async () => { + while (next < tasks.length) { + const task = tasks[next++]; + await task(); + } + }; + await Promise.all( + Array.from({ length: Math.min(limit, tasks.length) }, worker), + ); +} + +// ── Sources ────────────────────────────────────────────────────────── + +interface SearchContext { + recordId: string; + query: string; + words: readonly string[]; + foldedQuery: string; + wanted: number; + timezone: string; + display: Locale; + modules: SurfaceModuleMap | null; + budget: ScanBudget; +} + +/** Decrypt a free-text `Bytes` column fail-soft: a key gap reads as absent. */ +function decryptText(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + try { + const text = decryptFromBytes(value); + return text.trim() ? text : null; + } catch { + return null; + } +} + +/** `readNote` fail-soft, for the two columns still carrying a plaintext twin. */ +function readNoteSoft( + ciphertext: Uint8Array | null, + plaintext: string | null, +): string | null { + try { + const text = readNote(ciphertext, plaintext); + return text?.trim() ? text : null; + } catch { + return null; + } +} + +/** The candidate when it is a hit for the context's query, else null. */ +function keepIfHit( + ctx: SearchContext, + candidate: SearchCandidate, +): SearchCandidate | null { + return isHit(scoreCandidate(candidate, ctx.words, ctx.foldedQuery)) + ? candidate + : null; +} + +function noteCandidate(args: { + id: string; + source: SearchSourceKey; + recordId: string; + title: string | null; + note: string; + date: Date | null; + day: string | null; + path: string; + noteSource?: SearchNoteSource; +}): SearchCandidate { + return { + id: args.id, + title: args.title ?? "", + url: args.path, + kindOrder: 0, + date: args.date?.getTime() ?? null, + fields: [{ text: args.note, weight: WEIGHT.tertiary, body: true }], + hit: { + kind: SEARCH_SOURCE_KIND[args.source], + recordId: args.recordId, + title: args.title, + context: [], + day: args.day, + path: args.path, + ...(args.noteSource ? { source: args.noteSource } : {}), + }, + }; +} + +async function labAnalyteCandidates( + ctx: SearchContext, +): Promise { + const rows = await prisma.labResult.groupBy({ + by: ["analyte"], + where: { userId: ctx.recordId, deletedAt: null }, + _max: { takenAt: true }, + orderBy: { _max: { takenAt: "desc" } }, + take: MAX_NAME_ROWS, + }); + ctx.budget.rows += rows.length; + return rows.flatMap((row) => { + const latest = row._max.takenAt; + const path = `/labs?analyte=${encodeURIComponent(row.analyte)}`; + const candidate = keepIfHit(ctx, { + id: `labAnalyte:${row.analyte}`, + title: row.analyte, + url: path, + kindOrder: 0, + date: latest?.getTime() ?? null, + fields: [{ text: row.analyte, weight: WEIGHT.primary }], + hit: { + kind: "lab", + recordId: row.analyte, + title: row.analyte, + context: [], + day: latest ? userDayKey(latest, ctx.timezone) : null, + path, + }, + }); + return candidate ? [candidate] : []; + }); +} + +function labNoteCandidates(ctx: SearchContext): Promise { + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + read: (cursor, take) => + prisma.labResult.findMany({ + where: { + userId: ctx.recordId, + deletedAt: null, + noteEncrypted: { not: null }, + }, + select: { id: true, analyte: true, takenAt: true, noteEncrypted: true }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + const note = decryptText(row.noteEncrypted); + if (!note) return null; + return keepIfHit( + ctx, + noteCandidate({ + id: `labNote:${row.id}`, + source: "labNote", + recordId: row.id, + title: row.analyte, + note, + date: row.takenAt, + day: userDayKey(row.takenAt, ctx.timezone), + path: `/labs?analyte=${encodeURIComponent(row.analyte)}`, + }), + ); + }, + }); +} + +async function medicationCandidates( + ctx: SearchContext, +): Promise { + const rows = await prisma.medication.findMany({ + where: { userId: ctx.recordId }, + select: { id: true, name: true, dose: true, createdAt: true }, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + take: MAX_NAME_ROWS, + }); + ctx.budget.rows += rows.length; + return rows.flatMap((row) => { + const path = `/medications/${encodeURIComponent(row.id)}`; + const candidate = keepIfHit(ctx, { + id: `medication:${row.id}`, + title: row.name, + url: path, + kindOrder: 0, + date: row.createdAt.getTime(), + fields: [ + { text: row.name, weight: WEIGHT.primary }, + { text: row.dose, weight: WEIGHT.secondary }, + ], + hit: { + kind: "medication", + recordId: row.id, + title: row.name, + context: row.dose.trim() ? [row.dose.trim()] : [], + day: null, + path, + }, + }); + return candidate ? [candidate] : []; + }); +} + +/** The record's symptom names, decrypted once per search. */ +function symptomLabels( + ctx: SearchContext, +): () => Promise> { + let pending: Promise> | null = null; + return () => { + pending ??= prisma.symptomDefinition + .findMany({ + where: { userId: ctx.recordId }, + select: { id: true, labelEncrypted: true }, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + take: MAX_NAME_ROWS, + }) + .then((rows) => { + ctx.budget.rows += rows.length; + return new Map( + rows.map((row) => [row.id, decryptText(row.labelEncrypted)]), + ); + }); + return pending; + }; +} + +async function symptomCandidates( + ctx: SearchContext, + labels: () => Promise>, +): Promise { + const byId = await labels(); + return [...byId].flatMap(([id, label]) => { + if (!label) return []; + const path = `/illness?symptom=${encodeURIComponent(id)}`; + const candidate = keepIfHit(ctx, { + id: `symptom:${id}`, + title: label, + url: path, + kindOrder: 0, + date: null, + fields: [{ text: label, weight: WEIGHT.primary }], + hit: { + kind: "symptom", + recordId: id, + title: label, + context: [], + day: null, + path, + }, + }); + return candidate ? [candidate] : []; + }); +} + +async function symptomNoteCandidates( + ctx: SearchContext, + labels: () => Promise>, +): Promise { + const byId = await labels(); + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + read: (cursor, take) => + prisma.symptomEvent.findMany({ + where: { userId: ctx.recordId, noteEncrypted: { not: null } }, + select: { + id: true, + definitionId: true, + occurredAt: true, + noteEncrypted: true, + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + const note = decryptText(row.noteEncrypted); + if (!note) return null; + const day = userDayKey(row.occurredAt, ctx.timezone); + return keepIfHit( + ctx, + noteCandidate({ + id: `symptomNote:${row.id}`, + source: "symptomNote", + recordId: row.id, + title: byId.get(row.definitionId) ?? null, + note, + date: row.occurredAt, + day, + path: `/illness?day=${day}`, + }), + ); + }, + }); +} + +/** The sport's name in the display language, else its words. */ +function sportLabel(sportType: string, locale: Locale): string { + const key = `insights.workouts.sport.${sportType}`; + const label = getServerTranslator(locale).t(key); + return label === key ? sportType.toLowerCase().replace(/_/g, " ") : label; +} + +async function workoutCandidates( + ctx: SearchContext, +): Promise { + const types = await prisma.workout.groupBy({ + by: ["sportType"], + where: { userId: ctx.recordId }, + }); + ctx.budget.rows += types.length; + // A sport matches by its name in the display language, in English and as + // its enum words, so "laufen", "running" and "run" all find a run. + const matched = types + .map((t) => t.sportType) + .filter((sportType) => + keepIfHit(ctx, { + id: sportType, + title: sportType, + url: "", + kindOrder: 0, + date: null, + fields: [ + { + text: [ + sportLabel(sportType, ctx.display), + sportLabel(sportType, "en"), + sportType.toLowerCase().replace(/_/g, " "), + ].join(" "), + weight: WEIGHT.primary, + }, + ], + }), + ); + if (matched.length === 0) return []; + const rows = await prisma.workout.findMany({ + where: { userId: ctx.recordId, sportType: { in: matched } }, + select: { id: true, sportType: true, startedAt: true }, + orderBy: [{ startedAt: "desc" }, { id: "desc" }], + take: ctx.wanted + 1, + }); + ctx.budget.rows += rows.length; + return rows.map((row) => { + const path = `/insights/workouts/${encodeURIComponent(row.id)}`; + const label = sportLabel(row.sportType, ctx.display); + return { + id: `workout:${row.id}`, + title: label, + url: path, + kindOrder: 0, + date: row.startedAt.getTime(), + fields: [ + { + text: [ + label, + sportLabel(row.sportType, "en"), + row.sportType.toLowerCase().replace(/_/g, " "), + ].join(" "), + weight: WEIGHT.primary, + }, + ], + hit: { + kind: "workout", + recordId: row.id, + title: label, + context: [], + day: userDayKey(row.startedAt, ctx.timezone), + path, + }, + }; + }); +} + +function moodNoteCandidates(ctx: SearchContext): Promise { + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + read: (cursor, take) => + prisma.moodEntry.findMany({ + where: { + userId: ctx.recordId, + deletedAt: null, + OR: [{ noteEncrypted: { not: null } }, { note: { not: null } }], + }, + select: { + id: true, + date: true, + moodLoggedAt: true, + note: true, + noteEncrypted: true, + }, + orderBy: [{ moodLoggedAt: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + const note = readNoteSoft(row.noteEncrypted, row.note); + if (!note) return null; + return keepIfHit( + ctx, + noteCandidate({ + id: `moodNote:${row.id}`, + source: "moodNote", + recordId: row.id, + title: null, + note, + date: row.moodLoggedAt, + day: row.date, + path: `/mood?day=${row.date}`, + noteSource: "mood", + }), + ); + }, + }); +} + +function illnessNoteCandidates(ctx: SearchContext): Promise { + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + read: (cursor, take) => + prisma.illnessDayLog.findMany({ + where: { + userId: ctx.recordId, + deletedAt: null, + noteEncrypted: { not: null }, + episode: { deletedAt: null }, + }, + select: { + id: true, + date: true, + noteEncrypted: true, + episode: { select: { id: true, label: true } }, + }, + orderBy: [{ date: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + const note = decryptText(row.noteEncrypted); + if (!note) return null; + return keepIfHit( + ctx, + noteCandidate({ + id: `illnessNote:${row.id}`, + source: "illnessNote", + recordId: row.id, + title: row.episode.label, + note, + // The day log's own calendar date; noon keeps the sort stable. + date: new Date(`${row.date}T12:00:00Z`), + day: row.date, + path: `/illness/${encodeURIComponent(row.episode.id)}`, + noteSource: "illness", + }), + ); + }, + }); +} + +function measurementNoteCandidates( + ctx: SearchContext, +): Promise { + // A note on a reading follows the module of the reading's type. + const typeVisible = (type: MeasurementType): boolean => { + const owner = moduleForMeasurementType(type); + return owner === null || ctx.modules?.[owner] !== false; + }; + return scanNewest({ + budget: ctx.budget, + wanted: ctx.wanted, + // Served by the partial index `measurements_noted_live_idx` (migration + // 0397): only live rows that carry a note, so a record of millions of + // readings with a handful of notes reads the handful. + read: (cursor, take) => + prisma.measurement.findMany({ + where: { + userId: ctx.recordId, + deletedAt: null, + OR: [{ notesEncrypted: { not: null } }, { notes: { not: null } }], + }, + select: { + id: true, + type: true, + measuredAt: true, + notes: true, + notesEncrypted: true, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + take, + ...after(cursor), + }), + match: (row) => { + if (!typeVisible(row.type)) return null; + const note = readNoteSoft(row.notesEncrypted, row.notes); + if (!note) return null; + const day = userDayKey(row.measuredAt, ctx.timezone); + return keepIfHit( + ctx, + noteCandidate({ + id: `measurementNote:${row.id}`, + source: "measurementNote", + recordId: row.id, + title: null, + note, + date: row.measuredAt, + day, + path: `/measurements?day=${day}`, + noteSource: "measurement", + }), + ); + }, + }); +} + +// ── Ranking and the answer ─────────────────────────────────────────── + +const RECORD_SOURCES: ReadonlyArray = [ + "visit", + "condition", + "document", + "vaccination", +]; + +interface Ranked { + candidate: SearchCandidate; + matched: number; + titleHit: boolean; + score: number; +} + +function compareRanked(a: Ranked, b: Ranked): number { + return ( + b.matched - a.matched || + Number(b.titleHit) - Number(a.titleHit) || + b.score - a.score || + (b.candidate.date ?? 0) - (a.candidate.date ?? 0) || + (a.candidate.id < b.candidate.id + ? -1 + : a.candidate.id > b.candidate.id + ? 1 + : 0) + ); +} + +function markedOrNull( + text: string | null, + query: string, +): SearchMarkedText | null { + return text ? marked(text, query) : null; +} + +/** The snippet: the first free-text field the query hit, unless it is the title. */ +function snippetFor( + candidate: SearchCandidate, + query: string, +): SearchMarkedText | null { + const title = candidate.hit?.title ?? null; + for (const field of candidate.fields) { + if (!field.body || !field.text.trim()) continue; + if (title !== null && field.text.trim() === title.trim()) continue; + const snippet = snippetOf(field.text, query); + if (snippet) return snippet; + } + return null; +} + +function toHit(candidate: SearchCandidate, query: string): SearchHit { + const hit = candidate.hit!; + return { + id: candidate.id, + kind: hit.kind as SearchHitKind, + title: markedOrNull(hit.title, query), + context: hit.context.map((value) => marked(value, query)), + snippet: snippetFor(candidate, query), + day: hit.day, + path: hit.path, + source: (hit.source as SearchNoteSource | undefined) ?? null, + }; +} + +/** + * A snippet of the indexed text for the listed documents that matched by + * their text alone. Bounded: the few listed documents, never the vault. + */ +async function addDocumentSnippets( + recordId: string, + hits: SearchHit[], + query: string, +): Promise { + const wanting = hits + .filter( + (hit) => + hit.kind === "document" && + hit.snippet === null && + (hit.title?.marks.length ?? 0) === 0, + ) + .slice(0, MAX_DOCUMENT_SNIPPETS); + if (wanting.length === 0) return; + const byDocument = new Map( + wanting.map((hit) => [hit.id.slice("document:".length), hit]), + ); + const rows = await prisma.documentContentIndex.findMany({ + where: { userId: recordId, documentId: { in: [...byDocument.keys()] } }, + select: { + documentId: true, + textEncrypted: true, + verbatimTextEncrypted: true, + }, + }); + for (const row of rows) { + const hit = byDocument.get(row.documentId); + if (!hit) continue; + try { + const text = + row.verbatimTextEncrypted && row.verbatimTextEncrypted.byteLength > 0 + ? decryptVerbatimText(row.verbatimTextEncrypted) + : decryptIndexText(row.textEncrypted); + hit.snippet = snippetOf(text, query); + } catch { + // A key gap leaves the hit without a snippet; it still opens. + } + } +} + +export interface SearchRecordsArgs { + /** The record the session is acting for. */ + recordId: string; + query: string; + /** Most hits per group. */ + limit: number; + /** The record's module map; an explicit `false` hides a source. */ + modules: SurfaceModuleMap | null; + /** Whether the caller's grant covers a section (always true for the owner). */ + domainVisible: (domain: ShareScope) => boolean; + /** The language of labels the server names (a visit kind, a sport). */ + displayLocale: Locale; + /** The record's zone, for the calendar day of an instant. */ + timezone: string; + /** Overrides for tests. */ + now?: () => number; + budgetMs?: number; +} + +export interface SearchStats { + sources: number; + rows: number; + candidates: number; + elapsedMs: number; +} + +/** The sources this caller may read on this record. */ +export function readableSources( + modules: SurfaceModuleMap | null, + domainVisible: (domain: ShareScope) => boolean, +): SearchSourceKey[] { + return SEARCH_SOURCE_KEYS.filter( + (key) => + domainVisible(SEARCH_SOURCE_DOMAIN[key]) && + isSurfaceVisible(`search:${key}`, modules), + ); +} + +export async function searchRecords( + args: SearchRecordsArgs, +): Promise { + const now = args.now ?? Date.now; + const started = now(); + const budget: ScanBudget = { + deadline: started + (args.budgetMs ?? SEARCH_TIME_BUDGET_MS), + now, + truncated: false, + rows: 0, + }; + const ctx: SearchContext = { + recordId: args.recordId, + query: args.query, + words: queryWords(args.query), + foldedQuery: normalizeSearchText(args.query), + wanted: args.limit, + timezone: args.timezone, + display: args.displayLocale, + modules: args.modules, + budget, + }; + const sources = new Set(readableSources(args.modules, args.domainVisible)); + const collected: SearchCandidate[] = []; + const labels = symptomLabels(ctx); + + const tasks: Array<() => Promise> = []; + const add = (key: SearchSourceKey, run: () => Promise) => { + if (!sources.has(key)) return; + tasks.push(async () => { + collected.push(...(await run())); + }); + }; + + const recordKinds = new Set( + RECORD_SOURCES.filter((key) => sources.has(key)), + ); + if (recordKinds.size > 0) { + tasks.push(async () => { + const candidates = await loadRecordCandidates( + args.recordId, + args.query, + "", + { + kinds: recordKinds, + displayLocale: args.displayLocale, + includeAiDeferredText: true, + }, + ); + budget.rows += candidates.length; + // Each kind reads its newest `MAX_ROWS_PER_KIND`; a kind that filled + // the read may have older rows the search did not see. + const perKind = new Map(); + for (const candidate of candidates) { + const kind = candidate.hit?.kind ?? ""; + perKind.set(kind, (perKind.get(kind) ?? 0) + 1); + if (keepIfHit(ctx, candidate)) collected.push(candidate); + } + if ([...perKind.values()].some((n) => n >= MAX_ROWS_PER_KIND)) { + budget.truncated = true; + } + }); + } + add("labAnalyte", () => labAnalyteCandidates(ctx)); + add("labNote", () => labNoteCandidates(ctx)); + add("medication", () => medicationCandidates(ctx)); + add("symptom", () => symptomCandidates(ctx, labels)); + add("symptomNote", () => symptomNoteCandidates(ctx, labels)); + add("workout", () => workoutCandidates(ctx)); + add("moodNote", () => moodNoteCandidates(ctx)); + add("illnessNote", () => illnessNoteCandidates(ctx)); + add("measurementNote", () => measurementNoteCandidates(ctx)); + + await inPool(tasks, SOURCE_CONCURRENCY); + + const ranked: Ranked[] = collected + .filter((candidate) => candidate.hit) + .map((candidate) => { + const s = scoreCandidate(candidate, ctx.words, ctx.foldedQuery); + const title = candidate.hit?.title ?? null; + return { + candidate, + matched: s.matched, + score: s.score, + titleHit: title !== null && marked(title, args.query).marks.length > 0, + }; + }) + .sort(compareRanked); + + const groups = new Map(); + for (const { candidate } of ranked) { + const kind = candidate.hit!.kind as SearchHitKind; + let group = groups.get(kind); + if (!group) { + group = { kind, hits: [], more: false }; + groups.set(kind, group); + } + if (group.hits.length < args.limit) + group.hits.push(toHit(candidate, args.query)); + else group.more = true; + } + // Groups in the order of their best hit; a tie keeps the fixed order. + const ordered = [...groups.values()].sort((a, b) => { + const ia = ranked.findIndex((r) => r.candidate.hit?.kind === a.kind); + const ib = ranked.findIndex((r) => r.candidate.hit?.kind === b.kind); + return ( + ia - ib || + SEARCH_HIT_KINDS.indexOf(a.kind) - SEARCH_HIT_KINDS.indexOf(b.kind) + ); + }); + + await addDocumentSnippets( + args.recordId, + ordered.flatMap((group) => group.hits), + args.query, + ); + + return { + groups: ordered, + truncated: budget.truncated, + stats: { + sources: sources.size, + rows: budget.rows, + candidates: ranked.length, + elapsedMs: now() - started, + }, + }; +} diff --git a/src/lib/search/sources.ts b/src/lib/search/sources.ts new file mode 100644 index 000000000..b654ae606 --- /dev/null +++ b/src/lib/search/sources.ts @@ -0,0 +1,72 @@ +/** + * What the in-app record search reads: one source per table and field it + * matches, the group each lists its hits under, and the record section each + * belongs to. Pure data, so the surface registry test reads it too. + * + * Two masks, as everywhere else. A source is read only when the record's + * module for it is on (the `search:` entries of + * `src/lib/modules/surface.ts`; a source with no entry is core) and when the + * caller's grant covers its section (`SEARCH_SOURCE_DOMAIN`). The route + * admits the whole record only, so for today's callers the second mask is + * always open; it is applied all the same, so narrowing that admission later + * cannot widen what the search reads. + */ +import type { ShareDomain } from "@/lib/sharing/scope"; + +import type { SearchHitKind } from "./contract"; + +export const SEARCH_SOURCE_KEYS = [ + "visit", + "condition", + "document", + "vaccination", + "labAnalyte", + "labNote", + "medication", + "symptom", + "symptomNote", + "workout", + "moodNote", + "illnessNote", + "measurementNote", +] as const; + +export type SearchSourceKey = (typeof SEARCH_SOURCE_KEYS)[number]; + +/** The group each source lists its hits under. */ +export const SEARCH_SOURCE_KIND: Record = { + visit: "visit", + condition: "condition", + document: "document", + vaccination: "vaccination", + labAnalyte: "lab", + labNote: "lab", + medication: "medication", + symptom: "symptom", + symptomNote: "symptom", + workout: "workout", + moodNote: "note", + illnessNote: "note", + measurementNote: "note", +}; + +/** + * The record section each source reads, as the routes that serve those rows + * declare it: visits and the immunization log sit in `profile`, workouts and + * reading notes in `measurements`, mood in `mind`. + */ +export const SEARCH_SOURCE_DOMAIN: Record = { + visit: "profile", + condition: "illness", + document: "documents", + vaccination: "profile", + labAnalyte: "labs", + labNote: "labs", + medication: "medications", + symptom: "illness", + symptomNote: "illness", + workout: "measurements", + moodNote: "mind", + illnessNote: "illness", + measurementNote: "measurements", +}; diff --git a/src/lib/search/wire-schemas.ts b/src/lib/search/wire-schemas.ts new file mode 100644 index 000000000..6523acf04 --- /dev/null +++ b/src/lib/search/wire-schemas.ts @@ -0,0 +1,76 @@ +/** + * Zod schemas for `GET /api/search`. The route parses its query with these + * and the OpenAPI table publishes them. Server and OpenAPI side only: the + * palette imports its types from `./contract.ts`. + */ +import { z } from "zod/v4"; + +import { + SEARCH_HIT_KINDS, + SEARCH_LIMIT_DEFAULT, + SEARCH_LIMIT_MAX, + SEARCH_NOTE_SOURCES, + SEARCH_QUERY_MAX, + SEARCH_QUERY_MIN, +} from "./contract"; + +export const searchQuerySchema = z.object({ + q: z + .string() + .trim() + .min(SEARCH_QUERY_MIN) + .max(SEARCH_QUERY_MAX) + .describe( + "What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue or ss.", + ), + limit: z.coerce + .number() + .int() + .min(1) + .max(SEARCH_LIMIT_MAX) + .default(SEARCH_LIMIT_DEFAULT) + .describe("Most hits per group."), +}); + +const markedTextSchema = z + .object({ + text: z.string(), + marks: z + .array(z.tuple([z.number().int(), z.number().int()])) + .describe( + "Half-open [start, end) character ranges of `text` a query word matched.", + ), + }) + .meta({ id: "SearchMarkedText" }); + +export const searchHitSchema = z + .object({ + id: z.string(), + kind: z.enum(SEARCH_HIT_KINDS), + title: markedTextSchema.nullable(), + context: z.array(markedTextSchema), + snippet: markedTextSchema.nullable(), + day: z.string().nullable(), + path: z + .string() + .describe("In-app path that opens the record, never an API path."), + source: z.enum(SEARCH_NOTE_SOURCES).nullable(), + }) + .meta({ id: "SearchHit" }); + +export const searchResponseSchema = z + .object({ + groups: z.array( + z.object({ + kind: z.enum(SEARCH_HIT_KINDS), + hits: z.array(searchHitSchema), + more: z.boolean(), + }), + ), + truncated: z + .boolean() + .describe( + "The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries may match and are not listed.", + ), + }) + .meta({ id: "SearchResponse" });