projects:robin_net
Differences
This shows you the differences between two versions of the page.
| Both sides previous revisionPrevious revision | |||
| projects:robin_net [2026/03/11 14:14] – freedomotter | projects:robin_net [2026/03/11 15:34] (current) – removed freedomotter | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| - | ====== Robin Net ====== | ||
| - | |||
| - | ===== SQLite Schema ===== | ||
| - | < | ||
| - | PRAGMA foreign_keys = ON; | ||
| - | |||
| - | -- ========================================================= | ||
| - | -- node_info | ||
| - | -- One row per RobinNet node instance | ||
| - | -- ========================================================= | ||
| - | CREATE TABLE IF NOT EXISTS node_info ( | ||
| - | id INTEGER PRIMARY KEY CHECK (id = 1), | ||
| - | |||
| - | node_uuid TEXT NOT NULL UNIQUE, | ||
| - | node_name TEXT NOT NULL UNIQUE, | ||
| - | |||
| - | operator_name TEXT NOT NULL DEFAULT '', | ||
| - | operator_callsign TEXT NOT NULL DEFAULT '', | ||
| - | location_text TEXT NOT NULL DEFAULT '', | ||
| - | |||
| - | transport_profile TEXT NOT NULL DEFAULT ' | ||
| - | |||
| - | created_at TEXT NOT NULL, | ||
| - | updated_at TEXT NOT NULL | ||
| - | ); | ||
| - | |||
| - | -- ========================================================= | ||
| - | -- peers | ||
| - | -- Known neighboring RobinNet nodes | ||
| - | -- ========================================================= | ||
| - | CREATE TABLE IF NOT EXISTS peers ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| - | |||
| - | peer_uuid TEXT, | ||
| - | peer_name TEXT NOT NULL, | ||
| - | base_url TEXT NOT NULL, | ||
| - | transport TEXT NOT NULL DEFAULT ' | ||
| - | |||
| - | enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)), | ||
| - | |||
| - | last_seen_at TEXT, | ||
| - | last_sync_at TEXT, | ||
| - | |||
| - | notes TEXT NOT NULL DEFAULT '', | ||
| - | |||
| - | created_at TEXT NOT NULL, | ||
| - | updated_at TEXT NOT NULL, | ||
| - | |||
| - | UNIQUE(base_url) | ||
| - | ); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_peers_enabled | ||
| - | ON peers(enabled); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_peers_name | ||
| - | ON peers(peer_name); | ||
| - | |||
| - | -- ========================================================= | ||
| - | -- messages | ||
| - | -- Core store-and-forward message table | ||
| - | -- ========================================================= | ||
| - | CREATE TABLE IF NOT EXISTS messages ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| - | |||
| - | msg_uuid TEXT NOT NULL UNIQUE, | ||
| - | msg_hash TEXT NOT NULL, | ||
| - | |||
| - | msg_type TEXT NOT NULL, -- bulletin, direct, status | ||
| - | scope TEXT NOT NULL DEFAULT ' | ||
| - | |||
| - | origin_node_uuid TEXT NOT NULL, | ||
| - | origin_node_name TEXT NOT NULL, | ||
| - | origin_time TEXT NOT NULL, | ||
| - | |||
| - | author TEXT NOT NULL DEFAULT '', | ||
| - | author_callsign TEXT NOT NULL DEFAULT '', | ||
| - | |||
| - | destination TEXT NOT NULL DEFAULT '', | ||
| - | title TEXT NOT NULL, | ||
| - | body TEXT NOT NULL, | ||
| - | |||
| - | created_at TEXT NOT NULL, | ||
| - | expires_at TEXT, | ||
| - | |||
| - | priority INTEGER NOT NULL DEFAULT 2 CHECK (priority BETWEEN 0 AND 9), | ||
| - | |||
| - | status TEXT NOT NULL DEFAULT ' | ||
| - | |||
| - | is_local_only INTEGER NOT NULL DEFAULT 0 CHECK (is_local_only IN (0,1)), | ||
| - | is_rf_eligible INTEGER NOT NULL DEFAULT 0 CHECK (is_rf_eligible IN (0,1)), | ||
| - | |||
| - | imported_from_peer_uuid TEXT, | ||
| - | imported_from_peer_name TEXT, | ||
| - | |||
| - | first_seen_at TEXT NOT NULL, | ||
| - | last_seen_at TEXT NOT NULL, | ||
| - | |||
| - | CHECK (length(trim(title)) > 0), | ||
| - | CHECK (length(msg_uuid) > 0), | ||
| - | CHECK (length(msg_hash) > 0) | ||
| - | ); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_type | ||
| - | ON messages(msg_type); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_scope | ||
| - | ON messages(scope); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_created_at | ||
| - | ON messages(created_at DESC); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_origin_node_uuid | ||
| - | ON messages(origin_node_uuid); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_status | ||
| - | ON messages(status); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_expires_at | ||
| - | ON messages(expires_at); | ||
| - | |||
| - | -- Helpful when checking integrity collisions or reimports | ||
| - | CREATE INDEX IF NOT EXISTS idx_messages_hash | ||
| - | ON messages(msg_hash); | ||
| - | |||
| - | -- ========================================================= | ||
| - | -- message_trace | ||
| - | -- Per-message trail of where a message was seen/ | ||
| - | -- Useful for troubleshooting and future RF accountability | ||
| - | -- ========================================================= | ||
| - | CREATE TABLE IF NOT EXISTS message_trace ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| - | |||
| - | msg_uuid TEXT NOT NULL, | ||
| - | event_type TEXT NOT NULL, -- created, imported, exported, synced, rejected | ||
| - | |||
| - | event_time TEXT NOT NULL, | ||
| - | |||
| - | local_node_uuid TEXT NOT NULL, | ||
| - | local_node_name TEXT NOT NULL, | ||
| - | |||
| - | peer_uuid TEXT, | ||
| - | peer_name TEXT, | ||
| - | |||
| - | detail TEXT NOT NULL DEFAULT '', | ||
| - | |||
| - | FOREIGN KEY (msg_uuid) REFERENCES messages(msg_uuid) ON DELETE CASCADE | ||
| - | ); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_message_trace_msg_uuid | ||
| - | ON message_trace(msg_uuid); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_message_trace_event_time | ||
| - | ON message_trace(event_time DESC); | ||
| - | |||
| - | -- ========================================================= | ||
| - | -- sync_state | ||
| - | -- Tracks per-peer sync knowledge so you can avoid repeating work | ||
| - | -- ========================================================= | ||
| - | CREATE TABLE IF NOT EXISTS sync_state ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| - | |||
| - | peer_id INTEGER NOT NULL, | ||
| - | msg_uuid TEXT NOT NULL, | ||
| - | |||
| - | delivery_state TEXT NOT NULL DEFAULT ' | ||
| - | attempt_count INTEGER NOT NULL DEFAULT 0, | ||
| - | |||
| - | first_attempt_at TEXT, | ||
| - | last_attempt_at TEXT, | ||
| - | delivered_at TEXT, | ||
| - | |||
| - | last_error TEXT NOT NULL DEFAULT '', | ||
| - | |||
| - | created_at TEXT NOT NULL, | ||
| - | updated_at TEXT NOT NULL, | ||
| - | |||
| - | FOREIGN KEY (peer_id) REFERENCES peers(id) ON DELETE CASCADE, | ||
| - | FOREIGN KEY (msg_uuid) REFERENCES messages(msg_uuid) ON DELETE CASCADE, | ||
| - | |||
| - | UNIQUE(peer_id, | ||
| - | ); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_sync_state_peer_id | ||
| - | ON sync_state(peer_id); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_sync_state_msg_uuid | ||
| - | ON sync_state(msg_uuid); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_sync_state_delivery_state | ||
| - | ON sync_state(delivery_state); | ||
| - | |||
| - | -- ========================================================= | ||
| - | -- local_settings | ||
| - | -- Small key/value table for future expansion | ||
| - | -- ========================================================= | ||
| - | CREATE TABLE IF NOT EXISTS local_settings ( | ||
| - | setting_key TEXT PRIMARY KEY, | ||
| - | setting_value TEXT NOT NULL, | ||
| - | updated_at TEXT NOT NULL | ||
| - | ); | ||
| - | </ | ||
| - | |||
| - | |||
| - | This gives you the basics without boxing you in later. | ||
| - | |||
| - | node_info | ||
| - | |||
| - | Stores the identity of the current RobinNet node. | ||
| - | |||
| - | You only ever want one row here, so I used: | ||
| - | |||
| - | id INTEGER PRIMARY KEY CHECK (id = 1) | ||
| - | |||
| - | That makes it easy to enforce “single node identity per DB.” | ||
| - | |||
| - | peers | ||
| - | |||
| - | Stores neighboring nodes. | ||
| - | |||
| - | Each peer has: | ||
| - | |||
| - | friendly name | ||
| - | |||
| - | base URL | ||
| - | |||
| - | transport type | ||
| - | |||
| - | enabled flag | ||
| - | |||
| - | last seen / last sync timestamps | ||
| - | |||
| - | That’s enough for LAN testing and later expansion. | ||
| - | |||
| - | messages | ||
| - | |||
| - | This is the heart of RobinNet. | ||
| - | |||
| - | Important fields: | ||
| - | |||
| - | msg_uuid → global tracking ID | ||
| - | |||
| - | msg_hash → SHA256 integrity hash | ||
| - | |||
| - | origin_node_uuid / origin_node_name → where it started | ||
| - | |||
| - | origin_time → when it started | ||
| - | |||
| - | destination → freeform for now | ||
| - | |||
| - | imported_from_peer_* → last known import source | ||
| - | |||
| - | first_seen_at / last_seen_at → local traceability | ||
| - | |||
| - | That gives you solid “where did this come from?” tracking. | ||
| - | |||
| - | message_trace | ||
| - | |||
| - | This is the piece you were asking for when you mentioned tracking messages. | ||
| - | |||
| - | Instead of only storing one hash and calling it done, this gives you a real event trail like: | ||
| - | |||
| - | created locally | ||
| - | |||
| - | imported from bravo | ||
| - | |||
| - | exported to charlie | ||
| - | |||
| - | rejected because duplicate | ||
| - | |||
| - | later maybe approved for RF | ||
| - | |||
| - | Example rows might look like: | ||
| - | < | ||
| - | msg_uuid: abc123 | ||
| - | event_type: created | ||
| - | local_node_name: | ||
| - | detail: Local bulletin created by operator | ||
| - | </ | ||
| - | |||
| - | < | ||
| - | msg_uuid: abc123 | ||
| - | event_type: imported | ||
| - | local_node_name: | ||
| - | peer_name: alpha | ||
| - | detail: Imported during sync | ||
| - | </ | ||
| - | |||
| - | That is going to be much more useful than only an MD5. | ||
| - | |||
| - | sync_state | ||
| - | |||
| - | Tracks sync per peer per message. | ||
| - | |||
| - | That lets you later say: | ||
| - | |||
| - | message pending to peer X | ||
| - | |||
| - | already sent to peer Y | ||
| - | |||
| - | failed to send to peer Z | ||
| - | |||
| - | Without having to guess from logs. | ||
| - | |||
| - | This is very useful once you move beyond a toy prototype. | ||
| - | |||
| - | local_settings | ||
| - | |||
| - | Small utility table for: | ||
| - | |||
| - | schema version | ||
| - | |||
| - | sync cursor | ||
| - | |||
| - | admin flags | ||
| - | |||
| - | defaults | ||
| - | |||
| - | You’ll be glad it’s there later. | ||
| - | |||
| - | Suggested enum values | ||
| - | |||
| - | These are not SQLite enums, just recommended values you should keep consistent in code. | ||
| - | |||
| - | msg_type | ||
| - | bulletin | ||
| - | direct | ||
| - | status | ||
| - | scope | ||
| - | local | ||
| - | regional | ||
| - | network | ||
| - | status | ||
| - | active | ||
| - | expired | ||
| - | cancelled | ||
| - | event_type in message_trace | ||
| - | created | ||
| - | imported | ||
| - | exported | ||
| - | synced | ||
| - | rejected | ||
| - | expired | ||
| - | delivery_state in sync_state | ||
| - | pending | ||
| - | sent | ||
| - | failed | ||
| - | skipped | ||
| - | Recommended canonical hash input | ||
| - | |||
| - | For the msg_hash, hash only the canonical message content, not local metadata. | ||
| - | |||
| - | I’d recommend this exact field order: | ||
| - | |||
| - | msg_type | ||
| - | scope | ||
| - | origin_node_uuid | ||
| - | origin_node_name | ||
| - | origin_time | ||
| - | author | ||
| - | author_callsign | ||
| - | destination | ||
| - | title | ||
| - | body | ||
| - | expires_at | ||
| - | priority | ||
| - | is_local_only | ||
| - | is_rf_eligible | ||
| - | |||
| - | That way: | ||
| - | |||
| - | the same content always hashes the same | ||
| - | |||
| - | local import/ | ||
| - | |||
| - | tampering is detectable | ||
| - | |||
| - | ===== Suggested Python helper for SHA256 ===== | ||
| - | |||
| - | < | ||
| - | import hashlib | ||
| - | import json | ||
| - | |||
| - | |||
| - | def canonical_message_payload(message: | ||
| - | payload = { | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | " | ||
| - | } | ||
| - | return json.dumps(payload, | ||
| - | |||
| - | |||
| - | def compute_message_hash(message: | ||
| - | canonical = canonical_message_payload(message) | ||
| - | return hashlib.sha256(canonical.encode(" | ||
| - | </ | ||
| - | |||
| - | |||
| - | ===== db.py ===== | ||
| - | < | ||
| - | # robinnet/ | ||
| - | from __future__ import annotations | ||
| - | |||
| - | import sqlite3 | ||
| - | from contextlib import contextmanager | ||
| - | from pathlib import Path | ||
| - | from typing import Any, Iterable | ||
| - | |||
| - | |||
| - | SCHEMA_SQL = """ | ||
| - | PRAGMA foreign_keys = ON; | ||
| - | |||
| - | CREATE TABLE IF NOT EXISTS node_info ( | ||
| - | id INTEGER PRIMARY KEY CHECK (id = 1), | ||
| - | node_uuid TEXT NOT NULL UNIQUE, | ||
| - | node_name TEXT NOT NULL UNIQUE, | ||
| - | operator_name TEXT NOT NULL DEFAULT '', | ||
| - | operator_callsign TEXT NOT NULL DEFAULT '', | ||
| - | location_text TEXT NOT NULL DEFAULT '', | ||
| - | transport_profile TEXT NOT NULL DEFAULT ' | ||
| - | created_at TEXT NOT NULL, | ||
| - | updated_at TEXT NOT NULL | ||
| - | ); | ||
| - | |||
| - | CREATE TABLE IF NOT EXISTS peers ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| - | peer_uuid TEXT, | ||
| - | peer_name TEXT NOT NULL, | ||
| - | base_url TEXT NOT NULL, | ||
| - | transport TEXT NOT NULL DEFAULT ' | ||
| - | enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)), | ||
| - | last_seen_at TEXT, | ||
| - | last_sync_at TEXT, | ||
| - | notes TEXT NOT NULL DEFAULT '', | ||
| - | created_at TEXT NOT NULL, | ||
| - | updated_at TEXT NOT NULL, | ||
| - | UNIQUE(base_url) | ||
| - | ); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_peers_enabled | ||
| - | ON peers(enabled); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_peers_name | ||
| - | ON peers(peer_name); | ||
| - | |||
| - | CREATE TABLE IF NOT EXISTS messages ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| - | msg_uuid TEXT NOT NULL UNIQUE, | ||
| - | msg_hash TEXT NOT NULL, | ||
| - | msg_type TEXT NOT NULL, | ||
| - | scope TEXT NOT NULL DEFAULT ' | ||
| - | origin_node_uuid TEXT NOT NULL, | ||
| - | origin_node_name TEXT NOT NULL, | ||
| - | origin_time TEXT NOT NULL, | ||
| - | author TEXT NOT NULL DEFAULT '', | ||
| - | author_callsign TEXT NOT NULL DEFAULT '', | ||
| - | destination TEXT NOT NULL DEFAULT '', | ||
| - | title TEXT NOT NULL, | ||
| - | body TEXT NOT NULL, | ||
| - | created_at TEXT NOT NULL, | ||
| - | expires_at TEXT, | ||
| - | priority INTEGER NOT NULL DEFAULT 2 CHECK (priority BETWEEN 0 AND 9), | ||
| - | status TEXT NOT NULL DEFAULT ' | ||
| - | is_local_only INTEGER NOT NULL DEFAULT 0 CHECK (is_local_only IN (0,1)), | ||
| - | is_rf_eligible INTEGER NOT NULL DEFAULT 0 CHECK (is_rf_eligible IN (0,1)), | ||
| - | imported_from_peer_uuid TEXT, | ||
| - | imported_from_peer_name TEXT, | ||
| - | first_seen_at TEXT NOT NULL, | ||
| - | last_seen_at TEXT NOT NULL, | ||
| - | CHECK (length(trim(title)) > 0), | ||
| - | CHECK (length(msg_uuid) > 0), | ||
| - | CHECK (length(msg_hash) > 0) | ||
| - | ); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_type | ||
| - | ON messages(msg_type); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_scope | ||
| - | ON messages(scope); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_created_at | ||
| - | ON messages(created_at DESC); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_origin_node_uuid | ||
| - | ON messages(origin_node_uuid); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_status | ||
| - | ON messages(status); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_expires_at | ||
| - | ON messages(expires_at); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_messages_hash | ||
| - | ON messages(msg_hash); | ||
| - | |||
| - | CREATE TABLE IF NOT EXISTS message_trace ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| - | msg_uuid TEXT NOT NULL, | ||
| - | event_type TEXT NOT NULL, | ||
| - | event_time TEXT NOT NULL, | ||
| - | local_node_uuid TEXT NOT NULL, | ||
| - | local_node_name TEXT NOT NULL, | ||
| - | peer_uuid TEXT, | ||
| - | peer_name TEXT, | ||
| - | detail TEXT NOT NULL DEFAULT '', | ||
| - | FOREIGN KEY (msg_uuid) REFERENCES messages(msg_uuid) ON DELETE CASCADE | ||
| - | ); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_message_trace_msg_uuid | ||
| - | ON message_trace(msg_uuid); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_message_trace_event_time | ||
| - | ON message_trace(event_time DESC); | ||
| - | |||
| - | CREATE TABLE IF NOT EXISTS sync_state ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| - | peer_id INTEGER NOT NULL, | ||
| - | msg_uuid TEXT NOT NULL, | ||
| - | delivery_state TEXT NOT NULL DEFAULT ' | ||
| - | attempt_count INTEGER NOT NULL DEFAULT 0, | ||
| - | first_attempt_at TEXT, | ||
| - | last_attempt_at TEXT, | ||
| - | delivered_at TEXT, | ||
| - | last_error TEXT NOT NULL DEFAULT '', | ||
| - | created_at TEXT NOT NULL, | ||
| - | updated_at TEXT NOT NULL, | ||
| - | FOREIGN KEY (peer_id) REFERENCES peers(id) ON DELETE CASCADE, | ||
| - | FOREIGN KEY (msg_uuid) REFERENCES messages(msg_uuid) ON DELETE CASCADE, | ||
| - | UNIQUE(peer_id, | ||
| - | ); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_sync_state_peer_id | ||
| - | ON sync_state(peer_id); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_sync_state_msg_uuid | ||
| - | ON sync_state(msg_uuid); | ||
| - | |||
| - | CREATE INDEX IF NOT EXISTS idx_sync_state_delivery_state | ||
| - | ON sync_state(delivery_state); | ||
| - | |||
| - | CREATE TABLE IF NOT EXISTS local_settings ( | ||
| - | setting_key TEXT PRIMARY KEY, | ||
| - | setting_value TEXT NOT NULL, | ||
| - | updated_at TEXT NOT NULL | ||
| - | ); | ||
| - | """ | ||
| - | |||
| - | |||
| - | class RobinDB: | ||
| - | def __init__(self, | ||
| - | self.db_path = Path(db_path) | ||
| - | |||
| - | @contextmanager | ||
| - | def connect(self) -> Iterable[sqlite3.Connection]: | ||
| - | self.db_path.parent.mkdir(parents=True, | ||
| - | conn = sqlite3.connect(self.db_path) | ||
| - | conn.row_factory = sqlite3.Row | ||
| - | conn.execute(" | ||
| - | try: | ||
| - | yield conn | ||
| - | conn.commit() | ||
| - | except Exception: | ||
| - | conn.rollback() | ||
| - | raise | ||
| - | finally: | ||
| - | conn.close() | ||
| - | |||
| - | def init_db(self) -> None: | ||
| - | with self.connect() as conn: | ||
| - | conn.executescript(SCHEMA_SQL) | ||
| - | conn.execute( | ||
| - | """ | ||
| - | INSERT OR REPLACE INTO local_settings (setting_key, | ||
| - | VALUES (' | ||
| - | """ | ||
| - | ) | ||
| - | |||
| - | # ========================================================= | ||
| - | # Generic helpers | ||
| - | # ========================================================= | ||
| - | def fetch_one(self, | ||
| - | with self.connect() as conn: | ||
| - | row = conn.execute(sql, | ||
| - | return dict(row) if row else None | ||
| - | |||
| - | def fetch_all(self, | ||
| - | with self.connect() as conn: | ||
| - | rows = conn.execute(sql, | ||
| - | return [dict(r) for r in rows] | ||
| - | |||
| - | def execute(self, | ||
| - | with self.connect() as conn: | ||
| - | cur = conn.execute(sql, | ||
| - | return int(cur.lastrowid) | ||
| - | |||
| - | # ========================================================= | ||
| - | # local_settings | ||
| - | # ========================================================= | ||
| - | def get_setting(self, | ||
| - | row = self.fetch_one( | ||
| - | " | ||
| - | (key,), | ||
| - | ) | ||
| - | return None if row is None else str(row[" | ||
| - | |||
| - | def set_setting(self, | ||
| - | with self.connect() as conn: | ||
| - | conn.execute( | ||
| - | """ | ||
| - | INSERT INTO local_settings (setting_key, | ||
| - | VALUES (?, ?, ?) | ||
| - | ON CONFLICT(setting_key) DO UPDATE SET | ||
| - | setting_value = excluded.setting_value, | ||
| - | updated_at = excluded.updated_at | ||
| - | """, | ||
| - | (key, value, updated_at), | ||
| - | ) | ||
| - | |||
| - | # ========================================================= | ||
| - | # node_info | ||
| - | # ========================================================= | ||
| - | def get_node_info(self) -> dict[str, Any] | None: | ||
| - | return self.fetch_one(" | ||
| - | |||
| - | def save_node_info( | ||
| - | self, | ||
| - | *, | ||
| - | node_uuid: str, | ||
| - | node_name: str, | ||
| - | operator_name: | ||
| - | operator_callsign: | ||
| - | location_text: | ||
| - | transport_profile: | ||
| - | created_at: str, | ||
| - | updated_at: str, | ||
| - | ) -> None: | ||
| - | with self.connect() as conn: | ||
| - | existing = conn.execute(" | ||
| - | if existing: | ||
| - | conn.execute( | ||
| - | """ | ||
| - | UPDATE node_info | ||
| - | SET node_uuid = ?, | ||
| - | node_name = ?, | ||
| - | operator_name = ?, | ||
| - | operator_callsign = ?, | ||
| - | location_text = ?, | ||
| - | transport_profile = ?, | ||
| - | updated_at = ? | ||
| - | WHERE id = 1 | ||
| - | """, | ||
| - | ( | ||
| - | node_uuid, | ||
| - | node_name, | ||
| - | operator_name, | ||
| - | operator_callsign, | ||
| - | location_text, | ||
| - | transport_profile, | ||
| - | updated_at, | ||
| - | ), | ||
| - | ) | ||
| - | else: | ||
| - | conn.execute( | ||
| - | """ | ||
| - | INSERT INTO node_info ( | ||
| - | id, | ||
| - | node_uuid, | ||
| - | node_name, | ||
| - | operator_name, | ||
| - | operator_callsign, | ||
| - | location_text, | ||
| - | transport_profile, | ||
| - | created_at, | ||
| - | updated_at | ||
| - | ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| - | """, | ||
| - | ( | ||
| - | node_uuid, | ||
| - | node_name, | ||
| - | operator_name, | ||
| - | operator_callsign, | ||
| - | location_text, | ||
| - | transport_profile, | ||
| - | created_at, | ||
| - | updated_at, | ||
| - | ), | ||
| - | ) | ||
| - | |||
| - | # ========================================================= | ||
| - | # peers | ||
| - | # ========================================================= | ||
| - | def add_peer( | ||
| - | self, | ||
| - | *, | ||
| - | peer_name: str, | ||
| - | base_url: str, | ||
| - | transport: str = " | ||
| - | peer_uuid: str | None = None, | ||
| - | enabled: int = 1, | ||
| - | notes: str = "", | ||
| - | created_at: str, | ||
| - | updated_at: str, | ||
| - | ) -> int: | ||
| - | with self.connect() as conn: | ||
| - | cur = conn.execute( | ||
| - | """ | ||
| - | INSERT INTO peers ( | ||
| - | peer_uuid, | ||
| - | peer_name, | ||
| - | base_url, | ||
| - | transport, | ||
| - | enabled, | ||
| - | notes, | ||
| - | created_at, | ||
| - | updated_at | ||
| - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) | ||
| - | """, | ||
| - | ( | ||
| - | peer_uuid, | ||
| - | peer_name, | ||
| - | base_url, | ||
| - | transport, | ||
| - | int(bool(enabled)), | ||
| - | notes, | ||
| - | created_at, | ||
| - | updated_at, | ||
| - | ), | ||
| - | ) | ||
| - | return int(cur.lastrowid) | ||
| - | |||
| - | def upsert_peer( | ||
| - | self, | ||
| - | *, | ||
| - | peer_name: str, | ||
| - | base_url: str, | ||
| - | transport: str = " | ||
| - | peer_uuid: str | None = None, | ||
| - | enabled: int = 1, | ||
| - | notes: str = "", | ||
| - | updated_at: str, | ||
| - | created_at: str | None = None, | ||
| - | ) -> int: | ||
| - | with self.connect() as conn: | ||
| - | existing = conn.execute( | ||
| - | " | ||
| - | (base_url, | ||
| - | ).fetchone() | ||
| - | |||
| - | if existing: | ||
| - | peer_id = int(existing[" | ||
| - | conn.execute( | ||
| - | """ | ||
| - | UPDATE peers | ||
| - | SET peer_uuid = ?, | ||
| - | peer_name = ?, | ||
| - | transport = ?, | ||
| - | enabled = ?, | ||
| - | notes = ?, | ||
| - | updated_at = ? | ||
| - | WHERE id = ? | ||
| - | """, | ||
| - | ( | ||
| - | peer_uuid, | ||
| - | peer_name, | ||
| - | transport, | ||
| - | int(bool(enabled)), | ||
| - | notes, | ||
| - | updated_at, | ||
| - | peer_id, | ||
| - | ), | ||
| - | ) | ||
| - | return peer_id | ||
| - | |||
| - | if created_at is None: | ||
| - | created_at = updated_at | ||
| - | |||
| - | cur = conn.execute( | ||
| - | """ | ||
| - | INSERT INTO peers ( | ||
| - | peer_uuid, | ||
| - | peer_name, | ||
| - | base_url, | ||
| - | transport, | ||
| - | enabled, | ||
| - | notes, | ||
| - | created_at, | ||
| - | updated_at | ||
| - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) | ||
| - | """, | ||
| - | ( | ||
| - | peer_uuid, | ||
| - | peer_name, | ||
| - | base_url, | ||
| - | transport, | ||
| - | int(bool(enabled)), | ||
| - | notes, | ||
| - | created_at, | ||
| - | updated_at, | ||
| - | ), | ||
| - | ) | ||
| - | return int(cur.lastrowid) | ||
| - | |||
| - | def get_peer_by_id(self, | ||
| - | return self.fetch_one(" | ||
| - | |||
| - | def get_peer_by_url(self, | ||
| - | return self.fetch_one(" | ||
| - | |||
| - | def list_peers(self, | ||
| - | if enabled_only: | ||
| - | return self.fetch_all( | ||
| - | " | ||
| - | ) | ||
| - | return self.fetch_all(" | ||
| - | |||
| - | def update_peer_seen( | ||
| - | self, | ||
| - | *, | ||
| - | peer_id: int, | ||
| - | last_seen_at: | ||
| - | last_sync_at: | ||
| - | updated_at: str, | ||
| - | ) -> None: | ||
| - | with self.connect() as conn: | ||
| - | current = conn.execute( | ||
| - | " | ||
| - | (peer_id,), | ||
| - | ).fetchone() | ||
| - | if not current: | ||
| - | raise ValueError(f" | ||
| - | |||
| - | new_last_seen = last_seen_at if last_seen_at is not None else current[" | ||
| - | new_last_sync = last_sync_at if last_sync_at is not None else current[" | ||
| - | |||
| - | conn.execute( | ||
| - | """ | ||
| - | UPDATE peers | ||
| - | SET last_seen_at = ?, | ||
| - | last_sync_at = ?, | ||
| - | updated_at = ? | ||
| - | WHERE id = ? | ||
| - | """, | ||
| - | (new_last_seen, | ||
| - | ) | ||
| - | |||
| - | def set_peer_enabled(self, | ||
| - | with self.connect() as conn: | ||
| - | conn.execute( | ||
| - | """ | ||
| - | UPDATE peers | ||
| - | SET enabled = ?, | ||
| - | updated_at = ? | ||
| - | WHERE id = ? | ||
| - | """, | ||
| - | (1 if enabled else 0, updated_at, peer_id), | ||
| - | ) | ||
| - | |||
| - | def delete_peer(self, | ||
| - | with self.connect() as conn: | ||
| - | conn.execute(" | ||
| - | |||
| - | # ========================================================= | ||
| - | # messages | ||
| - | # ========================================================= | ||
| - | def create_message( | ||
| - | self, | ||
| - | *, | ||
| - | msg_uuid: str, | ||
| - | msg_hash: str, | ||
| - | msg_type: str, | ||
| - | scope: str, | ||
| - | origin_node_uuid: | ||
| - | origin_node_name: | ||
| - | origin_time: | ||
| - | author: str, | ||
| - | author_callsign: | ||
| - | destination: | ||
| - | title: str, | ||
| - | body: str, | ||
| - | created_at: str, | ||
| - | expires_at: str | None, | ||
| - | priority: int, | ||
| - | status: str, | ||
| - | is_local_only: | ||
| - | is_rf_eligible: | ||
| - | imported_from_peer_uuid: | ||
| - | imported_from_peer_name: | ||
| - | first_seen_at: | ||
| - | last_seen_at: | ||
| - | ) -> int: | ||
| - | with self.connect() as conn: | ||
| - | cur = conn.execute( | ||
| - | """ | ||
| - | INSERT INTO messages ( | ||
| - | msg_uuid, | ||
| - | msg_hash, | ||
| - | msg_type, | ||
| - | scope, | ||
| - | origin_node_uuid, | ||
| - | origin_node_name, | ||
| - | origin_time, | ||
| - | author, | ||
| - | author_callsign, | ||
| - | destination, | ||
| - | title, | ||
| - | body, | ||
| - | created_at, | ||
| - | expires_at, | ||
| - | priority, | ||
| - | status, | ||
| - | is_local_only, | ||
| - | is_rf_eligible, | ||
| - | imported_from_peer_uuid, | ||
| - | imported_from_peer_name, | ||
| - | first_seen_at, | ||
| - | last_seen_at | ||
| - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| - | """, | ||
| - | ( | ||
| - | msg_uuid, | ||
| - | msg_hash, | ||
| - | msg_type, | ||
| - | scope, | ||
| - | origin_node_uuid, | ||
| - | origin_node_name, | ||
| - | origin_time, | ||
| - | author, | ||
| - | author_callsign, | ||
| - | destination, | ||
| - | title, | ||
| - | body, | ||
| - | created_at, | ||
| - | expires_at, | ||
| - | priority, | ||
| - | status, | ||
| - | int(bool(is_local_only)), | ||
| - | int(bool(is_rf_eligible)), | ||
| - | imported_from_peer_uuid, | ||
| - | imported_from_peer_name, | ||
| - | first_seen_at, | ||
| - | last_seen_at, | ||
| - | ), | ||
| - | ) | ||
| - | return int(cur.lastrowid) | ||
| - | |||
| - | def message_exists(self, | ||
| - | row = self.fetch_one( | ||
| - | " | ||
| - | (msg_uuid, | ||
| - | ) | ||
| - | return row is not None | ||
| - | |||
| - | def get_message_by_uuid(self, | ||
| - | return self.fetch_one( | ||
| - | " | ||
| - | (msg_uuid, | ||
| - | ) | ||
| - | |||
| - | def get_message_by_hash(self, | ||
| - | return self.fetch_all( | ||
| - | " | ||
| - | (msg_hash, | ||
| - | ) | ||
| - | |||
| - | def list_messages( | ||
| - | self, | ||
| - | *, | ||
| - | msg_type: str | None = None, | ||
| - | status: str | None = None, | ||
| - | limit: int = 100, | ||
| - | include_expired: | ||
| - | ) -> list[dict[str, | ||
| - | sql = " | ||
| - | params: list[Any] = [] | ||
| - | |||
| - | if msg_type: | ||
| - | sql += " AND msg_type = ?" | ||
| - | params.append(msg_type) | ||
| - | |||
| - | if status: | ||
| - | sql += " AND status = ?" | ||
| - | params.append(status) | ||
| - | |||
| - | if not include_expired: | ||
| - | sql += " AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)" | ||
| - | |||
| - | sql += " ORDER BY created_at DESC LIMIT ?" | ||
| - | params.append(limit) | ||
| - | |||
| - | return self.fetch_all(sql, | ||
| - | |||
| - | def list_message_summaries( | ||
| - | self, | ||
| - | *, | ||
| - | limit: int = 500, | ||
| - | include_local_only: | ||
| - | ) -> list[dict[str, | ||
| - | sql = """ | ||
| - | SELECT | ||
| - | msg_uuid, | ||
| - | msg_hash, | ||
| - | msg_type, | ||
| - | scope, | ||
| - | origin_node_uuid, | ||
| - | origin_node_name, | ||
| - | origin_time, | ||
| - | author, | ||
| - | author_callsign, | ||
| - | destination, | ||
| - | title, | ||
| - | created_at, | ||
| - | expires_at, | ||
| - | priority, | ||
| - | status, | ||
| - | is_local_only, | ||
| - | is_rf_eligible | ||
| - | FROM messages | ||
| - | WHERE status = ' | ||
| - | AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) | ||
| - | """ | ||
| - | params: list[Any] = [] | ||
| - | |||
| - | if not include_local_only: | ||
| - | sql += " AND is_local_only = 0" | ||
| - | |||
| - | sql += " ORDER BY created_at DESC LIMIT ?" | ||
| - | params.append(limit) | ||
| - | |||
| - | return self.fetch_all(sql, | ||
| - | |||
| - | def update_message_last_seen( | ||
| - | self, | ||
| - | *, | ||
| - | msg_uuid: str, | ||
| - | last_seen_at: | ||
| - | imported_from_peer_uuid: | ||
| - | imported_from_peer_name: | ||
| - | ) -> None: | ||
| - | with self.connect() as conn: | ||
| - | conn.execute( | ||
| - | """ | ||
| - | UPDATE messages | ||
| - | SET last_seen_at = ?, | ||
| - | imported_from_peer_uuid = COALESCE(?, imported_from_peer_uuid), | ||
| - | imported_from_peer_name = COALESCE(?, imported_from_peer_name) | ||
| - | WHERE msg_uuid = ? | ||
| - | """, | ||
| - | ( | ||
| - | last_seen_at, | ||
| - | imported_from_peer_uuid, | ||
| - | imported_from_peer_name, | ||
| - | msg_uuid, | ||
| - | ), | ||
| - | ) | ||
| - | |||
| - | def set_message_status(self, | ||
| - | with self.connect() as conn: | ||
| - | conn.execute( | ||
| - | """ | ||
| - | UPDATE messages | ||
| - | SET status = ?, | ||
| - | last_seen_at = ? | ||
| - | WHERE msg_uuid = ? | ||
| - | """, | ||
| - | (status, last_seen_at, | ||
| - | ) | ||
| - | |||
| - | def delete_message(self, | ||
| - | with self.connect() as conn: | ||
| - | conn.execute(" | ||
| - | |||
| - | # ========================================================= | ||
| - | # import helper | ||
| - | # ========================================================= | ||
| - | def import_message_if_new( | ||
| - | self, | ||
| - | *, | ||
| - | message: dict[str, Any], | ||
| - | local_node_uuid: | ||
| - | local_node_name: | ||
| - | event_time: str, | ||
| - | imported_from_peer_uuid: | ||
| - | imported_from_peer_name: | ||
| - | ) -> bool: | ||
| - | existing = self.get_message_by_uuid(message[" | ||
| - | if existing: | ||
| - | self.update_message_last_seen( | ||
| - | msg_uuid=message[" | ||
| - | last_seen_at=event_time, | ||
| - | imported_from_peer_uuid=imported_from_peer_uuid, | ||
| - | imported_from_peer_name=imported_from_peer_name, | ||
| - | ) | ||
| - | self.record_trace_event( | ||
| - | msg_uuid=message[" | ||
| - | event_type=" | ||
| - | event_time=event_time, | ||
| - | local_node_uuid=local_node_uuid, | ||
| - | local_node_name=local_node_name, | ||
| - | peer_uuid=imported_from_peer_uuid, | ||
| - | peer_name=imported_from_peer_name, | ||
| - | detail=" | ||
| - | ) | ||
| - | return False | ||
| - | |||
| - | self.create_message( | ||
| - | msg_uuid=message[" | ||
| - | msg_hash=message[" | ||
| - | msg_type=message[" | ||
| - | scope=message.get(" | ||
| - | origin_node_uuid=message[" | ||
| - | origin_node_name=message[" | ||
| - | origin_time=message[" | ||
| - | author=message.get(" | ||
| - | author_callsign=message.get(" | ||
| - | destination=message.get(" | ||
| - | title=message[" | ||
| - | body=message[" | ||
| - | created_at=message[" | ||
| - | expires_at=message.get(" | ||
| - | priority=int(message.get(" | ||
| - | status=message.get(" | ||
| - | is_local_only=int(bool(message.get(" | ||
| - | is_rf_eligible=int(bool(message.get(" | ||
| - | imported_from_peer_uuid=imported_from_peer_uuid, | ||
| - | imported_from_peer_name=imported_from_peer_name, | ||
| - | first_seen_at=event_time, | ||
| - | last_seen_at=event_time, | ||
| - | ) | ||
| - | |||
| - | self.record_trace_event( | ||
| - | msg_uuid=message[" | ||
| - | event_type=" | ||
| - | event_time=event_time, | ||
| - | local_node_uuid=local_node_uuid, | ||
| - | local_node_name=local_node_name, | ||
| - | peer_uuid=imported_from_peer_uuid, | ||
| - | peer_name=imported_from_peer_name, | ||
| - | detail=" | ||
| - | ) | ||
| - | return True | ||
| - | |||
| - | # ========================================================= | ||
| - | # message_trace | ||
| - | # ========================================================= | ||
| - | def record_trace_event( | ||
| - | self, | ||
| - | *, | ||
| - | msg_uuid: str, | ||
| - | event_type: str, | ||
| - | event_time: str, | ||
| - | local_node_uuid: | ||
| - | local_node_name: | ||
| - | peer_uuid: str | None = None, | ||
| - | peer_name: str | None = None, | ||
| - | detail: str = "", | ||
| - | ) -> int: | ||
| - | with self.connect() as conn: | ||
| - | cur = conn.execute( | ||
| - | """ | ||
| - | INSERT INTO message_trace ( | ||
| - | msg_uuid, | ||
| - | event_type, | ||
| - | event_time, | ||
| - | local_node_uuid, | ||
| - | local_node_name, | ||
| - | peer_uuid, | ||
| - | peer_name, | ||
| - | detail | ||
| - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) | ||
| - | """, | ||
| - | ( | ||
| - | msg_uuid, | ||
| - | event_type, | ||
| - | event_time, | ||
| - | local_node_uuid, | ||
| - | local_node_name, | ||
| - | peer_uuid, | ||
| - | peer_name, | ||
| - | detail, | ||
| - | ), | ||
| - | ) | ||
| - | return int(cur.lastrowid) | ||
| - | |||
| - | def get_message_trace(self, | ||
| - | return self.fetch_all( | ||
| - | """ | ||
| - | SELECT * | ||
| - | FROM message_trace | ||
| - | WHERE msg_uuid = ? | ||
| - | ORDER BY event_time ASC, id ASC | ||
| - | """, | ||
| - | (msg_uuid, | ||
| - | ) | ||
| - | |||
| - | # ========================================================= | ||
| - | # sync_state | ||
| - | # ========================================================= | ||
| - | def get_sync_state(self, | ||
| - | return self.fetch_one( | ||
| - | """ | ||
| - | SELECT * | ||
| - | FROM sync_state | ||
| - | WHERE peer_id = ? AND msg_uuid = ? | ||
| - | """, | ||
| - | (peer_id, msg_uuid), | ||
| - | ) | ||
| - | |||
| - | def upsert_sync_state( | ||
| - | self, | ||
| - | *, | ||
| - | peer_id: int, | ||
| - | msg_uuid: str, | ||
| - | delivery_state: | ||
| - | updated_at: str, | ||
| - | attempt_count: | ||
| - | first_attempt_at: | ||
| - | last_attempt_at: | ||
| - | delivered_at: | ||
| - | last_error: str = "", | ||
| - | created_at: str | None = None, | ||
| - | ) -> int: | ||
| - | with self.connect() as conn: | ||
| - | existing = conn.execute( | ||
| - | """ | ||
| - | SELECT id, attempt_count, | ||
| - | FROM sync_state | ||
| - | WHERE peer_id = ? AND msg_uuid = ? | ||
| - | """, | ||
| - | (peer_id, msg_uuid), | ||
| - | ).fetchone() | ||
| - | |||
| - | if existing: | ||
| - | row_id = int(existing[" | ||
| - | current_attempt_count = int(existing[" | ||
| - | final_attempt_count = current_attempt_count if attempt_count is None else attempt_count | ||
| - | |||
| - | existing_first_attempt = existing[" | ||
| - | final_first_attempt = first_attempt_at if first_attempt_at is not None else existing_first_attempt | ||
| - | |||
| - | conn.execute( | ||
| - | """ | ||
| - | UPDATE sync_state | ||
| - | SET delivery_state = ?, | ||
| - | attempt_count = ?, | ||
| - | first_attempt_at = ?, | ||
| - | last_attempt_at = ?, | ||
| - | delivered_at = ?, | ||
| - | last_error = ?, | ||
| - | updated_at = ? | ||
| - | WHERE id = ? | ||
| - | """, | ||
| - | ( | ||
| - | delivery_state, | ||
| - | final_attempt_count, | ||
| - | final_first_attempt, | ||
| - | last_attempt_at, | ||
| - | delivered_at, | ||
| - | last_error, | ||
| - | updated_at, | ||
| - | row_id, | ||
| - | ), | ||
| - | ) | ||
| - | return row_id | ||
| - | |||
| - | if created_at is None: | ||
| - | created_at = updated_at | ||
| - | |||
| - | if attempt_count is None: | ||
| - | attempt_count = 0 | ||
| - | |||
| - | cur = conn.execute( | ||
| - | """ | ||
| - | INSERT INTO sync_state ( | ||
| - | peer_id, | ||
| - | msg_uuid, | ||
| - | delivery_state, | ||
| - | attempt_count, | ||
| - | first_attempt_at, | ||
| - | last_attempt_at, | ||
| - | delivered_at, | ||
| - | last_error, | ||
| - | created_at, | ||
| - | updated_at | ||
| - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| - | """, | ||
| - | ( | ||
| - | peer_id, | ||
| - | msg_uuid, | ||
| - | delivery_state, | ||
| - | attempt_count, | ||
| - | first_attempt_at, | ||
| - | last_attempt_at, | ||
| - | delivered_at, | ||
| - | last_error, | ||
| - | created_at, | ||
| - | updated_at, | ||
| - | ), | ||
| - | ) | ||
| - | return int(cur.lastrowid) | ||
| - | |||
| - | def mark_sync_pending( | ||
| - | self, | ||
| - | *, | ||
| - | peer_id: int, | ||
| - | msg_uuid: str, | ||
| - | updated_at: str, | ||
| - | ) -> int: | ||
| - | existing = self.get_sync_state(peer_id, | ||
| - | attempt_count = 0 if not existing else int(existing[" | ||
| - | first_attempt_at = None if not existing else existing[" | ||
| - | |||
| - | return self.upsert_sync_state( | ||
| - | peer_id=peer_id, | ||
| - | msg_uuid=msg_uuid, | ||
| - | delivery_state=" | ||
| - | attempt_count=attempt_count, | ||
| - | first_attempt_at=first_attempt_at, | ||
| - | last_attempt_at=None, | ||
| - | delivered_at=None, | ||
| - | last_error="", | ||
| - | updated_at=updated_at, | ||
| - | created_at=updated_at if not existing else None, | ||
| - | ) | ||
| - | |||
| - | def mark_sync_sent( | ||
| - | self, | ||
| - | *, | ||
| - | peer_id: int, | ||
| - | msg_uuid: str, | ||
| - | attempt_time: | ||
| - | ) -> int: | ||
| - | existing = self.get_sync_state(peer_id, | ||
| - | current_attempts = 0 if not existing else int(existing[" | ||
| - | first_attempt = attempt_time if not existing or not existing[" | ||
| - | |||
| - | return self.upsert_sync_state( | ||
| - | peer_id=peer_id, | ||
| - | msg_uuid=msg_uuid, | ||
| - | delivery_state=" | ||
| - | attempt_count=current_attempts + 1, | ||
| - | first_attempt_at=first_attempt, | ||
| - | last_attempt_at=attempt_time, | ||
| - | delivered_at=attempt_time, | ||
| - | last_error="", | ||
| - | updated_at=attempt_time, | ||
| - | created_at=attempt_time if not existing else None, | ||
| - | ) | ||
| - | |||
| - | def mark_sync_failed( | ||
| - | self, | ||
| - | *, | ||
| - | peer_id: int, | ||
| - | msg_uuid: str, | ||
| - | attempt_time: | ||
| - | error_text: str, | ||
| - | ) -> int: | ||
| - | existing = self.get_sync_state(peer_id, | ||
| - | current_attempts = 0 if not existing else int(existing[" | ||
| - | first_attempt = attempt_time if not existing or not existing[" | ||
| - | |||
| - | return self.upsert_sync_state( | ||
| - | peer_id=peer_id, | ||
| - | msg_uuid=msg_uuid, | ||
| - | delivery_state=" | ||
| - | attempt_count=current_attempts + 1, | ||
| - | first_attempt_at=first_attempt, | ||
| - | last_attempt_at=attempt_time, | ||
| - | delivered_at=None, | ||
| - | last_error=error_text, | ||
| - | updated_at=attempt_time, | ||
| - | created_at=attempt_time if not existing else None, | ||
| - | ) | ||
| - | |||
| - | def list_sync_queue( | ||
| - | self, | ||
| - | *, | ||
| - | peer_id: int | None = None, | ||
| - | delivery_state: | ||
| - | limit: int = 200, | ||
| - | ) -> list[dict[str, | ||
| - | sql = """ | ||
| - | SELECT | ||
| - | s.*, | ||
| - | p.peer_name, | ||
| - | p.base_url, | ||
| - | m.title, | ||
| - | m.msg_type, | ||
| - | m.created_at AS message_created_at | ||
| - | FROM sync_state s | ||
| - | JOIN peers p ON p.id = s.peer_id | ||
| - | JOIN messages m ON m.msg_uuid = s.msg_uuid | ||
| - | WHERE 1=1 | ||
| - | """ | ||
| - | params: list[Any] = [] | ||
| - | |||
| - | if peer_id is not None: | ||
| - | sql += " AND s.peer_id = ?" | ||
| - | params.append(peer_id) | ||
| - | |||
| - | if delivery_state is not None: | ||
| - | sql += " AND s.delivery_state = ?" | ||
| - | params.append(delivery_state) | ||
| - | |||
| - | sql += " ORDER BY s.updated_at DESC LIMIT ?" | ||
| - | params.append(limit) | ||
| - | |||
| - | return self.fetch_all(sql, | ||
| - | |||
| - | # ========================================================= | ||
| - | # maintenance helpers | ||
| - | # ========================================================= | ||
| - | def expire_messages(self, | ||
| - | with self.connect() as conn: | ||
| - | cur = conn.execute( | ||
| - | """ | ||
| - | UPDATE messages | ||
| - | SET status = ' | ||
| - | last_seen_at = ? | ||
| - | WHERE status = ' | ||
| - | AND expires_at IS NOT NULL | ||
| - | AND expires_at <= ? | ||
| - | """, | ||
| - | (now_iso, now_iso), | ||
| - | ) | ||
| - | return int(cur.rowcount) | ||
| - | |||
| - | def list_exportable_messages(self, | ||
| - | return self.fetch_all( | ||
| - | """ | ||
| - | SELECT * | ||
| - | FROM messages | ||
| - | WHERE status = ' | ||
| - | AND is_local_only = 0 | ||
| - | AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) | ||
| - | ORDER BY created_at DESC | ||
| - | LIMIT ? | ||
| - | """, | ||
| - | (limit,), | ||
| - | ) | ||
| - | </ | ||
| - | ==== Example Usage ==== | ||
| - | < | ||
| - | from robinnet.db import RobinDB | ||
| - | |||
| - | db = RobinDB(" | ||
| - | db.init_db() | ||
| - | |||
| - | db.save_node_info( | ||
| - | node_uuid=" | ||
| - | node_name=" | ||
| - | operator_name=" | ||
| - | operator_callsign="", | ||
| - | location_text=" | ||
| - | transport_profile=" | ||
| - | created_at=" | ||
| - | updated_at=" | ||
| - | ) | ||
| - | |||
| - | node = db.get_node_info() | ||
| - | print(node) | ||
| - | </ | ||
projects/robin_net.1773238445.txt.gz · Last modified: by freedomotter
