plan migrace /remind na sqlite
This commit is contained in:
161
plans/remind-sqlite-redesign.md
Normal file
161
plans/remind-sqlite-redesign.md
Normal file
@@ -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`
|
||||
Reference in New Issue
Block a user