PR-preview database
Spin up an isolated copy of acme/orders for every pull request. No
request or row charges until your tests query it; storage is metered on
the data the branch inherits from main ($0.40/GB-month, prorated for
the branch’s lifetime). Reclaiming the same ref is idempotent — call it
on every CI run with the same pr-${num} ref.
CI step — the easy way
Section titled “CI step — the easy way”Use the persql/preview-db-action
GitHub Action. One step, no teardown:
name: Preview DBon: pull_request:
jobs: preview: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6
- name: Claim PerSQL branch id: persql uses: persql/preview-db-action@v1 with: token: ${{ secrets.PERSQL_TOKEN }} database: acme/orders branch: pr-${{ github.event.pull_request.number }} ttl-seconds: 86400 # 1 day, default
# Anything in this job can now target the branch via its scoped token. - name: Apply migrations + smoke test env: PERSQL_TOKEN: ${{ steps.persql.outputs.token }} run: | pnpm dlx @persql/cli@latest db migrate pnpm test:e2e
- name: Comment branch URL on PR uses: actions/github-script@v8 with: script: | github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: `🌿 Preview DB: \`${{ steps.persql.outputs.branch-ref }}\` (expires ${{ steps.persql.outputs.expires-at }})`, });The action returns four outputs: branch-ref, token (scoped to the branch,
masked in logs), expires-at (ISO-8601), and outcome (created on first
claim, reset when reclaiming an existing ref).
CI step — without the action
Section titled “CI step — without the action”If you’d rather call the API directly (different CI system, or you want to
avoid the dependency), upsert the branch with PUT:
- name: Provision preview DB env: PERSQL_TOKEN: ${{ secrets.PERSQL_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | curl -sf -X PUT "https://api.persql.com/v1/db/acme/orders/branches/pr-$PR_NUMBER" \ -H "Authorization: Bearer $PERSQL_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "fromRef": "main", "expiresAt": "'"$(date -u -d '+14 days' +%FT%TZ)"'" }'The expiresAt field auto-deletes the branch when its lease elapses; you
don’t need to wire pr_closed events.
App side
Section titled “App side”Your preview app reads PR_NUMBER from its env and connects to the right
branch — same auth, same SDK:
import { PerSQL } from "@persql/sdk";
const persql = new PerSQL({ token: process.env.PERSQL_TOKEN! });const db = persql.database("acme", `orders.pr-${process.env.PR_NUMBER}`);
await db.query("SELECT COUNT(*) FROM customers");Promoting a branch back to main
Section titled “Promoting a branch back to main”Once the PR is approved, fold the branch’s schema or data back:
await db.branches.merge("pr-42", { mode: "schema" }); // schema-onlyawait db.branches.merge("pr-42", { mode: "promote" }); // schema + rowsNext step
Section titled “Next step”Run db.doctor() on the branch as a CI check — fail
the PR if the migration introduced an LLM-hostile schema (missing PKs,
ambiguous column names, unindexed FKs).
const report = await db.doctor();if (report.findings.some((f) => f.severity === "error")) process.exit(1);