diff --git a/app/e2e/query-safety.spec.ts b/app/e2e/query-safety.spec.ts index 00bb46a0..0c4bb3c1 100644 --- a/app/e2e/query-safety.spec.ts +++ b/app/e2e/query-safety.spec.ts @@ -20,13 +20,14 @@ import type { APIRequestContext } from "@playwright/test"; * connection/src/generalized/interfaces.ts:84 — the CLAUDE.md claim of * 30s is stale. Tests use the real 2s default. * - * 2. Effective row cap is 5000, not 10000. The PG and Neo4j connectors - * truncate at `config.rowLimit = 5000` (interfaces.ts:89) BEFORE the API - * route's `MAX_ROWS = 10_000` check runs. The route's truncation logic - * is dead code and `meta.truncated` is never set — which means the - * "Showing first 10,000 rows…" banner in card-container.tsx:569 never - * renders in practice. Tests pin the current reality; a follow-up bug - * issue is filed to reconnect the driver→route→UI signal. + * 2. Row cap is 5000 by default, user-configurable per connection via + * `credentials.maxRows` (#499 fix). The driver signals truncation by + * calling `setStatus(COMPLETE_TRUNCATED)`, which the query-executor + * captures into `truncated: true` on its return value. The API route + * forwards both `truncated` and the effective `rowLimit` into meta, + * and the UI banner renders "Showing first N rows" with the dynamic + * value. Test 3 verifies the default, test 4b verifies a per-connection + * override is honored. * * 3. The empty-state card header reads "No results", not "No data". The * exploration agent misread card-container.tsx earlier. @@ -150,23 +151,18 @@ test.describe("Query safety nets — timeout + row cap + error UX", () => { }); // ───────────────────────────────────────────────────────────────────────── - // 3. PostgreSQL row cap — asserts the CURRENT (buggy) behavior + // 3. PostgreSQL row cap — driver signal reaches meta.truncated + banner // ───────────────────────────────────────────────────────────────────────── // - // Intended design: API returns 10_000 rows + meta.truncated=true, UI - // shows "Showing first 10,000 rows…" banner. - // - // Actual behavior: The PG connector slices at rowLimit=5000 BEFORE the - // route sees the data. The route's MAX_ROWS=10_000 - // comparison never triggers, so meta.truncated is never - // set and the banner never renders. Filed follow-up - // bug #TBD — the driver needs to signal "truncated" - // through the onSuccess callback. - // - // This test pins the current reality so the fix is visible when it lands. - test("PG row-cap pins the driver-level 5000 limit (meta.truncated is currently never set)", async ({ + // After #499, the PG connector's COMPLETE_TRUNCATED status flows through + // the query-executor's setStatus handler into the API response, so both + // meta.truncated and meta.rowLimit are populated and the widget renders + // the "Showing first N rows" banner. + test("PG row cap propagates driver truncation signal to API and widget banner", async ({ page, }) => { + // API-level assertion first — seeded conn-pg-001 has no maxRows + // override, so the effective cap is DEFAULT_MAX_ROWS (5000). const apiRes = await page.request.post("/api/query", { data: { connectionId: PG_CONNECTION_ID, @@ -176,20 +172,35 @@ test.describe("Query safety nets — timeout + row cap + error UX", () => { expect(apiRes.status()).toBe(200); const body = await apiRes.json(); - // Current reality: driver rowLimit caps at 5000. expect(Array.isArray(body.data?.data)).toBe(true); expect((body.data?.data as unknown[]).length).toBe(5_000); + expect(body.meta?.truncated).toBe(true); + expect(body.meta?.rowLimit).toBe(5000); - // Current reality: meta.truncated never set. - // When the follow-up bug is fixed, this assertion will start failing — - // flip to `.toBe(true)` and update the row count expectation. - expect(body.meta?.truncated).toBeUndefined(); + // UI-level assertion: create a dashboard that runs the same query + // and verify the banner renders with the correct dynamic text. + const { id, cleanup } = await createSingleTableDashboard( + page.request, + `pg-row-cap ${Date.now()}`, + PG_CONNECTION_ID, + "SELECT generate_series(1, 15000) AS id", + ); + try { + await page.goto(`/${id}`); + await expect( + page.getByText( + /Showing first 5,000 rows\. Refine your query to see all results\./, + ), + ).toBeVisible({ timeout: 20_000 }); + } finally { + await cleanup(); + } }); // ───────────────────────────────────────────────────────────────────────── - // 4. Cypher row cap — same caveat as #3 + // 4. Cypher row cap — same behavior via Neo4j driver signal // ───────────────────────────────────────────────────────────────────────── - test("Cypher row-cap pins the driver-level 5000 limit (meta.truncated is currently never set)", async ({ + test("Cypher row cap propagates driver truncation signal to API and widget banner", async ({ page, }) => { const apiRes = await page.request.post("/api/query", { @@ -203,7 +214,87 @@ test.describe("Query safety nets — timeout + row cap + error UX", () => { expect(Array.isArray(body.data?.data)).toBe(true); expect((body.data?.data as unknown[]).length).toBe(5_000); - expect(body.meta?.truncated).toBeUndefined(); + expect(body.meta?.truncated).toBe(true); + expect(body.meta?.rowLimit).toBe(5000); + + const { id, cleanup } = await createSingleTableDashboard( + page.request, + `cypher-row-cap ${Date.now()}`, + NEO4J_CONNECTION_ID, + "UNWIND range(1, 15000) AS x RETURN x AS id", + ); + try { + await page.goto(`/${id}`); + await expect( + page.getByText( + /Showing first 5,000 rows\. Refine your query to see all results\./, + ), + ).toBeVisible({ timeout: 20_000 }); + } finally { + await cleanup(); + } + }); + + // ───────────────────────────────────────────────────────────────────────── + // 4b. Per-connection maxRows override + // ───────────────────────────────────────────────────────────────────────── + // + // Creators can raise (or lower) the cap on a per-connection basis via + // Advanced Settings > Max Rows per Query. This test creates a PG + // connection with maxRows=1000 and verifies the driver honors it — both + // the row count and the banner should reflect the custom value. + test("per-connection maxRows override is honored by driver + banner", async ({ + page, + }) => { + // Create a fresh PG connection with an explicit maxRows cap. + const createRes = await page.request.post("/api/connections", { + data: { + name: `maxrows-override ${Date.now()}`, + type: "postgresql", + config: { + uri: `postgresql://localhost:${process.env.TEST_PG_PORT ?? "5432"}`, + username: "neoboard", + password: "neoboard", + database: "movies", + maxRows: 1000, + }, + }, + }); + expect(createRes.status()).toBe(201); + const connId = (await createRes.json()).data.id as string; + + try { + // API-level: effective cap should be 1000, not the 5000 default. + const apiRes = await page.request.post("/api/query", { + data: { + connectionId: connId, + query: "SELECT generate_series(1, 5000) AS id", + }, + }); + expect(apiRes.status()).toBe(200); + const body = await apiRes.json(); + expect((body.data?.data as unknown[]).length).toBe(1_000); + expect(body.meta?.truncated).toBe(true); + expect(body.meta?.rowLimit).toBe(1000); + + // UI-level: banner should render with the override value. + const { id, cleanup } = await createSingleTableDashboard( + page.request, + `pg-override ${Date.now()}`, + connId, + "SELECT generate_series(1, 5000) AS id", + ); + try { + await page.goto(`/${id}`); + await expect(page.getByText(/Showing first 1,000 rows\./)).toBeVisible({ + timeout: 20_000, + }); + } finally { + await cleanup(); + } + } finally { + await page.request.delete(`/api/connections/${connId}`); + } }); // ───────────────────────────────────────────────────────────────────────── diff --git a/app/playwright.config.ts b/app/playwright.config.ts index ad65d8ab..e515f523 100644 --- a/app/playwright.config.ts +++ b/app/playwright.config.ts @@ -42,12 +42,23 @@ export default defineConfig({ screenshot: "only-on-failure", navigationTimeout: 15_000, actionTimeout: 10_000, + // Force a fixed, generously-sized viewport for the whole suite. The + // default Desktop Chrome viewport is 1280×720; tall modal forms (e.g. + // the connection editor with all advanced settings open) push their + // submit buttons below the fold and Playwright's "scroll into view" + // racing with Radix Dialog's own scroll container leaves clicks + // unresolved. 1280×1024 fits every dialog in the suite without + // changing per-test code, and never auto-resizes during a run. + viewport: { width: 1280, height: 1024 }, }, projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 1024 }, + }, }, ], }); diff --git a/app/src/app/(dashboard)/connections/page.tsx b/app/src/app/(dashboard)/connections/page.tsx index 7b445d64..4e71bb83 100644 --- a/app/src/app/(dashboard)/connections/page.tsx +++ b/app/src/app/(dashboard)/connections/page.tsx @@ -60,6 +60,7 @@ const DEFAULT_FORM = { idleTimeout: "", statementTimeout: "", sslRejectUnauthorized: undefined as boolean | undefined, + maxRows: "", }; export default function ConnectionsPage() { @@ -125,6 +126,7 @@ export default function ConnectionsPage() { idleTimeout: parseOptionalInt(form.idleTimeout), statementTimeout: parseOptionalInt(form.statementTimeout), sslRejectUnauthorized: form.sslRejectUnauthorized, + maxRows: parseOptionalInt(form.maxRows), }; } @@ -338,6 +340,7 @@ export default function ConnectionsPage() { idleTimeout: parseOptionalInt(editForm.idleTimeout), statementTimeout: parseOptionalInt(editForm.statementTimeout), sslRejectUnauthorized: editForm.sslRejectUnauthorized, + maxRows: parseOptionalInt(editForm.maxRows), }; } @@ -615,6 +618,23 @@ export default function ConnectionsPage() { > )} + + {/* Result limits — shared across connector types */} +
+ Results beyond this cap are truncated and a banner is + shown on the widget. Default 5,000. Increase cautiously + — higher limits raise per-query memory usage. +
)} @@ -848,6 +868,23 @@ export default function ConnectionsPage() { > )} + + {/* Result limits — shared across connector types */} ++ Results beyond this cap are truncated and a banner is + shown on the widget. Default 5,000. Increase cautiously + — higher limits raise per-query memory usage. +
)} diff --git a/app/src/app/api/query/__tests__/route.test.ts b/app/src/app/api/query/__tests__/route.test.ts index 3cdef80d..916408e3 100644 --- a/app/src/app/api/query/__tests__/route.test.ts +++ b/app/src/app/api/query/__tests__/route.test.ts @@ -390,9 +390,14 @@ describe("POST /api/query", () => { expect(mockDb.select).toHaveBeenCalledTimes(1); }); - // --- MAX_ROWS truncation tests --- - - it("truncates data to 10,000 rows and sets truncated:true when result exceeds MAX_ROWS", async () => { + // --- Row cap (driver-reported truncation) tests --- + // + // Truncation is now enforced at the driver layer and signaled via the + // executor's setStatus callback. The route just forwards `truncated` and + // `rowLimit` from executeQuery's return value into the response meta — + // no more post-hoc `rawData.length > MAX_ROWS` slicing. + + it("forwards truncated:true and rowLimit when the driver signals truncation", async () => { mockRequireSession.mockResolvedValue(defaultSession); mockDb.select.mockReturnValue( drizzleSelectChain([ @@ -409,20 +414,26 @@ describe("POST /api/query", () => { username: "u", password: "p", }); - // Return 10001 rows - const bigData = Array.from({ length: 10001 }, (_, i) => ({ n: i })); - mockExecuteQuery.mockResolvedValue({ data: bigData, fields: ["n"] }); + // Driver already sliced to exactly rowLimit rows + set truncated flag. + const cappedData = Array.from({ length: 5000 }, (_, i) => ({ n: i })); + mockExecuteQuery.mockResolvedValue({ + data: cappedData, + fields: ["n"], + truncated: true, + rowLimit: 5000, + }); const res = await POST( makeRequest({ connectionId: "c1", query: "SELECT * FROM t" }), ); expect(res.status).toBe(200); const body = await res.json(); - expect(body.data.data).toHaveLength(10000); + expect(body.data.data).toHaveLength(5000); expect(body.meta.truncated).toBe(true); + expect(body.meta.rowLimit).toBe(5000); }); - it("does not truncate and omits truncated flag when result is exactly 10,000 rows", async () => { + it("omits truncated flag when driver reports no truncation", async () => { mockRequireSession.mockResolvedValue(defaultSession); mockDb.select.mockReturnValue( drizzleSelectChain([ @@ -439,19 +450,27 @@ describe("POST /api/query", () => { username: "u", password: "p", }); - const data = Array.from({ length: 10000 }, (_, i) => ({ n: i })); - mockExecuteQuery.mockResolvedValue({ data, fields: ["n"] }); + mockExecuteQuery.mockResolvedValue({ + data: [{ n: 1 }], + fields: ["n"], + truncated: false, + rowLimit: 5000, + }); const res = await POST( - makeRequest({ connectionId: "c1", query: "SELECT * FROM t" }), + makeRequest({ connectionId: "c1", query: "SELECT 1" }), ); expect(res.status).toBe(200); const body = await res.json(); - expect(body.data.data).toHaveLength(10000); + expect(body.data.data).toHaveLength(1); expect(body.meta.truncated).toBeUndefined(); + expect(body.meta.rowLimit).toBe(5000); }); - it("does not truncate when result is well below 10,000 rows", async () => { + it("echoes the per-connection rowLimit override when the creator raised it", async () => { + // When a connection's credentials.maxRows is set to e.g. 20000, the + // executor uses that as rowLimit and returns it in the result. This + // test pins that the route faithfully forwards the override. mockRequireSession.mockResolvedValue(defaultSession); mockDb.select.mockReturnValue( drizzleSelectChain([ @@ -467,19 +486,27 @@ describe("POST /api/query", () => { uri: "postgres://localhost", username: "u", password: "p", + maxRows: 20000, + }); + const cappedData = Array.from({ length: 20000 }, (_, i) => ({ n: i })); + mockExecuteQuery.mockResolvedValue({ + data: cappedData, + fields: ["n"], + truncated: true, + rowLimit: 20000, }); - mockExecuteQuery.mockResolvedValue({ data: [{ n: 1 }], fields: ["n"] }); const res = await POST( - makeRequest({ connectionId: "c1", query: "SELECT 1" }), + makeRequest({ connectionId: "c1", query: "SELECT * FROM t" }), ); expect(res.status).toBe(200); const body = await res.json(); - expect(body.data.data).toHaveLength(1); - expect(body.meta.truncated).toBeUndefined(); + expect(body.data.data).toHaveLength(20000); + expect(body.meta.truncated).toBe(true); + expect(body.meta.rowLimit).toBe(20000); }); - it("does not apply MAX_ROWS truncation when result data is not an array", async () => { + it("forwards truncated correctly for non-array (graph) results", async () => { mockRequireSession.mockResolvedValue(defaultSession); mockDb.select.mockReturnValue( drizzleSelectChain([ @@ -491,10 +518,13 @@ describe("POST /api/query", () => { username: "neo4j", password: "pass", }); - // Non-array result (e.g. graph data object) + // Non-array result (e.g. graph data object) — still carries a + // rowLimit in meta, but not truncated since the driver didn't flag it. mockExecuteQuery.mockResolvedValue({ data: { nodes: [], edges: [] }, fields: [], + truncated: false, + rowLimit: 5000, }); const res = await POST( @@ -503,6 +533,7 @@ describe("POST /api/query", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.meta.truncated).toBeUndefined(); + expect(body.meta.rowLimit).toBe(5000); expect(body.data.data).toEqual({ nodes: [], edges: [] }); }); }); diff --git a/app/src/app/api/query/route.ts b/app/src/app/api/query/route.ts index 641718dd..31b8f760 100644 --- a/app/src/app/api/query/route.ts +++ b/app/src/app/api/query/route.ts @@ -15,9 +15,6 @@ import { } from "@/lib/api/api-utils"; import { apiSuccess } from "@/lib/api/api-response"; -/** Maximum number of rows returned per query execution to prevent OOM. */ -const MAX_ROWS = 10_000; - const querySchema = z.object({ connectionId: z.string().min(1), query: z.string().min(1), @@ -112,19 +109,19 @@ export async function POST(request: Request) { // cache key. Normalization handled inside computeResultId. const resultId = computeResultId(connectionId, query, params); - // TODO: MAX_ROWS truncation currently happens after full materialisation. - // Ideally, pass a maxRows option to executeQuery so the driver can stop - // reading at MAX_ROWS+1 (cursor/stream consumption) to avoid OOM on very - // large result sets. See CodeRabbit review on PR #75. - const rawData = result.data; - const truncated = Array.isArray(rawData) && rawData.length > MAX_ROWS; - const truncatedData = truncated - ? (rawData as unknown[]).slice(0, MAX_ROWS) - : rawData; + // Truncation is enforced at the driver level (see + // lib/query/query-executor.ts — it spreads `rowLimit` onto the connector + // config and each connector slices at that value before calling + // onSuccess). The executor captures the `COMPLETE_TRUNCATED` signal via + // its setStatus handler and returns { truncated, rowLimit } alongside + // the data, so the route just forwards those fields to the client for + // the widget banner. + const { data, fields, truncated, rowLimit } = result; - return apiSuccess({ ...result, data: truncatedData }, 200, { + return apiSuccess({ data, fields }, 200, { resultId, serverDurationMs, + rowLimit, ...(truncated ? { truncated: true } : {}), }); } catch (error) { diff --git a/app/src/components/__tests__/card-container-states.test.tsx b/app/src/components/__tests__/card-container-states.test.tsx index 2c914a88..2f1f8cc5 100644 --- a/app/src/components/__tests__/card-container-states.test.tsx +++ b/app/src/components/__tests__/card-container-states.test.tsx @@ -333,7 +333,7 @@ describe("CardContainer", () => { // ----- Truncation warning ----- - it("shows truncation warning when data is truncated", () => { + it("shows truncation warning with the dynamic rowLimit when data is truncated", () => { mockUseWidgetQuery.mockReturnValue({ isPending: false, fetchStatus: "idle", @@ -342,13 +342,33 @@ describe("CardContainer", () => { data: [{ name: "Alice", value: 10 }], resultId: "r1", truncated: true, + rowLimit: 5000, }, missingParams: [], }); render(