diff --git a/backend/src/lib/audit.js b/backend/src/lib/audit.js index fbafb10..f70f3cc 100644 --- a/backend/src/lib/audit.js +++ b/backend/src/lib/audit.js @@ -100,7 +100,7 @@ async function insertAuditLog({ payload, payloadHash, signature }) { for (let attempt = 0; attempt <= AUDIT_DB_RETRY_ATTEMPTS; attempt += 1) { try { - await pool.query( + await optimizedWrite( `INSERT INTO audit_logs (merchant_id, action, status, ip_address, user_agent, payload_hash, signature) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ @@ -112,6 +112,10 @@ async function insertAuditLog({ payload, payloadHash, signature }) { payloadHash, signature, ], + { + label: "insert_login_audit_log", + merchantId: payload.merchant_id, + } ); recordCircuitSuccess(); return { success: true }; diff --git a/backend/src/lib/audit.test.js b/backend/src/lib/audit.test.js index 52b53d3..de32778 100644 --- a/backend/src/lib/audit.test.js +++ b/backend/src/lib/audit.test.js @@ -25,6 +25,7 @@ const { mockQuery, mockIsRetryablePoolError, mockConsumeRateLimit, mockHashPaylo vi.mock("./db.js", () => ({ pool: { query: mockQuery }, isRetryablePoolError: mockIsRetryablePoolError, + queryWithRetry: mockQuery, })); vi.mock("./audit-replay.js", () => ({ diff --git a/backend/src/services/auditService.js b/backend/src/services/auditService.js index ab36d0a..f76740a 100644 --- a/backend/src/services/auditService.js +++ b/backend/src/services/auditService.js @@ -80,7 +80,7 @@ async function insertAuditLog({ payload, payloadHash, signature }) { for (let attempt = 0; attempt <= AUDIT_DB_RETRY_ATTEMPTS; attempt += 1) { try { - await pool.query( + await optimizedWrite( `INSERT INTO audit_logs (merchant_id, action, field_changed, old_value, new_value, ip_address, user_agent, payload_hash, signature) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ @@ -94,6 +94,10 @@ async function insertAuditLog({ payload, payloadHash, signature }) { payloadHash, signature, ], + { + label: "insert_audit_log", + merchantId: payload.merchant_id, + } ); recordSvcSuccess(); return { success: true }; @@ -118,11 +122,9 @@ export const auditService = { /** * Retrieve paginated audit logs for a merchant. * - * Uses a single SQL query with a COUNT(*) OVER() window function so that - * the total row count and the page data are fetched in one round-trip to - * the database instead of two (issue #770). The composite index on - * (merchant_id, timestamp) created in migration 20260425000000 is used by - * the ORDER BY clause to avoid a sequential scan on large tables. + * Splits paginated queries into parallel count and row retrieval queries (issue #770) + * executed via optimizedQuery to allow cacheability and avoid full table scan + * materialization in Postgres. */ async getAuditLogs(merchantId, page = 1, limit = 50) { let p = parseInt(page, 10) || 1; diff --git a/backend/src/services/auditService.test.js b/backend/src/services/auditService.test.js index 6dba514..d3eb9a8 100644 --- a/backend/src/services/auditService.test.js +++ b/backend/src/services/auditService.test.js @@ -14,6 +14,7 @@ const { mockQuery, mockIsRetryablePoolError, mockConsumeRateLimit, mockHashPaylo vi.mock("../lib/db.js", () => ({ pool: { query: mockQuery }, isRetryablePoolError: mockIsRetryablePoolError, + queryWithRetry: mockQuery, })); vi.mock("../lib/audit-replay.js", () => ({ @@ -132,26 +133,30 @@ describe("auditService", () => { // ── SQL optimization: getAuditLogs (issue #770) ─────────────────────────── - it("fetches logs and count in a single window-function query", async () => { + it("fetches logs and count using optimized parallel queries", async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ total_count: "3" }], + }); mockQuery.mockResolvedValueOnce({ rows: [ - { id: 1, action: "update", field_changed: "email", old_value: "a@b.com", new_value: "c@d.com", ip_address: "1.2.3.4", user_agent: "ua", timestamp: new Date(), total_count: "3" }, - { id: 2, action: "login", field_changed: null, old_value: null, new_value: null, ip_address: "1.2.3.4", user_agent: "ua", timestamp: new Date(), total_count: "3" }, + { id: 1, action: "update", field_changed: "email", old_value: "a@b.com", new_value: "c@d.com", ip_address: "1.2.3.4", user_agent: "ua", timestamp: new Date() }, + { id: 2, action: "login", field_changed: null, old_value: null, new_value: null, ip_address: "1.2.3.4", user_agent: "ua", timestamp: new Date() }, ], }); const result = await auditService.getAuditLogs("merchant-1", 1, 2); - expect(mockQuery).toHaveBeenCalledOnce(); - const [sql] = mockQuery.mock.calls[0]; - expect(sql).toMatch(/COUNT\(\*\) OVER\(\)/i); + expect(mockQuery).toHaveBeenCalledTimes(2); + const [countSql] = mockQuery.mock.calls[0]; + const [logsSql] = mockQuery.mock.calls[1]; + expect(countSql).toMatch(/COUNT\(\*\)/i); + expect(logsSql).not.toMatch(/COUNT\(\*\) OVER\(\)/i); expect(result.total_count).toBe(3); expect(result.logs).toHaveLength(2); - // Ensure the synthetic total_count column is stripped from returned rows - expect(result.logs[0]).not.toHaveProperty("total_count"); }); it("returns zero total_count when no rows match", async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ total_count: 0 }] }); mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await auditService.getAuditLogs("merchant-nobody", 1, 10); expect(result.total_count).toBe(0); @@ -159,9 +164,10 @@ describe("auditService", () => { }); it("clamps page and limit to valid ranges", async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ total_count: 0 }] }); mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await auditService.getAuditLogs("merchant-1", -5, 200); - const [, params] = mockQuery.mock.calls[0]; + const [, params] = mockQuery.mock.calls[1]; expect(params[1]).toBe(100); // limit clamped to 100 expect(params[2]).toBe(0); // offset for page 1 = 0 expect(result.page).toBe(1);