diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index f63ed7ff1..c8075f511 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -713,6 +713,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A wave verdict — no code change; #1040 merged as cde6c5c) | canary run 29827012719 (#61, main cde6c5c) vs banked #60 (29800029819) | OPTION A WAVE ADOPTED — FIRST FULLY-GREEN 44-CASE CANARY IN PROGRAM HISTORY (Blocking failures: None). (1) Option A payoff EXCEEDED: citation_failure_rate 0.0227→0; the neuroleptic-side-effect-escalation case flipped from wrong-doc→failed-generation→1-citation-fallback to **strong route, successful gpt-5.6-sol generation, passed in 15.4s with no fallback marker** — the rescued S3 retrieval fixed generation itself, not just the citation count; expected_source_hit 0.6364→0.6591. (2) Golden held exactly as the blast-radius analysis promised: 36/36 PASS, content_recall 1.0, mrr@10 0.8921 BYTE-IDENTICAL to the pre-wave baseline (zero ordering movement — no golden case fires the predicate), irrelevant@10 0.1083→0.0917 (slightly better). (3) Parity payoff PARTIAL: monitoring targeting 1/5→2/5 (olanzapine-lai flipped — previously called a retrieval-depth residual; quetiapine-dose also flipped on the dose side); lithium-range (232ch) + metabolic (73ch, byte-identical answer to #60) did NOT flip despite offline-proven fixes — their live chunk sets evidently contain no admissible schedule sentence even under the widened gate → reclassified as retrieval-depth/live-content residuals joining adhd; below the ≥3/5 target but strictly improved, no regression anywhere. Dose 2/5 vs 2/4: same passing count, applicable set grew (new quality-metformin-renal-dosing miss = eval-set churn, not regression). (4) No-worse EXCEEDED: relevance 0.5333→0.6 (the two-step watch-item slide FULLY REVERSED to the #58 level), targeting_rate 0.6667→0.6957, fail_closed 0.9 held, readability/artifact_leaks 1.0, route ceilings 0, grounded 1.0, unsupported_correct 1.0, numeric 0, p95 22.8s, red_result 3/3. Adoption per the measured-gain rule: primary goal achieved, three case flips, relevance recovered, zero regressions. Residual queue: monitoring retrieval-depth trio (lithium-range/metabolic/adhd), E-3d H2 discards, weekly ANSWER_CASE_LIMIT 8→44 raise now unblocked (gate would be green), comparison-class coverage. Wave spend +~$2-4 → Phase E + Option A total ~$12-20 of ≤$20. | Evidence: run #61 job log read (Threshold Status: None; Answer Metrics; neuroleptic diagnostics row; targeting metric_rates + 6-miss list; golden 36 PASS lines + summary). Revert drill NOT triggered. | | 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: I9 weekly coverage raise) | see PR head | ADDENDUM 5 post-green item I9 (plan-authorized "after reds fixed"): weekly scheduled canary ANSWER_CASE_LIMIT default 8→44 — the Sunday 18:00 UTC cron now guards the FULL answer-quality case set instead of the first 8 (both #57 blocking reds historically lived OUTSIDE the first 8, leaving the weekly gate blind to them). Unblocked by run #61 proving the citation gate green on the full 44. Cost: est +$1-2/week (user-authorized in the plan). Contract test pin updated in lockstep (eval-canary-workflow.test.ts). Dispatch shapes unchanged (input override still wins); operational-risk diff, plain-revert rollback. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS (20/20); eval-canary-workflow contract 4/4; prettier clean; no provider calls | | 2026-07-21 | claude/database-governance-audit-10b6ed (PR #1051: source-governance audit — safe subset) | cee396730 | Governance-metadata observability + UI display + provenance flow test; no ranking/retrieval/generation surface touched. | IMPLEMENTED + handed off (not a review of prior work). Resolved audit #1 (logger.warn on unrecognized enum values; return value unchanged), #2 (review_due_source added to frontendVisibleWarningCodes → answer-level badge; warning-severity, no refusal impact), #9 (source_metadata retained on safety-finding citations + governance pill in SafetyFindingsListContent), #13 (new tests/provenance-flow.test.ts: DB-normalize→governance→client payload sources+safety citations→render policy). Deferred #4/5/6/8/10 (RAG-protected ranking/selection/LLM-context/cache — need live eval-canary+approval), #11/#5 flag debt (D5/D4), #3 (is_public schema/RLS), #7 (conflict-detection scope), #12 (canary automation). Rebased onto origin/main (was 18 behind; conflict-free — none of the 18 commits touched the 8 files). PR-policy CI green (confirmed no ragRankingPatterns match). | verify:pr-local exit 0 (351 files/3129 tests, production build, client-bundle secret scan, offline RAG fixtures 36/36); typecheck + lint + prettier green. verify:ui NOT run locally: pre-existing globals.css Tailwind/Turbopack dev-compile error (git-clean, unrelated; prod build passed) — CI Production UI job covers it. check:production-readiness deferred (offline env/config validator; PR changes no env/secret/config inputs; secretless worktree). No provider calls. | +| 2026-07-24 | PR #1132 / `codex/review-code-for-bugs-and-issues` (latest commit review: Next security patch + merge-readiness stabilizers) | 444972627e829775510638295665db09422f2812 | Scoped local review requested by user for remaining ASAP issues after latest PR commit. | No high-confidence code defect found in the current latest-commit diff. Recommended remaining ASAP work is process/environmental rather than another code patch: run the merge-readiness gate on Node 24.x, fetch/provide `main` or a remote so a true checkout-against-main check can run, and keep provider-backed/live gates approval-gated. Evidence: Next packages are updated to 16.2.11 in package.json/package-lock; `src/lib/search-scope.ts` adds batched label loading and abort propagation; sheet teardown guard prevents `document` access after DOM teardown; PDF deadline test now avoids scheduler-start race and zombie false-positive. | Local inspection: git status/HEAD/branch/remotes, latest commit diff, docs/codex-review-protocol.md. Prior checks from the commit: focused search/private-access tests passed, PDF/sheet focused tests passed, typecheck passed, npm audit high found 0 vulnerabilities. `npm run verify:cheap` remains blocked in this container by Node v20.20.2 vs repo Node 24.x engine / `node:module.registerHooks`; no hosted CI, GitHub, Supabase, OpenAI, or live provider calls. | | 2026-07-24 | codex/reindex-agent-enrichment-guard (PR #1143) | f82cf9cfa | Run PR sweep: CI fix + threads + drift | Before: PR required green, 1 unresolved reindex/agent-claim serialization thread, branch behind main. After: remote branch already had atomic RPC fix 2bb0470d plus merge-main d2d57d7c; validated pushed head, resolved thread via GraphQL; reply mutation 403 noted in commit f82cf9cf. | node scripts/run-vitest.mjs run --reporter=dot tests/reindex-enrichment-lease.test.ts tests/private-access-routes.test.ts tests/supabase-schema.test.ts tests/drift-detection.test.ts PASS (218/218); npm run check:migration-role PASS; npm run typecheck PASS; npm run check:production-readiness PASS with non-blocking warnings; no live eval gates run. | ## 2026-07-24 — work search chrome behaviour review diff --git a/package-lock.json b/package-lock.json index 18a6c8139..85e50bddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "devDependencies": { "@axe-core/playwright": "^4.12.1", "@babel/parser": "^8.0.4", - "@next/bundle-analyzer": "^16.2.10", + "@next/bundle-analyzer": "^16.2.11", "@tailwindcss/postcss": "^4.3.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", @@ -45,7 +45,7 @@ "@vitest/coverage-v8": "^4.1.10", "esbuild": "0.28.1", "eslint": "^9.39.5", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.11", "fast-check": "^4.8.0", "jsdom": "^29.1.1", "knip": "^6.27.0", @@ -1523,9 +1523,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1542,9 +1539,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1561,9 +1555,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1580,9 +1571,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1599,9 +1587,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1618,9 +1603,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1637,9 +1619,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1656,9 +1635,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1675,9 +1651,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1700,9 +1673,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1725,9 +1695,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1750,9 +1717,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1775,9 +1739,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1800,9 +1761,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1825,9 +1783,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1850,9 +1805,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2234,9 +2186,9 @@ } }, "node_modules/@next/bundle-analyzer": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.10.tgz", - "integrity": "sha512-KcepWhb3IVniZgm00GSSCQDEUQqZXuXtuXRh8J6e3Un342TcQ77iK4DedeEkct+fcx7yFEDL2J6z4Jeho5JDAw==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.11.tgz", + "integrity": "sha512-C5228wy1RC0g7uOSvmQhrZXCuEeLIPureKkqramAkySmqY2Y0yw6K+TyAxXXvtrYe4uehnZs3YMFfJcorBRwpg==", "dev": true, "license": "MIT", "dependencies": { @@ -2250,9 +2202,9 @@ "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz", - "integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.11.tgz", + "integrity": "sha512-vMEf/aXOpzFFdtIvFYOnIDPKb0xBbrXONsz83CcKdRrekfxNdL8PNkq5qHqAHSXVlIifnX68LOMaxr3z5PkeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3686,9 +3638,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3706,9 +3655,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3726,9 +3672,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3746,9 +3689,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6217,13 +6157,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz", - "integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.11.tgz", + "integrity": "sha512-FIpbK/dUyxUExchDB7eBg3k+VU8R2iR/Cx9/kqTBUTFv2bOIR9aRrpno4rvAQ9VhiPQAyFKNA2NlZwouGWtclA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.10", + "@next/eslint-plugin-next": "16.2.11", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/package.json b/package.json index afaeb300c..109c1a911 100644 --- a/package.json +++ b/package.json @@ -212,7 +212,7 @@ "devDependencies": { "@axe-core/playwright": "^4.12.1", "@babel/parser": "^8.0.4", - "@next/bundle-analyzer": "^16.2.10", + "@next/bundle-analyzer": "^16.2.11", "@tailwindcss/postcss": "^4.3.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", @@ -226,7 +226,7 @@ "@vitest/coverage-v8": "^4.1.10", "esbuild": "0.28.1", "eslint": "^9.39.5", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.11", "fast-check": "^4.8.0", "jsdom": "^29.1.1", "knip": "^6.27.0", diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index c3c3515b3..bfc8de55e 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1022,7 +1022,9 @@ export async function POST(request: Request) { const failurePayload = { results: [], telemetry: { - query_class: classifyRagQuery(error.message).queryClass, + query_class: fallbackBody + ? classifyRagQuery(fallbackBody.query).queryClass + : classifyRagQuery(error.message).queryClass, retrieval_strategy: null, failure_code: code, }, @@ -1031,7 +1033,7 @@ export async function POST(request: Request) { logSearchObservation({ supabase, ownerId, - query: "unknown", + query: fallbackBody?.query ?? "unknown", results: [], payload: failurePayload, failure: { diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index f0b9eba2b..685ae1310 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -222,6 +222,7 @@ export function Sheet({ window.setTimeout(() => { if ( restoreTarget.isConnected && + typeof document !== "undefined" && document.activeElement !== restoreTarget && document.activeElement === document.body ) { diff --git a/src/lib/search-scope.ts b/src/lib/search-scope.ts index 9e468bd34..fdb7330fb 100644 --- a/src/lib/search-scope.ts +++ b/src/lib/search-scope.ts @@ -22,6 +22,8 @@ const labelTypes = [ const sourceStatusValues = ["current", "review_due", "outdated", "unknown"] as const; const validationStatusValues = ["unverified", "locally_reviewed", "approved"] as const; const documentScopeQueryPageSize = 1000; +const labelScopeDocumentBatchSize = 200; +const labelScopeQueryPageSize = 1000; export const searchScopeFiltersSchema = z .object({ @@ -78,6 +80,7 @@ type ScopeDocumentRow = { }; type ScopeLabelRow = { + id?: string; document_id: string; label: string; label_type: DocumentLabelType; @@ -163,6 +166,39 @@ function labelMatches(labels: ScopeLabelRow[], type: DocumentLabelType, requeste return labels.some((label) => label.label_type === type && wanted.has(normalizeFilterText(label.label))); } +async function loadScopeLabels(args: { + supabase: SupabaseClient; + candidateIds: string[]; + signal?: AbortSignal; +}): Promise { + const rows: ScopeLabelRow[] = []; + + for (let start = 0; start < args.candidateIds.length; start += labelScopeDocumentBatchSize) { + const documentIdBatch = args.candidateIds.slice(start, start + labelScopeDocumentBatchSize); + for (let offset = 0; ; offset += labelScopeQueryPageSize) { + let labelQuery = args.supabase + .from("document_labels") + .select("id,document_id,label,label_type") + .in("document_id", documentIdBatch) + .in("label_type", [...labelTypes]) + .order("document_id", { ascending: true }) + .order("label_type", { ascending: true }) + .order("label", { ascending: true }) + .order("id", { ascending: true }) + .range(offset, offset + labelScopeQueryPageSize - 1); + if (args.signal) labelQuery = labelQuery.abortSignal(args.signal); + + const { data, error } = await labelQuery; + if (error) throw new Error(error.message); + const page = (data ?? []) as ScopeLabelRow[]; + rows.push(...page); + if (page.length < labelScopeQueryPageSize) break; + } + } + + return rows; +} + function isLocalSource(metadata: ClinicalSourceMetadata) { const jurisdiction = `${metadata.jurisdiction ?? ""} ${metadata.publisher ?? ""}`.toLowerCase(); return /\b(?:wa|western australia|north metropolitan|east metropolitan|south metropolitan|perth|health service)\b/.test( @@ -326,14 +362,9 @@ export async function resolveSearchScope(args: { hasValues(filters.labelTypesAny); let labelsByDocument = new Map(); if (needsLabels) { - const { data: labelRows, error: labelError } = await args.supabase - .from("document_labels") - .select("document_id,label,label_type") - .in("document_id", candidateIds) - .in("label_type", [...labelTypes]); - if (labelError) throw new Error(labelError.message); + const labelRows = await loadScopeLabels({ supabase: args.supabase, candidateIds, signal: args.signal }); labelsByDocument = new Map(); - for (const label of (labelRows ?? []) as ScopeLabelRow[]) { + for (const label of labelRows) { labelsByDocument.set(label.document_id, [...(labelsByDocument.get(label.document_id) ?? []), label]); } } diff --git a/tests/pdf-extraction-budget.test.ts b/tests/pdf-extraction-budget.test.ts index f802a8f79..ff7f978cd 100644 --- a/tests/pdf-extraction-budget.test.ts +++ b/tests/pdf-extraction-budget.test.ts @@ -121,7 +121,7 @@ describe("PDF extraction budgets", () => { ); await expect( - runPythonPdfExtractor(inputPath, outputDir, { ...PDF_EXTRACTION_BUDGET, totalTimeoutMs: 1_000 }, fakeExtractor), + runPythonPdfExtractor(inputPath, outputDir, { ...PDF_EXTRACTION_BUDGET, totalTimeoutMs: 3_000 }, fakeExtractor), ).rejects.toMatchObject({ code: "PDF_EXTRACTION_DEADLINE_EXCEEDED" }); const childPid = Number(await readFile(childPidPath, "utf8")); @@ -131,6 +131,13 @@ describe("PDF extraction budgets", () => { while (Date.now() < deadline) { try { process.kill(childPid, 0); + if (process.platform === "linux") { + const stat = await readFile(`/proc/${childPid}/stat`, "utf8").catch(() => ""); + if (stat.split(" ")[2] === "Z") { + childIsAlive = false; + break; + } + } await new Promise((resolve) => setTimeout(resolve, 25)); } catch { childIsAlive = false; diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 3646b608b..aaa5e0766 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -4071,6 +4071,45 @@ describe("private document API access", () => { expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); }); + it("logs failed search telemetry against the parsed query instead of an unknown placeholder", async () => { + vi.stubEnv("NODE_ENV", "production"); + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { retrieval_strategy: "text_fast_path" }, + })); + const client = createSupabaseMock((call) => + call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]), + ); + mockRuntime(client, { searchChunksWithTelemetry }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + authenticatedRequest("/api/search", { + method: "POST", + body: JSON.stringify({ + query: "Clozapine monitoring", + includeRelatedDocuments: false, + filters: { sourceStatuses: ["current"] }, + }), + }), + ); + + expect(response.status).toBe(500); + await vi.waitFor(() => { + const insert = client.calls.find((call) => call.table === "rag_queries" && call.operation === "insert"); + expect(insert).toBeTruthy(); + const payload = insert?.insertPayload as Record; + const expectedHash = createHmac("sha256", "test-query-hash-secret-at-least-16-chars") + .update("clozapine monitoring") + .digest("hex"); + const unknownHash = createHmac("sha256", "test-query-hash-secret-at-least-16-chars") + .update("unknown") + .digest("hex"); + expect(payload.query).toBe(`redacted-query:${expectedHash}`); + expect(payload.query).not.toBe(`redacted-query:${unknownHash}`); + }); + }); + it("falls back to visible demo answers only outside production when Supabase rejects the API key", async () => { const answerQuestionWithScope = vi.fn(async () => ({ answer: "Live answer", diff --git a/tests/search-scope.test.ts b/tests/search-scope.test.ts index 49f2fb6aa..e2a8f16a4 100644 --- a/tests/search-scope.test.ts +++ b/tests/search-scope.test.ts @@ -1,6 +1,84 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { activeScopeFilterCount, resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; +type QueryCall = { + table: string; + selected?: string; + range?: { from: number; to: number }; + filters: Array<{ column: string; value: unknown }>; + inFilters: Array<{ column: string; values: unknown[] }>; + orders: string[]; + abortSignals: AbortSignal[]; +}; + +type QueryResult = { data: unknown[]; error: { message: string } | null }; +type QueryResolver = (call: QueryCall) => QueryResult; + +class QueryBuilder implements PromiseLike { + constructor( + private readonly call: QueryCall, + private readonly resolver: QueryResolver, + ) {} + + select(selected: string) { + this.call.selected = selected; + return this; + } + + eq(column: string, value: unknown) { + this.call.filters.push({ column, value }); + return this; + } + + is(column: string, value: unknown) { + this.call.filters.push({ column, value }); + return this; + } + + or() { + return this; + } + + in(column: string, values: unknown[]) { + this.call.inFilters.push({ column, values }); + return this; + } + + order(column: string) { + this.call.orders.push(column); + return this; + } + + range(from: number, to: number) { + this.call.range = { from, to }; + return this; + } + + abortSignal(signal: AbortSignal) { + this.call.abortSignals.push(signal); + return this; + } + + then( + onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return Promise.resolve(this.resolver(this.call)).then(onfulfilled, onrejected); + } +} + +function supabaseMock(resolver: QueryResolver) { + const calls: QueryCall[] = []; + return { + calls, + from: vi.fn((table: string) => { + const call: QueryCall = { table, filters: [], inFilters: [], orders: [], abortSignals: [] }; + calls.push(call); + return new QueryBuilder(call, resolver); + }), + }; +} + describe("search scope filters", () => { it("accepts smart document label filter groups", () => { const filters = searchScopeFiltersSchema.parse({ @@ -55,4 +133,85 @@ describe("search scope filters", () => { summary: "All public documents", }); }); + + it("paginates label rows so later-page label matches are not silently dropped", async () => { + const wantedDocumentId = "22222222-2222-4222-8222-222222222222"; + const supabase = supabaseMock((call) => { + if (call.table === "documents") { + return { + data: [ + { id: "11111111-1111-4111-8111-111111111111", metadata: {}, import_batch_id: null }, + { id: wantedDocumentId, metadata: {}, import_batch_id: null }, + ], + error: null, + }; + } + if (call.table === "document_labels") { + if (call.range?.from === 0) { + return { + data: Array.from({ length: 1000 }, (_, index) => ({ + id: `label-${index.toString().padStart(4, "0")}`, + document_id: "11111111-1111-4111-8111-111111111111", + label: "other topic", + label_type: "topic", + })), + error: null, + }; + } + return { + data: [ + { + id: "label-wanted", + document_id: wantedDocumentId, + label: "clozapine", + label_type: "topic", + }, + ], + error: null, + }; + } + return { data: [], error: null }; + }); + + await expect( + resolveSearchScope({ + supabase: supabase as never, + accessScope: { ownerId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", includePublic: false }, + filters: { topics: ["clozapine"] }, + }), + ).resolves.toMatchObject({ + documentIds: [wantedDocumentId], + matchedDocumentCount: 1, + }); + + const labelCalls = supabase.calls.filter((call) => call.table === "document_labels"); + expect(labelCalls.map((call) => call.range)).toEqual([ + { from: 0, to: 999 }, + { from: 1000, to: 1999 }, + ]); + expect(labelCalls.every((call) => call.orders.includes("id"))).toBe(true); + }); + + it("propagates caller cancellation to label scope queries", async () => { + const controller = new AbortController(); + const supabase = supabaseMock((call) => { + if (call.table === "documents") { + return { + data: [{ id: "11111111-1111-4111-8111-111111111111", metadata: {}, import_batch_id: null }], + error: null, + }; + } + return { data: [], error: null }; + }); + + await resolveSearchScope({ + supabase: supabase as never, + accessScope: { ownerId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", includePublic: false }, + filters: { labelTypesAny: ["topic"] }, + signal: controller.signal, + }); + + const labelCall = supabase.calls.find((call) => call.table === "document_labels"); + expect(labelCall?.abortSignals).toContain(controller.signal); + }); });