Database
PostgreSQL 16. The full definition is db/schema.sql, which is applied on
every deploy and written to be idempotent — re-running it is the normal case
and also the repair path.
Extensions: pgcrypto for UUID generation, btree_gist for the special event
overlap constraint.
Roles
The schema is applied as postgres, so postgres owns every table. The
application connects as ezfd, which is granted SELECT, INSERT, UPDATE
and DELETE — no ownership.
One practical consequence: TRUNCATE requires ownership and fails at runtime
with a permission error. Bulk clears in application code use DELETE FROM.
lib/masterCallsigns.ts does this inside a transaction, so a mid-import
failure can't leave a partial list carrying a fresh timestamp.
Any new table needs an explicit GRANT or every query against it fails in
production while working fine in a superuser test.
events
One row per event. The join code is what operators type; the UUID is internal.
| Column | Notes |
|---|---|
id |
UUID primary key |
join_code |
Unique, six characters, what operators enter |
club_name, club_call |
Display name and the callsign signed on the air |
event_year |
|
event_type |
FD, WFD or SES |
class |
Nullable. NULL for special events |
arrl_section |
Nullable. NULL or optional for special events |
power |
HIGH, LOW, QRP — scoring multiplier, contests only |
location |
Free text |
qrz_username, qrz_password |
Password encrypted at rest, never returned by the API |
qrz_session_key, qrz_session_expires |
Cached QRZ session |
bonuses |
JSONB, contest bonus tracking |
use_call_history, use_master_callsign_file |
Opt-in callsign databases |
starts_at, ends_at |
Special event activation window |
ses_description, ses_qsl_info |
Shown to operators on join |
slot_enforcement |
SOFT or HARD |
slot_minutes |
Default checkout length |
dupe_rule |
EVENT, DAY or NONE |
require_operator_approval |
Roster gating, default false |
class and arrl_section became nullable when special events were added.
Every read of them needs a null guard — cabrillo.ts, adif.ts,
SummarySheet.tsx and the QSO insert paths all have one.
qsos
| Column | Notes |
|---|---|
id |
UUID primary key |
event_id |
FK, cascade delete |
callsign, band, mode |
mode is constrained to PH, CW, DIG |
datetime_utc |
Defaults to now |
sent_class, sent_section |
Copied from the event at insert |
rcvd_class, rcvd_section |
The received exchange |
operator_call, station_number |
Who logged it |
is_dupe |
Computed at insert per the event's dupe rule |
rst_sent, rst_rcvd |
Special event exchange |
rcvd_name, rcvd_qth, rcvd_grid, comment |
Special event exchange |
adif_mode |
The real submode, e.g. FT8 |
freq_khz |
Actual frequency when known |
The mode CHECK constraint stays at three values deliberately. Scoring,
duplicate detection and the band/mode UI all rely on that split. The real
submode goes in adif_mode, which ADIF export prefers when set. Widening the
CHECK would break the three things that depend on it.
Indexes cover the three access patterns: by event, the duplicate lookup
(event_id, callsign, band, mode), and chronological listing.
presence
Ephemeral, one row per operator per event, TTL-based rather than explicitly cleaned. Drives the Operators panel. Rows older than 90 seconds are treated as gone; the read filters them rather than a job deleting them.
ses_operators
The special event roster. One row per operator per event.
| Column | Notes |
|---|---|
event_id, op_call |
Composite primary key |
op_name, grid, state, county, dxcc |
The operator's own location |
approved |
Enforced only when the event requires approval |
This table is the only source for the ADIF MY_GRIDSQUARE, MY_STATE and
MY_CNTY fields on a special event's exports. Losing it produces a log that
no longer uploads correctly to LoTW, which is why the admin console's backup
and restore carry it.
ses_reservations
Call checkouts. The important part is the constraint, not the columns.
| Column | Notes |
|---|---|
id |
UUID primary key |
event_id, op_call |
Who holds it |
band, mode |
What they hold |
during |
TSTZRANGE — the window |
planned_freq |
Free text, no exclusivity |
note |
|
status |
RESERVED or RELEASED |
CONSTRAINT ses_no_overlap EXCLUDE USING gist (
event_id WITH =,
band WITH =,
mode WITH =,
during WITH &&
) WHERE (status <> 'RELEASED')
This is what makes "one signal per band per mode" true rather than merely
intended. Two operators claiming the same slot simultaneously cannot both
succeed; the loser gets SQLSTATE 23P01.
Three things to know before changing it:
- Do not narrow the granularity to frequency. Special event rules generally permit one signal per band per mode, so a finer slot would let the database bless something the rules forbid.
- Do not replace it with an application-level check. A
SELECTfollowed by anINSERThas a window between them, which is the entire problem. - Releasing clamps with
GREATEST(lower(during), NOW()). Clamping toNOW()alone inverts the range when a not-yet-started slot is cancelled, and Postgres rejects an inverted range.
Because the constraint is declared inline in CREATE TABLE, which Postgres
skips once the table exists, the schema also carries an explicit
pg_constraint check that re-adds it if missing. Without that, re-applying
the schema — the documented repair path — could not restore it.
db/test-ses-constraint.sql asserts all of this against a real database and
runs in CI.
call_history_entries and master_callsigns
Optional callsign databases.
call_history_entries is per-event, imported from the N1MM file, and carries
a station's usual class and section. The file is contest- and year-specific.
master_callsigns is a single global list from MASTER.SCP, shared by every
event that opts in and refreshed at most once a day. It is not event-scoped
because it isn't contest-specific.
Notification triggers
Two triggers publish to pg_notify:
| Trigger | Channel | Fires on |
|---|---|---|
qso_notify |
qsos_<event_id> |
INSERT, UPDATE, DELETE on qsos |
ses_reservation_notify |
ses_<event_id> |
INSERT, UPDATE, DELETE on ses_reservations |
Both send the operation and the full row as JSON. The reservation payload carries the raw range column rather than decomposed timestamps, so the client treats it as a signal to refetch rather than parsing it.
See Architecture.