-- 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.