Prufy notes
Types and data · 10 min

The database

Application code has bugs. Constraints don't care. The schema is written so that illegal rows can't be stored, even by a hand-typed SQL statement.

The tables

Ten tablesClick one
Everything below hangs off a claim
One row is: One attempt by one account to own one name
Only one owner per name. One open attempt per account.
id, account_id
domain_ascii, domain_unicode, zone_apex
status, version
failed_checks, last_failure
verified_at … abandoned_at
provider_id, nameservers, domain_connect
next_check_at
lease_owner, lease_expires_at
An arrow means the table below points at the one above with a foreign key.

Shape: the idea behind the constraints

In TypeScript, each status carries exactly its own fields. The database enforces the same thing, with one CHECK constraint per status. "Shape" is just the name for which timestamp columns a row of that status is allowed to have.

The shape of each statusClick a row
Statusverified_atlast_seen_atat_risk_sincelapsed_atexpired_attransferred_atabandoned_at
-- domain_claims_at_risk_shape
CHECK (status <> 'at_risk' OR (
  verified_at IS NOT NULL AND
  last_seen_at IS NOT NULL AND
  at_risk_since IS NOT NULL AND
  lapsed_at IS NULL AND
  expired_at IS NULL AND
  transferred_at IS NULL AND
  abandoned_at IS NULL
))

The status <> '...' OR (...) part is how you write "if this status, then these rules" in SQL. For every other status the left side is true, so the rule doesn't apply.

Solid means the column must be set. Hollow means it must be null. There's no third option, so a row can't carry a timestamp left over from its previous status.

Partial unique indexes

A normal unique index covers every row. A partial one covers only rows matching a condition, so any number of pending claims can exist while only one can be the owner.

domain_claims_one_owner
Unique domain where status is verified or at_risk
The only-one-owner rule. The most important line in the schema, because it holds however requests interleave.
domain_claims_one_open_per_account
Unique (account, domain) where not terminal
One live attempt per account, so a double click can't create two.
challenges_one_active_per_claim
Unique claim where superseded_at is null
A claim has exactly one current code.
checks_one_in_flight_per_claim
Unique claim where finished_at is null
Two checks of one claim can't run at once.
Predict it first
Could five different accounts all have a pending claim on example.com at once?

The record's name is pinned by the database too

Two CHECK constraints on challenges spell out the record: challenges_name_prefix needs the name to start with _prufy-challenge., and challenges_value_matches_token needs the value to be prufy-domain-verification= followed by the row's own token. So when the product was renamed from Relay, changing CHALLENGE_LABEL and RECORD_VALUE_PREFIX in packages/core/src/constants.ts wasn't enough. The stored rows had to change with it.

Renaming a value two constraints depend onStep through it
Step 1 of 3
1. Drop both constraints
ALTER TABLE "challenges" DROP CONSTRAINT "challenges_value_matches_token";
ALTER TABLE "challenges" DROP CONSTRAINT "challenges_name_prefix";

The old constraints would refuse a renamed row, so they have to go before any row changes.

packages/db/drizzle/20261109235104_rename_challenge_to_prufy/migration.sql

The newer tables follow the same habits

ConstraintWhat it refuses
sessions_expiry_after_creationA session that's expired before it starts
sessions.token_hash UNIQUETwo sessions sharing one token
accounts_email_lowercaseThe same address stored twice in different capitals
accounts_verified_needs_emailA confirmation date with no address to confirm
accounts_email_token_pairA confirmation token with no expiry, or an expiry with no token
claim_shares_expiry_after_creationA setup link that's expired before it's made
push_subscriptions.endpoint UNIQUEOne browser subscribed twice

Four columns hold a hash where you might expect a secret: sessions.token_hash, claim_shares.token_hash, accounts.email_token_hash and domain_connect_states.state_hash. Reading the database gives nobody a way in. The Sessions chapter shows why.

The audit log can't be edited

A hand-written migration adds triggers that refuse UPDATE, DELETE and TRUNCATE on claim_events. Application code never tries to, and now it couldn't. That's also why tests create a fresh database rather than emptying tables.

Rows never leave the database layer

mapping.ts is the bridge. claimFromRow turns a row into a core Claim with brands applied. columnsFromState goes the other way and always writes every per-status column, setting unused ones to null. The rest of the system never touches a raw row.