diff --git a/bundle-budget.json b/bundle-budget.json
index 84feae937..9ebae9854 100644
--- a/bundle-budget.json
+++ b/bundle-budget.json
@@ -1,6 +1,7 @@
{
- "$comment": "Client JS bundle-size budget. Warn-only until a baseline is captured: after a known-good `npm run build`, run `npm run check:bundle-budget -- --update` to set totalGzipBytes, then flip enforce to true so CI fails on >tolerancePct growth. See scripts/check-bundle-budget.mjs.",
- "enforce": false,
+ "$comment": "Client JS bundle-size budget captured from a known-good production build. CI fails when total gzip size grows beyond tolerancePct; refresh intentionally with `npm run check:bundle-budget -- --update`.",
+ "enforce": true,
"tolerancePct": 10,
- "totalGzipBytes": null
+ "totalGzipBytes": 1363382,
+ "updatedAt": "2026-07-17T12:11:55.267Z"
}
diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md
index 43cd46874..e3e7258e9 100644
--- a/docs/branch-review-ledger.md
+++ b/docs/branch-review-ledger.md
@@ -18,44 +18,46 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD
## Review Records
-| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks |
-| ---------- | ------------------------------------------------------ | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 2026-07-13 | codex/lithium-answer-recovery-pr | c5fde11e64d8976e1c163d1b8618f58a52e0b8ff | lithium answer recovery and source governance | Fixed the provider-failure path with a grounded Australian source-backed fallback, a public-safe progress lifecycle, centralised Australian authority/context selection, and fail-closed locality repair. The live audit exposed and the branch fixed hierarchy identity false conflicts plus registry projections entering clinical metadata gates. The corrected audit/backfill found zero proposals, so no production write was made. No high-confidence defect remains. Residual risk is provider latency; live generation timed out but the grounded fallback completed. | `npm run verify:pr-local` passed: 1,988 tests passed/1 skipped, build/client scan, 36 fixtures, and 267 offline RAG tests. After review fixes, `npm run verify:cheap` passed with 1,992 tests passed/1 skipped. `npm run check:production-readiness` passed 5/5. `npm run test:e2e:critical` passed 9/9. `node scripts/run-playwright.mjs tests/answer-progress-ui-smoke.spec.ts --project=chromium` passed 2/2. `node scripts/run-eval-safe.mjs scripts/eval-rag.ts --question "Lithium dosing" --expect-australian --fail-on-threshold --json` exited 0 with one grounded FSH citation and no threshold/safety failures. `npm run audit:source-governance` reported 0 gaps/conflicts/proposals across 2,851 rows. `npm run backfill:source-metadata -- --locality-only` reported 0 changes. `git diff --check` passed. Full `npm run verify:ui` was not rerun after the aggregate runner lost its local server. |
-| 2026-07-10 | codex/design-ux-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | design-system + UX + design | Five issue groups confirmed; scoped fixes applied in the worktree. | `npm run check:type-scale`; focused Vitest (19/19); `npm run typecheck`; `npm run lint`; `npm run sitemap:check`; browser/API-backed checks awaiting approval |
-| 2026-07-11 | codex/design-ux-review-integration | 98093ec7b | branch-integration-review | Replayed the reviewed design and UX fixes onto current `origin/main`, preserved the lightweight evidence-panel boundary, and retained the merged quality fixes. | `npm run check:type-scale`; combined focused Vitest (8 files, 42 tests); runtime/action/sitemap/type-scale/lint stages of `verify:cheap`; typecheck blocked by stale worktree dependencies pending hosted clean install; `git diff --check` |
-| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` |
-| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` |
-| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` |
-| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` |
-| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` |
-| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification |
-| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` |
-| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval |
-| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` |
-| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI |
-| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained |
-| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` |
-| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head |
-| 2026-07-11 | codex/pr-check-followup | 298e8f5bec2a4673dd225da3f446f008b8f25953 | residual-pr-check-hardening | Ported only the three PR-check improvements not already merged by PR #454: pinned Supabase CLI/cache ownership, advisory Semgrep coverage for Edge Functions, and regression guards for both contracts. | `npm run check:github-actions`; `npm run check:ci-scope`; focused Prettier; `git diff --check` |
-| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check |
-| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
-| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. |
-| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. |
-| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. |
-| 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
-| 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
-| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
-| 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. |
-| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. |
-| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. |
-| 2026-07-11 | codex/repository-review-remediation | 70ec6409a11a85e1678eb4b320519624673a94a0 | comprehensive repository report remediation | Revalidated all 17 findings from the 2026-07-09 comprehensive review. Remediated the current workflow injection, document scope, numeric faithfulness, ingestion lease/ownership, transactional enrichment replacement, request and upload budgets, browser identity isolation, PHI retention, public DTO, cache cancellation/versioning, evidence labelling, modal focus, misleading controls, telemetry, orphan-module issues, and two server/client loading-boundary failures exposed during browser QA. The runbook filename was already fixed on the reviewed head. | TypeScript, lint, focused Vitest (68/68), full offline Vitest (1,607/1,607; 1 skipped), production build, client-bundle secret scan, Docker schema replay and regenerated drift manifest, isolated full migration reset, local lease-reclaim concurrency proof, cache/enrichment SQL smoke, configured production-readiness (`READY`), Chromium document-scope/modal QA (4/4), manual disabled-control accessibility snapshots, and `git diff --check` passed. Read-only live drift found 26 unexpected differences, including the three unapplied remediation functions; no live mutation was performed. |
-| 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. |
-| 2026-07-13 | codex/repository-review-remediation | b72cefd2f5c0da79788cc0f8d0d40837c711ae92 | live-drift reconciliation and release review | Reconciled production migration history and live-ahead governance/retrieval definitions without mutating live; removed the migration-version collision; made captured OUT-signature changes fresh-replay-safe; preserved production ACLs; added a forward lexical-score correction; and reduced read-only live drift from 27 differences to five changes fully explained by the unapplied remediation migrations. No remaining high-confidence source defect was found in the reviewed scope. | Docker schema replay and regenerated manifest; isolated full Supabase migration reset; focused Vitest 74/74; full Vitest 1,712 passed/1 skipped; lint; typecheck; production build and client-bundle secret scan; configured production-readiness READY; targeted Chromium scope/modal/control QA 4/4; read-only live drift; Supabase security advisor clear. Live apply not run because authorization remained read-only. |
-| 2026-07-13 | codex/repository-review-remediation | 452275824294564a1e08e6bec169bd4af744d09a | live migration apply and post-apply review | Applied the four reviewed forward migrations to `Clinical KB Database`, aligned repository filenames to the generated production versions, and corrected the schema snapshot so the legacy unfenced commit overload remains inaccessible to `service_role`. Live drift is clean and no active ingestion/enrichment overlap or duplicate open ingestion group was found. | Ran `npm run check:drift`: passed clean. Ran `npm run check:production-readiness`: READY. Ran Docker schema replay: passed. Ran focused concurrency/retrieval Vitest: 166/166 passed. Ran offline RAG: 36 fixtures and 60/60 contract tests passed. Ran M13, retrieval-owner, schema-health, lexical-retrieval, concurrency, and ACL live probes: passed; lexical retrieval returned 12 truthfully scored results. Not completed: full provider retrieval-quality evaluation exceeded the local command window; deterministic live retrieval checks passed. |
-| 2026-07-13 | codex/fix-48h-review-findings-current | 49735663370735a60870d065ed0de3b9d34e077f | last-48-hours PR remediation | Revalidated the last-48-hours findings on current main after PRs #538 and #540; retained only unique fixes across auth/cache isolation, stale-response protection, upload/routing/UI behavior, RAG coalescing, telemetry, worktree tooling, and SAST enforcement. No remaining high-confidence local defect was found in the changed scope. The approved live drift check reported only the five differences already explained by unapplied migrations from #540. | Focused Vitest 107/107; `npm run verify:pr-local` (1,762 passed, 1 skipped; production build and client-bundle scan; offline RAG 60/60); critical Chromium 8/8; live `check:drift`; `git diff --check`. Full Chromium remains advisory after the earlier runner hang; the required critical subset passed on current main. |
-| 2026-07-13 | codex/public-anonymous-access | 7f3eded3d17c9daf6a443c9cac3f0553e4e9321b | production UI design and accessibility review | Fixed the fullscreen clinical-table focus leak and divergent modal implementation, removed the non-native table-surface control, and lifted meaningful production metadata from 8-10px to the 11px floor with stronger muted contrast. No remaining high-confidence defect was found in the reviewed visual scope. | Baseline/final screenshots at 1440x1000 and 390x820; focused Chromium table expansion 3/3; focused Vitest 6/6; `npm run typecheck`; targeted ESLint; type-scale and focused Prettier checks; `git diff --check`. `verify:cheap` timed out in full lint/test execution; full `verify:ui` deferred under the API confirmation boundary. |
-| 2026-07-13 | main (PR #570 squash, glass header) | cc6bfc1c80902ca3c91e5ba2ebe78a80f3fd9e14 | post-merge review: CSS/visual/a11y/perf + logic/regression | No P0/P1. P2s confirmed and fixed in follow-up: build pipeline dropped ALL hand-authored backdrop-filter declarations (manual -webkit- duplicates confused Lightning CSS — header scrim, bottom dock, and composer pill were tint-only in every engine); scrim retuned to carry the bar's frost alone (header backdrop-root removed) with masks fading to true zero; private-scope alert made sticky inside main (was scroll-away in non-answer modes); scroll-hide reporter reset on breakpoint-gate change; non-forced fallback backgrounds layered to preserve the utility-wins contract. | Ran custom Playwright probe (`node scratchpad/probe-blur.mjs`, Chromium 390x844): baseline showed `getComputedStyle(.edge-glass-header-backdrop).backdropFilter === "none"` on all three passes, after fix `blur(14px)/blur(20px)/blur(26px)` — passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium -g "glass header\|collapse hide\|private-scope alert\|phone (short\|long) answer stays"`: 6/6 passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium` (full file): 71 passed, 4 failed (pre-existing `/privacy` heading test, fails identically on clean main); Ran `npm run verify:cheap`: exit 0 (1935 unit tests passed); Ran `npm run format:check`: passed; Not run: WebKit/Safari real-device check (no WebKit runner in this environment — served client chunk verified to pair `-webkit-backdrop-filter` with each declaration for Safari <= 17) |
-| 2026-07-13 | codex/rag-review-followup | 755ac9e517a3b81f8e12a119f80f3769dd58ae4e | PR #575 post-merge review finding remediation | Fixed the P1 path that could combine a medication amount and route from separate chunks, expanded the shared explicit amount/route/frequency intent detector, corrected route-only failure classification, and added microgram-symbol coverage. Requested attributes must now be co-located with the medication subject before the text fast path is accepted. No additional high-confidence defect was found in the changed scope after integrating the production answer-budget fix from PR #580. | Focused Vitest 143/143; `npm run eval:rag:offline` (21 files, 265/265); `npm run typecheck`; targeted ESLint; full `npm test` (211 files passed, 1 skipped; 1,946 tests passed, 1 skipped); PR-local dry-run selected runtime, format, lint, typecheck, full tests, build, and offline RAG; `git diff --check`. `verify:cheap` passed all pre-test stages but its 10-minute host bound expired during the full suite; the same suite then passed independently with a longer bound. |
+| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks |
+| ---------- | -------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2026-07-17 | PR #718 / codex/performance-latency-remediation-20260717 | b5f509744d4f4bac74d414644cd1802f64b97fa9 | CodeRabbit performance and SQL correctness follow-up | Resolved nine confirmed findings and dispositioned one stale test comment: document downloads revalidate signed URLs on every action; committed-generation filtering precedes detail pagination; enrichment fallback errors preserve identity; caller cancellation leaves the shared classifier flight alive; registry seeding preserves its cache signal; aliases emit canonical corrections; rate-limit success metadata is coherent; ambiguous upserts use named constraints; and grantable default ACLs fail closed. The proxy mock duplicate was not present. No remaining high-confidence P0-P2 defect was found. | Integrated focused Vitest 122/122; post-format Vitest 71/71; `npm run verify:cheap` passed runtime/policy/static guards, ESLint, TypeScript, and 2,684/2,684 tests; focused Prettier and `git diff --check`; disposable Docker replay, regenerated drift manifest, and transactional local SQL probes. No OpenAI calls, live Supabase DDL/migration/data write, deployment, or production mutation ran. |
+| 2026-07-17 | PR #718 / codex/performance-latency-remediation-20260717 | d47ef7a329256687a615c33dd311806c2c1214a8 | hosted UI and migration-order merge-blocker follow-up | Fixed both integration defects exposed by the exact-head UI run: the document scope surface now triggers the deferred, deduplicated catalogue load, and viewer navigation closes unrelated disclosures before opening or scrolling to the selected section. Updated SSR-aware browser fixtures without restoring the removed detail request. Renumbered all three new migrations after the latest production migration and regenerated the drift manifest. No remaining high-confidence P0-P2 defect was found in the follow-up diff. | Focused Vitest 65/65; scoped ESLint and Prettier; `git diff --check`; isolated production Webpack build and TypeScript; focused Chromium 7/7 including both stress viewports; Docker schema replay and drift-manifest regeneration passed in 15 seconds with unchanged schema SHA. No OpenAI calls or live Supabase DDL, migration, rate-limit RPC, or data write ran. |
+| 2026-07-13 | codex/lithium-answer-recovery-pr | c5fde11e64d8976e1c163d1b8618f58a52e0b8ff | lithium answer recovery and source governance | Fixed the provider-failure path with a grounded Australian source-backed fallback, a public-safe progress lifecycle, centralised Australian authority/context selection, and fail-closed locality repair. The live audit exposed and the branch fixed hierarchy identity false conflicts plus registry projections entering clinical metadata gates. The corrected audit/backfill found zero proposals, so no production write was made. No high-confidence defect remains. Residual risk is provider latency; live generation timed out but the grounded fallback completed. | `npm run verify:pr-local` passed: 1,988 tests passed/1 skipped, build/client scan, 36 fixtures, and 267 offline RAG tests. After review fixes, `npm run verify:cheap` passed with 1,992 tests passed/1 skipped. `npm run check:production-readiness` passed 5/5. `npm run test:e2e:critical` passed 9/9. `node scripts/run-playwright.mjs tests/answer-progress-ui-smoke.spec.ts --project=chromium` passed 2/2. `node scripts/run-eval-safe.mjs scripts/eval-rag.ts --question "Lithium dosing" --expect-australian --fail-on-threshold --json` exited 0 with one grounded FSH citation and no threshold/safety failures. `npm run audit:source-governance` reported 0 gaps/conflicts/proposals across 2,851 rows. `npm run backfill:source-metadata -- --locality-only` reported 0 changes. `git diff --check` passed. Full `npm run verify:ui` was not rerun after the aggregate runner lost its local server. |
+| 2026-07-10 | codex/design-ux-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | design-system + UX + design | Five issue groups confirmed; scoped fixes applied in the worktree. | `npm run check:type-scale`; focused Vitest (19/19); `npm run typecheck`; `npm run lint`; `npm run sitemap:check`; browser/API-backed checks awaiting approval |
+| 2026-07-11 | codex/design-ux-review-integration | 98093ec7b | branch-integration-review | Replayed the reviewed design and UX fixes onto current `origin/main`, preserved the lightweight evidence-panel boundary, and retained the merged quality fixes. | `npm run check:type-scale`; combined focused Vitest (8 files, 42 tests); runtime/action/sitemap/type-scale/lint stages of `verify:cheap`; typecheck blocked by stale worktree dependencies pending hosted clean install; `git diff --check` |
+| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` |
+| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` |
+| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` |
+| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` |
+| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` |
+| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification |
+| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` |
+| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval |
+| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` |
+| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI |
+| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained |
+| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` |
+| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head |
+| 2026-07-11 | codex/pr-check-followup | 298e8f5bec2a4673dd225da3f446f008b8f25953 | residual-pr-check-hardening | Ported only the three PR-check improvements not already merged by PR #454: pinned Supabase CLI/cache ownership, advisory Semgrep coverage for Edge Functions, and regression guards for both contracts. | `npm run check:github-actions`; `npm run check:ci-scope`; focused Prettier; `git diff --check` |
+| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check |
+| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
+| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. |
+| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. |
+| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. |
+| 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
+| 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
+| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. |
+| 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. |
+| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. |
+| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. |
+| 2026-07-11 | codex/repository-review-remediation | 70ec6409a11a85e1678eb4b320519624673a94a0 | comprehensive repository report remediation | Revalidated all 17 findings from the 2026-07-09 comprehensive review. Remediated the current workflow injection, document scope, numeric faithfulness, ingestion lease/ownership, transactional enrichment replacement, request and upload budgets, browser identity isolation, PHI retention, public DTO, cache cancellation/versioning, evidence labelling, modal focus, misleading controls, telemetry, orphan-module issues, and two server/client loading-boundary failures exposed during browser QA. The runbook filename was already fixed on the reviewed head. | TypeScript, lint, focused Vitest (68/68), full offline Vitest (1,607/1,607; 1 skipped), production build, client-bundle secret scan, Docker schema replay and regenerated drift manifest, isolated full migration reset, local lease-reclaim concurrency proof, cache/enrichment SQL smoke, configured production-readiness (`READY`), Chromium document-scope/modal QA (4/4), manual disabled-control accessibility snapshots, and `git diff --check` passed. Read-only live drift found 26 unexpected differences, including the three unapplied remediation functions; no live mutation was performed. |
+| 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. |
+| 2026-07-13 | codex/repository-review-remediation | b72cefd2f5c0da79788cc0f8d0d40837c711ae92 | live-drift reconciliation and release review | Reconciled production migration history and live-ahead governance/retrieval definitions without mutating live; removed the migration-version collision; made captured OUT-signature changes fresh-replay-safe; preserved production ACLs; added a forward lexical-score correction; and reduced read-only live drift from 27 differences to five changes fully explained by the unapplied remediation migrations. No remaining high-confidence source defect was found in the reviewed scope. | Docker schema replay and regenerated manifest; isolated full Supabase migration reset; focused Vitest 74/74; full Vitest 1,712 passed/1 skipped; lint; typecheck; production build and client-bundle secret scan; configured production-readiness READY; targeted Chromium scope/modal/control QA 4/4; read-only live drift; Supabase security advisor clear. Live apply not run because authorization remained read-only. |
+| 2026-07-13 | codex/repository-review-remediation | 452275824294564a1e08e6bec169bd4af744d09a | live migration apply and post-apply review | Applied the four reviewed forward migrations to `Clinical KB Database`, aligned repository filenames to the generated production versions, and corrected the schema snapshot so the legacy unfenced commit overload remains inaccessible to `service_role`. Live drift is clean and no active ingestion/enrichment overlap or duplicate open ingestion group was found. | Ran `npm run check:drift`: passed clean. Ran `npm run check:production-readiness`: READY. Ran Docker schema replay: passed. Ran focused concurrency/retrieval Vitest: 166/166 passed. Ran offline RAG: 36 fixtures and 60/60 contract tests passed. Ran M13, retrieval-owner, schema-health, lexical-retrieval, concurrency, and ACL live probes: passed; lexical retrieval returned 12 truthfully scored results. Not completed: full provider retrieval-quality evaluation exceeded the local command window; deterministic live retrieval checks passed. |
+| 2026-07-13 | codex/fix-48h-review-findings-current | 49735663370735a60870d065ed0de3b9d34e077f | last-48-hours PR remediation | Revalidated the last-48-hours findings on current main after PRs #538 and #540; retained only unique fixes across auth/cache isolation, stale-response protection, upload/routing/UI behavior, RAG coalescing, telemetry, worktree tooling, and SAST enforcement. No remaining high-confidence local defect was found in the changed scope. The approved live drift check reported only the five differences already explained by unapplied migrations from #540. | Focused Vitest 107/107; `npm run verify:pr-local` (1,762 passed, 1 skipped; production build and client-bundle scan; offline RAG 60/60); critical Chromium 8/8; live `check:drift`; `git diff --check`. Full Chromium remains advisory after the earlier runner hang; the required critical subset passed on current main. |
+| 2026-07-13 | codex/public-anonymous-access | 7f3eded3d17c9daf6a443c9cac3f0553e4e9321b | production UI design and accessibility review | Fixed the fullscreen clinical-table focus leak and divergent modal implementation, removed the non-native table-surface control, and lifted meaningful production metadata from 8-10px to the 11px floor with stronger muted contrast. No remaining high-confidence defect was found in the reviewed visual scope. | Baseline/final screenshots at 1440x1000 and 390x820; focused Chromium table expansion 3/3; focused Vitest 6/6; `npm run typecheck`; targeted ESLint; type-scale and focused Prettier checks; `git diff --check`. `verify:cheap` timed out in full lint/test execution; full `verify:ui` deferred under the API confirmation boundary. |
+| 2026-07-13 | main (PR #570 squash, glass header) | cc6bfc1c80902ca3c91e5ba2ebe78a80f3fd9e14 | post-merge review: CSS/visual/a11y/perf + logic/regression | No P0/P1. P2s confirmed and fixed in follow-up: build pipeline dropped ALL hand-authored backdrop-filter declarations (manual -webkit- duplicates confused Lightning CSS — header scrim, bottom dock, and composer pill were tint-only in every engine); scrim retuned to carry the bar's frost alone (header backdrop-root removed) with masks fading to true zero; private-scope alert made sticky inside main (was scroll-away in non-answer modes); scroll-hide reporter reset on breakpoint-gate change; non-forced fallback backgrounds layered to preserve the utility-wins contract. | Ran custom Playwright probe (`node scratchpad/probe-blur.mjs`, Chromium 390x844): baseline showed `getComputedStyle(.edge-glass-header-backdrop).backdropFilter === "none"` on all three passes, after fix `blur(14px)/blur(20px)/blur(26px)` — passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium -g "glass header\|collapse hide\|private-scope alert\|phone (short\|long) answer stays"`: 6/6 passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium` (full file): 71 passed, 4 failed (pre-existing `/privacy` heading test, fails identically on clean main); Ran `npm run verify:cheap`: exit 0 (1935 unit tests passed); Ran `npm run format:check`: passed; Not run: WebKit/Safari real-device check (no WebKit runner in this environment — served client chunk verified to pair `-webkit-backdrop-filter` with each declaration for Safari <= 17) |
+| 2026-07-13 | codex/rag-review-followup | 755ac9e517a3b81f8e12a119f80f3769dd58ae4e | PR #575 post-merge review finding remediation | Fixed the P1 path that could combine a medication amount and route from separate chunks, expanded the shared explicit amount/route/frequency intent detector, corrected route-only failure classification, and added microgram-symbol coverage. Requested attributes must now be co-located with the medication subject before the text fast path is accepted. No additional high-confidence defect was found in the changed scope after integrating the production answer-budget fix from PR #580. | Focused Vitest 143/143; `npm run eval:rag:offline` (21 files, 265/265); `npm run typecheck`; targeted ESLint; full `npm test` (211 files passed, 1 skipped; 1,946 tests passed, 1 skipped); PR-local dry-run selected runtime, format, lint, typecheck, full tests, build, and offline RAG; `git diff --check`. `verify:cheap` passed all pre-test stages but its 10-minute host bound expired during the full suite; the same suite then passed independently with a longer bound. |
| 2026-07-13 | origin/main (detached review worktree) | c523cabeae4b68ebdf569ecbc18d9f5a7b5afbf1 | repo-wide audit | Changes requested: one P1 clinical-answer trust cluster; three P2 tenancy/reindex guardrail issues; three P3 information-disclosure, dead-code, and transitive-deprecation cleanup items. No P0 found. | `npm run verify:cheap` (1,721 passed, 1 skipped); `npm run test:coverage` (thresholds passed); `npm run build`; `npm run verify:ui` (137/137); `npm run eval:rag:offline` (36 fixtures, 60 tests); format, Edge Function, Codex workflow, import/secret/dead-reference scans. Provider-backed checks skipped. |
| 2026-07-13 | HEAD / origin/main (detached worktree) | 04c1d0b036cae8af4dabfc692055c7aab93d5888 | OpenAI-facing API and integration review | Read-only review found one P1 clinical-streaming defect and four P2 reliability/API-contract issues: provisional clinical prose is exposed before validation; mid-stream failure can silently trigger a second buffered generation; answer caches are not model/prompt fingerprinted; OpenAI access/model errors are under-classified; and table-fact route IDs are not validated before database access. GPT-5.6 migration also requires replacing the legacy prompt-cache parameter. No application code was changed. | Static call-flow, prompt, model, schema, streaming, cache, error, route, test, and governance inspection; official OpenAI model/Responses/structured-output/streaming/prompt-cache guidance reviewed. Provider-backed checks were not run. Local tests were not run because this worktree has no installed dependencies (`openai`, `vitest`). |
@@ -576,5 +578,6 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD
| 2026-07-17 | PR #713 / codex/chat-workflow-ideas-0916 | b52112df6aa36311d7420189064acd79dcf2c3f5 + reviewed follow-up diff | workflow toolkit review follow-up | Fixed all 14 actionable Codex and CodeRabbit threads: cross-platform path fixtures, installation-managed preflight guidance, complete Supabase-backed API database scoping, per-command approval boundaries, plugin-ignore narrowing, isolated CI-scope proof, remote-Git command guarding, repository-skill verification classification, `TypeError` diagnosis, strict CLI option values, machine-parseable JSON evidence output, and preservation of baseline database/clinical approval gates in the RAG lab. No unresolved actionable finding remains in the reviewed scope. | `npm run verify:cheap` passed with 273 files and 2,599 tests; focused toolkit Vitest 20/20; CI-scope self-test; plugin-ignore proof; `git diff --check`. Exact-head hosted CI remains required after the follow-up push. No Supabase, OpenAI, or other live product-provider command was run. |
| 2026-07-17 | PR #699 / codex/test-reliability-hardening | 6202835ab1cf3703af311b9afdf372f74c63e040 | branch-cleanup-superseded | Closed as fully superseded by merged PR #705 (`e5caaa46c`). Range-diff maps the original implementation commit to #705's first integration commit; #705 then adds seven focused reliability fixes, while #699's remaining commit is merge-only and contributes no unique relevant patch. The exact-SHA remote ref and the unregistered local predecessor ref (`b518c1de9`) were deleted after final rechecks. | Fresh GitHub PR/head/status inventory; exact `ls-remote` and local-ref checks; cherry-pick-aware log; range-diff against PR #705's merged head; merge-parent verification; exact leased remote deletion and exact-old-value local `update-ref` deletion. No Supabase/OpenAI/product-provider checks run. |
| 2026-07-17 | PR #716 / codex/pr-process-hardening-20260717 | 220de891f10f82df11eb2e8137367514c139a206 + reviewed implementation/follow-up diff | PR metadata, CI triage, review routing, Actions permissions, secret scanning, and branch-hygiene hardening | No remaining high-confidence P0-P2 defect in the reviewed process diff. Added a trusted base-SHA PR metadata/risk policy with draft and merge-queue handling; corrected CI triage to compare only the same workflow's latest completed `main` run; wired self-tests into local/hosted gates; documented the policy; created the missing durable Codex routing labels; removed four unused per-head routing labels; and applied read-only default Actions tokens, immutable Action pinning, no Actions-authored approvals, push protection, and automatic merged-branch deletion. Four policy defects were fixed before merge: API-only paths no longer trigger UI evidence, slash-bearing outcome titles are accepted, all seven governance items are required exactly, and placeholder risk/rollback text is rejected. | Offline `check:pr-policy`, `check:ci-triage`, `check:ci-scope`, action-pin check, scoped Prettier, and `git diff --check` passed. Initial hosted CI passed static, safety, coverage, build, images, Semgrep, Gitleaks, GitGuardian, and the aggregate gate; exact-head CI is required after the review fix. Broader local gates were deferred because other registered worktrees repeatedly held the heavyweight lock. GitHub provider inspection/settings changes were explicitly authorized. No Supabase/OpenAI/product-provider command ran. |
+| 2026-07-17 | codex/performance-latency-remediation-20260717 | f277d13512e85dcaae4f9b94d028a8087e41375a | end-to-end database, network, middleware, client rendering, migration, privacy, and merge-readiness review | Fixed all confirmed performance findings plus three final review blockers: title-vocabulary backfill now installs its privacy trigger first, canonical schema replay preserves hardened trigger-function ACLs, and medication auto-seeding no longer aborts its own owner-cache flight. The isolated worktree also uses Next's supported Webpack fallback only when shared dependencies resolve outside the project. No remaining high-confidence P0-P2 defect was found in the reviewed diff. | Docker scratch replay and drift-manifest regeneration passed; live read-only plans selected lexical, table-fact trigram, and chunk HNSW indexes; exact TypeScript passed; focused Vitest owner-cache 9/9, runner safety 12/12, bundle budget 10/10, offline RAG 291/291, and earlier aggregate unit run 2,657 passed with its sole corrected schema assertion subsequently validated by direct ordering/ACL checks; production build and bundle budget passed at 1,363,382 gzip bytes; targeted Chromium dashboard deferral and NDJSON search passed, while the viewer trace reached all hydration/preview assertions and its corrected download-control selector remains for exact-head hosted UI. No OpenAI calls, live Supabase writes/migrations, deployment, or production mutation ran. |
| 2026-07-17 | codex/mobile-search-phone-refresh-20260717 (supersedes PR #700) | 42a3e3ce65dc5a0e1dce386e0b91fccd23d13d6c + reviewed follow-up diff | phone universal-search command-panel recovery and merge-readiness review | Recovered the still-useful behavior from PR #700 onto current `main`, including its hydration fix and wide-touch regression coverage. Hosted Production UI then exposed one desktop focus race: capability state intentionally initializes false for hydration safety, but an input could receive focus before the post-hydration effect synchronized the real browser state. The follow-up recomputes the same guarded predicate synchronously on focus; it requires the placement breakpoint plus either a fine pointer or a zero-touch desktop fallback, so wide touch devices remain suppressed while desktop keeps the first command-panel interaction. No remaining high-confidence P0-P2 defect was found in the scoped diff. | Focused Vitest 7/7; `npm run ensure` verified the project at `http://localhost:3751`; hosted static, safety, coverage, build, advisory UI, Semgrep, Gitleaks, and GitGuardian passed; the first hosted Production UI run isolated the nine desktop regressions. The focused browser proof reproduced the desktop race while the wide-touch regression passed, and exact-head hosted Production UI remains required after the focus fix. `format:changed -- --check` and `git diff --check` passed before the final follow-up. No Supabase/OpenAI/product-provider command ran. |
| 2026-07-17 | final historical branch/worktree cleanup against `origin/main` | 5d195d7ca8752b2ae4006725c6b145c5662bb687 | branch-cleanup | Merged PRs #716 and #717, closed superseded PR #700, and removed the three clean task worktrees. Deleted exact remote refs for `codex/mobile-search-phone-fix-20260717` (`590f32b73`), `claude/audit-findings-review-phgz92` (`ea3b8f95b8`), `codex/chat-forms-import-6914` (`b05da82f82`), `codex/chat-supabase-migration-preflight-b463` (`1ef0faee95`), and `codex/dsm-diagnosis-mode` (`f6cda83ca6`); the merged #716/#717 branches were deleted automatically. Deleted 14 unregistered local refs only after direct-main ancestry, exact ledger deletion-pending proof, or exact merged-PR commit provenance. Final inventory found zero remote branches without an open PR or registered worktree, zero locally merged or exact deletion-pending orphan refs, and no retired target refs or paths. Thirteen non-ancestor local refs and 25 registered worktrees remain preserved because they are backups, patch-unique/unresolved, open-PR-owned, or ownership could not be safely disproved. | Fresh fetch/prune; exact GitHub PR/head/merge associations; cherry-pick-aware logs; DSM PR #661 exact commit/file provenance; exact leased remote deletes; exact-old-value local `update-ref` deletes; clean-worktree and path/process checks; worktree prune; final zero-orphan inventory. The Codex task registry lookup timed out, so ambiguous registered worktrees were conservatively retained. No Supabase, OpenAI, production-data, or live clinical workflow ran. |
diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md
new file mode 100644
index 000000000..9cb1faf3a
--- /dev/null
+++ b/docs/operator-apply-performance-latency-remediation.md
@@ -0,0 +1,25 @@
+# Operator apply — performance and latency remediation
+
+This worktree does not apply migrations. Production rollout remains a separate,
+explicitly authorized operation after local replay, review, and backups.
+
+## Registry projection index on a busy database
+
+`20260717170000_registry_projection_cleanup.sql` creates
+`documents_registry_projection_lookup_idx` transactionally so clean local
+replay remains deterministic. On a busy production database, pre-create the
+exact index outside a transaction:
+
+```sql
+create index concurrently if not exists documents_registry_projection_lookup_idx
+ on public.documents (
+ (metadata->>'registry_record_kind'),
+ (metadata->>'registry_record_id')
+ )
+ where metadata->>'source_kind' = 'registry_record';
+```
+
+After the index is valid and ready, the migration's `create index if not
+exists` is a no-op. Do not mark the migration applied merely because the index
+exists: the cleanup function, hardened privileges, and three lifecycle triggers
+must still be installed through the normal authorized migration rollout.
diff --git a/docs/process-hardening.md b/docs/process-hardening.md
index 5bc236c26..c692dc6f4 100644
--- a/docs/process-hardening.md
+++ b/docs/process-hardening.md
@@ -320,7 +320,7 @@ hybrid:10}`, all 10 forced-embedding vector cases passed (`force_embedding_failu
## 2026-07-13 audit remediation batch (branch claude/audit-remediation-2026-07-13)
- **Lexical retrieval rewrite (audit finding 1):** `20260713100000_index_friendly_lexical_retrieval.sql` splits `match_document_chunks_text`'s OR-across-relations candidate search into two GIN-index probes unioned by chunk id (same contract, same scores; the `_v2` wrapper inherits the speedup). Parity + plan + timing harness: `scripts/sql/lexical-rpc-parity-check.sql` (scratch databases only; run it against the drift-manifest container kept with `--keep`). Re-run it whenever either lexical body changes.
-- **supabase_admin default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies the intended defaults before migrations on fresh databases. Supabase CLI reads that file as the unprivileged local `postgres` role, so the migration-replay job starts an empty emulator without migrations, applies the file once as `supabase_admin`, and then applies every migration with `supabase migration up --local` in the same fresh database. This preserves the database-scoped defaults without granting reserved role membership. `20260717161000_assert_supabase_admin_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. The migration attempts the hardening statements and then fails closed if the effective postcondition is unsafe, returning the exact six-statement operator remediation in its error hint. Existing hosted databases do not receive `roles.sql` through ordinary migration deployment; after an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. The future-object probe in `20260713102000_revoke_supabase_admin_default_privileges.sql` remains useful in environments that can assume `supabase_admin`.
+- **supabase_admin default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies the intended defaults before migrations on fresh databases. Supabase CLI reads that file as the unprivileged local `postgres` role, so the migration-replay job starts an empty emulator without migrations, applies the file once as `supabase_admin`, and then applies every migration with `supabase migration up --local` in the same fresh database. This preserves the database-scoped defaults without granting reserved role membership. `20260717161000_assert_supabase_admin_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260717173000_reassert_supabase_admin_default_privileges.sql` repeats that fail-closed postcondition after the later performance migrations and must remain the final migration. Existing hosted databases do not receive `roles.sql` through ordinary migration deployment; after an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. The future-object probe in `20260713102000_revoke_supabase_admin_default_privileges.sql` remains useful in environments that can assume `supabase_admin`.
- **Legacy rag query text scrub (audit finding 5):** `20260713103000_scrub_legacy_rag_query_text.sql` performs four redaction/deletion operations for pre-HMAC plaintext query text: (1) scrubs `rag_queries.query` rows not matching `redacted-query:%`, replacing them with salted `redacted-query:legacy:` placeholders; (2) scrubs both `rag_query_misses.query` and `rag_query_misses.normalized_query` not matching `redacted-query:%`; (3) scrubs both `rag_retrieval_logs.query` and `rag_retrieval_logs.normalized_query` not matching `redacted-query:%` (nullable); (4) deletes `rag_response_cache` rows where `normalized_query` does not match `redacted-cache:%` (cache entries, not re-keyed). **Operator verification after live apply:** for each affected table/operation, count rows not matching the expected redacted pattern (expect 0 unless `RAG_PERSIST_RAW_QUERY_TEXT` is deliberately enabled): `select count(*) from rag_queries where query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_query_misses where query not like 'redacted-query:%' or normalized_query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_retrieval_logs where query not like 'redacted-query:%' or (normalized_query is not null and normalized_query not like 'redacted-query:%');` (expect 0), `select count(*) from rag_response_cache where normalized_query not like 'redacted-cache:%';` (expect 0). The migration includes a post-apply assertion block that enforces these exact checks and fails the migration if any unscrubbed/undeleted rows remain.
- Remaining operator items from the 2026-07-13 audit (confirmation-required, not automated): apply this batch's migrations live, re-run Supabase advisors, trigger the Eval Canary and require two consecutive green scheduled runs, repair the invalid `storage.idx_objects_bucket_id_name_lower` index via dashboard/support, and dedupe response-cache purge cron jobs if `cron.job` shows duplicates.
diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs
index c1a17349c..47e6a7113 100644
--- a/scripts/check-bundle-budget.mjs
+++ b/scripts/check-bundle-budget.mjs
@@ -25,6 +25,30 @@ import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const CHUNKS_DIR = path.join(root, ".next", "static", "chunks");
const BUDGET_PATH = path.join(root, "bundle-budget.json");
+const APP_BUILD_MANIFEST_PATH = path.join(root, ".next", "app-build-manifest.json");
+const BUILD_MANIFEST_PATH = path.join(root, ".next", "build-manifest.json");
+const ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH = path.join(
+ root,
+ ".next",
+ "server",
+ "app",
+ "page_client-reference-manifest.js",
+);
+
+const fixtureSnapshotMarkerGroups = [
+ {
+ name: "services snapshot",
+ markers: ["deep_research_citation_tokens", "canonical_name_key", "source_table_lines"],
+ },
+ {
+ name: "forms fixture catalogue",
+ markers: ["transport-crisis-form", "extension-transport-order", "detention-examination-movement"],
+ },
+ {
+ name: "differentials snapshot",
+ markers: ["redFlagFlows", "searchAliases", "exportedAt"],
+ },
+];
function walkJsFiles(dir) {
const out = [];
@@ -74,6 +98,45 @@ export function compareToBudget(current, budget) {
};
}
+/** Resolve the JavaScript chunks required by the root App Router dashboard. */
+export function initialDashboardChunkNames(appBuildManifest, pageClientReferenceManifest) {
+ const pages = appBuildManifest?.pages ?? {};
+ const pageClientChunks = Object.values(pageClientReferenceManifest?.clientModules ?? {}).flatMap((module) =>
+ Array.isArray(module?.chunks) ? module.chunks : [],
+ );
+ const names = new Set([
+ ...(appBuildManifest?.rootMainFiles ?? []),
+ ...(pages["/layout"] ?? []),
+ ...(pages["/page"] ?? []),
+ ...pageClientChunks,
+ ]);
+ return [...names]
+ .filter((name) => typeof name === "string" && name.endsWith(".js"))
+ .map((name) => name.replace(/^\/?static\/chunks\//, ""));
+}
+
+function loadRootPageClientReferenceManifest() {
+ if (!existsSync(ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH)) return null;
+ const source = readFileSync(ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH, "utf8");
+ const marker = 'globalThis.__RSC_MANIFEST["/page"]=';
+ const start = source.indexOf(marker);
+ if (start < 0) return null;
+ const jsonStart = start + marker.length;
+ const jsonEnd = source.lastIndexOf(";");
+ if (jsonEnd <= jsonStart) return null;
+ return JSON.parse(source.slice(jsonStart, jsonEnd));
+}
+
+/** Identify large fixture payloads from stable groups of serialized keys/slugs.
+ * Requiring every marker in a group avoids failing on ordinary UI copy that
+ * happens to mention one fixture term. */
+export function findFixtureSnapshotsInChunks(files) {
+ const content = files.map(({ buffer }) => buffer.toString("utf8")).join("\n");
+ return fixtureSnapshotMarkerGroups
+ .filter((group) => group.markers.every((marker) => content.includes(marker)))
+ .map((group) => group.name);
+}
+
function kb(bytes) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
@@ -101,6 +164,30 @@ function main() {
name: path.relative(CHUNKS_DIR, full),
buffer: readFileSync(full),
}));
+ const manifestPath = existsSync(APP_BUILD_MANIFEST_PATH)
+ ? APP_BUILD_MANIFEST_PATH
+ : existsSync(BUILD_MANIFEST_PATH)
+ ? BUILD_MANIFEST_PATH
+ : null;
+ if (!manifestPath) {
+ console.error("[bundle-budget] FAIL — no build manifest is available; cannot verify initial dashboard chunks.");
+ process.exit(1);
+ }
+ const appBuildManifest = JSON.parse(readFileSync(manifestPath, "utf8"));
+ const pageClientReferenceManifest = loadRootPageClientReferenceManifest();
+ const initialChunkNames = new Set(initialDashboardChunkNames(appBuildManifest, pageClientReferenceManifest));
+ const initialDashboardChunks = files.filter((file) => initialChunkNames.has(file.name.replace(/\\/g, "/")));
+ if (initialDashboardChunks.length === 0) {
+ console.error("[bundle-budget] FAIL — no root dashboard JavaScript chunks were resolved from the build manifest.");
+ process.exit(1);
+ }
+ const fixtureViolations = findFixtureSnapshotsInChunks(initialDashboardChunks);
+ if (fixtureViolations.length > 0) {
+ console.error(
+ `[bundle-budget] FAIL — initial dashboard chunks contain fixture payloads: ${fixtureViolations.join(", ")}.`,
+ );
+ process.exit(1);
+ }
const current = measureChunks(files);
const budget = loadBudget();
@@ -113,7 +200,7 @@ function main() {
const verdict = compareToBudget(current, budget);
if (asJson) {
- console.log(JSON.stringify({ current, verdict }, null, 2));
+ console.log(JSON.stringify({ current, verdict, initialDashboardChunks: initialDashboardChunks.length }, null, 2));
} else {
console.log(
`[bundle-budget] client chunks: ${current.files} files, ${kb(current.totalGzipBytes)} gzip (${kb(current.totalRawBytes)} raw).`,
@@ -122,6 +209,9 @@ function main() {
console.log(`[bundle-budget] baseline ${kb(verdict.baseline)} gzip; ${verdict.reason}.`);
console.log("[bundle-budget] largest chunks (gzip):");
for (const c of current.largest) console.log(` ${kb(c.gzipBytes).padStart(12)} ${c.name}`);
+ console.log(
+ `[bundle-budget] initial dashboard fixture assertion passed (${initialDashboardChunks.length} chunks).`,
+ );
}
if (verdict.status === "fail") {
diff --git a/scripts/dev-free-port.mjs b/scripts/dev-free-port.mjs
index 6c3bbcf66..b5ba23ba0 100644
--- a/scripts/dev-free-port.mjs
+++ b/scripts/dev-free-port.mjs
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
+import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -60,6 +61,24 @@ function removePortArgs(args) {
return cleaned;
}
+function dependencyBundlerArgs(command, args) {
+ if (command !== "dev" || args.some((arg) => ["--webpack", "--turbopack", "--turbo"].includes(arg))) {
+ return [];
+ }
+
+ try {
+ const dependenciesPath = fs.realpathSync(path.join(projectRoot, "node_modules"));
+ const relativeDependenciesPath = path.relative(projectRoot, dependenciesPath);
+ const dependenciesAreExternal =
+ relativeDependenciesPath === ".." ||
+ relativeDependenciesPath.startsWith(`..${path.sep}`) ||
+ path.isAbsolute(relativeDependenciesPath);
+ return dependenciesAreExternal ? ["--webpack"] : [];
+ } catch {
+ return [];
+ }
+}
+
function canListenOnHost(port, host) {
return new Promise((resolve) => {
const server = net.createServer();
@@ -140,6 +159,7 @@ const child = spawn(
"--port",
String(freePort),
...removePortArgs(forwardedArgs),
+ ...dependencyBundlerArgs(parsedCommand.command, forwardedArgs),
],
{
cwd: projectRoot,
diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts
index eff39611a..843ee7153 100644
--- a/src/app/api/answer/stream/route.ts
+++ b/src/app/api/answer/stream/route.ts
@@ -5,6 +5,7 @@ import { isDemoMode } from "@/lib/env";
import { PublicApiError, jsonError } from "@/lib/http";
import {
allowRateLimitInMemoryFallbackOnUnavailable,
+ consumeSummaryRateLimits,
consumeSubjectApiRateLimit,
rateLimitJsonResponse,
type ApiRateLimitResult,
@@ -272,24 +273,24 @@ export async function POST(request: Request) {
const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);
- const rateLimit = await consumeSubjectApiRateLimit({
- supabase,
- subject: access.rateLimitSubject,
- bucket: "answer",
- allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
- });
- if (rateLimit.limited) return rateLimitStream(rateLimit);
-
if (body.summaryMode) {
- // Streamed full-document summaries use the same paid provider path as the
- // legacy summary endpoint. Preserve the general answer ceiling, then also
- // enforce the stricter summary quota before the SSE stream can start.
- const summaryRateLimit = await consumeSubjectApiRateLimit({
+ const decision = await consumeSummaryRateLimits({
+ supabase,
+ subject: access.rateLimitSubject,
+ });
+ if (decision.rateLimit.limited) {
+ return decision.bucket === "document_summarize"
+ ? documentSummaryRateLimitStream(decision.rateLimit)
+ : rateLimitStream(decision.rateLimit);
+ }
+ } else {
+ const rateLimit = await consumeSubjectApiRateLimit({
supabase,
subject: access.rateLimitSubject,
- bucket: "document_summarize",
+ bucket: "answer",
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
- if (summaryRateLimit.limited) return documentSummaryRateLimitStream(summaryRateLimit);
+ if (rateLimit.limited) return rateLimitStream(rateLimit);
}
return streamAnswer(body, resolveRetrievalAccessScope(access.ownerId), request.signal);
diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts
index e61e7fcf1..25fd4e50f 100644
--- a/src/app/api/differentials/[slug]/route.ts
+++ b/src/app/api/differentials/[slug]/route.ts
@@ -17,6 +17,7 @@ import {
import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed";
import { getDifferentialDetailContext, getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { safeErrorLogDetails } from "@/lib/privacy";
import { publicAccessContext } from "@/lib/public-api-access";
@@ -30,10 +31,13 @@ const differentialDetailQuerySchema = z.object({
kind: z.enum(["presentation", "diagnosis"]).optional().default("diagnosis"),
});
-function differentialResponse(payload: Record, init?: { status?: number }) {
+function differentialResponse(
+ payload: Record,
+ init: { status?: number; request?: Request; fixture?: boolean } = {},
+) {
return NextResponse.json(payload, {
- status: init?.status ?? 200,
- headers: { "Cache-Control": "private, no-store" },
+ status: init.status ?? 200,
+ headers: fixtureResponseHeaders(init.request, init),
});
}
@@ -53,20 +57,26 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (kind === "presentation") {
const workflow = getPresentationWorkflow(normalizedSlug);
if (!workflow) return notFoundResponse(normalizedSlug);
- return differentialResponse({
- workflow,
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- demoMode: true,
- });
+ return differentialResponse(
+ {
+ workflow,
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
const record = getDifferentialRecord(normalizedSlug);
if (!record) return notFoundResponse(normalizedSlug);
- return differentialResponse({
- record,
- detailContext: getDifferentialDetailContext(record),
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- demoMode: true,
- });
+ return differentialResponse(
+ {
+ record,
+ detailContext: getDifferentialDetailContext(record),
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit before we serve the seed detail:
@@ -91,20 +101,26 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (kind === "presentation") {
const workflow = getPresentationWorkflow(normalizedSlug);
if (!workflow) return notFoundResponse(normalizedSlug);
- return differentialResponse({
- workflow,
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- publicAccess: true,
- });
+ return differentialResponse(
+ {
+ workflow,
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const record = getDifferentialRecord(normalizedSlug);
if (!record) return notFoundResponse(normalizedSlug);
- return differentialResponse({
- record,
- detailContext: getDifferentialDetailContext(record),
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- publicAccess: true,
- });
+ return differentialResponse(
+ {
+ record,
+ detailContext: getDifferentialDetailContext(record),
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const fetchRecord = async () => {
diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts
index afc883800..a4ccc4744 100644
--- a/src/app/api/differentials/presentations/[slug]/route.ts
+++ b/src/app/api/differentials/presentations/[slug]/route.ts
@@ -16,6 +16,7 @@ import {
import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed";
import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { safeErrorLogDetails } from "@/lib/privacy";
import { publicAccessContext } from "@/lib/public-api-access";
@@ -24,10 +25,13 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
export const runtime = "nodejs";
-function differentialResponse(payload: Record, init?: { status?: number }) {
+function differentialResponse(
+ payload: Record,
+ init: { status?: number; request?: Request; fixture?: boolean } = {},
+) {
return NextResponse.json(payload, {
- status: init?.status ?? 200,
- headers: { "Cache-Control": "private, no-store" },
+ status: init.status ?? 200,
+ headers: fixtureResponseHeaders(init.request, init),
});
}
@@ -50,12 +54,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (!record) return [];
return [{ ...candidate, record }];
});
- return differentialResponse({
- workflow,
- candidates,
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- demoMode: true,
- });
+ return differentialResponse(
+ {
+ workflow,
+ candidates,
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit before we serve the seed detail:
@@ -84,12 +91,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (!record) return [];
return [{ ...candidate, record }];
});
- return differentialResponse({
- workflow,
- candidates,
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- publicAccess: true,
- });
+ return differentialResponse(
+ {
+ workflow,
+ candidates,
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const fetchPresentation = async () => {
diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts
index 256d6a187..ae4559303 100644
--- a/src/app/api/differentials/route.ts
+++ b/src/app/api/differentials/route.ts
@@ -22,6 +22,7 @@ import {
type DifferentialRecordMatch,
} from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { publicAccessContext } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
@@ -43,8 +44,11 @@ const differentialListQuerySchema = z.object({
limit: queryInteger({ fallback: 100, min: 1, max: 200 }),
});
-function differentialResponse(payload: Record) {
- return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
+function differentialResponse(
+ payload: Record,
+ options: { request?: Request; fixture?: boolean } = {},
+) {
+ return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) });
}
function recordMatchesPayload(matches: DifferentialRecordMatch[]) {
@@ -82,10 +86,13 @@ export async function GET(request: Request) {
const { kind, q, limit } = parseRequestQuery(request, differentialListQuerySchema, "Invalid differential query.");
if (isDemoMode() || isLocalNoAuthMode()) {
- return differentialResponse({
- ...publicDifferentialPayload(kind, q, limit),
- demoMode: true,
- });
+ return differentialResponse(
+ {
+ ...publicDifferentialPayload(kind, q, limit),
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit: publicAccessContext skips the
@@ -105,10 +112,13 @@ export async function GET(request: Request) {
}
if (!access.ownerId) {
- return differentialResponse({
- ...publicDifferentialPayload(kind, q, limit),
- publicAccess: true,
- });
+ return differentialResponse(
+ {
+ ...publicDifferentialPayload(kind, q, limit),
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const rows = await fetchOwnerDifferentialRowsWithSeed(supabase, access.ownerId, kind, DIFFERENTIAL_MAX_RECORDS);
diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts
index dde68684b..6cd574841 100644
--- a/src/app/api/documents/[id]/labels/route.ts
+++ b/src/app/api/documents/[id]/labels/route.ts
@@ -145,7 +145,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
.single();
if (error) throw new Error(error.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
return NextResponse.json({ label, labels: await selectLabels(supabase, id) }, { status: 201 });
} catch (error) {
if (error instanceof AuthenticationError) {
@@ -200,7 +200,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.single();
if (error) throw new Error(error.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
return NextResponse.json({ label });
}
@@ -243,7 +243,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.single();
if (error) throw new Error(error.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
return NextResponse.json({ label, labels: await selectLabels(supabase, id) });
} catch (error) {
if (error instanceof AuthenticationError) {
@@ -288,7 +288,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
.eq("source", "manual");
if (error) throw new Error(error.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
return NextResponse.json({ deleted: true, labelId: parsed.labelId, labels: await selectLabels(supabase, id) });
} catch (error) {
if (error instanceof AuthenticationError) {
diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts
index 87bdf5f18..abaa39321 100644
--- a/src/app/api/documents/[id]/route.ts
+++ b/src/app/api/documents/[id]/route.ts
@@ -2,19 +2,21 @@ import { NextResponse } from "next/server";
import type { Json } from "@/lib/supabase/database.types";
import { z } from "zod";
import { rateLimitJsonResponse } from "@/lib/api-rate-limit";
-import { getDemoDocumentPayload } from "@/lib/demo-data";
import { env, isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { buildStorageCleanupJobUpdate } from "@/lib/ingestion";
import { invalidateRagCachesForDocumentMutation } from "@/lib/rag";
-import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
-import { callerOwnsDocumentRow, enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
import { writeAuditLog } from "@/lib/audit";
+import {
+ DocumentDetailRateLimitError,
+ documentDetailQuerySchema,
+ loadAuthorizedDocumentDetail,
+} from "@/lib/document-detail";
import { parseJsonBody } from "@/lib/validation/body";
import { parseRouteParams } from "@/lib/validation/params";
-import { optionalQueryString, parseRequestQuery, queryInteger } from "@/lib/validation/query";
+import { parseRequestQuery } from "@/lib/validation/query";
export const runtime = "nodejs";
@@ -42,85 +44,10 @@ const deleteDocumentResultSchema = z.discriminatedUnion("outcome", [
}),
]);
-const defaultPageWindow = 9;
-const maxPageWindow = 40;
-const defaultChunkWindow = 16;
-const maxChunkWindow = 80;
-const selectedChunkNeighborCount = 3;
-
-const documentDetailQuerySchema = z.object({
- chunk: optionalQueryString({ maxLength: 80 }),
- page: queryInteger({ fallback: 1, min: 1, max: 1_000_000 }),
- pageLimit: queryInteger({ fallback: defaultPageWindow, min: 1, max: maxPageWindow }),
- chunkLimit: queryInteger({ fallback: defaultChunkWindow, min: 1, max: maxChunkWindow }),
- chunkOffset: queryInteger({ fallback: 0, min: 0, max: 1_000_000 }),
-});
-
-function pageWindowAround(pageNumber: number, limit: number, maxPage?: number | null) {
- const half = Math.floor(limit / 2);
- const max = Math.max(1, maxPage ?? Number.MAX_SAFE_INTEGER);
- const from = Math.max(1, Math.min(pageNumber - half, Math.max(1, max - limit + 1)));
- const to = Math.min(max, from + limit - 1);
- return { from, to };
-}
-
function safeMetadata(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {};
}
-function metadataText(metadata: Record, key: string) {
- const value = metadata[key];
- return typeof value === "string" && value.trim() ? value.trim() : null;
-}
-
-function compactTableText(value: string | null, limit = 500) {
- if (!value) return null;
- const compact = value.replace(/\s+/g, " ").trim();
- if (!compact) return null;
- return compact.length > limit ? `${compact.slice(0, limit - 3).trim()}...` : compact;
-}
-
-function metadataStringArrayRows(metadata: Record, key: string) {
- const value = metadata[key];
- if (!Array.isArray(value)) return null;
- const rows = value
- .filter((row): row is unknown[] => Array.isArray(row))
- .map((row) => row.map((cell) => String(cell ?? "").trim()));
- return rows.length ? rows : null;
-}
-
-function metadataStringArray(metadata: Record, key: string) {
- const value = metadata[key];
- if (!Array.isArray(value)) return null;
- const items = value.map((item) => String(item ?? "").trim()).filter(Boolean);
- return items.length ? items : null;
-}
-
-function withImageTableMetadata(image: T) {
- const metadata = safeMetadata(image.metadata);
- const rawTableText = metadataText(metadata, "table_text");
- const tableText = rawTableText ?? metadataText(metadata, "table_text_snippet");
- const publicImage = { ...image };
- delete publicImage.metadata;
- return {
- ...publicImage,
- tableLabel: metadataText(metadata, "table_label"),
- tableTitle: metadataText(metadata, "table_title"),
- tableRole: metadataText(metadata, "table_role"),
- tableTextSnippet: compactTableText(tableText),
- clinicalUseClass: metadataText(metadata, "clinical_use_class"),
- clinicalUseReason: metadataText(metadata, "clinical_use_reason"),
- accessibleTableMarkdown: metadataText(metadata, "accessible_table_markdown") ?? rawTableText,
- tableRows: metadataStringArrayRows(metadata, "table_rows"),
- tableColumns: metadataStringArray(metadata, "table_columns"),
- };
-}
-
-function committedRows(document: { metadata?: unknown }, rows: T[]) {
- const committedGeneration = committedIndexGeneration(document.metadata);
- return rows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: row.metadata, committedGeneration }));
-}
-
function storageWarningsFrom(error: unknown, label: string) {
const message =
error && typeof error === "object" && "message" in error ? String(error.message) : "Storage cleanup failed.";
@@ -188,185 +115,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
try {
const { id: rawId } = await params;
const detailQuery = parseRequestQuery(request, documentDetailQuerySchema, "Invalid document detail query.");
- if (isDemoMode()) {
- const payload = getDemoDocumentPayload(rawId, detailQuery.chunk ?? null);
- if (!payload) {
- return NextResponse.json({ error: "Demo document not found." }, { status: 404 });
- }
- return NextResponse.json({ ...payload, demoMode: true });
- }
-
- const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id.");
- const supabase = createAdminClient();
- const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase);
- if (rateLimit.limited) {
- return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit);
- }
- const { data: document, error } = await withOwnerReadScope(
- supabase.from("documents").select("*").eq("id", id),
- access.ownerId,
- ).maybeSingle();
-
- if (error) throw new Error(error.message);
- if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 });
-
- // withOwnerReadScope also returns PUBLIC (owner_id IS NULL) documents to an authenticated
- // caller. The owner-only projection (raw metadata, storage_path/content_hash, index health,
- // image storage paths) must be gated on OWNERSHIP, not merely on being authenticated, so an
- // authed non-owner viewing a shared public document gets the same redacted view as an
- // anonymous caller (S1/D1).
- const isOwner = callerOwnsDocumentRow(document, access.ownerId);
-
- const chunkId = detailQuery.chunk ?? null;
- const requestedPage = Math.min(detailQuery.page, Math.max(1, document.page_count ?? 1));
- const pageLimit = detailQuery.pageLimit;
- const chunkLimit = detailQuery.chunkLimit;
- const chunkOffset = detailQuery.chunkOffset;
-
- let selectedChunk: {
- id: string;
- page_number: number | null;
- chunk_index: number;
- section_heading: string | null;
- content: string;
- image_ids: string[];
- } | null = null;
-
- if (chunkId) {
- const { data, error: selectedChunkError } = await supabase
- .from("document_chunks")
- .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata")
- .eq("document_id", id)
- .eq("id", chunkId)
- .maybeSingle();
-
- if (selectedChunkError) throw new Error(selectedChunkError.message);
- selectedChunk = data && committedRows(document, [data]).length > 0 ? data : null;
- }
-
- const effectivePage = selectedChunk?.page_number ?? requestedPage;
- const pageWindow = pageWindowAround(effectivePage, pageLimit, document.page_count);
- const { data: pages, error: pagesError } = await supabase
- .from("document_pages")
- .select("id,page_number,text,ocr_used,metadata")
- .eq("document_id", id)
- .gte("page_number", pageWindow.from)
- .lte("page_number", pageWindow.to)
- .order("page_number", { ascending: true });
-
- if (pagesError) throw new Error(pagesError.message);
-
- const { data: images, error: imagesError } = await supabase
- .from("document_images")
- .select(
- "id,page_number,storage_path,caption,bbox,mime_type,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata",
- )
- .eq("document_id", id)
- .neq("image_type", "logo_decorative")
- .or("searchable.eq.true,source_kind.eq.table_crop")
- .order("page_number", { ascending: true });
-
- if (imagesError) throw new Error(imagesError.message);
-
- const chunkQuery = supabase
- .from("document_chunks")
- .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata")
- .eq("document_id", id)
- .order("chunk_index", { ascending: true });
-
- const chunkRangeStart = selectedChunk
- ? Math.max(0, selectedChunk.chunk_index - selectedChunkNeighborCount)
- : chunkOffset;
- const chunkRangeEnd = selectedChunk
- ? selectedChunk.chunk_index + selectedChunkNeighborCount
- : chunkOffset + chunkLimit - 1;
-
- const { data: chunks, error: chunksError } = selectedChunk
- ? await chunkQuery.gte("chunk_index", chunkRangeStart).lte("chunk_index", chunkRangeEnd)
- : await chunkQuery.range(chunkRangeStart, chunkRangeEnd);
-
- if (chunksError) throw new Error(chunksError.message);
-
- const [labelsResult, summaryResult, tableFactsResult] = await Promise.all([
- supabase.from("document_labels").select("*").eq("document_id", id).order("confidence", { ascending: false }),
- supabase.from("document_summaries").select("*").eq("document_id", id).maybeSingle(),
- supabase
- .from("document_table_facts")
- .select("*")
- .eq("document_id", id)
- .order("page_number", { ascending: true })
- .limit(200),
- ]);
-
- if (labelsResult.error) throw new Error(labelsResult.error.message);
- if (summaryResult.error) throw new Error(summaryResult.error.message);
- if (tableFactsResult.error) throw new Error(tableFactsResult.error.message);
-
- const omitPublicInternalFields = (row: Record) => {
- const internalKeys = new Set([
- "owner_id",
- "storage_path",
- "content_hash",
- "source_path",
- "import_batch_id",
- "error_message",
- "metadata",
- // Summary provenance: only present on document_summaries rows (fetched with select("*")).
- // A non-owner viewing a public document's summary must not see the owner's chunk/image
- // source IDs or the generation model, matching the list route's PUBLIC_SUMMARY projection.
- "source_chunk_ids",
- "source_image_ids",
- "model",
- ]);
- return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key)));
- };
- const publicRows = >(rows: T[]) =>
- isOwner ? rows : rows.map(omitPublicInternalFields);
- const responseDocument = isOwner ? document : omitPublicInternalFields(document as Record);
-
- return NextResponse.json({
- document: {
- ...responseDocument,
- labels: publicRows((labelsResult.data ?? []) as Record[]),
- summary:
- isOwner || !summaryResult.data
- ? (summaryResult.data ?? null)
- : omitPublicInternalFields(summaryResult.data as Record),
- },
- pages: publicRows((pages ?? []) as Record[]),
- images: publicRows(
- committedRows(document, images ?? []).map(withImageTableMetadata) as Record[],
- ),
- tableFacts: publicRows(committedRows(document, tableFactsResult.data ?? []) as Record[]),
- chunks: publicRows(committedRows(document, chunks ?? []) as Record[]),
- pageWindow: {
- from: pageWindow.from,
- to: pageWindow.to,
- limit: pageLimit,
- total: document.page_count ?? null,
- hasBefore: pageWindow.from > 1,
- hasAfter: Boolean(document.page_count && pageWindow.to < document.page_count),
- },
- chunkWindow: {
- offset: chunkRangeStart,
- limit: selectedChunk ? chunkRangeEnd - chunkRangeStart + 1 : chunkLimit,
- total: document.chunk_count ?? null,
- hasBefore: chunkRangeStart > 0,
- hasAfter: Boolean(document.chunk_count && chunkRangeEnd + 1 < document.chunk_count),
- selectedChunkId: selectedChunk?.id ?? null,
- },
- ...(isOwner
- ? {
- indexHealth: {
- extractionQuality: safeMetadata(document.metadata).extraction_quality ?? null,
- indexedAt: safeMetadata(document.metadata).indexed_at ?? null,
- indexVersion: safeMetadata(document.metadata).rag_indexing_version ?? null,
- warnings: safeMetadata(document.metadata).extraction_warnings ?? [],
- },
- }
- : {}),
- });
+ const payload = await loadAuthorizedDocumentDetail({ request, rawId, query: detailQuery });
+ return NextResponse.json(payload);
} catch (error) {
+ if (error instanceof DocumentDetailRateLimitError) {
+ return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", error.rateLimit);
+ }
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
}
@@ -418,7 +172,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.single();
if (updateError) throw new Error(updateError.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
await writeAuditLog(supabase, {
ownerId: user.id,
action: "document_rename",
@@ -481,7 +235,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
});
if (ledgerWarning) cleanup.storageWarnings.push(ledgerWarning);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
await writeAuditLog(supabase, {
ownerId: user.id,
action: "document_delete",
diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts
index 48de02a16..a02dfaebe 100644
--- a/src/app/api/medications/[slug]/route.ts
+++ b/src/app/api/medications/[slug]/route.ts
@@ -6,6 +6,7 @@ import {
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { getMedicationRecord } from "@/lib/medication-snapshot";
import { ensureMedicationsSeeded } from "@/lib/medication-seed";
@@ -23,10 +24,13 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
export const runtime = "nodejs";
-function medicationResponse(payload: Record, init?: { status?: number }) {
+function medicationResponse(
+ payload: Record,
+ init: { status?: number; request?: Request; fixture?: boolean } = {},
+) {
return NextResponse.json(payload, {
- status: init?.status ?? 200,
- headers: { "Cache-Control": "private, no-store" },
+ status: init.status ?? 200,
+ headers: fixtureResponseHeaders(init.request, init),
});
}
@@ -55,10 +59,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (isDemoMode() || isLocalNoAuthMode()) {
const payload = publicMedicationDetailPayload(normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
- return medicationResponse({
- ...payload,
- demoMode: true,
- });
+ return medicationResponse(
+ {
+ ...payload,
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit before we serve the seed detail:
@@ -80,10 +87,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (!access.ownerId) {
const payload = publicMedicationDetailPayload(normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
- return medicationResponse({
- ...payload,
- publicAccess: true,
- });
+ return medicationResponse(
+ {
+ ...payload,
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const fetchRecord = async () => {
diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts
index adb6e9743..7562cf418 100644
--- a/src/app/api/medications/route.ts
+++ b/src/app/api/medications/route.ts
@@ -7,6 +7,7 @@ import {
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed";
import {
@@ -61,8 +62,8 @@ function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] {
}));
}
-function medicationResponse(payload: Record) {
- return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
+function medicationResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) {
+ return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) });
}
function matchesPayload(matches: MedicationSearchMatch[]) {
@@ -99,10 +100,13 @@ export async function GET(request: Request) {
const { q, limit, fields } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query.");
if (isDemoMode() || isLocalNoAuthMode()) {
- return medicationResponse({
- ...publicMedicationPayload(q, limit, fields),
- demoMode: true,
- });
+ return medicationResponse(
+ {
+ ...publicMedicationPayload(q, limit, fields),
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit: publicAccessContext skips the
@@ -122,10 +126,13 @@ export async function GET(request: Request) {
}
if (!access.ownerId) {
- return medicationResponse({
- ...publicMedicationPayload(q, limit, fields),
- publicAccess: true,
- });
+ return medicationResponse(
+ {
+ ...publicMedicationPayload(q, limit, fields),
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const rows = await fetchOwnerMedicationRowsWithSeed(supabase, access.ownerId, MEDICATION_MAX_RECORDS);
diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts
index e3bb5a6be..44d8f372d 100644
--- a/src/app/api/registry/records/[slug]/route.ts
+++ b/src/app/api/registry/records/[slug]/route.ts
@@ -7,6 +7,7 @@ import {
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { publicAccessContext } from "@/lib/public-api-access";
import { getFormRecord } from "@/lib/forms";
@@ -28,10 +29,13 @@ const registryDetailQuerySchema = z.object({
kind: z.enum(["service", "form"]),
});
-function registryResponse(payload: Record, init?: { status?: number }) {
+function registryResponse(
+ payload: Record,
+ init: { status?: number; request?: Request; fixture?: boolean } = {},
+) {
return NextResponse.json(payload, {
- status: init?.status ?? 200,
- headers: { "Cache-Control": "private, no-store" },
+ status: init.status ?? 200,
+ headers: fixtureResponseHeaders(init.request, init),
});
}
@@ -59,10 +63,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (isDemoMode() || isLocalNoAuthMode()) {
const payload = publicRegistryDetailPayload(kind, normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
- return registryResponse({
- ...payload,
- demoMode: true,
- });
+ return registryResponse(
+ {
+ ...payload,
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit before we serve the seed detail:
@@ -84,10 +91,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (!access.ownerId) {
const payload = publicRegistryDetailPayload(kind, normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
- return registryResponse({
- ...payload,
- publicAccess: true,
- });
+ return registryResponse(
+ {
+ ...payload,
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const fetchRecord = async () => {
diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts
index 309981079..8326c168d 100644
--- a/src/app/api/registry/records/route.ts
+++ b/src/app/api/registry/records/route.ts
@@ -7,6 +7,7 @@ import {
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { publicAccessContext } from "@/lib/public-api-access";
import { rankFormRecords, formRecords } from "@/lib/forms";
@@ -45,8 +46,8 @@ function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query:
return kind === "form" ? rankFormRecords(records, query, limit) : rankServiceRecords(records, query, limit);
}
-function registryResponse(payload: Record) {
- return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
+function registryResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) {
+ return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) });
}
function matchesPayload(matches: ServiceSearchMatch[]) {
@@ -74,10 +75,13 @@ export async function GET(request: Request) {
const { kind, q, limit } = parseRequestQuery(request, registryListQuerySchema, "Invalid registry query.");
if (isDemoMode() || isLocalNoAuthMode()) {
- return registryResponse({
- ...publicRegistryPayload(kind, q, limit),
- demoMode: true,
- });
+ return registryResponse(
+ {
+ ...publicRegistryPayload(kind, q, limit),
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit: publicAccessContext skips the
@@ -97,10 +101,13 @@ export async function GET(request: Request) {
}
if (!access.ownerId) {
- return registryResponse({
- ...publicRegistryPayload(kind, q, limit),
- publicAccess: true,
- });
+ return registryResponse(
+ {
+ ...publicRegistryPayload(kind, q, limit),
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const rows = await fetchOwnerRegistryRows(supabase, access.ownerId, kind, REGISTRY_MAX_RECORDS);
diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts
index ef4b0541b..32d4d1702 100644
--- a/src/app/api/search/universal/route.ts
+++ b/src/app/api/search/universal/route.ts
@@ -15,6 +15,7 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import {
runUniversalSearch,
universalSearchDomains,
+ type RunUniversalSearchArgs,
type UniversalSearchDomain,
type UniversalSearchResponse,
} from "@/lib/universal-search";
@@ -35,6 +36,7 @@ const universalSearchQuerySchema = z.object({
q: z.string().trim().min(2).max(200),
limit: queryInteger({ fallback: 5, min: 1, max: 10 }),
mode: z.enum(appModeIds).optional(),
+ stream: z.enum(["ndjson"]).optional(),
domains: z
.string()
.trim()
@@ -64,22 +66,84 @@ function universalResponse(
return NextResponse.json(payload, { headers });
}
+function universalStreamResponse(
+ request: Request,
+ searchArgs: RunUniversalSearchArgs,
+ decoration: { demoMode?: true; publicAccess?: true } = {},
+) {
+ const encoder = new TextEncoder();
+ const searchController = new AbortController();
+ const abortFromRequest = () => searchController.abort(request.signal.reason);
+ if (request.signal.aborted) abortFromRequest();
+ else request.signal.addEventListener("abort", abortFromRequest, { once: true });
+
+ const body = new ReadableStream({
+ start(controller) {
+ const enqueue = (event: Record) => {
+ if (!searchController.signal.aborted) {
+ controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
+ }
+ };
+
+ void runUniversalSearch({
+ ...searchArgs,
+ signal: searchController.signal,
+ onGroup: (group) => enqueue({ type: "group", query: searchArgs.query, group }),
+ })
+ .then((response) => {
+ if (searchController.signal.aborted) return;
+ enqueue({ type: "complete", response: { ...response, ...decoration } });
+ controller.close();
+ })
+ .catch((error: unknown) => {
+ if (searchController.signal.aborted) {
+ try {
+ controller.close();
+ } catch {
+ // The consumer may already have cancelled the stream.
+ }
+ return;
+ }
+ controller.error(error);
+ })
+ .finally(() => request.signal.removeEventListener("abort", abortFromRequest));
+ },
+ cancel(reason) {
+ searchController.abort(
+ reason instanceof Error ? reason : new DOMException("The search stream was cancelled.", "AbortError"),
+ );
+ request.signal.removeEventListener("abort", abortFromRequest);
+ },
+ });
+
+ return new Response(body, {
+ headers: {
+ "Cache-Control": "private, no-store",
+ "Content-Type": "application/x-ndjson; charset=utf-8",
+ "X-Accel-Buffering": "no",
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+}
+
export async function GET(request: Request) {
try {
- const { q, limit, domains, mode } = parseRequestQuery(
+ const { q, limit, domains, mode, stream } = parseRequestQuery(
request,
universalSearchQuerySchema,
"Invalid universal query.",
);
if (isDemoMode() || isLocalNoAuthMode()) {
- const payload = await runUniversalSearch({
+ const searchArgs: RunUniversalSearchArgs = {
query: q,
limitPerDomain: limit,
domains,
contextMode: mode,
demo: true,
- });
+ };
+ if (stream === "ndjson") return universalStreamResponse(request, searchArgs, { demoMode: true });
+ const payload = await runUniversalSearch({ ...searchArgs, signal: request.signal });
return universalResponse({ ...payload, demoMode: true });
}
@@ -99,7 +163,7 @@ export async function GET(request: Request) {
// demo:false + supabase always run the live pipeline. An anonymous caller (ownerId
// undefined) is scoped to the public corpus via allowGlobalSearch and the real default
// catalogues — never the synthetic demo fixtures.
- const payload = await runUniversalSearch({
+ const searchArgs: RunUniversalSearchArgs = {
query: q,
limitPerDomain: limit,
domains,
@@ -107,7 +171,11 @@ export async function GET(request: Request) {
supabase,
ownerId: access.ownerId,
demo: false,
- });
+ };
+ if (stream === "ndjson") {
+ return universalStreamResponse(request, searchArgs, access.ownerId ? {} : { publicAccess: true });
+ }
+ const payload = await runUniversalSearch({ ...searchArgs, signal: request.signal });
return universalResponse(access.ownerId ? payload : { ...payload, publicAccess: true });
} catch (error) {
if (error instanceof AuthenticationError) {
diff --git a/src/app/documents/[id]/page.tsx b/src/app/documents/[id]/page.tsx
index 19a7cf04b..10413f356 100644
--- a/src/app/documents/[id]/page.tsx
+++ b/src/app/documents/[id]/page.tsx
@@ -1,4 +1,11 @@
+import { headers } from "next/headers";
import { DocumentViewerLazy as DocumentViewer } from "@/components/document-viewer-lazy";
+import {
+ documentDetailQuerySchema,
+ loadAuthorizedDocumentDetail,
+ sanitizeDocumentDetailError,
+} from "@/lib/document-detail";
+import type { DocumentDetailPayload } from "@/lib/document-detail-contract";
export default async function DocumentPage({
params,
@@ -10,5 +17,32 @@ export default async function DocumentPage({
const [{ id }, query] = await Promise.all([params, searchParams]);
const parsedPage = Number.parseInt(query.page ?? "", 10);
const initialPage = Number.isFinite(parsedPage) && parsedPage >= 1 ? parsedPage : 1;
- return ;
+ let initialDetail: DocumentDetailPayload | undefined;
+ let initialError: string | undefined;
+
+ try {
+ const detailQuery = documentDetailQuerySchema.parse({
+ page: initialPage,
+ chunk: query.chunk,
+ assetScope: "window",
+ });
+ const requestHeaders = new Headers(await headers());
+ const request = new Request(`http://document-detail.local/documents/${encodeURIComponent(id)}`, {
+ headers: requestHeaders,
+ });
+ initialDetail = await loadAuthorizedDocumentDetail({ request, rawId: id, query: detailQuery });
+ } catch (error) {
+ initialError = sanitizeDocumentDetailError(error);
+ }
+
+ return (
+
+ );
}
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index 931335a1a..322216daf 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -187,8 +187,8 @@ import {
import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope";
import { parseApiErrorResponse } from "@/lib/api-client-error";
import { answerLifecycleReducer, initialAnswerLifecycle } from "@/lib/answer-lifecycle";
-import { rankFormRecords } from "@/lib/forms";
-import { rankServiceRecords } from "@/lib/services";
+import { rankFormRecords } from "@/lib/form-ranker";
+import { rankServiceRecords } from "@/lib/service-ranker";
import { useRegistryRecords } from "@/lib/use-registry-records";
import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up";
import {
@@ -232,6 +232,7 @@ const stagedDashboardExtraction = {
type RefreshOptions = {
includeSetup?: boolean;
includeDashboardData?: boolean;
+ includeAdministrationData?: boolean;
includeDocumentMeta?: boolean;
};
type PollHint = {
@@ -446,16 +447,23 @@ export function ClinicalDashboard({
const scrollFrameRef = useRef(null);
const navSyncLockRef = useRef(null);
const autoRunSearchSignatureRef = useRef(null);
- const refreshInFlightRef = useRef<{ epoch: number; promise: Promise } | null>(null);
+ const refreshInFlightRef = useRef<{
+ epoch: number;
+ dataScope: number;
+ promise: Promise;
+ } | null>(null);
+ const dashboardDataLoadedRef = useRef(false);
+ const administrationDataLoadedRef = useRef(false);
const nextWorkStatePollRef = useRef(0);
const urlSearchBootstrappedRef = useRef(false);
const urlDocumentSearchBootstrappedRef = useRef(false);
const lastSyncedSearchParamsRef = useRef(searchParams.toString());
const modeChangeFromUiRef = useRef(false);
const [documents, setDocuments] = useState([]);
+ const documentsRef = useRef(documents);
const [documentsPagination, setDocumentsPagination] = useState(null);
const indexedDocumentTotal = documentsPagination?.total ?? documents.length;
- const [dashboardDataLoading, setDashboardDataLoading] = useState(true);
+ const [dashboardDataLoading, setDashboardDataLoading] = useState(false);
const [loadingMoreDocuments, setLoadingMoreDocuments] = useState(false);
const [jobs, setJobs] = useState([]);
const [batches, setBatches] = useState([]);
@@ -640,6 +648,7 @@ export function ClinicalDashboard({
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed();
const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false);
+ const [documentScopeOpen, setDocumentScopeOpen] = useState(false);
const [documentsDrawerMode, setDocumentsDrawerMode] = useState("library");
const [uploadDrawerOpen, setUploadDrawerOpen] = useState(false);
const [uploadMobileTab, setUploadMobileTab] = useState("upload");
@@ -664,6 +673,7 @@ export function ClinicalDashboard({
);
const [indexingActionId, setIndexingActionId] = useState(null);
const [indexingActive, setIndexingActive] = useState(false);
+ const [userStartedIngestion, setUserStartedIngestion] = useState(false);
const [nextRefreshDelayMs, setNextRefreshDelayMs] = useState(null);
const { theme, toggleTheme } = useTheme();
const auth = useAuthSession();
@@ -743,6 +753,9 @@ export function ClinicalDashboard({
setJobs([]);
setBatches([]);
setQualityItems([]);
+ dashboardDataLoadedRef.current = false;
+ administrationDataLoadedRef.current = false;
+ setUserStartedIngestion(false);
setSelectedDocumentIds([]);
setDocumentMatches([]);
setSearchScope(null);
@@ -987,6 +1000,10 @@ export function ClinicalDashboard({
answerThreadOwnerId,
]);
+ useEffect(() => {
+ documentsRef.current = documents;
+ }, [documents]);
+
useEffect(() => {
jobsRef.current = jobs;
}, [jobs]);
@@ -995,10 +1012,22 @@ export function ClinicalDashboard({
batchesRef.current = batches;
}, [batches]);
- const refresh = useCallback(
+ const refresh: (options?: RefreshOptions) => Promise = useCallback(
async (options: RefreshOptions = {}) => {
- if (refreshInFlightRef.current?.epoch === authEpoch) {
- return refreshInFlightRef.current.promise;
+ const includeDashboardData = options.includeDashboardData ?? true;
+ const includeAdministrationData = options.includeAdministrationData ?? includeDashboardData;
+ const requestedDataScope = (includeDashboardData ? 1 : 0) | (includeAdministrationData ? 2 : 0);
+ while (refreshInFlightRef.current?.epoch === authEpoch) {
+ const activeRefresh = refreshInFlightRef.current;
+ const needsFollowUp = (requestedDataScope & ~activeRefresh.dataScope) !== 0;
+ await activeRefresh.promise;
+ // A setup-only refresh cannot satisfy a data request that arrived
+ // while it was in flight. Run one follow-up request; same-scope calls
+ // stay coalesced on the original promise.
+ if (!needsFollowUp) return;
+ // The promise is complete, so release its coalescing slot now. The
+ // owning call still releases its auth request in its own finally.
+ if (refreshInFlightRef.current === activeRefresh) refreshInFlightRef.current = null;
}
const controller = new AbortController();
@@ -1006,12 +1035,11 @@ export function ClinicalDashboard({
const canCommit = () => isAuthEpochCurrent(authRequest.epoch) && !controller.signal.aborted;
const promise = (async () => {
- const trackDashboardLoading = options.includeDashboardData ?? true;
+ const trackDashboardLoading = requestedDataScope !== 0;
await Promise.resolve();
if (trackDashboardLoading) setDashboardDataLoading(true);
const includeSetup = options.includeSetup ?? true;
- const includeDashboardData = options.includeDashboardData ?? true;
const includeDocumentMeta = options.includeDocumentMeta ?? true;
let nextDemoMode = clientDemoMode;
let routeIndexingActive = false;
@@ -1078,7 +1106,7 @@ export function ClinicalDashboard({
return;
}
- if (!includeDashboardData) {
+ if (requestedDataScope === 0) {
setIndexingActive(routeIndexingActive);
setNextRefreshDelayMs(routePollDelayMs);
return;
@@ -1091,14 +1119,17 @@ export function ClinicalDashboard({
}
const now = Date.now();
- const shouldRefreshWorkState = now >= nextWorkStatePollRef.current;
+ const shouldRefreshWorkState =
+ includeAdministrationData && (!administrationDataLoadedRef.current || now >= nextWorkStatePollRef.current);
if (shouldRefreshWorkState) nextWorkStatePollRef.current = now + indexingWorkDetailsPollMs;
const [documentsResponse, jobsResponse, batchesResponse, qualityResponse] = await Promise.all([
- fetch(`/api/documents?${documentParams.toString()}`, {
- headers: protectedHeaders,
- signal: controller.signal,
- }),
+ includeDashboardData
+ ? fetch(`/api/documents?${documentParams.toString()}`, {
+ headers: protectedHeaders,
+ signal: controller.signal,
+ })
+ : Promise.resolve(null as Response | null),
shouldRefreshWorkState
? fetch("/api/ingestion/jobs", { headers: protectedHeaders, signal: controller.signal })
: Promise.resolve(null as Response | null),
@@ -1110,9 +1141,8 @@ export function ClinicalDashboard({
: Promise.resolve(null as Response | null),
]);
if (!canCommit()) return;
-
if (
- documentsResponse.status === 401 ||
+ (documentsResponse !== null && documentsResponse.status === 401) ||
(jobsResponse !== null && jobsResponse.status === 401) ||
(batchesResponse !== null && batchesResponse.status === 401) ||
(qualityResponse !== null && qualityResponse.status === 401)
@@ -1128,22 +1158,23 @@ export function ClinicalDashboard({
return;
}
- let nextDocuments: ClinicalDocument[] = [];
+ let nextDocuments: ClinicalDocument[] = includeDashboardData ? [] : documentsRef.current;
let nextJobs: IngestionJob[] = shouldRefreshWorkState ? [] : jobsRef.current;
let nextBatches: ImportBatch[] = shouldRefreshWorkState ? [] : batchesRef.current;
- if (documentsResponse.ok) {
+ if (documentsResponse?.ok) {
const payload = (await documentsResponse.json()) as DocumentsPayload;
nextDocuments = payload.documents ?? [];
setDocuments((current) =>
includeDocumentMeta ? nextDocuments : mergeDocumentRefresh(current, nextDocuments),
);
setDocumentsPagination(payload.pagination ?? null);
+ dashboardDataLoadedRef.current = true;
routeIndexingActive ||= Boolean(payload.indexing?.active);
routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.indexing?.pollAfterMs);
if (payload.demoMode) setDemoMode(true);
if (payload.setupRequired) setSetupWarning(payload.error ?? null);
- } else {
+ } else if (includeDashboardData) {
setApiUnavailable(true);
}
@@ -1178,17 +1209,21 @@ export function ClinicalDashboard({
setApiUnavailable(true);
}
+ if (jobsResponse?.ok && batchesResponse?.ok && qualityResponse?.ok) {
+ administrationDataLoadedRef.current = true;
+ }
+
const activeWork = hasActiveIndexingWork(nextDocuments, nextJobs, nextBatches, routeIndexingActive);
setIndexingActive(activeWork);
setNextRefreshDelayMs(routePollDelayMs ?? (activeWork ? activeIndexingPollFallbackMs : null));
})();
- refreshInFlightRef.current = { epoch: authRequest.epoch, promise };
+ refreshInFlightRef.current = { epoch: authRequest.epoch, dataScope: requestedDataScope, promise };
try {
return await promise;
} finally {
authRequest.release();
- if ((options.includeDashboardData ?? true) === true && canCommit()) setDashboardDataLoading(false);
+ if (requestedDataScope !== 0 && canCommit()) setDashboardDataLoading(false);
if (refreshInFlightRef.current?.promise === promise) {
refreshInFlightRef.current = null;
}
@@ -1267,6 +1302,8 @@ export function ClinicalDashboard({
if (!response.ok) {
throw new Error(typeof payload.error === "string" ? payload.error : "Job retry could not be started.");
}
+ setUserStartedIngestion(true);
+ setIndexingActive(true);
setActionNotice({
tone: "success",
message: "Ingestion job retry queued.",
@@ -1312,6 +1349,8 @@ export function ClinicalDashboard({
: "Document reindex could not be started.",
);
}
+ setUserStartedIngestion(true);
+ setIndexingActive(true);
setActionNotice({
tone: "success",
message: mode === "enrichment" ? "Document enrichment refreshed." : "Document reindex queued.",
@@ -1475,32 +1514,79 @@ export function ClinicalDashboard({
[documents, jobs, batches, indexingActive],
);
const needsSetupRecheck = useMemo(() => setupNeedsSlowRecheck(setupChecks), [setupChecks]);
+ const dashboardDataSurfaceVisible = documentScopeOpen || documentsDrawerOpen || uploadDrawerOpen;
+ const administrationSurfaceVisible = uploadDrawerOpen || (documentsDrawerOpen && documentsDrawerMode === "admin");
useEffect(() => {
- refresh({ includeSetup: true, includeDashboardData: true, includeDocumentMeta: true }).catch(() => undefined);
+ dashboardDataLoadedRef.current = false;
+ administrationDataLoadedRef.current = false;
+ }, [authEpoch]);
+
+ useEffect(() => {
+ refresh({ includeSetup: true, includeDashboardData: false, includeDocumentMeta: false }).catch(() => undefined);
}, [authStatus, authorizationHeader, clientDemoMode, refresh]);
useEffect(() => {
- const hasScheduledWork = activeIndexingWork || needsSetupRecheck;
- if (!shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork)) {
+ const includeDashboardData = dashboardDataSurfaceVisible && !dashboardDataLoadedRef.current;
+ const includeAdministrationData = administrationSurfaceVisible && !administrationDataLoadedRef.current;
+ if (!includeDashboardData && !includeAdministrationData) return;
+ refresh({
+ includeSetup: false,
+ includeDashboardData,
+ includeAdministrationData,
+ includeDocumentMeta: includeDashboardData,
+ }).catch(() => undefined);
+ }, [administrationSurfaceVisible, authEpoch, dashboardDataSurfaceVisible, refresh]);
+
+ useEffect(() => {
+ if (!userStartedIngestion || !dashboardDataLoadedRef.current || activeIndexingWork) return;
+ let cancelled = false;
+ queueMicrotask(() => {
+ if (!cancelled) setUserStartedIngestion(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [activeIndexingWork, userStartedIngestion]);
+
+ useEffect(() => {
+ const visibleSurfaceHasActiveWork = dashboardDataSurfaceVisible && activeIndexingWork;
+ const userOperationHasActiveWork = userStartedIngestion && activeIndexingWork;
+ const shouldPollDashboardData = visibleSurfaceHasActiveWork || userOperationHasActiveWork;
+ const hasScheduledWork = shouldPollDashboardData || needsSetupRecheck;
+ const pollingAllowed =
+ userOperationHasActiveWork || shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork);
+ if (!pollingAllowed) {
return;
}
- const delay = activeIndexingWork ? (nextRefreshDelayMs ?? activeIndexingPollFallbackMs) : setupRecheckPollMs;
+ const delay = shouldPollDashboardData ? (nextRefreshDelayMs ?? activeIndexingPollFallbackMs) : setupRecheckPollMs;
const timeout = window.setTimeout(() => {
- if (!shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork)) {
+ const stillAllowed =
+ userOperationHasActiveWork || shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork);
+ if (!stillAllowed) {
return;
}
refresh({
- includeSetup: !activeIndexingWork,
- includeDashboardData: activeIndexingWork,
+ includeSetup: !shouldPollDashboardData,
+ includeDashboardData: shouldPollDashboardData,
+ includeAdministrationData: shouldPollDashboardData && (administrationSurfaceVisible || userStartedIngestion),
includeDocumentMeta: false,
}).catch(() => undefined);
}, delay);
return () => window.clearTimeout(timeout);
- }, [activeIndexingWork, demoMode, needsSetupRecheck, nextRefreshDelayMs, refresh]);
+ }, [
+ activeIndexingWork,
+ administrationSurfaceVisible,
+ dashboardDataSurfaceVisible,
+ demoMode,
+ needsSetupRecheck,
+ nextRefreshDelayMs,
+ refresh,
+ userStartedIngestion,
+ ]);
useEffect(() => {
const refreshVisibleDashboard = () => {
@@ -1510,7 +1596,8 @@ export function ClinicalDashboard({
refresh({
includeSetup: true,
- includeDashboardData: activeIndexingWork || canUsePrivateApis || clientDemoMode,
+ includeDashboardData: dashboardDataSurfaceVisible || (userStartedIngestion && activeIndexingWork),
+ includeAdministrationData: administrationSurfaceVisible || (userStartedIngestion && activeIndexingWork),
includeDocumentMeta: false,
}).catch(() => undefined);
};
@@ -1521,7 +1608,7 @@ export function ClinicalDashboard({
document.removeEventListener("visibilitychange", refreshVisibleDashboard);
window.removeEventListener("focus", refreshVisibleDashboard);
};
- }, [activeIndexingWork, canUsePrivateApis, clientDemoMode, refresh]);
+ }, [activeIndexingWork, administrationSurfaceVisible, dashboardDataSurfaceVisible, refresh, userStartedIngestion]);
useEffect(() => {
const updateOnline = () => setIsOnline(navigator.onLine);
@@ -2567,6 +2654,8 @@ export function ClinicalDashboard({
const payload = await response.json().catch(() => ({}));
if (!isAuthEpochCurrent(requestEpoch)) return;
if (!response.ok) throw new Error(payload.error || errorCopy.bulkReindexFailed);
+ setUserStartedIngestion(true);
+ setIndexingActive(true);
setBulkActionStatus(
`${payload.results?.filter((result: { ok: boolean }) => result.ok).length ?? 0} selected documents updated.`,
);
@@ -3174,6 +3263,8 @@ export function ClinicalDashboard({
},
];
const handleUploadQueued = () => {
+ setUserStartedIngestion(true);
+ setIndexingActive(true);
setUploadMobileTab("jobs");
void refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false });
};
@@ -3313,6 +3404,7 @@ export function ClinicalDashboard({
onClearScope={() => setSelectedDocumentIds([])}
onQueryModeChange={setQueryMode}
onScopeFiltersChange={setScopeFilters}
+ onScopeOpenChange={setDocumentScopeOpen}
onToggleScope={toggleDocumentScope}
onOpenUpload={openUploadDrawer}
onOpenEvidence={openEvidenceDrawer}
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index 2ac217e8c..178f93939 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -103,6 +103,7 @@ import {
} from "@/lib/document-summary-formatting";
import { buildDocumentSummaryBadges } from "@/lib/document-summary-badges";
import { documentSummaryQuestion } from "@/lib/answer-contract";
+import type { DocumentDetailPayload } from "@/lib/document-detail-contract";
type PageRow = {
id: string;
@@ -230,39 +231,6 @@ async function requestSignedUrlPayload(
return payload;
}
-// Fetch the preview + download signed URLs together (serving the client cache
-// first when allowed). Returns their settled results for the caller to apply.
-function fetchSignedUrlPair(
- signedUrlEndpoint: string,
- downloadSignedUrlEndpoint: string,
- options: {
- signal: AbortSignal;
- headers: HeadersInit | undefined;
- onUnauthorized: () => void;
- useCache: boolean;
- },
-) {
- const cachedSigned = options.useCache ? getCachedSignedUrl(signedUrlEndpoint) : null;
- const cachedDownload = options.useCache ? getCachedSignedUrl(downloadSignedUrlEndpoint) : null;
- const signedUrlRequest: Promise = cachedSigned
- ? Promise.resolve(cachedSigned)
- : requestSignedUrlPayload(signedUrlEndpoint, {
- signal: options.signal,
- headers: options.headers,
- onUnauthorized: options.onUnauthorized,
- errorMessage: "Source preview could not be loaded.",
- });
- const signedDownloadUrlRequest: Promise = cachedDownload
- ? Promise.resolve(cachedDownload)
- : requestSignedUrlPayload(downloadSignedUrlEndpoint, {
- signal: options.signal,
- headers: options.headers,
- onUnauthorized: options.onUnauthorized,
- errorMessage: "Download URL could not be loaded.",
- });
- return Promise.allSettled([signedUrlRequest, signedDownloadUrlRequest]);
-}
-
function getInitialPdfViewerMode() {
if (typeof window === "undefined") {
return {
@@ -290,6 +258,12 @@ function getInitialPdfViewerMode() {
};
}
+function rowsById(incoming: T[]) {
+ const rows = new Map();
+ for (const row of incoming) rows.set(row.id, row);
+ return Array.from(rows.values());
+}
+
function hasProfileItems(items: unknown): items is DocumentSummaryProfileItem[] {
return Array.isArray(items) && items.some((item) => item && typeof item === "object" && "text" in item);
}
@@ -602,7 +576,7 @@ function DocumentViewerAnchors({
className,
}: {
evidenceHref: "#source-evidence" | "#source-evidence-rail";
- textHref: "#source-text-mobile" | "#source-text-desktop";
+ textHref: "#source-text";
className?: string;
}) {
const anchors = [
@@ -626,6 +600,11 @@ function DocumentViewerAnchors({
href={anchor.href}
onClick={() => {
const target = window.document.querySelector(anchor.href);
+ window.document
+ .querySelectorAll('details[name="document-viewer-section"]')
+ .forEach((disclosure) => {
+ if (disclosure !== target) disclosure.open = false;
+ });
if (target instanceof HTMLDetailsElement) target.open = true;
}}
className="inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-semibold text-[color:var(--clinical-accent)] shadow-[var(--shadow-tight)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
@@ -897,7 +876,7 @@ const IndexedTextPanel = memo(function IndexedTextPanel({
searchingDocument: boolean;
documentSearchError: string | null;
idPrefix: string;
- sectionId?: "source-text-mobile" | "source-text-desktop";
+ sectionId?: "source-text";
selectedChunkId?: string;
onSearchChange: (value: string) => void;
}) {
@@ -1412,12 +1391,33 @@ function documentKeySections(document: ClinicalDocument) {
return Array.from(new Set(labels)).slice(0, 3);
}
-function DocumentPagePreview({ href, pageNumber }: { href: string; pageNumber: number | null }) {
+function DocumentPagePreview({
+ href,
+ pageNumber,
+ onNavigate,
+}: {
+ href: string;
+ pageNumber: number | null;
+ onNavigate: (page: number) => void;
+}) {
// A real "jump to page" chip rather than a fake wireframe thumbnail that looks
// like a skeleton that never resolves.
return (
{
+ if (
+ pageNumber === null ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey
+ )
+ return;
+ event.preventDefault();
+ onNavigate(pageNumber);
+ }}
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent)]/40 hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)]"
>
@@ -1435,21 +1435,25 @@ function DocumentOverviewLanding({
document,
initialPage,
signedUrl,
- downloadUrl,
pages,
pageHref,
+ onPageChange,
onAskFromDocument,
onAddToScope,
+ onDownload,
+ downloading,
canSummarizeDocument,
}: {
document: ClinicalDocument;
initialPage: number;
signedUrl: string | null;
- downloadUrl: string | null;
pages: PageRow[];
pageHref: (page: number) => string;
+ onPageChange: (page: number) => void;
onAskFromDocument: () => void;
onAddToScope: () => void;
+ onDownload: () => void;
+ downloading: boolean;
canSummarizeDocument: boolean;
}) {
const keySections = documentKeySections(document);
@@ -1505,18 +1509,14 @@ function DocumentOverviewLanding({
Open preview
)}
- {downloadUrl ? (
-
- Download
-
- ) : null}
+
+ {downloading ? "Preparing" : "Download"}
+
Most relevant pages for this document.
{(usefulPages.length ? usefulPages : [initialPage]).map((page) => (
-
+
))}
@@ -1600,12 +1600,42 @@ export function DocumentViewer({
documentId,
initialPage,
chunkId,
+ initialDetail,
+ initialError,
}: {
documentId: string;
initialPage: number;
chunkId?: string;
+ initialDetail?: DocumentDetailPayload;
+ initialError?: string;
}) {
const router = useRouter();
+ const [activeRoute, setActiveRoute] = useState(() => ({ page: initialPage, chunkId }));
+ const activePage = activeRoute.page;
+ const activeChunkId = activeRoute.chunkId;
+
+ useEffect(() => {
+ const syncFromHistory = () => {
+ const params = new URLSearchParams(window.location.search);
+ const parsedPage = Number.parseInt(params.get("page") ?? "", 10);
+ setActiveRoute({
+ page: Number.isFinite(parsedPage) && parsedPage >= 1 ? parsedPage : 1,
+ chunkId: params.get("chunk") ?? undefined,
+ });
+ };
+ window.addEventListener("popstate", syncFromHistory);
+ return () => window.removeEventListener("popstate", syncFromHistory);
+ }, []);
+
+ const navigateToPage = useCallback(
+ (page: number) => {
+ const nextPage = Math.max(1, Math.trunc(page));
+ if (nextPage === activePage) return;
+ window.history.pushState(null, "", documentPageHref(documentId, nextPage));
+ setActiveRoute({ page: nextPage, chunkId: undefined });
+ },
+ [activePage, documentId],
+ );
useEffect(() => {
const previousOpenStates = new Map();
const expandPrintableDisclosures = () => {
@@ -1639,21 +1669,23 @@ export function DocumentViewer({
window.removeEventListener("afterprint", restorePrintableDisclosures);
};
}, []);
- const [document, setDocument] = useState(null);
- const [pages, setPages] = useState([]);
- const [images, setImages] = useState([]);
- const [tableFacts, setTableFacts] = useState([]);
- const [chunks, setChunks] = useState([]);
- const [indexHealth, setIndexHealth] = useState(null);
+ const [document, setDocument] = useState(() => initialDetail?.document ?? null);
+ const [pages, setPages] = useState(() => initialDetail?.pages ?? []);
+ const [images, setImages] = useState(() => initialDetail?.images ?? []);
+ const [tableFacts, setTableFacts] = useState(() => initialDetail?.tableFacts ?? []);
+ const [chunks, setChunks] = useState(() => initialDetail?.chunks ?? []);
+ const [indexHealth, setIndexHealth] = useState(() => initialDetail?.indexHealth ?? null);
const [signedUrl, setSignedUrl] = useState(null);
const [downloadSignedUrl, setDownloadSignedUrl] = useState(null);
const [summary, setSummary] = useState(null);
const [summaryQuery, setSummaryQuery] = useState(documentSummaryQuestion);
const [summaryProgressEvents, setSummaryProgressEvents] = useState([]);
const [summaryProgressStartedAt, setSummaryProgressStartedAt] = useState(null);
- const [loadingDocument, setLoadingDocument] = useState(true);
- const [viewerError, setViewerError] = useState(null);
+ const [loadingDocument, setLoadingDocument] = useState(() => !initialDetail && !initialError);
+ const [viewerError, setViewerError] = useState(() => initialError ?? null);
const [previewError, setPreviewError] = useState(null);
+ const [downloadError, setDownloadError] = useState(null);
+ const [downloadingSource, setDownloadingSource] = useState(false);
const [loadingSummary, setLoadingSummary] = useState(false);
const [summaryError, setSummaryError] = useState(null);
const [previewAttempt, setPreviewAttempt] = useState(0);
@@ -1700,7 +1732,7 @@ export function DocumentViewer({
}, []);
const scrollHidden = useHideOnScroll({
...(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {}),
- resetKey: `${documentId}:${initialPage}:${chunkId ?? ""}`,
+ resetKey: `${documentId}:${activePage}:${activeChunkId ?? ""}`,
});
const composerScrollHidden = scrollHidden && !mobileActionsOpen && !composerChromeFocused;
// Read localStorage once on mount, then seed both derived states from it.
@@ -1727,7 +1759,9 @@ export function DocumentViewer({
markSessionExpired,
} = useAuthSession();
const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false);
- const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true");
+ const [serverDemoMode, setServerDemoMode] = useState(
+ () => initialDetail?.demoMode ?? process.env.NEXT_PUBLIC_DEMO_MODE === "true",
+ );
const localNoAuthMode = isLocalNoAuthMode();
const clientDemoMode = localNoAuthMode || serverDemoMode;
const canViewSourceDocuments = localProjectReady;
@@ -1790,80 +1824,43 @@ export function DocumentViewer({
}
}, [useNativePdfViewer, viewerModeInitialized, hasExplicitPdfViewerMode]);
- useEffect(() => {
- let active = true;
- readLocalProjectIdentity()
- .then((identity) => {
- if (!active) return null;
- if (!identity?.localServer?.safeLocalOrigin) {
- setLocalProjectReady(false);
- setViewerError(unsafeLocalProjectMessage(identity));
- setLoadingDocument(false);
- return null;
- }
- setLocalProjectReady(true);
- return fetch("/api/setup-status", { headers: authorizationHeader });
- })
- .then((response) => (response?.ok ? response.json() : null))
- .then((payload) => {
- if (active && typeof payload?.demoMode === "boolean") setServerDemoMode(payload.demoMode);
- })
- .catch(() => undefined);
- return () => {
- active = false;
- };
- }, [authorizationHeader, isConfigured]);
-
- // Apply the settled preview + download signed-URL results to state. Shared by
- // the initial load effect and the expired-URL refresh so a re-issued URL takes
- // the exact same path (cache write, previewError, download fallback).
- const applySignedUrlResults = useCallback(
- (
- signedUrlResult: PromiseSettledResult,
- downloadResult: PromiseSettledResult,
- signedUrlEndpoint: string,
- downloadSignedUrlEndpoint: string,
- ) => {
- if (signedUrlResult.status === "fulfilled") {
- const payload = signedUrlResult.value;
- if (payload?.url) setCachedSignedUrl(signedUrlEndpoint, { ...payload, url: payload.url });
- setSignedUrl(payload?.url ?? null);
+ const applyPreviewSignedUrlResult = useCallback(
+ (result: PromiseSettledResult, endpoint: string) => {
+ if (result.status === "fulfilled") {
+ const payload = result.value;
+ if (payload.url) setCachedSignedUrl(endpoint, { ...payload, url: payload.url });
+ setSignedUrl(payload.url ?? null);
setPreviewError(null);
- } else {
- setSignedUrl(null);
- setDownloadSignedUrl(null);
- setPreviewError(
- signedUrlResult.reason instanceof Error
- ? signedUrlResult.reason.message
- : "Source preview could not be loaded.",
- );
- }
-
- if (downloadResult.status === "fulfilled") {
- const payload = downloadResult.value;
- if (payload?.url) {
- setCachedSignedUrl(downloadSignedUrlEndpoint, { ...payload, url: payload.url });
- setDownloadSignedUrl(payload.url);
- return;
- }
- }
-
- if (signedUrlResult.status === "fulfilled") {
- setDownloadSignedUrl(signedUrlResult.value?.url ?? null);
- } else {
- setDownloadSignedUrl(null);
+ return;
}
+ setSignedUrl(null);
+ setPreviewError(result.reason instanceof Error ? result.reason.message : "Source preview could not be loaded.");
},
[],
);
- // Re-issue only the signed URLs (no document-detail refetch) when a PDF's URL
+ const openSourcePreview = useCallback(
+ (options: { signal: AbortSignal; useCache: boolean }) => {
+ const endpoint = `/api/documents/${documentId}/signed-url`;
+ const cached = options.useCache ? getCachedSignedUrl(endpoint) : null;
+ return cached
+ ? Promise.resolve(cached)
+ : requestSignedUrlPayload(endpoint, {
+ signal: options.signal,
+ headers: clientDemoMode ? undefined : authorizationHeader,
+ onUnauthorized: markSessionExpired,
+ errorMessage: "Source preview could not be loaded.",
+ });
+ },
+ [authorizationHeader, clientDemoMode, documentId, markSessionExpired],
+ );
+
+ // Re-issue only the preview URL (no document-detail or download request) when a PDF's URL
// expires mid-session, so the viewer refreshes in place without the full
// reload/flicker. Its AbortController is cancelled on the next refresh and on unmount.
const refreshControllerRef = useRef(null);
const refreshSignedUrls = useCallback(() => {
const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`;
- const downloadSignedUrlEndpoint = `${signedUrlEndpoint}?download=true`;
refreshControllerRef.current?.abort();
const controller = new AbortController();
@@ -1879,16 +1876,11 @@ export function DocumentViewer({
throw new Error(unsafeLocalProjectMessage(identity));
}
// handleSignedUrlExpired already cleared the cache, so always mint fresh.
- return fetchSignedUrlPair(signedUrlEndpoint, downloadSignedUrlEndpoint, {
- signal: controller.signal,
- headers: clientDemoMode ? undefined : authorizationHeader,
- onUnauthorized: markSessionExpired,
- useCache: false,
- });
+ return openSourcePreview({ signal: controller.signal, useCache: false });
})
- .then(([signedUrlResult, downloadResult]) => {
+ .then((payload) => {
if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
- applySignedUrlResults(signedUrlResult, downloadResult, signedUrlEndpoint, downloadSignedUrlEndpoint);
+ applyPreviewSignedUrlResult({ status: "fulfilled", value: payload }, signedUrlEndpoint);
})
.catch((error) => {
if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
@@ -1898,23 +1890,91 @@ export function DocumentViewer({
authRequest.release();
if (refreshControllerRef.current === controller) refreshControllerRef.current = null;
});
+ }, [documentId, registerAuthRequest, isAuthEpochCurrent, openSourcePreview, applyPreviewSignedUrlResult]);
+
+ useEffect(() => () => refreshControllerRef.current?.abort(), []);
+
+ const downloadActionRef = useRef | null>(null);
+ const downloadControllerRef = useRef(null);
+ const currentDocumentFileName = document?.file_name;
+ const openSourceDownload = useCallback(() => {
+ if (downloadActionRef.current) return downloadActionRef.current;
+
+ const endpoint = `/api/documents/${documentId}/signed-url?download=true`;
+ const controller = new AbortController();
+ downloadControllerRef.current = controller;
+ const authRequest = registerAuthRequest(controller);
+ const action = (async () => {
+ setDownloadingSource(true);
+ setDownloadError(null);
+ try {
+ const identity = await readLocalProjectIdentity();
+ if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
+ if (!identity?.localServer?.safeLocalOrigin) throw new Error(unsafeLocalProjectMessage(identity));
+
+ const cached = getCachedSignedUrl(endpoint);
+ const payload =
+ cached ??
+ (await requestSignedUrlPayload(endpoint, {
+ signal: controller.signal,
+ headers: clientDemoMode ? undefined : authorizationHeader,
+ onUnauthorized: markSessionExpired,
+ errorMessage: "Download URL could not be loaded.",
+ }));
+ if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch) || !payload.url) return;
+
+ setCachedSignedUrl(endpoint, { ...payload, url: payload.url });
+ setDownloadSignedUrl(payload.url);
+ const anchor = window.document.createElement("a");
+ anchor.href = payload.url;
+ anchor.rel = "noreferrer";
+ anchor.download = currentDocumentFileName || "clinical-source";
+ anchor.click();
+ } catch (error) {
+ if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
+ setDownloadError(error instanceof Error ? error.message : "Download URL could not be loaded.");
+ } finally {
+ authRequest.release();
+ if (downloadControllerRef.current === controller) {
+ downloadControllerRef.current = null;
+ setDownloadingSource(false);
+ }
+ }
+ })();
+ downloadActionRef.current = action;
+ void action.finally(() => {
+ if (downloadActionRef.current === action) downloadActionRef.current = null;
+ });
+ return action;
}, [
+ authorizationHeader,
+ clientDemoMode,
+ currentDocumentFileName,
documentId,
- registerAuthRequest,
isAuthEpochCurrent,
- clientDemoMode,
- authorizationHeader,
markSessionExpired,
- applySignedUrlResults,
+ registerAuthRequest,
]);
- useEffect(() => () => refreshControllerRef.current?.abort(), []);
+ useEffect(
+ () => () => {
+ downloadControllerRef.current?.abort();
+ downloadControllerRef.current = null;
+ downloadActionRef.current = null;
+ },
+ [documentId],
+ );
// Distinguishes a full document (re)load — a new documentId or an explicit
// retry (previewAttempt) — from page/chunk navigation on the already-loaded
// document. Navigation only re-windows the detail; a full load also resets the
- // preview and re-issues signed URLs.
+ // preview and re-issues only its signed URL.
const loadedKeyRef = useRef(null);
+ const detailControllerRef = useRef(null);
+ const detailRequestSequenceRef = useRef(0);
+ const localProjectIdentityPromiseRef = useRef | null>(null);
+ const initialRouteRef = useRef({ documentId, initialPage, chunkId });
+ const navigatedFromInitialRouteRef = useRef(false);
useEffect(() => {
if (!canViewSourceDocuments && authStatus === "loading") {
@@ -1924,33 +1984,70 @@ export function DocumentViewer({
return () => undefined;
}
+ const matchesInitialRoute =
+ initialRouteRef.current.documentId === documentId &&
+ initialRouteRef.current.initialPage === activePage &&
+ initialRouteRef.current.chunkId === activeChunkId;
+ if (!matchesInitialRoute) navigatedFromInitialRouteRef.current = true;
+ const useInitialResult =
+ previewAttempt === 0 &&
+ matchesInitialRoute &&
+ !navigatedFromInitialRouteRef.current &&
+ Boolean(initialDetail || initialError);
+
+ detailControllerRef.current?.abort();
const controller = new AbortController();
+ detailControllerRef.current = controller;
+ const requestSequence = ++detailRequestSequenceRef.current;
const authRequest = registerAuthRequest(controller);
const loadKey = documentLoadKey(documentId, previewAttempt);
const isFullReload = isFullDocumentReload(loadedKeyRef.current, loadKey);
const reset = window.setTimeout(() => {
// Skip the reset on navigation so the mounted PDF and current content stay
// visible (no loading flash) while the new page window loads in the background.
- if (!controller.signal.aborted && isFullReload) {
+ if (!controller.signal.aborted && isFullReload && !useInitialResult) {
setLoadingDocument(true);
setViewerError(null);
setPreviewError(null);
+ setDownloadError(null);
+ setDownloadingSource(false);
setSignedUrl(null);
setDownloadSignedUrl(null);
}
}, 0);
const detailParams = new URLSearchParams({
- page: String(Math.max(1, initialPage || 1)),
+ page: String(Math.max(1, activePage || 1)),
pageLimit: "9",
chunkLimit: "16",
+ assetScope: "window",
});
- if (chunkId) detailParams.set("chunk", chunkId);
+ if (activeChunkId) detailParams.set("chunk", activeChunkId);
const detailUrl = `/api/documents/${documentId}?${detailParams.toString()}`;
const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`;
- const downloadSignedUrlEndpoint = `${signedUrlEndpoint}?download=true`;
- readLocalProjectIdentity()
+ if (!localProjectIdentityPromiseRef.current) {
+ const pendingIdentity = readLocalProjectIdentity();
+ localProjectIdentityPromiseRef.current = pendingIdentity;
+ void pendingIdentity.then(
+ (identity) => {
+ if (!identity?.localServer?.safeLocalOrigin && localProjectIdentityPromiseRef.current === pendingIdentity) {
+ localProjectIdentityPromiseRef.current = null;
+ }
+ },
+ () => {
+ if (localProjectIdentityPromiseRef.current === pendingIdentity) {
+ localProjectIdentityPromiseRef.current = null;
+ }
+ },
+ );
+ }
+ const identityRequest = localProjectIdentityPromiseRef.current!;
+ identityRequest
.then((identity) => {
- if (!isAuthEpochCurrent(authRequest.epoch)) {
+ if (
+ controller.signal.aborted ||
+ requestSequence !== detailRequestSequenceRef.current ||
+ !isAuthEpochCurrent(authRequest.epoch)
+ ) {
throw new DOMException("Stale authentication epoch", "AbortError");
}
if (!identity?.localServer?.safeLocalOrigin) {
@@ -1959,47 +2056,57 @@ export function DocumentViewer({
}
setLocalProjectReady(true);
- const detailRequest = fetch(detailUrl, {
- signal: controller.signal,
- headers: clientDemoMode ? undefined : authorizationHeader,
- }).then(async (response) => {
- const payload = await response.json();
- if (response.status === 401) markSessionExpired();
- if (!response.ok) throw new Error(payload.error || "Document details could not be loaded.");
- return payload;
- });
- // Navigation keeps the current signed URLs; only a full load re-issues them.
- const signedUrlPair = isFullReload
- ? fetchSignedUrlPair(signedUrlEndpoint, downloadSignedUrlEndpoint, {
+ const detailRequest: Promise = useInitialResult
+ ? initialDetail
+ ? Promise.resolve(initialDetail)
+ : Promise.reject(new Error(initialError || "Document could not be loaded."))
+ : fetch(detailUrl, {
signal: controller.signal,
headers: clientDemoMode ? undefined : authorizationHeader,
- onUnauthorized: markSessionExpired,
- useCache: true,
- })
+ }).then(async (response) => {
+ const payload = await response.json();
+ if (response.status === 401) markSessionExpired();
+ if (!response.ok) throw new Error(payload.error || "Document details could not be loaded.");
+ return payload as DocumentDetailPayload;
+ });
+ // Navigation keeps the current preview; a full load re-issues only the preview URL.
+ const previewRequest = isFullReload
+ ? Promise.allSettled([openSourcePreview({ signal: controller.signal, useCache: true })])
: Promise.resolve(null);
- return Promise.all([Promise.allSettled([detailRequest]), signedUrlPair]);
+ return Promise.all([Promise.allSettled([detailRequest]), previewRequest]);
})
- .then(([[detailResult], signedUrlPair]) => {
- if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
+ .then(([[detailResult], previewResults]) => {
+ if (
+ controller.signal.aborted ||
+ requestSequence !== detailRequestSequenceRef.current ||
+ !isAuthEpochCurrent(authRequest.epoch)
+ )
+ return;
const detailLoaded = detailResult.status === "fulfilled";
- // Advance the loaded key only on a successful detail load. A failed full
- // load must stay "not loaded" so the next page/chunk navigation is still
- // treated as a full reload — re-fetching signed URLs and refreshing the
- // error — rather than a cheap navigation that skips that recovery.
- loadedKeyRef.current = nextLoadedDocumentKey(loadedKeyRef.current, loadKey, detailLoaded);
+ // The server-rendered initial result (including a sanitized failure) is
+ // already authoritative for this attempt. Mark it handled so an auth
+ // state refresh cannot duplicate the initial detail/preview requests;
+ // an explicit retry increments previewAttempt and gets a fresh key.
+ loadedKeyRef.current = useInitialResult
+ ? loadKey
+ : nextLoadedDocumentKey(loadedKeyRef.current, loadKey, detailLoaded);
if (detailLoaded) {
const detail = detailResult.value;
setDocument(detail.document ?? null);
- setPages(detail.pages ?? []);
- setImages(detail.images ?? []);
- setTableFacts(detail.tableFacts ?? []);
- setChunks(detail.chunks ?? []);
+ // Keep the previous window visible while loading, then atomically
+ // replace it so client memory and mounted DOM stay bounded.
+ setPages(rowsById(detail.pages));
+ setImages(rowsById(detail.images));
+ setTableFacts(rowsById(detail.tableFacts));
+ setChunks(rowsById(detail.chunks));
setIndexHealth(detail.indexHealth ?? null);
- } else if (isFullReload) {
- // Only clear the viewer on a full load; a transient detail failure
- // during navigation keeps the current content on screen.
+ setServerDemoMode(detail.demoMode);
+ setViewerError(null);
+ } else {
+ // Never retain evidence from the previous page under a newly selected
+ // route. A navigation failure becomes an explicit retryable error.
setDocument(null);
setPages([]);
setImages([]);
@@ -2019,23 +2126,38 @@ export function DocumentViewer({
}
}
- if (signedUrlPair) {
- const [signedUrlResult, signedDownloadUrlResult] = signedUrlPair;
- applySignedUrlResults(signedUrlResult, signedDownloadUrlResult, signedUrlEndpoint, downloadSignedUrlEndpoint);
+ if (previewResults) {
+ const previewResult = previewResults[0];
+ if (previewResult) applyPreviewSignedUrlResult(previewResult, signedUrlEndpoint);
}
})
.catch((error) => {
- if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
- if (isFullReload) setViewerError(error instanceof Error ? error.message : "Document could not be loaded.");
+ if (
+ controller.signal.aborted ||
+ requestSequence !== detailRequestSequenceRef.current ||
+ !isAuthEpochCurrent(authRequest.epoch)
+ )
+ return;
+ setDocument(null);
+ setPages([]);
+ setImages([]);
+ setTableFacts([]);
+ setChunks([]);
+ setIndexHealth(null);
+ setViewerError(error instanceof Error ? error.message : "Document could not be loaded.");
})
.finally(() => {
- if (!controller.signal.aborted) setLoadingDocument(false);
+ if (!controller.signal.aborted && requestSequence === detailRequestSequenceRef.current) {
+ setLoadingDocument(false);
+ if (detailControllerRef.current === controller) detailControllerRef.current = null;
+ }
});
return () => {
window.clearTimeout(reset);
controller.abort();
authRequest.release();
+ if (detailControllerRef.current === controller) detailControllerRef.current = null;
};
}, [
authStatus,
@@ -2044,14 +2166,17 @@ export function DocumentViewer({
canViewSourceDocuments,
clientDemoMode,
documentId,
- chunkId,
- initialPage,
+ activeChunkId,
+ activePage,
isConfigured,
markSessionExpired,
registerAuthRequest,
isAuthEpochCurrent,
previewAttempt,
- applySignedUrlResults,
+ initialDetail,
+ initialError,
+ openSourcePreview,
+ applyPreviewSignedUrlResult,
]);
useEffect(() => {
@@ -2240,9 +2365,9 @@ export function DocumentViewer({
? "Document"
: "Source unavailable";
const headerSubtitle = readyDocument
- ? `page ${initialPage} · ${readyDocument.file_name}`
+ ? `page ${activePage} · ${readyDocument.file_name}`
: viewerState === "loading"
- ? `page ${initialPage} · loading source`
+ ? `page ${activePage} · loading source`
: (effectiveViewerError ?? "Source unavailable");
const documentHomeHref = "/?mode=documents";
const scopedDocumentHref = readyDocument
@@ -2251,8 +2376,10 @@ export function DocumentViewer({
const usefulPageHref = (page: number) => documentPageHref(documentId, page);
const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis;
const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering";
- const selectedPage = pages.find((page) => page.page_number === initialPage) ?? pages[0];
- const selectedChunk = chunkId ? chunks.find((chunk) => chunk.id === chunkId) : undefined;
+ const pageByNumber = useMemo(() => new Map(pages.map((page) => [page.page_number, page])), [pages]);
+ const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk])), [chunks]);
+ const selectedPage = pageByNumber.get(activePage) ?? pages[0];
+ const selectedChunk = activeChunkId ? chunkById.get(activeChunkId) : undefined;
const { clinicalImages, auditImages } = partitionViewerImages(images);
const generatedSummaryText = summary ? cleanClinicalSummaryText(summary.answer) : "";
const generatedAnswerIsSummary = summaryQuery === documentSummaryQuestion;
@@ -2269,30 +2396,18 @@ export function DocumentViewer({
? [indexHealth.warnings]
: [];
useEffect(() => {
- if (!chunkId || loadingDocument) return;
- // Both the mobile and desktop IndexedTextPanel render the pinned chunk, so a
- // plain querySelector returns the first match in DOM order — the mobile one,
- // which is display:none on lg+ and lives in a collapsed on phones.
- // Scroll the copy the user can actually see: skip display:none matches and
- // expand the mobile when the pinned chunk only exists inside it.
- const matches = Array.from(
- window.document.querySelectorAll(`[data-source-chunk-id="${CSS.escape(chunkId)}"]`),
- );
- const isDisplayed = (element: HTMLElement) => element.offsetParent !== null || element.getClientRects().length > 0;
- const inClosedDetails = (element: HTMLElement) => Boolean(element.closest("details:not([open])"));
- let target = matches.find((element) => isDisplayed(element) && !inClosedDetails(element));
- if (!target) {
- const collapsed = matches
- .map((element) => element.closest("details"))
- .find((node): node is HTMLDetailsElement => node instanceof HTMLDetailsElement && !node.open);
- if (collapsed) collapsed.open = true;
- target = matches.find((element) => isDisplayed(element) && !inClosedDetails(element)) ?? matches[0];
- }
- target?.scrollIntoView({ block: "center", behavior: "smooth" });
- }, [chunkId, loadingDocument, chunks.length]);
+ if (!activeChunkId || loadingDocument) return;
+ window.document
+ .querySelector(`[data-source-chunk-id="${CSS.escape(activeChunkId)}"]`)
+ ?.scrollIntoView({ block: "center", behavior: "smooth" });
+ }, [activeChunkId, loadingDocument, chunks.length]);
const retryPreview = () => {
setViewerError(null);
setPreviewError(null);
+ setDownloadError(null);
+ // Re-open the guarded load path after a transient identity failure; the
+ // cleared identity promise is still revalidated before any API request.
+ setLocalProjectReady(true);
setLoadingDocument(true);
setPreviewAttempt((current) => current + 1);
};
@@ -2300,8 +2415,8 @@ export function DocumentViewer({
signedUrlRefreshCountRef.current = 0;
}, [documentId]);
// The PDF signed URL has a 10-min TTL and pdf.js holds a dead reference once it
- // expires. When the canvas reports an expiry, drop the cached URLs and re-run
- // the fetch pipeline to mint fresh ones (bounded so a broken URL can't loop).
+ // expires. When the canvas reports an expiry, drop cached URLs and mint a fresh
+ // preview only (bounded so a broken URL can't loop). Download remains click-gated.
// Stable identity (useCallback) so the memoised PdfCanvasViewer isn't re-rendered
// — and its page re-rastered — every time an unrelated parent state (source-search
// keystroke, composer focus, online/offline) changes.
@@ -2311,6 +2426,7 @@ export function DocumentViewer({
const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`;
clearCachedSignedUrl(signedUrlEndpoint);
clearCachedSignedUrl(`${signedUrlEndpoint}?download=true`);
+ setDownloadSignedUrl(null);
refreshSignedUrls();
}, [documentId, refreshSignedUrls]);
// A successful reload means the refreshed URL was accepted, so the recovery
@@ -2478,19 +2594,22 @@ export function DocumentViewer({
Open original PDF
)}
- {downloadSignedUrl ? (
- setMobileActionsOpen(false)}
- className={cn(secondaryButton, "min-h-12 justify-start text-xs")}
- >
+
- ) : null}
+ )}
+ {downloadingSource ? "Preparing PDF" : "Download PDF"}
+
{useNativePdfViewer ? (
-
+
) : (
{
- router.push(documentPageHref(documentId, page), { scroll: false });
- }}
+ onPageChange={navigateToPage}
/>
)}
>
@@ -2706,55 +2836,15 @@ export function DocumentViewer({
-
-
-
-
-
-
-
-
-
- Indexed page text
-
- {effectiveLoadingDocument
- ? "Loading indexed page text"
- : `Page ${selectedPage?.page_number ?? initialPage} extracted text`}
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -2784,11 +2874,7 @@ export function DocumentViewer({
) : null}
-
+
({
- label: preset.query.replace(/\bdifferential diagnosis\b/i, "").trim() || preset.query,
- query: preset.query.includes("differential") ? preset.query : `${preset.query} differential diagnosis`,
- icon: BrainCircuit,
- }));
+const recentDifferentials: RecentDifferential[] = defaultDifferentialRecentQueries.map((query) => ({
+ label: query.replace(/\bdifferential diagnosis\b/i, "").trim() || query,
+ query: query.includes("differential") ? query : `${query} differential diagnosis`,
+ icon: BrainCircuit,
+}));
const candidateIconBySlug: Array<[string, LucideIcon]> = [
["substance", FlaskConical],
diff --git a/src/components/clinical-dashboard/use-universal-search.ts b/src/components/clinical-dashboard/use-universal-search.ts
index dabdbbd6d..cdca941e9 100644
--- a/src/components/clinical-dashboard/use-universal-search.ts
+++ b/src/components/clinical-dashboard/use-universal-search.ts
@@ -11,6 +11,7 @@ import type {
// Value import from the leaf module only: universal-search.ts itself is server-only
// (snapshot catalogues, rag, supabase) and must never enter the client bundle.
import { universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search-domains";
+import { consumeUniversalSearchNdjson } from "@/lib/universal-search-stream";
import type { AppModeId } from "@/lib/app-modes";
import { useAuthSession } from "@/lib/supabase/client";
@@ -34,6 +35,7 @@ export type UniversalSearchState = {
type UniversalSearchResult = {
groups: UniversalSearchGroup[];
query: string;
+ complete: boolean;
interpretation?: UniversalSearchInterpretation;
domainOrder?: UniversalSearchDomain[];
topHit?: UniversalSearchTopHit;
@@ -102,7 +104,7 @@ export function useUniversalSearch(args: {
limitPerDomain?: number;
}): UniversalSearchState {
const { authorizationHeader } = useAuthSession();
- const [result, setResult] = useState({ groups: [], query: "" });
+ const [result, setResult] = useState({ groups: [], query: "", complete: true });
const requestSeqRef = useRef(0);
const prevAuthRef = useRef(authorizationHeader);
const trimmedQuery = args.query.trim();
@@ -126,7 +128,7 @@ export function useUniversalSearch(args: {
const key = cacheKey;
if (authChanged) {
- setResult({ groups: [], query: "" });
+ setResult({ groups: [], query: "", complete: true });
}
// Instant path: a previously fetched query needs no fetch. The render below reads the cache
@@ -151,19 +153,43 @@ export function useUniversalSearch(args: {
limit: String(limitPerDomain),
domains: domains.join(","),
mode: args.contextMode,
+ stream: "ndjson",
});
fetch(`/api/search/universal?${params.toString()}`, { headers: authorizationHeader, signal: controller.signal })
.then(async (response) => {
if (requestId !== requestSeqRef.current) return;
if (!response.ok) {
- setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode });
+ setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode, complete: true });
return;
}
- const payload = (await response.json()) as Partial;
- if (requestId !== requestSeqRef.current) return;
+ const payload = await consumeUniversalSearchNdjson(response, {
+ signal: controller.signal,
+ onGroup: (group, streamedQuery) => {
+ if (controller.signal.aborted || requestId !== requestSeqRef.current || streamedQuery !== trimmedQuery) {
+ return;
+ }
+
+ setResult((current) => {
+ if (controller.signal.aborted || requestId !== requestSeqRef.current) return current;
+ const groups =
+ current.query === trimmedQuery && current.contextMode === args.contextMode && !current.complete
+ ? current.groups.filter((candidate) => candidate.kind !== group.kind)
+ : [];
+ if (!group.error && group.items.length > 0) groups.push(group);
+ return {
+ groups,
+ query: trimmedQuery,
+ contextMode: args.contextMode,
+ complete: false,
+ };
+ });
+ },
+ });
+ if (controller.signal.aborted || requestId !== requestSeqRef.current) return;
const next: UniversalSearchResult = {
groups: (payload.groups ?? []).filter((group) => !group.error && group.items.length > 0),
query: trimmedQuery,
+ complete: true,
interpretation: payload.interpretation,
domainOrder: payload.domainOrder,
topHit: payload.topHit,
@@ -176,9 +202,9 @@ export function useUniversalSearch(args: {
})
.catch((error: unknown) => {
// An aborted fetch is a superseded keystroke, not a failure — leave state to the newer request.
- if (error instanceof DOMException && error.name === "AbortError") return;
+ if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
if (requestId !== requestSeqRef.current) return;
- setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode });
+ setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode, complete: true });
});
}, debounceMs);
@@ -209,7 +235,7 @@ export function useUniversalSearch(args: {
const fresh = result.query === trimmedQuery && result.contextMode === args.contextMode;
return {
groups: fresh ? result.groups : [],
- loading: !fresh,
+ loading: !fresh || !result.complete,
query: result.query,
interpretation: fresh ? result.interpretation : undefined,
domainOrder: fresh ? result.domainOrder : undefined,
diff --git a/src/components/document-viewer-lazy.tsx b/src/components/document-viewer-lazy.tsx
index 26a39fa0e..c5ebc83fc 100644
--- a/src/components/document-viewer-lazy.tsx
+++ b/src/components/document-viewer-lazy.tsx
@@ -1,9 +1,5 @@
"use client";
-import dynamic from "next/dynamic";
-
-// `ssr: false` requires a Client Component in the App Router; this wrapper
-// keeps the viewer bundle browser-only for the server-rendered document page.
-export const DocumentViewerLazy = dynamic(() => import("@/components/DocumentViewer").then((m) => m.DocumentViewer), {
- ssr: false,
-});
+// Retain the compatibility export while allowing Next.js to prerender the
+// browser-guarded Client Component with the Server Component's initial detail.
+export { DocumentViewer as DocumentViewerLazy } from "@/components/DocumentViewer";
diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx
index 8016e9c04..ae022e777 100644
--- a/src/components/forms/form-detail-page.tsx
+++ b/src/components/forms/form-detail-page.tsx
@@ -44,9 +44,8 @@ import {
toneWarning,
} from "@/components/ui-primitives";
import { appModeHomeHref } from "@/lib/app-modes";
-import { formCatalogDetails } from "@/lib/form-catalog";
-import type { FormRecord } from "@/lib/forms";
-import type { ServiceChipTone, ServiceContact, ServiceCriterion, ServiceSummaryCard } from "@/lib/services";
+import { formCatalogDetails, type FormRecord } from "@/lib/form-ranker";
+import type { ServiceChipTone, ServiceContact, ServiceCriterion, ServiceSummaryCard } from "@/lib/service-ranker";
import { readSavedRegistrySlugs, savedFormsStorageKey, writeSavedRegistrySlugs } from "@/lib/saved-registry-storage";
const missingText = "Not listed";
diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx
index d386ca447..9a32ccf65 100644
--- a/src/components/forms/forms-search-results-page.tsx
+++ b/src/components/forms/forms-search-results-page.tsx
@@ -19,8 +19,7 @@ import {
import { useId, useMemo, useState } from "react";
import { appModeHomeHref } from "@/lib/app-modes";
-import { formCatalogDetails } from "@/lib/form-catalog";
-import { rankFormRecords, type FormSearchMatch } from "@/lib/forms";
+import { formCatalogDetails, rankFormRecords, type FormSearchMatch } from "@/lib/form-ranker";
import { useRegistryRecords, type RegistryRequestStatus } from "@/lib/use-registry-records";
import {
cn,
diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx
index 365b6a8ef..ccf02b9d9 100644
--- a/src/components/services/service-detail-page.tsx
+++ b/src/components/services/service-detail-page.tsx
@@ -50,7 +50,7 @@ import {
type ServiceRecord,
type ServiceStatusChip,
type ServiceSummaryCard,
-} from "@/lib/services";
+} from "@/lib/service-ranker";
import { readSavedRegistrySlugs, savedServicesStorageKey, writeSavedRegistrySlugs } from "@/lib/saved-registry-storage";
const missingText = "Not listed";
diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx
index bd1c68cd1..87b090573 100644
--- a/src/components/services/services-navigator-page.tsx
+++ b/src/components/services/services-navigator-page.tsx
@@ -33,7 +33,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command
import { appModeHomeHref } from "@/lib/app-modes";
import { recordMatchesCommandScopes } from "@/lib/search-command-surface";
import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer";
-import { rankServiceRecords, serviceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/services";
+import { rankServiceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/service-ranker";
import { useRegistryRecords } from "@/lib/use-registry-records";
import { sortResultItems } from "@/lib/result-sort";
import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";
@@ -475,13 +475,15 @@ export function ServicesNavigatorPage() {
});
return map;
}, [scopedMatches]);
- const [selectedSlugs, setSelectedSlugs] = useState(() => serviceRecords.slice(0, 2).map((service) => service.slug));
- const selected = searchableRecords.filter((service) => selectedSlugs.includes(service.slug));
+ const [selectedSlugs, setSelectedSlugs] = useState(null);
+ const effectiveSelectedSlugs = selectedSlugs ?? searchableRecords.slice(0, 2).map((service) => service.slug);
+ const selected = searchableRecords.filter((service) => effectiveSelectedSlugs.includes(service.slug));
function toggleSelected(slug: string) {
- setSelectedSlugs((current) =>
- current.includes(slug) ? current.filter((item) => item !== slug) : [slug, ...current].slice(0, 5),
- );
+ setSelectedSlugs((current) => {
+ const selected = current ?? effectiveSelectedSlugs;
+ return selected.includes(slug) ? selected.filter((item) => item !== slug) : [slug, ...selected].slice(0, 5);
+ });
}
function applyServiceQuery(nextQuery: string) {
@@ -634,7 +636,7 @@ export function ServicesNavigatorPage() {
service={service}
index={index}
relevanceRank={sortValue === "alpha" ? null : (relevanceRankMap.get(service.slug) ?? null)}
- selected={selectedSlugs.includes(service.slug)}
+ selected={effectiveSelectedSlugs.includes(service.slug)}
onToggleSelected={toggleSelected}
/>
))}
diff --git a/src/lib/answer-client-payload.ts b/src/lib/answer-client-payload.ts
index 7e09f7095..6785af5fd 100644
--- a/src/lib/answer-client-payload.ts
+++ b/src/lib/answer-client-payload.ts
@@ -9,10 +9,12 @@ import type { RagAnswer, SearchResult } from "@/lib/types";
// caches, generation inputs, and eval behavior byte-identical while cutting
// the final SSE/JSON event the user waits on after the prose has streamed.
//
-// Ordering contract: sourceGovernanceWarnings and logAnswerDiagnostics consume
-// the FULL answer and must run before this trim (both routes do). Full source
-// content remains client-visible because the safety panel scans it for clinical
-// warnings that may occur beyond the display synopsis.
+// Ordering contract: source-governance/safety derivation and diagnostics consume
+// the FULL answer and must run before this trim (both routes do). The browser
+// receives only the same bounded snippet the source UI renders; explicit
+// server-derived safetyWarnings preserve findings beyond that display window.
+
+const clientSourceSnippetMaxChars = 900;
const sourceFieldPolicy = {
id: "client",
@@ -48,7 +50,7 @@ const sourceFieldPolicy = {
table_facts: "server",
index_unit: "server",
indexing_quality: "client",
- images: "client",
+ images: "server",
} as const satisfies Record;
function trimSourceForClient(source: SearchResult): SearchResult {
@@ -57,8 +59,15 @@ function trimSourceForClient(source: SearchResult): SearchResult {
.filter((key) => sourceFieldPolicy[key] === "client" && key in source)
.map((key) => [key, source[key]]),
) as SearchResult;
- trimmed.content = source.content ?? "";
- trimmed.images ??= [];
+ const renderedSnippet = (source.retrieval_synopsis ?? source.content ?? "").trim();
+ trimmed.content =
+ renderedSnippet.length <= clientSourceSnippetMaxChars
+ ? renderedSnippet
+ : `${renderedSnippet.slice(0, clientSourceSnippetMaxChars - 1).trimEnd()}…`;
+ if (source.retrieval_synopsis != null) trimmed.retrieval_synopsis = trimmed.content;
+ // Full image/table objects remain available to generation and diagnostics,
+ // but the answer UI resolves source media from the bounded image_ids list.
+ trimmed.images = [];
return trimmed;
}
diff --git a/src/lib/answer-response.ts b/src/lib/answer-response.ts
index 7245b69b1..e603c3fe1 100644
--- a/src/lib/answer-response.ts
+++ b/src/lib/answer-response.ts
@@ -1,10 +1,28 @@
import { toClientAnswerPayload } from "@/lib/answer-client-payload";
+import { extractSafetyFindings } from "@/lib/clinical-safety";
import {
hasDangerSourceGovernanceWarning,
sourceGovernanceRefusalAnswer,
sourceGovernanceWarnings,
} from "@/lib/source-governance";
-import type { RagAnswer } from "@/lib/types";
+import type { RagAnswer, SafetyWarning } from "@/lib/types";
+
+function clientSafetyWarning(warning: SafetyWarning): SafetyWarning {
+ const citation = warning.citation;
+ return {
+ ...warning,
+ citation: {
+ chunk_id: citation.chunk_id,
+ document_id: citation.document_id,
+ title: citation.title,
+ file_name: citation.file_name,
+ page_number: citation.page_number,
+ chunk_index: citation.chunk_index,
+ ...(citation.similarity === undefined ? {} : { similarity: citation.similarity }),
+ ...(citation.provenance === undefined ? {} : { provenance: citation.provenance }),
+ },
+ };
+}
export function answerDegradedModeSignal(
answer?: Pick,
@@ -19,6 +37,7 @@ export function answerDegradedModeSignal(
/** Apply the shared browser-boundary source-governance contract. */
export function buildGovernedAnswerClientResponse(answer: RagAnswer) {
+ const safetyWarnings = extractSafetyFindings(answer).map(clientSafetyWarning);
const warnings = sourceGovernanceWarnings({
results: answer.sources ?? [],
relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null,
@@ -55,6 +74,7 @@ export function buildGovernedAnswerClientResponse(answer: RagAnswer) {
sources: [],
degradedMode: answerDegradedModeSignal(answer),
sourceGovernanceWarnings: warnings,
+ safetyWarnings: [],
},
};
}
@@ -67,6 +87,7 @@ export function buildGovernedAnswerClientResponse(answer: RagAnswer) {
...toClientAnswerPayload(answer),
degradedMode: answerDegradedModeSignal(answer),
sourceGovernanceWarnings: warnings,
+ safetyWarnings,
},
};
}
diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts
index 04b7472f2..fb8b5a68a 100644
--- a/src/lib/api-rate-limit.ts
+++ b/src/lib/api-rate-limit.ts
@@ -78,6 +78,7 @@ const anonymousApiRateLimitDefaults: Partial;
type RateLimitRpcRow = {
+ bucket?: string | null;
limited?: boolean;
limit_value?: number;
remaining?: number;
@@ -270,6 +271,61 @@ export async function consumeSubjectApiRateLimit(args: {
};
}
+export type SummaryRateLimitBucket = "answer" | "document_summarize";
+
+export type SummaryRateLimitDecision = {
+ bucket: SummaryRateLimitBucket | null;
+ rateLimit: ApiRateLimitResult;
+};
+
+/**
+ * Atomically applies the answer and document-summary policies used by streamed
+ * summaries. The database function locks every participating bucket in a
+ * stable order, avoiding the partial accounting and lock-order risk of two
+ * serial RPC calls.
+ */
+export async function consumeSummaryRateLimits(args: {
+ supabase: SupabaseAdmin;
+ subject: RateLimitSubject;
+}): Promise {
+ const answerDefaults =
+ args.subject.kind === "owner"
+ ? apiRateLimitDefaults.answer
+ : (anonymousApiRateLimitDefaults.answer ?? apiRateLimitDefaults.answer);
+ const summaryDefaults = apiRateLimitDefaults.document_summarize;
+ const globalAnswerDefaults = apiRateLimitDefaults.answer;
+ const { data, error } = await args.supabase.rpc("consume_summary_rate_limits_atomic", {
+ p_owner_id: args.subject.kind === "owner" ? args.subject.ownerId : null,
+ p_subject_key: args.subject.kind === "anonymous" ? args.subject.subjectKey : null,
+ p_answer_limit: answerDefaults.limit,
+ p_answer_window_seconds: answerDefaults.windowSeconds,
+ p_summary_limit: summaryDefaults.limit,
+ p_summary_window_seconds: summaryDefaults.windowSeconds,
+ p_global_answer_limit: globalAnswerDefaults.limit,
+ p_global_answer_window_seconds: globalAnswerDefaults.windowSeconds,
+ });
+
+ if (error) throw new ApiRateLimitUnavailableError();
+
+ const row = parseRateLimitRow(data);
+ const bucket = row?.bucket;
+ const validBucket = bucket === "answer" || bucket === "document_summarize" ? bucket : null;
+ if (!row || typeof row.limited !== "boolean" || (row.limited && validBucket === null)) {
+ throw new ApiRateLimitUnavailableError();
+ }
+
+ return {
+ bucket: validBucket,
+ rateLimit: {
+ limited: row.limited,
+ limit: Number(row.limit_value ?? summaryDefaults.limit),
+ remaining: Number(row.remaining ?? 0),
+ retryAfterSeconds: Math.max(1, Number(row.retry_after_seconds ?? summaryDefaults.windowSeconds)),
+ resetAt: String(row.reset_at ?? new Date(Date.now() + summaryDefaults.windowSeconds * 1000).toISOString()),
+ },
+ };
+}
+
function consumeInMemoryApiRateLimit({
ownerId,
bucket,
@@ -320,6 +376,7 @@ export function rateLimitJsonResponse(message: string, rateLimit: ApiRateLimitRe
{
status: 429,
headers: {
+ "Cache-Control": "private, no-store",
"Retry-After": String(rateLimit.retryAfterSeconds),
},
},
diff --git a/src/lib/clinical-safety.ts b/src/lib/clinical-safety.ts
index 2b970b6da..b1d1bed47 100644
--- a/src/lib/clinical-safety.ts
+++ b/src/lib/clinical-safety.ts
@@ -6,19 +6,10 @@ import {
sourceTextForCompactDisplay,
sourceTextForDisplay,
} from "@/lib/source-text-sanitizer";
-import type { Citation, RagAnswer, SearchResult } from "@/lib/types";
-
-export type SafetyFindingKind =
- "contraindication" | "red_flag" | "escalation" | "dose_limit" | "monitoring" | "exclusion" | "caveat";
-
-export type SafetyFinding = {
- id: string;
- kind: SafetyFindingKind;
- label: string;
- text: string;
- citation: Citation;
- href: string;
-};
+import type { Citation, RagAnswer, SafetyWarning, SafetyWarningKind, SearchResult } from "@/lib/types";
+
+export type SafetyFindingKind = SafetyWarningKind;
+export type SafetyFinding = SafetyWarning;
const safetyPatterns: Array<{ kind: SafetyFindingKind; label: string; pattern: RegExp }> = [
{
@@ -95,6 +86,7 @@ function hasQueryConceptOverlap(text: string, terms: string[]) {
}
export function extractSafetyFindings(answer: RagAnswer | null | undefined, limit = 5): SafetyFinding[] {
+ if (answer?.safetyWarnings) return answer.safetyWarnings.slice(0, limit);
if (!answer?.grounded) return [];
if (answer.relevance && !answer.relevance.isSourceBacked) return [];
diff --git a/src/lib/corpus-grounding.ts b/src/lib/corpus-grounding.ts
index 57b2c2d55..f86d900c7 100644
--- a/src/lib/corpus-grounding.ts
+++ b/src/lib/corpus-grounding.ts
@@ -9,6 +9,20 @@ import {
type RetrievalAccessScope,
} from "@/lib/owner-scope";
+type AbortableQuery = PromiseLike & { abortSignal?: (signal: AbortSignal) => PromiseLike };
+
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
+async function resolveAbortableQuery(query: AbortableQuery, signal?: AbortSignal): Promise {
+ if (signal?.aborted) throw abortReason(signal);
+ const pending = signal && typeof query.abortSignal === "function" ? query.abortSignal(signal) : query;
+ const result = await pending;
+ if (signal?.aborted) throw abortReason(signal);
+ return result;
+}
+
// Finding #11 (corpus-grounded relevance): the deterministic query analyzer cannot tell an
// in-corpus bare topic ("bipolar disorder") from an invented one ("florbizone syndrome
// management") — both land in the unsupported soft tail with identical confidence, and the LLM
@@ -121,7 +135,9 @@ export async function classifyCorpusGrounding(args: {
// The exact owner_filter retrieval will use (null = unscoped, zero-UUID = public docs only).
ownerFilter: string | null;
accessScope?: RetrievalAccessScope;
+ signal?: AbortSignal;
}): Promise {
+ if (args.signal?.aborted) throw abortReason(args.signal);
const terms = corpusGroundingTerms(args.query);
if (terms.length === 0) return { verdict: "inconclusive", anchorTerms: [], absentTerms: [] };
@@ -142,20 +158,29 @@ export async function classifyCorpusGrounding(args: {
if (missing.length > 0) {
try {
const ownerFilter = accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL;
- const versioned = await args.supabase.rpc("corpus_topic_term_stats_v2", {
- terms: missing,
- owner_filter: ownerFilter,
- include_public: accessScope.includePublic,
- });
+ const versioned = await resolveAbortableQuery(
+ args.supabase.rpc("corpus_topic_term_stats_v2", {
+ terms: missing,
+ owner_filter: ownerFilter,
+ include_public: accessScope.includePublic,
+ }),
+ args.signal,
+ );
const calls =
!versioned || isMissingRetrievalRpcError(versioned.error)
? await Promise.all([
- args.supabase.rpc("corpus_topic_term_stats", { terms: missing, owner_filter: ownerFilter }),
+ resolveAbortableQuery(
+ args.supabase.rpc("corpus_topic_term_stats", { terms: missing, owner_filter: ownerFilter }),
+ args.signal,
+ ),
accessScope.ownerId && accessScope.includePublic
- ? args.supabase.rpc("corpus_topic_term_stats", {
- terms: missing,
- owner_filter: PUBLIC_OWNER_FILTER_SENTINEL,
- })
+ ? resolveAbortableQuery(
+ args.supabase.rpc("corpus_topic_term_stats", {
+ terms: missing,
+ owner_filter: PUBLIC_OWNER_FILTER_SENTINEL,
+ }),
+ args.signal,
+ )
: Promise.resolve({ data: [], error: null }),
])
: [versioned];
@@ -182,11 +207,13 @@ export async function classifyCorpusGrounding(args: {
// A term the RPC did not echo back got dropped SQL-side (blank after trim); treat the
// whole classification as inconclusive rather than guessing.
if (rows.length !== missing.length) return { verdict: "inconclusive", anchorTerms: [], absentTerms: [] };
+ if (args.signal?.aborted) throw abortReason(args.signal);
for (const row of rows) {
storeCachedStats(ownerScopeKey, row);
stats.push(row);
}
} catch {
+ if (args.signal?.aborted) throw abortReason(args.signal);
// Fail open: missing RPC (migration not applied), transient DB error, demo mode — the
// caller keeps today's behaviour (LLM classifier fallback + soft-tail short-circuit).
return { verdict: "inconclusive", anchorTerms: [], absentTerms: [] };
diff --git a/src/lib/cross-mode-links.ts b/src/lib/cross-mode-links.ts
index 7da1b82ce..1ac555613 100644
--- a/src/lib/cross-mode-links.ts
+++ b/src/lib/cross-mode-links.ts
@@ -1,5 +1,5 @@
import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes";
-import { rankFormRecords, type FormRecord } from "@/lib/forms";
+import { rankFormRecords, type FormRecord } from "@/lib/form-ranker";
import {
medicationIdentityBadges,
medicationIndication,
@@ -7,7 +7,7 @@ import {
type MedicationRecord,
} from "@/lib/medications";
import { extractKeywordTerms } from "@/lib/keyword-query";
-import { rankServiceRecords, type ServiceRecord } from "@/lib/services";
+import { rankServiceRecords, type ServiceRecord } from "@/lib/service-ranker";
export type CrossModeLinkModeId = Extract;
diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts
index aee3bbc7c..9a7dd551c 100644
--- a/src/lib/deep-memory.ts
+++ b/src/lib/deep-memory.ts
@@ -34,6 +34,24 @@ import type {
SearchResult,
} from "@/lib/types";
+type AbortableQuery = PromiseLike & { abortSignal?: (signal: AbortSignal) => PromiseLike };
+
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (signal?.aborted) throw abortReason(signal);
+}
+
+async function resolveAbortableQuery(query: AbortableQuery, signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ const pending = signal && typeof query.abortSignal === "function" ? query.abortSignal(signal) : query;
+ const result = await pending;
+ throwIfAborted(signal);
+ return result;
+}
+
export const ragDeepMemoryVersion = "rag-deep-memory-v1" as const;
export const localDeepMemoryProducer = "local-worker" as const;
@@ -893,18 +911,23 @@ export async function fetchMemoryCardsForQuery(args: {
accessScope?: RetrievalAccessScope;
documentIds?: string[];
matchCount?: number;
+ signal?: AbortSignal;
}) {
try {
+ throwIfAborted(args.signal);
const accessScope = retrievalAccessScopeForArgs(args);
if (args.queryEmbedding?.length) {
- const versioned = await args.supabase.rpc("match_document_memory_cards_hybrid_v3", {
- query_embedding: args.queryEmbedding,
- query_text: buildClinicalTextSearchQuery(args.query),
- match_count: args.matchCount ?? 32,
- min_similarity: 0.1,
- document_filters: args.documentIds?.length ? args.documentIds : null,
- ...retrievalRpcScopeArgs(accessScope),
- });
+ const versioned = await resolveAbortableQuery(
+ args.supabase.rpc("match_document_memory_cards_hybrid_v3", {
+ query_embedding: args.queryEmbedding,
+ query_text: buildClinicalTextSearchQuery(args.query),
+ match_count: args.matchCount ?? 32,
+ min_similarity: 0.1,
+ document_filters: args.documentIds?.length ? args.documentIds : null,
+ ...retrievalRpcScopeArgs(accessScope),
+ }),
+ args.signal,
+ );
let data = versioned?.data;
let error = versioned?.error;
if (!versioned || isMissingRetrievalRpcError(versioned.error)) {
@@ -915,16 +938,22 @@ export async function fetchMemoryCardsForQuery(args: {
min_similarity: 0.1,
document_filters: args.documentIds?.length ? args.documentIds : null,
};
- const ownerResult = await args.supabase.rpc("match_document_memory_cards_hybrid_v2", {
- ...baseArgs,
- owner_filter: accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL,
- });
+ const ownerResult = await resolveAbortableQuery(
+ args.supabase.rpc("match_document_memory_cards_hybrid_v2", {
+ ...baseArgs,
+ owner_filter: accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL,
+ }),
+ args.signal,
+ );
const publicResult =
accessScope.ownerId && accessScope.includePublic
- ? await args.supabase.rpc("match_document_memory_cards_hybrid_v2", {
- ...baseArgs,
- owner_filter: PUBLIC_OWNER_FILTER_SENTINEL,
- })
+ ? await resolveAbortableQuery(
+ args.supabase.rpc("match_document_memory_cards_hybrid_v2", {
+ ...baseArgs,
+ owner_filter: PUBLIC_OWNER_FILTER_SENTINEL,
+ }),
+ args.signal,
+ )
: { data: [], error: null };
error = ownerResult.error ?? publicResult.error;
type MemoryCardHybridRow = { id: string; hybrid_score: number; rrf_score?: number | null } & Record<
@@ -999,7 +1028,7 @@ export async function fetchMemoryCardsForQuery(args: {
}
if (args.documentIds?.length) queryBuilder = queryBuilder.in("document_id", args.documentIds);
- const { data, error } = await queryBuilder;
+ const { data, error } = await resolveAbortableQuery(queryBuilder, args.signal);
if (error) return [];
const cards = (data ?? []) as DocumentMemoryCard[];
@@ -1013,7 +1042,7 @@ export async function fetchMemoryCardsForQuery(args: {
documentsQuery = documentsQuery.is("owner_id", null);
}
const { data: documents, error: documentsError } = documentIds.length
- ? await documentsQuery
+ ? await resolveAbortableQuery(documentsQuery, args.signal)
: { data: [], error: null };
const committedGenerationByDocument = new Map(
(documents ?? []).map((document) => [document.id, committedIndexGeneration(document.metadata)] as const),
@@ -1038,6 +1067,7 @@ export async function fetchMemoryCardsForQuery(args: {
.sort((a, b) => scoreMemoryCardForQuery(args.query, b) - scoreMemoryCardForQuery(args.query, a))
.slice(0, args.matchCount ?? 32);
} catch {
+ if (args.signal?.aborted) throw abortReason(args.signal);
return [];
}
}
diff --git a/src/lib/differential-search-composition.ts b/src/lib/differential-search-composition.ts
new file mode 100644
index 000000000..aa075712d
--- /dev/null
+++ b/src/lib/differential-search-composition.ts
@@ -0,0 +1,126 @@
+import type { DifferentialPresentationWorkflow, DifferentialRecord } from "@/lib/differential-snapshot";
+
+export type DifferentialRecordMatch = {
+ record: DifferentialRecord;
+ score: number;
+ reasons: string[];
+};
+
+/** Back-compat alias kept for the universal-search workstream naming. */
+export type DifferentialSearchMatch = DifferentialRecordMatch;
+
+export type DifferentialPresentationMatch = {
+ workflow: DifferentialPresentationWorkflow;
+ score: number;
+ reasons: string[];
+};
+
+export type DifferentialSearchResultItem = {
+ id: string;
+ kind: "presentation" | "diagnosis";
+ slug: string;
+ title: string;
+ subtitle: string;
+ href: string;
+ status: DifferentialRecord["status"];
+ score: number;
+ matchLabel: "Best match" | "High match" | "Moderate match" | "Lower match";
+ tags: string[];
+ safety: string;
+ reasons: string[];
+};
+
+function diagnosisResultItem(match: DifferentialRecordMatch): Omit {
+ const { record, score, reasons } = match;
+ return {
+ id: record.slug,
+ kind: "diagnosis",
+ slug: record.slug,
+ title: record.title,
+ subtitle: record.clinicalHinge || record.subtitle,
+ href: `/differentials/diagnoses/${record.slug}`,
+ status: record.status,
+ score,
+ tags: [...record.currentPresentation.slice(0, 3), record.investigations[0]]
+ .filter((value): value is string => Boolean(value?.trim()))
+ .slice(0, 4),
+ safety: record.safetySnapshot.summary,
+ reasons,
+ };
+}
+
+function presentationResultItem(
+ match: DifferentialPresentationMatch,
+): Omit {
+ const { workflow, score, reasons } = match;
+ return {
+ id: workflow.id,
+ kind: "presentation",
+ slug: workflow.id,
+ title: workflow.title,
+ subtitle: workflow.subtitle,
+ href: `/differentials/presentations/${workflow.id}`,
+ status: workflow.status,
+ score,
+ tags: workflow.safetySnapshot.tags.slice(0, 4),
+ safety: workflow.safetySnapshot.summary,
+ reasons,
+ };
+}
+
+/** Compose ranked diagnosis + presentation matches into one adaptive result
+ * list: when a presentation matches about as strongly as the best diagnosis
+ * it leads (followed by its candidate diagnoses in ranked order), otherwise
+ * results interleave purely by score. Deduped by id, capped at `limit`. */
+export function composeDifferentialSearchResults(
+ diagnoses: DifferentialRecordMatch[],
+ presentations: DifferentialPresentationMatch[],
+ limit = 8,
+): DifferentialSearchResultItem[] {
+ const items: Array> = [];
+ const seen = new Set();
+ const push = (item: Omit) => {
+ const key = `${item.kind}:${item.id}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ items.push(item);
+ };
+
+ const topPresentation = presentations[0];
+ const topDiagnosisScore = diagnoses[0]?.score ?? 0;
+ const presentationLeads =
+ Boolean(topPresentation) && (topDiagnosisScore === 0 || topPresentation!.score >= topDiagnosisScore * 0.8);
+
+ if (topPresentation && presentationLeads) {
+ push(presentationResultItem(topPresentation));
+ const candidateSlugs = new Set(topPresentation.workflow.candidates.map((candidate) => candidate.slug));
+ for (const match of diagnoses) {
+ if (candidateSlugs.has(match.record.slug)) push(diagnosisResultItem(match));
+ }
+ for (const match of diagnoses) push(diagnosisResultItem(match));
+ for (const match of presentations.slice(1)) push(presentationResultItem(match));
+ } else {
+ const merged = [
+ ...diagnoses.map((match) => ({ score: match.score, item: diagnosisResultItem(match) })),
+ ...presentations.map((match) => ({ score: match.score, item: presentationResultItem(match) })),
+ ].sort((left, right) => right.score - left.score);
+ for (const entry of merged) push(entry.item);
+ }
+
+ return items.slice(0, limit).map((item, index) => ({
+ ...item,
+ matchLabel:
+ index === 0 ? "Best match" : item.score >= 12 ? "High match" : item.score >= 6 ? "Moderate match" : "Lower match",
+ }));
+}
+
+// These are the same compact entry queries previously derived at module load
+// from the generated snapshot. Keeping only the five rendered strings avoids
+// shipping the full snapshot to the client for a small "Recent work" list.
+export const defaultDifferentialRecentQueries = [
+ "older adult acute confusion",
+ "first episode psychosis",
+ "perinatal mood psychosis",
+ "agitated intoxicated patient",
+ "withdrawal tremor autonomic",
+] as const;
diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts
index dec593abd..49750c037 100644
--- a/src/lib/differentials.ts
+++ b/src/lib/differentials.ts
@@ -2,6 +2,10 @@ import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
import { cleanDifferentialItem, type DifferentialDetailContext } from "@/lib/differential-detail";
import { loadDifferentialSnapshot } from "@/lib/differential-fixtures";
import { deriveGovernanceFromSnapshot } from "@/lib/differential-records";
+import {
+ type DifferentialPresentationMatch,
+ type DifferentialRecordMatch,
+} from "@/lib/differential-search-composition";
import type {
DifferentialComparisonCandidate,
DifferentialComparisonCriterion,
@@ -35,6 +39,13 @@ export type {
DifferentialScenarioPreset,
DifferentialSection,
};
+export {
+ composeDifferentialSearchResults,
+ type DifferentialPresentationMatch,
+ type DifferentialRecordMatch,
+ type DifferentialSearchMatch,
+ type DifferentialSearchResultItem,
+} from "@/lib/differential-search-composition";
function catalog() {
return loadDifferentialSnapshot();
@@ -261,21 +272,6 @@ const differentialStatusRank: Record = {
routine: 2,
};
-export type DifferentialRecordMatch = {
- record: DifferentialRecord;
- score: number;
- reasons: string[];
-};
-
-/** Back-compat alias kept for the universal-search workstream naming. */
-export type DifferentialSearchMatch = DifferentialRecordMatch;
-
-export type DifferentialPresentationMatch = {
- workflow: DifferentialPresentationWorkflow;
- score: number;
- reasons: string[];
-};
-
function diagnosisHingeText(record: DifferentialRecord) {
return normalizeSearchText(
[record.subtitle, record.clinicalHinge, record.safetySnapshot.summary, ...record.safetySnapshot.tags].join(" "),
@@ -414,105 +410,6 @@ export function rankPresentationWorkflows(
}));
}
-export type DifferentialSearchResultItem = {
- id: string;
- kind: "presentation" | "diagnosis";
- slug: string;
- title: string;
- subtitle: string;
- href: string;
- status: DifferentialRecord["status"];
- score: number;
- matchLabel: "Best match" | "High match" | "Moderate match" | "Lower match";
- tags: string[];
- safety: string;
- reasons: string[];
-};
-
-function diagnosisResultItem(match: DifferentialRecordMatch): Omit {
- const { record, score, reasons } = match;
- return {
- id: record.slug,
- kind: "diagnosis",
- slug: record.slug,
- title: record.title,
- subtitle: record.clinicalHinge || record.subtitle,
- href: `/differentials/diagnoses/${record.slug}`,
- status: record.status,
- score,
- tags: [...record.currentPresentation.slice(0, 3), record.investigations[0]]
- .filter((value): value is string => Boolean(value?.trim()))
- .slice(0, 4),
- safety: record.safetySnapshot.summary,
- reasons,
- };
-}
-
-function presentationResultItem(
- match: DifferentialPresentationMatch,
-): Omit {
- const { workflow, score, reasons } = match;
- return {
- id: workflow.id,
- kind: "presentation",
- slug: workflow.id,
- title: workflow.title,
- subtitle: workflow.subtitle,
- href: `/differentials/presentations/${workflow.id}`,
- status: workflow.status,
- score,
- tags: workflow.safetySnapshot.tags.slice(0, 4),
- safety: workflow.safetySnapshot.summary,
- reasons,
- };
-}
-
-/** Compose ranked diagnosis + presentation matches into one adaptive result
- * list: when a presentation matches about as strongly as the best diagnosis
- * it leads (followed by its candidate diagnoses in ranked order), otherwise
- * results interleave purely by score. Deduped by id, capped at `limit`. */
-export function composeDifferentialSearchResults(
- diagnoses: DifferentialRecordMatch[],
- presentations: DifferentialPresentationMatch[],
- limit = 8,
-): DifferentialSearchResultItem[] {
- const items: Array> = [];
- const seen = new Set();
- const push = (item: Omit) => {
- const key = `${item.kind}:${item.id}`;
- if (seen.has(key)) return;
- seen.add(key);
- items.push(item);
- };
-
- const topPresentation = presentations[0];
- const topDiagnosisScore = diagnoses[0]?.score ?? 0;
- const presentationLeads =
- Boolean(topPresentation) && (topDiagnosisScore === 0 || topPresentation!.score >= topDiagnosisScore * 0.8);
-
- if (topPresentation && presentationLeads) {
- push(presentationResultItem(topPresentation));
- const candidateSlugs = new Set(topPresentation.workflow.candidates.map((candidate) => candidate.slug));
- for (const match of diagnoses) {
- if (candidateSlugs.has(match.record.slug)) push(diagnosisResultItem(match));
- }
- for (const match of diagnoses) push(diagnosisResultItem(match));
- for (const match of presentations.slice(1)) push(presentationResultItem(match));
- } else {
- const merged = [
- ...diagnoses.map((match) => ({ score: match.score, item: diagnosisResultItem(match) })),
- ...presentations.map((match) => ({ score: match.score, item: presentationResultItem(match) })),
- ].sort((left, right) => right.score - left.score);
- for (const entry of merged) push(entry.item);
- }
-
- return items.slice(0, limit).map((item, index) => ({
- ...item,
- matchLabel:
- index === 0 ? "Best match" : item.score >= 12 ? "High match" : item.score >= 6 ? "Moderate match" : "Lower match",
- }));
-}
-
/** Back-compat wrapper: empty query returns the full catalogue (the API route
* relies on this), otherwise relevance-ranked results in ranked order. */
export function searchDifferentialRecords(query: string) {
diff --git a/src/lib/document-detail-contract.ts b/src/lib/document-detail-contract.ts
new file mode 100644
index 000000000..7d90e136f
--- /dev/null
+++ b/src/lib/document-detail-contract.ts
@@ -0,0 +1,99 @@
+import type { ClinicalDocument } from "@/lib/types";
+
+export type DocumentAssetScope = "document" | "window";
+
+export type DocumentDetailPage = {
+ id: string;
+ page_number: number;
+ text: string;
+ ocr_used: boolean;
+ metadata?: Record | null;
+};
+
+export type DocumentDetailImage = {
+ id: string;
+ page_number: number | null;
+ caption: string;
+ image_type?: string | null;
+ searchable?: boolean | null;
+ clinical_relevance_score?: number | null;
+ labels?: string[] | null;
+ source_kind?: string | null;
+ tableLabel?: string | null;
+ tableTitle?: string | null;
+ tableRole?: string | null;
+ tableTextSnippet?: string | null;
+ clinicalUseClass?: string | null;
+ clinicalUseReason?: string | null;
+ accessibleTableMarkdown?: string | null;
+ tableRows?: string[][] | null;
+ tableColumns?: string[] | null;
+};
+
+export type DocumentDetailTableFact = {
+ id: string;
+ document_id: string;
+ source_image_id: string | null;
+ page_number: number | null;
+ table_title: string | null;
+ row_label: string | null;
+ clinical_parameter: string | null;
+ threshold_value: string | null;
+ action: string | null;
+ metadata?: Record | null;
+};
+
+export type DocumentDetailChunk = {
+ id: string;
+ page_number: number | null;
+ chunk_index: number;
+ section_heading: string | null;
+ content: string;
+ image_ids: string[];
+ metadata?: Record | null;
+};
+
+export type DocumentDetailIndexHealth = {
+ extractionQuality?: string | null;
+ indexedAt?: string | null;
+ indexVersion?: string | null;
+ warnings?: unknown;
+};
+
+export type DocumentPageWindow = {
+ from: number;
+ to: number;
+ limit: number;
+ total: number | null;
+ hasBefore: boolean;
+ hasAfter: boolean;
+};
+
+export type DocumentChunkWindow = {
+ offset: number;
+ limit: number;
+ total: number | null;
+ hasBefore: boolean;
+ hasAfter: boolean;
+ selectedChunkId: string | null;
+};
+
+export type DocumentDetailPayload = {
+ document: ClinicalDocument;
+ pages: DocumentDetailPage[];
+ images: DocumentDetailImage[];
+ tableFacts: DocumentDetailTableFact[];
+ chunks: DocumentDetailChunk[];
+ indexHealth?: DocumentDetailIndexHealth;
+ demoMode: boolean;
+ assetScope: DocumentAssetScope;
+ window: {
+ requestedPage: number;
+ effectivePage: number;
+ selectedChunkId: string | null;
+ pages: DocumentPageWindow;
+ chunks: DocumentChunkWindow;
+ };
+ pageWindow: DocumentPageWindow;
+ chunkWindow: DocumentChunkWindow;
+};
diff --git a/src/lib/document-detail.ts b/src/lib/document-detail.ts
new file mode 100644
index 000000000..cdc502457
--- /dev/null
+++ b/src/lib/document-detail.ts
@@ -0,0 +1,539 @@
+import "server-only";
+
+import { z } from "zod";
+import { getDemoDocumentPayload } from "@/lib/demo-data";
+import { isDemoMode } from "@/lib/env";
+import { PublicApiError } from "@/lib/http";
+import { callerOwnsDocumentRow, enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
+import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
+import { createAdminClient } from "@/lib/supabase/admin";
+import { AuthenticationError } from "@/lib/supabase/auth";
+import { parseRouteParams } from "@/lib/validation/params";
+import { optionalQueryString, queryInteger } from "@/lib/validation/query";
+import type { ApiRateLimitResult } from "@/lib/api-rate-limit";
+import type { ClinicalDocument } from "@/lib/types";
+import type {
+ DocumentAssetScope,
+ DocumentChunkWindow,
+ DocumentDetailChunk,
+ DocumentDetailImage,
+ DocumentDetailPage,
+ DocumentDetailPayload,
+ DocumentDetailTableFact,
+ DocumentPageWindow,
+} from "@/lib/document-detail-contract";
+
+const defaultPageWindow = 9;
+const maxPageWindow = 40;
+const defaultChunkWindow = 16;
+const maxChunkWindow = 80;
+const selectedChunkNeighborCount = 3;
+const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const documentDetailProjection =
+ "id,owner_id,title,description,file_name,file_type,file_size,storage_path,content_hash,source_path,import_batch_id,status,page_count,chunk_count,image_count,error_message,metadata,created_at,updated_at" as const;
+const tableFactDetailProjection =
+ "id,document_id,source_image_id,page_number,table_title,row_label,clinical_parameter,threshold_value,action,metadata" as const;
+const documentLabelDetailProjection =
+ "id,document_id,owner_id,label,label_type,source,confidence,metadata,created_at,updated_at" as const;
+const documentSummaryDetailProjection =
+ "id,document_id,owner_id,summary,clinical_specifics,source_chunk_ids,source_image_ids,model,generated_at,created_at,updated_at" as const;
+
+const documentRouteParamsSchema = z.object({
+ id: z.string().uuid(),
+});
+
+export const documentDetailQuerySchema = z.object({
+ chunk: optionalQueryString({ maxLength: 80 }),
+ page: queryInteger({ fallback: 1, min: 1, max: 1_000_000 }),
+ pageLimit: queryInteger({ fallback: defaultPageWindow, min: 1, max: maxPageWindow }),
+ chunkLimit: queryInteger({ fallback: defaultChunkWindow, min: 1, max: maxChunkWindow }),
+ chunkOffset: queryInteger({ fallback: 0, min: 0, max: 1_000_000 }),
+ assetScope: z.enum(["document", "window"]).default("document"),
+});
+
+export type DocumentDetailQuery = {
+ chunk?: string;
+ page: number;
+ pageLimit: number;
+ chunkLimit: number;
+ chunkOffset: number;
+ assetScope: "document" | "window";
+};
+
+export class DocumentDetailRateLimitError extends Error {
+ constructor(readonly rateLimit: ApiRateLimitResult) {
+ super("Document requests are rate limited. Try again shortly.");
+ this.name = "DocumentDetailRateLimitError";
+ }
+}
+
+function pageWindowAround(pageNumber: number, limit: number, maxPage?: number | null) {
+ const half = Math.floor(limit / 2);
+ const max = Math.max(1, maxPage ?? Number.MAX_SAFE_INTEGER);
+ const from = Math.max(1, Math.min(pageNumber - half, Math.max(1, max - limit + 1)));
+ const to = Math.min(max, from + limit - 1);
+ return { from, to };
+}
+
+function safeMetadata(value: unknown) {
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {};
+}
+
+function metadataText(metadata: Record, key: string) {
+ const value = metadata[key];
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function compactTableText(value: string | null, limit = 500) {
+ if (!value) return null;
+ const compact = value.replace(/\s+/g, " ").trim();
+ if (!compact) return null;
+ return compact.length > limit ? `${compact.slice(0, limit - 3).trim()}...` : compact;
+}
+
+function metadataStringArrayRows(metadata: Record, key: string) {
+ const value = metadata[key];
+ if (!Array.isArray(value)) return null;
+ const rows = value
+ .filter((row): row is unknown[] => Array.isArray(row))
+ .map((row) => row.map((cell) => String(cell ?? "").trim()));
+ return rows.length ? rows : null;
+}
+
+function metadataStringArray(metadata: Record, key: string) {
+ const value = metadata[key];
+ if (!Array.isArray(value)) return null;
+ const items = value.map((item) => String(item ?? "").trim()).filter(Boolean);
+ return items.length ? items : null;
+}
+
+function withImageTableMetadata(image: T) {
+ const metadata = safeMetadata(image.metadata);
+ const rawTableText = metadataText(metadata, "table_text");
+ const tableText = rawTableText ?? metadataText(metadata, "table_text_snippet");
+ const publicImage = { ...image };
+ delete publicImage.metadata;
+ return {
+ ...publicImage,
+ tableLabel: metadataText(metadata, "table_label"),
+ tableTitle: metadataText(metadata, "table_title"),
+ tableRole: metadataText(metadata, "table_role"),
+ tableTextSnippet: compactTableText(tableText),
+ clinicalUseClass: metadataText(metadata, "clinical_use_class"),
+ clinicalUseReason: metadataText(metadata, "clinical_use_reason"),
+ accessibleTableMarkdown: metadataText(metadata, "accessible_table_markdown") ?? rawTableText,
+ tableRows: metadataStringArrayRows(metadata, "table_rows"),
+ tableColumns: metadataStringArray(metadata, "table_columns"),
+ };
+}
+
+function withoutMetadata(row: T) {
+ const projected = { ...row };
+ delete projected.metadata;
+ return projected;
+}
+
+function withTableFactReviewMetadata(fact: T) {
+ const metadata = safeMetadata(fact.metadata);
+ const projected = withoutMetadata(fact);
+ const reviewClass = metadataText(metadata, "review_class");
+ return reviewClass ? { ...projected, metadata: { review_class: reviewClass } } : projected;
+}
+
+function isHiddenDocumentLabel(label: { metadata?: unknown }) {
+ const metadata = safeMetadata(label.metadata);
+ return metadata.hidden === true || metadata.review_status === "hidden" || metadata.label_review_status === "hidden";
+}
+
+function withDocumentLabelReviewMetadata(label: T) {
+ const metadata = safeMetadata(label.metadata);
+ const projected = withoutMetadata(label);
+ const reviewStatus = metadataText(metadata, "review_status");
+ const legacyReviewStatus = metadataText(metadata, "label_review_status");
+ const reviewMetadata = {
+ ...(reviewStatus ? { review_status: reviewStatus } : {}),
+ ...(legacyReviewStatus ? { label_review_status: legacyReviewStatus } : {}),
+ ...(metadata.hidden === true ? { hidden: true } : {}),
+ };
+ return Object.keys(reviewMetadata).length > 0 ? { ...projected, metadata: reviewMetadata } : projected;
+}
+
+function committedRows(document: { metadata?: unknown }, rows: T[]) {
+ const committedGeneration = committedIndexGeneration(document.metadata);
+ return rows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: row.metadata, committedGeneration }));
+}
+
+function committedGenerationFilter(document: { metadata?: unknown }) {
+ const committedGeneration = committedIndexGeneration(document.metadata);
+ if (!committedGeneration || !uuidPattern.test(committedGeneration)) return null;
+ return `metadata->>index_generation_id.is.null,metadata->>index_generation_id.eq.${committedGeneration}`;
+}
+
+function selectedImageIds(selectedChunk: DocumentDetailChunk | null) {
+ return Array.from(
+ new Set(
+ (selectedChunk?.image_ids ?? []).filter((id): id is string => typeof id === "string" && uuidPattern.test(id)),
+ ),
+ );
+}
+
+function imageWindowFilter(pageWindow: { from: number; to: number }, imageIds: string[]) {
+ const filters = [
+ `and(image_type.neq.logo_decorative,or(searchable.eq.true,source_kind.eq.table_crop),page_number.gte.${pageWindow.from},page_number.lte.${pageWindow.to})`,
+ ];
+ if (imageIds.length > 0) filters.push(`id.in.(${imageIds.join(",")})`);
+ return filters.join(",");
+}
+
+function tableFactWindowFilter(pageWindow: { from: number; to: number }, imageIds: string[]) {
+ const filters = ["page_number.is.null", `and(page_number.gte.${pageWindow.from},page_number.lte.${pageWindow.to})`];
+ if (imageIds.length > 0) filters.push(`source_image_id.in.(${imageIds.join(",")})`);
+ return filters.join(",");
+}
+
+function windowMetadata(args: {
+ requestedPage: number;
+ effectivePage: number;
+ selectedChunk: DocumentDetailChunk | null;
+ pageWindow: { from: number; to: number };
+ pageLimit: number;
+ pageTotal: number | null;
+ chunkRangeStart: number;
+ chunkRangeEnd: number;
+ chunkLimit: number;
+ chunkTotal: number | null;
+}) {
+ const pageWindow: DocumentPageWindow = {
+ from: args.pageWindow.from,
+ to: args.pageWindow.to,
+ limit: args.pageLimit,
+ total: args.pageTotal,
+ hasBefore: args.pageWindow.from > 1,
+ hasAfter: Boolean(args.pageTotal && args.pageWindow.to < args.pageTotal),
+ };
+ const chunkWindow: DocumentChunkWindow = {
+ offset: args.chunkRangeStart,
+ limit: args.selectedChunk ? args.chunkRangeEnd - args.chunkRangeStart + 1 : args.chunkLimit,
+ total: args.chunkTotal,
+ hasBefore: args.chunkRangeStart > 0,
+ hasAfter: Boolean(args.chunkTotal && args.chunkRangeEnd + 1 < args.chunkTotal),
+ selectedChunkId: args.selectedChunk?.id ?? null,
+ };
+ return {
+ pageWindow,
+ chunkWindow,
+ window: {
+ requestedPage: args.requestedPage,
+ effectivePage: args.effectivePage,
+ selectedChunkId: args.selectedChunk?.id ?? null,
+ pages: pageWindow,
+ chunks: chunkWindow,
+ },
+ };
+}
+
+function filterDemoAssets(
+ rows: T[],
+ assetScope: DocumentAssetScope,
+ pageWindow: { from: number; to: number },
+ preserve: (row: T) => boolean,
+ includeGlobal: boolean,
+) {
+ if (assetScope === "document") return rows;
+ return rows.filter(
+ (row) =>
+ (includeGlobal && row.page_number === null) ||
+ (row.page_number !== null && row.page_number >= pageWindow.from && row.page_number <= pageWindow.to) ||
+ preserve(row),
+ );
+}
+
+function loadDemoDocumentDetail(rawId: string, query: DocumentDetailQuery): DocumentDetailPayload {
+ const rawPayload = getDemoDocumentPayload(rawId);
+ if (!rawPayload) throw new PublicApiError("Demo document not found.", 404);
+
+ const payload = rawPayload as unknown as {
+ document: ClinicalDocument;
+ pages: DocumentDetailPage[];
+ images: DocumentDetailImage[];
+ chunks: DocumentDetailChunk[];
+ tableFacts?: DocumentDetailTableFact[];
+ indexHealth?: DocumentDetailPayload["indexHealth"];
+ };
+ const allChunks = payload.chunks ?? [];
+ const selectedChunk = query.chunk ? (allChunks.find((chunk) => chunk.id === query.chunk) ?? null) : null;
+ const requestedPage = Math.min(query.page, Math.max(1, payload.document.page_count ?? 1));
+ const effectivePage = selectedChunk?.page_number ?? requestedPage;
+ const pageRange = pageWindowAround(effectivePage, query.pageLimit, payload.document.page_count);
+ const chunkRangeStart = selectedChunk
+ ? Math.max(0, selectedChunk.chunk_index - selectedChunkNeighborCount)
+ : query.chunkOffset;
+ const chunkRangeEnd = selectedChunk
+ ? selectedChunk.chunk_index + selectedChunkNeighborCount
+ : query.chunkOffset + query.chunkLimit - 1;
+ const chunks = allChunks.filter(
+ (chunk) => chunk.chunk_index >= chunkRangeStart && chunk.chunk_index <= chunkRangeEnd,
+ );
+ const preservedImageIds = new Set(selectedChunk?.image_ids ?? []);
+ const images = filterDemoAssets(
+ payload.images ?? [],
+ query.assetScope,
+ pageRange,
+ (image) => preservedImageIds.has(image.id),
+ false,
+ );
+ const tableFacts = filterDemoAssets(
+ payload.tableFacts ?? [],
+ query.assetScope,
+ pageRange,
+ (fact) => Boolean(fact.source_image_id && preservedImageIds.has(fact.source_image_id)),
+ true,
+ );
+ const metadata = windowMetadata({
+ requestedPage,
+ effectivePage,
+ selectedChunk,
+ pageWindow: pageRange,
+ pageLimit: query.pageLimit,
+ pageTotal: payload.document.page_count ?? null,
+ chunkRangeStart,
+ chunkRangeEnd,
+ chunkLimit: query.chunkLimit,
+ chunkTotal: payload.document.chunk_count ?? allChunks.length,
+ });
+
+ return {
+ document: payload.document,
+ pages: (payload.pages ?? []).filter(
+ (page) => page.page_number >= pageRange.from && page.page_number <= pageRange.to,
+ ),
+ images,
+ tableFacts,
+ chunks,
+ ...(payload.indexHealth ? { indexHealth: payload.indexHealth } : {}),
+ demoMode: true,
+ assetScope: query.assetScope,
+ ...metadata,
+ };
+}
+
+function omitPublicInternalFields(row: Record) {
+ const internalKeys = new Set([
+ "owner_id",
+ "storage_path",
+ "content_hash",
+ "source_path",
+ "import_batch_id",
+ "error_message",
+ "metadata",
+ "source_chunk_ids",
+ "source_image_ids",
+ "model",
+ ]);
+ return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key)));
+}
+
+/**
+ * Loads the minimal authorized document-detail DTO shared by the API route and
+ * the Server Component page. The document authorization check is completed
+ * before any child table is queried.
+ */
+export async function loadAuthorizedDocumentDetail(args: {
+ request: Request;
+ rawId: string;
+ query: DocumentDetailQuery;
+}): Promise {
+ const { rawId, query } = args;
+ args.request.signal.throwIfAborted();
+ if (isDemoMode()) return loadDemoDocumentDetail(rawId, query);
+
+ const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id.");
+ const supabase = createAdminClient();
+ const { access, rateLimit } = await enforceDocumentReadRateLimit(args.request, supabase);
+ if (rateLimit.limited) throw new DocumentDetailRateLimitError(rateLimit);
+ args.request.signal.throwIfAborted();
+
+ const { data: document, error: documentError } = await withOwnerReadScope(
+ supabase.from("documents").select(documentDetailProjection).eq("id", id),
+ access.ownerId,
+ )
+ .abortSignal(args.request.signal)
+ .maybeSingle();
+ if (documentError) throw new Error(documentError.message);
+ if (!document) throw new PublicApiError("Document not found.", 404);
+ args.request.signal.throwIfAborted();
+
+ const isOwner = callerOwnsDocumentRow(document, access.ownerId);
+ let selectedChunk: DocumentDetailChunk | null = null;
+ if (query.chunk) {
+ const { data, error } = await supabase
+ .from("document_chunks")
+ .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata")
+ .eq("document_id", id)
+ .eq("id", query.chunk)
+ .abortSignal(args.request.signal)
+ .maybeSingle();
+ if (error) throw new Error(error.message);
+ if (data && committedRows(document, [data]).length > 0) {
+ selectedChunk = {
+ ...data,
+ image_ids: Array.isArray(data.image_ids) ? data.image_ids : [],
+ } as DocumentDetailChunk;
+ }
+ args.request.signal.throwIfAborted();
+ }
+
+ const requestedPage = Math.min(query.page, Math.max(1, document.page_count ?? 1));
+ const effectivePage = selectedChunk?.page_number ?? requestedPage;
+ const pageRange = pageWindowAround(effectivePage, query.pageLimit, document.page_count);
+ const chunkRangeStart = selectedChunk
+ ? Math.max(0, selectedChunk.chunk_index - selectedChunkNeighborCount)
+ : query.chunkOffset;
+ const chunkRangeEnd = selectedChunk
+ ? selectedChunk.chunk_index + selectedChunkNeighborCount
+ : query.chunkOffset + query.chunkLimit - 1;
+ const preservedImageIds = selectedImageIds(selectedChunk);
+ const generationFilter = committedGenerationFilter(document);
+
+ const pagesRequest = supabase
+ .from("document_pages")
+ .select("id,page_number,text,ocr_used,metadata")
+ .eq("document_id", id)
+ .gte("page_number", pageRange.from)
+ .lte("page_number", pageRange.to)
+ .order("page_number", { ascending: true })
+ .abortSignal(args.request.signal);
+
+ const chunkQuery = supabase
+ .from("document_chunks")
+ .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata")
+ .eq("document_id", id);
+ const filteredChunkQuery = generationFilter ? chunkQuery.or(generationFilter) : chunkQuery;
+ const orderedChunkQuery = filteredChunkQuery.order("chunk_index", { ascending: true });
+ const chunksRequest = (
+ selectedChunk
+ ? orderedChunkQuery.gte("chunk_index", chunkRangeStart).lte("chunk_index", chunkRangeEnd)
+ : orderedChunkQuery.range(chunkRangeStart, chunkRangeEnd)
+ ).abortSignal(args.request.signal);
+
+ let imagesRequest = supabase
+ .from("document_images")
+ .select(
+ "id,page_number,storage_path,caption,bbox,mime_type,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata",
+ )
+ .eq("document_id", id);
+ if (query.assetScope === "window") {
+ imagesRequest = imagesRequest.or(imageWindowFilter(pageRange, preservedImageIds));
+ } else {
+ imagesRequest = imagesRequest
+ .neq("image_type", "logo_decorative")
+ .or("searchable.eq.true,source_kind.eq.table_crop");
+ }
+ const imagesPending = imagesRequest.order("page_number", { ascending: true }).abortSignal(args.request.signal);
+
+ let tableFactsRequest = supabase.from("document_table_facts").select(tableFactDetailProjection).eq("document_id", id);
+ if (generationFilter) {
+ tableFactsRequest = tableFactsRequest.or(generationFilter);
+ }
+ if (query.assetScope === "window") {
+ tableFactsRequest = tableFactsRequest.or(tableFactWindowFilter(pageRange, preservedImageIds));
+ }
+ const tableFactsPending = tableFactsRequest
+ .order("page_number", { ascending: true })
+ .limit(200)
+ .abortSignal(args.request.signal);
+
+ const labelsRequest = supabase
+ .from("document_labels")
+ .select(documentLabelDetailProjection)
+ .eq("document_id", id)
+ .order("confidence", { ascending: false })
+ .abortSignal(args.request.signal);
+ const summaryRequest = supabase
+ .from("document_summaries")
+ .select(documentSummaryDetailProjection)
+ .eq("document_id", id)
+ .abortSignal(args.request.signal)
+ .maybeSingle();
+
+ const [pagesResult, chunksResult, imagesResult, tableFactsResult, labelsResult, summaryResult] = await Promise.all([
+ pagesRequest,
+ chunksRequest,
+ imagesPending,
+ tableFactsPending,
+ labelsRequest,
+ summaryRequest,
+ ]);
+
+ for (const result of [pagesResult, chunksResult, imagesResult, tableFactsResult, labelsResult, summaryResult]) {
+ if (result.error) throw new Error(result.error.message);
+ }
+
+ const publicRows = >(rows: T[]) =>
+ isOwner ? rows : rows.map(omitPublicInternalFields);
+ const labels = (labelsResult.data ?? [])
+ .filter((label) => !isHiddenDocumentLabel(label))
+ .map(withDocumentLabelReviewMetadata);
+ const responseDocument = isOwner
+ ? document
+ : omitPublicInternalFields(document as unknown as Record);
+ const documentMetadata = safeMetadata(document.metadata);
+ const metadata = windowMetadata({
+ requestedPage,
+ effectivePage,
+ selectedChunk,
+ pageWindow: pageRange,
+ pageLimit: query.pageLimit,
+ pageTotal: document.page_count ?? null,
+ chunkRangeStart,
+ chunkRangeEnd,
+ chunkLimit: query.chunkLimit,
+ chunkTotal: document.chunk_count ?? null,
+ });
+
+ return {
+ document: {
+ ...responseDocument,
+ labels: publicRows(labels as Record[]),
+ summary:
+ isOwner || !summaryResult.data
+ ? (summaryResult.data ?? null)
+ : omitPublicInternalFields(summaryResult.data as Record),
+ } as unknown as ClinicalDocument,
+ pages: publicRows(
+ committedRows(document, pagesResult.data ?? []).map(withoutMetadata) as Record[],
+ ) as DocumentDetailPage[],
+ images: publicRows(
+ committedRows(document, imagesResult.data ?? []).map(withImageTableMetadata) as Record[],
+ ) as DocumentDetailImage[],
+ tableFacts: publicRows(
+ committedRows(document, tableFactsResult.data ?? []).map(withTableFactReviewMetadata) as Record<
+ string,
+ unknown
+ >[],
+ ) as DocumentDetailTableFact[],
+ chunks: publicRows(
+ committedRows(document, chunksResult.data ?? []).map(withoutMetadata) as Record[],
+ ) as DocumentDetailChunk[],
+ ...(isOwner
+ ? {
+ indexHealth: {
+ extractionQuality: metadataText(documentMetadata, "extraction_quality"),
+ indexedAt: metadataText(documentMetadata, "indexed_at"),
+ indexVersion: metadataText(documentMetadata, "rag_indexing_version"),
+ warnings: documentMetadata.extraction_warnings ?? [],
+ },
+ }
+ : {}),
+ demoMode: false,
+ assetScope: query.assetScope,
+ ...metadata,
+ };
+}
+
+export function sanitizeDocumentDetailError(error: unknown) {
+ if (error instanceof DocumentDetailRateLimitError) return error.message;
+ if (error instanceof AuthenticationError) return "Sign in to open private source documents.";
+ if (error instanceof PublicApiError) return error.message;
+ return "Document could not be loaded.";
+}
diff --git a/src/lib/document-enrichment.ts b/src/lib/document-enrichment.ts
index 3f7089839..2c06a85c1 100644
--- a/src/lib/document-enrichment.ts
+++ b/src/lib/document-enrichment.ts
@@ -735,26 +735,32 @@ export async function fetchRelatedDocumentMetadata(args: {
ownerId?: string;
accessScope?: RetrievalAccessScope;
documentIds: string[];
+ signal?: AbortSignal;
}) {
const accessScope = retrievalAccessScopeForArgs(args);
- const versionedResult = await args.supabase.rpc("get_related_document_metadata_v2", {
+ const versionedQuery = args.supabase.rpc("get_related_document_metadata_v2", {
document_ids: args.documentIds,
...retrievalRpcScopeArgs(accessScope),
});
+ const versionedResult = await (args.signal ? versionedQuery.abortSignal(args.signal) : versionedQuery);
let rpcData = versionedResult?.data;
let rpcError = versionedResult?.error;
if (!versionedResult || isMissingRetrievalRpcError(versionedResult.error)) {
const ownerFilter = accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL;
- const ownerResult = await args.supabase.rpc("get_related_document_metadata", {
+ const ownerQuery = args.supabase.rpc("get_related_document_metadata", {
document_ids: args.documentIds,
owner_filter: ownerFilter,
});
+ const ownerResult = await (args.signal ? ownerQuery.abortSignal(args.signal) : ownerQuery);
const publicResult =
accessScope.ownerId && accessScope.includePublic
- ? await args.supabase.rpc("get_related_document_metadata", {
- document_ids: args.documentIds,
- owner_filter: PUBLIC_OWNER_FILTER_SENTINEL,
- })
+ ? await (() => {
+ const query = args.supabase.rpc("get_related_document_metadata", {
+ document_ids: args.documentIds,
+ owner_filter: PUBLIC_OWNER_FILTER_SENTINEL,
+ });
+ return args.signal ? query.abortSignal(args.signal) : query;
+ })()
: { data: [], error: null };
rpcError = ownerResult.error ?? publicResult.error;
const merged = new Map();
@@ -792,7 +798,14 @@ export async function fetchRelatedDocumentMetadata(args: {
summariesQuery = summariesQuery.is("owner_id", null);
}
+ if (args.signal) {
+ labelsQuery = labelsQuery.abortSignal(args.signal);
+ summariesQuery = summariesQuery.abortSignal(args.signal);
+ }
+
const [labelsResult, summariesResult] = await Promise.all([labelsQuery, summariesQuery]);
+ const metadataError = labelsResult.error ?? summariesResult.error;
+ if (metadataError) throw metadataError;
const labels = (labelsResult.data ?? []) as DocumentLabel[];
const summaries = (summariesResult.data ?? []) as Pick[];
const labelsByDocument = new Map();
@@ -819,6 +832,8 @@ export async function fetchRelatedDocuments(args: {
query: string;
results: SearchResult[];
limit?: number;
+ includeVisualCounts?: boolean;
+ signal?: AbortSignal;
}) {
const grouped = new Map<
string,
@@ -858,14 +873,19 @@ export async function fetchRelatedDocuments(args: {
const documentIds = Array.from(grouped.keys());
if (documentIds.length === 0) return [];
+ const visualCountsPromise =
+ args.includeVisualCounts === false
+ ? Promise.resolve(new Map())
+ : fetchDocumentVisualCounts(args.supabase, documentIds, args.signal);
const [metadataRows, visualCounts] = await Promise.all([
fetchRelatedDocumentMetadata({
supabase: args.supabase,
ownerId: args.ownerId,
accessScope: args.accessScope,
documentIds,
+ signal: args.signal,
}),
- fetchDocumentVisualCounts(args.supabase, documentIds),
+ visualCountsPromise,
]);
const labelsByDocument = new Map();
const summariesByDocument = new Map();
@@ -903,16 +923,18 @@ export async function fetchRelatedDocuments(args: {
.slice(0, args.limit ?? 6);
}
-export async function fetchDocumentVisualCounts(supabase: SupabaseClient, documentIds: string[]) {
+export async function fetchDocumentVisualCounts(supabase: SupabaseClient, documentIds: string[], signal?: AbortSignal) {
const counts = new Map();
const uniqueIds = Array.from(new Set(documentIds));
if (uniqueIds.length === 0) return counts;
- const { data, error } = await supabase
+ let query = supabase
.from("document_images")
.select("document_id,source_kind,searchable,image_type,clinical_relevance_score,metadata")
.in("document_id", uniqueIds)
.neq("image_type", "logo_decorative");
+ if (signal) query = query.abortSignal(signal);
+ const { data, error } = await query;
if (error) throw new Error(error.message);
diff --git a/src/lib/fixture-response-cache.ts b/src/lib/fixture-response-cache.ts
new file mode 100644
index 000000000..920e8512e
--- /dev/null
+++ b/src/lib/fixture-response-cache.ts
@@ -0,0 +1,45 @@
+const PUBLIC_FIXTURE_CACHE_CONTROL = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400";
+const PRIVATE_CACHE_CONTROL = "private, no-store";
+
+function hasAuthenticationSignal(request: Request) {
+ if (request.headers.get("authorization")?.trim()) return true;
+
+ const cookieHeader = request.headers.get("cookie");
+ if (!cookieHeader) return false;
+
+ return cookieHeader.split(";").some((part) => {
+ const separator = part.indexOf("=");
+ const name = (separator === -1 ? part : part.slice(0, separator)).trim().toLowerCase();
+ return name.startsWith("sb-");
+ });
+}
+
+function addVary(headers: Headers, names: string[]) {
+ const values = (headers.get("Vary") ?? "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ const seen = new Set(values.map((value) => value.toLowerCase()));
+
+ for (const name of names) {
+ if (!seen.has(name.toLowerCase())) {
+ values.push(name);
+ seen.add(name.toLowerCase());
+ }
+ }
+
+ headers.set("Vary", values.join(", "));
+}
+
+export function fixtureResponseHeaders(
+ request: Request | undefined,
+ options: { fixture?: boolean; headers?: HeadersInit } = {},
+) {
+ const headers = new Headers(options.headers);
+ const isPublicFixture = Boolean(request && options.fixture && !hasAuthenticationSignal(request));
+
+ headers.set("Cache-Control", isPublicFixture ? PUBLIC_FIXTURE_CACHE_CONTROL : PRIVATE_CACHE_CONTROL);
+ if (request) addVary(headers, ["Cookie", "Authorization"]);
+
+ return headers;
+}
diff --git a/src/lib/form-catalog.ts b/src/lib/form-catalog.ts
index 6b66a654b..852e4127f 100644
--- a/src/lib/form-catalog.ts
+++ b/src/lib/form-catalog.ts
@@ -1,62 +1,16 @@
import formsCatalog from "../../data/forms-catalog.json";
import formsPdfManifest from "../../data/forms-pdf-manifest.json";
+import type { FormAvailability, FormCatalogDetails } from "@/lib/form-ranker";
import type { ServiceChipTone, ServiceRecord } from "@/lib/services";
+export { formCatalogDetails } from "@/lib/form-ranker";
+export type { FormAvailability, FormCatalogDetails } from "@/lib/form-ranker";
+
export const officialFormsRegisterUrl =
"https://www.chiefpsychiatrist.wa.gov.au/laws-and-rights/legislation/mental-health-act-2014-forms/";
export const officialFormsReviewedDate = "17 July 2026";
-export type FormAvailability = "downloadable" | "unavailable" | "contact_ocp";
-
-export type FormCatalogDetails = {
- id: string;
- form: string;
- name: string;
- category: string;
- purpose: string;
- maker: string;
- involved: string;
- threshold: string;
- clock: string;
- destination: string;
- authorises: string;
- doesNotAuthorise: string;
- before: string[];
- parallel: string[];
- after: string[];
- copies: string;
- documentationStem: string;
- traps: string[];
- safetyPearl: string;
- sourceNote: string;
- aliases: string[];
- searchTerms: string[];
- riskLevel: "high" | "medium" | "low";
- indexedClock?: string;
- indexedTerms?: string[];
- legalNote: string;
- practicePearls: string[];
- preUseChecks: string[];
- sourceFacts?: {
- documentTitle?: string;
- fileName?: string;
- pages?: number;
- timings?: string[];
- sectionCue?: string;
- indexedAt?: string;
- };
- availability: FormAvailability;
- officialPdfUrl?: string;
- officialRegisterUrl: string;
- localPdfPath?: string;
- localPdfSha256?: string;
- localPdfBytes?: number;
- officialPdfPasswordProtected?: boolean;
- officialTitleCheckedAt: string;
- archiveGeneratedAt?: string;
-};
-
type OfficialForm = {
code: string;
title: string;
@@ -473,11 +427,3 @@ export function loadFormCatalogDetails(): FormCatalogDetails[] {
export function mapFormCatalogToRecords(): ServiceRecord[] {
return loadFormCatalogDetails().map(toFormRecord);
}
-
-export function formCatalogDetails(record: ServiceRecord): FormCatalogDetails | null {
- const payload = record.catalogPayload;
- if (!payload || typeof payload !== "object") return null;
- const candidate = payload as Partial;
- if (!candidate.form || !candidate.name || !candidate.category || !candidate.availability) return null;
- return candidate as FormCatalogDetails;
-}
diff --git a/src/lib/form-ranker.ts b/src/lib/form-ranker.ts
new file mode 100644
index 000000000..0100e53aa
--- /dev/null
+++ b/src/lib/form-ranker.ts
@@ -0,0 +1,171 @@
+import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
+import type { ServiceRecord, ServiceSearchMatch } from "@/lib/service-ranker";
+
+export type FormRecord = ServiceRecord;
+export type FormSearchMatch = ServiceSearchMatch;
+
+export type FormAvailability = "downloadable" | "unavailable" | "contact_ocp";
+
+export type FormCatalogDetails = {
+ id: string;
+ form: string;
+ name: string;
+ category: string;
+ purpose: string;
+ maker: string;
+ involved: string;
+ threshold: string;
+ clock: string;
+ destination: string;
+ authorises: string;
+ doesNotAuthorise: string;
+ before: string[];
+ parallel: string[];
+ after: string[];
+ copies: string;
+ documentationStem: string;
+ traps: string[];
+ safetyPearl: string;
+ sourceNote: string;
+ aliases: string[];
+ searchTerms: string[];
+ riskLevel: "high" | "medium" | "low";
+ indexedClock?: string;
+ indexedTerms?: string[];
+ legalNote: string;
+ practicePearls: string[];
+ preUseChecks: string[];
+ sourceFacts?: {
+ documentTitle?: string;
+ fileName?: string;
+ pages?: number;
+ timings?: string[];
+ sectionCue?: string;
+ indexedAt?: string;
+ };
+ availability: FormAvailability;
+ officialPdfUrl?: string;
+ officialRegisterUrl: string;
+ localPdfPath?: string;
+ localPdfSha256?: string;
+ localPdfBytes?: number;
+ officialPdfPasswordProtected?: boolean;
+ officialTitleCheckedAt: string;
+ archiveGeneratedAt?: string;
+};
+
+export function formCatalogDetails(record: FormRecord): FormCatalogDetails | null {
+ const payload = record.catalogPayload;
+ if (!payload || typeof payload !== "object") return null;
+ const candidate = payload as Partial;
+ if (!candidate.form || !candidate.name || !candidate.category || !candidate.availability) return null;
+ return candidate as FormCatalogDetails;
+}
+
+export function formRecordSearchText(form: FormRecord) {
+ const details = formCatalogDetails(form);
+ const values = [
+ form.title,
+ form.slug,
+ form.subtitle,
+ form.route,
+ form.eligibility,
+ form.cost,
+ form.referral,
+ form.location,
+ form.bestUse,
+ form.catalogueLabel,
+ form.navigatorQuery,
+ form.primaryContact?.value,
+ form.primaryContact?.detail,
+ form.source?.label,
+ form.source?.status,
+ form.source?.reviewed,
+ ...(form.tags ?? []),
+ ...(form.catchments ?? []),
+ ...(form.statusChips ?? []).flatMap((chip) => [chip.label]),
+ ...(form.contacts ?? []).flatMap((contact) => [contact.label, contact.value, contact.detail]),
+ ...(form.summaryCards ?? []).flatMap((card) => [card.label, card.title, card.detail]),
+ ...(form.referralInfo ?? []).flatMap((row) => [row.label, row.value]),
+ ...(form.criteria ?? []).flatMap((criterion) => [criterion.label, criterion.tone]),
+ ...(form.verification?.notes ?? []),
+ details?.form,
+ details?.category,
+ details?.purpose,
+ details?.maker,
+ details?.threshold,
+ details?.clock,
+ details?.authorises,
+ details?.doesNotAuthorise,
+ ...(details?.aliases ?? []),
+ ...(details?.searchTerms ?? []),
+ ...(details?.indexedTerms ?? []),
+ "form",
+ "forms",
+ "checklist",
+ "assessment",
+ "transfer",
+ "template",
+ ].filter((value): value is string => Boolean(value?.trim()));
+
+ return normalizeSearchText(values.join(" "));
+}
+
+export function formNavigatorQuery(form: FormRecord) {
+ return (
+ [form.navigatorQuery, form.title, form.primaryContact?.value, form.subtitle, form.slug].find((value) =>
+ value?.trim(),
+ ) ?? form.slug
+ );
+}
+
+export function rankFormRecords(
+ records: FormRecord[],
+ query: string,
+ limit = records.length,
+ // Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane.
+ expansions: string[] = [],
+): FormSearchMatch[] {
+ const normalizedQuery = normalizeSearchText(query);
+ if (!normalizedQuery) return [];
+ // A bare "service(s)" query belongs to the services catalogue, not forms.
+ if (/^services?$/.test(normalizedQuery)) return [];
+
+ return rankCatalogRecords(records, query, {
+ fields: [
+ { id: "title", weight: 6, text: (form) => normalizeSearchText(`${form.title} ${form.slug}`) },
+ { id: "contact", weight: 5, text: (form) => normalizeSearchText(form.primaryContact?.value ?? "") },
+ ],
+ fullText: formRecordSearchText,
+ contentWeight: 2,
+ compactBonus: 5,
+ phraseBonus: 4,
+ broadTerms: [
+ "form",
+ "forms",
+ "checklist",
+ "transport",
+ "transfer",
+ "extension",
+ "detention",
+ "movement",
+ "examination",
+ "template",
+ "assessment",
+ ],
+ broadBonus: 1,
+ expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined,
+ limit,
+ // No tieBreak: forms historically tie-break by catalogue (input) order, which is the
+ // generic ranker's default.
+ }).map(({ record, score, signals }) => ({
+ service: record,
+ score,
+ reasons: [
+ signals.fields.title ? "title" : "",
+ signals.fields.contact || signals.compact ? "contact" : "",
+ signals.content ? "record fields" : "",
+ signals.broad ? "psychiatry forms catalogue" : "",
+ ].filter(Boolean),
+ }));
+}
diff --git a/src/lib/forms.ts b/src/lib/forms.ts
index 6e6c37e6b..1bab4e3c0 100644
--- a/src/lib/forms.ts
+++ b/src/lib/forms.ts
@@ -1,61 +1,10 @@
-import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
-import { formCatalogDetails, mapFormCatalogToRecords } from "@/lib/form-catalog";
-import type { ServiceRecord, ServiceSearchMatch } from "@/lib/services";
+import { mapFormCatalogToRecords } from "@/lib/form-catalog";
+import { rankFormRecords, type FormRecord, type FormSearchMatch } from "@/lib/form-ranker";
-export type FormRecord = ServiceRecord;
-export type FormSearchMatch = ServiceSearchMatch;
+export * from "@/lib/form-ranker";
export const formRecords: FormRecord[] = mapFormCatalogToRecords();
-export function formRecordSearchText(form: FormRecord) {
- const details = formCatalogDetails(form);
- const values = [
- form.title,
- form.slug,
- form.subtitle,
- form.route,
- form.eligibility,
- form.cost,
- form.referral,
- form.location,
- form.bestUse,
- form.catalogueLabel,
- form.navigatorQuery,
- form.primaryContact?.value,
- form.primaryContact?.detail,
- form.source?.label,
- form.source?.status,
- form.source?.reviewed,
- ...(form.tags ?? []),
- ...(form.catchments ?? []),
- ...(form.statusChips ?? []).flatMap((chip) => [chip.label]),
- ...(form.contacts ?? []).flatMap((contact) => [contact.label, contact.value, contact.detail]),
- ...(form.summaryCards ?? []).flatMap((card) => [card.label, card.title, card.detail]),
- ...(form.referralInfo ?? []).flatMap((row) => [row.label, row.value]),
- ...(form.criteria ?? []).flatMap((criterion) => [criterion.label, criterion.tone]),
- ...(form.verification?.notes ?? []),
- details?.form,
- details?.category,
- details?.purpose,
- details?.maker,
- details?.threshold,
- details?.clock,
- details?.authorises,
- details?.doesNotAuthorise,
- ...(details?.aliases ?? []),
- ...(details?.searchTerms ?? []),
- ...(details?.indexedTerms ?? []),
- "form",
- "forms",
- "checklist",
- "assessment",
- "transfer",
- "template",
- ].filter((value): value is string => Boolean(value?.trim()));
-
- return normalizeSearchText(values.join(" "));
-}
-
function normalizeSlug(value: string) {
return value.trim().toLowerCase();
}
@@ -73,65 +22,6 @@ export function defaultFormSlug() {
return formRecords[0]?.slug ?? null;
}
-export function formNavigatorQuery(form: FormRecord) {
- return (
- [form.navigatorQuery, form.title, form.primaryContact?.value, form.subtitle, form.slug].find((value) =>
- value?.trim(),
- ) ?? form.slug
- );
-}
-
-export function rankFormRecords(
- records: FormRecord[],
- query: string,
- limit = records.length,
- // Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane.
- expansions: string[] = [],
-): FormSearchMatch[] {
- const normalizedQuery = normalizeSearchText(query);
- if (!normalizedQuery) return [];
- // A bare "service(s)" query belongs to the services catalogue, not forms.
- if (/^services?$/.test(normalizedQuery)) return [];
-
- return rankCatalogRecords(records, query, {
- fields: [
- { id: "title", weight: 6, text: (form) => normalizeSearchText(`${form.title} ${form.slug}`) },
- { id: "contact", weight: 5, text: (form) => normalizeSearchText(form.primaryContact?.value ?? "") },
- ],
- fullText: formRecordSearchText,
- contentWeight: 2,
- compactBonus: 5,
- phraseBonus: 4,
- broadTerms: [
- "form",
- "forms",
- "checklist",
- "transport",
- "transfer",
- "extension",
- "detention",
- "movement",
- "examination",
- "template",
- "assessment",
- ],
- broadBonus: 1,
- expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined,
- limit,
- // No tieBreak: forms historically tie-break by catalogue (input) order, which is the
- // generic ranker's default.
- }).map(({ record, score, signals }) => ({
- service: record,
- score,
- reasons: [
- signals.fields.title ? "title" : "",
- signals.fields.contact || signals.compact ? "contact" : "",
- signals.content ? "record fields" : "",
- signals.broad ? "psychiatry forms catalogue" : "",
- ].filter(Boolean),
- }));
-}
-
export function searchFormRecords(query: string, limit = formRecords.length): FormSearchMatch[] {
return rankFormRecords(formRecords, query, limit);
}
diff --git a/src/lib/http.ts b/src/lib/http.ts
index 652981f52..82ee6613c 100644
--- a/src/lib/http.ts
+++ b/src/lib/http.ts
@@ -68,7 +68,7 @@ export function jsonError(error: unknown, status = 500) {
code,
...(requestId ? { requestId } : {}),
},
- { status: responseStatus },
+ { status: responseStatus, headers: { "Cache-Control": "private, no-store" } },
);
}
diff --git a/src/lib/medication-seed.ts b/src/lib/medication-seed.ts
index fcac4ee32..239264fe9 100644
--- a/src/lib/medication-seed.ts
+++ b/src/lib/medication-seed.ts
@@ -1,5 +1,6 @@
import { buildDefaultMedicationRows, defaultMedicationRecords } from "@/lib/medication-fixtures";
import { type MedicationRecordInsert, type MedicationRecordRow } from "@/lib/medication-records";
+import { invalidateOwnerCatalogueCache } from "@/lib/owner-catalogue-cache";
import { safeErrorLogDetails } from "@/lib/privacy";
type AdminClient = ReturnType;
@@ -8,20 +9,36 @@ function loadRegistryCorpus() {
return import("@/lib/registry-corpus");
}
+type OwnerMedicationFetchOptions = {
+ signal?: AbortSignal;
+ select?: string;
+};
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (!signal?.aborted) return;
+ throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
export function buildMedicationSeedRows(ownerId: string): MedicationRecordInsert[] {
return buildDefaultMedicationRows(ownerId);
}
-export async function ensureMedicationsSeeded(supabase: AdminClient, ownerId: string): Promise {
+export async function ensureMedicationsSeeded(
+ supabase: AdminClient,
+ ownerId: string,
+ options: Pick = {},
+): Promise {
const rows = buildMedicationSeedRows(ownerId);
- const { data, error } = await supabase
- .from("medication_records")
- .upsert(rows, { onConflict: "owner_id,slug" })
- .select("*");
+ let query = supabase.from("medication_records").upsert(rows, { onConflict: "owner_id,slug" }).select("*");
+ if (options.signal) query = query.abortSignal(options.signal);
+ const { data, error } = await query;
if (error) throw new Error(`Medication seed failed: ${error.message}`);
+ invalidateOwnerCatalogueCache({ ownerId, kind: "medication", preserveSignal: options.signal });
+ throwIfAborted(options.signal);
const seededRows = (data ?? []) as MedicationRecordRow[];
const { bestEffortSyncMedicationRows } = await loadRegistryCorpus();
await bestEffortSyncMedicationRows(supabase, seededRows);
+ throwIfAborted(options.signal);
return seededRows;
}
@@ -36,23 +53,29 @@ export async function fetchOwnerMedicationRowsWithSeed(
supabase: AdminClient,
ownerId: string,
maxRecords = 500,
+ options: OwnerMedicationFetchOptions = {},
): Promise {
const fetchRecords = async () => {
- const { data, error } = await supabase
+ let query = supabase
.from("medication_records")
- .select("*")
+ .select(options.select ?? "*")
.eq("owner_id", ownerId)
.order("name")
.limit(maxRecords);
+ if (options.signal) query = query.abortSignal(options.signal);
+ const { data, error } = await query;
if (error) throw new Error(error.message);
- return (data ?? []) as MedicationRecordRow[];
+ throwIfAborted(options.signal);
+ // The optional projection is intentionally narrower than the generated
+ // table Row type; callers map only the fields they requested.
+ return (data ?? []) as unknown as MedicationRecordRow[];
};
let rows = await fetchRecords();
if (rows.length === 0) {
let seedError: unknown = null;
try {
- await ensureMedicationsSeeded(supabase, ownerId);
+ await ensureMedicationsSeeded(supabase, ownerId, { signal: options.signal });
} catch (error) {
seedError = error;
console.error("[medications] auto-seed failed", safeErrorLogDetails(error));
diff --git a/src/lib/owner-catalogue-cache.ts b/src/lib/owner-catalogue-cache.ts
new file mode 100644
index 000000000..20cd5c97d
--- /dev/null
+++ b/src/lib/owner-catalogue-cache.ts
@@ -0,0 +1,209 @@
+export type OwnerCatalogueKind = "medication" | "service" | "form";
+
+type OwnerCatalogueEntry = {
+ ownerId: string;
+ kind: OwnerCatalogueKind;
+ limit: number;
+ expiresAt: number;
+ value: unknown[];
+};
+
+type OwnerCatalogueFlight = {
+ ownerId: string;
+ kind: OwnerCatalogueKind;
+ limit: number;
+ controller: AbortController;
+ callers: number;
+ settled: boolean;
+ promise: Promise;
+};
+
+const ownerCatalogueTtlMs = 5_000;
+const ownerCatalogueMaxEntries = 128;
+const ownerCatalogueCache = new Map();
+const ownerCatalogueFlights = new Map();
+
+function ownerCatalogueKey(ownerId: string, kind: OwnerCatalogueKind, limit: number) {
+ return JSON.stringify([ownerId, kind, limit]);
+}
+
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (signal?.aborted) throw abortReason(signal);
+}
+
+function readOwnerCatalogue(key: string, now: number): T[] | undefined {
+ const entry = ownerCatalogueCache.get(key);
+ if (!entry) return undefined;
+ if (entry.expiresAt <= now) {
+ ownerCatalogueCache.delete(key);
+ return undefined;
+ }
+
+ // LRU recency bump. The cached catalogue is independent of the search query, so every
+ // typeahead prefix for the same owner/kind/limit can reuse this successful snapshot.
+ ownerCatalogueCache.delete(key);
+ ownerCatalogueCache.set(key, entry);
+ return entry.value as T[];
+}
+
+function writeOwnerCatalogue(args: {
+ key: string;
+ ownerId: string;
+ kind: OwnerCatalogueKind;
+ limit: number;
+ value: T[];
+ now: number;
+}) {
+ ownerCatalogueCache.delete(args.key);
+ ownerCatalogueCache.set(args.key, {
+ ownerId: args.ownerId,
+ kind: args.kind,
+ limit: args.limit,
+ value: args.value,
+ expiresAt: args.now + ownerCatalogueTtlMs,
+ });
+ while (ownerCatalogueCache.size > ownerCatalogueMaxEntries) {
+ const oldest = ownerCatalogueCache.keys().next().value;
+ if (oldest === undefined) break;
+ ownerCatalogueCache.delete(oldest);
+ }
+}
+
+function startOwnerCatalogueFlight(args: {
+ key: string;
+ ownerId: string;
+ kind: OwnerCatalogueKind;
+ limit: number;
+ load: (signal: AbortSignal) => Promise;
+}) {
+ const controller = new AbortController();
+ let removeAbortListener: () => void = () => undefined;
+ const aborted = new Promise((_resolve, reject) => {
+ const onAbort = () => reject(abortReason(controller.signal));
+ controller.signal.addEventListener("abort", onAbort, { once: true });
+ removeAbortListener = () => controller.signal.removeEventListener("abort", onAbort);
+ });
+ const load = Promise.resolve().then(() => args.load(controller.signal));
+ const flight: OwnerCatalogueFlight = {
+ ownerId: args.ownerId,
+ kind: args.kind,
+ limit: args.limit,
+ controller,
+ callers: 0,
+ settled: false,
+ // Replaced synchronously below before the flight is published.
+ promise: Promise.resolve([]),
+ };
+ flight.promise = Promise.race([load, aborted])
+ .then((value) => {
+ // A successful load is cacheable only while at least one non-aborted
+ // caller still wants it. If every caller left, the shared controller is
+ // aborted and even a loader that ignored its signal cannot populate the cache.
+ if (controller.signal.aborted || flight.callers === 0) throw abortReason(controller.signal);
+ writeOwnerCatalogue({ ...args, value, now: Date.now() });
+ return value;
+ })
+ .finally(() => {
+ removeAbortListener();
+ flight.settled = true;
+ if (ownerCatalogueFlights.get(args.key) === flight) ownerCatalogueFlights.delete(args.key);
+ });
+ ownerCatalogueFlights.set(args.key, flight);
+ return flight;
+}
+
+function joinOwnerCatalogueFlight(flight: OwnerCatalogueFlight, signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ flight.callers += 1;
+
+ return new Promise((resolve, reject) => {
+ let callerSettled = false;
+ const finish = (complete: () => void) => {
+ if (callerSettled) return;
+ callerSettled = true;
+ signal?.removeEventListener("abort", onAbort);
+ flight.callers = Math.max(0, flight.callers - 1);
+ if (flight.callers === 0 && !flight.settled) {
+ flight.controller.abort(new DOMException("No active catalogue callers.", "AbortError"));
+ }
+ complete();
+ };
+ const onAbort = () =>
+ finish(() => reject(signal ? abortReason(signal) : new DOMException("Aborted", "AbortError")));
+
+ signal?.addEventListener("abort", onAbort, { once: true });
+ if (signal?.aborted) {
+ onAbort();
+ return;
+ }
+
+ flight.promise.then(
+ (value) => {
+ if (signal?.aborted) {
+ onAbort();
+ return;
+ }
+ finish(() => resolve(value as T[]));
+ },
+ (error: unknown) => finish(() => reject(error)),
+ );
+ });
+}
+
+/**
+ * Cache only completed owner-catalogue values. Pending promises, rejected loads, and values
+ * produced after cancellation are deliberately never inserted.
+ */
+export async function loadOwnerCatalogue(args: {
+ ownerId: string;
+ kind: OwnerCatalogueKind;
+ limit: number;
+ signal?: AbortSignal;
+ load: (signal: AbortSignal) => Promise;
+}): Promise {
+ throwIfAborted(args.signal);
+ const key = ownerCatalogueKey(args.ownerId, args.kind, args.limit);
+ const cached = readOwnerCatalogue(key, Date.now());
+ if (cached) return cached;
+
+ let flight = ownerCatalogueFlights.get(key);
+ if (flight?.controller.signal.aborted) {
+ if (ownerCatalogueFlights.get(key) === flight) ownerCatalogueFlights.delete(key);
+ flight = undefined;
+ }
+ flight ??= startOwnerCatalogueFlight({ ...args, key });
+ return joinOwnerCatalogueFlight(flight, args.signal);
+}
+
+/** Remove every cached limit for the catalogue that was mutated. */
+export function invalidateOwnerCatalogueCache(args: {
+ ownerId: string;
+ kind?: OwnerCatalogueKind;
+ preserveSignal?: AbortSignal;
+}) {
+ for (const [key, entry] of ownerCatalogueCache) {
+ if (entry.ownerId === args.ownerId && (!args.kind || entry.kind === args.kind)) {
+ ownerCatalogueCache.delete(key);
+ }
+ }
+ for (const [key, flight] of ownerCatalogueFlights) {
+ if (flight.ownerId === args.ownerId && (!args.kind || flight.kind === args.kind)) {
+ if (flight.controller.signal === args.preserveSignal) continue;
+ ownerCatalogueFlights.delete(key);
+ flight.controller.abort(new DOMException("Owner catalogue invalidated.", "AbortError"));
+ }
+ }
+}
+
+/** Test/process-lifecycle helper; production invalidation should stay owner/kind scoped. */
+export function clearOwnerCatalogueCache() {
+ ownerCatalogueCache.clear();
+ for (const flight of ownerCatalogueFlights.values()) {
+ flight.controller.abort(new DOMException("Owner catalogue cache cleared.", "AbortError"));
+ }
+ ownerCatalogueFlights.clear();
+}
diff --git a/src/lib/rag-cache.ts b/src/lib/rag-cache.ts
index 90ee32774..a32ceae06 100644
--- a/src/lib/rag-cache.ts
+++ b/src/lib/rag-cache.ts
@@ -31,6 +31,14 @@ const cacheIndexingVersionTtlMs = 5000;
const cacheIndexingVersionMaxEntries = 512;
const cacheIndexingVersionCache = new Map();
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (signal?.aborted) throw abortReason(signal);
+}
+
function documentScopeKey(args: Pick) {
const scope = args.documentIds?.length
? [...args.documentIds].sort().join(",")
@@ -264,6 +272,7 @@ export async function getCachedSearch(
queryClass?: RagQueryClass,
queryVariants: string[] = [],
): Promise<{ results: SearchResult[]; telemetry: SearchTelemetry } | null> {
+ throwIfAborted(args.signal);
if (!isSearchCacheEnabled(args)) return null;
const key = scopedSearchCacheKey(args, queryClass, queryVariants);
@@ -274,6 +283,7 @@ export async function getCachedSearch(
return null;
}
const indexingVersion = await cacheIndexingVersion(args);
+ throwIfAborted(args.signal);
if (cached.indexingVersion !== indexingVersion) {
searchCache.delete(key);
return null;
@@ -303,10 +313,12 @@ export async function setCachedSearch(
queryVariants: string[] = [],
options?: { indexingVersionAtRetrievalStart?: string | null },
): Promise {
+ throwIfAborted(args.signal);
if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return;
const cacheTelemetry = normalizeCacheStorageTelemetry(telemetry);
const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true });
+ throwIfAborted(args.signal);
if (options?.indexingVersionAtRetrievalStart && indexingVersion !== options.indexingVersionAtRetrievalStart) return;
const key = scopedSearchCacheKey(args, telemetry.query_class, queryVariants);
searchCache.set(key, {
@@ -333,7 +345,7 @@ function sharedCacheSelector(
kind: SharedCacheKind,
args: Pick<
SearchChunksArgs,
- "query" | "documentId" | "documentIds" | "ownerId" | "accessScope" | "queryMode" | "forceEmbedding"
+ "query" | "documentId" | "documentIds" | "ownerId" | "accessScope" | "queryMode" | "forceEmbedding" | "signal"
>,
indexingVersion: string,
normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)),
@@ -354,9 +366,10 @@ function sharedCacheSelector(
}
export async function cacheIndexingVersion(
- args: Pick,
+ args: Pick,
options?: { forceRefresh?: boolean },
) {
+ throwIfAborted(args.signal);
const cacheKey = cacheIndexingVersionCacheKey(args);
if (options?.forceRefresh) cacheIndexingVersionCache.delete(cacheKey);
const cached = readExpiringCacheEntry(cacheIndexingVersionCache, cacheKey);
@@ -381,7 +394,9 @@ export async function cacheIndexingVersion(
query = query.is("owner_id", null);
}
if (documentFilters?.length) query = query.in("id", documentFilters);
+ if (args.signal) query = query.abortSignal(args.signal);
const { data, error } = await query;
+ throwIfAborted(args.signal);
if (error || !data?.length) {
value = `${ragDeepMemoryVersion}:no-indexed-documents`;
} else {
@@ -395,8 +410,10 @@ export async function cacheIndexingVersion(
value = `${ragDeepMemoryVersion}:${latest.id ?? "all"}:${indexedAt}:${generationId}`;
}
} catch {
+ if (args.signal?.aborted) throw abortReason(args.signal);
value = `${ragDeepMemoryVersion}:index-stamp-unavailable`;
}
+ throwIfAborted(args.signal);
writeBoundedExpiringCacheEntry(
cacheIndexingVersionCache,
cacheKey,
@@ -415,17 +432,15 @@ export async function getSharedCachedSearch(
| { kind: "miss"; reason: SharedCacheMissReason }
| null
> {
+ throwIfAborted(args.signal);
if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0) return null;
const normalizedQuery = retrievalPlanCacheQuery(args, queryClass, queryVariants);
const indexingVersion = await cacheIndexingVersion(args);
try {
- const { data, error } = await sharedCacheSelector(
- createAdminClient(),
- "search",
- args,
- indexingVersion,
- normalizedQuery,
- ).maybeSingle();
+ let query = sharedCacheSelector(createAdminClient(), "search", args, indexingVersion, normalizedQuery);
+ if (args.signal) query = query.abortSignal(args.signal);
+ const { data, error } = await query.maybeSingle();
+ throwIfAborted(args.signal);
if (error) return { kind: "miss", reason: "cache_lookup_error" };
// The selector deliberately folds TTL, dependency, and indexing-version
// validity into the hit query. Keep a filtered miss coarse rather than pay
@@ -482,6 +497,7 @@ export async function getSharedCachedSearch(
},
};
} catch {
+ if (args.signal?.aborted) throw abortReason(args.signal);
return { kind: "miss", reason: "cache_lookup_exception" };
}
}
@@ -526,7 +542,7 @@ async function replaceSharedCacheRow(
kind: SharedCacheKind,
args: Pick<
SearchChunksArgs,
- "query" | "documentId" | "documentIds" | "ownerId" | "accessScope" | "queryMode" | "forceEmbedding"
+ "query" | "documentId" | "documentIds" | "ownerId" | "accessScope" | "queryMode" | "forceEmbedding" | "signal"
>,
payload: unknown,
ttlMs: number,
@@ -534,6 +550,7 @@ async function replaceSharedCacheRow(
) {
if (ttlMs <= 0) return;
try {
+ if (args.signal?.aborted) return;
const supabase = createAdminClient();
const indexingVersion = await cacheIndexingVersion(args);
let deleteQuery = supabase
@@ -545,8 +562,10 @@ async function replaceSharedCacheRow(
.eq("indexing_version", indexingVersion)
.eq("dependency_version", ragCacheDependencyVersion);
deleteQuery = args.ownerId ? deleteQuery.eq("owner_id", args.ownerId) : deleteQuery.is("owner_id", null);
+ if (args.signal) deleteQuery = deleteQuery.abortSignal(args.signal);
await deleteQuery;
- await supabase.from("rag_response_cache").insert({
+ if (args.signal?.aborted) return;
+ let insertQuery = supabase.from("rag_response_cache").insert({
owner_id: args.ownerId ?? null,
cache_kind: kind,
scope_key: scopeKey(args),
@@ -557,6 +576,8 @@ async function replaceSharedCacheRow(
payload: payload as Json,
expires_at: new Date(Date.now() + ttlMs).toISOString(),
});
+ if (args.signal) insertQuery = insertQuery.abortSignal(args.signal);
+ await insertQuery;
} catch {
// Shared cache must never be part of the correctness path.
}
@@ -654,9 +675,12 @@ function invalidateAnonymousSharedRagCaches() {
})();
}
-export function invalidateRagCachesForDocumentMutation(ownerId: string) {
+export function invalidateRagCachesForDocumentMutation(
+ ownerId: string,
+ options: { affectsPublicCorpus?: boolean } = {},
+) {
invalidateRagCachesForOwner(ownerId);
- invalidateAnonymousSharedRagCaches();
+ if (options.affectsPublicCorpus !== false) invalidateAnonymousSharedRagCaches();
}
function sourceContextPackLimit(queryClass: RagQueryClass, options: { crossDocument?: boolean } = {}) {
diff --git a/src/lib/rag-candidate-sources.ts b/src/lib/rag-candidate-sources.ts
index b62be11a5..cdb236174 100644
--- a/src/lib/rag-candidate-sources.ts
+++ b/src/lib/rag-candidate-sources.ts
@@ -45,6 +45,26 @@ import type { DocumentIndexUnitMatch, DocumentMemoryCard, SearchResult } from "@
// where telemetry is in scope, record the failing RPC + code so it shows up in rag_retrieval_logs.
export type SupabaseRpcError = { message?: string; code?: string; details?: string; hint?: string } | null;
+type AbortableQuery = PromiseLike & {
+ abortSignal?: (signal: AbortSignal) => PromiseLike;
+};
+
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (signal?.aborted) throw abortReason(signal);
+}
+
+async function resolveQuery(query: AbortableQuery, signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ const pending = signal && typeof query.abortSignal === "function" ? query.abortSignal(signal) : query;
+ const result = await pending;
+ throwIfAborted(signal);
+ return result;
+}
+
function legacyRankFields(versionedName: string) {
if (versionedName === "match_document_chunks_v2") return ["similarity"];
if (versionedName === "match_documents_for_query_v2") return ["text_rank"];
@@ -81,15 +101,17 @@ export async function callVersionedRetrievalRpc
versionedName: string,
legacyName: string,
args: Record,
+ signal?: AbortSignal,
): Promise<{ data: T | null; error: SupabaseRpcError }> {
+ type RpcResult = { data: T | null; error: SupabaseRpcError };
const client = supabase as unknown as {
- rpc: (name: string, rpcArgs: Record) => Promise<{ data: T | null; error: SupabaseRpcError }>;
+ rpc: (name: string, rpcArgs: Record) => AbortableQuery;
};
- const versioned = await client.rpc(versionedName, args);
+ const versioned = await resolveQuery(client.rpc(versionedName, args), signal);
if (versioned && !isMissingRetrievalRpcError(versioned.error)) return versioned;
const legacyArgs = { ...args };
delete legacyArgs.include_public;
- const ownerResult = await client.rpc(legacyName, legacyArgs);
+ const ownerResult = await resolveQuery(client.rpc(legacyName, legacyArgs), signal);
const ownerFilter = String(args.owner_filter ?? "");
if (
ownerResult.error ||
@@ -99,10 +121,13 @@ export async function callVersionedRetrievalRpc
) {
return ownerResult;
}
- const publicResult = await client.rpc(legacyName, {
- ...legacyArgs,
- owner_filter: PUBLIC_OWNER_FILTER_SENTINEL,
- });
+ const publicResult = await resolveQuery(
+ client.rpc(legacyName, {
+ ...legacyArgs,
+ owner_filter: PUBLIC_OWNER_FILTER_SENTINEL,
+ }),
+ signal,
+ );
if (publicResult.error) return publicResult;
return {
data: mergeLegacyAccessRows(
@@ -179,6 +204,7 @@ export async function searchTextChunkCandidates(args: {
allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
+ signal?: AbortSignal;
}) {
const runChunkText = async (queryText: string, matchCount: number) => {
const accessScope = retrievalAccessScopeForArgs(args);
@@ -192,6 +218,7 @@ export async function searchTextChunkCandidates(args: {
document_filters: args.documentIds ?? undefined,
...retrievalRpcScopeArgs(accessScope),
},
+ args.signal,
);
// Report the error before returning empty so a schema drift on this
// most-terminal lexical layer surfaces in hybrid_rpc_errors telemetry
@@ -248,10 +275,13 @@ export async function searchTextChunkCandidates(args: {
const primary = variants[0] ?? "";
let effectivePrimary = primary;
if (primary) {
- const { data: corrected } = await args.supabase.rpc("correct_clinical_query_terms", {
- input_query: primary,
- min_sim: 0.45,
- });
+ const { data: corrected } = await resolveQuery(
+ args.supabase.rpc("correct_clinical_query_terms", {
+ input_query: primary,
+ min_sim: 0.45,
+ }),
+ args.signal,
+ );
if (typeof corrected === "string" && corrected && corrected !== primary) {
const correctedResults = await runChunkText(corrected, args.matchCount);
if (correctedResults.length > 0) return correctedResults;
@@ -376,6 +406,7 @@ async function fetchBestDocumentLookupChunks(args: {
ownerId?: string;
accessScope?: RetrievalAccessScope;
allowGlobalSearch?: boolean;
+ signal?: AbortSignal;
}) {
const terms = documentLookupChunkTerms(args.query);
const { data: rpcChunks, error: rpcError } = await callVersionedRetrievalRpc(
@@ -388,6 +419,7 @@ async function fetchBestDocumentLookupChunks(args: {
match_count: Math.max(args.limit * 3, 24),
...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)),
},
+ args.signal,
);
if (!rpcError && rpcChunks?.length) {
const ranked = (rpcChunks as DocumentLookupChunkRow[])
@@ -418,9 +450,8 @@ async function fetchBestDocumentLookupChunks(args: {
.in("document_id", args.documentIds)
.limit(Math.max(args.limit * 4, 24));
- const { data: matchedChunks, error: matchedError } = safeFilters
- ? await baseQuery.or(safeFilters)
- : await baseQuery.order("chunk_index", { ascending: true });
+ const matchedQuery = safeFilters ? baseQuery.or(safeFilters) : baseQuery.order("chunk_index", { ascending: true });
+ const { data: matchedChunks, error: matchedError } = await resolveQuery(matchedQuery, args.signal);
if (!matchedError && matchedChunks?.length) {
const ranked = (matchedChunks as DocumentLookupChunkRow[])
@@ -439,7 +470,7 @@ async function fetchBestDocumentLookupChunks(args: {
.in("document_id", args.documentIds)
.order("chunk_index", { ascending: true })
.limit(args.limit);
- const { data: fallbackChunks, error: fallbackError } = await fallbackQuery;
+ const { data: fallbackChunks, error: fallbackError } = await resolveQuery(fallbackQuery, args.signal);
if (fallbackError || !fallbackChunks?.length) return { chunks: [] as DocumentLookupChunkRow[], terms };
return { chunks: fallbackChunks as DocumentLookupChunkRow[], terms };
}
@@ -451,6 +482,7 @@ async function fetchDocumentTitleAliasRows(args: {
ownerId?: string;
accessScope?: RetrievalAccessScope;
documentIds?: string[];
+ signal?: AbortSignal;
}) {
const terms = analyzeClinicalQuery(args.query)
.documentTitleTerms.map((term) => term.replace(/[%_,]/g, " ").replace(/\s+/g, " ").trim())
@@ -474,7 +506,7 @@ async function fetchDocumentTitleAliasRows(args: {
}
if (args.documentIds?.length) query = query.in("id", args.documentIds);
- const { data, error } = await query;
+ const { data, error } = await resolveQuery(query, args.signal);
if (error || !data?.length) return [] as DocumentLookupRow[];
return (data as DocumentLookupRow[]).map((document) => ({
@@ -499,6 +531,7 @@ export async function searchDocumentLookupFastPath(args: {
documentIds?: string[];
matchCount: number;
telemetry?: SearchTelemetry;
+ signal?: AbortSignal;
}): Promise {
if (!args.ownerId) return [] as SearchResult[];
const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice(
@@ -515,6 +548,7 @@ export async function searchDocumentLookupFastPath(args: {
match_count: matchCount,
...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)),
},
+ args.signal,
);
if (error) recordHybridRpcError(args.telemetry, "match_documents_for_query", error);
if (error || !data?.length) return [] as DocumentLookupRow[];
@@ -533,6 +567,7 @@ export async function searchDocumentLookupFastPath(args: {
ownerId: args.ownerId,
accessScope: args.accessScope,
documentIds: args.documentIds,
+ signal: args.signal,
});
const documentsById = new Map();
for (const document of [...titleAliasDocuments, ...documentSets.flat()]) {
@@ -566,6 +601,7 @@ export async function searchDocumentLookupFastPath(args: {
limit: Math.max(args.matchCount, rankedDocuments.length * 4),
ownerId: args.ownerId,
accessScope: args.accessScope,
+ signal: args.signal,
});
if (!chunks.length) return [];
@@ -621,6 +657,7 @@ export async function loadChunksForMemoryCards(
supabase: ReturnType,
cards: DocumentMemoryCard[],
accessScope: RetrievalAccessScope,
+ signal?: AbortSignal,
) {
const documentIds = Array.from(new Set(cards.map((card) => card.document_id))).slice(0, 80);
if (documentIds.length === 0) return [] as SearchResult[];
@@ -636,7 +673,7 @@ export async function loadChunksForMemoryCards(
} else {
documentQuery = documentQuery.is("owner_id", null);
}
- const { data: documents, error: documentsError } = await documentQuery;
+ const { data: documents, error: documentsError } = await resolveQuery(documentQuery, signal);
if (documentsError || !documents?.length) return [] as SearchResult[];
const documentById = new Map(documents.map((document) => [document.id, document]));
@@ -647,7 +684,7 @@ export async function loadChunksForMemoryCards(
),
).slice(0, 80);
if (chunkIds.length === 0) return [] as SearchResult[];
- const { data: chunks, error: chunksError } = await supabase
+ const chunksQuery = supabase
.from("document_chunks")
.select(
"id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids,index_generation_id",
@@ -655,6 +692,7 @@ export async function loadChunksForMemoryCards(
.in("id", chunkIds)
.in("document_id", [...allowedDocumentIds])
.limit(chunkIds.length);
+ const { data: chunks, error: chunksError } = await resolveQuery(chunksQuery, signal);
if (chunksError || !chunks?.length) return [] as SearchResult[];
const bestCardByChunk = new Map();
for (const card of cards) {
@@ -705,6 +743,7 @@ export async function loadChunksForSignalMatches(args: {
matches: ChunkSignalMatch[];
ownerId?: string;
accessScope?: RetrievalAccessScope;
+ signal?: AbortSignal;
}) {
const bestMatchByChunk = new Map();
for (const match of args.matches) {
@@ -715,11 +754,12 @@ export async function loadChunksForSignalMatches(args: {
if (chunkIds.length === 0) return [] as SearchResult[];
const accessScope = retrievalAccessScopeForArgs(args);
- const { data: chunkScopes, error: chunkScopesError } = await args.supabase
+ const chunkScopesQuery = args.supabase
.from("document_chunks")
.select("id,document_id")
.in("id", chunkIds)
.limit(chunkIds.length);
+ const { data: chunkScopes, error: chunkScopesError } = await resolveQuery(chunkScopesQuery, args.signal);
if (chunkScopesError || !chunkScopes?.length) return [] as SearchResult[];
const documentIds = Array.from(new Set(chunkScopes.map((chunk) => chunk.document_id)));
@@ -735,7 +775,7 @@ export async function loadChunksForSignalMatches(args: {
} else {
documentQuery = documentQuery.is("owner_id", null);
}
- const { data: documents, error: documentsError } = await documentQuery;
+ const { data: documents, error: documentsError } = await resolveQuery(documentQuery, args.signal);
if (documentsError || !documents?.length) return [] as SearchResult[];
const documentById = new Map(documents.map((document) => [document.id, document]));
const allowedDocumentIds = new Set(documentById.keys());
@@ -743,7 +783,7 @@ export async function loadChunksForSignalMatches(args: {
.filter((chunk) => allowedDocumentIds.has(chunk.document_id))
.map((chunk) => chunk.id);
if (allowedChunkIds.length === 0) return [] as SearchResult[];
- const { data: chunks, error: chunksError } = await args.supabase
+ const chunksQuery = args.supabase
.from("document_chunks")
.select(
"id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids,index_generation_id",
@@ -751,6 +791,7 @@ export async function loadChunksForSignalMatches(args: {
.in("id", allowedChunkIds)
.in("document_id", [...allowedDocumentIds])
.limit(allowedChunkIds.length);
+ const { data: chunks, error: chunksError } = await resolveQuery(chunksQuery, args.signal);
if (chunksError || !chunks?.length) return [] as SearchResult[];
return chunks
@@ -820,6 +861,7 @@ export async function searchTableFactCandidates(args: {
allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
+ signal?: AbortSignal;
}) {
const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice(
0,
@@ -836,6 +878,7 @@ export async function searchTableFactCandidates(args: {
document_filters: args.documentIds ?? undefined,
...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)),
},
+ args.signal,
);
if (error) recordHybridRpcError(args.telemetry, "match_document_table_facts_text", error);
if (error || !data?.length) return [] as TableFactRpcRow[];
@@ -876,6 +919,7 @@ export async function searchTableFactCandidates(args: {
matches: Array.from(grouped.values()),
ownerId: args.ownerId,
accessScope: args.accessScope,
+ signal: args.signal,
});
}
@@ -890,6 +934,7 @@ export async function searchEmbeddingFieldCandidates(args: {
allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
+ signal?: AbortSignal;
}) {
const { data, error } = await callVersionedRetrievalRpc(
args.supabase,
@@ -903,6 +948,7 @@ export async function searchEmbeddingFieldCandidates(args: {
document_filters: args.documentIds ?? undefined,
...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)),
},
+ args.signal,
);
if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error);
if (error || !data?.length) return [] as SearchResult[];
@@ -939,6 +985,7 @@ export async function searchEmbeddingFieldCandidates(args: {
matches,
ownerId: args.ownerId,
accessScope: args.accessScope,
+ signal: args.signal,
});
}
@@ -953,6 +1000,7 @@ export async function searchIndexUnitCandidates(args: {
allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
+ signal?: AbortSignal;
}) {
const { data, error } = await callVersionedRetrievalRpc(
args.supabase,
@@ -966,6 +1014,7 @@ export async function searchIndexUnitCandidates(args: {
document_filters: args.documentIds ?? undefined,
...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)),
},
+ args.signal,
);
if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error);
if (error || !data?.length) return [] as SearchResult[];
@@ -1004,6 +1053,7 @@ export async function searchIndexUnitCandidates(args: {
matches,
ownerId: args.ownerId,
accessScope: args.accessScope,
+ signal: args.signal,
});
}
@@ -1020,6 +1070,7 @@ export async function withMemoryBoostedCandidates(args: {
documentIds?: string[];
matchCount: number;
cardCache?: MemoryCardCache;
+ signal?: AbortSignal;
}) {
// A3: the memory-card fetch is invoked at several waterfall stages. Memoize per request,
// scoped by owner/document filters because fetchMemoryCardsForQuery applies those filters.
@@ -1042,13 +1093,19 @@ export async function withMemoryBoostedCandidates(args: {
accessScope: args.accessScope,
documentIds: args.documentIds,
matchCount: effectiveMatchCount,
+ signal: args.signal,
});
args.cardCache?.set(cacheKey, cardsPromise);
}
const cards = await cardsPromise;
if (cards.length === 0) return { results: args.candidates, cards };
- const memoryChunkResults = await loadChunksForMemoryCards(args.supabase, cards, retrievalAccessScopeForArgs(args));
+ const memoryChunkResults = await loadChunksForMemoryCards(
+ args.supabase,
+ cards,
+ retrievalAccessScopeForArgs(args),
+ args.signal,
+ );
const merged = mergeSearchResults(memoryChunkResults, args.candidates);
return {
results: applyMemoryCardBoosts(args.query, merged, cards),
diff --git a/src/lib/rag-retrieval-variants.ts b/src/lib/rag-retrieval-variants.ts
index 564665266..ea37c693e 100644
--- a/src/lib/rag-retrieval-variants.ts
+++ b/src/lib/rag-retrieval-variants.ts
@@ -19,6 +19,14 @@ const maxRagAliasCacheEntries = 256;
const maxRagAliasesPerScope = 200;
const maxRagAliasExpansions = 12;
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (signal?.aborted) throw abortReason(signal);
+}
+
/** Text candidate budget for query class. */
export function textCandidateBudgetForQueryClass(queryClass: RagQueryClass | undefined, topK: number) {
if (queryClass === "comparison") return Math.max(topK * 7, 72);
@@ -109,7 +117,9 @@ export async function fetchEnabledRagAliases(
supabase: ReturnType,
ownerId?: string,
accessScope?: RetrievalAccessScope,
+ signal?: AbortSignal,
): Promise {
+ throwIfAborted(signal);
const scope = retrievalAccessScopeForArgs({ ownerId, accessScope });
const cacheKey = retrievalAccessScopeKey(scope);
const cached = readExpiringCacheEntry(ragAliasCache, cacheKey);
@@ -124,7 +134,9 @@ export async function fetchEnabledRagAliases(
.order("weight", { ascending: false })
.limit(maxRagAliasesPerScope);
query = scopeOwnerId ? query.eq("owner_id", scopeOwnerId) : query.is("owner_id", null);
+ if (signal) query = query.abortSignal(signal);
const { data, error } = await query;
+ throwIfAborted(signal);
if (error) throw error;
return (data ?? []) as RagAliasInput[];
}
@@ -143,6 +155,7 @@ export async function fetchEnabledRagAliases(
merged.push(alias);
if (merged.length >= maxRagAliasesPerScope) break;
}
+ throwIfAborted(signal);
writeBoundedExpiringCacheEntry(
ragAliasCache,
cacheKey,
@@ -151,6 +164,7 @@ export async function fetchEnabledRagAliases(
);
return merged;
} catch {
+ if (signal?.aborted) throw abortReason(signal);
// Do not cache an empty result on a transient rag_aliases read failure: caching [] would suppress
// alias-based query expansion (and could let an alias-rescuable query short-circuit) for the whole
// TTL. Return empty for this call only and retry on the next call.
diff --git a/src/lib/rag.ts b/src/lib/rag.ts
index a2942d577..bb4b396ad 100644
--- a/src/lib/rag.ts
+++ b/src/lib/rag.ts
@@ -416,9 +416,30 @@ const confidenceOrder = {
} as const;
/** Throw if aborted. */
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) {
- throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
+ throw abortReason(signal);
+ }
+}
+
+async function awaitWithAbortSignal(pending: Promise, signal?: AbortSignal): Promise {
+ if (!signal) return pending;
+ throwIfAborted(signal);
+
+ let onAbort: (() => void) | undefined;
+ const aborted = new Promise((_resolve, reject) => {
+ onAbort = () => reject(abortReason(signal));
+ signal.addEventListener("abort", onAbort, { once: true });
+ if (signal.aborted) onAbort();
+ });
+ try {
+ return await Promise.race([pending, aborted]);
+ } finally {
+ if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
@@ -1289,6 +1310,8 @@ export async function analyzeQueryWithClassifierFallback(
// owner_filter retrieval will use so grounding can never see documents retrieval cannot.
corpusGrounding?: { supabase: ReturnType; ownerFilter: string | null };
ownerId?: string | null;
+ signal?: AbortSignal;
+ skipClassifier?: boolean;
},
) {
if (
@@ -1317,6 +1340,7 @@ export async function analyzeQueryWithClassifierFallback(
supabase: opts.corpusGrounding.supabase,
query,
ownerFilter: opts.corpusGrounding.ownerFilter,
+ signal: opts.signal,
});
if (grounding.verdict === "in_corpus_topic") {
return {
@@ -1342,7 +1366,8 @@ export async function analyzeQueryWithClassifierFallback(
analysis = { ...analysis, corpusGrounding: "inconclusive" };
}
- if (!analysis.needsClassifierFallback || !env.OPENAI_API_KEY) return analysis;
+ if (!analysis.needsClassifierFallback || !env.OPENAI_API_KEY || opts?.skipClassifier) return analysis;
+ throwIfAborted(opts?.signal);
const memoKey = classifierVerdictMemoKey(query, analysis);
const memoized = classifierVerdictMemo.get(memoKey);
@@ -1360,10 +1385,11 @@ export async function analyzeQueryWithClassifierFallback(
}
try {
- const verdict = await pending;
+ const verdict = await awaitWithAbortSignal(pending, opts?.signal);
storeClassifierVerdictMemo(memoKey, verdict);
return applyClassifierVerdict(analysis, verdict);
} catch {
+ if (opts?.signal?.aborted) throw abortReason(opts.signal);
// Transport/parse failures are deliberately NOT memoized: fall back to the deterministic
// analysis for this request only, and let the next request retry the classifier.
return analysis;
@@ -1512,6 +1538,7 @@ export async function attachDocumentRankingMetadata(
results: SearchResult[],
ownerId?: string,
cache = createDocumentRankingMetadataCache(),
+ signal?: AbortSignal,
) {
const documentIds = Array.from(new Set(results.map((result) => result.document_id)));
if (documentIds.length === 0) return results;
@@ -1541,7 +1568,7 @@ export async function attachDocumentRankingMetadata(
document_summary: metadata.summary,
};
});
- return attachIndexQualityMetadata(supabase, enriched, ownerId, cache);
+ return attachIndexQualityMetadata(supabase, enriched, ownerId, cache, signal);
}
const [metadataRows, indexedResults] = await Promise.all([
@@ -1549,8 +1576,12 @@ export async function attachDocumentRankingMetadata(
supabase,
ownerId,
documentIds: missingDocumentIds,
- }).catch(() => null),
- attachIndexQualityMetadata(supabase, results, ownerId, cache),
+ signal,
+ }).catch(() => {
+ if (signal?.aborted) throw abortReason(signal);
+ return null;
+ }),
+ attachIndexQualityMetadata(supabase, results, ownerId, cache, signal),
]);
if (!metadataRows) return indexedResults;
@@ -1587,6 +1618,7 @@ async function attachIndexQualityMetadata(
results: SearchResult[],
ownerId?: string,
cache = createDocumentRankingMetadataCache(),
+ signal?: AbortSignal,
): Promise {
const documentIds = Array.from(new Set(results.map((result) => result.document_id)));
if (documentIds.length === 0) return results;
@@ -1598,12 +1630,15 @@ async function attachIndexQualityMetadata(
.select("document_id,owner_id,quality_score,extraction_quality,metrics,issues,updated_at")
.in("document_id", missingDocumentIds);
if (ownerId) query = query.eq("owner_id", ownerId);
+ if (signal) query = query.abortSignal(signal);
const { data, error } = await query;
+ throwIfAborted(signal);
if (error) return results;
for (const documentId of missingDocumentIds) cache.indexQuality.set(documentId, null);
for (const row of data ?? []) cache.indexQuality.set(row.document_id, row as SearchResult["indexing_quality"]);
return withCachedIndexQuality(results, cache);
} catch {
+ if (signal?.aborted) throw abortReason(signal);
return results;
}
}
@@ -1612,6 +1647,7 @@ async function attachIndexQualityMetadata(
export async function attachPageVisualEvidence(
supabase: ReturnType,
results: SearchResult[],
+ signal?: AbortSignal,
): Promise {
const documentIds = Array.from(new Set(results.map((result) => result.document_id)));
const pageNumbers = Array.from(
@@ -1631,7 +1667,7 @@ export async function attachPageVisualEvidence(
const selectColumns =
"id,document_id,page_number,storage_path,caption,bbox,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata";
- const [pageData, directData] = await Promise.all([
+ const pageQuery =
pageNumbers.length > 0
? supabase
.from("document_images")
@@ -1642,7 +1678,8 @@ export async function attachPageVisualEvidence(
.neq("image_type", "logo_decorative")
.order("clinical_relevance_score", { ascending: false })
.limit(80)
- : Promise.resolve({ data: [], error: null }),
+ : null;
+ const directQuery =
sourceImageIds.length > 0
? supabase
.from("document_images")
@@ -1651,8 +1688,20 @@ export async function attachPageVisualEvidence(
.eq("searchable", true)
.neq("image_type", "logo_decorative")
.limit(sourceImageIds.length)
+ : null;
+ const [pageData, directData] = await Promise.all([
+ pageQuery
+ ? signal && typeof pageQuery.abortSignal === "function"
+ ? pageQuery.abortSignal(signal)
+ : pageQuery
+ : Promise.resolve({ data: [], error: null }),
+ directQuery
+ ? signal && typeof directQuery.abortSignal === "function"
+ ? directQuery.abortSignal(signal)
+ : directQuery
: Promise.resolve({ data: [], error: null }),
]);
+ throwIfAborted(signal);
const data = [...(pageData.data ?? []), ...(directData.data ?? [])];
if ((pageData.error && directData.error) || data.length === 0) return results;
@@ -2224,6 +2273,8 @@ async function prepareCoverageGateResults(args: {
queryClass: RagQueryClass;
telemetry: SearchTelemetry;
metadataCache: DocumentRankingMetadataCache;
+ includeVisualEvidence?: boolean;
+ signal?: AbortSignal;
}) {
const startedAt = Date.now();
const candidates = await attachDocumentRankingMetadata(
@@ -2231,18 +2282,20 @@ async function prepareCoverageGateResults(args: {
args.candidates,
args.ownerId,
args.metadataCache,
+ args.signal,
);
- let results = await attachPageVisualEvidence(
- args.supabase,
- selectRankedRetrievalResults({
- query: args.query,
- queryClass: args.queryClass,
- candidates,
- topK: args.topK,
- maxResultsPerDocument: args.maxResultsPerDocument,
- telemetry: args.telemetry,
- }),
- );
+ const rankedResults = selectRankedRetrievalResults({
+ query: args.query,
+ queryClass: args.queryClass,
+ candidates,
+ topK: args.topK,
+ maxResultsPerDocument: args.maxResultsPerDocument,
+ telemetry: args.telemetry,
+ });
+ let results =
+ args.includeVisualEvidence === false
+ ? rankedResults
+ : await attachPageVisualEvidence(args.supabase, rankedResults, args.signal);
results = applySecondStageRerankIfNeeded({
queryClass: args.queryClass,
results,
@@ -2361,6 +2414,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
}
const indexingVersionAtRetrievalStart = await cacheIndexingVersion(args, { forceRefresh: true });
const supabase = createAdminClient();
+ const attachSearchVisualEvidence = (results: SearchResult[]) =>
+ args.lexicalOnly ? Promise.resolve(results) : attachPageVisualEvidence(supabase, results, args.signal);
// When the provider is source-only (offline mode, or auto mode without a usable key) we must
// never call OpenAI for embeddings; retrieval falls back to the lexical text-fast-path only.
const sourceOnlyRetrieval = isSourceOnlyMode();
@@ -2396,6 +2451,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
const queryAnalysis = await analyzeQueryWithClassifierFallback(retrievalQuery, analyzeClinicalQuery(retrievalQuery), {
corpusGrounding: corpusGroundingScope,
ownerId: args.ownerId,
+ signal: args.signal,
+ skipClassifier: args.lexicalOnly,
});
throwIfAborted(args.signal);
if (modeQueryClass) queryAnalysis.queryClass = modeQueryClass;
@@ -2407,7 +2464,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
const telemetry = createSearchTelemetry(retrievalQuery, queryClassification.queryClass);
if (queryAnalysis.corpusGrounding) telemetry.corpus_grounding = queryAnalysis.corpusGrounding;
- const ragAliases = await fetchEnabledRagAliases(supabase, args.ownerId, args.accessScope);
+ const ragAliases = await fetchEnabledRagAliases(supabase, args.ownerId, args.accessScope, args.signal);
const ragAliasExpansions = selectRagAliasExpansions(retrievalQuery, ragAliases);
telemetry.rag_alias_count = ragAliases.length;
telemetry.rag_alias_expansion_count = ragAliasExpansions.length;
@@ -2447,10 +2504,13 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
// in searchTextChunkCandidates). Only reached for would-be-unsupported queries, so it adds no
// hot-path cost; `typoCorrected` guards against recursion.
if (!args.typoCorrected && !sourceOnlyRetrieval) {
- const { data: corrected } = await supabase.rpc("correct_clinical_query_terms", {
+ let correctionQuery = supabase.rpc("correct_clinical_query_terms", {
input_query: retrievalQuery,
min_sim: 0.45,
});
+ if (args.signal) correctionQuery = correctionQuery.abortSignal(args.signal);
+ const { data: corrected } = await correctionQuery;
+ throwIfAborted(args.signal);
if (typeof corrected === "string" && corrected && corrected.toLowerCase() !== retrievalQuery.toLowerCase()) {
return searchChunksWithTelemetry({ ...args, query: corrected, typoCorrected: true });
}
@@ -2485,6 +2545,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
allowGlobalSearch: args.allowGlobalSearch,
matchCount: textCandidateCount,
telemetry,
+ signal: args.signal,
});
telemetry.text_candidate_count = textData.length;
telemetry.text_fast_path_latency_ms = Date.now() - textRpcStartedAt;
@@ -2502,6 +2563,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
textData as SearchResult[],
args.ownerId,
documentRankingMetadataCache,
+ args.signal,
);
expandedQuery = expandClinicalQueryWithCandidateMetadata(args.query, expandedQuery, textCandidates);
const baseTextResults = selectRankedRetrievalResults({
@@ -2515,7 +2577,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
const baseTextFastPath = decideTextFastPath(args.query, baseTextResults, queryClassification.queryClass);
if (!args.forceEmbedding && shouldReturnBeforeMemory(queryClassification.queryClass, baseTextFastPath)) {
- textFastResults = await attachPageVisualEvidence(supabase, baseTextResults);
+ textFastResults = await attachSearchVisualEvidence(baseTextResults);
textFastResults = applySecondStageRerankIfNeeded({
queryClass: queryClassification.queryClass,
results: textFastResults,
@@ -2539,6 +2601,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
documentIds: documentFilterList,
matchCount: candidateCount,
cardCache: memoryCardCache,
+ signal: args.signal,
});
telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length);
telemetry.memory_top_score = Math.max(
@@ -2556,7 +2619,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
maxResultsPerDocument,
telemetry,
});
- textFastResults = await attachPageVisualEvidence(supabase, textFastResults);
+ textFastResults = await attachSearchVisualEvidence(textFastResults);
textFastResults = applySecondStageRerankIfNeeded({
queryClass: queryClassification.queryClass,
results: textFastResults,
@@ -2590,6 +2653,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),
telemetry,
+ signal: args.signal,
});
const tableFactLatencyMs = Date.now() - tableFactStartedAt;
telemetry.supabase_rpc_latency_ms += tableFactLatencyMs;
@@ -2613,6 +2677,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
documentIds: documentFilterList,
matchCount: candidateCount,
telemetry,
+ signal: args.signal,
});
const documentLookupLatencyMs = Date.now() - documentLookupStartedAt;
telemetry.supabase_rpc_latency_ms += documentLookupLatencyMs;
@@ -2628,6 +2693,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
mergeSearchResults(documentLookupData, textFastResults),
args.ownerId,
documentRankingMetadataCache,
+ args.signal,
);
expandedQuery = expandClinicalQueryWithCandidateMetadata(args.query, expandedQuery, documentLookupCandidates);
const memoryBoost = await withMemoryBoostedCandidates({
@@ -2639,6 +2705,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
documentIds: documentFilterList,
matchCount: candidateCount,
cardCache: memoryCardCache,
+ signal: args.signal,
});
telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length);
telemetry.memory_top_score = Math.max(
@@ -2653,8 +2720,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
topScore: Math.max(telemetry.memory_top_score ?? 0, ...memoryBoost.cards.map(memoryCardChunkScore)),
},
);
- let documentLookupResults = await attachPageVisualEvidence(
- supabase,
+ let documentLookupResults = await attachSearchVisualEvidence(
selectRankedRetrievalResults({
query: retrievalQuery,
queryClass: queryClassification.queryClass,
@@ -2704,6 +2770,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryClass: queryClassification.queryClass,
telemetry,
metadataCache: documentRankingMetadataCache,
+ includeVisualEvidence: !args.lexicalOnly,
+ signal: args.signal,
});
const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass);
applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted);
@@ -2776,6 +2844,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),
telemetry,
+ signal: args.signal,
});
return { candidates, latencyMs: Date.now() - startedAt };
})(),
@@ -2791,6 +2860,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 64),
telemetry,
+ signal: args.signal,
});
return { candidates, latencyMs: Date.now() - startedAt };
})(),
@@ -2808,6 +2878,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
document_filters: documentFilterList ?? undefined,
...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)),
},
+ args.signal,
);
return { data, error, latencyMs: Date.now() - startedAt };
})(),
@@ -2858,6 +2929,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
merged,
args.ownerId,
documentRankingMetadataCache,
+ args.signal,
);
const memoryBoost = await withMemoryBoostedCandidates({
supabase,
@@ -2869,6 +2941,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
documentIds: documentFilterList,
matchCount: candidateCount,
cardCache: memoryCardCache,
+ signal: args.signal,
});
telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length);
telemetry.memory_top_score = Math.max(
@@ -2885,6 +2958,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
maxResultsPerDocument,
telemetry,
}),
+ args.signal,
);
results = applySecondStageRerankIfNeeded({
queryClass: queryClassification.queryClass,
@@ -2915,6 +2989,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
document_filter: documentFilter ?? undefined,
...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)),
},
+ args.signal,
);
if (error) throw new Error(error.message);
@@ -2942,6 +3017,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults),
args.ownerId,
documentRankingMetadataCache,
+ args.signal,
);
const memoryBoost = await withMemoryBoostedCandidates({
supabase,
@@ -2953,6 +3029,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
documentIds: documentFilterList,
matchCount: candidateCount,
cardCache: memoryCardCache,
+ signal: args.signal,
});
telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length);
telemetry.memory_top_score = Math.max(
@@ -2969,6 +3046,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
maxResultsPerDocument,
telemetry,
}),
+ args.signal,
);
results = applySecondStageRerankIfNeeded({
queryClass: queryClassification.queryClass,
diff --git a/src/lib/registry-seed.ts b/src/lib/registry-seed.ts
index ea7e1e840..7c684f00c 100644
--- a/src/lib/registry-seed.ts
+++ b/src/lib/registry-seed.ts
@@ -1,4 +1,5 @@
import { formRecords } from "@/lib/forms";
+import { invalidateOwnerCatalogueCache } from "@/lib/owner-catalogue-cache";
import { buildDefaultFormRows, buildDefaultServiceRows, defaultServiceRecords } from "@/lib/registry-fixtures";
import {
deriveGovernanceColumns,
@@ -21,6 +22,16 @@ function loadRegistryCorpus() {
return import("@/lib/registry-corpus");
}
+type OwnerRegistryFetchOptions = {
+ signal?: AbortSignal;
+ select?: string;
+};
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (!signal?.aborted) return;
+ throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
/** The curated shared registry fixtures for a kind — the same baseline the CLI
* can materialize and every API caller receives automatically. */
export function defaultRegistryRecords(kind: RegistryRecordKind) {
@@ -146,16 +157,22 @@ export async function ensureRegistrySeeded(
supabase: AdminClient,
ownerId: string,
kind: RegistryRecordKind,
+ options: Pick = {},
): Promise {
const rows = buildDefaultRegistryRows(ownerId, kind);
- const { data, error } = await supabase
+ let query = supabase
.from("clinical_registry_records")
.upsert(rows, { onConflict: "owner_id,kind,slug", ignoreDuplicates: true })
.select("*");
+ if (options.signal) query = query.abortSignal(options.signal);
+ const { data, error } = await query;
if (error) throw new Error(`Registry seed failed: ${error.message}`);
+ invalidateOwnerCatalogueCache({ ownerId, kind, preserveSignal: options.signal });
+ throwIfAborted(options.signal);
const seededRows = (data ?? []) as RegistryRecordRow[];
const { bestEffortSyncClinicalRegistryRows } = await loadRegistryCorpus();
await bestEffortSyncClinicalRegistryRows(supabase, seededRows);
+ throwIfAborted(options.signal);
return seededRows;
}
@@ -169,14 +186,20 @@ export async function fetchOwnerRegistryRows(
ownerId: string,
kind: RegistryRecordKind,
maxRecords = 500,
+ options: OwnerRegistryFetchOptions = {},
): Promise {
- const { data, error } = await supabase
+ let query = supabase
.from("clinical_registry_records")
- .select("*")
+ .select(options.select ?? "*")
.eq("owner_id", ownerId)
.eq("kind", kind)
.order("title")
.limit(maxRecords);
+ if (options.signal) query = query.abortSignal(options.signal);
+ const { data, error } = await query;
if (error) throw new Error(error.message);
- return (data ?? []) as RegistryRecordRow[];
+ throwIfAborted(options.signal);
+ // The optional projection is intentionally narrower than the generated
+ // table Row type; callers map only the fields they requested.
+ return (data ?? []) as unknown as RegistryRecordRow[];
}
diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts
index 771e334ec..3e2762a1d 100644
--- a/src/lib/search-command-surface.ts
+++ b/src/lib/search-command-surface.ts
@@ -1,6 +1,5 @@
import type { AppModeId } from "@/lib/app-modes";
-import type { ServiceRecord } from "@/lib/services";
-import { serviceRecordSearchText } from "@/lib/services";
+import { serviceRecordSearchText, type ServiceRecord } from "@/lib/service-ranker";
export type CommandScopeChip = {
id: string;
diff --git a/src/lib/service-ranker.ts b/src/lib/service-ranker.ts
new file mode 100644
index 000000000..b67912e51
--- /dev/null
+++ b/src/lib/service-ranker.ts
@@ -0,0 +1,165 @@
+import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
+
+export type ServiceChipTone = "danger" | "info" | "warning" | "success" | "neutral";
+export type ServiceCriterionTone = "meet" | "caution" | "reject";
+
+export type ServiceStatusChip = {
+ label?: string | null;
+ tone?: ServiceChipTone | null;
+};
+
+export type ServiceContact = {
+ label: string;
+ value?: string | null;
+ detail?: string | null;
+ kind: "phone" | "email" | "web" | "text" | "unknown";
+};
+
+export type ServiceSummaryCard = {
+ id: string;
+ label?: string | null;
+ title?: string | null;
+ detail?: string | null;
+};
+
+export type ServiceInfoRow = {
+ label: string;
+ value?: string | null;
+};
+
+export type ServiceCriterion = {
+ label: string;
+ tone: ServiceCriterionTone;
+};
+
+export type ServiceVerification = {
+ locallyVerified?: boolean | null;
+ confidence?: "High" | "Medium" | "Low" | "Unknown" | null;
+ notes?: string[] | null;
+};
+
+export type ServiceSource = {
+ label?: string | null;
+ status?: string | null;
+ url?: string | null;
+ published?: string | null;
+ reviewed?: string | null;
+ notes?: string[] | null;
+};
+
+export type ServiceRecord = {
+ slug: string;
+ title: string;
+ subtitle?: string;
+ statusChips?: ServiceStatusChip[];
+ primaryContact?: ServiceContact;
+ contacts?: ServiceContact[];
+ route?: string;
+ eligibility?: string;
+ cost?: string;
+ referral?: string;
+ location?: string;
+ summaryCards?: ServiceSummaryCard[];
+ referralInfo?: ServiceInfoRow[];
+ bestUse?: string;
+ criteria?: ServiceCriterion[];
+ verification?: ServiceVerification;
+ tags?: string[];
+ catchments?: string[];
+ catalogueLabel?: string;
+ navigatorQuery?: string;
+ source?: ServiceSource;
+ /** Full source-specific payload retained in the registry JSONB column. */
+ catalogPayload?: Record;
+};
+
+export type ServiceSearchMatch = {
+ service: ServiceRecord;
+ score: number;
+ reasons: string[];
+};
+
+export function serviceNavigatorQuery(service: ServiceRecord) {
+ return (
+ [service.navigatorQuery, service.title, service.primaryContact?.value, service.subtitle].find((value) =>
+ value?.trim(),
+ ) ?? service.slug
+ );
+}
+
+function serviceRecordSearchParts(service: ServiceRecord) {
+ return [
+ service.title,
+ service.slug,
+ service.subtitle,
+ service.route,
+ service.eligibility,
+ service.cost,
+ service.referral,
+ service.location,
+ service.bestUse,
+ service.catalogueLabel,
+ service.navigatorQuery,
+ service.primaryContact?.value,
+ service.primaryContact?.detail,
+ service.source?.label,
+ service.source?.status,
+ service.source?.reviewed,
+ ...(service.tags ?? []),
+ ...(service.catchments ?? []),
+ ...(service.statusChips ?? []).flatMap((chip) => [chip.label, chip.tone]),
+ ...(service.contacts ?? []).flatMap((contact) => [contact.label, contact.value, contact.detail, contact.kind]),
+ ...(service.summaryCards ?? []).flatMap((card) => [card.label, card.title, card.detail]),
+ ...(service.referralInfo ?? []).flatMap((row) => [row.label, row.value]),
+ ...(service.criteria ?? []).flatMap((criterion) => [criterion.label, criterion.tone]),
+ ...(service.verification?.notes ?? []),
+ ...(service.source?.notes ?? []),
+ "service",
+ "services",
+ "source record",
+ "pathway",
+ ].filter((value): value is string => Boolean(value?.trim()));
+}
+
+export function serviceRecordSearchText(service: ServiceRecord) {
+ return normalizeSearchText(serviceRecordSearchParts(service).join(" "));
+}
+
+export function rankServiceRecords(
+ records: ServiceRecord[],
+ query: string,
+ limit = records.length,
+ // Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane.
+ expansions: string[] = [],
+): ServiceSearchMatch[] {
+ return rankCatalogRecords(records, query, {
+ fields: [
+ { id: "title", weight: 6, text: (service) => normalizeSearchText(`${service.title} ${service.slug}`) },
+ { id: "contact", weight: 5, text: (service) => normalizeSearchText(service.primaryContact?.value ?? "") },
+ {
+ id: "tags",
+ weight: 3,
+ text: (service) => normalizeSearchText([...(service.tags ?? []), ...(service.catchments ?? [])].join(" ")),
+ },
+ ],
+ fullText: serviceRecordSearchText,
+ contentWeight: 2,
+ compactBonus: 5,
+ phraseBonus: 4,
+ broadTerms: ["service", "services", "pathway", "pathways"],
+ broadBonus: 1,
+ expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined,
+ limit,
+ tieBreak: (left, right) => left.title.localeCompare(right.title),
+ }).map(({ record, score, signals }) => ({
+ service: record,
+ score,
+ reasons: [
+ signals.fields.title ? "title" : "",
+ signals.fields.contact || signals.compact ? "contact" : "",
+ signals.fields.tags ? "tags" : "",
+ signals.content ? "record fields" : "",
+ signals.broad ? "services catalogue" : "",
+ ].filter(Boolean),
+ }));
+}
diff --git a/src/lib/services.ts b/src/lib/services.ts
index e9fe94574..d37a5d619 100644
--- a/src/lib/services.ts
+++ b/src/lib/services.ts
@@ -1,84 +1,7 @@
-import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
import { defaultServiceRecords } from "@/lib/registry-fixtures";
+import { rankServiceRecords, type ServiceRecord, type ServiceSearchMatch } from "@/lib/service-ranker";
-export type ServiceChipTone = "danger" | "info" | "warning" | "success" | "neutral";
-export type ServiceCriterionTone = "meet" | "caution" | "reject";
-
-export type ServiceStatusChip = {
- label?: string | null;
- tone?: ServiceChipTone | null;
-};
-
-export type ServiceContact = {
- label: string;
- value?: string | null;
- detail?: string | null;
- kind: "phone" | "email" | "web" | "text" | "unknown";
-};
-
-export type ServiceSummaryCard = {
- id: string;
- label?: string | null;
- title?: string | null;
- detail?: string | null;
-};
-
-export type ServiceInfoRow = {
- label: string;
- value?: string | null;
-};
-
-export type ServiceCriterion = {
- label: string;
- tone: ServiceCriterionTone;
-};
-
-export type ServiceVerification = {
- locallyVerified?: boolean | null;
- confidence?: "High" | "Medium" | "Low" | "Unknown" | null;
- notes?: string[] | null;
-};
-
-export type ServiceSource = {
- label?: string | null;
- status?: string | null;
- url?: string | null;
- published?: string | null;
- reviewed?: string | null;
- notes?: string[] | null;
-};
-
-export type ServiceRecord = {
- slug: string;
- title: string;
- subtitle?: string;
- statusChips?: ServiceStatusChip[];
- primaryContact?: ServiceContact;
- contacts?: ServiceContact[];
- route?: string;
- eligibility?: string;
- cost?: string;
- referral?: string;
- location?: string;
- summaryCards?: ServiceSummaryCard[];
- referralInfo?: ServiceInfoRow[];
- bestUse?: string;
- criteria?: ServiceCriterion[];
- verification?: ServiceVerification;
- tags?: string[];
- catchments?: string[];
- catalogueLabel?: string;
- navigatorQuery?: string;
- source?: ServiceSource;
- /** Full source-specific payload retained in the registry JSONB column. */
- catalogPayload?: Record;
-};
-
-export type ServiceSearchMatch = {
- service: ServiceRecord;
- score: number;
- reasons: string[];
-};
+export * from "@/lib/service-ranker";
export const serviceRecords: ServiceRecord[] = defaultServiceRecords();
@@ -99,91 +22,6 @@ export function defaultServiceSlug() {
return serviceRecords[0]?.slug ?? null;
}
-export function serviceNavigatorQuery(service: ServiceRecord) {
- return (
- [service.navigatorQuery, service.title, service.primaryContact?.value, service.subtitle].find((value) =>
- value?.trim(),
- ) ?? service.slug
- );
-}
-
-function serviceRecordSearchParts(service: ServiceRecord) {
- return [
- service.title,
- service.slug,
- service.subtitle,
- service.route,
- service.eligibility,
- service.cost,
- service.referral,
- service.location,
- service.bestUse,
- service.catalogueLabel,
- service.navigatorQuery,
- service.primaryContact?.value,
- service.primaryContact?.detail,
- service.source?.label,
- service.source?.status,
- service.source?.reviewed,
- ...(service.tags ?? []),
- ...(service.catchments ?? []),
- ...(service.statusChips ?? []).flatMap((chip) => [chip.label, chip.tone]),
- ...(service.contacts ?? []).flatMap((contact) => [contact.label, contact.value, contact.detail, contact.kind]),
- ...(service.summaryCards ?? []).flatMap((card) => [card.label, card.title, card.detail]),
- ...(service.referralInfo ?? []).flatMap((row) => [row.label, row.value]),
- ...(service.criteria ?? []).flatMap((criterion) => [criterion.label, criterion.tone]),
- ...(service.verification?.notes ?? []),
- ...(service.source?.notes ?? []),
- "service",
- "services",
- "source record",
- "pathway",
- ].filter((value): value is string => Boolean(value?.trim()));
-}
-
-export function serviceRecordSearchText(service: ServiceRecord) {
- return normalizeSearchText(serviceRecordSearchParts(service).join(" "));
-}
-
-export function rankServiceRecords(
- records: ServiceRecord[],
- query: string,
- limit = records.length,
- // Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane.
- expansions: string[] = [],
-): ServiceSearchMatch[] {
- return rankCatalogRecords(records, query, {
- fields: [
- { id: "title", weight: 6, text: (service) => normalizeSearchText(`${service.title} ${service.slug}`) },
- { id: "contact", weight: 5, text: (service) => normalizeSearchText(service.primaryContact?.value ?? "") },
- {
- id: "tags",
- weight: 3,
- text: (service) => normalizeSearchText([...(service.tags ?? []), ...(service.catchments ?? [])].join(" ")),
- },
- ],
- fullText: serviceRecordSearchText,
- contentWeight: 2,
- compactBonus: 5,
- phraseBonus: 4,
- broadTerms: ["service", "services", "pathway", "pathways"],
- broadBonus: 1,
- expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined,
- limit,
- tieBreak: (left, right) => left.title.localeCompare(right.title),
- }).map(({ record, score, signals }) => ({
- service: record,
- score,
- reasons: [
- signals.fields.title ? "title" : "",
- signals.fields.contact || signals.compact ? "contact" : "",
- signals.fields.tags ? "tags" : "",
- signals.content ? "record fields" : "",
- signals.broad ? "services catalogue" : "",
- ].filter(Boolean),
- }));
-}
-
export function searchServiceRecords(query: string, limit = serviceRecords.length): ServiceSearchMatch[] {
return rankServiceRecords(serviceRecords, query, limit);
}
diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts
index 8f20205c9..49b6bee29 100644
--- a/src/lib/supabase/auth.ts
+++ b/src/lib/supabase/auth.ts
@@ -78,7 +78,10 @@ export class AuthenticationError extends Error {
export function unauthorizedResponse(error?: AuthenticationError) {
void error;
- return NextResponse.json({ error: "Authentication required." }, { status: 401 });
+ return NextResponse.json(
+ { error: "Authentication required." },
+ { status: 401, headers: { "Cache-Control": "private, no-store" } },
+ );
}
/**
diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts
index b4a4b9375..0e3e3e581 100644
--- a/src/lib/supabase/database.types.ts
+++ b/src/lib/supabase/database.types.ts
@@ -2298,6 +2298,26 @@ export type Database = {
retry_after_seconds: number;
}[];
};
+ consume_summary_rate_limits_atomic: {
+ Args: {
+ p_answer_limit: number;
+ p_answer_window_seconds: number;
+ p_global_answer_limit: number;
+ p_global_answer_window_seconds: number;
+ p_owner_id: string | null;
+ p_subject_key: string | null;
+ p_summary_limit: number;
+ p_summary_window_seconds: number;
+ };
+ Returns: {
+ bucket: string | null;
+ limit_value: number;
+ limited: boolean;
+ remaining: number;
+ reset_at: string;
+ retry_after_seconds: number;
+ }[];
+ };
corpus_topic_term_stats: {
Args: { terms: string[]; owner_filter?: string | null };
Returns: {
diff --git a/src/lib/types.ts b/src/lib/types.ts
index a71f4e707..100c35c1e 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -440,6 +440,18 @@ export type Citation = {
provenance?: CitationProvenance;
};
+export type SafetyWarningKind =
+ "contraindication" | "red_flag" | "escalation" | "dose_limit" | "monitoring" | "exclusion" | "caveat";
+
+export type SafetyWarning = {
+ id: string;
+ kind: SafetyWarningKind;
+ label: string;
+ text: string;
+ citation: Citation;
+ href: string;
+};
+
export type SupportedClaim = {
claimId: string;
text: string;
@@ -1005,6 +1017,9 @@ export type RagAnswer = {
}>;
scope?: SearchScopeSummary;
sourceGovernanceWarnings?: SourceGovernanceWarning[];
+ /** Server-derived safety findings. Browser payloads use these bounded
+ * findings instead of scanning full source chunks client-side. */
+ safetyWarnings?: SafetyWarning[];
// GEN-C1: set when the model output was cut off (status="incomplete" /
// max_output_tokens). The clinical content may be missing a dose/threshold, so
// the UI must surface "answer truncated — verify against sources".
diff --git a/src/lib/universal-search-stream.ts b/src/lib/universal-search-stream.ts
new file mode 100644
index 000000000..c3ba49156
--- /dev/null
+++ b/src/lib/universal-search-stream.ts
@@ -0,0 +1,84 @@
+import type { UniversalSearchGroup, UniversalSearchResponse } from "@/lib/universal-search";
+
+export type UniversalSearchStreamResponse = UniversalSearchResponse & {
+ demoMode?: boolean;
+ publicAccess?: boolean;
+};
+
+export type UniversalSearchStreamEvent =
+ | { type: "group"; query: string; group: UniversalSearchGroup }
+ | { type: "complete"; response: UniversalSearchStreamResponse };
+
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (signal?.aborted) throw abortReason(signal);
+}
+
+function parseEvent(line: string): UniversalSearchStreamEvent {
+ const parsed = JSON.parse(line) as Partial;
+ if (parsed.type === "group" && typeof parsed.query === "string" && parsed.group) {
+ return parsed as Extract;
+ }
+ if (parsed.type === "complete" && parsed.response) {
+ return parsed as Extract;
+ }
+ throw new Error("Invalid universal-search NDJSON event.");
+}
+
+/** Consume split NDJSON chunks, surfacing groups immediately and returning final JSON parity. */
+export async function consumeUniversalSearchNdjson(
+ response: Response,
+ options: {
+ signal?: AbortSignal;
+ onGroup?: (group: UniversalSearchGroup, query: string) => void | Promise;
+ } = {},
+): Promise {
+ throwIfAborted(options.signal);
+ if (!response.body) throw new Error("Universal-search NDJSON response has no body.");
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let complete: UniversalSearchStreamResponse | undefined;
+ const cancelReader = () => {
+ void reader.cancel(options.signal?.reason).catch(() => undefined);
+ };
+ options.signal?.addEventListener("abort", cancelReader, { once: true });
+
+ const acceptLine = async (line: string) => {
+ if (!line.trim()) return;
+ const event = parseEvent(line);
+ if (event.type === "group") await options.onGroup?.(event.group, event.query);
+ else complete = event.response;
+ };
+
+ try {
+ while (true) {
+ throwIfAborted(options.signal);
+ const { value, done } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ let newline = buffer.indexOf("\n");
+ while (newline >= 0) {
+ const line = buffer.slice(0, newline);
+ buffer = buffer.slice(newline + 1);
+ await acceptLine(line);
+ newline = buffer.indexOf("\n");
+ }
+ }
+ buffer += decoder.decode();
+ if (buffer.trim()) await acceptLine(buffer);
+ throwIfAborted(options.signal);
+ if (!complete) throw new Error("Universal-search NDJSON stream ended without a complete event.");
+ return complete;
+ } catch (error) {
+ if (options.signal?.aborted) throw abortReason(options.signal);
+ throw error;
+ } finally {
+ options.signal?.removeEventListener("abort", cancelReader);
+ reader.releaseLock();
+ }
+}
diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts
index 1901c2a35..b6fe3d872 100644
--- a/src/lib/universal-search.ts
+++ b/src/lib/universal-search.ts
@@ -14,6 +14,7 @@ import { formRecords, rankFormRecords, type FormRecord } from "@/lib/forms";
import { rowToMedicationRecord } from "@/lib/medication-records";
import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed";
import { medicationIndication, rankMedicationRecords, type MedicationRecord } from "@/lib/medications";
+import { loadOwnerCatalogue } from "@/lib/owner-catalogue-cache";
import { searchChunksWithTelemetry } from "@/lib/rag";
import { registryCorpusDetailHref } from "@/lib/registry-corpus-links";
import { fetchOwnerRegistryRows, mergeRegistryRecordsWithDefaults } from "@/lib/registry-seed";
@@ -109,6 +110,10 @@ export type RunUniversalSearchArgs = {
supabase?: AdminClient;
ownerId?: string;
demo: boolean;
+ signal?: AbortSignal;
+ // Optional progressive delivery hook. Domain work remains parallel; each completed group is
+ // reported immediately while the final response preserves canonical group order.
+ onGroup?: (group: UniversalSearchGroup) => void | Promise;
};
// Args after query understanding: registry adapters rank against baseQuery (typo-corrected)
@@ -122,21 +127,74 @@ type ResolvedSearchArgs = RunUniversalSearchArgs & {
const registryDomainTimeoutMs = 2500;
const documentsDomainTimeoutMs = 6000;
+const ownerCatalogueLimit = 500;
+
+// Owner typeahead needs the complete rankable catalogue, but not governance timestamps, IDs,
+// audit columns, or other route-only payload. These projections keep the short-lived cache and
+// Supabase response limited to fields consumed by row conversion, ranking, and result cards.
+const medicationRankingProjection = "slug,name,class,subclass,category,tag,schedule,stats,sections,quick";
+const registryRankingProjection = [
+ "slug",
+ "title",
+ "subtitle",
+ "status_chips",
+ "primary_contact",
+ "contacts",
+ "route",
+ "eligibility",
+ "cost",
+ "referral",
+ "location",
+ "summary_cards",
+ "referral_info",
+ "best_use",
+ "criteria",
+ "verification",
+ "tags",
+ "catchments",
+ "catalogue_label",
+ "navigator_query",
+ "source",
+ "catalog_payload",
+].join(",");
+
+function abortReason(signal: AbortSignal): Error {
+ return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError");
+}
-function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise {
- return new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error(`${label} search timed out after ${timeoutMs}ms`)), timeoutMs);
- promise.then(
- (value) => {
- clearTimeout(timer);
- resolve(value);
- },
- (error: unknown) => {
- clearTimeout(timer);
- reject(error instanceof Error ? error : new Error(String(error)));
- },
- );
+function throwIfAborted(signal?: AbortSignal) {
+ if (signal?.aborted) throw abortReason(signal);
+}
+
+async function withTimeout(
+ run: (signal: AbortSignal) => Promise,
+ timeoutMs: number,
+ label: string,
+ callerSignal?: AbortSignal,
+): Promise {
+ const controller = new AbortController();
+ const abortFromCaller = () => controller.abort(callerSignal?.reason);
+ if (callerSignal?.aborted) abortFromCaller();
+ else callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
+
+ const timeout = setTimeout(() => {
+ controller.abort(new DOMException(`${label} search timed out after ${timeoutMs}ms`, "TimeoutError"));
+ }, timeoutMs);
+ let rejectOnAbort: ((reason: Error) => void) | undefined;
+ const aborted = new Promise((_resolve, reject) => {
+ rejectOnAbort = reject;
});
+ const onAbort = () => rejectOnAbort?.(abortReason(controller.signal));
+ controller.signal.addEventListener("abort", onAbort, { once: true });
+ if (controller.signal.aborted) onAbort();
+
+ try {
+ return await Promise.race([run(controller.signal), aborted]);
+ } finally {
+ clearTimeout(timeout);
+ callerSignal?.removeEventListener("abort", abortFromCaller);
+ controller.signal.removeEventListener("abort", onAbort);
+ }
}
function medicationItem(record: MedicationRecord, score: number): UniversalSearchItem {
@@ -180,7 +238,19 @@ function formItem(record: FormRecord, score: number): UniversalSearchItem {
async function searchMedicationsDomain(args: ResolvedSearchArgs): Promise {
const records =
!args.demo && args.supabase && args.ownerId
- ? (await fetchOwnerMedicationRowsWithSeed(args.supabase, args.ownerId)).map(rowToMedicationRecord)
+ ? (
+ await loadOwnerCatalogue({
+ ownerId: args.ownerId,
+ kind: "medication",
+ limit: ownerCatalogueLimit,
+ signal: args.signal,
+ load: (signal) =>
+ fetchOwnerMedicationRowsWithSeed(args.supabase!, args.ownerId!, ownerCatalogueLimit, {
+ signal,
+ select: medicationRankingProjection,
+ }),
+ })
+ ).map(rowToMedicationRecord)
: defaultMedicationRecords();
return rankMedicationRecords(records, args.baseQuery, args.limitPerDomain, args.expansions).map((match) =>
medicationItem(match.medication, match.score),
@@ -192,7 +262,17 @@ async function searchServicesDomain(args: ResolvedSearchArgs): Promise
+ fetchOwnerRegistryRows(args.supabase!, args.ownerId!, "service", ownerCatalogueLimit, {
+ signal,
+ select: registryRankingProjection,
+ }),
+ }),
)
: serviceRecords;
return rankServiceRecords(records, args.baseQuery, args.limitPerDomain, args.expansions).map((match) =>
@@ -203,7 +283,20 @@ async function searchServicesDomain(args: ResolvedSearchArgs): Promise {
const records =
!args.demo && args.supabase && args.ownerId
- ? mergeRegistryRecordsWithDefaults("form", await fetchOwnerRegistryRows(args.supabase, args.ownerId, "form"))
+ ? mergeRegistryRecordsWithDefaults(
+ "form",
+ await loadOwnerCatalogue({
+ ownerId: args.ownerId,
+ kind: "form",
+ limit: ownerCatalogueLimit,
+ signal: args.signal,
+ load: (signal) =>
+ fetchOwnerRegistryRows(args.supabase!, args.ownerId!, "form", ownerCatalogueLimit, {
+ signal,
+ select: registryRankingProjection,
+ }),
+ }),
+ )
: formRecords;
return rankFormRecords(records, args.baseQuery, args.limitPerDomain, args.expansions).map((match) =>
formItem(match.service, match.score),
@@ -372,6 +465,7 @@ async function searchDocumentsDomain(args: ResolvedSearchArgs): Promise 0) {
return related.map((document) => ({
@@ -528,6 +624,7 @@ function buildInterpretation(
export async function runUniversalSearch(args: RunUniversalSearchArgs): Promise {
const startedAt = Date.now();
+ throwIfAborted(args.signal);
const requested = args.domains?.length
? universalSearchDomains.filter((domain) => args.domains!.includes(domain))
: universalSearchDomains;
@@ -540,31 +637,42 @@ export async function runUniversalSearch(args: RunUniversalSearchArgs): Promise<
const expansions = deriveExpansions(analysis, baseQuery);
const resolved: ResolvedSearchArgs = { ...args, baseQuery, expansions };
- const settled = await Promise.allSettled(
+ const groups = await Promise.all(
requested.map(async (domain): Promise => {
const domainStartedAt = Date.now();
const adapter = domainAdapters[domain];
- const items = await withTimeout(adapter.run(resolved), adapter.timeoutMs, domain);
- // Tag near-exact title matches so best-bet + ordering can compare across domains without
- // leaning on the per-domain scores (which are not comparable across domains).
- const tagged = items
- .slice(0, args.limitPerDomain)
- .map((item) => ({ ...item, confident: titleMatchesQuery(item.title, baseQuery) }));
- return {
- kind: domain,
- total: tagged.length,
- items: tagged,
- latencyMs: Date.now() - domainStartedAt,
- };
+ let group: UniversalSearchGroup;
+ try {
+ const items = await withTimeout(
+ (signal) => adapter.run({ ...resolved, signal }),
+ adapter.timeoutMs,
+ domain,
+ args.signal,
+ );
+ // Tag near-exact title matches so best-bet + ordering can compare across domains without
+ // leaning on the per-domain scores (which are not comparable across domains).
+ const tagged = items
+ .slice(0, args.limitPerDomain)
+ .map((item) => ({ ...item, confident: titleMatchesQuery(item.title, baseQuery) }));
+ group = {
+ kind: domain,
+ total: tagged.length,
+ items: tagged,
+ latencyMs: Date.now() - domainStartedAt,
+ };
+ } catch {
+ // A failed/timed-out domain yields an empty errored group — one slow or broken adapter
+ // must never blank the whole response. Caller cancellation is different: propagate it
+ // so all downstream work and the NDJSON stream terminate instead of caching/returning.
+ throwIfAborted(args.signal);
+ group = { kind: domain, total: 0, items: [], latencyMs: Date.now() - domainStartedAt, error: true };
+ }
+ throwIfAborted(args.signal);
+ await args.onGroup?.(group);
+ return group;
}),
);
-
- // A failed domain yields an empty errored group — one slow or broken adapter must never
- // blank the whole response.
- const groups = settled.map((result, index): UniversalSearchGroup => {
- if (result.status === "fulfilled") return result.value;
- return { kind: requested[index], total: 0, items: [], latencyMs: Date.now() - startedAt, error: true };
- });
+ throwIfAborted(args.signal);
const preferredDomains = universalSearchPreferredDomains(args.contextMode).filter((domain) =>
requested.includes(domain),
diff --git a/src/proxy.ts b/src/proxy.ts
index 2367aeb9e..d01c51386 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -115,9 +115,9 @@ export async function proxy(request: NextRequest) {
});
// Refresh the session. Per @supabase/ssr guidance, do not run other logic
- // between createServerClient and getUser — a stale token here would sign the
+ // between createServerClient and getClaims — a stale token here would sign the
// user out on the next request.
- await supabase.auth.getUser();
+ await supabase.auth.getClaims();
return withCsp(response);
}
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index 4c7e9a0a4..da865fcb0 100644
--- a/supabase/drift-manifest.json
+++ b/supabase/drift-manifest.json
@@ -1,9 +1,9 @@
{
- "generated_at": "2026-07-17T14:08:59.595Z",
+ "generated_at": "2026-07-17T15:00:51.747Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127",
- "schema_sha256": "e93fe90cae38654e77b7be0f81e5a0c90b0c7db2fd37d76e32f4f52a089eff45",
- "replay_seconds": 30,
+ "schema_sha256": "bde3b7c60872a8c4493555e6670c30ac777a862a12567ef2091dfc050c86240e",
+ "replay_seconds": 77,
"snapshot": {
"views": [
{
@@ -2312,6 +2312,34 @@
"rls_forced": false,
"rls_enabled": true
},
+ {
+ "acl": [
+ "postgres=arwdDxtm/postgres",
+ "service_role=arwdDxtm/postgres"
+ ],
+ "name": "document_title_words",
+ "columns": [
+ {
+ "name": "document_id",
+ "type": "uuid",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ },
+ {
+ "name": "word",
+ "type": "text",
+ "default": null,
+ "identity": "",
+ "not_null": true,
+ "generated": ""
+ }
+ ],
+ "reloptions": null,
+ "rls_forced": false,
+ "rls_enabled": true
+ },
{
"acl": [
"postgres=arwdDxtm/postgres",
@@ -5262,6 +5290,24 @@
"table": "document_table_facts",
"def_hash": "9ac4c08564d3f549df99a1e543875cb8"
},
+ {
+ "def": "CREATE INDEX document_title_words_document_id_idx ON public.document_title_words USING btree (document_id)",
+ "name": "document_title_words_document_id_idx",
+ "table": "document_title_words",
+ "def_hash": "003cf77523b819a06ff50cf8df6fcf4b"
+ },
+ {
+ "def": "CREATE UNIQUE INDEX document_title_words_pkey ON public.document_title_words USING btree (word, document_id)",
+ "name": "document_title_words_pkey",
+ "table": "document_title_words",
+ "def_hash": "8da6b7cecd94a940059ae6052c585a50"
+ },
+ {
+ "def": "CREATE INDEX document_title_words_word_trgm_idx ON public.document_title_words USING gin (word extensions.gin_trgm_ops)",
+ "name": "document_title_words_word_trgm_idx",
+ "table": "document_title_words",
+ "def_hash": "e7ea304b143482a8aeac075dd5f7484d"
+ },
{
"def": "CREATE INDEX documents_import_batch_idx ON public.documents USING btree (import_batch_id)",
"name": "documents_import_batch_idx",
@@ -5310,6 +5356,12 @@
"table": "documents",
"def_hash": "fc27ae2194373897e2f316e1cac47fb0"
},
+ {
+ "def": "CREATE INDEX documents_registry_projection_lookup_idx ON public.documents USING btree (((metadata ->> 'registry_record_kind'::text)), ((metadata ->> 'registry_record_id'::text))) WHERE ((metadata ->> 'source_kind'::text) = 'registry_record'::text)",
+ "name": "documents_registry_projection_lookup_idx",
+ "table": "documents",
+ "def_hash": "2c19fee4cac85c85d9ec69f179a6cb79"
+ },
{
"def": "CREATE INDEX documents_search_idx ON public.documents USING gin (search_tsv)",
"name": "documents_search_idx",
@@ -5514,6 +5566,12 @@
"table": "rag_aliases",
"def_hash": "de4c954f6260a846824662bdc55ea171"
},
+ {
+ "def": "CREATE INDEX rag_aliases_canonical_trgm_idx ON public.rag_aliases USING gin (lower(canonical) extensions.gin_trgm_ops)",
+ "name": "rag_aliases_canonical_trgm_idx",
+ "table": "rag_aliases",
+ "def_hash": "e88844a9cb30e4980f1c92a94ae0b24a"
+ },
{
"def": "CREATE INDEX rag_aliases_owner_enabled_idx ON public.rag_aliases USING btree (owner_id, enabled)",
"name": "rag_aliases_owner_enabled_idx",
@@ -6232,11 +6290,21 @@
}
],
"triggers": [
+ {
+ "def": "CREATE TRIGGER clinical_registry_records_delete_cleanup AFTER DELETE ON public.clinical_registry_records FOR EACH ROW EXECUTE FUNCTION public.cleanup_registry_corpus_document()",
+ "name": "clinical_registry_records_delete_cleanup",
+ "table": "clinical_registry_records"
+ },
{
"def": "CREATE TRIGGER clinical_registry_records_updated_at BEFORE UPDATE ON public.clinical_registry_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()",
"name": "clinical_registry_records_updated_at",
"table": "clinical_registry_records"
},
+ {
+ "def": "CREATE TRIGGER differential_records_delete_cleanup AFTER DELETE ON public.differential_records FOR EACH ROW EXECUTE FUNCTION public.cleanup_registry_corpus_document()",
+ "name": "differential_records_delete_cleanup",
+ "table": "differential_records"
+ },
{
"def": "CREATE TRIGGER differential_records_updated_at BEFORE UPDATE ON public.differential_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()",
"name": "differential_records_updated_at",
@@ -6272,6 +6340,11 @@
"name": "documents_require_publication_approval",
"table": "documents"
},
+ {
+ "def": "CREATE TRIGGER documents_sync_title_words AFTER INSERT OR DELETE OR UPDATE OF title, status, owner_id ON public.documents FOR EACH ROW EXECUTE FUNCTION public.sync_document_title_words()",
+ "name": "documents_sync_title_words",
+ "table": "documents"
+ },
{
"def": "CREATE TRIGGER documents_updated_at BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()",
"name": "documents_updated_at",
@@ -6292,6 +6365,11 @@
"name": "ingestion_jobs_updated_at",
"table": "ingestion_jobs"
},
+ {
+ "def": "CREATE TRIGGER medication_records_delete_cleanup AFTER DELETE ON public.medication_records FOR EACH ROW EXECUTE FUNCTION public.cleanup_registry_corpus_document()",
+ "name": "medication_records_delete_cleanup",
+ "table": "medication_records"
+ },
{
"def": "CREATE TRIGGER medication_records_updated_at BEFORE UPDATE ON public.medication_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()",
"name": "medication_records_updated_at",
@@ -6385,6 +6463,13 @@
"def_hash": "4ee148dcab28d6bbe10cfa542213f0f6",
"signature": "public.cleanup_abandoned_document_index_generations(uuid,integer,boolean)"
},
+ {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "def_hash": "7104ee6e947fcca68fdebdf2fa878339",
+ "signature": "public.cleanup_registry_corpus_document()"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -6440,6 +6525,14 @@
"def_hash": "e263f3819b05177abcf2eedd3f468991",
"signature": "public.consume_api_subject_rate_limit(text,text,integer,integer)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "833c00fa1743026d9269b9af28e0b86f",
+ "signature": "public.consume_summary_rate_limits_atomic(uuid,text,integer,integer,integer,integer,integer,integer)"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -6461,7 +6554,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "a5ebdd1ec14008cdf0c8601a32b4d24c",
+ "def_hash": "cb65883a561cb1f5cd2247213f417a41",
"signature": "public.correct_clinical_query_terms(text,real)"
},
{
@@ -6469,7 +6562,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "675d521a0746befe09e68e47986c6355",
+ "def_hash": "a6f0e7c27da08e47e2c01dce4ab5d58d",
"signature": "public.default_privileges_status(text,text)"
},
{
@@ -6985,6 +7078,13 @@
"def_hash": "3b5c6084651ca0f8fd8e069106e8f7dd",
"signature": "public.stamp_document_deep_memory_version(uuid,text)"
},
+ {
+ "acl": [
+ "postgres=X/postgres"
+ ],
+ "def_hash": "6b51ece12494d136b39977d8532c013c",
+ "signature": "public.sync_document_title_words()"
+ },
{
"acl": [
"postgres=X/postgres",
@@ -7491,6 +7591,26 @@
"name": "document_table_facts_source_image_id_fkey",
"table": "document_table_facts"
},
+ {
+ "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE",
+ "name": "document_title_words_document_id_fkey",
+ "table": "document_title_words"
+ },
+ {
+ "def": "CHECK ((word = lower(word)))",
+ "name": "document_title_words_lowercase",
+ "table": "document_title_words"
+ },
+ {
+ "def": "PRIMARY KEY (word, document_id)",
+ "name": "document_title_words_pkey",
+ "table": "document_title_words"
+ },
+ {
+ "def": "CHECK (((length(word) >= 4) AND (length(word) <= 40)))",
+ "name": "document_title_words_word_length",
+ "table": "document_title_words"
+ },
{
"def": "FOREIGN KEY (import_batch_id) REFERENCES public.import_batches(id) ON DELETE SET NULL",
"name": "documents_import_batch_id_fkey",
diff --git a/supabase/migrations/20260717170000_registry_projection_cleanup.sql b/supabase/migrations/20260717170000_registry_projection_cleanup.sql
new file mode 100644
index 000000000..5b3f926c7
--- /dev/null
+++ b/supabase/migrations/20260717170000_registry_projection_cleanup.sql
@@ -0,0 +1,51 @@
+set lock_timeout = '5s';
+set statement_timeout = '60s';
+
+-- Operators rolling this out on a busy database may pre-create this exact
+-- index concurrently outside the migration transaction. The IF NOT EXISTS
+-- below then becomes a no-op during the eventual authorized migration apply.
+create index if not exists documents_registry_projection_lookup_idx
+ on public.documents (
+ (metadata->>'registry_record_kind'),
+ (metadata->>'registry_record_id')
+ )
+ where metadata->>'source_kind' = 'registry_record';
+
+create or replace function public.cleanup_registry_corpus_document()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ delete from public.documents
+ where metadata->>'source_kind' = 'registry_record'
+ and metadata->>'registry_record_kind' = case tg_table_name
+ when 'clinical_registry_records' then pg_catalog.to_jsonb(old)->>'kind'
+ when 'medication_records' then 'medication'
+ when 'differential_records' then 'differential'
+ else null
+ end
+ and metadata->>'registry_record_id' = old.id::text;
+
+ return old;
+end;
+$$;
+
+revoke execute on function public.cleanup_registry_corpus_document()
+ from public, anon, authenticated, service_role;
+
+drop trigger if exists clinical_registry_records_delete_cleanup on public.clinical_registry_records;
+create trigger clinical_registry_records_delete_cleanup
+ after delete on public.clinical_registry_records
+ for each row execute function public.cleanup_registry_corpus_document();
+
+drop trigger if exists medication_records_delete_cleanup on public.medication_records;
+create trigger medication_records_delete_cleanup
+ after delete on public.medication_records
+ for each row execute function public.cleanup_registry_corpus_document();
+
+drop trigger if exists differential_records_delete_cleanup on public.differential_records;
+create trigger differential_records_delete_cleanup
+ after delete on public.differential_records
+ for each row execute function public.cleanup_registry_corpus_document();
diff --git a/supabase/migrations/20260717171000_public_title_corrector.sql b/supabase/migrations/20260717171000_public_title_corrector.sql
new file mode 100644
index 000000000..e9a439470
--- /dev/null
+++ b/supabase/migrations/20260717171000_public_title_corrector.sql
@@ -0,0 +1,156 @@
+set lock_timeout = '5s';
+set statement_timeout = '60s';
+
+create table if not exists public.document_title_words (
+ word text not null,
+ document_id uuid not null references public.documents(id) on delete cascade,
+ primary key (word, document_id),
+ constraint document_title_words_word_length check (length(word) between 4 and 40),
+ constraint document_title_words_lowercase check (word = lower(word))
+);
+
+create index if not exists document_title_words_word_trgm_idx
+ on public.document_title_words using gin (word extensions.gin_trgm_ops);
+
+create index if not exists document_title_words_document_id_idx
+ on public.document_title_words (document_id);
+
+create index if not exists rag_aliases_canonical_trgm_idx
+ on public.rag_aliases using gin (lower(canonical) extensions.gin_trgm_ops);
+
+alter table public.document_title_words enable row level security;
+revoke all on table public.document_title_words from public, anon, authenticated;
+grant select, insert, update, delete on table public.document_title_words to service_role;
+
+create or replace function public.sync_document_title_words()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if tg_op <> 'INSERT' then
+ delete from public.document_title_words where document_id = old.id;
+ end if;
+
+ if tg_op <> 'DELETE' and new.owner_id is null and new.status = 'indexed' then
+ insert into public.document_title_words (word, document_id)
+ select distinct lower(title_word), new.id
+ from pg_catalog.unnest(pg_catalog.regexp_split_to_array(lower(new.title), '[^a-z]+')) as title_word
+ where length(title_word) between 4 and 40
+ on conflict do nothing;
+ end if;
+
+ return null;
+end;
+$$;
+
+revoke execute on function public.sync_document_title_words()
+ from public, anon, authenticated, service_role;
+
+drop trigger if exists documents_sync_title_words on public.documents;
+create trigger documents_sync_title_words
+ after insert or update of title, status, owner_id or delete on public.documents
+ for each row execute function public.sync_document_title_words();
+
+insert into public.document_title_words (word, document_id)
+select distinct lower(title_word), d.id
+from public.documents d
+cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word
+where d.owner_id is null and d.status = 'indexed'
+ and length(title_word) between 4 and 40
+on conflict do nothing;
+
+create or replace function public.correct_clinical_query_terms(
+ input_query text,
+ min_sim real default 0.45
+)
+returns text
+language plpgsql
+stable
+security definer
+set search_path = pg_catalog, extensions
+as $$
+declare
+ tokens text[];
+ tok text;
+ best text;
+ best_sim real;
+ corrected text[] := array[]::text[];
+ changed boolean := false;
+begin
+ if input_query is null or length(trim(input_query)) = 0 then
+ return input_query;
+ end if;
+
+ tokens := regexp_split_to_array(lower(input_query), '\s+');
+ foreach tok in array tokens loop
+ if length(tok) < 4 then
+ corrected := corrected || tok;
+ continue;
+ end if;
+
+ best := null;
+ best_sim := 0;
+ select candidate.term, candidate.match_sim
+ into best, best_sim
+ from (
+ (
+ select
+ lower(canonical) as term,
+ similarity(lower(alias), tok) as match_sim
+ from public.rag_aliases
+ where enabled
+ and owner_id is null
+ and length(alias) between 4 and 40
+ and length(canonical) between 4 and 40
+ and lower(alias) % tok
+ order by similarity(lower(alias), tok) desc, lower(alias)
+ limit 32
+ )
+ union all
+ (
+ select
+ lower(canonical) as term,
+ similarity(lower(canonical), tok) as match_sim
+ from public.rag_aliases
+ where enabled
+ and owner_id is null
+ and length(canonical) between 4 and 40
+ and lower(canonical) % tok
+ order by similarity(lower(canonical), tok) desc, lower(canonical)
+ limit 32
+ )
+ union all
+ (
+ select
+ word as term,
+ similarity(word, tok) as match_sim
+ from public.document_title_words
+ where length(word) between 4 and 40
+ and word % tok
+ order by similarity(word, tok) desc, word
+ limit 32
+ )
+ ) candidate
+ order by candidate.match_sim desc, candidate.term
+ limit 1;
+
+ if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then
+ corrected := corrected || best;
+ changed := true;
+ else
+ corrected := corrected || tok;
+ end if;
+ end loop;
+
+ if not changed then
+ return input_query;
+ end if;
+ return array_to_string(corrected, ' ');
+end;
+$$;
+
+revoke execute on function public.correct_clinical_query_terms(text, real)
+ from public, anon, authenticated;
+grant execute on function public.correct_clinical_query_terms(text, real) to service_role;
diff --git a/supabase/migrations/20260717172000_atomic_summary_rate_limits.sql b/supabase/migrations/20260717172000_atomic_summary_rate_limits.sql
new file mode 100644
index 000000000..d92442de9
--- /dev/null
+++ b/supabase/migrations/20260717172000_atomic_summary_rate_limits.sql
@@ -0,0 +1,182 @@
+set lock_timeout = '5s';
+set statement_timeout = '30s';
+
+create or replace function public.consume_summary_rate_limits_atomic(
+ p_owner_id uuid,
+ p_subject_key text,
+ p_answer_limit integer,
+ p_answer_window_seconds integer,
+ p_summary_limit integer,
+ p_summary_window_seconds integer,
+ p_global_answer_limit integer,
+ p_global_answer_window_seconds integer
+)
+returns table (
+ bucket text,
+ limited boolean,
+ limit_value integer,
+ remaining integer,
+ retry_after_seconds integer,
+ reset_at timestamptz
+)
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_now timestamptz := pg_catalog.statement_timestamp();
+ v_policy record;
+ v_count integer;
+ v_remaining integer;
+ v_reset_at timestamptz;
+ v_min_remaining integer := 2147483647;
+ v_success_limit integer;
+ v_success_reset_at timestamptz;
+begin
+ if (p_owner_id is null) = (p_subject_key is null or pg_catalog.btrim(p_subject_key) = '') then
+ raise exception 'exactly one owner_id or subject_key is required';
+ end if;
+ if p_answer_limit is null or p_answer_limit < 1
+ or p_answer_window_seconds is null or p_answer_window_seconds < 1
+ or p_summary_limit is null or p_summary_limit < 1
+ or p_summary_window_seconds is null or p_summary_window_seconds < 1
+ or p_global_answer_limit is null or p_global_answer_limit < 1
+ or p_global_answer_window_seconds is null or p_global_answer_window_seconds < 1 then
+ raise exception 'limits and windows must be positive';
+ end if;
+
+ if p_owner_id is not null then
+ insert into public.api_rate_limits (owner_id, bucket, window_start, request_count, updated_at)
+ values
+ (p_owner_id, 'answer', v_now, 0, v_now),
+ (p_owner_id, 'document_summarize', v_now, 0, v_now)
+ on conflict on constraint api_rate_limits_pkey do nothing;
+
+ -- Acquire every participating row before incrementing, in one stable order.
+ perform 1
+ from public.api_rate_limits as rl
+ where rl.owner_id = p_owner_id
+ and rl.bucket in ('answer', 'document_summarize')
+ order by rl.bucket
+ for update;
+
+ for v_policy in
+ select *
+ from (values
+ ('answer'::text, 1, p_answer_limit, p_answer_window_seconds),
+ ('document_summarize'::text, 2, p_summary_limit, p_summary_window_seconds)
+ ) as policy(bucket, ordinal, limit_value, window_seconds)
+ order by ordinal
+ loop
+ update public.api_rate_limits as rl
+ set
+ window_start = case
+ when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then v_now
+ else rl.window_start
+ end,
+ request_count = case
+ when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then 1
+ else rl.request_count + 1
+ end,
+ updated_at = v_now
+ where rl.owner_id = p_owner_id and rl.bucket = v_policy.bucket
+ returning rl.request_count,
+ rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds)
+ into v_count, v_reset_at;
+
+ v_remaining := greatest(v_policy.limit_value - v_count, 0);
+ if v_remaining < v_min_remaining then
+ v_min_remaining := v_remaining;
+ v_success_limit := v_policy.limit_value;
+ v_success_reset_at := v_reset_at;
+ end if;
+ if v_count > v_policy.limit_value then
+ return query select
+ v_policy.bucket::text,
+ true,
+ v_policy.limit_value::integer,
+ 0,
+ greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer),
+ v_reset_at;
+ return;
+ end if;
+ end loop;
+ else
+ insert into public.api_rate_limit_subjects (subject_key, bucket, window_start, request_count, updated_at)
+ values
+ (p_subject_key, 'answer', v_now, 0, v_now),
+ ('anon:answer:global', 'answer', v_now, 0, v_now),
+ (p_subject_key, 'document_summarize', v_now, 0, v_now)
+ on conflict on constraint api_rate_limit_subjects_pkey do nothing;
+
+ -- Subject and global rows share one stable lexical lock order.
+ perform 1
+ from public.api_rate_limit_subjects as rl
+ where (rl.subject_key, rl.bucket) in (
+ (p_subject_key, 'answer'),
+ ('anon:answer:global', 'answer'),
+ (p_subject_key, 'document_summarize')
+ )
+ order by rl.subject_key, rl.bucket
+ for update;
+
+ for v_policy in
+ select *
+ from (values
+ ('answer'::text, 1, p_subject_key, p_answer_limit, p_answer_window_seconds, 'answer'::text),
+ ('answer'::text, 2, 'anon:answer:global', p_global_answer_limit, p_global_answer_window_seconds, 'answer'::text),
+ ('document_summarize'::text, 3, p_subject_key, p_summary_limit, p_summary_window_seconds, 'document_summarize'::text)
+ ) as policy(bucket, ordinal, subject_key, limit_value, window_seconds, rejection_bucket)
+ order by ordinal
+ loop
+ update public.api_rate_limit_subjects as rl
+ set
+ window_start = case
+ when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then v_now
+ else rl.window_start
+ end,
+ request_count = case
+ when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then 1
+ else rl.request_count + 1
+ end,
+ updated_at = v_now
+ where rl.subject_key = v_policy.subject_key and rl.bucket = v_policy.bucket
+ returning rl.request_count,
+ rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds)
+ into v_count, v_reset_at;
+
+ v_remaining := greatest(v_policy.limit_value - v_count, 0);
+ if v_remaining < v_min_remaining then
+ v_min_remaining := v_remaining;
+ v_success_limit := v_policy.limit_value;
+ v_success_reset_at := v_reset_at;
+ end if;
+ if v_count > v_policy.limit_value then
+ return query select
+ v_policy.rejection_bucket::text,
+ true,
+ v_policy.limit_value::integer,
+ 0,
+ greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer),
+ v_reset_at;
+ return;
+ end if;
+ end loop;
+ end if;
+
+ return query select
+ null::text,
+ false,
+ v_success_limit,
+ v_min_remaining,
+ greatest(1, pg_catalog.ceil(extract(epoch from (v_success_reset_at - v_now)))::integer),
+ v_success_reset_at;
+end;
+$$;
+
+revoke execute on function public.consume_summary_rate_limits_atomic(
+ uuid, text, integer, integer, integer, integer, integer, integer
+) from public, anon, authenticated;
+grant execute on function public.consume_summary_rate_limits_atomic(
+ uuid, text, integer, integer, integer, integer, integer, integer
+) to service_role;
diff --git a/supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql b/supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql
new file mode 100644
index 000000000..b8ee1c8c8
--- /dev/null
+++ b/supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql
@@ -0,0 +1,185 @@
+-- Reassert the fail-closed default-ACL postcondition after the later
+-- performance migrations so this safety check remains the final migration.
+-- Catalog-level, fail-closed verification for future objects created by
+-- supabase_admin. A missing pg_default_acl row must be interpreted through
+-- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions.
+
+create or replace function public.default_privileges_status(
+ p_role_name text default 'supabase_admin',
+ p_schema_name text default 'public'
+)
+returns jsonb
+language plpgsql
+stable
+security definer
+set search_path = ''
+as $$
+declare
+ v_role_oid oid;
+ v_namespace_oid oid;
+ v_entries text[] := '{}'::text[];
+ v_safe boolean := false;
+ v_has_unexpected_grantee boolean := false;
+ v_has_grantable boolean := false;
+begin
+ select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name;
+ select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name;
+
+ if v_role_oid is null or v_namespace_oid is null then
+ return jsonb_build_object(
+ 'role_exists', v_role_oid is not null,
+ 'schema_exists', v_namespace_oid is not null,
+ 'safe', false,
+ 'entries', '[]'::jsonb
+ );
+ end if;
+
+ with object_types(object_type, object_code) as (
+ values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char")
+ ), effective_acls as (
+ select
+ ot.object_type,
+ coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid))
+ || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl
+ from object_types ot
+ left join pg_catalog.pg_default_acl global_acl
+ on global_acl.defaclrole = v_role_oid
+ and global_acl.defaclnamespace = 0
+ and global_acl.defaclobjtype = ot.object_code
+ left join pg_catalog.pg_default_acl schema_acl
+ on schema_acl.defaclrole = v_role_oid
+ and schema_acl.defaclnamespace = v_namespace_oid
+ and schema_acl.defaclobjtype = ot.object_code
+ ), exploded as (
+ select distinct
+ ea.object_type,
+ case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee,
+ lower(privilege.privilege_type) as privilege_type,
+ privilege.is_grantable
+ from effective_acls ea
+ cross join lateral pg_catalog.aclexplode(ea.acl) privilege
+ left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee
+ )
+ select
+ coalesce(
+ array_agg(format('%s:%s:%s', object_type, grantee, privilege_type)
+ order by object_type, grantee, privilege_type),
+ '{}'::text[]
+ ),
+ coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false),
+ coalesce(bool_or(is_grantable), false)
+ into v_entries, v_has_unexpected_grantee, v_has_grantable
+ from exploded;
+
+ v_safe :=
+ not v_has_unexpected_grantee
+ and not v_has_grantable
+ and not exists (
+ select 1 from unnest(v_entries) entry
+ where entry like 'table:PUBLIC:%'
+ or entry like 'table:anon:%'
+ or entry like 'table:authenticated:%'
+ or entry like 'sequence:PUBLIC:%'
+ or entry like 'sequence:anon:%'
+ or entry like 'sequence:authenticated:%'
+ or entry = 'function:PUBLIC:execute'
+ or entry like 'function:anon:%'
+ or entry like 'function:authenticated:%'
+ )
+ and 'table:service_role:select' = any(v_entries)
+ and 'table:service_role:insert' = any(v_entries)
+ and 'table:service_role:update' = any(v_entries)
+ and 'table:service_role:delete' = any(v_entries)
+ and 'sequence:service_role:usage' = any(v_entries)
+ and 'sequence:service_role:select' = any(v_entries)
+ and 'function:service_role:execute' = any(v_entries)
+ and not exists (
+ select 1 from unnest(v_entries) entry
+ where entry like 'table:service_role:%'
+ and entry <> all(array[
+ 'table:service_role:select', 'table:service_role:insert',
+ 'table:service_role:update', 'table:service_role:delete'
+ ])
+ )
+ and not exists (
+ select 1 from unnest(v_entries) entry
+ where entry like 'sequence:service_role:%'
+ and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select'])
+ )
+ and not exists (
+ select 1 from unnest(v_entries) entry
+ where entry like 'function:service_role:%'
+ and entry <> 'function:service_role:execute'
+ );
+
+ return jsonb_build_object(
+ 'role_exists', true,
+ 'schema_exists', true,
+ 'safe', v_safe,
+ 'entries', to_jsonb(v_entries)
+ );
+end;
+$$;
+
+revoke all on function public.default_privileges_status(text, text)
+ from public, anon, authenticated;
+grant execute on function public.default_privileges_status(text, text)
+ to service_role;
+
+do $$
+declare
+ v_status jsonb;
+begin
+ if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then
+ raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable';
+ return;
+ end if;
+
+ begin
+ -- Local/Superuser-capable environments can assume the target role even
+ -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE
+ -- directly. Hosted environments that cannot assume it fall through to the
+ -- catalog assertion and block with operator instructions.
+ execute 'set local role supabase_admin';
+ -- Revokes must be global: per-schema ACLs cannot subtract privileges from
+ -- built-in or previously granted global defaults.
+ alter default privileges for role supabase_admin
+ revoke all privileges on tables from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin in schema public
+ revoke all privileges on tables from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin
+ revoke all privileges on sequences from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin in schema public
+ revoke all privileges on sequences from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin
+ revoke execute on functions from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin in schema public
+ revoke execute on functions from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin in schema public
+ grant select, insert, update, delete on tables to service_role;
+ alter default privileges for role supabase_admin in schema public
+ grant usage, select on sequences to service_role;
+ alter default privileges for role supabase_admin in schema public
+ grant execute on functions to service_role;
+ execute 'reset role';
+ exception when insufficient_privilege then
+ begin execute 'reset role'; exception when others then null; end;
+ raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user;
+ end;
+
+ v_status := public.default_privileges_status('supabase_admin', 'public');
+ if not coalesce((v_status->>'safe')::boolean, false) then
+ raise exception using
+ errcode = '42501',
+ message = 'Unsafe supabase_admin default privileges; migration blocked.',
+ detail = v_status::text,
+ hint = E'Run these six statements as supabase_admin, then retry the migration:\n'
+ 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n'
+ 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n'
+ 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n'
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n'
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n'
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;';
+ end if;
+end;
+$$;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 9a4175541..b3f440e2a 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -4982,6 +4982,184 @@ begin
end;
end $$;
+-- Performance remediation: registry projection lifecycle cleanup.
+create index if not exists documents_registry_projection_lookup_idx
+ on public.documents (
+ (metadata->>'registry_record_kind'),
+ (metadata->>'registry_record_id')
+ )
+ where metadata->>'source_kind' = 'registry_record';
+
+create or replace function public.cleanup_registry_corpus_document()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ delete from public.documents
+ where metadata->>'source_kind' = 'registry_record'
+ and metadata->>'registry_record_kind' = case tg_table_name
+ when 'clinical_registry_records' then pg_catalog.to_jsonb(old)->>'kind'
+ when 'medication_records' then 'medication'
+ when 'differential_records' then 'differential'
+ else null
+ end
+ and metadata->>'registry_record_id' = old.id::text;
+ return old;
+end;
+$$;
+
+revoke execute on function public.cleanup_registry_corpus_document()
+ from public, anon, authenticated, service_role;
+
+-- Performance remediation: privacy-preserving indexed title vocabulary.
+create table if not exists public.document_title_words (
+ word text not null,
+ document_id uuid not null references public.documents(id) on delete cascade,
+ primary key (word, document_id),
+ constraint document_title_words_word_length check (length(word) between 4 and 40),
+ constraint document_title_words_lowercase check (word = lower(word))
+);
+
+create index if not exists document_title_words_word_trgm_idx
+ on public.document_title_words using gin (word extensions.gin_trgm_ops);
+
+create index if not exists document_title_words_document_id_idx
+ on public.document_title_words (document_id);
+create index if not exists rag_aliases_canonical_trgm_idx
+ on public.rag_aliases using gin (lower(canonical) extensions.gin_trgm_ops);
+
+alter table public.document_title_words enable row level security;
+revoke all on table public.document_title_words from public, anon, authenticated;
+grant select, insert, update, delete on table public.document_title_words to service_role;
+
+create or replace function public.sync_document_title_words()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if tg_op <> 'INSERT' then
+ delete from public.document_title_words where document_id = old.id;
+ end if;
+ if tg_op <> 'DELETE' and new.owner_id is null and new.status = 'indexed' then
+ insert into public.document_title_words (word, document_id)
+ select distinct lower(title_word), new.id
+ from pg_catalog.unnest(pg_catalog.regexp_split_to_array(lower(new.title), '[^a-z]+')) as title_word
+ where length(title_word) between 4 and 40
+ on conflict do nothing;
+ end if;
+ return null;
+end;
+$$;
+
+revoke execute on function public.sync_document_title_words()
+ from public, anon, authenticated, service_role;
+drop trigger if exists documents_sync_title_words on public.documents;
+create trigger documents_sync_title_words
+ after insert or update of title, status, owner_id or delete on public.documents
+ for each row execute function public.sync_document_title_words();
+
+insert into public.document_title_words (word, document_id)
+select distinct lower(title_word), d.id
+from public.documents d
+cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word
+where d.owner_id is null and d.status = 'indexed'
+ and length(title_word) between 4 and 40
+on conflict do nothing;
+
+create or replace function public.correct_clinical_query_terms(
+ input_query text,
+ min_sim real default 0.45
+)
+returns text
+language plpgsql
+stable
+security definer
+set search_path = pg_catalog, extensions
+as $$
+declare
+ tokens text[];
+ tok text;
+ best text;
+ best_sim real;
+ corrected text[] := array[]::text[];
+ changed boolean := false;
+begin
+ if input_query is null or length(trim(input_query)) = 0 then
+ return input_query;
+ end if;
+ tokens := regexp_split_to_array(lower(input_query), '\s+');
+ foreach tok in array tokens loop
+ if length(tok) < 4 then
+ corrected := corrected || tok;
+ continue;
+ end if;
+ best := null;
+ best_sim := 0;
+ select candidate.term, candidate.match_sim
+ into best, best_sim
+ from (
+ (
+ select
+ lower(canonical) as term,
+ similarity(lower(alias), tok) as match_sim
+ from public.rag_aliases
+ where enabled
+ and owner_id is null
+ and length(alias) between 4 and 40
+ and length(canonical) between 4 and 40
+ and lower(alias) % tok
+ order by similarity(lower(alias), tok) desc, lower(alias)
+ limit 32
+ )
+ union all
+ (
+ select
+ lower(canonical) as term,
+ similarity(lower(canonical), tok) as match_sim
+ from public.rag_aliases
+ where enabled
+ and owner_id is null
+ and length(canonical) between 4 and 40
+ and lower(canonical) % tok
+ order by similarity(lower(canonical), tok) desc, lower(canonical)
+ limit 32
+ )
+ union all
+ (
+ select
+ word as term,
+ similarity(word, tok) as match_sim
+ from public.document_title_words
+ where length(word) between 4 and 40
+ and word % tok
+ order by similarity(word, tok) desc, word
+ limit 32
+ )
+ ) candidate
+ order by candidate.match_sim desc, candidate.term
+ limit 1;
+ if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then
+ corrected := corrected || best;
+ changed := true;
+ else
+ corrected := corrected || tok;
+ end if;
+ end loop;
+ if not changed then
+ return input_query;
+ end if;
+ return array_to_string(corrected, ' ');
+end;
+$$;
+
+revoke execute on function public.correct_clinical_query_terms(text, real)
+ from public, anon, authenticated;
+grant execute on function public.correct_clinical_query_terms(text, real) to service_role;
+
revoke usage on schema public from anon;
grant usage on schema public to authenticated, service_role;
@@ -5021,6 +5199,8 @@ grant select, insert, update, delete on table public.audit_logs to service_role;
grant usage, select on all sequences in schema public to service_role;
grant execute on all functions in schema public to service_role;
+revoke execute on function public.cleanup_registry_corpus_document() from service_role;
+revoke execute on function public.sync_document_title_words() from service_role;
revoke execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) from public, anon, authenticated;
grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role;
revoke execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) from public, anon, authenticated;
@@ -8139,3 +8319,367 @@ begin
end if;
end;
$$;
+drop trigger if exists clinical_registry_records_delete_cleanup on public.clinical_registry_records;
+create trigger clinical_registry_records_delete_cleanup
+ after delete on public.clinical_registry_records
+ for each row execute function public.cleanup_registry_corpus_document();
+drop trigger if exists medication_records_delete_cleanup on public.medication_records;
+create trigger medication_records_delete_cleanup
+ after delete on public.medication_records
+ for each row execute function public.cleanup_registry_corpus_document();
+drop trigger if exists differential_records_delete_cleanup on public.differential_records;
+create trigger differential_records_delete_cleanup
+ after delete on public.differential_records
+ for each row execute function public.cleanup_registry_corpus_document();
+
+create or replace function public.consume_summary_rate_limits_atomic(
+ p_owner_id uuid,
+ p_subject_key text,
+ p_answer_limit integer,
+ p_answer_window_seconds integer,
+ p_summary_limit integer,
+ p_summary_window_seconds integer,
+ p_global_answer_limit integer,
+ p_global_answer_window_seconds integer
+)
+returns table (
+ bucket text,
+ limited boolean,
+ limit_value integer,
+ remaining integer,
+ retry_after_seconds integer,
+ reset_at timestamptz
+)
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ v_now timestamptz := pg_catalog.statement_timestamp();
+ v_policy record;
+ v_count integer;
+ v_remaining integer;
+ v_reset_at timestamptz;
+ v_min_remaining integer := 2147483647;
+ v_success_limit integer;
+ v_success_reset_at timestamptz;
+begin
+ if (p_owner_id is null) = (p_subject_key is null or pg_catalog.btrim(p_subject_key) = '') then
+ raise exception 'exactly one owner_id or subject_key is required';
+ end if;
+ if p_answer_limit is null or p_answer_limit < 1
+ or p_answer_window_seconds is null or p_answer_window_seconds < 1
+ or p_summary_limit is null or p_summary_limit < 1
+ or p_summary_window_seconds is null or p_summary_window_seconds < 1
+ or p_global_answer_limit is null or p_global_answer_limit < 1
+ or p_global_answer_window_seconds is null or p_global_answer_window_seconds < 1 then
+ raise exception 'limits and windows must be positive';
+ end if;
+
+ if p_owner_id is not null then
+ insert into public.api_rate_limits (owner_id, bucket, window_start, request_count, updated_at)
+ values
+ (p_owner_id, 'answer', v_now, 0, v_now),
+ (p_owner_id, 'document_summarize', v_now, 0, v_now)
+ on conflict on constraint api_rate_limits_pkey do nothing;
+ perform 1
+ from public.api_rate_limits as rl
+ where rl.owner_id = p_owner_id and rl.bucket in ('answer', 'document_summarize')
+ order by rl.bucket
+ for update;
+ for v_policy in
+ select * from (values
+ ('answer'::text, 1, p_answer_limit, p_answer_window_seconds),
+ ('document_summarize'::text, 2, p_summary_limit, p_summary_window_seconds)
+ ) as policy(bucket, ordinal, limit_value, window_seconds)
+ order by ordinal
+ loop
+ update public.api_rate_limits as rl
+ set
+ window_start = case
+ when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then v_now
+ else rl.window_start
+ end,
+ request_count = case
+ when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then 1
+ else rl.request_count + 1
+ end,
+ updated_at = v_now
+ where rl.owner_id = p_owner_id and rl.bucket = v_policy.bucket
+ returning rl.request_count,
+ rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds)
+ into v_count, v_reset_at;
+ v_remaining := greatest(v_policy.limit_value - v_count, 0);
+ if v_remaining < v_min_remaining then
+ v_min_remaining := v_remaining;
+ v_success_limit := v_policy.limit_value;
+ v_success_reset_at := v_reset_at;
+ end if;
+ if v_count > v_policy.limit_value then
+ return query select
+ v_policy.bucket::text,
+ true,
+ v_policy.limit_value::integer,
+ 0,
+ greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer),
+ v_reset_at;
+ return;
+ end if;
+ end loop;
+ else
+ insert into public.api_rate_limit_subjects (subject_key, bucket, window_start, request_count, updated_at)
+ values
+ (p_subject_key, 'answer', v_now, 0, v_now),
+ ('anon:answer:global', 'answer', v_now, 0, v_now),
+ (p_subject_key, 'document_summarize', v_now, 0, v_now)
+ on conflict on constraint api_rate_limit_subjects_pkey do nothing;
+ perform 1
+ from public.api_rate_limit_subjects as rl
+ where (rl.subject_key, rl.bucket) in (
+ (p_subject_key, 'answer'),
+ ('anon:answer:global', 'answer'),
+ (p_subject_key, 'document_summarize')
+ )
+ order by rl.subject_key, rl.bucket
+ for update;
+ for v_policy in
+ select * from (values
+ ('answer'::text, 1, p_subject_key, p_answer_limit, p_answer_window_seconds, 'answer'::text),
+ ('answer'::text, 2, 'anon:answer:global', p_global_answer_limit, p_global_answer_window_seconds, 'answer'::text),
+ ('document_summarize'::text, 3, p_subject_key, p_summary_limit, p_summary_window_seconds, 'document_summarize'::text)
+ ) as policy(bucket, ordinal, subject_key, limit_value, window_seconds, rejection_bucket)
+ order by ordinal
+ loop
+ update public.api_rate_limit_subjects as rl
+ set
+ window_start = case
+ when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then v_now
+ else rl.window_start
+ end,
+ request_count = case
+ when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then 1
+ else rl.request_count + 1
+ end,
+ updated_at = v_now
+ where rl.subject_key = v_policy.subject_key and rl.bucket = v_policy.bucket
+ returning rl.request_count,
+ rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds)
+ into v_count, v_reset_at;
+ v_remaining := greatest(v_policy.limit_value - v_count, 0);
+ if v_remaining < v_min_remaining then
+ v_min_remaining := v_remaining;
+ v_success_limit := v_policy.limit_value;
+ v_success_reset_at := v_reset_at;
+ end if;
+ if v_count > v_policy.limit_value then
+ return query select
+ v_policy.rejection_bucket::text,
+ true,
+ v_policy.limit_value::integer,
+ 0,
+ greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer),
+ v_reset_at;
+ return;
+ end if;
+ end loop;
+ end if;
+ return query select
+ null::text,
+ false,
+ v_success_limit,
+ v_min_remaining,
+ greatest(1, pg_catalog.ceil(extract(epoch from (v_success_reset_at - v_now)))::integer),
+ v_success_reset_at;
+end;
+$$;
+
+revoke execute on function public.consume_summary_rate_limits_atomic(
+ uuid, text, integer, integer, integer, integer, integer, integer
+) from public, anon, authenticated;
+grant execute on function public.consume_summary_rate_limits_atomic(
+ uuid, text, integer, integer, integer, integer, integer, integer
+) to service_role;
+
+-- Catalog-level, fail-closed verification for future objects created by
+-- supabase_admin. A missing pg_default_acl row must be interpreted through
+-- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions.
+
+create or replace function public.default_privileges_status(
+ p_role_name text default 'supabase_admin',
+ p_schema_name text default 'public'
+)
+returns jsonb
+language plpgsql
+stable
+security definer
+set search_path = ''
+as $$
+declare
+ v_role_oid oid;
+ v_namespace_oid oid;
+ v_entries text[] := '{}'::text[];
+ v_safe boolean := false;
+ v_has_unexpected_grantee boolean := false;
+ v_has_grantable boolean := false;
+begin
+ select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name;
+ select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name;
+
+ if v_role_oid is null or v_namespace_oid is null then
+ return jsonb_build_object(
+ 'role_exists', v_role_oid is not null,
+ 'schema_exists', v_namespace_oid is not null,
+ 'safe', false,
+ 'entries', '[]'::jsonb
+ );
+ end if;
+
+ with object_types(object_type, object_code) as (
+ values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char")
+ ), effective_acls as (
+ select
+ ot.object_type,
+ coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid))
+ || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl
+ from object_types ot
+ left join pg_catalog.pg_default_acl global_acl
+ on global_acl.defaclrole = v_role_oid
+ and global_acl.defaclnamespace = 0
+ and global_acl.defaclobjtype = ot.object_code
+ left join pg_catalog.pg_default_acl schema_acl
+ on schema_acl.defaclrole = v_role_oid
+ and schema_acl.defaclnamespace = v_namespace_oid
+ and schema_acl.defaclobjtype = ot.object_code
+ ), exploded as (
+ select distinct
+ ea.object_type,
+ case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee,
+ lower(privilege.privilege_type) as privilege_type,
+ privilege.is_grantable
+ from effective_acls ea
+ cross join lateral pg_catalog.aclexplode(ea.acl) privilege
+ left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee
+ )
+ select
+ coalesce(
+ array_agg(format('%s:%s:%s', object_type, grantee, privilege_type)
+ order by object_type, grantee, privilege_type),
+ '{}'::text[]
+ ),
+ coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false),
+ coalesce(bool_or(is_grantable), false)
+ into v_entries, v_has_unexpected_grantee, v_has_grantable
+ from exploded;
+
+ v_safe :=
+ not v_has_unexpected_grantee
+ and not v_has_grantable
+ and not exists (
+ select 1 from unnest(v_entries) entry
+ where entry like 'table:PUBLIC:%'
+ or entry like 'table:anon:%'
+ or entry like 'table:authenticated:%'
+ or entry like 'sequence:PUBLIC:%'
+ or entry like 'sequence:anon:%'
+ or entry like 'sequence:authenticated:%'
+ or entry = 'function:PUBLIC:execute'
+ or entry like 'function:anon:%'
+ or entry like 'function:authenticated:%'
+ )
+ and 'table:service_role:select' = any(v_entries)
+ and 'table:service_role:insert' = any(v_entries)
+ and 'table:service_role:update' = any(v_entries)
+ and 'table:service_role:delete' = any(v_entries)
+ and 'sequence:service_role:usage' = any(v_entries)
+ and 'sequence:service_role:select' = any(v_entries)
+ and 'function:service_role:execute' = any(v_entries)
+ and not exists (
+ select 1 from unnest(v_entries) entry
+ where entry like 'table:service_role:%'
+ and entry <> all(array[
+ 'table:service_role:select', 'table:service_role:insert',
+ 'table:service_role:update', 'table:service_role:delete'
+ ])
+ )
+ and not exists (
+ select 1 from unnest(v_entries) entry
+ where entry like 'sequence:service_role:%'
+ and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select'])
+ )
+ and not exists (
+ select 1 from unnest(v_entries) entry
+ where entry like 'function:service_role:%'
+ and entry <> 'function:service_role:execute'
+ );
+
+ return jsonb_build_object(
+ 'role_exists', true,
+ 'schema_exists', true,
+ 'safe', v_safe,
+ 'entries', to_jsonb(v_entries)
+ );
+end;
+$$;
+
+revoke all on function public.default_privileges_status(text, text)
+ from public, anon, authenticated;
+grant execute on function public.default_privileges_status(text, text)
+ to service_role;
+
+do $$
+declare
+ v_status jsonb;
+begin
+ if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then
+ raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable';
+ return;
+ end if;
+
+ begin
+ -- Local/Superuser-capable environments can assume the target role even
+ -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE
+ -- directly. Hosted environments that cannot assume it fall through to the
+ -- catalog assertion and block with operator instructions.
+ execute 'set local role supabase_admin';
+ -- Revokes must be global: per-schema ACLs cannot subtract privileges from
+ -- built-in or previously granted global defaults.
+ alter default privileges for role supabase_admin
+ revoke all privileges on tables from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin in schema public
+ revoke all privileges on tables from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin
+ revoke all privileges on sequences from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin in schema public
+ revoke all privileges on sequences from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin
+ revoke execute on functions from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin in schema public
+ revoke execute on functions from public, anon, authenticated, service_role;
+ alter default privileges for role supabase_admin in schema public
+ grant select, insert, update, delete on tables to service_role;
+ alter default privileges for role supabase_admin in schema public
+ grant usage, select on sequences to service_role;
+ alter default privileges for role supabase_admin in schema public
+ grant execute on functions to service_role;
+ execute 'reset role';
+ exception when insufficient_privilege then
+ begin execute 'reset role'; exception when others then null; end;
+ raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user;
+ end;
+
+ v_status := public.default_privileges_status('supabase_admin', 'public');
+ if not coalesce((v_status->>'safe')::boolean, false) then
+ raise exception using
+ errcode = '42501',
+ message = 'Unsafe supabase_admin default privileges; migration blocked.',
+ detail = v_status::text,
+ hint = E'Run these six statements as supabase_admin, then retry the migration:\n'
+ 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n'
+ 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n'
+ 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n'
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n'
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n'
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;';
+ end if;
+end;
+$$;
diff --git a/tests/answer-client-payload.test.ts b/tests/answer-client-payload.test.ts
index 1cdc0f07a..78fda5eb0 100644
--- a/tests/answer-client-payload.test.ts
+++ b/tests/answer-client-payload.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { toClientAnswerPayload } from "@/lib/answer-client-payload";
+import { buildGovernedAnswerClientResponse, buildGovernedDemoAnswerClientResponse } from "@/lib/answer-response";
import { extractSafetyFindings } from "@/lib/clinical-safety";
import type { RagAnswer, SearchResult } from "@/lib/types";
@@ -32,6 +33,23 @@ function answerWith(sources: SearchResult[]): Pick {
}
describe("toClientAnswerPayload", () => {
+ it("governs empty-source real and demo answers without requiring source fields", () => {
+ const answer = {
+ answer: "No source details.",
+ grounded: true,
+ confidence: "high",
+ citations: [],
+ sources: [],
+ } as RagAnswer;
+
+ expect(buildGovernedAnswerClientResponse(answer).payload).toMatchObject({ sources: [], safetyWarnings: [] });
+ expect(buildGovernedDemoAnswerClientResponse(answer)).toMatchObject({
+ sources: [],
+ safetyWarnings: [],
+ demoMode: true,
+ });
+ });
+
it("drops server-only per-source fields the client never renders", () => {
const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0];
expect(trimmed.adjacent_context).toBeUndefined();
@@ -39,6 +57,22 @@ describe("toClientAnswerPayload", () => {
expect(trimmed.table_facts).toBeUndefined();
expect(trimmed.index_unit).toBeUndefined();
expect(trimmed.document_summary).toBeUndefined();
+ expect(trimmed.images).toEqual([]);
+ });
+
+ it("does not serialize bulky source image objects", () => {
+ const image = {
+ id: "image-1",
+ storage_path: "private/source/page-4.png",
+ metadata: { raw: "x".repeat(8_000) },
+ table_markdown: `| heading |\n| --- |\n| ${"cell ".repeat(1_000)} |`,
+ } as never;
+ const source = fullSource({ image_ids: ["image-1"], images: [image] });
+ const trimmed = toClientAnswerPayload(answerWith([source])).sources![0];
+
+ expect(trimmed.image_ids).toEqual(["image-1"]);
+ expect(trimmed.images).toEqual([]);
+ expect(JSON.stringify(trimmed)).not.toContain("private/source/page-4.png");
});
it("does not pass unclassified runtime fields through the route boundary", () => {
@@ -54,27 +88,32 @@ describe("toClientAnswerPayload", () => {
expect(trimmed.id).toBe("chunk-1");
expect(trimmed.title).toBe("Clozapine monitoring guideline");
expect(trimmed.retrieval_synopsis).toBe("FBC weekly for 18 weeks, then monthly.");
+ expect(trimmed.content).toBe("FBC weekly for 18 weeks, then monthly.");
expect(trimmed.similarity).toBe(0.82);
expect(trimmed.source_metadata).toEqual({ document_status: "current" });
expect(trimmed.page_number).toBe(4);
});
- it("preserves full source content so client-side safety scanning cannot miss later warnings", () => {
+ it("derives safety warnings before replacing full source content with the rendered snippet", () => {
const source = fullSource({ content: `${"Routine context. ".repeat(60)}Contraindicated in severe disease.` });
- const payload = toClientAnswerPayload({
+ const response = buildGovernedAnswerClientResponse({
answer: "Review the source.",
grounded: true,
confidence: "medium",
citations: [],
sources: [source],
} as RagAnswer);
+ const payload = response.payload;
- expect(payload.sources![0].content).toBe(source.content);
+ expect(payload.sources![0].content).toBe(source.retrieval_synopsis);
+ expect(payload.sources![0].content.length).toBeLessThan(source.content.length);
+ expect(payload.safetyWarnings).toHaveLength(1);
+ expect(payload.safetyWarnings![0].citation).not.toHaveProperty("source_metadata");
expect(extractSafetyFindings(payload)).toHaveLength(1);
});
it("leaves short content untouched", () => {
- const short = fullSource({ content: "Short snippet." });
+ const short = fullSource({ content: "Short snippet.", retrieval_synopsis: undefined });
expect(toClientAnswerPayload(answerWith([short])).sources![0].content).toBe("Short snippet.");
});
diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts
index a0ce75871..76c1e0192 100644
--- a/tests/api-rate-limit-fallback.test.ts
+++ b/tests/api-rate-limit-fallback.test.ts
@@ -105,6 +105,56 @@ describe("paid anonymous answer limits", () => {
});
});
+describe("atomic streamed-summary limits", () => {
+ it("applies anonymous caller, global-answer, and summary policies in one RPC", async () => {
+ const { consumeSummaryRateLimits } = await import("../src/lib/api-rate-limit");
+ const rpc = vi.fn(async () => ({
+ data: [
+ {
+ bucket: null,
+ limited: false,
+ limit_value: 12,
+ remaining: 5,
+ retry_after_seconds: 60,
+ reset_at: new Date(Date.now() + 60_000).toISOString(),
+ },
+ ],
+ error: null,
+ }));
+
+ const decision = await consumeSummaryRateLimits({
+ supabase: { rpc } as never,
+ subject: { kind: "anonymous", subjectKey: "anon:caller" },
+ });
+
+ expect(decision).toMatchObject({ bucket: null, rateLimit: { limited: false, limit: 12, remaining: 5 } });
+ expect(rpc).toHaveBeenCalledTimes(1);
+ expect(rpc).toHaveBeenCalledWith("consume_summary_rate_limits_atomic", {
+ p_owner_id: null,
+ p_subject_key: "anon:caller",
+ p_answer_limit: 6,
+ p_answer_window_seconds: 60,
+ p_summary_limit: 12,
+ p_summary_window_seconds: 60,
+ p_global_answer_limit: 30,
+ p_global_answer_window_seconds: 60,
+ });
+ });
+
+ it("fails closed when the atomic RPC is unavailable", async () => {
+ const { ApiRateLimitUnavailableError, consumeSummaryRateLimits } = await import("../src/lib/api-rate-limit");
+ const rpc = vi.fn(async () => ({ data: null, error: { code: "PGRST202", message: "missing RPC" } }));
+
+ await expect(
+ consumeSummaryRateLimits({
+ supabase: { rpc } as never,
+ subject: { kind: "owner", ownerId: "owner-1" },
+ }),
+ ).rejects.toBeInstanceOf(ApiRateLimitUnavailableError);
+ expect(rpc).toHaveBeenCalledTimes(1);
+ });
+});
+
describe("document_upload fail-closed limiter", () => {
it("fails closed (does not fall back to per-instance memory) when the durable limiter is unavailable", async () => {
vi.stubEnv("NODE_ENV", "production");
diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts
index 2a0becd58..41743422c 100644
--- a/tests/api-validation-contract.test.ts
+++ b/tests/api-validation-contract.test.ts
@@ -101,6 +101,11 @@ class QueryBuilder implements PromiseLike {
return this;
}
+ abortSignal(signal: AbortSignal) {
+ void signal;
+ return this;
+ }
+
maybeSingle() {
this.call.maybeSingle = true;
return this.resolve();
diff --git a/tests/bundle-budget.test.ts b/tests/bundle-budget.test.ts
index e8831f48f..f15d9d751 100644
--- a/tests/bundle-budget.test.ts
+++ b/tests/bundle-budget.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { compareToBudget, measureChunks } from "../scripts/check-bundle-budget.mjs";
+import {
+ compareToBudget,
+ findFixtureSnapshotsInChunks,
+ initialDashboardChunkNames,
+ measureChunks,
+} from "../scripts/check-bundle-budget.mjs";
const buf = (n: number) => Buffer.alloc(n, "a"); // highly compressible; gzip < raw
@@ -46,3 +51,51 @@ describe("compareToBudget", () => {
expect(v.status).toBe("ok");
});
});
+
+describe("initial dashboard fixture boundary", () => {
+ it("resolves root layout, page, and shared chunks without dynamic route chunks", () => {
+ expect(
+ initialDashboardChunkNames({
+ rootMainFiles: ["static/chunks/main.js"],
+ pages: {
+ "/layout": ["static/chunks/layout.js", "static/css/layout.css"],
+ "/page": ["static/chunks/page.js"],
+ "/documents/[id]/page": ["static/chunks/document-viewer.js"],
+ },
+ }),
+ ).toEqual(["main.js", "layout.js", "page.js"]);
+ });
+
+ it("resolves Next 16 root-page chunks from the client-reference manifest", () => {
+ expect(
+ initialDashboardChunkNames(
+ {
+ rootMainFiles: ["static/chunks/main-app.js"],
+ pages: { "/_app": [] },
+ },
+ {
+ clientModules: {
+ home: { chunks: ["1234", "static/chunks/1234-home.js"] },
+ lazy: { chunks: [] },
+ },
+ },
+ ),
+ ).toEqual(["main-app.js", "1234-home.js"]);
+ });
+
+ it("detects complete fixture marker groups in initial chunks", () => {
+ const violations = findFixtureSnapshotsInChunks([
+ {
+ name: "page.js",
+ buffer: Buffer.from("transport-crisis-form extension-transport-order detention-examination-movement"),
+ },
+ ]);
+ expect(violations).toEqual(["forms fixture catalogue"]);
+ });
+
+ it("does not flag an isolated UI string as a serialized fixture", () => {
+ expect(
+ findFixtureSnapshotsInChunks([{ name: "page.js", buffer: Buffer.from("Try first episode psychosis") }]),
+ ).toEqual([]);
+ });
+});
diff --git a/tests/client-performance-boundaries.test.ts b/tests/client-performance-boundaries.test.ts
new file mode 100644
index 000000000..ed3d2b525
--- /dev/null
+++ b/tests/client-performance-boundaries.test.ts
@@ -0,0 +1,60 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+function source(path: string) {
+ return readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
+}
+
+describe("fixture-free client performance boundaries", () => {
+ it("keeps service and form rankers independent of server fixture catalogues", () => {
+ const serviceRanker = source("src/lib/service-ranker.ts");
+ const formRanker = source("src/lib/form-ranker.ts");
+ expect(serviceRanker).not.toMatch(/registry-fixtures|services-snapshot|defaultServiceRecords/);
+ expect(formRanker).not.toMatch(/@\/lib\/forms|formRecords|registry-fixtures/);
+ expect(source("src/lib/services.ts")).toContain('export * from "@/lib/service-ranker"');
+ expect(source("src/lib/forms.ts")).toContain('export * from "@/lib/form-ranker"');
+ });
+
+ it("keeps differential result composition independent of the generated snapshot", () => {
+ const composition = source("src/lib/differential-search-composition.ts");
+ expect(composition).not.toMatch(/differential-fixtures|differentials-snapshot|loadDifferentialSnapshot/);
+ const dashboard = source("src/components/clinical-dashboard/differentials-home.tsx");
+ expect(dashboard).toContain('from "@/lib/differential-search-composition"');
+ expect(dashboard).not.toContain('from "@/lib/differentials"');
+ });
+
+ it("keeps initial dashboard rankers on fixture-free entry points", () => {
+ const dashboard = source("src/components/ClinicalDashboard.tsx");
+ expect(dashboard).toContain('from "@/lib/form-ranker"');
+ expect(dashboard).toContain('from "@/lib/service-ranker"');
+ expect(source("src/lib/cross-mode-links.ts")).not.toMatch(/@\/lib\/(forms|services)"/);
+ });
+
+ it("loads administration data only after its source surface opens", () => {
+ const dashboard = source("src/components/ClinicalDashboard.tsx");
+ expect(dashboard).toContain("includeSetup: true, includeDashboardData: false");
+ expect(dashboard).toContain("dashboardDataSurfaceVisible && !dashboardDataLoadedRef.current");
+ expect(dashboard).toContain("administrationSurfaceVisible && !administrationDataLoadedRef.current");
+ expect(dashboard).toContain("userStartedIngestion && activeIndexingWork");
+ });
+
+ it("retains PDF.js as an on-demand import", () => {
+ const pdfViewer = source("src/components/document-viewer/pdf-canvas-viewer.tsx");
+ const eagerPdfJsImport = /^[ \t]*import[ \t]+(?!type\b)(?:[^\r\n"']+[ \t]+from[ \t]+)?["']pdfjs-dist["'][ \t]*;?/m;
+
+ expect(pdfViewer).toContain('await import("pdfjs-dist")');
+ expect(pdfViewer).not.toMatch(eagerPdfJsImport);
+ expect('import "pdfjs-dist";').toMatch(eagerPdfJsImport);
+ expect('import pdfjs from "pdfjs-dist";').toMatch(eagerPdfJsImport);
+ expect('import type { PDFDocumentProxy } from "pdfjs-dist";').not.toMatch(eagerPdfJsImport);
+ });
+
+ it("revalidates cached document download URLs on every viewer action", () => {
+ const viewer = source("src/components/DocumentViewer.tsx");
+
+ expect(viewer).toContain("const cached = getCachedSignedUrl(endpoint)");
+ expect(viewer).toContain('anchor.download = currentDocumentFileName || "clinical-source"');
+ expect(viewer).not.toContain("href={downloadSignedUrl}");
+ expect(viewer).not.toContain("downloadUrl={downloadSignedUrl}");
+ });
+});
diff --git a/tests/differentials-route.test.ts b/tests/differentials-route.test.ts
index 508e69cda..57b450169 100644
--- a/tests/differentials-route.test.ts
+++ b/tests/differentials-route.test.ts
@@ -2,6 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const token = "valid-token";
+const publicFixtureCacheControl = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400";
+
+function expectPublicFixtureCache(response: Response) {
+ expect(response.headers.get("cache-control")).toBe(publicFixtureCacheControl);
+ expect(response.headers.get("vary")).toBe("Cookie, Authorization");
+}
+
+function expectPrivateCache(response: Response) {
+ expect(response.headers.get("cache-control")).toBe("private, no-store");
+}
type QueryError = { message: string };
type QueryResult = { data: unknown; error: QueryError | null };
@@ -185,6 +195,7 @@ describe("differentials API routes", () => {
};
expect(response.status).toBe(200);
+ expectPrivateCache(response);
expect(payload.detailContext?.knownRelatedSlugs).toEqual([]);
expect(payload.detailContext?.overlapLinks).toEqual({});
expect(payload.detailContext?.comparePresentation).toBeNull();
@@ -205,6 +216,7 @@ describe("differentials API routes", () => {
};
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.demoMode).toBe(true);
expect(payload.record?.slug).toBe("delirium");
expect(payload.detailContext?.comparePresentation?.slug).toBe("acute-confusion-encephalopathy");
@@ -227,6 +239,7 @@ describe("differentials API routes", () => {
};
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.demoMode).toBe(true);
expect(payload.workflow?.id).toBe("acute-confusion-encephalopathy");
expect(payload.candidates?.length).toBeGreaterThan(0);
@@ -247,6 +260,7 @@ describe("differentials API routes", () => {
};
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.demoMode).toBe(true);
expect((payload.total ?? 0) > 100).toBe(true);
expect(payload.records?.length).toBeGreaterThan(0);
diff --git a/tests/document-detail-performance.test.ts b/tests/document-detail-performance.test.ts
new file mode 100644
index 000000000..b3840f113
--- /dev/null
+++ b/tests/document-detail-performance.test.ts
@@ -0,0 +1,120 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+function source(path: string) {
+ return readFileSync(resolve(process.cwd(), path), "utf8");
+}
+
+describe("document detail loading contract", () => {
+ it("uses one server-only authorized loader from both the route and page", () => {
+ const loader = source("src/lib/document-detail.ts");
+ const route = source("src/app/api/documents/[id]/route.ts");
+ const page = source("src/app/documents/[id]/page.tsx");
+
+ expect(loader).toContain('import "server-only"');
+ expect(loader).toContain("loadAuthorizedDocumentDetail");
+ expect(route).toContain("loadAuthorizedDocumentDetail");
+ expect(page).toContain("loadAuthorizedDocumentDetail");
+ expect(page).toContain("initialDetail={initialDetail}");
+ expect(page).toContain("initialError={initialError}");
+ expect(page).toContain('key={`${id}:${initialPage}:${query.chunk ?? ""}`}');
+ });
+
+ it("supports document and window asset scopes and starts independent detail reads together", () => {
+ const loader = source("src/lib/document-detail.ts");
+
+ expect(loader).toContain('assetScope: "document" | "window"');
+ expect(loader).toContain("Promise.all([");
+ expect(loader).toContain("pagesRequest");
+ expect(loader).toContain("chunksRequest");
+ expect(loader).toContain("imagesRequest");
+ expect(loader).toContain("tableFactsRequest");
+ expect(loader).toContain("labelsRequest");
+ expect(loader).toContain("summaryRequest");
+ expect(loader).toContain("selectedImageIds(selectedChunk)");
+ expect(loader).toContain("imagesRequest.or(imageWindowFilter");
+ expect(loader).toContain("and(image_type.neq.logo_decorative,or(searchable.eq.true,source_kind.eq.table_crop)");
+ expect(loader).toContain("id.in.(${imageIds.join");
+ expect(loader).toContain("tableFactsRequest.or(tableFactWindowFilter");
+ expect(loader).toContain("page_number.is.null");
+
+ const chunkGenerationFilter = loader.indexOf("const filteredChunkQuery = generationFilter");
+ const chunkRange = loader.indexOf("orderedChunkQuery.range", chunkGenerationFilter);
+ const tableFactGenerationFilter = loader.indexOf("tableFactsRequest = tableFactsRequest.or(generationFilter)");
+ const tableFactLimit = loader.indexOf(".limit(200)", tableFactGenerationFilter);
+ expect(chunkGenerationFilter).toBeGreaterThan(-1);
+ expect(chunkRange).toBeGreaterThan(chunkGenerationFilter);
+ expect(tableFactGenerationFilter).toBeGreaterThan(-1);
+ expect(tableFactLimit).toBeGreaterThan(tableFactGenerationFilter);
+ });
+
+ it("cancels every database phase and projects only viewer fields", () => {
+ const loader = source("src/lib/document-detail.ts");
+ const abortAttachments = loader.match(/\.abortSignal\(args\.request\.signal\)/g) ?? [];
+
+ expect(abortAttachments).toHaveLength(8);
+ expect(loader).toContain("args.request.signal.throwIfAborted()");
+ expect(loader).not.toContain('.select("*")');
+ expect(loader).toContain("documentDetailProjection");
+ expect(loader).toContain("tableFactDetailProjection");
+ expect(loader).toContain("map(withoutMetadata)");
+ expect(loader).toContain("map(withTableFactReviewMetadata)");
+ expect(loader).toContain("map(withDocumentLabelReviewMetadata)");
+ expect(loader).toContain("isHiddenDocumentLabel");
+ });
+
+ it("returns explicit demo, scope, and request-window metadata", () => {
+ const loader = source("src/lib/document-detail.ts");
+
+ expect(loader).toContain("demoMode:");
+ expect(loader).toContain("assetScope:");
+ expect(loader).toContain("window:");
+ expect(loader).toContain("requestedPage:");
+ expect(loader).toContain("effectivePage:");
+ });
+});
+
+describe("document viewer latency guards", () => {
+ it("server-prerenders the viewer without a setup-status round trip", () => {
+ const lazy = source("src/components/document-viewer-lazy.tsx");
+ const viewer = source("src/components/DocumentViewer.tsx");
+
+ expect(lazy).not.toContain("ssr: false");
+ expect(viewer).not.toContain("/api/setup-status");
+ });
+
+ it("loads window-scoped navigation details and renders one indexed-text panel", () => {
+ const viewer = source("src/components/DocumentViewer.tsx");
+ const panelInstances = viewer.match(/ {
+ const viewer = source("src/components/DocumentViewer.tsx");
+
+ expect(viewer).toContain("openSourcePreview");
+ expect(viewer).toContain("openSourceDownload");
+ expect(viewer).toContain("downloadActionRef");
+ expect(viewer.indexOf("?download=true")).toBeGreaterThan(viewer.indexOf("openSourceDownload"));
+ expect(viewer).not.toContain("fetchSignedUrlPair");
+ });
+});
diff --git a/tests/document-enrichment-retrieval-scope.test.ts b/tests/document-enrichment-retrieval-scope.test.ts
index f88c1d99c..d7b215f3d 100644
--- a/tests/document-enrichment-retrieval-scope.test.ts
+++ b/tests/document-enrichment-retrieval-scope.test.ts
@@ -35,4 +35,27 @@ describe("related-document metadata RPC rollout", () => {
});
expect(rpc).toHaveBeenCalledTimes(1);
});
+
+ it.each(["document_labels", "document_summaries"])("propagates %s fallback query errors", async (failedTable) => {
+ const rpc = vi.fn(async () => ({ data: null, error: { code: "42501", message: "permission denied" } }));
+ const fallbackError = Object.assign(new Error(`${failedTable} fallback failed`), { code: "PGRST000" });
+ const from = vi.fn((table: string) => {
+ const result = table === failedTable ? { data: null, error: fallbackError } : { data: [], error: null };
+ const query = {
+ select: () => query,
+ in: () => query,
+ is: () => query,
+ then: (resolve: (value: typeof result) => unknown) => Promise.resolve(result).then(resolve),
+ };
+ return query;
+ });
+
+ await expect(
+ fetchRelatedDocumentMetadata({
+ supabase: { rpc, from } as never,
+ accessScope: { includePublic: true },
+ documentIds: ["public-doc"],
+ }),
+ ).rejects.toBe(fallbackError);
+ });
});
diff --git a/tests/document-enrichment-visual-counts.test.ts b/tests/document-enrichment-visual-counts.test.ts
new file mode 100644
index 000000000..fe0d4fa40
--- /dev/null
+++ b/tests/document-enrichment-visual-counts.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { fetchRelatedDocumentMetadata, fetchRelatedDocuments } from "../src/lib/document-enrichment";
+import type { SearchResult } from "../src/lib/types";
+
+const results = [
+ {
+ id: "chunk-1",
+ document_id: "document-1",
+ title: "Clozapine monitoring",
+ file_name: "clozapine.pdf",
+ page_number: 2,
+ chunk_index: 0,
+ section_heading: "Monitoring",
+ content: "Clozapine monitoring guidance.",
+ image_ids: [],
+ similarity: 0.9,
+ hybrid_score: 0.92,
+ images: [],
+ },
+] as SearchResult[];
+
+function metadataRpc() {
+ return vi.fn(async () => ({
+ data: [{ document_id: "document-1", labels: [], summary: "Monitoring guidance." }],
+ error: null,
+ }));
+}
+
+describe("related-document visual counts", () => {
+ it("skips the visual-count query when includeVisualCounts is false", async () => {
+ const from = vi.fn(() => {
+ throw new Error("visual-count query should not run");
+ });
+ const supabase = { rpc: metadataRpc(), from };
+
+ const related = await fetchRelatedDocuments({
+ supabase: supabase as never,
+ query: "clozapine",
+ results,
+ includeVisualCounts: false,
+ });
+
+ expect(from).not.toHaveBeenCalled();
+ expect(related[0]).toMatchObject({ document_id: "document-1", image_count: 0, table_count: 0 });
+ });
+
+ it("keeps visual counts enabled by default", async () => {
+ const query = {
+ select: vi.fn(),
+ in: vi.fn(),
+ neq: vi.fn(async () => ({
+ data: [
+ {
+ document_id: "document-1",
+ source_kind: "table_crop",
+ searchable: true,
+ image_type: "table",
+ clinical_relevance_score: 0.9,
+ metadata: { clinical_use_class: "clinical_evidence" },
+ },
+ ],
+ error: null,
+ })),
+ };
+ query.select.mockReturnValue(query);
+ query.in.mockReturnValue(query);
+ const from = vi.fn(() => query);
+ const supabase = { rpc: metadataRpc(), from };
+
+ const related = await fetchRelatedDocuments({
+ supabase: supabase as never,
+ query: "clozapine",
+ results,
+ });
+
+ expect(from).toHaveBeenCalledWith("document_images");
+ expect(related[0]).toMatchObject({ document_id: "document-1", image_count: 1, table_count: 1 });
+ });
+
+ it("attaches the caller signal to metadata RPC builders", async () => {
+ const controller = new AbortController();
+ const abortSignal = vi.fn(async () => ({
+ data: [{ document_id: "document-1", labels: [], summary: null }],
+ error: null,
+ }));
+ const supabase = { rpc: vi.fn(() => ({ abortSignal })) };
+
+ await fetchRelatedDocumentMetadata({
+ supabase: supabase as never,
+ documentIds: ["document-1"],
+ signal: controller.signal,
+ });
+
+ expect(abortSignal).toHaveBeenCalledWith(controller.signal);
+ });
+});
diff --git a/tests/fixture-response-cache.test.ts b/tests/fixture-response-cache.test.ts
new file mode 100644
index 000000000..d3055559a
--- /dev/null
+++ b/tests/fixture-response-cache.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+
+import { fixtureResponseHeaders } from "../src/lib/fixture-response-cache";
+
+const publicFixtureCacheControl = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400";
+
+describe("fixture response cache policy", () => {
+ it("publicly caches only conclusively anonymous fixture responses", () => {
+ const anonymous = fixtureResponseHeaders(new Request("http://localhost/api/fixture"), { fixture: true });
+ const bearer = fixtureResponseHeaders(
+ new Request("http://localhost/api/fixture", { headers: { Authorization: "Bearer token" } }),
+ { fixture: true },
+ );
+ const sessionCookie = fixtureResponseHeaders(
+ new Request("http://localhost/api/fixture", { headers: { Cookie: "sb-project-auth-token=opaque" } }),
+ { fixture: true },
+ );
+
+ expect(anonymous.get("Cache-Control")).toBe(publicFixtureCacheControl);
+ expect(anonymous.get("Vary")).toBe("Cookie, Authorization");
+ expect(bearer.get("Cache-Control")).toBe("private, no-store");
+ expect(sessionCookie.get("Cache-Control")).toBe("private, no-store");
+ });
+
+ it("keeps live values private and merges existing Vary values without duplicates", () => {
+ const headers = fixtureResponseHeaders(new Request("http://localhost/api/live"), {
+ headers: { Vary: "Accept-Encoding, cookie" },
+ });
+
+ expect(headers.get("Cache-Control")).toBe("private, no-store");
+ expect(headers.get("Vary")).toBe("Accept-Encoding, cookie, Authorization");
+ });
+});
diff --git a/tests/medications-route.test.ts b/tests/medications-route.test.ts
index 2b30fb779..7d9aa281c 100644
--- a/tests/medications-route.test.ts
+++ b/tests/medications-route.test.ts
@@ -2,6 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const token = "valid-token";
+const publicFixtureCacheControl = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400";
+
+function expectPublicFixtureCache(response: Response) {
+ expect(response.headers.get("cache-control")).toBe(publicFixtureCacheControl);
+ expect(response.headers.get("vary")).toBe("Cookie, Authorization");
+}
+
+function expectPrivateCache(response: Response) {
+ expect(response.headers.get("cache-control")).toBe("private, no-store");
+}
const recordId = "11111111-1111-4111-8111-111111111111";
type QueryError = { message: string };
@@ -185,6 +195,7 @@ describe("medications API", () => {
const payload = (await response.json()) as { records: Array<{ slug: string }>; demoMode?: boolean };
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.demoMode).toBe(true);
expect(payload.records.some((record) => record.slug === "acamprosate")).toBe(true);
expect(client.from).not.toHaveBeenCalled();
@@ -224,6 +235,7 @@ describe("medications API", () => {
};
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.publicAccess).toBe(true);
expect(payload.records.some((record) => record.slug === "acamprosate")).toBe(true);
expect(payload.matches?.[0]?.medication.slug).toBe("acamprosate");
@@ -258,6 +270,7 @@ describe("medications API", () => {
};
expect(response.status).toBe(200);
+ expectPrivateCache(response);
expect(payload.records[0]?.slug).toBe("acamprosate");
expect(payload.matches?.[0]?.medication.slug).toBe("acamprosate");
expect(client.calls.some((call) => call.table === "medication_records")).toBe(true);
@@ -278,6 +291,7 @@ describe("medications API", () => {
};
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.publicAccess).toBe(true);
expect(payload.record.slug).toBe("acamprosate");
expect(payload.record.name).toBe("Acamprosate");
diff --git a/tests/owner-catalogue-cache.test.ts b/tests/owner-catalogue-cache.test.ts
new file mode 100644
index 000000000..cde0c058d
--- /dev/null
+++ b/tests/owner-catalogue-cache.test.ts
@@ -0,0 +1,218 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+async function loadCacheModule() {
+ return import("../src/lib/owner-catalogue-cache");
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+afterEach(async () => {
+ vi.useRealTimers();
+ const { clearOwnerCatalogueCache } = await loadCacheModule();
+ clearOwnerCatalogueCache();
+});
+
+describe("owner catalogue cache", () => {
+ it("reuses a successful catalogue across warm query prefixes", async () => {
+ const { loadOwnerCatalogue } = await loadCacheModule();
+ const load = vi.fn(async () => [{ slug: "clozapine" }]);
+ const args = { ownerId: "owner-a", kind: "medication" as const, limit: 500, load };
+
+ const first = await loadOwnerCatalogue(args);
+ const warmerPrefix = await loadOwnerCatalogue(args);
+
+ expect(warmerPrefix).toBe(first);
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it("coalesces concurrent same-key cold loads", async () => {
+ const { loadOwnerCatalogue } = await loadCacheModule();
+ const pending = deferred>();
+ const load = vi.fn(async () => pending.promise);
+ const args = { ownerId: "owner-concurrent", kind: "service" as const, limit: 500, load };
+
+ const first = loadOwnerCatalogue(args);
+ const second = loadOwnerCatalogue(args);
+ await Promise.resolve();
+ expect(load).toHaveBeenCalledTimes(1);
+
+ const catalogue = [{ slug: "crisis-service" }];
+ pending.resolve(catalogue);
+ const [firstValue, secondValue] = await Promise.all([first, second]);
+ expect(firstValue).toBe(secondValue);
+ expect(firstValue).toEqual(catalogue);
+ });
+
+ it("does not cache a failed shared load and retries after every waiter rejects", async () => {
+ const { loadOwnerCatalogue } = await loadCacheModule();
+ const pending = deferred>();
+ const load = vi
+ .fn<(signal: AbortSignal) => Promise>>()
+ .mockImplementationOnce(async () => pending.promise)
+ .mockResolvedValueOnce([{ slug: "recovered-service" }]);
+ const args = { ownerId: "owner-shared-failure", kind: "service" as const, limit: 500, load };
+
+ const first = loadOwnerCatalogue(args);
+ const second = loadOwnerCatalogue(args);
+ await Promise.resolve();
+ pending.reject(new Error("shared load failed"));
+ const outcomes = await Promise.allSettled([first, second]);
+ expect(outcomes.map((outcome) => outcome.status)).toEqual(["rejected", "rejected"]);
+
+ await expect(loadOwnerCatalogue(args)).resolves.toEqual([{ slug: "recovered-service" }]);
+ expect(load).toHaveBeenCalledTimes(2);
+ });
+
+ it("lets an active waiter complete when a coalesced caller aborts", async () => {
+ const { loadOwnerCatalogue } = await loadCacheModule();
+ const pending = deferred>();
+ let sharedSignal: AbortSignal | undefined;
+ const load = vi.fn(async (signal: AbortSignal) => {
+ sharedSignal = signal;
+ return pending.promise;
+ });
+ const firstController = new AbortController();
+ const secondController = new AbortController();
+ const args = { ownerId: "owner-partial-abort", kind: "medication" as const, limit: 500, load };
+
+ const abortedCaller = loadOwnerCatalogue({ ...args, signal: firstController.signal });
+ const activeCaller = loadOwnerCatalogue({ ...args, signal: secondController.signal });
+ await Promise.resolve();
+ firstController.abort(new DOMException("superseded", "AbortError"));
+ await expect(abortedCaller).rejects.toMatchObject({ name: "AbortError" });
+ expect(sharedSignal?.aborted).toBe(false);
+
+ const catalogue = [{ slug: "clozapine" }];
+ pending.resolve(catalogue);
+ await expect(activeCaller).resolves.toEqual(catalogue);
+ await expect(loadOwnerCatalogue(args)).resolves.toEqual(catalogue);
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not start or populate a flight for an already-aborted caller", async () => {
+ const { loadOwnerCatalogue } = await loadCacheModule();
+ const controller = new AbortController();
+ controller.abort(new DOMException("already superseded", "AbortError"));
+ const load = vi.fn(async () => [{ slug: "must-not-cache" }]);
+ const args = { ownerId: "owner-pre-aborted", kind: "form" as const, limit: 500, load };
+
+ await expect(loadOwnerCatalogue({ ...args, signal: controller.signal })).rejects.toMatchObject({
+ name: "AbortError",
+ });
+ expect(load).not.toHaveBeenCalled();
+
+ await expect(loadOwnerCatalogue(args)).resolves.toEqual([{ slug: "must-not-cache" }]);
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it("expires values after five seconds and retains at most 128 keys", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-07-17T00:00:00Z"));
+ const { loadOwnerCatalogue } = await loadCacheModule();
+ const expiringLoad = vi.fn(async () => [{ slug: "clozapine" }]);
+
+ await loadOwnerCatalogue({ ownerId: "owner-expiring", kind: "medication", limit: 500, load: expiringLoad });
+ vi.advanceTimersByTime(4_999);
+ await loadOwnerCatalogue({ ownerId: "owner-expiring", kind: "medication", limit: 500, load: expiringLoad });
+ vi.advanceTimersByTime(2);
+ await loadOwnerCatalogue({ ownerId: "owner-expiring", kind: "medication", limit: 500, load: expiringLoad });
+ expect(expiringLoad).toHaveBeenCalledTimes(2);
+
+ const loads = Array.from({ length: 129 }, () => vi.fn(async () => [{ ok: true }]));
+ for (let index = 0; index < loads.length; index += 1) {
+ await loadOwnerCatalogue({
+ ownerId: `owner-${index}`,
+ kind: "service",
+ limit: 500,
+ load: loads[index],
+ });
+ }
+ await loadOwnerCatalogue({ ownerId: "owner-0", kind: "service", limit: 500, load: loads[0] });
+ expect(loads[0]).toHaveBeenCalledTimes(2);
+ });
+
+ it("never caches failed or aborted loads", async () => {
+ const { loadOwnerCatalogue } = await loadCacheModule();
+ const failedLoad = vi
+ .fn<() => Promise>>()
+ .mockRejectedValueOnce(new Error("load failed"))
+ .mockResolvedValueOnce([{ slug: "clozapine" }]);
+ const failedArgs = { ownerId: "owner-failed", kind: "medication" as const, limit: 500, load: failedLoad };
+
+ await expect(loadOwnerCatalogue(failedArgs)).rejects.toThrow("load failed");
+ await expect(loadOwnerCatalogue(failedArgs)).resolves.toEqual([{ slug: "clozapine" }]);
+ expect(failedLoad).toHaveBeenCalledTimes(2);
+
+ const controller = new AbortController();
+ const abortedLoad = vi.fn(async () => {
+ controller.abort(new DOMException("superseded", "AbortError"));
+ return [{ slug: "acamprosate" }];
+ });
+ const abortedArgs = {
+ ownerId: "owner-aborted",
+ kind: "medication" as const,
+ limit: 500,
+ signal: controller.signal,
+ load: abortedLoad,
+ };
+
+ await expect(loadOwnerCatalogue(abortedArgs)).rejects.toMatchObject({ name: "AbortError" });
+ const retry = new AbortController();
+ await loadOwnerCatalogue({ ...abortedArgs, signal: retry.signal });
+ expect(abortedLoad).toHaveBeenCalledTimes(2);
+ });
+
+ it("invalidates only the mutated owner and catalogue kind across limits", async () => {
+ const { invalidateOwnerCatalogueCache, loadOwnerCatalogue } = await loadCacheModule();
+ const ownerMedication = vi.fn(async () => [{ slug: "clozapine" }]);
+ const ownerService = vi.fn(async () => [{ slug: "crisis-service" }]);
+ const otherMedication = vi.fn(async () => [{ slug: "lithium" }]);
+
+ await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 500, load: ownerMedication });
+ await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 100, load: ownerMedication });
+ await loadOwnerCatalogue({ ownerId: "owner-a", kind: "service", limit: 500, load: ownerService });
+ await loadOwnerCatalogue({ ownerId: "owner-b", kind: "medication", limit: 500, load: otherMedication });
+
+ invalidateOwnerCatalogueCache({ ownerId: "owner-a", kind: "medication" });
+
+ await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 500, load: ownerMedication });
+ await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 100, load: ownerMedication });
+ await loadOwnerCatalogue({ ownerId: "owner-a", kind: "service", limit: 500, load: ownerService });
+ await loadOwnerCatalogue({ ownerId: "owner-b", kind: "medication", limit: 500, load: otherMedication });
+
+ expect(ownerMedication).toHaveBeenCalledTimes(4);
+ expect(ownerService).toHaveBeenCalledTimes(1);
+ expect(otherMedication).toHaveBeenCalledTimes(1);
+ });
+
+ it("preserves the seeding flight while invalidating other medication loads", async () => {
+ const { invalidateOwnerCatalogueCache, loadOwnerCatalogue } = await loadCacheModule();
+ const load = vi.fn(async (signal: AbortSignal) => {
+ invalidateOwnerCatalogueCache({ ownerId: "owner-a", kind: "medication", preserveSignal: signal });
+ return [{ slug: "clozapine" }];
+ });
+
+ await expect(loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 500, load })).resolves.toEqual([
+ { slug: "clozapine" },
+ ]);
+ await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 500, load });
+
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it("preserves the registry seeding flight during mutation invalidation", () => {
+ const registrySeed = readFileSync(resolve(process.cwd(), "src/lib/registry-seed.ts"), "utf8");
+
+ expect(registrySeed).toContain("invalidateOwnerCatalogueCache({ ownerId, kind, preserveSignal: options.signal });");
+ });
+});
diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts
index a7787ba22..183930122 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -143,6 +143,11 @@ class QueryBuilder implements PromiseLike {
return this;
}
+ abortSignal(signal: AbortSignal) {
+ void signal;
+ return this;
+ }
+
single() {
this.call.single = true;
return this.resolve();
@@ -241,6 +246,12 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) {
error: null,
};
}
+ if (name === "consume_summary_rate_limits_atomic") {
+ return {
+ data: [rateLimitRow({ bucket: null, limit_value: 12 })],
+ error: null,
+ };
+ }
if (name === "consume_api_subject_rate_limit") {
const bucket = String(args?.p_bucket ?? "");
const limit = Number(args?.p_limit ?? 100);
@@ -2951,7 +2962,7 @@ describe("private document API access", () => {
expect(update).not.toHaveProperty("storage_path");
expect(update).not.toHaveProperty("content_hash");
expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId });
- expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId);
+ expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false });
});
it("rejects invalid document rename titles", async () => {
@@ -3027,7 +3038,7 @@ describe("private document API access", () => {
source: "manual",
confidence: 1,
});
- expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId);
+ expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false });
});
it("creates manual site labels for owned documents", async () => {
@@ -3067,7 +3078,7 @@ describe("private document API access", () => {
source: "manual",
confidence: 1,
});
- expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId);
+ expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false });
});
it("rejects noisy manual document labels before insert", async () => {
@@ -3121,7 +3132,7 @@ describe("private document API access", () => {
expect(response.status).toBe(200);
expect(update?.filters).toContainEqual({ column: "source", value: "manual" });
expect(update?.updatePayload).toMatchObject({ label: "lithium toxicity", label_type: "risk", source: "manual" });
- expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId);
+ expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false });
});
it.each([
@@ -3181,7 +3192,7 @@ describe("private document API access", () => {
expect(update?.updatePayload).toMatchObject({
metadata: expect.objectContaining({ review_status: reviewStatus, hidden, reviewed_by: "label-review-admin" }),
});
- expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId);
+ expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false });
});
it("does not review labels that are not owned by the authenticated user", async () => {
@@ -3298,7 +3309,7 @@ describe("private document API access", () => {
expect(response.status).toBe(200);
expect(await payload(response)).toMatchObject({ deleted: true, labelId: manualLabelId, labels: [] });
expect(deleteCall?.filters).toContainEqual({ column: "source", value: "manual" });
- expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId);
+ expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false });
});
it("permanently deletes an owned document, indexing traces, and storage objects", async () => {
@@ -4271,9 +4282,15 @@ describe("private document API access", () => {
expect(summarizeDocument).toHaveBeenCalledWith(documentId, userId, {
signal: expect.any(AbortSignal),
});
+ expect(client.rpc).toHaveBeenCalledTimes(1);
expect(client.rpc).toHaveBeenCalledWith(
- "consume_api_rate_limit",
- expect.objectContaining({ p_owner_id: userId, p_bucket: "document_summarize" }),
+ "consume_summary_rate_limits_atomic",
+ expect.objectContaining({
+ p_owner_id: userId,
+ p_subject_key: null,
+ p_answer_limit: 30,
+ p_summary_limit: 12,
+ }),
);
expect(answerQuestionWithScope).not.toHaveBeenCalled();
});
@@ -4287,12 +4304,20 @@ describe("private document API access", () => {
sources: [],
}));
const client = createSupabaseMock();
- client.rpc.mockImplementation(async (name: string, args?: Record) =>
- name === "consume_api_rate_limit" && args?.p_bucket === "document_summarize"
- ? { data: [rateLimitRow({ limited: true, limit_value: 12, remaining: 0 })], error: null }
- : name === "consume_api_rate_limit"
- ? { data: [rateLimitRow()], error: null }
- : ok([]),
+ client.rpc.mockImplementation(async (name: string) =>
+ name === "consume_summary_rate_limits_atomic"
+ ? {
+ data: [
+ rateLimitRow({
+ bucket: "document_summarize",
+ limited: true,
+ limit_value: 12,
+ remaining: 0,
+ }),
+ ],
+ error: null,
+ }
+ : ok([]),
);
mockRuntime(client, { summarizeDocument });
const { POST } = await import("../src/app/api/answer/stream/route");
@@ -4314,16 +4339,45 @@ describe("private document API access", () => {
error: "Too many document summary requests. Retry shortly.",
details: { retryAfterSeconds: 60 },
});
- expect(client.rpc).toHaveBeenNthCalledWith(
- 1,
- "consume_api_rate_limit",
- expect.objectContaining({ p_owner_id: userId, p_bucket: "answer" }),
+ expect(client.rpc).toHaveBeenCalledTimes(1);
+ expect(client.rpc).toHaveBeenCalledWith(
+ "consume_summary_rate_limits_atomic",
+ expect.objectContaining({ p_owner_id: userId, p_subject_key: null }),
);
- expect(client.rpc).toHaveBeenNthCalledWith(
- 2,
- "consume_api_rate_limit",
- expect.objectContaining({ p_owner_id: userId, p_bucket: "document_summarize" }),
+ expect(summarizeDocument).not.toHaveBeenCalled();
+ });
+
+ it("preserves the general answer rejection for atomic streamed-summary limits", async () => {
+ const summarizeDocument = vi.fn();
+ const client = createSupabaseMock();
+ client.rpc.mockImplementation(async (name: string) =>
+ name === "consume_summary_rate_limits_atomic"
+ ? {
+ data: [rateLimitRow({ bucket: "answer", limited: true, limit_value: 30, remaining: 0 })],
+ error: null,
+ }
+ : ok([]),
+ );
+ mockRuntime(client, { summarizeDocument });
+ const { POST } = await import("../src/app/api/answer/stream/route");
+
+ const response = await POST(
+ authenticatedRequest("/api/answer/stream", {
+ method: "POST",
+ body: JSON.stringify({
+ query: "Summarize this document for practical clinical use.",
+ documentId,
+ summaryMode: true,
+ }),
+ }),
);
+
+ expect(response.status).toBe(429);
+ expect(await payload(response)).toMatchObject({
+ error: "Too many answer requests. Retry shortly.",
+ details: { retryAfterSeconds: 60 },
+ });
+ expect(client.rpc).toHaveBeenCalledTimes(1);
expect(summarizeDocument).not.toHaveBeenCalled();
});
diff --git a/tests/private-error-cache.test.ts b/tests/private-error-cache.test.ts
new file mode 100644
index 000000000..bf078ca74
--- /dev/null
+++ b/tests/private-error-cache.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { rateLimitJsonResponse } from "../src/lib/api-rate-limit";
+import { jsonError } from "../src/lib/http";
+import { unauthorizedResponse } from "../src/lib/supabase/auth";
+
+describe("private API error cache policy", () => {
+ it("marks validation, authentication, and rate-limit responses private", () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ const validation = jsonError("Invalid request.", 400);
+ const unauthorized = unauthorizedResponse();
+ const limited = rateLimitJsonResponse("Too many requests.", {
+ limited: true,
+ limit: 1,
+ remaining: 0,
+ retryAfterSeconds: 60,
+ resetAt: new Date(60_000).toISOString(),
+ });
+
+ expect(validation.headers.get("Cache-Control")).toBe("private, no-store");
+ expect(unauthorized.headers.get("Cache-Control")).toBe("private, no-store");
+ expect(limited.headers.get("Cache-Control")).toBe("private, no-store");
+ expect(limited.headers.get("Retry-After")).toBe("60");
+ });
+});
diff --git a/tests/private-rag-access.test.ts b/tests/private-rag-access.test.ts
index b548dfada..8f986a2f3 100644
--- a/tests/private-rag-access.test.ts
+++ b/tests/private-rag-access.test.ts
@@ -195,7 +195,8 @@ function mockRuntime(options: { demoMode?: boolean } = {}) {
buildVisualEvidence: vi.fn(() => []),
diversifySearchResults: vi.fn((results: unknown[]) => results),
}));
- vi.doMock("@/lib/evidence-relevance", () => ({
+ vi.doMock("@/lib/evidence-relevance", async (importOriginal) => ({
+ ...(await importOriginal()),
annotateDocumentMatches: vi.fn((_query: string, documents: unknown[]) => documents),
annotateSearchResults: vi.fn((_query: string, results: unknown[]) => results),
buildEvidenceRelevance: vi.fn(() => ({
diff --git a/tests/proxy-session-refresh.test.ts b/tests/proxy-session-refresh.test.ts
index c380b94e1..a68cb1177 100644
--- a/tests/proxy-session-refresh.test.ts
+++ b/tests/proxy-session-refresh.test.ts
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
-// The proxy's second job (session-cookie refresh via supabase.auth.getUser)
+// The proxy's second job (session-cookie refresh via supabase.auth.getClaims)
// must run on page navigations and cookie-authenticated API routes. Route
// handlers can validate an SSR cookie, but their read-only adapter cannot return
// rotated Set-Cookie headers to the browser.
-const getUser = vi.fn(async () => ({ data: { user: null }, error: null }));
+const getClaims = vi.fn(async () => ({ data: { claims: null }, error: null }));
vi.mock("@supabase/ssr", async (importOriginal) => {
const actual = await importOriginal();
@@ -14,7 +14,7 @@ vi.mock("@supabase/ssr", async (importOriginal) => {
...actual,
createServerClient: vi.fn((_url, _key, options: { cookies: { setAll: (cookies: never[]) => void } }) => ({
auth: {
- getUser: async () => {
+ getClaims: async () => {
options.cookies.setAll([
{
name: "sb-unit-test-auth-token",
@@ -22,7 +22,7 @@ vi.mock("@supabase/ssr", async (importOriginal) => {
options: { path: "/", httpOnly: true },
},
] as never[]);
- return getUser();
+ return getClaims();
},
},
})),
@@ -48,7 +48,7 @@ function requestWithSessionCookie(path: string): NextRequest {
}
beforeEach(() => {
- getUser.mockClear();
+ getClaims.mockClear();
});
describe("proxy session refresh scoping", () => {
@@ -56,7 +56,7 @@ describe("proxy session refresh scoping", () => {
const { proxy } = await import("../src/proxy");
const response = await proxy(requestWithSessionCookie("/api/answer"));
- expect(getUser).toHaveBeenCalledTimes(1);
+ expect(getClaims).toHaveBeenCalledTimes(1);
expect(response.cookies.get("sb-unit-test-auth-token")?.value).toBe("rotated-session");
expect(response.headers.get("content-security-policy")).toBeTruthy();
});
@@ -65,16 +65,16 @@ describe("proxy session refresh scoping", () => {
const { proxy } = await import("../src/proxy");
const response = await proxy(requestWithSessionCookie("/documents/some-id"));
- expect(getUser).toHaveBeenCalledTimes(1);
+ expect(getClaims).toHaveBeenCalledTimes(1);
expect(response.cookies.get("sb-unit-test-auth-token")?.value).toBe("rotated-session");
expect(response.headers.get("content-security-policy")).toBeTruthy();
});
- it("never calls getUser without an sb- cookie", async () => {
+ it("never calls getClaims without an sb- cookie", async () => {
const { proxy } = await import("../src/proxy");
await proxy(new NextRequest(new URL("http://localhost/documents/some-id")));
- expect(getUser).not.toHaveBeenCalled();
+ expect(getClaims).not.toHaveBeenCalled();
});
it.each(["/sw.js", "/offline.html", "/manifest.webmanifest", "/apple-icon", "/icon.svg", "/icons/icon-192"])(
@@ -83,7 +83,7 @@ describe("proxy session refresh scoping", () => {
const { proxy } = await import("../src/proxy");
const response = await proxy(requestWithSessionCookie(path));
- expect(getUser).not.toHaveBeenCalled();
+ expect(getClaims).not.toHaveBeenCalled();
expect(response.headers.get("content-security-policy")).toBeNull();
},
);
diff --git a/tests/rag-abort-signal.test.ts b/tests/rag-abort-signal.test.ts
index f1fddbbfa..ad5391f31 100644
--- a/tests/rag-abort-signal.test.ts
+++ b/tests/rag-abort-signal.test.ts
@@ -33,6 +33,23 @@ describe("RAG abort signal propagation", () => {
expect(createAdminClient).not.toHaveBeenCalled();
}, 60_000);
+ it("attaches the caller signal to versioned retrieval RPC builders", async () => {
+ const controller = new AbortController();
+ const abortSignal = vi.fn(async () => ({ data: [], error: null }));
+ const supabase = { rpc: vi.fn(() => ({ abortSignal })) };
+ const { callVersionedRetrievalRpc } = await import("../src/lib/rag-candidate-sources");
+
+ await callVersionedRetrievalRpc(
+ supabase as never,
+ "match_document_chunks_text_v2",
+ "match_document_chunks_text",
+ { query_text: "clozapine", match_count: 8 },
+ controller.signal,
+ );
+
+ expect(abortSignal).toHaveBeenCalledWith(controller.signal);
+ });
+
it("refuses adversarial manipulation before Supabase work starts", async () => {
const createAdminClient = vi.fn();
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient }));
diff --git a/tests/rag-cache-invalidation.test.ts b/tests/rag-cache-invalidation.test.ts
index 3bcd4a9b7..afdbbbe28 100644
--- a/tests/rag-cache-invalidation.test.ts
+++ b/tests/rag-cache-invalidation.test.ts
@@ -61,5 +61,15 @@ describe("RAG cache invalidation", () => {
expect(calls.flat()).not.toContainEqual({ method: "eq", column: "owner_id", value: "anonymous" });
expect(calls[1]).toContainEqual({ method: "is", column: "owner_id", value: null });
expect(calls[1]).toContainEqual({ method: "in", column: "cache_kind", value: ["search", "answer"] });
+
+ calls.length = 0;
+ invalidateRagCachesForDocumentMutation(ownerId, { affectsPublicCorpus: false });
+ await vi.waitFor(() => expect(calls.length).toBe(1), { timeout: 10000 });
+ expect(calls[0]).toContainEqual({ method: "eq", column: "owner_id", value: ownerId });
+
+ calls.length = 0;
+ invalidateRagCachesForDocumentMutation(ownerId, { affectsPublicCorpus: true });
+ await vi.waitFor(() => expect(calls.length).toBe(2), { timeout: 10000 });
+ expect(calls[1]).toContainEqual({ method: "is", column: "owner_id", value: null });
});
});
diff --git a/tests/rag-classifier-memo.test.ts b/tests/rag-classifier-memo.test.ts
index 6a904a6e0..7f2419948 100644
--- a/tests/rag-classifier-memo.test.ts
+++ b/tests/rag-classifier-memo.test.ts
@@ -165,6 +165,32 @@ describe("classifier verdict memoization", () => {
expect(second.queryClass).toBe("broad_summary");
});
+ it("lets one cancelled caller leave a shared classifier request without cancelling the active caller", async () => {
+ let resolveCall: ((value: ReturnType) => void) | undefined;
+ const mock = vi.fn(
+ () =>
+ new Promise<{ parsed: ReturnType["parsed"] }>((resolve) => {
+ resolveCall = resolve;
+ }),
+ );
+ const { rag, analyzeClinicalQuery } = await loadWithClassifierMock(mock);
+ const { query, analysis } = fallbackQueryAnalysis(analyzeClinicalQuery);
+ const cancelledController = new AbortController();
+
+ const cancelled = rag.analyzeQueryWithClassifierFallback(query, analysis, {
+ signal: cancelledController.signal,
+ });
+ const active = rag.analyzeQueryWithClassifierFallback(query, analysis);
+ await vi.waitFor(() => expect(mock).toHaveBeenCalledTimes(1));
+
+ cancelledController.abort(new DOMException("superseded", "AbortError"));
+ await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
+
+ resolveCall?.(classifierResponse());
+ await expect(active).resolves.toMatchObject({ queryClass: "broad_summary" });
+ expect(mock).toHaveBeenCalledTimes(1);
+ });
+
it("re-calls the model after the memo TTL expires", async () => {
vi.useFakeTimers({ now: new Date("2026-07-06T00:00:00Z") });
const mock = vi.fn(async () => classifierResponse());
diff --git a/tests/rag-variant-early-exit.test.ts b/tests/rag-variant-early-exit.test.ts
index cf8e8db35..1a71d5848 100644
--- a/tests/rag-variant-early-exit.test.ts
+++ b/tests/rag-variant-early-exit.test.ts
@@ -86,10 +86,11 @@ async function runLexicalSearch(chunkResults: SearchResult[]) {
if (retrievalRpcBaseName(name) === "match_document_chunks_text") return { data: chunkResults, error: null };
return { data: [], error: null };
});
+ const from = vi.fn(() => new EmptyQuery());
vi.doMock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
rpc,
- from: vi.fn(() => new EmptyQuery()),
+ from,
}),
}));
@@ -103,7 +104,7 @@ async function runLexicalSearch(chunkResults: SearchResult[]) {
const chunkTextCalls = rpc.mock.calls.filter(
([name]) => retrievalRpcBaseName(name as string) === "match_document_chunks_text",
);
- return { chunkTextCalls, telemetry: result.telemetry };
+ return { chunkTextCalls, from, telemetry: result.telemetry };
}
afterEach(() => {
@@ -117,13 +118,14 @@ describe("lexical variant early-exit (PT-02)", () => {
it("a deep, precisely-anchored first pool issues exactly one chunk-text RPC", async () => {
// Deep (48-row) pool with a precise top hit: sibling variants are pure duplication.
const strongPool = Array.from({ length: 48 }, (_, index) => chunk(index, index === 0 ? 0.9 : 0.2));
- const { chunkTextCalls, telemetry } = await runLexicalSearch(strongPool);
+ const { chunkTextCalls, from, telemetry } = await runLexicalSearch(strongPool);
// Guard: the fixture query must actually produce sibling variants for the
// skip to be meaningful.
expect(telemetry.retrieval_query_variant_count ?? 1).toBeGreaterThan(1);
expect(chunkTextCalls).toHaveLength(1);
expect(telemetry.text_variant_early_exit).toBe(true);
expect(telemetry.text_variant_rpc_calls?.match_document_chunks_text).toBe(1);
+ expect(from).not.toHaveBeenCalledWith("document_images");
});
it("a weak first pool keeps the full sibling fan-out", async () => {
diff --git a/tests/registry-records-route.test.ts b/tests/registry-records-route.test.ts
index 19d08f3a5..a30b0ca71 100644
--- a/tests/registry-records-route.test.ts
+++ b/tests/registry-records-route.test.ts
@@ -2,6 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const token = "valid-token";
+const publicFixtureCacheControl = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400";
+
+function expectPublicFixtureCache(response: Response) {
+ expect(response.headers.get("cache-control")).toBe(publicFixtureCacheControl);
+ expect(response.headers.get("vary")).toBe("Cookie, Authorization");
+}
+
+function expectPrivateCache(response: Response) {
+ expect(response.headers.get("cache-control")).toBe("private, no-store");
+}
const recordId = "11111111-1111-4111-8111-111111111111";
type QueryError = { message: string };
@@ -192,6 +202,7 @@ describe("registry records API", () => {
const payload = (await response.json()) as { records: Array<{ slug: string }>; demoMode?: boolean };
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.demoMode).toBe(true);
expect(payload.records.some((record) => record.slug === "13yarn")).toBe(true);
expect(client.from).not.toHaveBeenCalled();
@@ -210,6 +221,7 @@ describe("registry records API", () => {
publicAccess?: boolean;
};
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.publicAccess).toBe(true);
expect(payload.records.some((record) => record.slug === "13yarn")).toBe(true);
expect(payload.matches?.[0]?.record.slug).toBe("13yarn");
@@ -257,6 +269,7 @@ describe("registry records API", () => {
};
expect(response.status).toBe(200);
+ expectPrivateCache(response);
expect(payload.records[0]?.slug).toBe("13yarn");
expect(payload.matches?.[0]?.record.slug).toBe("13yarn");
expect(payload.governance["13yarn"]?.validationStatus).toBe("locally_reviewed");
@@ -338,6 +351,7 @@ describe("registry records API", () => {
};
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.publicAccess).toBe(true);
expect(payload.record.slug).toBe("13yarn");
expect(payload.linkedDocuments).toEqual([]);
@@ -385,6 +399,7 @@ describe("registry records API", () => {
const payload = (await response.json()) as { record: { slug: string }; demoMode?: boolean };
expect(response.status).toBe(200);
+ expectPublicFixtureCache(response);
expect(payload.demoMode).toBe(true);
expect(payload.record.slug).toBe("transport-crisis-form");
expect(client.from).not.toHaveBeenCalled();
diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts
index 151f3d296..afbf529e7 100644
--- a/tests/supabase-schema.test.ts
+++ b/tests/supabase-schema.test.ts
@@ -129,7 +129,7 @@ const deleteDocumentIfIdleMigration = readFileSync(
"utf8",
).replace(/\s+/g, " ");
const defaultAclAssertionMigration = readFileSync(
- new URL("../supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql", import.meta.url),
+ new URL("../supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql", import.meta.url),
"utf8",
).replace(/\s+/g, " ");
const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace(
@@ -184,6 +184,18 @@ const responseCacheRetentionReconciliationMigration = readFileSync(
new URL("../supabase/migrations/20260713201542_consolidate_rag_response_cache_retention.sql", import.meta.url),
"utf8",
).replace(/\s+/g, " ");
+const registryProjectionCleanupMigration = readFileSync(
+ new URL("../supabase/migrations/20260717170000_registry_projection_cleanup.sql", import.meta.url),
+ "utf8",
+).replace(/\s+/g, " ");
+const publicTitleCorrectorMigration = readFileSync(
+ new URL("../supabase/migrations/20260717171000_public_title_corrector.sql", import.meta.url),
+ "utf8",
+).replace(/\s+/g, " ");
+const atomicSummaryRateLimitsMigration = readFileSync(
+ new URL("../supabase/migrations/20260717172000_atomic_summary_rate_limits.sql", import.meta.url),
+ "utf8",
+).replace(/\s+/g, " ");
const liveDatabaseDriftMigration = readFileSync(
new URL("../supabase/migrations/20260705230000_reconcile_live_database_drift.sql", import.meta.url),
"utf8",
@@ -1184,13 +1196,22 @@ describe("Supabase Preview replay guards", () => {
it("fails closed on effective supabase_admin default ACLs", () => {
for (const sql of [schema, defaultAclAssertionMigration]) {
- expect(sql).toContain("create or replace function public.default_privileges_status(");
- expect(sql).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)");
- expect(sql).toContain("pg_catalog.aclexplode(ea.acl)");
- expect(sql).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))");
- expect(sql).toContain("entry like 'table:PUBLIC:%'");
- expect(sql).toContain("entry like 'sequence:PUBLIC:%'");
- expect(sql).toContain("entry = 'function:PUBLIC:execute'");
+ const statusStart = sql.lastIndexOf("create or replace function public.default_privileges_status(");
+ const statusEnd = sql.indexOf("$$;", statusStart);
+ const statusFunction = sql.slice(statusStart, statusEnd);
+
+ expect(statusStart).toBeGreaterThanOrEqual(0);
+ expect(statusEnd).toBeGreaterThan(statusStart);
+ expect(statusFunction).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)");
+ expect(statusFunction).toContain("pg_catalog.aclexplode(ea.acl)");
+ expect(statusFunction).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))");
+ expect(statusFunction).toContain("privilege.is_grantable");
+ expect(statusFunction).toContain("coalesce(bool_or(is_grantable), false)");
+ expect(statusFunction).toContain("into v_entries, v_has_unexpected_grantee, v_has_grantable");
+ expect(statusFunction).toContain("not v_has_grantable");
+ expect(statusFunction).toContain("entry like 'table:PUBLIC:%'");
+ expect(statusFunction).toContain("entry like 'sequence:PUBLIC:%'");
+ expect(statusFunction).toContain("entry = 'function:PUBLIC:execute'");
expect(sql).toContain("message = 'Unsafe supabase_admin default privileges; migration blocked.'");
expect(sql).toContain("Run these six statements as supabase_admin, then retry the migration:");
}
@@ -1198,7 +1219,7 @@ describe("Supabase Preview replay guards", () => {
const migrationFiles = readdirSync(migrationDirectoryUrl)
.filter((fileName) => /^\d+_.+\.sql$/.test(fileName))
.sort();
- expect(migrationFiles.at(-1)).toBe("20260717161000_assert_supabase_admin_default_privileges.sql");
+ expect(migrationFiles.at(-1)).toBe("20260717173000_reassert_supabase_admin_default_privileges.sql");
});
it("bootstraps safe default ACLs before fresh local and preview migration replay", () => {
@@ -1328,6 +1349,128 @@ describe("Supabase Preview replay guards", () => {
expect(ingestionRpcPrivilegesDuplicateMigration).toContain("NEUTRALIZED 2026-07-13");
expect(ingestionRpcPrivilegesDuplicateMigration).toContain("select 1 where false;");
});
+
+ it("cleans registry projections with text-and-kind matching through the partial expression index", () => {
+ expect(registryProjectionCleanupMigration).toContain("set lock_timeout = '5s';");
+ expect(registryProjectionCleanupMigration).toContain("set statement_timeout = '60s';");
+
+ for (const sql of [registryProjectionCleanupMigration, schema]) {
+ const functionStart = sql.indexOf("create or replace function public.cleanup_registry_corpus_document()");
+ const functionEnd = sql.indexOf("$$;", functionStart);
+ const cleanupFunction = sql.slice(functionStart, functionEnd);
+
+ expect(functionStart).toBeGreaterThanOrEqual(0);
+ expect(cleanupFunction).toContain("metadata->>'registry_record_id' = old.id::text");
+ expect(cleanupFunction).not.toContain("metadata->>'registry_record_id')::uuid");
+ expect(cleanupFunction).toContain("metadata->>'registry_record_kind' = case tg_table_name");
+ expect(cleanupFunction).toContain("when 'clinical_registry_records' then pg_catalog.to_jsonb(old)->>'kind'");
+ expect(cleanupFunction).toContain("when 'medication_records' then 'medication'");
+ expect(cleanupFunction).toContain("when 'differential_records' then 'differential'");
+ expect(sql).toContain("create index if not exists documents_registry_projection_lookup_idx");
+ expect(sql).toMatch(/\(\s*\(metadata->>'registry_record_kind'\),\s*\(metadata->>'registry_record_id'\)\s*\)/);
+ expect(sql).toContain("where metadata->>'source_kind' = 'registry_record'");
+ expect(sql).toContain(
+ "revoke execute on function public.cleanup_registry_corpus_document() from public, anon, authenticated, service_role;",
+ );
+ }
+ });
+
+ it("indexes only public document-title words and probes capped trigram candidates", () => {
+ for (const sql of [publicTitleCorrectorMigration, schema]) {
+ const correctorStart = sql.lastIndexOf("create or replace function public.correct_clinical_query_terms(");
+ const correctorEnd = sql.indexOf(
+ "revoke execute on function public.correct_clinical_query_terms(text, real)",
+ correctorStart,
+ );
+ const corrector = sql.slice(correctorStart, correctorEnd);
+
+ expect(sql).toContain("create table if not exists public.document_title_words");
+ expect(sql).toContain("create index if not exists document_title_words_word_trgm_idx");
+ expect(sql).toContain("create index if not exists document_title_words_document_id_idx");
+ expect(sql).toContain("where d.owner_id is null and d.status = 'indexed'");
+ expect(sql).toContain("new.owner_id is null and new.status = 'indexed'");
+ expect(sql).toContain("delete from public.document_title_words where document_id = old.id");
+ expect(sql).toContain("alter table public.document_title_words enable row level security");
+ expect(sql).toContain("revoke all on table public.document_title_words from public, anon, authenticated");
+ expect(sql).toContain(
+ "grant select, insert, update, delete on table public.document_title_words to service_role",
+ );
+ expect(sql).toContain(
+ "revoke execute on function public.sync_document_title_words() from public, anon, authenticated, service_role;",
+ );
+ expect(sql.indexOf("create trigger documents_sync_title_words")).toBeLessThan(
+ sql.indexOf("select distinct lower(title_word), d.id"),
+ );
+ expect(sql).toContain("create index if not exists rag_aliases_canonical_trgm_idx");
+ expect(correctorStart).toBeGreaterThanOrEqual(0);
+ expect(correctorEnd).toBeGreaterThan(correctorStart);
+ expect(corrector).toContain("and lower(alias) % tok");
+ expect(corrector).toContain("and lower(canonical) % tok");
+ expect((corrector.match(/from public\.rag_aliases where enabled and owner_id is null/g) ?? []).length).toBe(2);
+ expect(corrector).toContain("and word % tok");
+ expect((corrector.match(/limit 32/g) ?? []).length).toBeGreaterThanOrEqual(3);
+ // An exact alias has similarity 1, but the emitted correction must be its
+ // canonical term rather than the alias itself. Keep the match score tied
+ // to the indexed alias expression so typo ranking remains index-friendly.
+ expect(corrector).toContain("select lower(canonical) as term, similarity(lower(alias), tok) as match_sim");
+ expect(corrector).not.toContain("select lower(alias) as term");
+ expect(corrector).toContain("select candidate.term, candidate.match_sim");
+ expect(corrector).toContain("order by candidate.match_sim desc, candidate.term");
+ expect(corrector).toContain("best_sim >= min_sim");
+ expect(corrector).toContain("length(best) >= length(tok)");
+ }
+
+ const broadServiceRoleGrant = schema.lastIndexOf(
+ "grant execute on all functions in schema public to service_role;",
+ );
+ expect(broadServiceRoleGrant).toBeGreaterThanOrEqual(0);
+ expect(
+ schema.lastIndexOf("revoke execute on function public.cleanup_registry_corpus_document() from service_role;"),
+ ).toBeGreaterThan(broadServiceRoleGrant);
+ expect(
+ schema.lastIndexOf("revoke execute on function public.sync_document_title_words() from service_role;"),
+ ).toBeGreaterThan(broadServiceRoleGrant);
+ });
+
+ it("keeps the table-facts trigram index expression identical to the active predicate", () => {
+ const indexStart = schema.indexOf("create index if not exists document_table_facts_title_row_param_trgm_idx");
+ const indexExpressionStart = schema.indexOf("using gin (", indexStart) + "using gin (".length;
+ const indexExpressionEnd = schema.indexOf(" extensions.gin_trgm_ops", indexExpressionStart);
+ const predicateSection = schema.indexOf("trgm_matches as (");
+ const predicateExpressionStart = schema.indexOf("on lower(", predicateSection) + "on ".length;
+ const predicateExpressionEnd = schema.indexOf(" % q.normalized", predicateExpressionStart);
+
+ expect(indexStart).toBeGreaterThanOrEqual(0);
+ expect(predicateSection).toBeGreaterThanOrEqual(0);
+ expect(schema.slice(predicateExpressionStart, predicateExpressionEnd).replaceAll("f.", "")).toBe(
+ schema.slice(indexExpressionStart, indexExpressionEnd),
+ );
+ expect(schema).not.toContain("document_table_facts_text_trgm_idx");
+ });
+
+ it("defines one stable-order atomic limiter for summary streaming", () => {
+ for (const sql of [atomicSummaryRateLimitsMigration, schema]) {
+ expect(sql).toContain("create or replace function public.consume_summary_rate_limits_atomic(");
+ expect(sql).toContain("order by rl.bucket for update");
+ expect(sql).toContain("order by rl.subject_key, rl.bucket for update");
+ expect(sql).toContain("'answer'::text, 1");
+ expect(sql).toContain("'document_summarize'::text, 3");
+ expect((sql.match(/v_success_limit := v_policy\.limit_value/g) ?? []).length).toBe(2);
+ expect((sql.match(/v_success_reset_at := v_reset_at/g) ?? []).length).toBe(2);
+ expect(sql).toContain("on conflict on constraint api_rate_limits_pkey do nothing");
+ expect(sql).toContain("on conflict on constraint api_rate_limit_subjects_pkey do nothing");
+ expect(sql).not.toMatch(/on conflict \([^)]*bucket[^)]*\) do nothing/);
+ expect(sql).toContain(
+ "return query select null::text, false, v_success_limit, v_min_remaining, greatest(1, pg_catalog.ceil(extract(epoch from (v_success_reset_at - v_now)))::integer), v_success_reset_at;",
+ );
+ expect(sql).toMatch(
+ /revoke execute on function public\.consume_summary_rate_limits_atomic\(\s*uuid, text, integer, integer, integer, integer, integer, integer\s*\)/,
+ );
+ expect(sql).toMatch(
+ /grant execute on function public\.consume_summary_rate_limits_atomic\(\s*uuid, text, integer, integer, integer, integer, integer, integer\s*\) to service_role/,
+ );
+ }
+ });
});
describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () => {
diff --git a/tests/test-runner-safety.test.ts b/tests/test-runner-safety.test.ts
index 1e39508d0..745305d39 100644
--- a/tests/test-runner-safety.test.ts
+++ b/tests/test-runner-safety.test.ts
@@ -184,4 +184,11 @@ describe("provider-safe test environment", () => {
expect(baseUrl.indexOf("if (!allowEnsure)")).toBeLessThan(baseUrl.indexOf("findExistingLocalProjectUrl();"));
expect(ragRunner).toContain("cwd: projectRoot");
});
+
+ it("uses webpack when shared worktree dependencies resolve outside the project", () => {
+ const devRunner = readFileSync(new URL("../scripts/dev-free-port.mjs", import.meta.url), "utf8");
+ expect(devRunner).toContain('fs.realpathSync(path.join(projectRoot, "node_modules"))');
+ expect(devRunner).toContain('return dependenciesAreExternal ? ["--webpack"] : [];');
+ expect(devRunner).toContain('args.some((arg) => ["--webpack", "--turbopack", "--turbo"].includes(arg))');
+ });
});
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index af50bc122..e61b4eda3 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -2849,6 +2849,40 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(documentResults).toContainText("Best match");
});
+ test("dashboard defers source and administration requests until their surfaces open @critical", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await mockDemoApi(page);
+ const requestCounts = { documents: 0, jobs: 0, batches: 0, quality: 0 };
+ page.on("request", (request) => {
+ const pathname = new URL(request.url()).pathname;
+ if (pathname === "/api/documents") requestCounts.documents += 1;
+ if (pathname === "/api/ingestion/jobs") requestCounts.jobs += 1;
+ if (pathname === "/api/ingestion/batches") requestCounts.batches += 1;
+ if (pathname === "/api/ingestion/quality") requestCounts.quality += 1;
+ });
+
+ await gotoApp(page, "/");
+ await waitForDemoDashboardReady(page);
+ expect(requestCounts).toEqual({ documents: 0, jobs: 0, batches: 0, quality: 0 });
+
+ await openScopeControl(page);
+ await expect.poll(() => requestCounts.documents).toBe(1);
+ expect(requestCounts.jobs).toBe(0);
+ expect(requestCounts.batches).toBe(0);
+ expect(requestCounts.quality).toBe(0);
+ await page.keyboard.press("Escape");
+
+ await switchToDocumentSearchMode(page);
+ await page
+ .getByRole("button", { name: /Browse library/i })
+ .first()
+ .click();
+ await expect.poll(() => requestCounts.documents).toBe(1);
+ expect(requestCounts.jobs).toBe(0);
+ expect(requestCounts.batches).toBe(0);
+ expect(requestCounts.quality).toBe(0);
+ });
+
test("tools mode searches the existing applications registry inside the dashboard", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockPrivateUnauthenticatedApi(page);
@@ -2887,19 +2921,20 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByRole("heading", { name: "No matching documents" }).first()).toBeVisible();
const demoDocId = "11111111-1111-4111-8111-111111111111";
- await gotoApp(page, `/documents/${demoDocId}?chunk=55555555-5555-4555-8555-555555555555`);
- await expect(page).toHaveURL(/chunk=55555555-5555-4555-8555-555555555555/);
+ await gotoApp(page, `/documents/${demoDocId}?chunk=44444444-4444-4444-8444-444444444442`);
+ await expect(page).toHaveURL(/chunk=44444444-4444-4444-8444-444444444442/);
await expect(page.locator("#source-evidence").getByTestId("highlighted-source-passage")).toContainText(
- "Patient safety plan should include",
+ "Escalate review when there is vomiting",
);
await expect(
- page.getByTestId("desktop-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"),
+ page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"),
).toBeVisible();
const sourceSearch = page.getByLabel("Search within indexed source text").last();
await sourceSearch.fill("safety plan include");
- const desktopTextPanel = page.getByTestId("desktop-chunk-indexed-text-panel");
+ const desktopTextPanel = page.getByTestId("source-chunk-indexed-text-panel");
await expect(desktopTextPanel.getByText("Hit 1 of 2").first()).toBeVisible();
+ await expect(desktopTextPanel.locator("mark").filter({ hasText: "safety" }).first()).toBeVisible();
const previousHit = desktopTextPanel.getByRole("button", { name: "Previous document search hit" });
const nextHit = desktopTextPanel.getByRole("button", { name: "Next document search hit" });
await expect(previousHit).toHaveAttribute("title", "Previous document search hit");
@@ -2908,10 +2943,52 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(nextHit).toHaveText("");
await nextHit.click();
await expect(desktopTextPanel.getByText("Hit 2 of 2")).toBeVisible();
- await expect(desktopTextPanel.locator("mark").filter({ hasText: "safety" }).first()).toBeVisible();
await expectNoPageHorizontalOverflow(page);
});
+ test("document viewer hydrates once and signs downloads only on demand @critical", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await mockDemoApi(page);
+
+ const documentId = "11111111-1111-4111-8111-111111111111";
+ const browserDetailRequests: string[] = [];
+ const setupRequests: string[] = [];
+ const signedUrlRequests: Array<"preview" | "download"> = [];
+ page.on("request", (request) => {
+ const url = new URL(request.url());
+ if (url.pathname === `/api/documents/${documentId}`) browserDetailRequests.push(request.url());
+ if (url.pathname === "/api/setup-status") setupRequests.push(request.url());
+ });
+ await page.route(/\/api\/documents\/([^/]+)\/signed-url(?:\?.*)?$/, async (route) => {
+ const url = new URL(route.request().url());
+ const id = url.pathname.split("/").at(-2) ?? "";
+ const document = getDemoDocument(id);
+ if (!document) {
+ await route.fulfill({ status: 404, json: { error: "Demo document not found." } });
+ return;
+ }
+ const requestKind = url.searchParams.get("download") === "true" ? "download" : "preview";
+ signedUrlRequests.push(requestKind);
+ if (requestKind === "download") await new Promise((resolve) => setTimeout(resolve, 250));
+ await route.fulfill({
+ json: { url: document.storage_path, fileType: document.file_type, demoMode: true },
+ });
+ });
+
+ await gotoApp(page, `/documents/${documentId}?page=1&chunk=44444444-4444-4444-8444-444444444442`);
+ await expect(page.getByRole("heading", { level: 1, name: "Synthetic lithium monitoring protocol" })).toBeVisible();
+ await expect(page.getByTestId("source-chunk-indexed-text-panel")).toHaveCount(1);
+ await expect.poll(() => signedUrlRequests.filter((kind) => kind === "preview").length).toBe(1);
+ expect(browserDetailRequests).toHaveLength(0);
+ expect(setupRequests).toHaveLength(0);
+ expect(signedUrlRequests.filter((kind) => kind === "download")).toHaveLength(0);
+
+ const downloadButton = page.getByRole("button", { name: "Download", exact: true });
+ await expect(downloadButton).toBeEnabled();
+ await downloadButton.dblclick();
+ await expect.poll(() => signedUrlRequests.filter((kind) => kind === "download").length).toBe(1);
+ });
+
test("document viewer puts the PDF preview first with pinned evidence after it on mobile", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 720 });
await mockDemoApi(page);
@@ -2928,9 +3005,9 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(evidence).toBeVisible();
await expect(evidence.getByText("Highlighted source passage")).toBeVisible();
- await expect(page.locator("#source-text-mobile")).toHaveJSProperty("open", true);
+ await expect(page.locator("#source-text")).toBeVisible();
await expect(
- page.getByTestId("mobile-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"),
+ page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"),
).toBeVisible();
await expect(viewerNav.getByRole("link", { name: "Evidence" })).toBeVisible();
await expect(viewerNav.getByRole("link", { name: "PDF" })).toBeVisible();
@@ -2953,7 +3030,10 @@ test.describe("Clinical KB UI smoke coverage", () => {
const evidenceBox = await evidence.boundingBox();
const previewBox = await preview.boundingBox();
- const indexedTextBox = await page.getByText("Indexed page text", { exact: true }).boundingBox();
+ const indexedTextHeading = page
+ .getByTestId("source-chunk-indexed-text-panel")
+ .getByRole("heading", { name: "Indexed source text", exact: true });
+ const indexedTextBox = await indexedTextHeading.boundingBox();
const imagesBox = await page.getByRole("heading", { name: "Tables and diagrams" }).boundingBox();
expect(evidenceBox).not.toBeNull();
@@ -2977,7 +3057,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
await activateFocusedControl(page, viewerNav.getByRole("link", { name: "PDF" }));
await expect(preview).toBeInViewport();
await activateFocusedControl(page, viewerNav.getByRole("link", { name: "Text" }));
- await expect(page.getByText("Indexed page text", { exact: true })).toBeInViewport();
+ await expect(indexedTextHeading).toBeInViewport();
await activateFocusedControl(page, viewerNav.getByRole("link", { name: "PDF" }));
await expect(preview).toBeInViewport();
@@ -3063,13 +3143,14 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByRole("heading", { level: 1, name: "Synthetic lithium monitoring protocol" })).toBeVisible({
timeout: 30_000,
});
- const indexedText = page.locator("#source-text-mobile");
+ const indexedText = page.locator("#source-text");
const summary = page.getByTestId("high-yield-summary");
const images = page.locator("#source-images");
const indexingDetails = page.getByTestId("indexing-details");
const viewerNav = page.getByRole("navigation", { name: "Document viewer sections" }).first();
- for (const disclosure of [indexedText, summary, images, indexingDetails]) {
+ await expect(indexedText).toBeVisible();
+ for (const disclosure of [summary, images, indexingDetails]) {
await expect(disclosure).toHaveJSProperty("open", false);
}
@@ -3086,12 +3167,12 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(images).toHaveJSProperty("open", true);
await viewerNav.getByRole("link", { name: "Text" }).click();
- await expect(indexedText).toHaveJSProperty("open", true);
+ await expect(indexedText).toBeInViewport();
await expect(images).toHaveJSProperty("open", false);
await viewerNav.getByRole("link", { name: "Summary" }).click();
await expect(summary).toHaveJSProperty("open", true);
- await expect(indexedText).toHaveJSProperty("open", false);
+ await expect(indexedText).toBeVisible();
await viewerNav.getByRole("link", { name: "Images" }).click();
await expect(images).toHaveJSProperty("open", true);
@@ -3442,15 +3523,9 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expectNoPageHorizontalOverflow(page);
});
- test("document viewer private missing source state is coherent", async ({ page }) => {
+ test("document viewer missing source state is coherent", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockPrivateUnauthenticatedApi(page);
- await page.route(/\/api\/documents\/[^/]+(?:\?.*)?$/, async (route) => {
- await route.fulfill({
- status: 404,
- json: { error: "Document not found." },
- });
- });
await page.route(/\/api\/documents\/[^/]+\/signed-url(?:\?.*)?$/, async (route) => {
await route.fulfill({
status: 404,
@@ -3459,15 +3534,13 @@ test.describe("Clinical KB UI smoke coverage", () => {
});
await gotoApp(
page,
- "/documents/11111111-1111-4111-8111-111111111111?page=1&chunk=44444444-4444-4444-8444-444444444442",
+ "/documents/99999999-9999-4999-8999-999999999999?page=1&chunk=99999999-9999-4999-8999-999999999998",
);
- await expect(page.getByRole("heading", { level: 1, name: /Sign in required|Source unavailable/ })).toBeVisible({
+ await expect(page.getByRole("heading", { level: 1, name: "Source unavailable" })).toBeVisible({
timeout: 30000,
});
- await expect(page.locator("body")).toContainText(
- /Sign in to open private source documents\.|Document not found\.|Supabase browser authentication is not configured for private source documents\./,
- );
+ await expect(page.locator("body")).toContainText(/Demo document not found\./i);
await expect(page.getByRole("button", { name: /^Answer from this(?: document)?$/ }).first()).toBeDisabled();
await expect(page.locator("body")).not.toContainText("loading source");
await expect(page.locator("body")).not.toContainText("Loading source metadata");
diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts
index 068a39661..478c4e982 100644
--- a/tests/ui-universal-search.spec.ts
+++ b/tests/ui-universal-search.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test, type Page } from "playwright/test";
+import { expect, test, type Page, type Route } from "playwright/test";
// Cross-entity universal typeahead in the command surface. The universal endpoint is
// mocked so this spec exercises the UI contract (grouped sections, navigation,
@@ -74,6 +74,15 @@ const universalPayload = {
],
};
+async function fulfillUniversalSearch(route: Route, response: typeof universalPayload & Record) {
+ const query = response.query;
+ const events = [...response.groups.map((group) => ({ type: "group", query, group })), { type: "complete", response }];
+ await route.fulfill({
+ body: `${events.map((event) => JSON.stringify(event)).join("\n")}\n`,
+ contentType: "application/x-ndjson; charset=utf-8",
+ });
+}
+
async function mockUniversalSearch(page: Page) {
await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => {
const mode = new URL(route.request().url()).searchParams.get("mode") ?? "documents";
@@ -90,13 +99,11 @@ async function mockUniversalSearch(page: Page) {
};
const preferredDomains = preferredByMode[mode] ?? [];
const responseOrder = universalPayload.groups.map((group) => group.kind);
- await route.fulfill({
- json: {
- ...universalPayload,
- contextMode: mode,
- preferredDomains,
- domainOrder: [...preferredDomains, ...responseOrder.filter((domain) => !preferredDomains.includes(domain))],
- },
+ await fulfillUniversalSearch(route, {
+ ...universalPayload,
+ contextMode: mode,
+ preferredDomains,
+ domainOrder: [...preferredDomains, ...responseOrder.filter((domain) => !preferredDomains.includes(domain))],
});
});
}
@@ -127,14 +134,13 @@ test.describe("universal search typeahead", () => {
test("does not count document-only hits as visible Medication rows", async ({ page }) => {
await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => {
- await route.fulfill({
- json: {
- query: "prescribing policy",
- contextMode: "documents",
- preferredDomains: ["documents"],
- domainOrder: ["documents"],
- groups: [universalPayload.groups[0]],
- },
+ await fulfillUniversalSearch(route, {
+ ...universalPayload,
+ query: "prescribing policy",
+ contextMode: "documents",
+ preferredDomains: ["documents"],
+ domainOrder: ["documents"],
+ groups: [universalPayload.groups[0]],
});
});
const input = await openComposer(page);
@@ -272,7 +278,7 @@ const smartPayload = {
test.describe("universal search smart affordances", () => {
async function mockSmartSearch(page: Page) {
await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => {
- await route.fulfill({ json: smartPayload });
+ await fulfillUniversalSearch(route, smartPayload);
});
await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => {
const answer = {
diff --git a/tests/universal-search-stream.test.ts b/tests/universal-search-stream.test.ts
new file mode 100644
index 000000000..338be2f88
--- /dev/null
+++ b/tests/universal-search-stream.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { UniversalSearchGroup, UniversalSearchResponse } from "../src/lib/universal-search";
+
+const encoder = new TextEncoder();
+
+function responseFromChunks(chunks: string[]) {
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
+ controller.close();
+ },
+ }),
+ { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } },
+ );
+}
+
+describe("consumeUniversalSearchNdjson", () => {
+ it("emits progressive groups and returns the canonical final response across split chunks", async () => {
+ const { consumeUniversalSearchNdjson } = await import("../src/lib/universal-search-stream");
+ const group: UniversalSearchGroup = {
+ kind: "medications",
+ total: 1,
+ latencyMs: 3,
+ items: [
+ {
+ id: "clozapine",
+ kind: "medications",
+ title: "Clozapine",
+ href: "/medications/clozapine",
+ score: 10,
+ },
+ ],
+ };
+ const final: UniversalSearchResponse = {
+ query: "clozapine",
+ groups: [group],
+ tookMs: 4,
+ domainOrder: ["medications"],
+ };
+ const payload = `${JSON.stringify({ type: "group", query: "clozapine", group })}\n${JSON.stringify({
+ type: "complete",
+ response: final,
+ })}\n`;
+ const splitAt = payload.indexOf("Clozapine") + 4;
+ const onGroup = vi.fn();
+
+ const result = await consumeUniversalSearchNdjson(
+ responseFromChunks([payload.slice(0, splitAt), payload.slice(splitAt)]),
+ { onGroup },
+ );
+
+ expect(onGroup).toHaveBeenCalledOnce();
+ expect(onGroup).toHaveBeenCalledWith(group, "clozapine");
+ expect(result).toEqual(final);
+ });
+
+ it("cancels the reader and rejects when the caller aborts", async () => {
+ const { consumeUniversalSearchNdjson } = await import("../src/lib/universal-search-stream");
+ const cancelled = vi.fn();
+ const response = new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ encoder.encode(
+ `${JSON.stringify({
+ type: "group",
+ query: "clozapine",
+ group: { kind: "medications", total: 0, latencyMs: 1, items: [] },
+ })}\n`,
+ ),
+ );
+ },
+ cancel(reason) {
+ cancelled(reason);
+ },
+ }),
+ { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } },
+ );
+ const controller = new AbortController();
+ const pending = consumeUniversalSearchNdjson(response, { signal: controller.signal });
+ await Promise.resolve();
+
+ controller.abort(new DOMException("superseded", "AbortError"));
+
+ await expect(pending).rejects.toMatchObject({ name: "AbortError" });
+ expect(cancelled).toHaveBeenCalledOnce();
+ });
+
+ it("rejects a truncated stream without a complete event", async () => {
+ const { consumeUniversalSearchNdjson } = await import("../src/lib/universal-search-stream");
+ const response = responseFromChunks([
+ `${JSON.stringify({
+ type: "group",
+ query: "clozapine",
+ group: { kind: "medications", total: 0, latencyMs: 1, items: [] },
+ })}\n`,
+ ]);
+
+ await expect(consumeUniversalSearchNdjson(response)).rejects.toThrow("complete event");
+ });
+});
diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts
index d5c84b80f..7739965a0 100644
--- a/tests/universal-search.test.ts
+++ b/tests/universal-search.test.ts
@@ -171,50 +171,52 @@ describe("runUniversalSearch (demo/fixtures path)", () => {
it("keeps registry hrefs when document search uses related-document mapping", async () => {
isolateNextModuleImport();
+ const searchChunksWithTelemetry = vi.fn(async () => ({
+ results: [
+ {
+ id: "registry-chunk",
+ document_id: "registry-doc",
+ title: "Crisis service",
+ file_name: "service-crisis-service.registry.json",
+ page_number: 1,
+ hybrid_score: 0.92,
+ similarity: 0.9,
+ images: [],
+ source_metadata: {
+ source_kind: "registry_record",
+ registry_record_kind: "service",
+ registry_record_slug: "crisis-service",
+ },
+ },
+ ],
+ }));
vi.doMock("@/lib/rag", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- searchChunksWithTelemetry: vi.fn(async () => ({
- results: [
- {
- id: "registry-chunk",
- document_id: "registry-doc",
- title: "Crisis service",
- file_name: "service-crisis-service.registry.json",
- page_number: 1,
- hybrid_score: 0.92,
- similarity: 0.9,
- images: [],
- source_metadata: {
- source_kind: "registry_record",
- registry_record_kind: "service",
- registry_record_slug: "crisis-service",
- },
- },
- ],
- })),
+ searchChunksWithTelemetry,
};
});
+ const fetchRelatedDocuments = vi.fn(async () => [
+ {
+ document_id: "registry-doc",
+ title: "Crisis service",
+ file_name: "service-crisis-service.registry.json",
+ labels: [],
+ summary: null,
+ best_pages: [1],
+ best_chunk_ids: ["registry-chunk"],
+ image_count: 0,
+ table_count: 0,
+ match_reason: "Matched 1 indexed passage",
+ score: 0.92,
+ },
+ ]);
vi.doMock("@/lib/document-enrichment", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- fetchRelatedDocuments: vi.fn(async () => [
- {
- document_id: "registry-doc",
- title: "Crisis service",
- file_name: "service-crisis-service.registry.json",
- labels: [],
- summary: null,
- best_pages: [1],
- best_chunk_ids: ["registry-chunk"],
- image_count: 0,
- table_count: 0,
- match_reason: "Matched 1 indexed passage",
- score: 0.92,
- },
- ]),
+ fetchRelatedDocuments,
};
});
@@ -228,6 +230,12 @@ describe("runUniversalSearch (demo/fixtures path)", () => {
});
expect(response.groups[0]?.items[0]?.href).toBe("/services/crisis-service");
+ expect(searchChunksWithTelemetry).toHaveBeenCalledWith(
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ );
+ expect(fetchRelatedDocuments).toHaveBeenCalledWith(
+ expect.objectContaining({ includeVisualCounts: false, signal: expect.any(AbortSignal) }),
+ );
});
});
@@ -299,6 +307,38 @@ describe("GET /api/search/universal (demo mode)", () => {
const invalid = await GET(new Request("http://localhost/api/search/universal?q=transport&mode=bogus"));
expect(invalid.status).toBe(400);
});
+
+ it("streams ready groups as NDJSON and finishes with the canonical response", async () => {
+ vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "true");
+ const { GET } = await import("../src/app/api/search/universal/route");
+ const response = await GET(
+ new Request(
+ "http://localhost/api/search/universal?q=acamprosate&limit=3&domains=medications,tools&stream=ndjson",
+ ),
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Content-Type")).toContain("application/x-ndjson");
+ const events = (await response.text())
+ .trim()
+ .split("\n")
+ .map((line) => JSON.parse(line) as Record);
+ const groupEvents = events.filter((event) => event.type === "group") as Array<{
+ query: string;
+ group: { kind: string };
+ }>;
+ const complete = events.at(-1) as {
+ type: string;
+ response: { query: string; groups: Array<{ kind: string }>; demoMode?: boolean };
+ };
+
+ expect(groupEvents).toHaveLength(2);
+ expect(groupEvents.every((event) => event.query === "acamprosate")).toBe(true);
+ expect(complete.type).toBe("complete");
+ expect(complete.response.demoMode).toBe(true);
+ expect(complete.response.groups.map((group) => group.kind)).toEqual(["medications", "tools"]);
+ expect(new Set(groupEvents.map((event) => event.group.kind))).toEqual(new Set(["medications", "tools"]));
+ });
});
describe("runUniversalSearch (query intelligence & ranking)", () => {
@@ -455,6 +495,99 @@ describe("runUniversalSearch (query intelligence & ranking)", () => {
});
});
+describe("runUniversalSearch (owner catalogue cache)", () => {
+ it("reads each owner catalogue once and reuses it across warmer prefixes", async () => {
+ isolateNextModuleImport();
+ const fetchOwnerMedicationRowsWithSeed = vi.fn<
+ (supabase: unknown, ownerId: string, limit: number, options: { select?: string }) => Promise
+ >(async () => [
+ {
+ slug: "clozapine",
+ name: "Clozapine",
+ class: "Antipsychotic",
+ subclass: "Second generation",
+ category: "Psychosis",
+ tag: "High monitoring",
+ schedule: "S4",
+ stats: [],
+ sections: [],
+ quick: [],
+ },
+ ]);
+ const fetchOwnerRegistryRows = vi.fn<
+ (
+ supabase: unknown,
+ ownerId: string,
+ kind: string,
+ limit: number,
+ options: { select?: string },
+ ) => Promise
+ >(async () => [
+ {
+ slug: "clozapine-clinic",
+ title: "Clozapine clinic",
+ subtitle: null,
+ status_chips: [],
+ primary_contact: null,
+ contacts: [],
+ route: null,
+ eligibility: null,
+ cost: null,
+ referral: null,
+ location: null,
+ summary_cards: [],
+ referral_info: [],
+ best_use: null,
+ criteria: [],
+ verification: {},
+ tags: [],
+ catchments: [],
+ catalogue_label: null,
+ navigator_query: null,
+ source: {},
+ catalog_payload: {},
+ },
+ ]);
+ vi.doMock("@/lib/medication-seed", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchOwnerMedicationRowsWithSeed,
+ }));
+ vi.doMock("@/lib/registry-seed", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchOwnerRegistryRows,
+ }));
+
+ const { runUniversalSearch } = await loadUniversalSearch();
+ const args = {
+ limitPerDomain: 3,
+ domains: ["medications", "services"] as Array<"medications" | "services">,
+ demo: false,
+ ownerId: "owner-a",
+ supabase: {} as never,
+ };
+ await runUniversalSearch({ ...args, query: "clo" });
+ await runUniversalSearch({ ...args, query: "cloz" });
+
+ expect(fetchOwnerMedicationRowsWithSeed).toHaveBeenCalledTimes(1);
+ expect(fetchOwnerRegistryRows).toHaveBeenCalledTimes(1);
+ expect(fetchOwnerMedicationRowsWithSeed).toHaveBeenCalledWith(
+ args.supabase,
+ "owner-a",
+ 500,
+ expect.objectContaining({ select: expect.any(String), signal: expect.any(AbortSignal) }),
+ );
+ expect(fetchOwnerRegistryRows).toHaveBeenCalledWith(
+ args.supabase,
+ "owner-a",
+ "service",
+ 500,
+ expect.objectContaining({ select: expect.any(String), signal: expect.any(AbortSignal) }),
+ );
+ expect(fetchOwnerMedicationRowsWithSeed.mock.calls[0]?.[3]?.select).not.toBe("*");
+ expect(fetchOwnerRegistryRows.mock.calls[0]?.[4]?.select).not.toBe("*");
+ });
+});
+
// Regression guard: outside an explicit demo/local deploy, no caller — including an anonymous,
// no-cookie live visitor — may be served the synthetic demo corpus. The documents domain must
// reach the real retrieval pipeline (demo:false + supabase), scoped to the public corpus for
diff --git a/tests/use-universal-search-stream.dom.test.tsx b/tests/use-universal-search-stream.dom.test.tsx
new file mode 100644
index 000000000..83f990563
--- /dev/null
+++ b/tests/use-universal-search-stream.dom.test.tsx
@@ -0,0 +1,191 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search";
+import type { UniversalSearchGroup, UniversalSearchResponse } from "@/lib/universal-search";
+import type { UniversalSearchStreamEvent } from "@/lib/universal-search-stream";
+
+const authSession = vi.hoisted(() => ({
+ authorizationHeader: { Authorization: "Bearer universal-search-test" },
+}));
+
+vi.mock("@/lib/supabase/client", () => ({
+ useAuthSession: () => authSession,
+}));
+
+const encoder = new TextEncoder();
+
+function controlledNdjsonResponse() {
+ let controller: ReadableStreamDefaultController;
+ const cancelled = vi.fn();
+ const response = new Response(
+ new ReadableStream({
+ start(streamController) {
+ controller = streamController;
+ },
+ cancel(reason) {
+ cancelled(reason);
+ },
+ }),
+ { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } },
+ );
+
+ return {
+ response,
+ cancelled,
+ write(event: UniversalSearchStreamEvent) {
+ controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
+ },
+ close() {
+ controller.close();
+ },
+ };
+}
+
+function searchGroup(kind: "medications" | "tools", title: string): UniversalSearchGroup {
+ return {
+ kind,
+ total: 1,
+ latencyMs: 4,
+ items: [
+ {
+ id: `${kind}-${title.toLowerCase().replaceAll(" ", "-")}`,
+ kind,
+ title,
+ href: `/${kind}/${title.toLowerCase().replaceAll(" ", "-")}`,
+ score: 10,
+ },
+ ],
+ };
+}
+
+async function startDebouncedRequest() {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(250);
+ });
+}
+
+async function flushStream() {
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+let fetchMock: ReturnType>;
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe("useUniversalSearch NDJSON integration", () => {
+ it("renders each ready group before committing the canonical final response", async () => {
+ const query = "progressive parity query";
+ const stream = controlledNdjsonResponse();
+ const medicationGroup = searchGroup("medications", "Clozapine");
+ const toolsGroup = searchGroup("tools", "Dose calculator");
+ const final: UniversalSearchResponse = {
+ query,
+ groups: [toolsGroup, medicationGroup],
+ tookMs: 12,
+ domainOrder: ["tools", "medications"],
+ contextMode: "answer",
+ preferredDomains: ["tools"],
+ };
+ fetchMock.mockResolvedValue(stream.response);
+
+ const { result } = renderHook(() =>
+ useUniversalSearch({ query, enabled: true, contextMode: "answer", excludeDomains: ["documents"] }),
+ );
+ await startDebouncedRequest();
+
+ const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]), "http://localhost");
+ expect(requestUrl.searchParams.get("stream")).toBe("ndjson");
+
+ stream.write({ type: "group", query, group: medicationGroup });
+ await flushStream();
+ expect(result.current).toMatchObject({ groups: [medicationGroup], loading: true, query });
+
+ stream.write({ type: "group", query, group: toolsGroup });
+ await flushStream();
+ expect(result.current.groups).toEqual([medicationGroup, toolsGroup]);
+ expect(result.current.loading).toBe(true);
+
+ stream.write({ type: "complete", response: final });
+ stream.close();
+ await flushStream();
+ expect(result.current).toMatchObject({
+ groups: final.groups,
+ loading: false,
+ query,
+ domainOrder: final.domainOrder,
+ contextMode: final.contextMode,
+ preferredDomains: final.preferredDomains,
+ });
+ });
+
+ it("aborts superseded streams, ignores their partial state, and caches only completed responses", async () => {
+ const firstQuery = "uncached partial query";
+ const secondQuery = "completed cached query";
+ const firstStream = controlledNdjsonResponse();
+ const secondStream = controlledNdjsonResponse();
+ const thirdStream = controlledNdjsonResponse();
+ const firstGroup = searchGroup("medications", "First partial");
+ const secondGroup = searchGroup("tools", "Second complete");
+ const streams = [firstStream, secondStream, thirdStream];
+ const requestSignals: AbortSignal[] = [];
+ fetchMock.mockImplementation((_input, init) => {
+ requestSignals.push(init?.signal as AbortSignal);
+ return Promise.resolve(streams[requestSignals.length - 1].response);
+ });
+
+ const { result, rerender } = renderHook(
+ ({ query }) => useUniversalSearch({ query, enabled: true, contextMode: "answer" }),
+ { initialProps: { query: firstQuery } },
+ );
+ await startDebouncedRequest();
+ firstStream.write({ type: "group", query: firstQuery, group: firstGroup });
+ await flushStream();
+ expect(result.current).toMatchObject({ groups: [firstGroup], loading: true });
+
+ rerender({ query: secondQuery });
+ await flushStream();
+ expect(requestSignals[0].aborted).toBe(true);
+ expect(firstStream.cancelled).toHaveBeenCalledOnce();
+ expect(result.current.groups).toEqual([]);
+
+ await startDebouncedRequest();
+ const completedResponse: UniversalSearchResponse = {
+ query: secondQuery,
+ groups: [secondGroup],
+ tookMs: 8,
+ contextMode: "answer",
+ };
+ secondStream.write({ type: "complete", response: completedResponse });
+ secondStream.close();
+ await flushStream();
+ expect(result.current).toMatchObject({ groups: [secondGroup], loading: false, query: secondQuery });
+
+ // The aborted first query had only a partial group, so revisiting it must fetch again.
+ rerender({ query: firstQuery });
+ await startDebouncedRequest();
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+
+ // The completed second query is cached; returning to it aborts the third request and needs no fourth fetch.
+ rerender({ query: secondQuery });
+ await flushStream();
+ await startDebouncedRequest();
+ expect(requestSignals[2].aborted).toBe(true);
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(result.current).toMatchObject({ groups: [secondGroup], loading: false, query: secondQuery });
+ });
+});