-- Copyright 2026 Beacon Contributors -- SPDX-License-Identifier: agpl -- ============================================================ -- Beacon schema migration -- ============================================================ -- ============================================================ -- IATA CODES -- ============================================================ CREATE TABLE iata_codes ( iata CHAR(3) PRIMARY KEY, display_name TEXT, approx_lat DOUBLE PRECISION, approx_lng DOUBLE PRECISION, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- ============================================================ -- REGIONS -- ============================================================ CREATE TABLE regions ( id SERIAL PRIMARY KEY, slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT, display_order INT DEFAULT 0, center_lat DOUBLE PRECISION, center_lng DOUBLE PRECISION, zoom_level INT DEFAULT 8, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE region_iatas ( region_id INT NOT NULL REFERENCES regions(id) ON DELETE CASCADE, iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (region_id, iata) ); CREATE INDEX idx_region_iatas_iata ON region_iatas(iata); -- ============================================================ -- TRANSPORT SCOPES -- ============================================================ CREATE TABLE transport_scopes ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, -- normalized key string e.g. "#bc" display_name TEXT, -- optional friendly name transport_key BYTEA NOT NULL, -- 16-byte derived key key_fingerprint BYTEA NOT NULL, -- SHA256(transport_key)[:8] for fast lookup created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_transport_scopes_fingerprint ON transport_scopes(key_fingerprint); -- ============================================================ -- NODES (must come before observers due to observer_owners FK) -- ============================================================ CREATE TABLE nodes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), public_key BYTEA UNIQUE NOT NULL, node_type SMALLINT NOT NULL, name TEXT, latitude DOUBLE PRECISION, longitude DOUBLE PRECISION, location_source TEXT, last_advert_at TIMESTAMPTZ, supports_multibyte_paths BOOLEAN NOT NULL DEFAULT FALSE, supports_multibyte_traces BOOLEAN NOT NULL DEFAULT FALSE, default_scope_id INT REFERENCES transport_scopes(id), min_firmware_version TEXT GENERATED ALWAYS AS ( CASE WHEN supports_multibyte_paths THEN '1.14.0+' WHEN supports_multibyte_traces THEN '1.11.0+' ELSE NULL END ) STORED, first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), radio_freq_mhz REAL, radio_sf SMALLINT, radio_bw_khz REAL, metadata JSONB ); CREATE INDEX idx_nodes_type_last_seen ON nodes(node_type, last_seen DESC); CREATE INDEX idx_nodes_location ON nodes(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; CREATE INDEX idx_nodes_pubkey ON nodes(public_key); CREATE INDEX idx_nodes_multibyte_paths ON nodes(supports_multibyte_paths) WHERE supports_multibyte_paths; CREATE INDEX idx_nodes_multibyte_traces ON nodes(supports_multibyte_traces) WHERE supports_multibyte_traces; CREATE INDEX idx_nodes_min_firmware ON nodes(min_firmware_version) WHERE min_firmware_version IS NOT NULL; -- ============================================================ -- OBSERVERS -- ============================================================ CREATE TABLE observers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), public_key BYTEA UNIQUE NOT NULL, display_name TEXT, observer_type TEXT, software_version TEXT, hardware_model TEXT, firmware_version TEXT, firmware_build TEXT, radio_freq_mhz REAL, radio_sf SMALLINT, radio_bw_khz REAL, radio_cr SMALLINT, battery_level REAL, uptime_seconds BIGINT, status_metadata JSONB, last_status_at TIMESTAMPTZ, first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), observation_count BIGINT DEFAULT 0, metadata JSONB ); CREATE INDEX idx_observers_last_seen ON observers(last_seen DESC); CREATE INDEX idx_observers_pubkey ON observers(public_key); CREATE INDEX idx_observers_type ON observers(observer_type) WHERE observer_type IS NOT NULL; CREATE TABLE observer_brokers ( observer_id UUID NOT NULL REFERENCES observers(id) ON DELETE CASCADE, broker_name TEXT NOT NULL, first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_packet_at TIMESTAMPTZ, auth_ok BOOLEAN DEFAULT TRUE, PRIMARY KEY (observer_id, broker_name) ); CREATE TABLE observer_locations ( observer_id UUID NOT NULL REFERENCES observers(id) ON DELETE CASCADE, iata CHAR(3) REFERENCES iata_codes(iata), latitude DOUBLE PRECISION, longitude DOUBLE PRECISION, reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (observer_id, reported_at) ); CREATE TABLE observer_scopes ( observer_id UUID NOT NULL REFERENCES observers(id) ON DELETE CASCADE, scope_id INT NOT NULL REFERENCES transport_scopes(id) ON DELETE CASCADE, first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (observer_id, scope_id) ); CREATE INDEX idx_observer_locations_recent ON observer_locations(observer_id, reported_at DESC); CREATE TABLE observer_telemetry ( id BIGSERIAL PRIMARY KEY, observer_id UUID NOT NULL REFERENCES observers(id) ON DELETE CASCADE, reported_at TIMESTAMPTZ NOT NULL, battery_voltage_mv INT, airtime_tx_pct REAL, airtime_rx_pct REAL, noise_floor_db REAL, uptime_seconds BIGINT, queue_length INT, debug_flags INT, receive_errors INT, UNIQUE (observer_id, reported_at) ); CREATE INDEX idx_telemetry_reported_brin ON observer_telemetry USING BRIN (reported_at); CREATE INDEX idx_telemetry_observer_recent ON observer_telemetry(observer_id, reported_at DESC); CREATE TABLE observer_owners ( observer_id UUID PRIMARY KEY REFERENCES observers(id) ON DELETE CASCADE, owner_node_id UUID REFERENCES nodes(id), owner_pubkey BYTEA, contact_name TEXT, contact_email TEXT, notes TEXT, source TEXT, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_observer_owners_node ON observer_owners(owner_node_id) WHERE owner_node_id IS NOT NULL; -- ============================================================ -- NODE IATAS AND SHORT IDS -- ============================================================ CREATE TABLE node_iatas ( node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE, first_heard TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_heard TIMESTAMPTZ NOT NULL DEFAULT NOW(), observation_count BIGINT DEFAULT 0, PRIMARY KEY (node_id, iata) ); CREATE INDEX idx_node_iatas_iata ON node_iatas(iata, last_heard DESC); CREATE TABLE node_short_ids ( node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE, prefix_4 BYTEA NOT NULL, prefix_1 BYTEA GENERATED ALWAYS AS (substring(prefix_4 from 1 for 1)) STORED, prefix_2 BYTEA GENERATED ALWAYS AS (substring(prefix_4 from 1 for 2)) STORED, prefix_3 BYTEA GENERATED ALWAYS AS (substring(prefix_4 from 1 for 3)) STORED, PRIMARY KEY (node_id, iata) ); CREATE INDEX idx_short_ids_p1 ON node_short_ids(iata, prefix_1); CREATE INDEX idx_short_ids_p2 ON node_short_ids(iata, prefix_2); CREATE INDEX idx_short_ids_p3 ON node_short_ids(iata, prefix_3); CREATE INDEX idx_short_ids_p4 ON node_short_ids(iata, prefix_4); -- ============================================================ -- PACKETS -- ============================================================ CREATE TABLE packets ( packet_hash BYTEA PRIMARY KEY, payload_type SMALLINT NOT NULL, payload_version SMALLINT NOT NULL, route_type SMALLINT NOT NULL, transport_codes_present BOOLEAN DEFAULT FALSE, region_code INT, sub_region_code INT, scope_id INT REFERENCES transport_scopes(id), origin_pubkey BYTEA, raw_payload BYTEA NOT NULL, raw_header BYTEA NOT NULL, parsed_payload JSONB, decrypted BOOLEAN DEFAULT FALSE, channel_hash BYTEA, trace_tag BYTEA, first_heard_at TIMESTAMPTZ NOT NULL, last_heard_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_packets_first_heard_brin ON packets USING BRIN (first_heard_at); CREATE INDEX idx_packets_payload_type ON packets(payload_type, first_heard_at DESC); CREATE INDEX idx_packets_route_type ON packets(route_type, first_heard_at DESC); CREATE INDEX idx_packets_origin ON packets(origin_pubkey, first_heard_at DESC) WHERE origin_pubkey IS NOT NULL; CREATE INDEX idx_packets_channel ON packets(channel_hash, first_heard_at DESC) WHERE channel_hash IS NOT NULL; CREATE INDEX idx_packets_trace_tag ON packets(trace_tag) WHERE trace_tag IS NOT NULL; -- ============================================================ -- PACKET OBSERVATIONS -- ============================================================ CREATE TABLE packet_observations ( id BIGSERIAL PRIMARY KEY, packet_hash BYTEA NOT NULL REFERENCES packets(packet_hash) ON DELETE CASCADE, observer_id UUID NOT NULL REFERENCES observers(id), iata CHAR(3) NOT NULL REFERENCES iata_codes(iata), heard_at TIMESTAMPTZ NOT NULL, path_length_byte SMALLINT NOT NULL, hash_size SMALLINT NOT NULL, hop_count SMALLINT NOT NULL, path_bytes BYTEA, rssi SMALLINT, snr REAL, propagation_time_ms INT, radio_freq_mhz REAL, spread_factor SMALLINT, bandwidth_khz REAL, coding_rate SMALLINT, source_broker TEXT, UNIQUE (packet_hash, observer_id, heard_at) ); CREATE INDEX idx_observations_heard_brin ON packet_observations USING BRIN (heard_at); CREATE INDEX idx_observations_iata_heard ON packet_observations(iata, heard_at DESC); CREATE INDEX idx_observations_observer ON packet_observations(observer_id, heard_at DESC); CREATE INDEX idx_observations_packet ON packet_observations(packet_hash); -- ============================================================ -- CHANNELS AND CHAT MESSAGES -- ============================================================ CREATE TABLE channels ( id SERIAL PRIMARY KEY, channel_hash BYTEA NOT NULL, -- key_fingerprint is the first 8 bytes of SHA256(key_bytes). -- Combined with channel_hash it uniquely identifies a channel, -- since a 1-byte hash has high collision probability. -- NULL when the key is unknown (hash-only record). key_fingerprint BYTEA, name TEXT, hashtag TEXT UNIQUE, -- e.g. "meshcore"; only set for hashtag-derived channels is_hashtag BOOLEAN DEFAULT FALSE, is_public BOOLEAN DEFAULT FALSE, key_known BOOLEAN DEFAULT FALSE, first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), message_count BIGINT DEFAULT 0, -- Unique on (hash, fingerprint): allows multiple channels with the same -- hash as long as they have different keys. NULL fingerprint is allowed -- once per hash (the unknown-key record). UNIQUE (channel_hash, key_fingerprint) ); CREATE UNIQUE INDEX idx_channels_hash_no_key ON channels (channel_hash) WHERE key_fingerprint IS NULL; CREATE INDEX idx_channels_last_seen ON channels(last_seen DESC); CREATE INDEX idx_channels_hash ON channels(channel_hash); CREATE INDEX idx_channels_hashtag ON channels(hashtag) WHERE hashtag IS NOT NULL; CREATE TABLE channel_keys ( channel_id INT PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, key_bytes BYTEA NOT NULL, key_fingerprint BYTEA NOT NULL, -- first 8 bytes of SHA256(key_bytes) added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), added_by TEXT ); CREATE TABLE channel_messages ( id BIGSERIAL PRIMARY KEY, channel_id INT NOT NULL REFERENCES channels(id), packet_hash BYTEA NOT NULL REFERENCES packets(packet_hash), sender_name TEXT, sender_pubkey BYTEA, content TEXT, sent_at TIMESTAMPTZ NOT NULL, UNIQUE (packet_hash) ); CREATE INDEX idx_channel_messages_channel ON channel_messages(channel_id, sent_at DESC); CREATE INDEX idx_channel_messages_sent_brin ON channel_messages USING BRIN (sent_at); -- ============================================================ -- ROUTES -- ============================================================ CREATE TABLE known_routes ( id BIGSERIAL PRIMARY KEY, node_ids UUID[] NOT NULL, -- resolved node UUIDs in hop order hash_prefix BYTEA[] NOT NULL, -- raw hash bytes in hop order for prefix matching iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE, hop_count INT NOT NULL, first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), observation_count BIGINT NOt NULL DEFAULT 1, UNIQUE (node_ids, iata) ); CREATE INDEX idx_known_routes_iata ON known_routes(iata); CREATE INDEX idx_known_routes_hop_count ON known_routes(iata, hop_count); -- ============================================================ -- NEIGHBORS -- ============================================================ CREATE TABLE node_neighbors ( node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, neighbor_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE, first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), observation_count BIGINT NOT NULL DEFAULT 1, PRIMARY KEY (node_id, neighbor_id, iata) ); CREATE INDEX idx_node_neighbors_node ON node_neighbors(node_id, iata); CREATE INDEX idx_node_neighbors_neighbor ON node_neighbors(neighbor_id, iata); -- ============================================================ -- MATERIALIZED VIEWS -- ============================================================ CREATE MATERIALIZED VIEW mv_hourly_iata_stats AS SELECT iata, date_trunc('hour', heard_at)::timestamptz AS hour, COUNT(*) AS observation_count, COUNT(DISTINCT packet_hash) AS unique_packets, COUNT(DISTINCT observer_id) AS active_observers FROM packet_observations WHERE heard_at > NOW() - INTERVAL '7 days' GROUP BY iata, date_trunc('hour', heard_at); CREATE MATERIALIZED VIEW mv_top_nodes_by_iata AS SELECT ni.iata, ni.node_id, n.name, n.node_type, ni.observation_count, ni.last_heard FROM node_iatas ni JOIN nodes n ON n.id = ni.node_id WHERE ni.last_heard > NOW() - INTERVAL '7 days'; CREATE UNIQUE INDEX idx_mv_top_nodes ON mv_top_nodes_by_iata(iata, node_id); CREATE UNIQUE INDEX idx_mv_hourly_iata_stats ON mv_hourly_iata_stats(iata, hour); CREATE MATERIALIZED VIEW mv_radio_presets AS SELECT concat(o.radio_freq_mhz, ',', o.radio_bw_khz, ',', o.radio_sf)::text AS preset, (SELECT po.iata FROM packet_observations po WHERE po.observer_id = o.id ORDER BY po.heard_at DESC LIMIT 1) AS iata, 'observer' AS source_type, COUNT(*) AS count FROM observers o WHERE o.radio_freq_mhz IS NOT NULL AND o.radio_sf IS NOT NULL AND o.radio_bw_khz IS NOT NULL GROUP BY concat(o.radio_freq_mhz, ',', o.radio_bw_khz, ',', o.radio_sf)::text, (SELECT po.iata FROM packet_observations po WHERE po.observer_id = o.id ORDER BY po.heard_at DESC LIMIT 1) HAVING (SELECT po.iata FROM packet_observations po WHERE po.observer_id = o.id ORDER BY po.heard_at DESC LIMIT 1) IS NOT NULL UNION ALL SELECT concat(n.radio_freq_mhz, ',', n.radio_bw_khz, ',', n.radio_sf) AS preset, ni.iata, 'node' AS source_type, COUNT(*) AS count FROM nodes n JOIN node_iatas ni ON ni.node_id = n.id WHERE n.radio_freq_mhz IS NOT NULL AND n.radio_sf IS NOT NULL AND n.radio_bw_khz IS NOT NULL GROUP BY concat(n.radio_freq_mhz, ',', n.radio_bw_khz, ',', n.radio_sf), ni.iata; CREATE UNIQUE INDEX idx_mv_radio_presets ON mv_radio_presets(preset, iata, source_type);