Skip to content

feat(store): own the analytics rollups in application code - #400

Merged
nourshoreibah merged 11 commits into
mainfrom
feat/rollups-in-application-code
Sep 8, 2026
Merged

nourshoreibah merged 11 commits into
mainfrom
feat/rollups-in-application-code

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Second of four PRs migrating BRANCH from RDS Postgres to Aurora DSQL. Still on RDS — no DSQL anywhere in this diff.

Built on #398, which is already merged. This branch was cut before that merge and #398 was squash-merged, so its three commits still appear in the commit list here — they contribute nothing to the file diff (the infrastructure/ diff against main is empty).

Why

Aurora DSQL supports neither triggers nor PL/pgSQL. 20260823055243_add_analytics_rollups.sql maintains expenditure_rollup and project_rollup with 9 triggers and 9 plpgsql functions, and four lambdas read those tables — so they can't just be dropped. Doing this move on RDS first means the existing suite is a real oracle; the DSQL cutover then has nothing left to prove about rollups.

Verified with @aws/dsql-lint: the old migration produces 9 plpgsql ERRORs + 9 trigger ERRORs, and the three LANGUAGE sql helpers are not flagged. That's the split this PR is built on — only the dispatch moves.

The hazard this is shaped around

The obvious design — a store per lambda — does not work. DELETE /donors/{id} and DELETE /users/{id} mutate rollup-affecting rows via FK cascade, from lambdas that never name project_donations or project_memberships. Neither would import a store for a table it doesn't mention, so those two paths would have gone silently unmaintained.

So the chokepoint is shared, and it owns those parent deletes.

What's here

shared/store@branch/store replaces the six identical db.ts files. It exports db as ReadOnlyDb — a Kysely<DB> with insertInto/updateTable/deleteFrom/transaction/schema/withSchema/with stripped — plus one named operation per write. A controller cannot write without going through an operation that also maintains the rollup, because the write builders don't typecheck. grep for insertInto|updateTable|deleteFrom|db.transaction across lambda source now returns nothing.

Write DTOs (NewExpenditure, ExpenditureEdit, NewDonation, …) live in @branch/types beside the row types, following the auth-types.d.ts precedent, and are re-exported by the store. Callers don't need Kysely generics, and generated columns are absent so they can't be set by accident.

The three LANGUAGE sql helpers stay in the database. They hold the arithmetic, including the ON CONFLICT against the expression index, and DSQL supports them. Only the 9 plpgsql dispatchers — each literally remove(OLD); add(NEW) — move to TypeScript.

ON DELETE RESTRICT on project_donations.donor_id and project_memberships.user_id, so a forgotten cascade becomes an FK error rather than a silently wrong total. The store deletes children first. This also earns its keep on DSQL, where cascaded rows count against the 3,000-row transaction limit.

claimUser exists because registration's two updates carry WHERE user_id = $1 AND cognito_sub IS NULL. That predicate is what makes a concurrent claim a no-op instead of overwriting a working account, so it needed its own operation rather than a generic updateUser.

tx() retries serialization failures (40001, 40P01, and DSQL's OC000/OC001). It retries the whole transaction, not just the rollup half — retrying only the rollup would double-count.

The safety net

testkit gains findRollupDrift / assertRollupsConsistent / reconcileRollups, recomputing both rollups from the base tables with the same aggregation as the original backfill. Verified end to end: a write that skips its rollup is detected, with a legible message, and reconcile repairs it.

resetData now reconciles after seeding — TRUNCATE empties the rollups and seed.sql only writes base rows, so with the triggers gone nothing refilled them. The new assertion caught that immediately.

rollup-triggers.e2e.test.tsrollup-store.e2e.test.ts. The old file drove the triggers with raw SQL, including shapes no route can produce (bulk UPDATE with no WHERE, TRUNCATE, moving an expenditure between projects by column) — that tested trigger generality. The replacement covers every operation the store exposes, and adds the two cascade cases the triggers previously hid.

Verification

  • 650 tests pass across all six lambdas: projects 159, auth 85, expenditures 136, reports 143, users 68, donors 59
  • All 8 migrations apply from scratch through the real Kysely migrator; against Postgres the result is 0 triggers, only the 3 sql functions, both FKs RESTRICT
  • npm run types produces no diff
  • dsql-lint reports the new migration fully DSQL-clean (needed DROP TRIGGER IF EXISTS and NOT VALID on the FK adds — both valid Postgres too, so the file stays cross-engine)

Two things reviewers should weigh in on

  1. The plan called for an ESLint no-restricted-imports backstop, and it isn't here. The root .eslintrc.json sets ignorePatterns: ["**/*"] and the lambdas have no config of their own, so eslint apps/backend currently lints nothing — the rule would be decoration. The type-level ReadOnlyDb is the real guard and it works (there's a type test that was checked to fail when the guarantee is removed). Fixing the eslint setup felt out of scope here; happy to do it as a follow-up.
  2. Deploy ordering. Migrations run before the code deploy, so there's a window where old trigger-dependent code runs against a trigger-less schema and rollups stop updating. reconcileRollups exists to close it, but wiring it in as a post-deploy step is not in this PR — worth deciding whether that lands here or as a follow-up.

Also fixes lambda-deploy.yml, which filtered on a hardcoded path list — a shared/store-only change would never have deployed. (shared/rbac and shared/lambda-auth are still missing from that list; left alone as pre-existing.)

Follow-ups

  1. Replace the Kysely migrator with Flyway + flyway-database-dsql (still on RDS)
  2. Cut over to Aurora DSQL

🤖 Generated with Claude Code

nourshoreibah and others added 6 commits September 6, 2026 17:49
Groundwork for the Aurora DSQL migration, split out so the PR that adds
the cluster does not also have to change IAM.

branch-ci-plan carries AWS-managed ReadOnlyAccess, which does not
reliably cover dsql:*. Without AmazonAuroraDSQLReadOnlyAccess, the PR
introducing an aws_dsql_cluster fails its own terraform plan.

branch-lambda-role gets dsql:DbConnectAdmin because DSQL authenticates
with an IAM token rather than a password. The role is shared with every
preview lambda, so previews are covered too. Resource is "*" until the
cluster exists.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
  - Auto-formatted .tf files with terraform fmt
  - Updated README.md with terraform-docs

  Co-authored-by: nourshoreibah <[email protected]>
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Aurora DSQL supports neither triggers nor PL/pgSQL, so the analytics rollups
have to be maintained by the application. This adds the package that owns
that, and the migration that removes the database-side machinery.

The three LANGUAGE sql helpers stay: DSQL supports those and they hold the
arithmetic. Only the nine plpgsql dispatchers move into TypeScript.

@branch/store exports `db` as ReadOnlyDb -- a Kysely handle with every
mutating entry point stripped -- plus one named operation per write. A
controller cannot write without going through an operation that also
maintains the rollup, because the write builders will not typecheck.

project_donations.donor_id and project_memberships.user_id become ON DELETE
RESTRICT. Those two cascades used to fire a row trigger from a lambda that
never names the rollup tables (DELETE /donors/{id}, DELETE /users/{id}), so
in application code they would have gone silently unmaintained. RESTRICT
turns a forgotten cascade into an FK error; the store deletes children first.

testkit gains findRollupDrift/assertRollupsConsistent/reconcileRollups,
recomputing both rollups from the base tables with the same aggregation as
the original backfill. resetData now reconciles after seeding, since nothing
refills the rollups once the triggers are gone.

Verified against postgres: 0 triggers remain, only the 3 sql functions
survive, both FKs are RESTRICT, and drift is detected and repaired.
Verified with @aws/dsql-lint: the migration is DSQL-clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Deletes the six near-identical db.ts files and points all 20 modules at
the shared store. The 21 write sites become named store operations, so
rollup maintenance travels with the write instead of being a thing each
caller has to remember.

Write DTOs (NewExpenditure, ExpenditureEdit, NewDonation, ...) are declared
in @branch/types beside the row types and re-exported by the store, so a
caller does not need Kysely's generics to write a row and cannot set a
generated column by accident.

claimUser exists because registration's two updates carry a compound
`WHERE user_id = $1 AND cognito_sub IS NULL`. That predicate is what makes
a concurrent claim a no-op rather than an overwrite of a working account,
so it needed its own operation rather than a generic updateUser.

syncMemberships moves from the projects lambda into the store, taking the
default role as a parameter so the store needs no lambda-local types. The
project create/update transactions move with it, which is what lets the
member_count rollup update inside the same transaction as the roster.

grep for insertInto/updateTable/deleteFrom/db.transaction across lambda
source now returns nothing. All six lambdas typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Mechanical for the most part: the six db.ts modules are gone, so imports
move to @branch/store and db.destroy() becomes closeConnection() (the
read-only handle deliberately has no destroy).

Mocks that drove the query builder now drive the store operation the code
actually calls -- mockDb.insertInto becomes mockRecordExpenditure, and so
on. That is a smaller mock in every case.

rollup-triggers.e2e.test.ts becomes rollup-store.e2e.test.ts. It drove the
row triggers with raw SQL, including shapes no route can produce: a bulk
UPDATE with no WHERE, TRUNCATE, moving an expenditure between projects by
column. Those tested trigger generality. The replacement exercises every
operation the store exposes, and adds the two cascade cases the old file
could not reach because the triggers hid them -- deleting a donor and
deleting a user.

Fixtures that stay on raw SQL now call reconcileRollups afterwards. Raw SQL
no longer maintains the rollups, and the dashboard fixture in particular
rewrites spent_on, which moves rows between buckets.

lambda-deploy.yml learns shared/store in both the push filter and the
in-job detector. Without it a store-only change would never deploy.

650 tests pass across all six lambdas.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/20260906215733_move_rollups_to_application.sql
  • apps/backend/db/migrations/20260907213524_project_rollup_bump_reports_hit.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally. cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

This PR also changes 80 files outside apps/backend/db/. If any are unrelated to this schema change, consider splitting them into a separate PR.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

Four review findings, all real.

Rollup delta applied even when the DELETE matched nothing. removeExpenditure,
removeReport and removeDonation all read the row, deleted, then backed the
amount out unconditionally. Two containers deleting the same id would each
decrement, leaving the count permanently low. removeDonor and removeUser
already guarded this; these three now do too, and the pre-read takes FOR
UPDATE so the loser re-reads and finds the row gone rather than racing.

The pre-read was also unlocked in editExpenditure, so `before` could be stale
by the time the rollup was adjusted -- it would subtract an amount no longer
in the bucket. tx()'s retry set could never catch it because Postgres does not
raise 40001 under READ COMMITTED. FOR UPDATE closes it on both engines: it
blocks on Postgres, and on DSQL the conflict surfaces as an OCC error the
retry already handles.

project_rollup_bump was a bare UPDATE returning void, so a project with no
project_rollup row swallowed every change silently. projects_rollup_seed used
to guarantee that row for any insert path; since 20260906215733 only the store
seeds it. The function now returns 1 or NULL and the caller throws, so an
unseeded project fails the transaction instead of drifting for ever.

removeUser did have an e2e test, but a weak one -- it only audited
consistency, which passes even if the delete does nothing. It now asserts the
RESTRICT path really cleared the memberships and that member_count reached
zero on both affected projects.

Note on the concurrency test: the double-decrement race is not reachable
in-process. The store pool is max: 1, so two tx() calls serialise on the one
connection and never interleave -- a test using Promise.all passes against the
unfixed code, which is why there is a sequential test and a comment rather
than a green test that proves nothing. Reproducing it needs two pools.

653 tests pass. Both new migrations are DSQL-clean per @aws/dsql-lint.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@nourshoreibah

Copy link
Copy Markdown
Collaborator Author

Pushed c446ffb addressing all four review findings.

1 — rollup delta applied on a zero-row DELETE. Confirmed. removeExpenditure, removeReport and removeDonation all bumped unconditionally after the DELETE. Each now captures numDeletedRows and returns early before touching the rollup, matching the guard removeDonor/removeUser already had. The pre-read also takes FOR UPDATE, so the loser of the race re-reads and finds the row gone rather than racing at all — that's the actual fix; the guard is defence in depth.

2 — stale before from an unlocked pre-read. Confirmed, and the diagnosis about tx() was right: Postgres doesn't raise 40001 under READ COMMITTED, so the retry set could never have caught it. FOR UPDATE on the pre-read in editExpenditure and removeExpenditure closes it on both engines — it blocks on Postgres, and on DSQL (REPEATABLE READ + OCC) the conflict surfaces as a code the retry already handles.

3 — projectRollupBump silent no-op. Confirmed. RETURNS void can't report affected rows, so 20260907213524 drops and recreates project_rollup_bump as RETURNS integer with UPDATE ... RETURNING 1 — 1 on a hit, NULL when nothing matched. The caller now throws, so an unseeded project fails the transaction instead of drifting permanently. dsql-lint reports the new migration DSQL-clean.

4 — coverage. Partly off: removeUser does have an e2e test, at rollup-store.e2e.test.ts:285. But the underlying point was right and the problem was worse than described — that test only called auditRollups, which compares rollups to base tables and so would pass even if removeUser deleted nothing at all. It proved consistency, not that the RESTRICT path worked. It now asserts the memberships were actually cleared, the user is gone, and member_count reached zero on both affected projects, plus a no-op case for an unknown id.

One thing worth flagging. I first wrote finding 1's regression test with Promise.all over two removeExpenditure calls. It passed — then I reverted the fix and it still passed. The store pool is max: 1, so two tx() calls in one process serialise on the single connection and can never interleave; the race needs two pools, which is exactly the production shape (one per Lambda container) but not reproducible in-process. Rather than ship a green test that proves nothing, the test is sequential and carries a comment saying why. Worth knowing before anyone tries to "restore" the concurrent version.

653 tests pass. No generated-type drift.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

nourshoreibah and others added 2 commits September 7, 2026 22:35
The earlier round fixed the single-row deletes but left the same bug in
every multi-row path: the delta came from an unlocked SELECT taken before
the mutation, so a concurrent write made it stale.

syncMemberships computed `members.length - existing.length`. Two roster
edits on one project, both reading existing = 2: the first deletes 2,
inserts 3 and bumps +1 (count 3, correct); the second deletes 3, inserts 1
and bumps -1, leaving count 2 against one actual row. Under READ COMMITTED
the second DELETE re-reads and removes the first's rows, but its delta was
already fixed from the stale count. Now counted from the DELETE itself.

removeDonor and removeUser bumped from a list read before deleting, so a
donation or membership inserted in between was deleted without ever coming
off the rollup. Both now use DELETE ... RETURNING and bump from what was
actually removed, which also drops the pre-read entirely.

RETURNING beats locking here: SELECT ... FOR UPDATE locks the rows it
finds, but does not stop a new child row appearing before the DELETE, so it
would not have closed either window.

Both early `return 0n` guards go: the bumps now correspond exactly to the
rows deleted, and FK RESTRICT means a child cannot outlive its parent, so
the observable result is unchanged.

DELETE ... RETURNING confirmed DSQL-compatible with @aws/dsql-lint.

As with the last round the race is not reproducible in-process (store pool
is max: 1), so these ship without a regression test rather than with one
that passes against the unfixed code. 568 tests pass across the five
affected lambdas.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@nourshoreibah

Copy link
Copy Markdown
Collaborator Author

Follow-up on race safety — cfd3165. Answering "is this approach race safe?": it wasn't. The previous round fixed the single-row deletes but left the identical bug in every multi-row path, where the rollup delta came from an unlocked SELECT taken before the mutation.

syncMemberships computed members.length - existing.length. Two roster edits on one project, both reading existing = 2: the first deletes 2, inserts 3, bumps +1 → count 3 ✓. The second deletes 3 (READ COMMITTED re-reads, so it removes the first's rows), inserts 1, bumps -1 → count 2 against one actual row. Permanent drift. Now counted from the DELETE's own numDeletedRows.

removeDonor / removeUser bumped from a list read before deleting, so a donation or membership inserted in that window got deleted without ever coming off the rollup. Both now use DELETE ... RETURNING and bump from what was actually removed — which also lets the pre-read go entirely.

RETURNING is the right tool here rather than locking: SELECT ... FOR UPDATE locks the rows it finds, but doesn't prevent a new child row appearing before the DELETE, so it would have closed neither window.

Both early return 0n guards are gone. The bumps now correspond exactly to the rows deleted, and FK RESTRICT means a child can't outlive its parent, so the observable behaviour is unchanged.

Verified DELETE ... RETURNING is DSQL-compatible with dsql-lint before relying on it.

Two things that are safe and worth recording, since they're easy to "optimise" into bugs later:

  • project_rollup_bump's SET col = col + delta is race-safe by construction. Postgres re-evaluates it against the updated row after the lock releases, so concurrent bumps both land; on DSQL the write-write conflict raises and tx() retries. Rewriting this as a read-modify-write in TypeScript would reintroduce exactly the class of bug this PR keeps fixing.
  • heldRole still comes from the unlocked read. That's deliberate — a stale role there is benign (you may preserve a role that just changed) and causes no count drift.

Still not covered by a test. Same reason as last round: the store pool is max: 1, so two tx() calls in one process serialise and can't interleave. These ship without a regression test rather than with one that passes against the unfixed code. Genuinely testing this class needs two pools — worth considering a small harness if we keep finding these.

Also unchanged and worth a decision: retry budget is attempts = 3, so a hot project row on DSQL will start returning errors rather than drifting under enough contention. That's the right failure mode, but the number is a guess.

568 tests pass across the five affected lambdas.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
data.aws_caller_identity.current: Reading...
aws_cloudwatch_log_group.lambda["donors"]: Refreshing state... [id=/aws/lambda/branch-donors]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=btt3bl5139]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E1ZI46GY0YEFAD]
data.aws_vpc.default: Reading...
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20260830181426405600000001]
data.aws_caller_identity.current: Read complete after 0s [id=404813129370]
aws_cloudwatch_log_group.lambda["expenditures"]: Refreshing state... [id=/aws/lambda/branch-expenditures]
data.aws_region.current: Reading...
data.aws_region.current: Read complete after 0s [id=us-east-2]
aws_cloudwatch_log_group.lambda["users"]: Refreshing state... [id=/aws/lambda/branch-users]
aws_cloudwatch_log_group.lambda["projects"]: Refreshing state... [id=/aws/lambda/branch-projects]
aws_cloudwatch_log_group.lambda["reports"]: Refreshing state... [id=/aws/lambda/branch-reports]
aws_cloudwatch_log_group.lambda["auth"]: Refreshing state... [id=/aws/lambda/branch-auth]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::404813129370:oidc-provider/token.actions.githubusercontent.com]
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-404813129370]
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-404813129370]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.grafana_folder: Reading...
data.infisical_secrets.sentry_folder: Reading...
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-btt3bl5139-DEFAULT_5XX]
data.infisical_secrets.grafana_folder: Read complete after 0s
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-btt3bl5139-DEFAULT_4XX]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=j2bjjp]
data.infisical_secrets.sentry_folder: Read complete after 0s
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=fbius2]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=5rlpdk]
data.aws_vpc.default: Read complete after 1s [id=vpc-0d3819d8bbb63db8c]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=r6frgh]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=ooaugc]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=x3f6cx]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=245163413]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3606114350]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=3235391464]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=1050147292]
aws_security_group.rds: Refreshing state... [id=sg-0fcbb6d585a94c4b9]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role_policy.lambda_dsql_connect: Refreshing state... [id=branch-lambda-role:branch-lambda-dsql-connect]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_iam_role_policy.lambda_ses_send: Refreshing state... [id=branch-lambda-role:branch-lambda-ses-send]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=qpfkcg]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=7tjm3k]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=37l1nw]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=2haqg7]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=zofyad]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=0s4etn]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-btt3bl5139-x3f6cx-GET]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-btt3bl5139-j2bjjp-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-btt3bl5139-fbius2-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-btt3bl5139-5rlpdk-GET]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-btt3bl5139-ooaugc-GET]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-btt3bl5139-r6frgh-POST]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-btt3bl5139-x3f6cx-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-btt3bl5139-r6frgh-PATCH]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-btt3bl5139-x3f6cx-POST]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-btt3bl5139-j2bjjp-GET]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-btt3bl5139-r6frgh-GET]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-btt3bl5139-5rlpdk-POST]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-btt3bl5139-x3f6cx-PATCH]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-btt3bl5139-5rlpdk-OPTIONS]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-btt3bl5139-ooaugc-OPTIONS]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-btt3bl5139-j2bjjp-POST]
aws_api_gateway_method.lambda_methods["donors-POST"]: Refreshing state... [id=agm-btt3bl5139-ooaugc-POST]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-btt3bl5139-r6frgh-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-btt3bl5139-fbius2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-btt3bl5139-r6frgh-DELETE]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-0937cfcf0113fcbe8]
aws_db_instance.branch_rds: Refreshing state... [id=db-RQUC7A6QEZXSCYCKNMBKSKTS3Y]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-04300761c6a4d1014]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20260830181426405600000001]
aws_iam_role_policy.lambda_s3_objects: Refreshing state... [id=branch-lambda-role:branch-lambda-s3-objects]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy_attachment.ci_plan_dsql_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/AmazonAuroraDSQLReadOnlyAccess]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_api_gateway_method.cors_proxy_options["reports"]: Refreshing state... [id=agm-btt3bl5139-qpfkcg-OPTIONS]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-btt3bl5139-qpfkcg-ANY]
aws_api_gateway_method.cors_proxy_options["users"]: Refreshing state... [id=agm-btt3bl5139-0s4etn-OPTIONS]
aws_api_gateway_method.cors_proxy_options["auth"]: Refreshing state... [id=agm-btt3bl5139-7tjm3k-OPTIONS]
aws_api_gateway_method.cors_proxy_options["donors"]: Refreshing state... [id=agm-btt3bl5139-37l1nw-OPTIONS]
aws_api_gateway_method.cors_proxy_options["projects"]: Refreshing state... [id=agm-btt3bl5139-zofyad-OPTIONS]
aws_api_gateway_method.cors_proxy_options["expenditures"]: Refreshing state... [id=agm-btt3bl5139-2haqg7-OPTIONS]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-btt3bl5139-0s4etn-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-btt3bl5139-7tjm3k-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-btt3bl5139-37l1nw-ANY]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-btt3bl5139-2haqg7-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-btt3bl5139-zofyad-ANY]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-404813129370]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-404813129370]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-404813129370]
aws_cloudfront_distribution.frontend: Refreshing state... [id=EOTKQTE3WUELO]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-404813129370/donors/initial.zip]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-404813129370/users/initial.zip]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-404813129370/projects/initial.zip]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-404813129370/reports/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-404813129370/auth/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-404813129370/expenditures/initial.zip]
aws_api_gateway_integration.cors["users-proxy"]: Refreshing state... [id=agi-btt3bl5139-0s4etn-OPTIONS]
aws_api_gateway_integration.cors["auth"]: Refreshing state... [id=agi-btt3bl5139-j2bjjp-OPTIONS]
aws_api_gateway_integration.cors["projects-proxy"]: Refreshing state... [id=agi-btt3bl5139-zofyad-OPTIONS]
aws_api_gateway_integration.cors["projects"]: Refreshing state... [id=agi-btt3bl5139-5rlpdk-OPTIONS]
aws_api_gateway_integration.cors["donors"]: Refreshing state... [id=agi-btt3bl5139-ooaugc-OPTIONS]
aws_api_gateway_integration.cors["expenditures-proxy"]: Refreshing state... [id=agi-btt3bl5139-2haqg7-OPTIONS]
aws_api_gateway_integration.cors["expenditures"]: Refreshing state... [id=agi-btt3bl5139-x3f6cx-OPTIONS]
aws_api_gateway_integration.cors["reports"]: Refreshing state... [id=agi-btt3bl5139-fbius2-OPTIONS]
aws_api_gateway_integration.cors["auth-proxy"]: Refreshing state... [id=agi-btt3bl5139-7tjm3k-OPTIONS]
aws_api_gateway_integration.cors["donors-proxy"]: Refreshing state... [id=agi-btt3bl5139-37l1nw-OPTIONS]
aws_api_gateway_integration.cors["reports-proxy"]: Refreshing state... [id=agi-btt3bl5139-qpfkcg-OPTIONS]
aws_api_gateway_integration.cors["users"]: Refreshing state... [id=agi-btt3bl5139-r6frgh-OPTIONS]
aws_api_gateway_method_response.cors["expenditures"]: Refreshing state... [id=agmr-btt3bl5139-x3f6cx-OPTIONS-200]
aws_api_gateway_method_response.cors["reports"]: Refreshing state... [id=agmr-btt3bl5139-fbius2-OPTIONS-200]
aws_api_gateway_method_response.cors["auth"]: Refreshing state... [id=agmr-btt3bl5139-j2bjjp-OPTIONS-200]
aws_api_gateway_method_response.cors["users-proxy"]: Refreshing state... [id=agmr-btt3bl5139-0s4etn-OPTIONS-200]
aws_api_gateway_method_response.cors["donors-proxy"]: Refreshing state... [id=agmr-btt3bl5139-37l1nw-OPTIONS-200]
aws_api_gateway_method_response.cors["expenditures-proxy"]: Refreshing state... [id=agmr-btt3bl5139-2haqg7-OPTIONS-200]
aws_api_gateway_method_response.cors["projects-proxy"]: Refreshing state... [id=agmr-btt3bl5139-zofyad-OPTIONS-200]
aws_api_gateway_method_response.cors["reports-proxy"]: Refreshing state... [id=agmr-btt3bl5139-qpfkcg-OPTIONS-200]
aws_api_gateway_method_response.cors["users"]: Refreshing state... [id=agmr-btt3bl5139-r6frgh-OPTIONS-200]
aws_api_gateway_method_response.cors["auth-proxy"]: Refreshing state... [id=agmr-btt3bl5139-7tjm3k-OPTIONS-200]
aws_api_gateway_method_response.cors["donors"]: Refreshing state... [id=agmr-btt3bl5139-ooaugc-OPTIONS-200]
aws_api_gateway_method_response.cors["projects"]: Refreshing state... [id=agmr-btt3bl5139-5rlpdk-OPTIONS-200]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1913669945]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-404813129370]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_ES8vlp7b4]
aws_api_gateway_integration_response.cors["projects-proxy"]: Refreshing state... [id=agir-btt3bl5139-zofyad-OPTIONS-200]
aws_api_gateway_integration_response.cors["auth-proxy"]: Refreshing state... [id=agir-btt3bl5139-7tjm3k-OPTIONS-200]
aws_api_gateway_integration_response.cors["reports-proxy"]: Refreshing state... [id=agir-btt3bl5139-qpfkcg-OPTIONS-200]
aws_api_gateway_integration_response.cors["projects"]: Refreshing state... [id=agir-btt3bl5139-5rlpdk-OPTIONS-200]
aws_api_gateway_integration_response.cors["users-proxy"]: Refreshing state... [id=agir-btt3bl5139-0s4etn-OPTIONS-200]
aws_api_gateway_integration_response.cors["auth"]: Refreshing state... [id=agir-btt3bl5139-j2bjjp-OPTIONS-200]
aws_api_gateway_integration_response.cors["users"]: Refreshing state... [id=agir-btt3bl5139-r6frgh-OPTIONS-200]
aws_api_gateway_integration_response.cors["donors-proxy"]: Refreshing state... [id=agir-btt3bl5139-37l1nw-OPTIONS-200]
aws_api_gateway_integration_response.cors["expenditures"]: Refreshing state... [id=agir-btt3bl5139-x3f6cx-OPTIONS-200]
aws_api_gateway_integration_response.cors["donors"]: Refreshing state... [id=agir-btt3bl5139-ooaugc-OPTIONS-200]
aws_api_gateway_integration_response.cors["expenditures-proxy"]: Refreshing state... [id=agir-btt3bl5139-2haqg7-OPTIONS-200]
aws_api_gateway_integration_response.cors["reports"]: Refreshing state... [id=agir-btt3bl5139-fbius2-OPTIONS-200]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=26r3n4d9ttjp6fvhdg1erd2eli]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-btt3bl5139-x3f6cx-GET]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-btt3bl5139-5rlpdk-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-btt3bl5139-x3f6cx-PATCH]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-btt3bl5139-x3f6cx-POST]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-btt3bl5139-5rlpdk-POST]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-btt3bl5139-r6frgh-POST]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-btt3bl5139-fbius2-GET]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-btt3bl5139-r6frgh-GET]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-btt3bl5139-r6frgh-PATCH]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-btt3bl5139-r6frgh-DELETE]
aws_api_gateway_integration.lambda_integrations["donors-POST"]: Refreshing state... [id=agi-btt3bl5139-ooaugc-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-btt3bl5139-ooaugc-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-btt3bl5139-j2bjjp-GET]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-btt3bl5139-j2bjjp-POST]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-btt3bl5139-7tjm3k-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-btt3bl5139-37l1nw-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-btt3bl5139-2haqg7-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-btt3bl5139-zofyad-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-btt3bl5139-qpfkcg-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-btt3bl5139-0s4etn-ANY]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=f4ddhq]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-btt3bl5139-prod]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@nourshoreibah nourshoreibah added the no-review The PR review bot won't run label Sep 8, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review September 8, 2026 02:38
@nourshoreibah
nourshoreibah merged commit ca4fa79 into main Sep 8, 2026
22 checks passed
@nourshoreibah
nourshoreibah deleted the feat/rollups-in-application-code branch September 8, 2026 02:44
nourshoreibah added a commit that referenced this pull request Sep 8, 2026
#400 gained V20260907213524__project_rollup_bump_reports_hit before it merged,
so it arrived in the pre-Flyway naming form. Rename it and move the adoption
baseline up to it -- production has already applied it, so Flyway must baseline
over it rather than re-run it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-review The PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant