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
|
||||
**Status:** ✅ COMPLETED
|
||||
**Date:** 2026-05-10
|
||||
|
|
|
|||
26
FUTURE.md
26
FUTURE.md
|
|
@ -3,7 +3,7 @@
|
|||
**This document tracks potential future enhancements for Bill Tracker.**
|
||||
|
||||
**Last Updated:** 2026-05-10
|
||||
**Current Version:** v0.22.0
|
||||
**Current Version:** v0.22.1
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
**Priority:** MEDIUM
|
||||
**Added:** 2026-05-08 by Neo
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# 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
|
||||
|
||||
### 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 RELEASE_NOTES = {
|
||||
version: '0.22.0',
|
||||
version: '0.22.1',
|
||||
date: '2026-05-10',
|
||||
highlights: [
|
||||
{ icon: '🔄', title: 'React Query Migration', desc: 'Tracker page now uses TanStack Query for data fetching with caching and auto-refetch.' },
|
||||
{ icon: '⚡', title: 'Query DevTools', desc: 'React Query DevTools available in development for inspecting query state.' },
|
||||
{ icon: '⚡', title: 'N+1 Query Optimization', desc: 'Batch queries for tracker and analytics — eliminated per-bill database loops for faster page loads.' },
|
||||
],
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.1",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -143,7 +143,10 @@ router.get('/summary', (req, res) => {
|
|||
const billIds = bills.map(b => b.id);
|
||||
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,
|
||||
substr(p.paid_date, 1, 7) AS month_key,
|
||||
SUM(p.amount) AS total
|
||||
|
|
@ -155,8 +158,12 @@ router.get('/summary', (req, res) => {
|
|||
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
|
||||
let stateRows = [];
|
||||
if (billIds.length > 0) {
|
||||
stateRows = db.prepare(`
|
||||
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
|
||||
FROM monthly_bill_state m
|
||||
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[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 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
|
||||
`).all(req.user.id);
|
||||
|
||||
const mbsStmt = db.prepare(
|
||||
'SELECT actual_amount, notes, is_skipped FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
|
||||
);
|
||||
// Batch fetch all monthly bill states for current month
|
||||
const billIds = bills.map(bill => bill.id);
|
||||
const placeholders = billIds.map(() => '?').join(',');
|
||||
|
||||
// Prepare statement for previous month payments
|
||||
const prevMonthPaymentsStmt = db.prepare(`
|
||||
SELECT SUM(amount) as total_paid
|
||||
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]));
|
||||
}
|
||||
|
||||
// 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
|
||||
WHERE bill_id = ? 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 ?
|
||||
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
|
||||
AND deleted_at IS NULL
|
||||
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);
|
||||
|
||||
// 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.monthly_notes = mbs?.notes ?? null;
|
||||
row.is_skipped = !!(mbs?.is_skipped);
|
||||
|
||||
// Get previous month paid amount
|
||||
const prevMonthPayments = prevMonthPaymentsStmt.get(bill.id, prevMonthRange.start, prevMonthRange.end);
|
||||
row.previous_month_paid = prevMonthPayments?.total_paid || 0;
|
||||
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
|
||||
|
||||
return row;
|
||||
});
|
||||
|
|
@ -214,18 +238,40 @@ router.get('/upcoming', (req, res) => {
|
|||
cutoff.setDate(cutoff.getDate() + days);
|
||||
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 = [];
|
||||
|
||||
for (const bill of bills) {
|
||||
const dueDate = resolveDueDate(bill, year, month);
|
||||
if (dueDate < todayStr || dueDate > cutoffStr) continue;
|
||||
|
||||
const payments = db.prepare(`
|
||||
SELECT * FROM payments
|
||||
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY paid_date DESC
|
||||
`).all(bill.id, start, end);
|
||||
// Get payments for this bill from the batched results
|
||||
const payments = allPayments[bill.id] || [];
|
||||
|
||||
const row = buildTrackerRow(bill, payments, year, month, todayStr);
|
||||
if (row.status === 'paid') continue; // skip already paid
|
||||
|
|
|
|||
Loading…
Reference in New Issue