11 KiB
/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
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:
- 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_firesodkazuje 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,
croniterho umí parsovat i expandovat (get_prev/get_next). - Rozparsování na
cron_minute INT,cron_hour INTatd. 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_messagezachytí proč Telegram API selhalo.delivered_atje 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_datetimeafire_timeby měly být aware (s offsetem+02:00) nebo explicitně vreminders.timezone. - Cron výrazy jsou vždy v lokální čase —
croniterběží naddatetime.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.pydostane parametr--db PATH(defaultworkspace/db/reminders.sqlite).
9.5 Migrace z YAML
Jednorázový skript:
- Načíst
reminder.yaml BEGIN TRANSACTION- Pro každý reminder:
INSERT INTO reminders→ získatlastrowid - Podle polí
at/at_times/cron_exprs/randomvložit do příslušných schedule tabulek COMMIT- Přejmenovat
reminder.yaml→reminder.yaml.bak
9.6 Soft delete vs hard delete
deleted_at TEXTmístoDELETE FROM reminders— zachová historii a umožní "undo".remind_edit.py removeby default nastavídeleted_at,--hardby 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 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. |