# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The database shape, kept apart from the code that queries it. Split out of ``store.py`` so both stay under the 250-line cap, and because "what the tables are" and "how the bot reads them" are two things. """ from __future__ import annotations from typing import Final # Bumped whenever the shape below changes. See Store._apply_schema for why # a version number is the only safe way to notice. # # Deliberately NOT bumped when a whole new table is added: CREATE TABLE IF # NOT EXISTS is additive and harmless, whereas a bump drops every table and # would throw away the live tallies to gain nothing. SCHEMA_VERSION: Final = 2 # Every table is keyed by group first. The bot watches several groups, and a # tally, a cooldown or a vote that leaked between them would mean a test # message throttling the production group -- so the scope is in the primary # key rather than in the callers' discipline. SCHEMA: Final = """ CREATE TABLE IF NOT EXISTS command_uses ( group_id TEXT NOT NULL, user_uuid TEXT NOT NULL, command TEXT NOT NULL, used_at REAL NOT NULL, PRIMARY KEY (group_id, user_uuid, command) ); CREATE TABLE IF NOT EXISTS daily_uses ( group_id TEXT NOT NULL, user_uuid TEXT NOT NULL, command TEXT NOT NULL, day TEXT NOT NULL, uses INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (group_id, user_uuid, command, day) ); CREATE TABLE IF NOT EXISTS votes ( group_id TEXT NOT NULL, command TEXT NOT NULL, voter_uuid TEXT NOT NULL, voted_at REAL NOT NULL, PRIMARY KEY (group_id, command, voter_uuid) ); CREATE TABLE IF NOT EXISTS group_meta ( group_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (group_id, key) ); CREATE TABLE IF NOT EXISTS message_events ( group_id TEXT NOT NULL, uuid TEXT NOT NULL, display_name TEXT NOT NULL, sent_at_ms INTEGER NOT NULL, day TEXT NOT NULL, local_hour INTEGER NOT NULL, media_kind TEXT NOT NULL DEFAULT '', media_count INTEGER NOT NULL DEFAULT 0, char_len INTEGER NOT NULL DEFAULT 0, is_command INTEGER NOT NULL DEFAULT 0, is_reply INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (group_id, uuid, sent_at_ms) ); CREATE INDEX IF NOT EXISTS idx_events_group_day ON message_events (group_id, day); CREATE INDEX IF NOT EXISTS idx_events_group_uuid_day ON message_events (group_id, uuid, day); CREATE TABLE IF NOT EXISTS group_people ( group_id TEXT NOT NULL, uuid TEXT NOT NULL, display_name TEXT NOT NULL, number TEXT NOT NULL DEFAULT '', first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, PRIMARY KEY (group_id, uuid) ); """ # Cleared when the bot leaves a group, and rebuilt on a schema-version bump. # Losing any of these costs a cooldown or a vote in progress. EPHEMERAL_TABLES: Final = ( "command_uses", "daily_uses", "votes", "group_meta", ) # Never dropped, by either path. These hold the whole statistical history, # which cannot be recomputed from anything -- so a transient group listing # that omits a member (the exact failure discovery._permitted guards against) # must not be able to delete years of it, and neither must a future schema # bump that only meant to reshape a cooldown table. PERMANENT_TABLES: Final = ( "message_events", "group_people", ) TABLES: Final = EPHEMERAL_TABLES + PERMANENT_TABLES