v0.22.1: N+1 Query Optimization

- Batch queries replace per-bill loops in tracker and analytics
- monthly_bill_state, payments, prev month payments batched with WHERE IN
- Empty billIds guards prevent SQL errors
- Hudson security audit: 5/5 PASS (SQL injection, empty IN, user scoping, data leakage, type safety)
This commit is contained in:
null 2026-05-10 03:29:09 -05:00
parent 5c35b20c00
commit 65849fc554
7 changed files with 154 additions and 90 deletions

View File

@ -6,6 +6,35 @@
--- ---
### v0.22.1 — N+1 Query Optimization
**Status:** ✅ COMPLETED
**Date:** 2026-05-10
**Priority:** MEDIUM
| Agent | Status | Time | Notes |
|-------|--------|------|-------|
| Neo | ✅ COMPLETED | 6m7s | Batch queries for tracker + analytics |
| Ripley | ✅ COMPLETED | — | Reviewed changes, version bump 0.22.0 → 0.22.1 |
| Bishop | ✅ COMPLETED | 2m13s | 6/6 PASS |
| Hudson | ✅ COMPLETED | 18s | 5/5 PASS |
**Files modified:** `routes/tracker.js`, `routes/analytics.js`
**Work Completed:**
- [x] Tracker: batch monthly_bill_state, payments, prev month payments, upcoming payments
- [x] Analytics: added empty billIds guards
- [x] All batch queries guarded by `billIds.length > 0` for empty list safety
- [x] IN clause built with parameterized placeholders (no SQL injection)
**Security Audit (Hudson):**
1. SQL injection: ✅ PASS — parameterized placeholders only
2. Empty IN clause: ✅ PASS — all guarded
3. User scoping: ✅ PASS — bills scoped by req.user.id
4. No data leakage: ✅ PASS — bills filtered before extracting IDs
5. Type safety: ✅ PASS — bill.id from SQLite auto-increment
---
### v0.22.0 — React Query Migration ### v0.22.0 — React Query Migration
**Status:** ✅ COMPLETED **Status:** ✅ COMPLETED
**Date:** 2026-05-10 **Date:** 2026-05-10

View File

@ -3,7 +3,7 @@
**This document tracks potential future enhancements for Bill Tracker.** **This document tracks potential future enhancements for Bill Tracker.**
**Last Updated:** 2026-05-10 **Last Updated:** 2026-05-10
**Current Version:** v0.22.0 **Current Version:** v0.22.1
## How to Use This Document ## How to Use This Document
@ -66,30 +66,6 @@ Many routes contain business logic that should be extracted to service layers.
``` ```
- Route handlers should call services, not contain business logic - Route handlers should call services, not contain business logic
### Performance: N+1 Query Patterns in Tracker and Analytics
**Priority:** MEDIUM
**Added:** 2026-05-08 by Neo
**Description:**
Looping over bills and querying payments/state individually causes N+1 queries.
**Rationale:**
- `tracker.js` line 27-37: iterates over `bills`, runs `mbsStmt.get()` per bill
- `analytics.js` uses `bills.map()` and builds maps with per-bill lookups
- With 50 bills, this creates 100+ extra queries per request
**Implementation Notes:**
- Files to modify: `/home/kaspa/.openclaw/Projects/bill-tracker/routes/tracker.js`, `/analytics.js`
- Estimated effort: 3 hours
- Use batch queries instead:
```js
// Fetch all monthly states for bills in one query
const states = db.prepare(`
SELECT * FROM monthly_bill_state
WHERE bill_id IN (${billIds.join(',')}) AND year=? AND month=?
`).all(billIds, year, month);
```
### Security: Session Token Not Rotated on Auth Events ### Security: Session Token Not Rotated on Auth Events
**Priority:** MEDIUM **Priority:** MEDIUM
**Added:** 2026-05-08 by Neo **Added:** 2026-05-08 by Neo

View File

@ -1,5 +1,11 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.22.1
### Changed
- **N+1 Query Optimization** — Batch queries replace per-bill loops in tracker and analytics (monthly states, payments, previous month, upcoming)
- Empty bill list edge case handled with `billIds.length > 0` guards
## v0.22.0 ## v0.22.0
### Added ### Added

View File

@ -1,11 +1,10 @@
export const APP_VERSION = '0.22.0'; export const APP_VERSION = '0.22.1';
export const APP_NAME = 'BillTracker'; export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.22.0', version: '0.22.1',
date: '2026-05-10', date: '2026-05-10',
highlights: [ highlights: [
{ icon: '🔄', title: 'React Query Migration', desc: 'Tracker page now uses TanStack Query for data fetching with caching and auto-refetch.' }, { icon: '⚡', title: 'N+1 Query Optimization', desc: 'Batch queries for tracker and analytics — eliminated per-bill database loops for faster page loads.' },
{ icon: '⚡', title: 'Query DevTools', desc: 'React Query DevTools available in development for inspecting query state.' },
], ],
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.22.0", "version": "0.22.1",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -143,32 +143,40 @@ router.get('/summary', (req, res) => {
const billIds = bills.map(b => b.id); const billIds = bills.map(b => b.id);
const placeholders = billIds.map(() => '?').join(','); const placeholders = billIds.map(() => '?').join(',');
const paymentRows = db.prepare(` // Batch fetch all payments for the date range
SELECT p.bill_id, let paymentRows = [];
substr(p.paid_date, 1, 7) AS month_key, if (billIds.length > 0) {
SUM(p.amount) AS total paymentRows = db.prepare(`
FROM payments p SELECT p.bill_id,
JOIN bills b ON b.id = p.bill_id substr(p.paid_date, 1, 7) AS month_key,
WHERE b.user_id = ? SUM(p.amount) AS total
AND p.bill_id IN (${placeholders}) FROM payments p
AND p.paid_date BETWEEN ? AND ? JOIN bills b ON b.id = p.bill_id
AND p.deleted_at IS NULL WHERE b.user_id = ?
GROUP BY p.bill_id, substr(p.paid_date, 1, 7) AND p.bill_id IN (${placeholders})
`).all(userId, ...billIds, startDate, endDate); AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
`).all(userId, ...billIds, startDate, endDate);
}
const stateRows = db.prepare(` // Batch fetch all monthly bill states for the date range
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped let stateRows = [];
FROM monthly_bill_state m if (billIds.length > 0) {
JOIN bills b ON b.id = m.bill_id stateRows = db.prepare(`
WHERE b.user_id = ? SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
AND m.bill_id IN (${placeholders}) FROM monthly_bill_state m
AND (m.year * 100 + m.month) BETWEEN ? AND ? JOIN bills b ON b.id = m.bill_id
`).all( WHERE b.user_id = ?
userId, AND m.bill_id IN (${placeholders})
...billIds, AND (m.year * 100 + m.month) BETWEEN ? AND ?
rangeMonths[0].year * 100 + rangeMonths[0].month, `).all(
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month, userId,
); ...billIds,
rangeMonths[0].year * 100 + rangeMonths[0].month,
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month,
);
}
const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0])); const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0]));
const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row])); const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));

View File

@ -39,47 +39,71 @@ router.get('/', (req, res) => {
ORDER BY b.due_day ASC, b.name ASC ORDER BY b.due_day ASC, b.name ASC
`).all(req.user.id); `).all(req.user.id);
const mbsStmt = db.prepare( // Batch fetch all monthly bill states for current month
'SELECT actual_amount, notes, is_skipped FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' const billIds = bills.map(bill => bill.id);
); const placeholders = billIds.map(() => '?').join(',');
let monthlyStates = {};
if (billIds.length > 0) {
const monthlyStateQuery = `
SELECT bill_id, actual_amount, notes, is_skipped
FROM monthly_bill_state
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
`;
const monthlyStateRows = db.prepare(monthlyStateQuery).all(...billIds, year, month);
monthlyStates = Object.fromEntries(monthlyStateRows.map(row => [row.bill_id, row]));
}
// Prepare statement for previous month payments // Batch fetch all payments for current month
const prevMonthPaymentsStmt = db.prepare(` let allPayments = {};
SELECT SUM(amount) as total_paid if (billIds.length > 0) {
FROM payments const paymentsQuery = `
WHERE bill_id = ? AND paid_date BETWEEN ? AND ? SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
AND deleted_at IS NULL FROM payments
`); WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
// Prepare statement for 3-month trend calculations
const threeMonthPaymentsStmt = db.prepare(`
SELECT SUM(amount) as total_paid, strftime('%Y-%m', paid_date) as month_key
FROM payments
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
GROUP BY strftime('%Y-%m', paid_date)
`);
const rows = bills.map(bill => {
// Only count non-deleted payments for status/totals
const payments = db.prepare(`
SELECT * FROM payments
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL AND deleted_at IS NULL
ORDER BY paid_date DESC ORDER BY paid_date DESC
`).all(bill.id, start, end); `;
const paymentRows = db.prepare(paymentsQuery).all(...billIds, start, end);
// Group payments by bill_id
allPayments = {};
paymentRows.forEach(row => {
if (!allPayments[row.bill_id]) {
allPayments[row.bill_id] = [];
}
allPayments[row.bill_id].push(row);
});
}
// Batch fetch all previous month payments
let prevMonthPayments = {};
if (billIds.length > 0) {
const prevPaymentsQuery = `
SELECT bill_id, SUM(amount) as total_paid
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
GROUP BY bill_id
`;
const prevPaymentRows = db.prepare(prevPaymentsQuery).all(...billIds, prevMonthRange.start, prevMonthRange.end);
prevMonthPayments = Object.fromEntries(prevPaymentRows.map(row => [row.bill_id, row.total_paid]));
}
const rows = bills.map(bill => {
// Get payments for this bill
const payments = allPayments[bill.id] || [];
const row = buildTrackerRow(bill, payments, year, month, todayStr); const row = buildTrackerRow(bill, payments, year, month, todayStr);
// Overlay monthly state overrides // Overlay monthly state overrides
const mbs = mbsStmt.get(bill.id, year, month); const mbs = monthlyStates[bill.id];
row.actual_amount = mbs?.actual_amount ?? null; row.actual_amount = mbs?.actual_amount ?? null;
row.monthly_notes = mbs?.notes ?? null; row.monthly_notes = mbs?.notes ?? null;
row.is_skipped = !!(mbs?.is_skipped); row.is_skipped = !!(mbs?.is_skipped);
// Get previous month paid amount // Get previous month paid amount
const prevMonthPayments = prevMonthPaymentsStmt.get(bill.id, prevMonthRange.start, prevMonthRange.end); row.previous_month_paid = prevMonthPayments[bill.id] || 0;
row.previous_month_paid = prevMonthPayments?.total_paid || 0;
return row; return row;
}); });
@ -214,18 +238,40 @@ router.get('/upcoming', (req, res) => {
cutoff.setDate(cutoff.getDate() + days); cutoff.setDate(cutoff.getDate() + days);
const cutoffStr = cutoff.toISOString().slice(0, 10); const cutoffStr = cutoff.toISOString().slice(0, 10);
// Get all bill IDs for batch processing
const billIds = bills.map(bill => bill.id);
// Batch fetch all payments for all bills in the date range
let allPayments = {};
if (billIds.length > 0) {
const placeholders = billIds.map(() => '?').join(',');
const paymentsQuery = `
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
ORDER BY paid_date DESC
`;
const paymentRows = db.prepare(paymentsQuery).all(...billIds, start, end);
// Group payments by bill_id
allPayments = {};
paymentRows.forEach(row => {
if (!allPayments[row.bill_id]) {
allPayments[row.bill_id] = [];
}
allPayments[row.bill_id].push(row);
});
}
const upcoming = []; const upcoming = [];
for (const bill of bills) { for (const bill of bills) {
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
if (dueDate < todayStr || dueDate > cutoffStr) continue; if (dueDate < todayStr || dueDate > cutoffStr) continue;
const payments = db.prepare(` // Get payments for this bill from the batched results
SELECT * FROM payments const payments = allPayments[bill.id] || [];
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
ORDER BY paid_date DESC
`).all(bill.id, start, end);
const row = buildTrackerRow(bill, payments, year, month, todayStr); const row = buildTrackerRow(bill, payments, year, month, todayStr);
if (row.status === 'paid') continue; // skip already paid if (row.status === 'paid') continue; // skip already paid