Files
nanobot-runtime/results/2026-06-10_remind-skill-sqlite-redesign.md
2026-06-10 06:39:52 +02:00

11 KiB
Raw Blame History

/remind skill — návrh přechodu z YAML na SQLite

1. Proč SQLite

Aspekt YAML (současné) SQLite (navrhované)
Atomicita tmp+rename, žádné transakce BEGINCOMMIT
Query Načíst celý soubor do paměti SELECT s JOIN a indexy
Dedup Externí .reminder_state.json Tabulka reminder_fires
Datové typy Vše string INTEGER, TEXT ISO, CHECK
Edit Chybí (celý záznam se přepisuje) UPDATE / DELETE per sloupec
Testy File-based, side-effects :memory: databáze
Audit Žádný reminder_fires.status + error_message

2. Navrhované schéma

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

-- Hlavní entita -----------------------------------------------------------
CREATE TABLE reminders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    text TEXT NOT NULL,
    enabled INTEGER NOT NULL DEFAULT 1,
    timezone TEXT NOT NULL DEFAULT 'Europe/Prague',
    created_at TEXT NOT NULL,            -- ISO-8601
    updated_at TEXT NOT NULL,            -- ISO-8601
    deleted_at TEXT                      -- soft-delete, NULL = aktivní
);

-- One-time scheduly -------------------------------------------------------
CREATE TABLE schedule_at (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
    at_datetime TEXT NOT NULL,           -- ISO-8601 (lokální čas dle reminders.timezone)
    enabled INTEGER NOT NULL DEFAULT 1
);

-- Recurring cron scheduly -------------------------------------------------
CREATE TABLE schedule_cron (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
    cron_expr TEXT NOT NULL,             -- standardní cron, např. "0 9 * * 1-5"
    enabled INTEGER NOT NULL DEFAULT 1
);

-- Random scheduly ---------------------------------------------------------
CREATE TABLE schedule_random (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
    times_per_day INTEGER NOT NULL,
    window_start_min INTEGER NOT NULL,   -- 0..1439 (minuty od půlnoci)
    window_end_min INTEGER NOT NULL,     -- 0..1440 (výhradně horní mez)
    days_filter TEXT,                    -- např. "1-5", NULL = každý den
    from_date TEXT,                      -- YYYY-MM-DD, NULL = okamžitě
    until_date TEXT,                     -- YYYY-MM-DD, NULL = navždy
    enabled INTEGER NOT NULL DEFAULT 1,

    CHECK(times_per_day >= 1),
    CHECK(window_start_min >= 0 AND window_start_min < 1440),
    CHECK(window_end_min > 0 AND window_end_min <= 1440),
    CHECK(window_start_min < window_end_min)
);

-- Audit / dedup / delivery log --------------------------------------------
CREATE TABLE reminder_fires (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
    schedule_id INTEGER NOT NULL,        -- ID v příslušné schedule_* tabulce
    schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at','cron','random')),
    fire_time TEXT NOT NULL,             -- ISO-8601, plánovaný čas výstřelu
    delivered_at TEXT,                   -- ISO-8601, skutečný čas doručení
    status TEXT NOT NULL DEFAULT 'pending'
        CHECK(status IN ('pending','delivered','failed')),
    error_message TEXT
);

-- Indexy ------------------------------------------------------------------
CREATE INDEX idx_reminders_text ON reminders(text);
CREATE INDEX idx_fire_lookup ON reminder_fires(
    reminder_id, schedule_type, schedule_id, fire_time, status
);
CREATE INDEX idx_at_datetime ON schedule_at(reminder_id, at_datetime);
CREATE INDEX idx_cron_expr ON schedule_cron(reminder_id, cron_expr);

3. Lepší datové typy oproti YAML

Pole (YAML) SQLite sloupec Proč lepší
times_per_day: "5" (string v YAML) times_per_day INTEGER Nativní číselná validace, CHECK constraint
window: "09:00-21:00" (string) window_start_min INTEGER, window_end_min INTEGER Umožňuje matematiku (fire_minute BETWEEN 540 AND 1260), sortable
at: "2026-06-10T10:00:00" at_datetime TEXT Sice stále TEXT, ale ISO formát je porovnatelný a sortable; SQLite nemá nativní datetime
days: "1-5" days_filter TEXT Zůstává TEXT — parsuje se až při běhu; alternativně normalizovat na random_days(day_of_week INT), ale pro 15 položek to je overkill
from / until from_date TEXT, until_date TEXT ISO date je sortable; pro query stačí date <= '2026-06-10'

Poznámka k časům: SQLite nemá nativní DATETIME typ. Doporučuji ukládat jako TEXT v ISO-8601 (např. 2026-06-10T10:00:00+02:00) místo Unix timestampu — je to čitelné, sortable a přímo použitelné s datetime.fromisoformat().

4. Jednotlivé typy časů — proč 1:N a ne jedna tabulka

Současný YAML model:

- text: "water the plants"
  cron_exprs: ["0 19 * * *"]
  random: {times_per_day: 2, window: "08:00-12:00"}

V DB to rozdělíme na jeden řádek reminders + řádky v schedule_cron a schedule_random. Důvody:

  • Normalizace: Každý schedule má svůj životní cyklus — jde zapnout/vypnout, editovat, mazat bez dotyku ostatních.
  • Dedup: reminder_fires odkazuje na konkrétní schedule_id + schedule_type. Víme přesně, který cron nebo random výstřel už byl doručen.
  • Extensibility: Přidání nového typu schedule = nová tabulka, není potřeba migrovat existující data.

5. Dává smysl ukládat cron jako cron string?

Ano.

  • Cron je de facto standard, croniter ho umí parsovat i expandovat (get_prev / get_next).
  • Rozparsování na cron_minute INT, cron_hour INT atd. by ztratilo expresivitu (*/15, L, ranges, step values).
  • Ukládání jako cron string je kompaktní a čitelné.

Random schedule nelze vyjádřit jako cron — je to vlastní algoritmus compute_fire_times(). Proto má samostatnou tabulku s parametry.

6. Dedup a state — z .reminder_state.json do DB

Současný mechanismus:

key = hashlib.sha1(text.encode()).hexdigest()[:8]
last = state.get(key)   # "2026-06-10T09:20:00"

Problém: hash textu je hrubý — změna textu znamená nový key, stejný text = stejný key pro všechny scheduly.

Nový mechanismus v SQLite:

-- Před odesláním:
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = ?
  AND fire_time = ? AND status = 'delivered';
  • Přesná dedup per schedule, ne per text.
  • status = 'failed' umožňuje retry při příštím běhu.
  • error_message zachytí proč Telegram API selhalo.
  • delivered_at je audit trail.

7. Query pro remind_send.py (místo načítání celého YAML)

-- Najít všechny due fires za posledních 60 sekund
SELECT
    r.id AS reminder_id,
    r.text,
    'at' AS schedule_type,
    sa.id AS schedule_id,
    sa.at_datetime AS fire_time
FROM reminders r
JOIN schedule_at sa ON sa.reminder_id = r.id
WHERE r.enabled = 1 AND sa.enabled = 1
  AND sa.at_datetime > datetime('now', '-60 seconds')
  AND sa.at_datetime <= datetime('now')
  AND NOT EXISTS (
      SELECT 1 FROM reminder_fires rf
      WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id
        AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime
        AND rf.status = 'delivered'
  )

UNION ALL

-- Cron: vypočítat v Pythonu přes croniter, ale DB řekne které expr existují
SELECT r.id, r.text, 'cron', sc.id, sc.cron_expr
FROM reminders r
JOIN schedule_cron sc ON sc.reminder_id = r.id
WHERE r.enabled = 1 AND sc.enabled = 1;
-- croniter.get_prev() se provede v Pythonu, pak se porovná s now-60s

UNION ALL

-- Random: všechny aktivní random scheduly
SELECT r.id, r.text, 'random', sr.id, NULL
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND sr.enabled = 1
  AND (sr.from_date IS NULL OR sr.from_date <= date('now'))
  AND (sr.until_date IS NULL OR sr.until_date >= date('now'));
-- compute_fire_times(date.today(), ...) se provede v Pythonu

8. CLI změny

remind_edit.py zachová stejné CLI rozhraní, backend se změní:

Subcommand Změna
list SQL JOIN místo yaml.safe_load + JSON dump
add INSERT INTO reminders + INSERT INTO schedule_* v jedné transakci
remove --keyword SELECT id FROM reminders WHERE text LIKE '%keyword%'DELETE nebo UPDATE deleted_at
nové edit --keyword UPDATE reminders.text nebo přidání/odebrání schedulů
nové enable / disable UPDATE reminders SET enabled = 0/1

9. Další věci k uvážení

9.1 Timezone

  • Všechny at_datetime a fire_time by měly být aware (s offsetem +02:00) nebo explicitně v reminders.timezone.
  • Cron výrazy jsou vždy v lokální čase — croniter běží nad datetime.now(TZ).
  • Doporučení: ukládat jako TEXT s offsetem (2026-06-10T10:00:00+02:00), při query převádět v Pythonu.

9.2 WAL mode

PRAGMA journal_mode = WAL;

Umožní čtení během zápisu. Pro remind_send.py (každou minutu SELECT) + remind_edit.py (občasný INSERT/UPDATE) je to kritické.

9.3 Schema versioning

CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY);
INSERT INTO _schema_version VALUES (1);

Při startu skriptu zkontrolovat verzi a spustit migrace.

9.4 Testování

  • SQLite podporuje :memory: databázi — testy mohou běžet bez file I/O.
  • remind_edit.py dostane parametr --db PATH (default workspace/db/reminders.sqlite).

9.5 Migrace z YAML

Jednorázový skript:

  1. Načíst reminder.yaml
  2. BEGIN TRANSACTION
  3. Pro každý reminder: INSERT INTO reminders → získat lastrowid
  4. Podle polí at / at_times / cron_exprs / random vložit do příslušných schedule tabulek
  5. COMMIT
  6. Přejmenovat reminder.yamlreminder.yaml.bak

9.6 Soft delete vs hard delete

  • deleted_at TEXT místo DELETE FROM reminders — zachová historii a umožní "undo".
  • remind_edit.py remove by default nastaví deleted_at, --hard by provedl skutečný DELETE.

9.7 FTS5 (volitelně)

Pokud bude >100 reminderů, CREATE VIRTUAL TABLE reminders_fts USING fts5(text) urychlí fulltext vyhledávání pro remove --keyword.

9.8 Konfigurace cesty k DB

DEFAULT_DB = Path(__file__).resolve().parent.parent.parent.parent / "db" / "reminders.sqlite"

Podle pravidel v AGENTS.md: Always store SQLite databases under db/*.sqlite.

10. Shrnutí rozhodnutí

Otázka Rozhodnutí
Ukládat cron jako string? Ano — standard, expresivní, croniter to zvládne.
Random do cron stringu? Ne — random je vlastní algoritmus, ukládat parametry.
Jedna tabulka vs schedule tabulky? 3 schedule tabulky (at, cron, random) — 1:N vztah.
Dedup externě nebo v DB? V DBreminder_fires per schedule.
Časy jako TEXT nebo INTEGER? TEXT ISO-8601 — čitelné, sortable, Python-compatible.
Window jako string nebo minuty? INTEGER minuty — umožňuje SQL matematiku.
Hard delete nebo soft delete? Soft delete (deleted_at) — audit trail.
Transakce? Ano — každý add / remove / edit v BEGIN…COMMIT.