diff --git a/plans/remind-sqlite-redesign.md b/plans/remind-sqlite-redesign.md new file mode 100644 index 0000000..f5732b9 --- /dev/null +++ b/plans/remind-sqlite-redesign.md @@ -0,0 +1,161 @@ +# Přechod /remind skillu z YAML na SQLite + +## Kontext + +Současný /remind skill ukládá reminder data do `reminder.yaml` a dedup state do `.reminder_state.json`. YAML má známé problémy: žádné transakce, celý soubor se načítá do paměti, dedup je hrubý (hash textu), chybí audit trail pro CRUD operace. Cílem je přejít na SQLite jako primární storage s textovým logem pro audit. + +## Postup + +### 1. Schéma a inicializace databáze + +**Cesta k DB:** `workspace/db/reminders.sqlite` (podle pravidel v AGENTS.md). + +Vytvořit `skills/remind/scripts/db.py`: +- Funkce `init_db(path)` — spustí CREATE TABLE/INDEX z schématu níže +- Funkce `get_db(path)` — vrací connection s WAL mode a foreign keys +- Funkce `log_operation(operation, reminder_id, details)` — append do `workspace/log/reminder.log` + +Schéma: +```sql +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL CHECK(text <> ''), + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)), + timezone TEXT NOT NULL DEFAULT 'Europe/Prague', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE TABLE IF NOT EXISTS schedule_at ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, + at_datetime TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS schedule_cron ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, + cron_expr TEXT NOT NULL CHECK(cron_expr <> '') +); + +CREATE TABLE IF NOT EXISTS schedule_random ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, + times_per_day INTEGER NOT NULL CHECK(times_per_day >= 1), + window_start INTEGER NOT NULL CHECK(window_start >= 0 AND window_start < 1440), + window_end INTEGER NOT NULL CHECK(window_end > 0 AND window_end <= 1440), + days_filter TEXT, + from_date TEXT, + until_date TEXT, + CHECK(window_start < window_end) +); + +CREATE TABLE IF NOT EXISTS reminder_fires ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE, + schedule_id INTEGER NOT NULL, + schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at', 'cron', 'random')), + fire_time TEXT NOT NULL, + delivered_at TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed')), + error_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_reminders_text ON reminders(text); +CREATE INDEX IF NOT EXISTS idx_at_datetime ON schedule_at(reminder_id, at_datetime); +CREATE INDEX IF NOT EXISTS idx_cron_expr ON schedule_cron(reminder_id, cron_expr); +CREATE INDEX IF NOT EXISTS idx_random_dates ON schedule_random(from_date, until_date); +CREATE INDEX IF NOT EXISTS idx_fire_lookup ON reminder_fires( + reminder_id, schedule_type, schedule_id, fire_time, status +); +``` + +### 2. Přepsat `remind_edit.py` + +Změnit backend z YAML na SQLite, zachovat CLI rozhraní. Všechny operace v jedné SQLite transakci. + +**Subcommandy:** +- `list` — SQL JOIN místo `yaml.safe_load`. Výstup: JSON pole reminderů se scheduly. +- `add` — `INSERT INTO reminders` → získat `lastrowid` → `INSERT INTO schedule_*` podle parametrů. Podporuje kombinaci `at`, `cron`, `random` v jednom reminderu. +- `remove --keyword` — `SELECT id FROM reminders WHERE text LIKE '%keyword%'` → `UPDATE deleted_at = now` (soft delete). Pokud keyword matchne více, vypsat seznam a vyžádat potvrzení. +- `edit --keyword` — `UPDATE reminders.text` nebo přidání/odebrání schedulů. Pokud keyword matchne více, vypsat seznam a vyžádat potvrzení. +- `enable --keyword` / `disable --keyword` — `UPDATE reminders SET enabled = 0/1`. + +**Log formát do `workspace/log/reminder.log`:** +``` +2026-06-10T06:40:00 [ADD] id=42 text="..." at=2026-06-11T09:00:00 +2026-06-10T06:41:00 [REMOVE] id=42 text="..." +2026-06-10T06:42:00 [EDIT] id=42 text="..." +2026-06-10T06:43:00 [ENABLE] id=42 +2026-06-10T06:44:00 [DISABLE] id=42 +``` + +### 3. Přepsat `remind_send.py` + +Změnit z YAML+JSON state na SQLite. Cesta k DB přes argument nebo default `workspace/db/reminders.sqlite`. + +**Algoritmus:** +1. Query pro všechny due fires (at + cron + random) v jednom SELECT s UNION ALL. +2. Pro každý fire: zkontrolovat `reminder_fires` — pokud existuje řádek se stejným `reminder_id + schedule_id + schedule_type + fire_time` a `status = 'delivered'`, přeskočit. +3. Odeslat Telegram notifikaci. +4. `INSERT INTO reminder_fires (... status='delivered', delivered_at=now)`. +5. Při chybě odeslání: `INSERT INTO reminder_fires (... status='failed', error_message=e)` — příští běh retry. +6. Log do `reminder.log`: `2026-06-10T09:00:00 [DELIVER] id=42 text="..."` + +**Cron job** — příkaz zůstává stejný (`uv run skills/remind/scripts/remind_send.py`), skript si sám najde DB. + +**Query pattern (at):** +```sql +SELECT r.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 r.deleted_at IS NULL + 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' + ) +``` + +**Query pattern (cron):** Načíst všechny aktivní cron expr, v Pythonu přes `croniter` vypočítat poslední fire time, porovnat s `now - 60s`. + +**Query pattern (random):** Načíst všechny aktivní random scheduly, v Pythonu přes `compute_fire_times(date.today(), ...)` vypočítat fire times, porovnat s `now - 60s`. + +### 4. Migrační skript + +Vytvořit `scripts/migrate_yaml_to_sqlite.py`: +- Načte `reminder.yaml` +- `BEGIN TRANSACTION` +- Pro každý reminder: `INSERT INTO reminders` → získat `lastrowid` +- Podle polí `at` / `at_times` / `cron_exprs` / `random` vložit do příslušných schedule tabulek +- `COMMIT` +- Přejmenovat `reminder.yaml` → `reminder.yaml.bak` + +### 5. Testy + +- `tests/test_db.py` — inicializace schématu, INSERT/SELECT/UPDATE/DELETE, foreign keys, constraints +- `tests/test_remind_edit.py` — add, remove, list, edit, enable/disable s `:memory:` databází +- `tests/test_remind_send.py` — due detection, dedup, retry failed, log output +- Aktualizovat `tests/test_random_times.py` — stále platný, random_times.py se nemění + +### 6. Aktualizovat SKILL.md a dokumentaci + +- Popsat nové DB storage místo YAML +- Popsat `reminder.log` formát +- Aktualizovat `reminder.example.yaml` na SQL příklady nebo odstranit + +## Ověření + +1. Spustit `pytest skills/remind/tests/` — všechny testy procházejí +2. Spustit migrační skript — `reminder.yaml` se převede, `.bak` vznikne +3. Ručně přidat reminder přes `remind_edit.py add` — ověřit v DB přes `sqlite3` +4. Spustit `remind_send.py` — ověřit delivery a log +5. Ověřit dedup: spustit send dvakrát ve stejné minutě — druhý běh nesmí poslat duplikát +6. Ověřit `remind_edit.py list` — zobrazí všechny aktivní reminder +7. Ověřit `remind_edit.py remove --keyword` — soft delete, záznam zůstane v DB s `deleted_at`