Skip to content

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.

Use the persql/preview-db-action GitHub Action. One step, no teardown:

.github/workflows/preview-db.yml
name: Preview DB
on:
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).

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.

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");

Once the PR is approved, fold the branch’s schema or data back:

await db.branches.merge("pr-42", { mode: "schema" }); // schema-only
await db.branches.merge("pr-42", { mode: "promote" }); // schema + rows

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);