initial, copy postgres stuff for migrations, main.go test
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
-- 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.
|
||||
Reference in New Issue
Block a user