Files
meshcore-bot/modules/db_migrations.py
T
agessaman ebc66992dd feat(neighbors): zero-hop neighbour discovery in packet capture
Port the observer firmware's neighbours feature into the bot's packet capture
service, by way of meshcore-packet-capture (upstream PRs #42/#43). On a long
interval the bot asks which repeaters it hears directly and records each
confirmed link with its measured SNR.

This is the strongest link evidence the bot collects: a first-party RF
measurement between two full 32-byte public keys. Path inference works from
1-3 byte prefixes with no keys, and complete_contact_tracking.hop_count
over-claims zero-hop (800 claimed vs 68 corroborated on the live database).

modules/neighbors_discovery.py keeps upstream's public names so its fixes and
tests stay portable. Two deliberate divergences:

- No command_lock plumbing. _SerializedCommands in modules/core.py already
  serialises and paces every radio command, strictly more than upstream's
  reentrant lock did.
- neighbors_collect_scopes defaults off. Upstream's zero-hop scope probe
  relies on a neighbour not being a known contact; this bot tracks contacts,
  and for a repeater with no stored path the library reaches zero-hop by
  calling change_contact_path() then reset_path() -- mutating the device's
  contact table per neighbour. Scope requests also hold the radio lock for
  their whole round trip (~25s), stalling bot replies. The default cycle is
  one command plus a passive listen window, during which the bot stays
  responsive.

Evidence lands in neighbor_links and neighbor_observations (migration 22)
rather than mesh_connections, which cannot persist provenance. The viewer
exposes it as evidence=neighbors on /api/mesh/edges and a Neighbours Only
mode on the mesh page, with populated public keys and real SNR; confirmed
neighbours also relabel edges in the combined view and count as
provenance-trusted when framing the initial map.

neighbors_enabled is the single switch. Every enabled broker publishes once
it is on (mqttN_neighbors defaults true; set false to hold one back). The
topic derives from each broker's packets topic with the last segment swapped,
so a templated broker gets meshcore/{IATA}/{PUBLIC_KEY}/neighbors -- the
topic the firmware uses -- instead of an unrelated flat one. A derived
location-routed topic is skipped with a warning when no iata is set, rather
than publishing into meshcore/XYZ/... on a shared namespace. Snapshots are
non-retained: heard_secs_ago is relative to publish time, so a retained copy
would read as current days later.

Also adds a DM-gated `neighbors` command (the 12h interval floor makes
waiting for the scheduler impractical), which acks immediately and reports in
a second message once the window closes.

Requires meshcore >= 2.3.8 for send_node_discover_req / req_regions_sync.
2026-08-04 19:24:18 -07:00

903 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Database migration versioning for MeshCore Bot.
Migrations are numbered functions applied exactly once and recorded in the
``schema_version`` table. New installs run all migrations in order;
upgraded installs skip any already-applied version.
Adding a migration
------------------
1. Write a function ``_mNNNN_short_description(cursor)`` below.
2. Append it to ``MIGRATIONS`` as ``(NNNN, "short description", _mNNNN_...)``.
Never modify or remove an existing migration — add a new one instead.
"""
import logging
import re
import sqlite3
from typing import Callable
# ---------------------------------------------------------------------------
# Helper utilities
# ---------------------------------------------------------------------------
VALID_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Allowed column definition pattern: type keyword(s) optionally followed by
# DEFAULT and a literal value. This prevents SQL injection through the
# definition parameter of _add_column().
_VALID_COL_DEF = re.compile(
r"^[A-Z]+(?:\s+[A-Z]+)*" # type name, e.g. "TEXT", "INTEGER", "BOOLEAN"
r"(?:\s+DEFAULT\s+(?:'[^']*'|[0-9.]+|NULL|CURRENT_TIMESTAMP))?" # optional DEFAULT clause
r"$",
re.IGNORECASE,
)
def _validate_ident(name: str, kind: str) -> None:
if not VALID_IDENT.match(name):
raise ValueError(f"Invalid {kind} identifier: {name!r}")
def _table_exists(cursor: sqlite3.Cursor, table: str) -> bool:
_validate_ident(table, "table")
cursor.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(table,),
)
return cursor.fetchone() is not None
def _column_exists(cursor: sqlite3.Cursor, table: str, column: str) -> bool:
"""Return True if *column* already exists in *table*."""
_validate_ident(table, "table")
_validate_ident(column, "column")
cursor.execute(f'PRAGMA table_info("{table}")')
return any(row[1] == column for row in cursor.fetchall())
def _validate_col_definition(definition: str) -> None:
"""Ensure *definition* matches a safe SQLite column definition pattern."""
if not _VALID_COL_DEF.match(definition.strip()):
raise ValueError(f"Invalid column definition: {definition!r}")
def _add_column(
cursor: sqlite3.Cursor, table: str, column: str, definition: str
) -> None:
"""Add *column* to *table* if it does not already exist."""
_validate_ident(table, "table")
_validate_ident(column, "column")
_validate_col_definition(definition)
if not _column_exists(cursor, table, column):
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
# ---------------------------------------------------------------------------
# Individual migrations
# ---------------------------------------------------------------------------
def _m0001_initial_schema(cursor: sqlite3.Cursor) -> None:
"""Create all base tables. No-op for tables that already exist."""
cursor.executescript("""
CREATE TABLE IF NOT EXISTS geocoding_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT UNIQUE NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS generic_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_key TEXT UNIQUE NOT NULL,
cache_value TEXT NOT NULL,
cache_type TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS bot_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS feed_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_type TEXT NOT NULL,
feed_url TEXT NOT NULL,
channel_name TEXT NOT NULL,
feed_name TEXT,
last_item_id TEXT,
last_check_time TIMESTAMP,
check_interval_seconds INTEGER DEFAULT 300,
enabled BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
api_config TEXT,
rss_config TEXT,
UNIQUE(feed_url, channel_name)
);
CREATE TABLE IF NOT EXISTS feed_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL,
item_id TEXT NOT NULL,
item_title TEXT,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
message_sent BOOLEAN DEFAULT 1,
FOREIGN KEY (feed_id) REFERENCES feed_subscriptions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS feed_errors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL,
error_type TEXT NOT NULL,
error_message TEXT,
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP,
FOREIGN KEY (feed_id) REFERENCES feed_subscriptions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS channels (
channel_idx INTEGER PRIMARY KEY,
channel_name TEXT NOT NULL,
channel_type TEXT,
channel_key_hex TEXT,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_idx)
);
CREATE TABLE IF NOT EXISTS channel_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation_type TEXT NOT NULL,
channel_idx INTEGER,
channel_name TEXT,
channel_key_hex TEXT,
status TEXT DEFAULT 'pending',
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS feed_message_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL,
channel_name TEXT NOT NULL,
message TEXT NOT NULL,
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMP,
FOREIGN KEY (feed_id) REFERENCES feed_subscriptions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_geocoding_query ON geocoding_cache(query);
CREATE INDEX IF NOT EXISTS idx_geocoding_expires ON geocoding_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_generic_key ON generic_cache(cache_key);
CREATE INDEX IF NOT EXISTS idx_generic_type ON generic_cache(cache_type);
CREATE INDEX IF NOT EXISTS idx_generic_expires ON generic_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_feed_sub_enabled ON feed_subscriptions(enabled);
CREATE INDEX IF NOT EXISTS idx_feed_sub_type ON feed_subscriptions(feed_type);
CREATE INDEX IF NOT EXISTS idx_feed_sub_last_check ON feed_subscriptions(last_check_time);
CREATE INDEX IF NOT EXISTS idx_feed_act_feed_id ON feed_activity(feed_id);
CREATE INDEX IF NOT EXISTS idx_feed_act_proc_at ON feed_activity(processed_at);
CREATE INDEX IF NOT EXISTS idx_feed_err_feed_id ON feed_errors(feed_id);
CREATE INDEX IF NOT EXISTS idx_feed_err_occur_at ON feed_errors(occurred_at);
CREATE INDEX IF NOT EXISTS idx_feed_err_resolved ON feed_errors(resolved_at);
CREATE INDEX IF NOT EXISTS idx_channels_name ON channels(channel_name);
CREATE INDEX IF NOT EXISTS idx_chan_ops_status ON channel_operations(status, created_at);
CREATE INDEX IF NOT EXISTS idx_fmq_feed_id ON feed_message_queue(feed_id);
CREATE INDEX IF NOT EXISTS idx_fmq_sent_at ON feed_message_queue(sent_at);
""")
def _m0002_feed_subscriptions_output_format(cursor: sqlite3.Cursor) -> None:
"""Add output_format and message_send_interval_seconds to feed_subscriptions."""
_add_column(cursor, "feed_subscriptions", "output_format", "TEXT")
_add_column(
cursor,
"feed_subscriptions",
"message_send_interval_seconds",
"REAL DEFAULT 2.0",
)
def _m0003_feed_subscriptions_filter_sort(cursor: sqlite3.Cursor) -> None:
"""Add filter_config and sort_config to feed_subscriptions."""
_add_column(cursor, "feed_subscriptions", "filter_config", "TEXT")
_add_column(cursor, "feed_subscriptions", "sort_config", "TEXT")
def _m0004_channel_operations_result_processed(cursor: sqlite3.Cursor) -> None:
"""Add result_data and processed_at to channel_operations."""
_add_column(cursor, "channel_operations", "result_data", "TEXT")
_add_column(cursor, "channel_operations", "processed_at", "TIMESTAMP")
def _m0005_feed_message_queue_item_fields(cursor: sqlite3.Cursor) -> None:
"""Add item_id, item_title, and priority to feed_message_queue."""
_add_column(cursor, "feed_message_queue", "item_id", "TEXT")
_add_column(cursor, "feed_message_queue", "item_title", "TEXT")
_add_column(cursor, "feed_message_queue", "priority", "INTEGER DEFAULT 0")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_fmq_priority "
"ON feed_message_queue(priority DESC, queued_at ASC)"
)
def _m0006_channel_operations_payload_data(cursor: sqlite3.Cursor) -> None:
"""Add payload_data to channel_operations for firmware config read/write operations."""
_add_column(cursor, "channel_operations", "payload_data", "TEXT")
# NOTE: Higher-numbered migrations can safely depend on tables created by other
# subsystems (e.g., repeater manager) by checking for table existence and then
# applying idempotent ALTER/CREATE INDEX statements.
def _m0007_packet_stream_table(cursor: sqlite3.Cursor) -> None:
"""Create packet_stream table and indexes (shared DB with web viewer)."""
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS packet_stream (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
data TEXT NOT NULL,
type TEXT NOT NULL
)
"""
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_packet_stream_timestamp ON packet_stream(timestamp)"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_packet_stream_type ON packet_stream(type)")
def _m0008_repeater_tables_optional_columns(cursor: sqlite3.Cursor) -> None:
"""Bring repeater/graph tables up to date if they exist."""
# repeater_contacts: add location columns only if table exists
if _table_exists(cursor, "repeater_contacts"):
for column_name, column_def in [
("latitude", "REAL"),
("longitude", "REAL"),
("city", "TEXT"),
("state", "TEXT"),
("country", "TEXT"),
]:
_add_column(cursor, "repeater_contacts", column_name, column_def)
# complete_contact_tracking: path columns + is_starred and out_bytes_per_hop
if _table_exists(cursor, "complete_contact_tracking"):
for column_name, column_def in [
("out_path", "TEXT"),
("out_path_len", "INTEGER"),
("snr", "REAL"),
("is_starred", "BOOLEAN DEFAULT 0"),
("out_bytes_per_hop", "INTEGER"),
]:
_add_column(cursor, "complete_contact_tracking", column_name, column_def)
# observed_paths: packet_hash + bytes_per_hop
if _table_exists(cursor, "observed_paths"):
for column_name, column_def in [
("packet_hash", "TEXT"),
("bytes_per_hop", "INTEGER"),
]:
_add_column(cursor, "observed_paths", column_name, column_def)
# mesh_connections: graph/viewer columns
if _table_exists(cursor, "mesh_connections"):
for column_name, column_def in [
("from_public_key", "TEXT"),
("to_public_key", "TEXT"),
("avg_hop_position", "REAL"),
("geographic_distance", "REAL"),
]:
_add_column(cursor, "mesh_connections", column_name, column_def)
def _m0009_repeater_optional_indexes(cursor: sqlite3.Cursor) -> None:
"""Create optional indexes for repeater/graph tables if they exist."""
if _table_exists(cursor, "unique_advert_packets"):
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_unique_advert_date_pubkey ON unique_advert_packets(date, public_key)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_unique_advert_hash ON unique_advert_packets(packet_hash)"
)
if _table_exists(cursor, "mesh_connections"):
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_from_prefix ON mesh_connections(from_prefix)"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_to_prefix ON mesh_connections(to_prefix)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_last_seen ON mesh_connections(last_seen)")
if _table_exists(cursor, "observed_paths"):
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_observed_paths_public_key ON observed_paths(public_key, packet_type)"
)
if _column_exists(cursor, "observed_paths", "packet_hash"):
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_observed_paths_packet_hash ON observed_paths(packet_hash) WHERE packet_hash IS NOT NULL"
)
cursor.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_observed_paths_advert_unique ON observed_paths(public_key, path_hex, packet_type) WHERE public_key IS NOT NULL"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_observed_paths_endpoints ON observed_paths(from_prefix, to_prefix, packet_type)"
)
cursor.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_observed_paths_message_unique ON observed_paths(from_prefix, to_prefix, path_hex, packet_type) WHERE public_key IS NULL"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_observed_paths_last_seen ON observed_paths(last_seen)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_observed_paths_type_seen ON observed_paths(packet_type, last_seen)"
)
def _m0010_create_repeater_and_graph_tables(cursor: sqlite3.Cursor) -> None:
"""Create repeater/graph tables used by the web viewer and repeater manager."""
cursor.executescript(
"""
CREATE TABLE IF NOT EXISTS repeater_contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
device_type TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
contact_data TEXT,
latitude REAL,
longitude REAL,
city TEXT,
state TEXT,
country TEXT,
is_active BOOLEAN DEFAULT 1,
purge_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS complete_contact_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL,
device_type TEXT,
first_heard TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_heard TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
advert_count INTEGER DEFAULT 1,
latitude REAL,
longitude REAL,
city TEXT,
state TEXT,
country TEXT,
raw_advert_data TEXT,
signal_strength REAL,
snr REAL,
hop_count INTEGER,
is_currently_tracked BOOLEAN DEFAULT 0,
last_advert_timestamp TIMESTAMP,
location_accuracy REAL,
contact_source TEXT DEFAULT 'advertisement',
out_path TEXT,
out_path_len INTEGER,
out_bytes_per_hop INTEGER,
is_starred INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS daily_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
public_key TEXT NOT NULL,
advert_count INTEGER DEFAULT 1,
first_advert_time TIMESTAMP,
last_advert_time TIMESTAMP,
UNIQUE(date, public_key)
);
CREATE TABLE IF NOT EXISTS unique_advert_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
public_key TEXT NOT NULL,
packet_hash TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, public_key, packet_hash)
);
CREATE TABLE IF NOT EXISTS purging_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
action TEXT NOT NULL,
public_key TEXT NOT NULL,
name TEXT NOT NULL,
reason TEXT
);
CREATE TABLE IF NOT EXISTS mesh_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_prefix TEXT NOT NULL,
to_prefix TEXT NOT NULL,
from_public_key TEXT,
to_public_key TEXT,
observation_count INTEGER DEFAULT 1,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
avg_hop_position REAL,
geographic_distance REAL,
UNIQUE(from_prefix, to_prefix)
);
CREATE TABLE IF NOT EXISTS observed_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT,
packet_hash TEXT,
from_prefix TEXT NOT NULL,
to_prefix TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_length INTEGER NOT NULL,
bytes_per_hop INTEGER,
packet_type TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
observation_count INTEGER DEFAULT 1
);
"""
)
def _m0011_repeater_and_graph_indexes(cursor: sqlite3.Cursor) -> None:
"""Create indexes for repeater/graph tables (safe to run repeatedly)."""
cursor.executescript(
"""
CREATE INDEX IF NOT EXISTS idx_public_key ON repeater_contacts(public_key);
CREATE INDEX IF NOT EXISTS idx_device_type ON repeater_contacts(device_type);
CREATE INDEX IF NOT EXISTS idx_last_seen ON repeater_contacts(last_seen);
CREATE INDEX IF NOT EXISTS idx_is_active ON repeater_contacts(is_active);
CREATE INDEX IF NOT EXISTS idx_complete_public_key ON complete_contact_tracking(public_key);
CREATE INDEX IF NOT EXISTS idx_complete_role ON complete_contact_tracking(role);
CREATE INDEX IF NOT EXISTS idx_complete_last_heard ON complete_contact_tracking(last_heard);
CREATE INDEX IF NOT EXISTS idx_complete_currently_tracked ON complete_contact_tracking(is_currently_tracked);
CREATE INDEX IF NOT EXISTS idx_complete_location ON complete_contact_tracking(latitude, longitude);
CREATE INDEX IF NOT EXISTS idx_complete_role_tracked ON complete_contact_tracking(role, is_currently_tracked);
CREATE INDEX IF NOT EXISTS idx_unique_advert_date_pubkey ON unique_advert_packets(date, public_key);
CREATE INDEX IF NOT EXISTS idx_unique_advert_hash ON unique_advert_packets(packet_hash);
CREATE INDEX IF NOT EXISTS idx_from_prefix ON mesh_connections(from_prefix);
CREATE INDEX IF NOT EXISTS idx_to_prefix ON mesh_connections(to_prefix);
CREATE INDEX IF NOT EXISTS idx_last_seen ON mesh_connections(last_seen);
CREATE INDEX IF NOT EXISTS idx_observed_paths_public_key ON observed_paths(public_key, packet_type);
CREATE INDEX IF NOT EXISTS idx_observed_paths_packet_hash ON observed_paths(packet_hash) WHERE packet_hash IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_observed_paths_advert_unique ON observed_paths(public_key, path_hex, packet_type) WHERE public_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_observed_paths_endpoints ON observed_paths(from_prefix, to_prefix, packet_type);
CREATE UNIQUE INDEX IF NOT EXISTS idx_observed_paths_message_unique ON observed_paths(from_prefix, to_prefix, path_hex, packet_type) WHERE public_key IS NULL;
CREATE INDEX IF NOT EXISTS idx_observed_paths_last_seen ON observed_paths(last_seen);
CREATE INDEX IF NOT EXISTS idx_observed_paths_type_seen ON observed_paths(packet_type, last_seen);
"""
)
def _m0012_purging_log_details_column(cursor: sqlite3.Cursor) -> None:
"""Add details column for newer purging log entries."""
if _table_exists(cursor, "purging_log"):
_add_column(cursor, "purging_log", "details", "TEXT")
def _m0013_observed_paths_advert_covering_index(cursor: sqlite3.Cursor) -> None:
"""Covering index for the contacts-page recent-advert-paths window query.
Lets the ROW_NUMBER() OVER (PARTITION BY public_key ORDER BY last_seen DESC)
scan in the contacts API run as an ordered index scan over advert rows,
avoiding a full materialization + temp B-tree sort of observed_paths.
"""
if not _table_exists(cursor, "observed_paths"):
return
cursor.executescript(
"""
CREATE INDEX IF NOT EXISTS idx_observed_paths_advert_pk_seen
ON observed_paths(public_key, last_seen DESC, path_hex, path_length,
bytes_per_hop, observation_count)
WHERE packet_type = 'advert';
"""
)
def _m0014_observed_paths_multibyte_covering_index(cursor: sqlite3.Cursor) -> None:
"""Partial covering index for the mesh-graph multibyte evidence query.
/api/mesh/edges?evidence=multibyte reads every multi-byte path row
(bytes_per_hop >= 2, roughly a fifth of observed_paths); without an index
that is a full scan of the widest table in the database (~2s at 700k
rows). This partial index covers the query's columns, so it never touches
the table, and last_seen in slot 2 keeps the optional days filter indexed.
"""
if not _table_exists(cursor, "observed_paths"):
return
cursor.executescript(
"""
CREATE INDEX IF NOT EXISTS idx_observed_paths_multibyte
ON observed_paths(bytes_per_hop, last_seen, path_hex,
observation_count, first_seen)
WHERE bytes_per_hop >= 2;
"""
)
def _m0015_channel_operations_claimed_at(cursor: sqlite3.Cursor) -> None:
"""Record when a queued hardware/config operation is durably claimed.
A ``processing`` row is deliberately not auto-requeued: after a process
crash the device may already have applied the operation, so retrying it
automatically could execute a non-idempotent command twice. ``claimed_at``
gives operators enough information to diagnose and explicitly resolve such
an ambiguous operation.
"""
if _table_exists(cursor, "channel_operations"):
_add_column(cursor, "channel_operations", "claimed_at", "TIMESTAMP")
def _m0016_channel_operations_claim_owner(cursor: sqlite3.Cursor) -> None:
"""Persist enough local-process identity to recover only provably dead claims."""
if not _table_exists(cursor, "channel_operations"):
return
_add_column(cursor, "channel_operations", "claim_owner_host", "TEXT")
_add_column(cursor, "channel_operations", "claim_owner_pid", "INTEGER")
_add_column(cursor, "channel_operations", "claim_owner_boot_id", "TEXT")
def _m0017_feed_queue_item_uniqueness(cursor: sqlite3.Cursor) -> None:
"""Deduplicate identifiable queue items and prevent future duplicates.
Blank and NULL item IDs deliberately remain unconstrained: without a stable
provider identifier they cannot safely be treated as the same feed item.
For duplicate valid IDs, retain a sent row when one exists (otherwise the
oldest queued row) so migration does not resurrect already-delivered work.
"""
if not _table_exists(cursor, "feed_message_queue"):
return
cursor.execute(
"""
DELETE FROM feed_message_queue
WHERE item_id IS NOT NULL AND trim(item_id) <> ''
AND id NOT IN (
SELECT COALESCE(
MIN(CASE WHEN sent_at IS NOT NULL THEN id END),
MIN(id)
)
FROM feed_message_queue
WHERE item_id IS NOT NULL AND trim(item_id) <> ''
GROUP BY feed_id, item_id
)
"""
)
cursor.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_fmq_feed_item_unique
ON feed_message_queue(feed_id, item_id)
WHERE item_id IS NOT NULL AND trim(item_id) <> ''
"""
)
def _m0018_dashboard_rollup_tables(cursor: sqlite3.Cursor) -> None:
"""Durable dashboard storage: per-day rollups plus a current-state snapshot.
``daily_rollup`` holds one row per local date for the metrics whose raw
sources are pruned long before the dashboard's 30-day window (message_stats
and friends at 7 days, packet_stream at 3). Signal metrics are stored as
sums and counts rather than means so an arbitrary window re-aggregates
correctly — averaging daily averages weights a quiet day the same as a busy
one. Every numeric column is nullable on purpose: NULL means "no source
data for this day", which the UI must render as a gap, while 0 means "the
source was present and the count really was zero".
``dashboard_snapshot`` is a single row of JSON. The current-state half of
the dashboard is ~40 heterogeneous scalars and small arrays that are read
together and never queried by field, so columnizing it would cost a
migration per new tile for no query benefit.
"""
cursor.executescript(
"""
CREATE TABLE IF NOT EXISTS daily_rollup (
date TEXT PRIMARY KEY,
messages_total INTEGER, messages_dm INTEGER, messages_channel INTEGER,
unique_senders INTEGER, unique_channels INTEGER,
commands_total INTEGER, commands_replied INTEGER, unique_command_users INTEGER,
path_obs_total INTEGER, path_len_max INTEGER, path_len_sum INTEGER,
snr_sum REAL, snr_count INTEGER, rssi_sum REAL, rssi_count INTEGER,
hops_sum INTEGER, hops_count INTEGER,
packets_total INTEGER, packets_flood INTEGER, packets_direct INTEGER,
packets_multibyte INTEGER,
adverts_total INTEGER, nodes_active INTEGER, nodes_new INTEGER,
adverts_from_multibyte INTEGER, adverts_from_singlebyte INTEGER,
contacts_known INTEGER, contacts_tracked INTEGER,
sources_present INTEGER NOT NULL DEFAULT 0,
is_backfilled INTEGER NOT NULL DEFAULT 0,
is_final INTEGER NOT NULL DEFAULT 0,
computed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_daily_rollup_computed ON daily_rollup(computed_at);
CREATE TABLE IF NOT EXISTS dashboard_snapshot (
id INTEGER PRIMARY KEY CHECK (id = 1),
generated_at REAL NOT NULL,
duration_ms INTEGER,
schema_rev INTEGER NOT NULL DEFAULT 1,
payload TEXT NOT NULL
);
"""
)
def _m0019_packet_stream_denorm_dims(cursor: sqlite3.Cursor) -> None:
"""Denormalize the four packet dimensions the dashboard aggregates on.
Counting packets by bytes_per_hop or route type via
``json_extract(data, '$.…')`` is a full scan that parses every row's JSON:
~3s for bytes_per_hop and ~6s for route_type_name on a 178k-row table.
The writer already holds these values as parsed fields, so storing them as
columns removes the parse entirely.
Deliberately no bulk UPDATE here. packet_stream rows average ~1KB, so
widening 178k of them rewrites ~180MB into the WAL inside MigrationRunner's
transaction — a multi-minute stall blocking bot startup on an SD-card Pi.
The dashboard refresher backfills a bounded batch per tick instead, and the
table's 3-day retention makes the gap self-healing regardless.
"""
if not _table_exists(cursor, "packet_stream"):
return
_add_column(cursor, "packet_stream", "route_type_name", "TEXT")
_add_column(cursor, "packet_stream", "payload_type_name", "TEXT")
_add_column(cursor, "packet_stream", "path_len", "INTEGER")
_add_column(cursor, "packet_stream", "bytes_per_hop", "INTEGER")
cursor.executescript(
"""
CREATE INDEX IF NOT EXISTS idx_packet_stream_dims
ON packet_stream(timestamp, bytes_per_hop, route_type_name, payload_type_name)
WHERE type = 'packet';
-- Worklist for the refresher's incremental backfill. Without it, the
-- "any rows left to dimension?" probe is a full table scan that costs
-- ~4.6s on a 180MB packet_stream — and it costs that on every tick
-- *after* the backlog is empty, because finding nothing still means
-- reading everything. The index shrinks toward empty as rows are
-- dimensioned, so the probe converges to a no-op. It must carry
-- ``type`` as well: non-packet rows keep a NULL route type forever and
-- would otherwise leave the index permanently non-empty.
CREATE INDEX IF NOT EXISTS idx_packet_stream_undimensioned
ON packet_stream(type, id)
WHERE route_type_name IS NULL;
"""
)
def _m0020_mesh_connections_last_seen_index(cursor: sqlite3.Cursor) -> None:
"""Add the table-specific time index used by graph windows and retention.
Older migrations attempted to create ``idx_last_seen`` for several tables.
SQLite index names are database-global, so the first table claimed that
name and ``mesh_connections`` was left without its intended index.
"""
if not _table_exists(cursor, "mesh_connections"):
return
# Some older databases already have the generic ``idx_last_seen`` attached
# to this table. Keep that usable index instead of building an identical
# second B-tree (which costs both storage and write amplification).
cursor.execute('PRAGMA index_list("mesh_connections")')
for index_row in cursor.fetchall():
index_name = index_row[1]
is_partial = bool(index_row[4]) if len(index_row) > 4 else False
cursor.execute(
"SELECT name FROM pragma_index_info(?) ORDER BY seqno",
(index_name,),
)
indexed_columns = [row[0] for row in cursor.fetchall()]
if not is_partial and indexed_columns == ["last_seen"]:
return
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_mesh_connections_last_seen
ON mesh_connections(last_seen)
"""
)
def _m0021_daily_rollup_packet_type_encoding(cursor: sqlite3.Cursor) -> None:
"""Record the per-payload-type multibyte split on each rollup day.
``packet_stream`` is pruned at three days, so the dashboard's 30-day
encoding trend cannot be recomputed from it after the fact — the split has
to be written as each day is rolled up, the same accumulate-forward shape
the advert share already uses.
One JSON column rather than a count pair per type: the payload-type
vocabulary belongs to the firmware, not to us, so a type appearing on the
mesh should not need a schema migration to be charted.
"""
if not _table_exists(cursor, "daily_rollup"):
return
_add_column(cursor, "daily_rollup", "packet_type_encoding", "TEXT")
def _m0022_neighbor_tables(cursor: sqlite3.Cursor) -> None:
"""Tables for zero-hop neighbor discovery (see modules/neighbors_discovery.py).
A discover response is the strongest link evidence this codebase has: a
confirmed direct RF reception between two *full* 32-byte public keys, with a
measured SNR. Every other edge source is weaker — ``observed_paths`` carries
13 byte prefixes and no keys, and ``complete_contact_tracking.hop_count``
over-claims zero-hop. So these keep the full keys rather than prefixes, and
stay separate from ``mesh_connections``, which cannot represent provenance
(its ``confirmed_2byte`` flag is memory-only and never persisted).
Two tables because they answer different questions: ``neighbor_links`` is the
current adjacency the mesh graph reads, ``neighbor_observations`` is the
per-cycle history a signal trend needs. SNR is stored as sum+count rather
than a mean, matching ``daily_rollup``, so any window re-aggregates exactly.
Index names are database-global in SQLite (see the note on migration 20), so
every name here is table-qualified.
"""
cursor.executescript(
"""
CREATE TABLE IF NOT EXISTS neighbor_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
self_public_key TEXT NOT NULL,
neighbor_public_key TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
observation_count INTEGER DEFAULT 1,
snr_sum REAL DEFAULT 0,
snr_count INTEGER DEFAULT 0,
best_snr REAL,
last_snr REAL,
last_status TEXT,
scopes TEXT,
UNIQUE(self_public_key, neighbor_public_key)
);
CREATE TABLE IF NOT EXISTS neighbor_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observed_at TIMESTAMP NOT NULL,
self_public_key TEXT NOT NULL,
neighbor_public_key TEXT NOT NULL,
snr REAL,
heard_secs_ago INTEGER,
scopes TEXT,
status TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_neighbor_links_last_seen
ON neighbor_links(last_seen);
CREATE INDEX IF NOT EXISTS idx_neighbor_links_neighbor
ON neighbor_links(neighbor_public_key);
CREATE INDEX IF NOT EXISTS idx_neighbor_observations_observed_at
ON neighbor_observations(observed_at);
CREATE INDEX IF NOT EXISTS idx_neighbor_observations_neighbor
ON neighbor_observations(neighbor_public_key, observed_at);
"""
)
# ---------------------------------------------------------------------------
# Migration registry — append new entries here, never remove or reorder.
# ---------------------------------------------------------------------------
MigrationEntry = tuple[int, str, Callable[[sqlite3.Cursor], None]]
MIGRATIONS: list[MigrationEntry] = [
(1, "initial schema", _m0001_initial_schema),
(2, "feed_subscriptions: output_format, message_send_interval_seconds", _m0002_feed_subscriptions_output_format),
(3, "feed_subscriptions: filter_config, sort_config", _m0003_feed_subscriptions_filter_sort),
(4, "channel_operations: result_data, processed_at", _m0004_channel_operations_result_processed),
(5, "feed_message_queue: item_id, item_title, priority", _m0005_feed_message_queue_item_fields),
(6, "channel_operations: payload_data", _m0006_channel_operations_payload_data),
(7, "packet_stream table for web viewer", _m0007_packet_stream_table),
(8, "optional repeater/graph columns", _m0008_repeater_tables_optional_columns),
(9, "optional repeater/graph indexes", _m0009_repeater_optional_indexes),
(10, "create repeater/graph tables", _m0010_create_repeater_and_graph_tables),
(11, "repeater/graph indexes", _m0011_repeater_and_graph_indexes),
(12, "purging_log: add details column", _m0012_purging_log_details_column),
(13, "observed_paths: advert covering index for contacts page", _m0013_observed_paths_advert_covering_index),
(14, "observed_paths: multibyte covering index for mesh graph", _m0014_observed_paths_multibyte_covering_index),
(15, "channel_operations: claimed_at", _m0015_channel_operations_claimed_at),
(16, "channel_operations: claim owner identity", _m0016_channel_operations_claim_owner),
(17, "feed_message_queue: unique identifiable items", _m0017_feed_queue_item_uniqueness),
(18, "dashboard rollup and snapshot tables", _m0018_dashboard_rollup_tables),
(19, "packet_stream: denormalized packet dimensions", _m0019_packet_stream_denorm_dims),
(20, "mesh_connections: table-specific last_seen index", _m0020_mesh_connections_last_seen_index),
(21, "daily_rollup: per-payload-type multibyte split", _m0021_daily_rollup_packet_type_encoding),
(22, "neighbor discovery tables", _m0022_neighbor_tables),
]
# ---------------------------------------------------------------------------
# Migration runner
# ---------------------------------------------------------------------------
class MigrationRunner:
"""Apply pending numbered migrations to a SQLite connection.
Usage::
with db_manager.connection() as conn:
runner = MigrationRunner(conn, logger)
runner.run()
conn.commit()
"""
def __init__(self, conn: sqlite3.Connection, logger: logging.Logger) -> None:
self.conn = conn
self.logger = logger
def _ensure_version_table(self) -> None:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL,
description TEXT,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Legacy DBs may have been created without a uniqueness constraint.
self.conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_schema_version_version ON schema_version(version)"
)
def _applied_versions(self) -> set[int]:
cursor = self.conn.execute("SELECT version FROM schema_version")
return {int(row[0]) for row in cursor.fetchall() if row and row[0] is not None}
def _validate_versions(self, applied: set[int]) -> None:
known = {v for v, _, _ in MIGRATIONS}
unknown = sorted(v for v in applied if v not in known)
if unknown:
raise RuntimeError(
"Database schema is newer or inconsistent with this codebase. "
f"Unknown applied migration version(s): {unknown}. "
"Upgrade the bot to a newer version that includes these migrations."
)
def _apply(self, version: int, description: str, fn: Callable[[sqlite3.Cursor], None]) -> None:
cursor = self.conn.cursor()
fn(cursor)
cursor.execute(
"INSERT INTO schema_version (version, description) VALUES (?, ?)",
(version, description),
)
self.logger.info(f"DB migration {version:04d} applied: {description}")
def run(self) -> None:
"""Apply all pending migrations in version order."""
self._ensure_version_table()
applied = self._applied_versions()
self._validate_versions(applied)
pending = [(v, d, f) for v, d, f in MIGRATIONS if v not in applied]
pending.sort(key=lambda x: x[0])
if not pending:
self.logger.debug("Database schema is up to date")
return
try:
self.conn.execute("BEGIN IMMEDIATE")
for version, description, fn in pending:
self._apply(version, description, fn)
self.conn.commit()
except Exception:
self.conn.rollback()
raise
self.logger.info(
f"Database migrations complete: {len(pending)} applied, "
f"schema now at version {pending[-1][0]}"
)