Files
hammy-backend/migrations/004_logbook.sql
T
2026-09-05 19:57:41 +02:00

122 lines
5.2 KiB
SQL

-- logbook: per-user QSO logs and import batches.
-- ---------------------------------------------------------------------------
-- imports
-- ---------------------------------------------------------------------------
-- Every QSO belongs to an import batch, so a bad ADIF file can be rolled back
-- as a unit rather than picked apart row by row afterwards.
CREATE TABLE logbook.imports (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
filename text,
source text, -- 'adif', 'lotw', 'clublog', 'manual'
status text NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'completed', 'failed', 'rolled_back')),
rows_total integer NOT NULL DEFAULT 0,
rows_accepted integer NOT NULL DEFAULT 0,
rows_duplicate integer NOT NULL DEFAULT 0,
rows_rejected integer NOT NULL DEFAULT 0,
error text,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz
);
CREATE INDEX imports_user_idx ON logbook.imports (user_id, started_at DESC);
-- ---------------------------------------------------------------------------
-- qsos
-- ---------------------------------------------------------------------------
CREATE TABLE logbook.qsos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
-- SET NULL rather than CASCADE: deleting an import record should not delete
-- the QSOs it brought in, only the audit trail of how they arrived.
import_id bigint REFERENCES logbook.imports(id) ON DELETE SET NULL,
callsign core.callsign NOT NULL,
-- ADIF gives QSO_DATE and TIME_ON separately, both UTC. Combined here.
qso_at timestamptz NOT NULL,
-- Dedup key. Generated columns must be IMMUTABLE, and date_trunc over a
-- timestamptz is only STABLE because it depends on the TimeZone setting.
-- timezone('UTC', tstz) -> timestamp IS immutable, and date_trunc over a
-- plain timestamp is immutable, so the chain works.
--
-- Minute granularity because two operators logging the same contact
-- routinely differ by a few seconds.
qso_minute timestamp GENERATED ALWAYS AS
(date_trunc('minute', timezone('UTC', qso_at))) STORED,
band text,
mode text,
submode text,
freq_hz bigint,
rst_sent text,
rst_rcvd text,
grid core.gridsquare,
dxcc integer, -- ADIF entity code, resolved at import
cq_zone smallint,
itu_zone smallint,
tx_power_w numeric(8,2),
comment text,
-- ADIF defines a few hundred fields and every logging program invents more.
-- Normalising the columns above and keeping the rest here is the main
-- reason for choosing Postgres: MariaDB's JSON is LONGTEXT with validation,
-- with no binary storage and no functional index on a path.
extra jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT qsos_freq_sane CHECK (freq_hz IS NULL OR (freq_hz > 0 AND freq_hz < 300000000000)),
CONSTRAINT qsos_zones_sane CHECK (
(cq_zone IS NULL OR cq_zone BETWEEN 1 AND 40)
AND (itu_zone IS NULL OR itu_zone BETWEEN 1 AND 90)
)
);
COMMENT ON COLUMN logbook.qsos.qso_minute IS 'Truncated copy of qso_at used only for duplicate detection. Do not query it directly; use qso_at.';
-- Duplicate detection. A repeat import of the same ADIF must not double the
-- log, and this is what makes ON CONFLICT DO NOTHING work on import.
--
-- NULLS NOT DISTINCT matters: band and mode are nullable, and in a unique index
-- NULLs are distinct by default. Without it, two identical QSOs that both have
-- band IS NULL do not collide, so an ADIF with no BAND field imports twice, and
-- again on every re-import. Requires PostgreSQL 15+.
CREATE UNIQUE INDEX qsos_dedup_uniq
ON logbook.qsos (user_id, callsign, band, mode, qso_minute)
NULLS NOT DISTINCT;
-- The four access patterns that actually happen.
CREATE INDEX qsos_user_time_idx ON logbook.qsos (user_id, qso_at DESC);
CREATE INDEX qsos_user_call_idx ON logbook.qsos (user_id, callsign);
CREATE INDEX qsos_user_band_mode_idx ON logbook.qsos (user_id, band, mode);
-- Award progress is DERIVED, never stored. DXCC and WAS counts are aggregates
-- over this table; a stored counter would need maintaining on every insert,
-- edit and rollback, and would drift. This index is what makes that cheap.
CREATE INDEX qsos_user_dxcc_idx ON logbook.qsos (user_id, dxcc) WHERE dxcc IS NOT NULL;
CREATE INDEX qsos_extra_idx ON logbook.qsos USING gin (extra);
-- Referencing side of the import FK. Without it, rolling back one import
-- sequentially scans the whole qsos table to apply ON DELETE SET NULL.
CREATE INDEX qsos_import_idx ON logbook.qsos (import_id) WHERE import_id IS NOT NULL;
-- Worth revisiting if a single user's log passes a few million rows: partition
-- by user_id hash, or by qso_at range. Not now - the indexes above carry a
-- normal log comfortably, and partitioning early costs flexibility.