diff --git a/src/remote/api-client.ts b/src/remote/api-client.ts index 6231910..9f048ea 100644 --- a/src/remote/api-client.ts +++ b/src/remote/api-client.ts @@ -33,12 +33,24 @@ export function hookUpApiReporter(api: RpcStub, remote: Remote): () = log.error(`Failed to push stats: ${err}`, "api-client"); }); }; + // pg_stat_statements retains everything the database ever executed — COPY + // from dumps/seeds, DDL from migrations, VACUUM. None of it is an app query: + // shipping it pollutes the project's query catalog and fires unfixable + // "no CI coverage" alerts (Query-Doctor/Site#3578). Only DML is pushed. + // `undefined` (analysis skipped or failed) is pushed too — fail open rather + // than silently losing real queries. Known gap: core classifies MergeStmt as + // "other", so MERGE statements are dropped until its map learns them. + const isAppQuery = (query: RecentQuery) => query.statementType !== "other"; + const onQueriesPolled = (queries: RecentQuery[]) => { - api.pushQuery(JSON.parse(JSON.stringify(queries))).catch((err) => { + const appQueries = queries.filter(isAppQuery); + if (appQueries.length === 0) return; + api.pushQuery(JSON.parse(JSON.stringify(appQueries))).catch((err) => { log.error(`Failed to push polled queries: ${err}`, "api-client"); }); }; const pushOptimizedQuery = (query: OptimizedQuery) => { + if (!isAppQuery(query)) return; const q = [query.toJSON()] api.pushQuery(q).catch((err) => { log.error(`Failed to push optimized query: ${err}`, "api-client"); diff --git a/src/sql/recent-query.test.ts b/src/sql/recent-query.test.ts index 6a1b82e..96a431a 100644 --- a/src/sql/recent-query.test.ts +++ b/src/sql/recent-query.test.ts @@ -259,6 +259,38 @@ test("analyze sets isSelectQuery=false for DELETE with EXISTS subquery", async ( expect(rq.isSelectQuery).toBe(false); }); +// --- statementType persistence (push sites drop "other" — see api-client) --- + +test("analyze keeps statementType on the instance for DML", async () => { + const data = makeRawQuery({ query: "SELECT * FROM users" }); + const rq = await RecentQuery.analyze(data, testHash, testNormalizedHash); + expect(rq.statementType).toBe("select"); +}); + +test("analyze classifies DDL as other", async () => { + const data = makeRawQuery({ + query: + 'CREATE INDEX "ci_runs_repo_created_at_idx" ON "ci_runs" USING btree ("repo", "created_at" DESC)', + }); + const rq = await RecentQuery.analyze(data, testHash, testNormalizedHash); + expect(rq.statementType).toBe("other"); +}); + +test("analyze classifies COPY as other", async () => { + const data = makeRawQuery({ + query: "COPY public.project_queries (id, hash) FROM stdin", + }); + const rq = await RecentQuery.analyze(data, testHash, testNormalizedHash); + expect(rq.statementType).toBe("other"); +}); + +test("oversized queries skip analysis and carry no statementType", async () => { + const data = makeRawQuery({ query: `SELECT ${"1,".repeat(30_000)} 1` }); + const rq = await RecentQuery.analyze(data, testHash, testNormalizedHash); + expect(rq.analysisSkipped).toBe(true); + expect(rq.statementType).toBeUndefined(); +}); + // --- displayQuery via analyze --- test("analyze populates displayQuery for wide SELECTs", async () => { diff --git a/src/sql/recent-query.ts b/src/sql/recent-query.ts index e67db76..c36d22b 100644 --- a/src/sql/recent-query.ts +++ b/src/sql/recent-query.ts @@ -49,7 +49,9 @@ export class RecentQuery { readonly hash: QueryHash, readonly normalizedHash: QueryHash, analysisSkipped = false, - statementType?: StatementType, + // Kept on the instance so push sites can drop non-DML (see api-client); + // undefined when analysis was skipped (oversized) or failed. + readonly statementType?: StatementType, ) { this.username = data.username; this.query = data.query;