261 lines
11 KiB
Markdown
261 lines
11 KiB
Markdown
# /remind skill — návrh přechodu z YAML na SQLite
|
||
|
||
## 1. Proč SQLite
|
||
|
||
| Aspekt | YAML (současné) | SQLite (navrhované) |
|
||
|--------|-----------------|---------------------|
|
||
| Atomicita | tmp+rename, žádné transakce | `BEGIN` … `COMMIT` |
|
||
| 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
|
||
|
||
```sql
|
||
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 1–5 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:
|
||
```yaml
|
||
- 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:
|
||
```python
|
||
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:
|
||
```sql
|
||
-- 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)
|
||
|
||
```sql
|
||
-- 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
|
||
```sql
|
||
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
|
||
```sql
|
||
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.yaml` → `reminder.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
|
||
```python
|
||
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 DB** — `reminder_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`. |
|