Skip to content
Draft
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
46 changes: 33 additions & 13 deletions .github/workflows/lambda-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ on:
description: 'Apply pending DB migrations without redeploying lambdas'
type: boolean
default: false
adopt_baseline:
description: 'One-time: let Flyway adopt a populated database that has no schema history'
type: boolean
default: false

jobs:
detect-changes:
Expand Down Expand Up @@ -162,7 +166,7 @@ jobs:
# reviewers: a pending approval here would strand a merged PR with its schema
# applied and its lambda code undeployed.
environment: production-db
# kysely takes a pg advisory lock, but serialize anyway so two merges in a row
# Flyway locks the history table, but serialize anyway so two merges in a row
# queue instead of racing -- and never cancel a half-run migration.
concurrency:
group: db-migrate-prod
Expand All @@ -172,6 +176,8 @@ jobs:
contents: read
actions: read
pull-requests: read
env:
FLYWAY_BASELINE_ON_MIGRATE: ${{ inputs.adopt_baseline == true }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
Expand Down Expand Up @@ -221,22 +227,36 @@ jobs:
- name: Pending migrations (read-only preflight)
id: status
run: |
set -o pipefail
npm run migrate:status --prefix apps/backend/db 2>&1 | tee status.log
pending=$(grep -oE '[0-9]+ pending' status.log | tail -1 | cut -d' ' -f1)
# Unknown (unexpected output) falls through as "1" so we take the safe
# path and snapshot before touching anything.
echo "pending=${pending:-1}" >> "$GITHUB_OUTPUT"
set -euo pipefail
npm run --silent migrate:info --prefix apps/backend/db -- -outputType=json > info.json
# `jq -e` on malformed JSON exits non-zero, which inside `if` reads as "condition absent".
jq -e 'type == "object" and has("migrations") and has("allSchemasEmpty")' \
info.json > /dev/null \
|| { echo "::error::could not parse the output of flyway info"; exit 1; }

jq -r '.migrations[] | "\(.state)\t\(.version)\t\(.description)"' info.json | tee status.log
pending=$(jq '[.migrations[] | select(.state == "Pending")] | length' info.json)
echo "pending=$pending" >> "$GITHUB_OUTPUT"
echo "$pending migration(s) pending"

# 0000_baseline_schema is idempotent, but "idempotent" is one typo away from
# DROP SCHEMA. It is adopted once by hand against production (see
# apps/backend/db/README.md); if it still shows as pending here, we are
# pointed at a database we did not expect -- refuse rather than guess.
# Empty schema means the wrong DB_HOST; populated with no history means a snapshot or clone -- refuse both.
- name: Refuse to execute the baseline against production
env:
ADOPT: ${{ inputs.adopt_baseline }}
run: |
if grep -E '0000_baseline_schema' status.log | grep -q 'PENDING'; then
echo "::error::0000_baseline_schema is PENDING on the target database. It was never adopted (or DB_HOST is wrong). Refusing to run -- see apps/backend/db/README.md."
set -euo pipefail
if [ "$(jq -r '.allSchemasEmpty' info.json)" = 'true' ]; then
echo "::error::schema \"branch\" is empty on the target database. Refusing to build production from scratch -- check DB_HOST."
exit 1
fi
tracked=$(jq -r '.schemaVersion' info.json)
if [ "$tracked" = 'null' ] && [ "$ADOPT" != 'true' ]; then
echo "::error::the target database has a populated schema \"branch\" but no Flyway history. Refusing to adopt it -- re-run this workflow manually with adopt_baseline if that is genuinely production."
exit 1
fi
if [ "$tracked" != 'null' ] \
&& jq -e 'any(.migrations[]; .version == "0000" and .state == "Pending")' info.json > /dev/null; then
echo "::error::V0000__baseline_schema is PENDING on a database Flyway already tracks. The schema history and the schema have drifted -- refusing to run."
exit 1
fi

Expand Down
131 changes: 105 additions & 26 deletions .github/workflows/lambda-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,9 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
# Build the schema the same way production does -- by applying
# db/migrations -- then load the dev seed rows the tests expect. The tests
# also call ensureSchema()/resetData() from db/testkit.ts themselves, so
# this doubles as a per-PR smoke test that migrations apply to a virgin DB.
- name: Apply migrations and seed
- name: Build the schema and seed
working-directory: apps/backend/db
run: npm ci --no-audit --no-fund && npm run migrate && npm run seed
run: npm ci --no-audit --no-fund && npm run reset
env:
DATABASE_URL: postgres://branch_dev:password@localhost:5432/branch_db
- name: Build shared packages
Expand Down Expand Up @@ -120,24 +116,50 @@ jobs:
esac
echo "sha=$base" >> "$GITHUB_OUTPUT"

# Keyed by version, not path: that is what Flyway's history pins.
- name: Applied migrations are immutable
if: steps.base.outputs.sha != ''
env:
BASE: ${{ steps.base.outputs.sha }}
run: |
set -euo pipefail
MB=$(git merge-base "$BASE" HEAD)
# Anything but A(dded) under migrations/ is a violation: M modified,
# D deleted, R renamed, C copied, T typechange. --find-renames is
# explicit so a rename reports R (not D+A) regardless of the runner's
# diff.renames default.
violations=$(git diff --name-status --find-renames "$MB" HEAD -- "$DIR" | grep -vE '^A[[:space:]]' || true)
if [ -n "$violations" ]; then
echo "::error::Migrations already on main can never be modified, renamed or deleted -- someone has already run them. Add a NEW migration that fixes the old one."
printf '%s\n' "$violations"
exit 1
TAB=$(printf '\t')

# version<TAB>blob<TAB>name, sorted because `join` requires sorted input.
list() {
git ls-tree -r "$1" -- "$DIR" | while read -r _ _ sha path; do
name=$(basename "$path")
printf '%s\t%s\t%s\n' \
"$(printf '%s' "$name" | sed -E 's/^V?0*([0-9]+)_+.*/\1/')" \
"$sha" "$name"
done | sort
}
list "$MB" > "$RUNNER_TEMP/was.tsv"
list HEAD > "$RUNNER_TEMP/now.tsv"

fail=0
gone=$(join -t"$TAB" -v1 -j1 "$RUNNER_TEMP/was.tsv" "$RUNNER_TEMP/now.tsv" | cut -f3)
if [ -n "$gone" ]; then
echo "::error::deleted, but already run on production -- add a NEW migration that undoes it: $(echo $gone)"
fail=1
fi

# Rename check only for V-names: adopting Flyway renamed every file once.
join -t"$TAB" -j1 "$RUNNER_TEMP/was.tsv" "$RUNNER_TEMP/now.tsv" > "$RUNNER_TEMP/both.tsv"
awk -F'\t' -v dir="$DIR" '
$2 != $4 {
printf "::error file=%s/%s::contents changed. Flyway checksums every applied migration, so this fails the next deploy. Add a NEW migration that fixes the old one.\n", dir, $5
bad = 1
}
$3 ~ /^V/ && $3 != $5 {
printf "::error file=%s/%s::renamed from %s. Production'"'"'s schema history records the old name, and Flyway rejects the mismatch.\n", dir, $5, $3
bad = 1
}
END { exit bad }
' "$RUNNER_TEMP/both.tsv" || fail=1
exit $fail

- name: New migrations must be named correctly
if: steps.base.outputs.sha != ''
env:
Expand All @@ -150,14 +172,44 @@ jobs:
last=$(git ls-tree -r --name-only "$MB" -- "$DIR" | xargs -r -n1 basename | sort | tail -1 || true)
fail=0
for f in $added; do
if ! echo "$f" | grep -qE '^(0000_baseline_schema|[0-9]{14}_[a-z0-9_]+)\.sql$'; then
echo "::error file=$DIR/$f::name must be YYYYMMDDHHMMSS_lower_snake_case.sql -- use 'make new-migration NAME=...' so it is generated for you"
if ! echo "$f" | grep -qE '^V(0000|[0-9]{14})__[a-z0-9_]+\.sql$'; then
echo "::error file=$DIR/$f::name must be VYYYYMMDDHHMMSS__lower_snake_case.sql -- use 'make new-migration NAME=...' so it is generated for you"
fail=1
fi
# Out-of-order merges are allowed at runtime (the migrator sets
# allowUnorderedMigrations), so this is advice, not a failure.
# Warning only: outOfOrder is set at runtime; the next step fails anything at or below the baseline.
if [ -n "${last:-}" ] && [ "$f" \< "$last" ]; then
echo "::warning file=$DIR/$f::sorts before $last, which is already on main, so it will be applied out of order. Harmless, but renaming it to a current timestamp keeps the history readable."
echo "::warning file=$DIR/$f::sorts before $last, which is already on main, so it will be applied out of order. Renaming it to a current UTC timestamp keeps the history readable."
fi
done
exit $fail

# Flyway skips anything at or below the baseline as "Below Baseline" with exit 0; CI's empty schema never baselines, so nothing else catches it.
- name: New migrations must sort above the adoption baseline
if: steps.base.outputs.sha != ''
env:
BASE: ${{ steps.base.outputs.sha }}
run: |
set -euo pipefail
baseline=$(sed -n 's/^BASELINE_VERSION=//p' apps/backend/db/flyway.sh)
case "$baseline" in
'' | *[!0-9]*)
echo "::error::could not read a numeric BASELINE_VERSION from apps/backend/db/flyway.sh"
exit 1 ;;
esac
MB=$(git merge-base "$BASE" HEAD)
version_of() { basename "$1" | sed -E 's/^V0*([0-9]+)_+.*/\1/'; }

# Renames show up as additions, so skip versions already on main.
git ls-tree -r --name-only "$MB" -- "$DIR" | while read -r p; do version_of "$p"; done \
| sort -u > "$RUNNER_TEMP/known.txt"

fail=0
for f in $(git diff --diff-filter=A --name-only "$MB" HEAD -- "$DIR"); do
v=$(version_of "$f")
grep -qxF "$v" "$RUNNER_TEMP/known.txt" && continue
if [ "$v" -le "$baseline" ]; then
echo "::error file=$f::version $v is at or below the adoption baseline $baseline, so production would report it \"Below Baseline\" and never run it -- while CI stays green. Rename it with a current UTC timestamp."
fail=1
fi
done
exit $fail
Expand Down Expand Up @@ -189,7 +241,7 @@ jobs:
fail=1
fi
if printf '%s\n' "$sql" | grep -inE 'CONCURRENTLY'; then
echo "::error file=$f::CONCURRENTLY cannot run inside a transaction, and the migrator wraps the run in one. This database is tiny -- use a plain CREATE INDEX."
echo "::error file=$f::CONCURRENTLY cannot run inside a transaction, and Flyway wraps each migration in one. This database is tiny -- use a plain CREATE INDEX."
fail=1
fi
if printf '%s\n' "$sql" | grep -inE '^[[:space:]]*INSERT[[:space:]]+INTO[[:space:]]+(branch\.)?users\b'; then
Expand All @@ -198,6 +250,26 @@ jobs:
done
exit $fail

# Advisory: the existing corpus still holds plpgsql the DSQL cutover drops.
- name: Aurora DSQL compatibility (advisory)
if: steps.base.outputs.sha != ''
continue-on-error: true
env:
BASE: ${{ steps.base.outputs.sha }}
run: |
set -euo pipefail
MB=$(git merge-base "$BASE" HEAD)
files=$(git diff --diff-filter=A --name-only "$MB" HEAD -- "$DIR" || true)
[ -n "$files" ] || { echo "no new migrations"; exit 0; }
npm install --prefix "$RUNNER_TEMP/dsql-lint" --no-audit --no-fund @aws/dsql-lint
lint=$RUNNER_TEMP/dsql-lint/node_modules/@aws/dsql-lint/bin/dsql-lint
for f in $files; do
[ -f "$f" ] || continue
echo "--- $f"
sed 's/${async}/ASYNC/g' "$f" > "$RUNNER_TEMP/$(basename "$f")"
node "$lint" "$RUNNER_TEMP/$(basename "$f")" || true
done

- name: Build and test shared packages
uses: ./.github/actions/build-shared-packages
with:
Expand All @@ -209,20 +281,27 @@ jobs:
- name: Apply every migration from scratch
run: npm run migrate --prefix apps/backend/db

- name: Ledger must be clean afterwards
- name: Schema history must be clean afterwards
run: |
set -o pipefail
npm run migrate:status --prefix apps/backend/db | tee status.log
if grep -q 'PENDING' status.log; then
echo "::error::migrations still pending after a successful migrate run"
npm run --silent migrate:info --prefix apps/backend/db -- -outputType=json > info.json
pending=$(jq '[.migrations[] | select(.state == "Pending")] | length' info.json)
if [ "$pending" != '0' ]; then
echo "::error::$pending migration(s) still pending after a successful migrate run"
jq -r '.migrations[] | select(.state == "Pending") | .filepath' info.json
exit 1
fi

- name: Re-running migrations must be a no-op
run: npm run migrate --prefix apps/backend/db

- name: Applied migrations must still match their checksums
run: npm run migrate:validate --prefix apps/backend/db

- name: Seeds must apply on top of the migrated schema
run: npm run seed --prefix apps/backend/db
run: |
npm run fingerprint --prefix apps/backend/db
npm run seed --prefix apps/backend/db

# Drift check: the committed types must be what these migrations produce.
- name: Committed types must match the migrations
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/regenerate-db-types.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ jobs:
echo "Applying db/migrations"
npm run migrate --prefix apps/backend/db

# Type generation refuses a schema without this marker.
npm run fingerprint --prefix apps/backend/db

echo "Verifying schema application"
psql -h localhost -U postgres -d testdb -c "\dt branch.*"
env:
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ http://localhost:3000/<service>/health
- Schema built from `db/migrations/*.sql` (Postgres schema `branch`); dev/test rows in `db/seed.sql`. Tables: `users`, `projects`, `project_memberships` (roles: Director/Student), `donors`, `project_donations`, `expenditures` (status: approved/pending/denied/needs_more_info), `reports` (report_type: technical/narrative), plus the trigger-maintained rollups `expenditure_rollup` and `project_rollup`.
- **Analytics reads go through the rollups, never the base tables.** `expenditure_rollup` (grain: project × month × category × status) and `project_rollup` (per project: member/donation/report counts and `total_donated`) are maintained by `AFTER INSERT OR UPDATE OR DELETE` row triggers, so they are exact rather than eventually consistent — there is nothing to refresh and no scheduler. Spend lives **only** in `expenditure_rollup`; do not add a `total_spent` column to `project_rollup`. Two rules when touching the triggers: increments upsert, decrements are a plain `UPDATE` (a project cascade-delete reaches `expenditures` and the rollups in an undefined order, so a zero-row `UPDATE` must be a safe no-op), and emptied buckets are left at zero rather than deleted. `TRUNCATE` fires no row triggers, so each base table also has an `AFTER TRUNCATE ... FOR EACH STATEMENT` trigger — a new base table needs both kinds. Time-relative figures (the active-project count, "spend on active projects") cannot be rolled up and still read `projects` live.
- Kysely connects via `db.ts` in each lambda (`Kysely<DB>` + `pg.Pool`, env `DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME`). Query with the `branch.` schema prefix: `db.selectFrom('branch.users')`.
- **Changing the schema:** `make new-migration NAME=add_thing` → write SQL → `make migrate` (applies it and regenerates `shared/types/db-types.d.ts`). `make show-migrations` shows applied vs pending; the ledger is `branch.kysely_migration`. Forward-only — never edit a merged migration, and never hand-edit `db-types.d.ts`. Migrations apply to prod automatically on merge **before** the lambda deploy, so a single PR may only contain additive changes; see `db/README.md` for expand/contract.
- The migration runner is kysely's `Migrator` over plain `.sql` files (`db/src/`). Root `package.json` still carries NestJS/TypeORM **dependencies** from the original scaffold, but nothing uses them; its dead `migration:*` scripts have been removed.
- **Changing the schema:** `make new-migration NAME=add_thing` → write SQL → `make migrate` (applies it and regenerates `shared/types/db-types.d.ts`). `make show-migrations` shows applied vs pending; the ledger is `branch.flyway_schema_history`. Forward-only — never edit a merged migration, and never hand-edit `db-types.d.ts`. Migrations apply to prod automatically on merge **before** the lambda deploy, so a single PR may only contain additive changes; see `db/README.md` for expand/contract.
- The migration runner is Flyway over plain `.sql` files, configured once in `db/flyway.sh`; `db/src/` holds seeding, the schema rebuild and type generation. Root `package.json` still carries NestJS/TypeORM **dependencies** from the original scaffold, but nothing uses them; its dead `migration:*` scripts have been removed.

## Shared packages

Expand Down
7 changes: 4 additions & 3 deletions apps/backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ grant-admin:
migrate:
@docker compose --env-file $(ENV_FILE) build migrator
@docker compose --env-file $(ENV_FILE) run --rm migrator npm run migrate
@docker compose --env-file $(ENV_FILE) run --rm migrator npm run fingerprint
@docker compose --env-file $(ENV_FILE) run --rm migrator npm run seed -- --if-empty
@$(MAKE) types
@$(MAKE) show-migrations
Expand All @@ -116,10 +117,10 @@ migrate:

# List every migration and whether it has been applied. Read-only.
#
# The source of truth is the branch.kysely_migration table -- one row per applied
# migration -- diffed against the .sql files in db/migrations.
# The source of truth is the branch.flyway_schema_history table -- one row per
# applied migration -- diffed against the .sql files in db/migrations.
show-migrations:
@docker compose --env-file $(ENV_FILE) run --rm migrator npm run migrate:status
@docker compose --env-file $(ENV_FILE) run --rm migrator npm run migrate:info

# Create an empty timestamped migration file, then say what to do next.
# usage: make new-migration NAME=add_expenditure_project_id_index
Expand Down
13 changes: 6 additions & 7 deletions apps/backend/db/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
# One-shot migration runner, built from the repo root like the lambdas.
#
# A Dockerfile rather than bind-mounting the repo into node:20-alpine and running
# `npm ci` on every `up`: that would clobber the host's apps/backend/db/node_modules
# with alpine-built artifacts and cost ~30s per start. This way docker caches the
# install layer.
FROM node:20-alpine
FROM flyway/flyway:13.5.0-alpine

RUN apk add --no-cache nodejs npm

WORKDIR /db

COPY apps/backend/db/package.json apps/backend/db/package-lock.json ./
RUN npm ci --no-audit --no-fund

COPY apps/backend/db/tsconfig.json apps/backend/db/testkit.ts apps/backend/db/seed.sql ./
COPY apps/backend/db/tsconfig.json apps/backend/db/testkit.ts apps/backend/db/seed.sql apps/backend/db/flyway.sh ./
COPY apps/backend/db/src ./src
COPY apps/backend/db/migrations ./migrations

# The base image entrypoint would prepend `flyway` to every compose `command:`.
ENTRYPOINT []
CMD ["npm", "run", "migrate"]
Loading
Loading