# `run_sql` — SQL na DuckDB grafu Nostr

Wykonujesz zapytania przez `run_sql(query, params?)`. Daemon ingest pisze
wprost do tej samej bazy — widzisz stan na żywo. Ten plik jest źródłem prawdy
o schemacie; nie ma osobnego dumpu kolumn — gdy struktura się zmienia,
aktualizujesz ten dokument.

**Whitelist (app-level):** dozwolone tylko `SELECT` / `WITH` / `SHOW` /
`PRAGMA` / `EXPLAIN` / `DESCRIBE` / `EXECUTE` oraz
`CREATE [OR REPLACE] TEMP {TABLE|VIEW|MACRO}`. Wszystko inne
(INSERT/UPDATE/DELETE/ALTER/DROP/ATTACH/non-TEMP CREATE) leci `ValueError`
przed wykonaniem — zamierzona siatka bezpieczeństwa, ponieważ daemon trzyma
plik w write-mode. Retry tej samej kwerendy nie pomoże; przepisz na SELECT.

**TEMP tabele/widoki przeżywają między wywołaniami** w sesji procesu MCP —
używaj jako "zmiennych pamięci" do dzielenia długich potoków. Restart MCP =
czyszczone.

`params` to pozycyjna lista do `?` placeholderów:
`run_sql(query='SELECT * FROM events WHERE kind=?', params=[1])`. Używaj
zawsze gdy wartość pochodzi spoza zaufanego źródła (input użytkownika,
treść eventu) — nie skleiaj stringów. Zwraca wiersze jako listę dictów.
Multi-statement (`;`) wykonuje się sekwencyjnie, wynik = ostatni SELECT.

## Schemat — 11 relacji (10 tabel + 1 widok)

Konwencja: `kolumna PK` = primary key, `kol → tabela.kol` = logiczny FK
(brak deklarowanych constraintów w DB, ale tak rób JOIN).

- `events(id PK, kind, created_at, content, sig, raw, pubkey → pubkeys.hex)` —
  surowy event NIP-01. `kind IS NULL` = stub (referencja z `e`-tagu innego
  eventu, którego nie zdążyliśmy ściągnąć — bez contentu, bez autora).
  `created_at` w **sekundach** unix. `pubkey` to hex autora (nazwa kolumny
  jest zwykłym `pubkey`, nie `author_hex`).
- `pubkeys(hex PK, relay_list_ts)` — katalog hexów wszystkich pubkeyów,
  które w ogóle pojawiły się w bazie. `relay_list_ts` = `created_at`
  ostatniego widzianego kind-10002 (NIP-65) tego pubkeya, w sekundach.
- `profiles(pubkey PK → pubkeys.hex, created_at, raw, name, display_name,
  about, picture, banner, nip05, lud16, lud06, website)` — kind-0
  zdenormalizowany do kolumn (z `raw` = oryginalny JSON content). **Nie każdy
  pubkey ma profil** (LEFT JOIN). `created_at` w sekundach.
- `relays(url PK, status, last_checked_at, last_error, active,
  consecutive_failures, discovery_picks, discovery_rejections,
  invalid_events_count)` — stan każdego znanego relay-a.
  `last_checked_at` w **sekundach** (unix). `status ∈ {'ok','error', NULL}`
  (NULL = nigdy nie próbowaliśmy się połączyć). `active` = czy reconcile
  daemon próbuje trzymać WS otwarte. `consecutive_failures` resetuje się
  do 0 na każdy `'ok'`. `discovery_picks/rejections` to liczniki z PHASE 2
  greedy-cover discovery (auto-disable po 100 odrzuceń przy 0 wyborów).
  `invalid_events_count` = ile eventów odrzuconych przez weryfikację
  podpisu/id z tego relay-a (1 = auto-disable).
- `fake_events(received_at, relay_url, claimed_id, claimed_pubkey, reason,
  raw)` — append-only forensics log eventów, które nie przeszły NIP-01
  (sha256(id) lub schnorr sig). `reason ∈ {'malformed','id_mismatch','bad_sig'}`.
  `received_at` w **sekundach** (unix). Bez PK — ten sam fałszywy id z różnych
  relayów daje wiele wierszy.
- `event_tags(event_id → events.id, tag_idx, elem_idx, name, value)`
  PK `(event_id, tag_idx, elem_idx)` — **płaski, jednorodny store tagów**.
  Jeden wiersz na element NIP-01: `tag_idx` = który tag w evencie,
  `elem_idx` = pozycja elementu wewnątrz tagu. **Brak polimorfizmu** — żaden
  tag nie jest traktowany inaczej niż inny; nie ma typowania referencji.
  `name` to litera tagu (`'p'`, `'e'`, `'a'`, `'t'`, `'d'`, …). **Wartość
  główna** tagu (pubkey dla `'p'`, id dla `'e'`, treść dla `'title'`) jest
  na `elem_idx=1`; `elem_idx=0` powtarza `name` (NIP-01 array[0]); pozycje
  2+ to dodatki (relay hint, marker, rola). Graf budujesz joinem po
  `value`: `WHERE name='p' AND elem_idx=1` + `JOIN pubkeys ON hex = value`
  (analogicznie `events.id` / `relays.url`). Filtruj po literach
  (`WHERE name='p'`), **NIE** `'pubkey'` ani `'event'`.
- `addressables(value, kind, pubkey, d)` — **WIDOK** nad `event_tags`, nie
  tabela. NIP-33 coordinate `kind:pubkey:d` rozcinany przy odczycie (z
  `name='a' AND elem_idx=1`): `value` = pełny coordinate, pozostałe trzy
  kolumny to jego rozparsowane człony. Tylko dobrze uformowane coordinaty
  (numeryczny `kind`, dwa dwukropki) trafiają do widoku.
- `event_seen_on(event_id, relay_url)` PK `(event_id, relay_url)` — set
  par "ten event był ściągnięty z tego relaya". Bez timestampu — to zbiór,
  nie audit log; duplikatowe dostawy z tego samego relaya nie nabijają wpisów.
- `pubkey_relays(pubkey, relay_url, read BOOL, write BOOL)` PK
  `(pubkey, relay_url)` — NIP-65 outbox/inbox autora, jedna para na relay.
  `write` = gdzie publikuje (outbox), `read` = skąd czyta (inbox); relay bez
  markera w `r`-tagu ma oba `TRUE`. Filtruj `WHERE write` / `WHERE read`.
  Wpisy z kind-10002. (Zastąpił dawne `pubkey_publishes_to` /
  `pubkey_reads_from`.)
- `live_coverage(pubkey, relay_url, direction, covered_through)` PK
  `(pubkey, relay_url, direction)` — dokąd (w sekundach) *wiemy*, że
  nadążyliśmy z live-subem dla danego konta na danym relayu. `direction ∈
  {'out','in'}`: `out` = wpisy konta (`authors`), `in` = wzmianki (`#p`).
  Źródło `since` przy zakładaniu nasłuchu — niezależne od daty ostatniego
  eventu (cichy autor nie cofa okna). Zapisywane przez cykliczny flush +
  przy wyłączeniu; przy odczycie cofane o margines bezpieczeństwa.
- `tracked_pubkeys(hex PK → pubkeys.hex, added_at, last_scan_at,
  limit_per_relay, productive_relays VARCHAR[], role)` — pubkeye, do
  których trzymamy live subskrypcję. `role ∈ {'primary','secondary'}`:
  **primary** = jawnie zasubskrybowany przez `subscribe_to_pubkey`, jego
  `p`-tagi rozszerzają frontier; **secondary** = wykryty z `p`-taga
  jakiegoś primary, sam pobierany w pełni, ale jego `p`-tagi NIE generują
  kolejnych wpisów (1-hop frontier). `last_scan_at IS NULL` = auto-tracker
  jeszcze nie wykonał discovery na tym wierszu. `productive_relays` =
  lista ≤3 relayów wybranych przez greedy-cover discovery; live-suby
  trzymane na dokładnie tych URL-ach.

## Indeksy (do optymalizacji JOIN-ów / filtrów)

`events.pubkey`, `events.kind`, `events.created_at`, `event_tags.event_id`,
`event_tags(name, value)`. Ten jeden composite index obsługuje wszystkie
lookupy po tagach tak samo (graf `p`→pubkey, `e`→event, filtry `title`/
`status`). PK są indeksowane automatycznie.

## Twarde reguły — złamanie = cicho błędne wyniki

1. **Wszystkie timestampy w sekundach unix.** Jednolicie w całej bazie —
   `events.created_at`, `profiles.created_at`, `pubkeys.relay_list_ts`,
   `relays.last_checked_at`, `fake_events.received_at`,
   `tracked_pubkeys.added_at`/`last_scan_at`. Porównujesz i formatujesz bez
   konwersji jednostek (`to_timestamp(col)`). `last_scan_at` może być `NULL`
   (nieskanowany) albo `-1` (skan w toku) — to sentinel, nie czas.

2. **p-tag ≠ interakcja.** Kindy strukturalne (3 = follows, 10000 = mute,
   10002 = relay list, itd.) tagują pubkeye z przyczyn systemowych, nie
   konwersacyjnych. Realne interakcje żyją w kindach 1 (notatka/odpowiedź),
   6 (repost), 7 (reakcja), 1111 (komentarz), 1311 (live chat). Dla pytań
   o interakcje/wzmianki/rozmowy filtruj `e.kind IN (1, 6, 7, 1111, 1311)`.

3. **Stuby i brakujące profile.** Dla zapytań analitycznych zwykle
   `WHERE e.kind IS NOT NULL` (stuby nie mają contentu ani autora). Do
   każdego pubkey **zawsze `LEFT JOIN profiles`**, wyświetlaj
   `coalesce(pr.display_name, pr.name, substr(hex, 1, 8))`.

4. **Jedno zapytanie zamiast łańcucha.** „Znajdź X, policz/pokaż Y dla X"
   rób jednym SELECT-em z JOIN na identyfikatorze — nie dwoma roundtripami
   (lookup + agregacja). Osobny SELECT tylko gdy krok drugi *logicznie
   zależy od zawartości* wyniku pierwszego (np. parsowanie `content`), nie
   od samego jego istnienia.

5. **Eksploruj przed filtrowaniem nieznanego wymiaru.** Zero wyników dla
   pytania, które intuicyjnie powinno coś zwrócić, prawie zawsze jest
   bugiem zapytania, nie prawdą o danych. Zanim filtrujesz po kategorii /
   kind / nazwie tagu słowem kluczowym, najpierw zobacz realny słownik
   wartości:
   ```sql
   SELECT col, count(*) AS n FROM t GROUP BY 1 ORDER BY n DESC LIMIT 50
   ```

6. **Zawsze `LIMIT`** dla zapytań eksploracyjnych. Cały result-set wraca w
   odpowiedzi narzędzia — `SELECT *` po `events` (≈760k wierszy) zje kontekst
   rozmowy i kosztuje tokeny po obu stronach. Dla rozeznania zacznij od
   `LIMIT 50`; agreguj (`count`, `GROUP BY`) zamiast pobierać surowe wiersze
   gdy się da.

7. **Czasy w wyjściu czytelne.** `to_timestamp(created_at)::VARCHAR` —
   wszystkie kolumny czasu są w sekundach (patrz reguła #1), bez `/1000`.

8. **Tag wielo-elementowy — zawsze dopnij `elem_idx`.** W `event_tags` każdy
   element tagu to osobny wiersz z tym samym `name`. **Wartość główna**
   (pubkey w `'p'`, id w `'e'`, treść w `'title'`) jest na `elem_idx=1`;
   `elem_idx=0` powtarza nazwę tagu, pozycje 2+ to dodatki (relay hint,
   marker, rola). Do wartości głównej zawsze `WHERE name='p' AND elem_idx=1`.
   Samo `WHERE name='p'` wciąga też hinty/role z pozycji 2+ → zawyżone,
   zaśmiecone wyniki. Pełny tag z dodatkami rekonstruujesz przez
   `ORDER BY elem_idx` w obrębie jednego `tag_idx`.

9. **Zlepki w jednym elemencie rozcina się przy odczycie.** Niektóre wartości
   pakują kilka pól w jeden string: `'a'` to `kind:pubkey:d` (po dwukropkach;
   `d` może zawierać dwukropki — patrz widok `addressables`), `'imeta'` to
   pary `klucz wartość` (po spacji). Storage trzyma je verbatim — rozbijasz
   je `split_part` / `regexp_extract` w zapytaniu, nie szukasz osobnych kolumn.

## Branching w SQL, nie w kodzie

„If" w SQL robi się deklaratywnie — nie kolejnym zapytaniem:
- brak wartości / fallback → `COALESCE(a, b, c)`
- warunek na kolumnie → `CASE WHEN … THEN … ELSE … END`
- „spróbuj X, jeśli puste — Y" → `WHERE X OR Y` lub `UNION ALL` z priorytetem
- top 1 per grupa → `QUALIFY row_number() OVER (PARTITION BY … ORDER BY … DESC) = 1`
- rekurencja (follows of follows, wątki) → `WITH RECURSIVE`
- brakujące powiązanie → `LEFT JOIN` + `count(*)` zwróci 0, nie NULL

## Wzorzec: interakcje A ↔ B

Dwie CTE (outgoing, incoming) liczące `p`-tagi z obu stron
(`event_tags WHERE name='p' AND elem_idx=1`, `value` = druga strona) +
`FULL OUTER JOIN`. Outgoing: `e.pubkey=A` i `value=B`; incoming odwrotnie.
**Obie CTE filtruj `e.kind IN (1, 6, 7, 1111, 1311)`** (reguła #2) — inaczej
follows/mute/relay-listy (kind 3/10000/10002) policzą się jako „interakcje".

## DuckDB — rzeczy, które łapią LLM-y

- **Krótkie słowa zarezerwowane jako aliasy.** `AT`, `TO`, `FROM`, `AS`, `IS`,
  `IN`, `ON`, `OR`, `OF`, `BY`, `DO`, `IF`, `END`, `CASE`, `WHEN`, `DESC`,
  `ASC`, `ALL`, `ANY`, `NOT`, `AND`. DuckDB rzuci `Parser Error` zwykle
  dopiero w `ORDER BY` / `WHERE`, więc na pierwszy rzut oka zapytanie
  „wygląda dobrze". Aliasy dawaj rozróżnialne (`ts`, `created`, `n`, `cnt`,
  `kind_id`) — nigdy 2-3-literowe słowa angielskie. W razie konieczności
  cytuj: `... AS "at"`.
- `USING (col)` wymaga identycznej nazwy kolumny po obu stronach — nie wspiera
  `USING (a AS b)`. Jak nazwy się różnią, pisz `JOIN … ON a = b`.
- `to_timestamp(sekundy)` zwraca `TIMESTAMP WITH TIME ZONE`; do wyjścia zwykle
  chcesz `::VARCHAR`.
- **Alias w `GROUP BY` przegrywa z kolumną o tej samej nazwie.**
  `SELECT coalesce(display_name, name) AS name … GROUP BY name` — DuckDB
  rozwiąże `name` jako kolumnę `profiles.name`, NIE jako alias z SELECT.
  Wtedy `display_name` (użyte w `coalesce`) nie jest pogrupowane → Binder
  Error. Wyjścia: pełne wyrażenie w `GROUP BY` (`GROUP BY pubkey,
  coalesce(display_name, name)`) albo pozycyjnie (`GROUP BY 1, 2`). Pozycyjny
  wariant jest najbezpieczniejszy gdy aliasy mogą kolidować z kolumnami.

## Gdy brakuje kontekstu

- Słownik wartości kolumny: `SELECT col, count(*) FROM t GROUP BY 1
  ORDER BY 2 DESC LIMIT 50`
- Kanoniczny DDL bootstrapu: `ingest_schema.py:BOOTSTRAP_DDL` (+ widok
  `addressables` w `ingest_schema.py:ADDRESSABLES_VIEW_DDL`)
