pgbr
Reference

Database Schema

The tables in pgbr's metadata database and how they relate.

pgbr's own Postgres holds users, connections, job history, schedules, recorded actions, and storage settings. Schema is defined with Drizzle in packages/db/src/schema/; migrations live in packages/db/drizzle/ and are applied automatically by the dashboard's entrypoint on boot.

This is pgbr's metadata database, not a database you back up. Losing it doesn't lose your artifacts — but it loses everything that tells you what those artifacts are.

Relationships

Auth tables

Managed by Better Auth.

users

ColumnTypeNotes
iduuidPrimary key
nametext
emailtextUnique
email_verifiedbooleanDefaults false; pgbr doesn't verify
usernametextUnique
display_usernametext
created_at / updated_attimestamp

There is no role column. "The first user is the admin" means they got in first, not that they hold a privilege — every user is equivalent.

sessions, accounts, verifications

Session tokens with expiry and IP/user-agent, credential records (hashed passwords live in accounts.password), and verification tokens. All cascade on user deletion, and each is indexed on the column it's looked up by.

databases

A saved connection.

ColumnTypeNotes
idtextPrimary key, UUID
user_iduuidusers.id, set null on delete
nametextUnique per user (databases_user_id_name_unique)
urltextEncrypted iv:authTag:ciphertext
backup_countintegerLifetime successful backups; only increments
created_at / updated_attimestamp

name is unique per owner, matching what the create and update actions check, so two users can each have a database named production. Reusing one of your own names is rejected with "Database name already exists" rather than a constraint violation surfaced as a generic error.

backup_jobs

ColumnTypeNotes
idtextPrimary key. A UUID, shared with the job's job_queue row
database_idtextdatabases.id, set null on delete
user_iduuidusers.id, set null on delete
schedule_idtextbackup_schedules.id, set null on delete
database_nametextDenormalized — survives the database being deleted
statustextpending / running / completed / failed
storage_keytextLogical object key, independent of any mount
flagsjsonbThe exact flags this dump ran with
errortextThe tool's stderr on failure
sizebigintBytes, counted during upload
started_at / completed_attimestamp

The FKs all set null rather than cascade, which is what lets a backup outlive its database and still be downloadable. database_name is denormalized for the same reason — after the database is gone, it's the only record of what was dumped.

flags being stored per job is what makes an artifact self-describing: the restore path reads the recorded format to decide whether the artifact needs expanding.

size is a bigint. It was a 32-bit integer through 2.3.0, which capped at 2,147,483,647 bytes (~2 GB) and failed any larger backup at the very last step — after the dump and upload had both succeeded. Migration 0004 widens the column in place; existing rows are unaffected.

backup_schedules

ColumnTypeNotes
idtextPrimary key
user_iduuidusers.id, cascade on delete
database_idtextdatabases.id, cascade. Immutable after creation
nametext
cron_expressiontext5 fields
timezonetextIANA. Defaults UTC
enabledbooleanDefaults true
flagsjsonbpg_dump flags for each run
keep_lastintegerRetention. Null = keep everything
next_run_attimestamptzWhen it fires next. Null = not scheduled yet

Schedules cascade where jobs set null: a schedule with no database is meaningless, but a backup without one is still an artifact you might need.

The row is the scheduler. next_run_at is the only thing that makes a schedule fire, so there is no derived registration anywhere to reconcile. A null means "not scheduled yet" — the worker computes the next occurrence without firing, which is what stops a fresh upgrade from running every schedule at once.

job_queue

ColumnTypeNotes
idtextPrimary key. Shared with the job's history row
queuetextbackup / restore / migrate
user_iduuidusers.id, cascade on delete
payloadjsonbThe job's arguments
statustextpending / active / completed / failed
run_attimestamptzNot claimable before this
attemptsintegerIncremented on claim
max_attemptsintegerDefaults 1 — nothing retries automatically
stallsintegerCounted separately from attempts
locked_bytextWhich worker holds it
locked_at / heartbeat_attimestamptzStaleness is judged on the heartbeat
last_errortext
created_at / completed_attimestamptz

Sharing id with the history row is what makes a re-delivered job land on the row it already created instead of orphaning it.

Two partial indexes back the hot paths — (queue, run_at) WHERE status = 'pending' for claiming and (heartbeat_at) WHERE status = 'active' for reaping. Their predicates have to match the queries exactly or Postgres won't use them.

Two triggers pg_notify the pgbr_jobs channel when a row becomes claimable, on insert and on requeue. Emitting from a trigger rather than application code covers rows the scheduler and reaper write, and can't be forgotten at a new call site.

Rows are purged seven days after they finish. The history tables are the permanent record; these are bookkeeping.

restore_jobs

ColumnTypeNotes
idtextPrimary key, UUID
database_idtextThe target. → databases.id, set null
user_iduuidusers.id, set null
database_nametextDenormalized target name
statustextSame four values
storage_keytextThe tracked backup's key, or a custom upload's
flagsjsonb
errortext
started_at / completed_attimestamp

No size — a restore consumes an artifact, it doesn't produce one.

migration_jobs

ColumnTypeNotes
idtextPrimary key, UUID
user_iduuidusers.id, set null
source_database_id / target_database_idtextNull for custom URLs
source_database_url / target_database_urltextEncrypted
source_database_name / target_database_nametextNull for custom URLs
backup_flags / restore_flagsjsonbBoth sides
statustext
errortextFiltered stderr, truncated at 3,000 chars
sizebigintBytes streamed from pg_dump into pg_restore
started_at / completed_attimestamp

Both URLs are stored encrypted, including custom ones you never saved as connections — a migration record shouldn't be a plaintext credential leak.

A migration streams straight from pg_dump into pg_restore and never lands an artifact, so size is counted as the bytes cross the pipe rather than measured from a file. It records the dump's size, not the space used in the target, and is recorded even when the restore side fails.

activity_events

What the Activity feed shows beyond the job tables: the destructive changes that leave no row of their own behind.

ColumnTypeNotes
iduuidPrimary key
user_iduuidusers.id, cascade on delete
actiontextdatabase.deleted, schedule.deleted, backup.deleted, restore.deleted, migration.deleted, restores.cleared, migrations.cleared, storage.updated, data.nuked
summarytextThe line rendered in the feed
detailsjsonbPer-action context: counts, names, the cron a deleted schedule ran on
created_attimestamptz

Jobs are not copied in here. The feed reads backup_jobs, restore_jobs, and migration_jobs directly and this table covers only what those don't record, so there is no second copy of a job to fall out of step with the first.

created_at is timestamptz where the job tables' timestamps are naive. An audit row is ordered against rows written by other processes, so the offset belongs in the value rather than in a convention about what the value means.

This is the one table that cascades on user deletion rather than setting null: a job row outlives its user because the artifact does, but a record of who did what is worthless once the who is gone.

Writes are best-effort: a failed insert is logged and never fails the action it describes. Deleting from the Activity page is deliberately not recorded.

storage_settings

A singleton row with id default.

ColumnTypeNotes
idtextPrimary key, always default
endpointtext
regiontext
buckettext
access_key_idtextPlaintext — not a secret
secret_access_keytextEncrypted. Never returned to the client
force_path_stylebooleanDefaults true

Its presence is what makes the settings page report source: "settings" instead of "environment". Absent, everything falls back to STORAGE_* and then the built-in defaults.

Migration history

MigrationChange
0000_breezy_lady_bullseyeInitial schema
0001_late_blink
0002_add_storage_key_and_settingsAdded storage_key and storage_settings — the move to object storage
0003_drop_backup_pathDropped the old filesystem backup_path
0004_fine_magnetoWidened size to bigint; made database names unique per user
0005_adorable_deadpoolAdded job_queue and next_run_at, the move off Redis
0006_tidy_moonstoneAdded activity_events

0002/0003 are split deliberately: adding the new column and dropping the old one in one step makes drizzle-kit generate ask whether it's a rename, which it can't do in a non-TTY shell. Add first, drop second.

Conventions

  • snake_case column naming, configured on the Drizzle client
  • timestampscreated_at / updated_at, with updated_at auto-touched
  • Encrypted columns store iv:authTag:ciphertext, hex-encoded
  • Job IDs are text, not uuid — they hold UUIDs today, but the column type predates that and widening is free
  • Denormalized names on job rows, so history survives its subject

On this page