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:
parent
5c35b20c00
commit
65849fc554
|
|
@ -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
|
||||||
|
|
|
||||||
26
FUTURE.md
26
FUTURE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.' },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,10 @@ 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
|
||||||
|
let paymentRows = [];
|
||||||
|
if (billIds.length > 0) {
|
||||||
|
paymentRows = db.prepare(`
|
||||||
SELECT p.bill_id,
|
SELECT p.bill_id,
|
||||||
substr(p.paid_date, 1, 7) AS month_key,
|
substr(p.paid_date, 1, 7) AS month_key,
|
||||||
SUM(p.amount) AS total
|
SUM(p.amount) AS total
|
||||||
|
|
@ -155,8 +158,12 @@ router.get('/summary', (req, res) => {
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
|
GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
|
||||||
`).all(userId, ...billIds, startDate, endDate);
|
`).all(userId, ...billIds, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
const stateRows = db.prepare(`
|
// Batch fetch all monthly bill states for the date range
|
||||||
|
let stateRows = [];
|
||||||
|
if (billIds.length > 0) {
|
||||||
|
stateRows = db.prepare(`
|
||||||
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
|
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
|
||||||
FROM monthly_bill_state m
|
FROM monthly_bill_state m
|
||||||
JOIN bills b ON b.id = m.bill_id
|
JOIN bills b ON b.id = m.bill_id
|
||||||
|
|
@ -169,6 +176,7 @@ router.get('/summary', (req, res) => {
|
||||||
rangeMonths[0].year * 100 + rangeMonths[0].month,
|
rangeMonths[0].year * 100 + rangeMonths[0].month,
|
||||||
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].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]));
|
||||||
|
|
|
||||||
|
|
@ -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(',');
|
||||||
|
|
||||||
// Prepare statement for previous month payments
|
let monthlyStates = {};
|
||||||
const prevMonthPaymentsStmt = db.prepare(`
|
if (billIds.length > 0) {
|
||||||
SELECT SUM(amount) as total_paid
|
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]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch fetch all payments for current month
|
||||||
|
let allPayments = {};
|
||||||
|
if (billIds.length > 0) {
|
||||||
|
const paymentsQuery = `
|
||||||
|
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
|
||||||
FROM payments
|
FROM payments
|
||||||
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
|
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
|
||||||
AND deleted_at IS NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue