diff --git a/src/lib/search-scope.ts b/src/lib/search-scope.ts index 9e468bd34..f86b82ae4 100644 --- a/src/lib/search-scope.ts +++ b/src/lib/search-scope.ts @@ -78,6 +78,7 @@ type ScopeDocumentRow = { }; type ScopeLabelRow = { + id: string; document_id: string; label: string; label_type: DocumentLabelType; @@ -326,15 +327,24 @@ 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); labelsByDocument = new Map(); - for (const label of (labelRows ?? []) as ScopeLabelRow[]) { - labelsByDocument.set(label.document_id, [...(labelsByDocument.get(label.document_id) ?? []), label]); + for (let offset = 0; ; offset += documentScopeQueryPageSize) { + let labelQuery = args.supabase + .from("document_labels") + .select("id,document_id,label,label_type") + .in("document_id", candidateIds) + .in("label_type", [...labelTypes]) + // `id` is unique, so range pages cannot silently skip or repeat rows + // when PostgREST applies its default 1,000-row response cap. + .order("id", { ascending: true }) + .range(offset, offset + documentScopeQueryPageSize - 1); + if (args.signal) labelQuery = labelQuery.abortSignal(args.signal); + const { data: labelRows, error: labelError } = await labelQuery; + if (labelError) throw new Error(labelError.message); + for (const label of (labelRows ?? []) as ScopeLabelRow[]) { + labelsByDocument.set(label.document_id, [...(labelsByDocument.get(label.document_id) ?? []), label]); + } + if ((labelRows ?? []).length < documentScopeQueryPageSize) break; } } diff --git a/tests/reconciliation-preflight.test.ts b/tests/reconciliation-preflight.test.ts index 1247b5f33..ea6d5132b 100644 --- a/tests/reconciliation-preflight.test.ts +++ b/tests/reconciliation-preflight.test.ts @@ -1,8 +1,7 @@ -import { spawnSync } from "node:child_process"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { classifyReconciliationState, + collectReconciliationState, collectProcessDiagnostics, parseWorktreePorcelain, } from "../scripts/reconciliation-preflight.mjs"; @@ -103,18 +102,14 @@ describe("reconciliation preflight", () => { }); it("emits parseable metadata-only JSON without fetching", () => { - const script = path.resolve(process.cwd(), "scripts", "reconciliation-preflight.mjs"); - const result = spawnSync(process.execPath, [script, "--json"], { - cwd: process.cwd(), - encoding: "utf8", - }); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const payload = JSON.parse(result.stdout); + // Exercise the exported collector in-process. Spawning a second Node/Vite + // module graph here made this otherwise synchronous contract contend with + // the full suite for CPU and occasionally exceed the global test timeout. + const stdout = JSON.stringify(collectReconciliationState()); + const payload = JSON.parse(stdout); expect(payload).toMatchObject({ cachedRefsOnly: true, fetched: false }); expect(payload.integrationBase).toBe("dedicated-worktree-required"); expect(payload.processDiagnostics).toMatchObject({ skipped: true, rawCommandLinesSerialized: false }); - expect(result.stdout).not.toContain("commandLine"); - }, 60_000); + expect(stdout).not.toContain("commandLine"); + }); }); diff --git a/tests/search-scope.test.ts b/tests/search-scope.test.ts index 49f2fb6aa..3ea34f252 100644 --- a/tests/search-scope.test.ts +++ b/tests/search-scope.test.ts @@ -2,6 +2,63 @@ import { describe, expect, it } from "vitest"; import { activeScopeFilterCount, resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; describe("search scope filters", () => { + it("paginates label enumeration beyond the PostgREST 1,000-row cap", async () => { + const documentIds = ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"]; + const documents = documentIds.map((id) => ({ id, metadata: {}, import_batch_id: null })); + const labels = Array.from({ length: 1_001 }, (_, index) => ({ + id: String(index).padStart(4, "0"), + document_id: index === 1_000 ? documentIds[1] : documentIds[0], + label: index === 1_000 ? "target-service" : `other-service-${index}`, + label_type: "service", + })); + const requestedRanges: Array<[string, number, number]> = []; + const requestedOrders: Array<[string, string, boolean]> = []; + + const from = (table: string) => { + let start = 0; + let end = 999; + const builder = { + select: () => builder, + eq: () => builder, + is: () => builder, + in: () => builder, + or: () => builder, + order: (column: string, options?: { ascending?: boolean }) => { + requestedOrders.push([table, column, options?.ascending !== false]); + return builder; + }, + abortSignal: () => builder, + range: (nextStart: number, nextEnd: number) => { + start = nextStart; + end = nextEnd; + requestedRanges.push([table, start, end]); + return builder; + }, + then: (resolve: (value: { data: unknown[]; error: null }) => unknown) => { + const rows = table === "documents" ? documents : labels; + return Promise.resolve(resolve({ data: rows.slice(start, end + 1), error: null })); + }, + }; + return builder; + }; + + await expect( + resolveSearchScope({ + supabase: { from } as never, + accessScope: { includePublic: true }, + filters: { services: ["target-service"] }, + }), + ).resolves.toMatchObject({ documentIds: [documentIds[1]], matchedDocumentCount: 1 }); + expect(requestedRanges.filter(([table]) => table === "document_labels")).toEqual([ + ["document_labels", 0, 999], + ["document_labels", 1_000, 1_999], + ]); + expect(requestedOrders.filter(([table]) => table === "document_labels")).toEqual([ + ["document_labels", "id", true], + ["document_labels", "id", true], + ]); + }); + it("accepts smart document label filter groups", () => { const filters = searchScopeFiltersSchema.parse({ services: ["mental-health"],