Reset the dev Neon Postgres branch from its parent (production) on demand or on a schedule, with a live monitor. Admin-only, surfaced at /admin/db. Postgres/Neon only in v1.
Status: built, validated (877 tests), and browser-verified live on 2026-06-20 — the refresh is guarded by a confirmation dialog, and one real manual refresh ran end-to-end and reached succeeded in ~2.5s (two Neon operations, both finished).
This is the data-plane sibling of drizzle-workflow.md: that doc promotes schema upward (dev → prod); this one copies data downward (prod → dev).
Two access planes
Neon exposes two independent channels. The refresh feature needs both, and confusing them is the #1 source of trouble.
| Plane | Endpoint | Auth | Used for |
|---|---|---|---|
| Data plane | postgresql://user:pass@ep-….neon.tech/db |
role password | Run SQL. The app and db:push use this. |
| Control plane | https://console.neon.tech/api/v2 (Management API) |
Bearer NEON_API_KEY |
Create / reset-from-parent / list branches. |
"Refresh dev from prod" means reset a branch to its parent's state. That operation has no SQL equivalent — it lives only on the control plane. A connection string is therefore necessary but not sufficient; the feature requires control-plane credentials.
The two planes also use different identifiers. The DSN host is the endpoint id (ep-…), not the branch id (br-…) the API needs. Neither the branch id nor the project id can be derived from the DSN. They must be configured explicitly.
Operations
| Operation | Trigger | Direction |
|---|---|---|
| Manual refresh | Admin clicks "Refresh dev from prod" on the Mirror tab | prod → dev |
| Scheduled refresh | dbops-refresh cron (gated, default off) |
prod → dev |
| Monitor | Mirror tab streams branch status live from the Neon API | read-only |
/admin/db is a nested section with three tabs:
- Observe — the existing DB-usage monitor (unchanged).
- Mirror — the "Refresh dev from prod" button plus a branch-status panel: branch name, age, last reset, branch count vs. the free-tier limit of 10, and storage budget. Shows a "not configured" state when
NEON_API_KEYis absent. No dev → prod affordance exists. - Runs — paginated refresh history (Operation, Status, Trigger, Actor, Duration, Ops, Time).
Verified hero flow (browser test, 2026-06-20): click → toast "Refresh started" → "Running" badge with the button disabled and spinning → "Succeeded" → button re-enabled → branch panel reads "Last reset: just now".
Live progress is client polling of the status endpoint (~1.5s interval, visibility-aware, stops on terminal), not SSE. There is no background worker — the poll itself advances the run (see Architecture).
The hero button opens a confirmation dialog (destructive variant) before anything runs — clicking Cancel starts nothing; only the red Refresh dev branch confirm fires the reset. The reset is destructive and irreversible. See Operating notes.
Setup
1. Environment
Set in .env (documented in .env.example):
| Variable | Required | Where to find it |
|---|---|---|
NEON_API_KEY |
Yes | Neon Console → Account settings → API keys |
NEON_PROJECT_ID |
Yes | Console URL, or GET /api/v2/projects |
NEON_DEV_BRANCH_ID |
Yes | The br-… of the dev branch — Console → Branches → dev |
NEON_PARENT_BRANCH_ID |
No | Defaults to the dev branch's own parent_id |
DBOPS_AUTO_REFRESH_ENABLED |
No | "true" enables the scheduled reset. Default "false". |
Until configured, the Mirror and Runs tabs show "not configured"; Observe is unaffected.
Keep
DBOPS_AUTO_REFRESH_ENABLEDoff while actively developing — every refresh overwrites the dev branch with prod's current state. See Constraints.
2. Schema
db:push creates the dbops schema. Run it through the PTY trick for the raw-TTY prompt:
script -qec "bun run db:push" /dev/null
The dbops ledger lives wherever the app's NEON_DATABASE_URL_PROD points (where the app runs) — not on the dev branch being reset. A reset wipes the branch; the run history must survive it.
Architecture
Two server domains. neon/ reads and mutates the control plane; dbops/ orchestrates a refresh as a tracked run.
admin/db routes ──▶ dbops/ (orchestrate) ──▶ neon/ (Neon Management API)
│
▼
dbops.run (Postgres ledger)
$lib/server/neon/
The Management-API client. Sole holder of NEON_API_KEY / NEON_PROJECT_ID. Never returns connection strings in any payload.
listBranches, getBranch, restoreBranchFromParent, getOperation, neonConfigured, resolveTargets, fetchBranchStatus.
When unconfigured it degrades to status: 'unavailable' rather than throwing — the same pattern as $lib/server/monitoring/upstash.ts.
$lib/server/dbops/
The operate orchestrator. Depends on the read-only monitoring/ layer; the reverse import (monitoring → dbops) is forbidden.
startOperation, advanceRun, getRun, listRuns, cancelRun, reapExpiredRuns.
Executor model — lazy-advance-on-poll. Neon's restore is asynchronous: restoreBranchFromParent returns an operations[] array, not a finished result. So:
startOperationfires the restore, records the returned operation ids, and returns immediately asrunning.- The status endpoint (
GET .../[id]) advances the run by polling Neon on each read. - The run reaches a terminal status (
succeeded/failed) only when something reads it — a client poll, or the scheduled job.
There is no background worker holding the run open. The poll is the executor.
Concurrency and recovery. A DB partial-unique index allows only one in-flight refresh at a time. Each run holds a lease; the reaper job sweeps runs whose lease expired (a crash mid-restore) and marks them failed.
dbops.run table
One row per refresh. Mutable status (queued | running | succeeded | failed | canceled); id prefix dbr_. Registered in drizzle.config.ts schemaFilter.
API
House { data } / { error } envelope. Admin-guarded, rate-limited, audited.
| Method + Path | Purpose | Status |
|---|---|---|
POST /api/admin/db/ops |
Start a refresh | 202 (started), 200 (idempotent replay) |
GET /api/admin/db/ops |
List runs (cursor-paginated) | 200 |
GET /api/admin/db/ops/[id] |
Poll one run — advances it | 200 |
POST /api/admin/db/ops/[id]/cancel |
Cancel a run | 200 |
Jobs
Both reuse the existing CRON_SECRET bearer auth and are registered in $lib/server/jobs/index.ts (see architecture/jobs.md).
| Job | Schedule | Purpose |
|---|---|---|
dbops-refresh |
0 4 * * * daily |
Scheduled auto-reset. Drives advanceRun to terminal. Gated by DBOPS_AUTO_REFRESH_ENABLED (default off). |
dbops-reaper |
*/15 * * * * |
Sweep stuck runs whose lease expired. |
vercel.json cron paths: /api/cron/dbops-refresh, /api/cron/dbops-reaper.
Operating notes
Current operator-facing behavior. Distinct from Constraints, which are fixed platform limits.
- A refresh is destructive and irreversible. It replaces all data in the dev branch with prod's current state. There is no undo. The dev branch is disposable by design — never keep anything in it you can't lose.
- Guarded by a confirmation dialog. The hero button opens a destructive-variant dialog ("Refresh dev from prod?" / "This permanently replaces all data in the dev branch with production's current state. It cannot be undone.") with Cancel and a red Refresh dev branch confirm. The reset fires only after the confirm — Cancel starts no run.
Constraints
These are real limits, stated honestly.
- prod → dev only. A dev → prod data push is refused. Neon has no native merge, and pushing data upward is an anti-pattern. The only legitimate upward "push" is manual schema promotion via
drizzle-kit pushagainst the prod DSN — see drizzle-workflow.md. - GDPR: a reset copies real prod data into dev. No anonymization in v1. Neon's anonymized-branch feature cannot reset-to-parent, so it is incompatible with this operation. Deferred, not solved.
- Scheduled refresh is a footgun. An auto-reset overwrites everything accumulated in the dev branch since the last refresh. Hence the env gate plus a daily (not hourly) default. Leave it off while developing.
- Free tier: 10 branches, 0.5 GB storage, 6-hour point-in-time restore window.
- Reaper cron needs Vercel Pro.
*/15(every 15 min) exceeds Hobby's daily-only cron limit.
Future (not in v1)
A broader "refresh the whole dev environment" design — Neo4j and R2 alongside Postgres, plus graph rebuild — exists as design only. None of it is built. v1 is Postgres/Neon, one branch, one operation.
Related
- drizzle-workflow.md — schema promotion (dev → prod), the upward sibling
- ../architecture/jobs.md — the job registry and cron model
- ../../stack/data/postgres.md — Neon, branching, two-plane access
- ../../stack/ops/deployment.md — crons and environment variables