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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,19 @@ jobs:
env:
TEST_DATABASE_URL: postgresql://oracledesk:oracledesk@localhost:5432/postgres
TEST_REDIS_URL: redis://localhost:6379

migrations:
name: Migration lint
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc

- name: Lint new migrations
run: npm run lint:migrations
4 changes: 4 additions & 0 deletions .squawk.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Squawk configuration for scripts/lint-migrations.sh (#104).
# Rules are suppressed per migration through prisma/migration-allowlist.json,
# never here. Keep this list empty.
excluded_rules = []
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ When adding `CHECK` constraints or foreign keys to existing tables, follow the
2. Run `scripts/audit-constraints.ts [--fix]` to detect and repair or quarantine any invalid rows.
3. Validate the constraint in a subsequent migration using `ALTER TABLE ... VALIDATE CONSTRAINT ...` (takes `SHARE UPDATE EXCLUSIVE`, keeping tables available for reads and writes).

### Migration lint

CI (`migrations` job) runs `npm run lint:migrations` against the migrations a
PR adds. Locally, run it after committing so it can diff against the base
(`MIGRATIONS_BASE_REF=upstream/develop npm run lint:migrations`).

- Editing, deleting or renaming a merged migration fails the lint.
- Every new migration must start with `SET lock_timeout = '5s';` so a
migration blocked by a busy table fails fast instead of queuing queries.
- New migrations are checked with a pinned [squawk](https://squawkhq.com/docs/)
(lock-heavy changes such as non-concurrent indexes, column type changes,
drops and renames are rejected).
- To suppress a rule, add an entry to `prisma/migration-allowlist.json` scoped
to one migration, listing the rules, a `reason` and an `approvedBy` reviewer:
`{ "migration": "<dir name>", "rules": ["ban-drop-column"], "reason": "...", "approvedBy": "@reviewer" }`.
- Enum values: add with `ALTER TYPE ... ADD VALUE` (the new value cannot be used
in the same transaction). Removing or renaming values needs expand/contract
(stop writing the value, migrate rows, recreate the type in a later release)
and an allowlist entry.

## Chain writes

Anything that signs must go through `src/services/chain.service.ts`, which
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"prisma:studio": "prisma studio",
"sync:contracts": "node scripts/sync-contracts.mjs",
"check:contracts": "node scripts/sync-contracts.mjs --check",
"lint:migrations": "bash scripts/lint-migrations.sh",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset --force --skip-seed && npm run db:seed",
"snapshot:export": "tsx scripts/snapshot-export.ts",
Expand Down
1 change: 1 addition & 0 deletions prisma/migration-allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
71 changes: 71 additions & 0 deletions scripts/lint-migrations.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Lints new Prisma migrations (#104).
#
# - Historical migrations are forward-only: modifying one fails.
# - Every new migration must set a lock_timeout.
# - New migrations are linted with a pinned squawk; exceptions come from
# prisma/migration-allowlist.json (one migration, specific rules, a reason).
#
# Base ref: origin/$GITHUB_BASE_REF on pull requests, HEAD~1 otherwise.
set -euo pipefail

SQUAWK_VERSION="2.21.0"
ALLOWLIST="prisma/migration-allowlist.json"

if [ -n "${GITHUB_BASE_REF:-}" ]; then
base="origin/${GITHUB_BASE_REF}"
else
base="${MIGRATIONS_BASE_REF:-HEAD~1}"
fi
if ! git rev-parse --verify --quiet "$base" >/dev/null; then
echo "Base ref $base not found; fetch full history (fetch-depth: 0)." >&2
exit 2
fi
merge_base="$(git merge-base "$base" HEAD)"
pattern='prisma/migrations/*/migration.sql'

modified="$(git diff --name-only --diff-filter=MDR "$merge_base" HEAD -- "$pattern")"
if [ -n "$modified" ]; then
echo "Merged migrations are forward-only; these were modified, deleted or renamed:" >&2
echo "$modified" >&2
exit 1
fi

added="$(git diff --name-only --diff-filter=A "$merge_base" HEAD -- "$pattern")"
if [ -z "$added" ]; then
echo "No new migrations; nothing to lint."
exit 0
fi

failed=0
while IFS= read -r file; do
name="$(basename "$(dirname "$file")")"

if ! grep -Eiq "^[[:space:]]*SET[[:space:]]+(LOCAL[[:space:]]+)?lock_timeout[[:space:]]*(=|TO)" "$file"; then
echo "$file: missing \"SET lock_timeout = '5s';\" (a blocked migration must fail fast)." >&2
failed=1
fi

# Allowlist entries must name a reason and a reviewer.
excludes="$(MIGRATION="$name" ALLOWLIST="$ALLOWLIST" node -e '
const entries = JSON.parse(require("fs").readFileSync(process.env.ALLOWLIST, "utf8"));
const rules = [];
for (const e of entries) {
if (!e.reason || !e.approvedBy || !Array.isArray(e.rules)) {
console.error("Invalid allowlist entry for " + e.migration + ": rules, reason and approvedBy are required.");
process.exit(1);
}
if (e.migration === process.env.MIGRATION) rules.push(...e.rules);
}
process.stdout.write(rules.join(","));
')"

args=(--assume-in-transaction)
if [ -n "$excludes" ]; then args+=(--exclude="$excludes"); fi
echo "Linting $file${excludes:+ (allowlisted: $excludes)}"
if ! npx --yes "squawk-cli@${SQUAWK_VERSION}" "${args[@]}" "$file"; then
failed=1
fi
done <<< "$added"

exit "$failed"