Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
-- 20260812022651_add_access_pattern_indexes
--
-- Every pending migration runs inside a SINGLE transaction, with
-- search_path = branch, public -- so table names can be unqualified, and
-- CREATE INDEX CONCURRENTLY / VACUUM will not work here.
--
-- This migration is applied to PRODUCTION automatically when the PR merges,
-- BEFORE the new lambda code is deployed. It must be safe for the code that is
-- live right now: additive changes only. See apps/backend/db/README.md for the
-- expand/contract rules that destructive changes need.
--
-- Forward-only: there is no rollback. Fix a mistake with a new migration, and
-- never edit a migration that has been merged -- someone has already run it.
-- Do not use IF NOT EXISTS: you want a failure, not silent drift.
--
--
-- Before this migration the schema had ELEVEN indexes, every one of them a
-- primary key or a UNIQUE constraint, and not one on a foreign key. Postgres
-- does not index a referencing column for you. So every lookup by project_id,
-- every list ordered by a date, and every ON DELETE CASCADE was a sequential
-- scan of the whole child table.
--
-- Adding an index is pure expand: it changes no column, breaks no live INSERT,
-- and only ever makes the currently deployed code faster.
--
-- Indexes are written ASC even where the query sorts DESC. Both ORDER BY
-- columns here are NOT NULL, so Postgres reads the same index backwards
-- ("Index Scan Backward") and gets the ordering for free; a DESC index would
-- buy nothing and only matters for mixed-direction multi-column sorts.

-- expenditures.project_id -> the single hottest access path in the app.
-- Serves the project-filtered expenditure list and its COUNT, the project
-- detail page's expenditure tab, the report generator's expenditure section,
-- and the FK check behind DELETE projects. Composite with spent_on because
-- every one of those list queries also does ORDER BY spent_on DESC -- the
-- second column turns a top-N sort over the whole table into an ordered walk
-- of the matching rows only.
CREATE INDEX expenditures_project_id_spent_on_idx
ON expenditures (project_id, spent_on);

-- The unfiltered expenditure list (GET /expenditures with no projectId) sorts
-- the entire table by spent_on to return one page. This is also the query
-- behind the new admin approve/deny review queue, which fetches the list and
-- filters to `pending` client-side -- so it pays the full sort on every open.
CREATE INDEX expenditures_spent_on_idx
ON expenditures (spent_on);

-- expenditures.entered_by is never filtered on directly, but it is a FK with
-- ON DELETE SET NULL: deleting one user rewrote every matching row only after
-- sequentially scanning all of expenditures to find them.
CREATE INDEX expenditures_entered_by_idx
ON expenditures (entered_by);

-- reports.project_id + date_created: same shape as expenditures -- filtered
-- list, its COUNT, and the DELETE projects cascade, all sorted date_created
-- DESC.
CREATE INDEX reports_project_id_date_created_idx
ON reports (project_id, date_created);

-- The unfiltered report list, sorted date_created DESC.
CREATE INDEX reports_date_created_idx
ON reports (date_created);

-- project_memberships.user_id. The UNIQUE constraint is (project_id, user_id),
-- so it cannot serve a lookup keyed on user_id alone -- and that is exactly
-- what GET /projects does for every non-admin user to find the projects they
-- belong to. It is the landing page of the app. Also the DELETE users cascade.
CREATE INDEX project_memberships_user_id_idx
ON project_memberships (user_id);

-- project_donations.project_id. Same wrong-leading-column problem: UNIQUE is
-- (donor_id, project_id). Serves GET /projects/{id}/donors, the report
-- generator's donation section, and the DELETE projects cascade.
CREATE INDEX project_donations_project_id_idx
ON project_donations (project_id);
4 changes: 4 additions & 0 deletions apps/backend/lambdas/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ await db.selectFrom('branch.users').where('cognito_sub', '=', sub).selectAll().e
await db.selectFrom('branch.users').select(db.fn.count('user_id').as('count')).executeTakeFirst();
```

**Aggregate and filter in SQL, not in the lambda.** Pulling rows over the wire to sum, group or filter them in JS costs a full table scan that no index can fix, and it grows with the table. Use `db.fn.sum`/`db.fn.count` + `groupBy`, and push every filter into `where`. `GET /projects/dashboard` (`projects/handler.ts:83`) is the counter-example: it selects every expenditure row and buckets it by month in a JS loop.

**Check your new query has an index.** Filter, join and `ORDER BY` columns need one — Postgres does not index foreign keys for you. `project_memberships` and `project_donations` in particular have UNIQUE constraints whose *leading* column is not the one most queries filter on, so those don't help. Add the index in the same PR (see `db/README.md`).

## Validation

`projects` and `expenditures` have `validation-utils.ts` with static-method classes (`ProjectValidationUtils`, `ExpenditureValidationUtils`) returning either a `ValidationResult<T>` (`{ isValid, value?, error? }`) or an `Error` instance. Validate before DB writes; return `json(400, { message })` on failure. Integer params validated with `/^\d+$/` + positivity; dates with `/^\d{4}-\d{2}-\d{2}$/`.
Expand Down
Loading