From bcd3585f09a6038cce75c4cba86722e5714f5fcd Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 31 Aug 2026 16:45:29 +0200 Subject: [PATCH] fix(snapshot): relay-union statt entweder-oder, quorum an events statt antworten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der post 'protocol-anthropology' erschien in der uebersicht, lieferte unter /protocol-anthropology/ aber 404: das event liegt nur auf relay.primal.net, waehrend nos.lol und relay.tchncs.de die uebrigen 27 posts halten und primal diese nicht liefert. loadReadRelays nahm bisher die NIP-65-liste ODER den fallback — je nach codepfad fehlte damit entweder der neue post oder fast alle alten. Jetzt vereinigungsmenge beider listen, normalisiert (trailing slash, case) und dedupliziert. FALLBACK_READ_RELAYS um 'primal.net' und 'relay-rpi.edufeed.org' ergaenzt: das sind andere hosts als 'relay.primal.net'/'relay.edufeed.org', keine schreibweisen — sie stehen so in der echten NIP-65-liste. Das 60%-antwort-quorum ersetzt durch eine untergrenze von 2 relays MIT events. Zwei gruende: der fetcher resolved bei timeout mit leerem array und zaehlt damit als "geantwortet" (die quote mass erreichbarkeit, nicht daten), und mit der groesseren union-liste liefern regulaer mehrere relays 0 events (gemessen: 6 von 11) — das haette ohne echten fehler hard-gefailt. Verifiziert: snapshot schreibt 28 statt 27 posts, prerender erzeugt build/protocol-anthropology/index.html mit korrektem titel und inhalt. 36 tests gruen. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-08-31-nostr-inbound-sync.md | 210 ++++++++++++++++++ snapshot/src/cli.ts | 6 + snapshot/src/core/checks.ts | 20 +- snapshot/src/core/relays.ts | 47 +++- snapshot/tests/checks.test.ts | 25 ++- snapshot/tests/relays.test.ts | 40 +++- 6 files changed, 331 insertions(+), 17 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-31-nostr-inbound-sync.md diff --git a/docs/superpowers/plans/2026-08-31-nostr-inbound-sync.md b/docs/superpowers/plans/2026-08-31-nostr-inbound-sync.md new file mode 100644 index 0000000..6716fb0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-nostr-inbound-sync.md @@ -0,0 +1,210 @@ +# Plan: Inbound-Sync — Nostr-native Posts im Blog und im Repo + +> Status: Entwurf, noch nicht umgesetzt. +> Auslöser: `protocol-anthropology` (naddr…70xst) erschien in der Übersicht, +> lieferte unter `/protocol-anthropology/` aber 404. + +## Problem + +Zwei getrennte Befunde, die zusammen den 404 erzeugen: + +**A — Relay-Abdeckung.** `snapshot/src/core/relays.ts` fragt die NIP-65-Liste +ab (`loadReadRelays`) und fällt nur dann auf `FALLBACK_READ_RELAYS` zurück, +wenn gar kein kind:10002 kommt. Gemessen am 2026-08-31: + +| Relay | kind:30023 | `protocol-anthropology` | +|---|---|---| +| relay.primal.net | 1 | ja | +| nos.lol | 27 | nein | +| relay.tchncs.de | 27 | nein | +| relay.damus.io | 0 | nein | +| relay.edufeed.org | 0 | nein | +| relay.plebstr.com | 0 | nein | + +Das Event liegt nur auf *einem* Relay, und dieses Relay liefert umgekehrt die +anderen 27 Posts nicht. Je nachdem, welche Liste greift, fehlt entweder der +neue Post oder fast alle alten. + +Erschwerend: die NIP-65-Liste (2026-04-24) nennt `wss://primal.net` und +`wss://relay-rpi.edufeed.org`, der Code-Fallback dagegen `wss://relay.primal.net` +und `wss://relay.edufeed.org` — verschiedene Hosts, nicht nur Schreibweisen. + +**B — kein Rückweg Nostr → Repo.** Der Snapshot baut `PostJson` allein aus dem +Event (`buildPostJson`), das Repo bleibt außen vor. Ein extern (Habla/Ditto) +verfasster Post existiert daher nie als `.md` und fehlt im Repo-Archiv. + +**Nicht das Problem:** Die Annahme „der Code kennt nur Markdown aus dem Repo" +trifft für den Build-Pfad nicht zu. `snapshot/src/cli.ts` liest ausschließlich +von Relays. Läge das Event auf `nos.lol`, wäre die Seite ohne jedes `.md` +gebaut worden. Der Nostr-first-Pfad existiert bereits — er ist an der +Relay-Abdeckung gescheitert. + +## Ziel + +1. Nostr-native Longform-Posts erscheinen automatisch im Blog (Phase 1). +2. Sie landen zusätzlich als `.md` im Repo — über einen PR, nicht per + Direkt-Commit (Phase 2). + +## Phase 1 — Relay-Union (behebt den 404) + +### 1.1 `loadReadRelays` auf Vereinigungsmenge umstellen + +`snapshot/src/core/relays.ts`: statt „NIP-65 *oder* Fallback" künftig +„NIP-65 *und* Fallback", dedupliziert. + +- Neue Funktion `normalizeRelayUrl(url)`: Trailing-Slash weg, lowercase, + Schema erhalten. Verhindert, dass `wss://nos.lol/` und `wss://nos.lol` + als zwei Relays zählen. +- `loadReadRelays` gibt `[...new Set([...nip65, ...fallback].map(normalize))]` + zurück. +- `FALLBACK_READ_RELAYS` um die Host-Varianten aus der echten NIP-65-Liste + ergänzen: `wss://primal.net`, `wss://relay-rpi.edufeed.org`. + +Damit wäre `protocol-anthropology` gefunden worden. + +### 1.2 Quorum-Check an die größere Liste anpassen + +`runChecks` verlangt 60 % Relay-Antworten. Bei größerer Liste mit mehreren +toten Relays (damus, edufeed und plebstr lieferten 0) kippt das in +False-Positive-Hard-Fails. + +Wichtige Unterscheidung: „hat geantwortet" ≠ „hat Events geliefert". Der +aktuelle `fetcher` resolved auch bei Timeout mit leerem Array, zählt also +als `ok`. Das Quorum misst damit Erreichbarkeit, nicht Vollständigkeit. + +- Quorum auf absolute Untergrenze umstellen: mindestens 2 Relays *mit + Events*, statt 60 % Antwortende. Genauer am Schutzziel. +- `eventCount`- und Drop-Check bleiben unverändert — die sind der eigentliche + Datenverlust-Schutz und haben hier gut funktioniert. + +### 1.3 Tests + +`snapshot/tests/relays.test.ts`: +- Union enthält NIP-65- *und* Fallback-Einträge. +- Normalisierung: `wss://nos.lol/` und `wss://nos.lol` → ein Eintrag. +- Leere NIP-65-Antwort → reine Fallback-Liste (Regression). + +`snapshot/tests/checks.test.ts`: +- Viele tote Relays + 2 mit Events → kein Fail. +- 1 Relay mit Events → Fail. + +### 1.4 Verifikation + +`deno task snapshot` lokal; erwartet ≥ 28 Posts inklusive +`protocol-anthropology.json` und `warum-dein-ki-gedaechtnis-luegen-muss.json`. + +## Phase 2 — Rückschreibung als PR + +### 2.1 Neuer Subcommand `sync-inbound` + +Neu: `publish/src/subcommands/sync-inbound.ts`. Bewusst in `publish/`, nicht +in `snapshot/` — dort liegen `frontmatter.ts`, `markdown.ts` und das +Frontmatter-Schema, das wir bedienen müssen. + +Ablauf: +1. `snapshot/output/index.json` lesen (läuft nach dem Snapshot). +2. Pro Post prüfen, ob `content/posts///index.md` existiert. + Achtung: der Ordnername im Repo trägt ein Datums-Präfix + (`2025-09-09-banksy-high-court-prophet`), der Nostr-`d`-Tag nicht + (`banksy-high-court-prophet`). Matching muss über den `slug:`-Wert im + Frontmatter laufen, nicht über den Ordnernamen — sonst wird jeder + bestehende Post als „fehlend" erkannt. +3. Für fehlende: `index.md` erzeugen aus `PostJson`. +4. Liste der neu erzeugten Pfade als JSON auf stdout (für die Action). + +### 2.2 Frontmatter-Rückabbildung + +Aus `PostJson` → Frontmatter (Gegenstück zu `buildKind30023`): + +| Frontmatter | Quelle | +|---|---| +| `title` | `title` | +| `slug` | `slug` (der `d`-Tag — muss exakt erhalten bleiben) | +| `date` | `published_at` → `YYYY-MM-DD` | +| `description` | `summary` | +| `image` | `cover_image.url` | +| `tags` | `tags` | +| `lang` | `lang` | +| `a` | aus `translations` rekonstruiert | + +Zusätzlich ein Marker, der die Herkunft festhält: + +```yaml +source: nostr +source_event_id: 8a16dea… +``` + +Der Marker ist nicht Kosmetik — er ist die Loop-Bremse (siehe 2.4). + +Ordnername: `-` aus `published_at`, konsistent zum Bestand. + +**Bilder bleiben remote.** Der Post referenziert Blossom-URLs +(`blossom.ditto.pub/…`). Kein Download, keine `images:`-Metadatenblöcke — +die Konvention aus `2026-04-16-image-metadata-convention.md` verlangt +Lizenz- und Autor-Angaben, die im Event schlicht nicht stehen. Erfinden wäre +falsch. Stattdessen ein Kommentar im Frontmatter, dass die Metadaten für +extern verfasste Posts fehlen und bei Bedarf manuell zu ergänzen sind. + +### 2.3 Workflow `sync-inbound.yml` + +Trigger: `schedule` (täglich) + `workflow_dispatch`. + +1. Checkout, Deno. +2. Snapshot laufen lassen (Phase 1 aktiv). +3. `deno run … src/cli.ts sync-inbound`. +4. Wenn nichts erzeugt → sauber beenden. +5. Sonst: Branch `nostr-sync/`, committen, PR gegen `main` per + `peter-evans/create-pull-request` oder `gh pr create`. + +PR-Body listet die importierten Posts mit naddr-Link. + +### 2.4 Loop-Schutz — der kritische Punkt + +`publish.yml` triggert auf `push` nach `content/posts/**`. Ein gemergter +Sync-PR feuert damit die Publish-Action, die das Event neu signiert und +publiziert — mit neuem `created_at`. Der nächste Snapshot sieht die neuere +Version, alles wandert eine Runde weiter. Kein Endlos-Loop (der Inhalt +konvergiert), aber jeder Merge überschreibt ein extern erstelltes Event mit +einer Neusignatur, und `dedupByDtag` bevorzugt das neuere — die +Original-Fassung aus dem Nostr-Editor verschwindet. + +Absicherung, zwei Ebenen: + +1. **In `publish.ts`**: Posts mit `source: nostr` im Frontmatter werden + übersprungen, außer `--force-all`. Der Marker aus 2.2 trägt diese + Entscheidung. +2. **Im Workflow**: `paths-ignore` allein reicht nicht, da der Sync-PR + zwangsläufig unter `content/posts/**` landet. Ebene 1 ist die eigentliche + Bremse; Ebene 2 wäre nur Redundanz. + +Der Marker macht damit eine bewusste Aussage: *dieser Post wird von Nostr +verwaltet, das Repo ist Archiv.* Wer ihn aus dem Frontmatter entfernt, +übernimmt den Post ins Repo-Regime — ein sauberer, expliziter Übergabepunkt. + +### 2.5 Tests + +`publish/tests/sync-inbound.test.ts`: +- `PostJson` ohne Repo-Datei → Frontmatter korrekt, `source: nostr` gesetzt. +- Post mit vorhandenem `.md` (Datums-Präfix im Ordner!) → übersprungen. +- Round-Trip: erzeugtes Frontmatter durch `parseFrontmatter` → `buildKind30023` + ergibt dieselben `d`/`title`/`published_at`/`t`-Tags wie das Ursprungsevent. +- `publish.ts` überspringt `source: nostr` ohne `--force-all`. + +## Reihenfolge + +Phase 1 ist eigenständig wertvoll und behebt den 404 sofort. Phase 2 baut +darauf auf, ist aber unabhängig testbar. Empfehlung: Phase 1 umsetzen und +deployen, den Effekt live prüfen, dann Phase 2. + +## Offene Punkte + +- **Löschungen.** Wird ein Nostr-Post per kind:5 gelöscht, verschwindet er aus + dem Snapshot, das `.md` bleibt. Vorschlag: zunächst bewusst so lassen (Repo + = Archiv), im PR-Body vermerken. +- **Nachträgliche Edits.** Ein extern editierter Post erzeugt beim nächsten + Sync keinen Diff, weil die Datei existiert. Ein `--update`-Modus, der + `source: nostr`-Dateien neu schreibt, wäre die Erweiterung — bewusst nicht + in Phase 2, um den ersten Durchstich klein zu halten. +- **Relay-Hygiene.** `relay.plebstr.com` steht an erster Stelle der + NIP-65-Liste, liefert aber nichts. Unabhängig von diesem Plan wäre die + kind:10002-Liste eine Aktualisierung wert. diff --git a/snapshot/src/cli.ts b/snapshot/src/cli.ts index dbd629e..945fbd8 100644 --- a/snapshot/src/cli.ts +++ b/snapshot/src/cli.ts @@ -38,8 +38,13 @@ async function main(): Promise { const fetched = await fetchEvents(readRelays, cfg.authorPubkeyHex) console.log( `snapshot: ${fetched.responded.length}/${fetched.queried.length} relays geantwortet, ` + + `${fetched.withEvents.length} davon mit events, ` + `${fetched.events.length} events roh`, ) + const silent = fetched.queried.filter((r) => !fetched.withEvents.includes(r)) + if (silent.length > 0) { + console.log(`snapshot: ohne events = ${silent.join(', ')}`) + } const posts: SignedEvent[] = [] const deletions: SignedEvent[] = [] @@ -59,6 +64,7 @@ async function main(): Promise { runChecks({ relaysQueried: fetched.queried.length, relaysResponded: fetched.responded.length, + relaysWithEvents: fetched.withEvents.length, eventCount: filtered.length, minEvents, lastKnownGoodCount: cache?.lastKnownGoodCount, diff --git a/snapshot/src/core/checks.ts b/snapshot/src/core/checks.ts index 59ecf49..797868b 100644 --- a/snapshot/src/core/checks.ts +++ b/snapshot/src/core/checks.ts @@ -1,6 +1,8 @@ export interface CheckInput { relaysQueried: number relaysResponded: number + /** Relays, die mindestens ein event geliefert haben (nicht nur geantwortet). */ + relaysWithEvents: number eventCount: number minEvents: number lastKnownGoodCount: number | undefined @@ -8,12 +10,22 @@ export interface CheckInput { allowShrink: boolean } +/** Untergrenze: unter zwei quellen ist der abgleich keine bestaetigung mehr. */ +export const MIN_RELAYS_WITH_EVENTS = 2 + export function runChecks(input: CheckInput): void { - const quorum = Math.ceil(input.relaysQueried * 0.6) - if (input.relaysResponded < quorum) { + // Frueher: 60% der angefragten relays mussten antworten. Zwei probleme — + // (1) der fetcher resolved bei timeout mit leerem array und zaehlt damit + // als "geantwortet", die quote misst also erreichbarkeit statt daten; + // (2) mit der groesseren union-liste aus loadReadRelays liefern regulaer + // mehrere relays 0 events (gemessen 2026-08-31: 6 von 11), was die quote + // ohne echten fehler reissen wuerde. Stattdessen absolute untergrenze an + // relays, die wirklich events geliefert haben. + if (input.relaysWithEvents < MIN_RELAYS_WITH_EVENTS) { throw new Error( - `Relay-Quorum nicht erreicht: ${input.relaysResponded}/${input.relaysQueried} ` + - `(brauche mindestens ${quorum})`, + `Relay-Quorum nicht erreicht: nur ${input.relaysWithEvents} relay(s) mit events ` + + `(von ${input.relaysResponded}/${input.relaysQueried} antwortenden, ` + + `brauche mindestens ${MIN_RELAYS_WITH_EVENTS})`, ) } if (input.eventCount < input.minEvents) { diff --git a/snapshot/src/core/relays.ts b/snapshot/src/core/relays.ts index 7510690..ccc8be0 100644 --- a/snapshot/src/core/relays.ts +++ b/snapshot/src/core/relays.ts @@ -13,8 +13,23 @@ export const FALLBACK_READ_RELAYS = [ 'wss://relay.primal.net', 'wss://relay.tchncs.de', 'wss://relay.edufeed.org', + // Host-varianten aus der echten NIP-65-liste des autors: 'primal.net' und + // 'relay-rpi.edufeed.org' sind ANDERE hosts als 'relay.primal.net' und + // 'relay.edufeed.org', keine schreibweisen. Beide abfragen, sonst haengt + // es vom codepfad ab, welche haelfte der posts gefunden wird. + 'wss://primal.net', + 'wss://relay-rpi.edufeed.org', ] +/** + * Vereinheitlicht relay-URLs fuer den set-vergleich: trailing slash weg, + * lowercase, umgebende whitespaces weg. Ohne das zaehlen 'wss://nos.lol/' + * und 'wss://nos.lol' als zwei relays und werden doppelt abgefragt. + */ +export function normalizeRelayUrl(url: string): string { + return url.trim().toLowerCase().replace(/\/+$/, '') +} + export function extractReadRelays(kind10002: SignedEvent): string[] { const out: string[] = [] for (const tag of kind10002.tags) { @@ -39,6 +54,17 @@ export const defaultRelayListLoader: RelayListLoader = async (bootstrap, pubkey) } } +/** + * Liefert die VEREINIGUNGSMENGE aus NIP-65-liste und fallback, normalisiert + * und dedupliziert, NIP-65-eintraege zuerst. + * + * Frueher war das ein entweder-oder: NIP-65 wenn vorhanden, sonst fallback. + * Das hat den 404 von 'protocol-anthropology' erzeugt — das event lag nur + * auf relay.primal.net, waehrend nos.lol und relay.tchncs.de die uebrigen + * 27 posts hielten und primal die nicht lieferte. Je nachdem, welche liste + * griff, fehlte entweder der neue post oder fast alle alten. Union statt + * oder: ein einzelnes relay, das nur ein event kennt, reicht jetzt aus. + */ export async function loadReadRelays( bootstrapRelay: string, authorPubkey: string, @@ -46,15 +72,29 @@ export async function loadReadRelays( fallback: string[] = FALLBACK_READ_RELAYS, ): Promise { const ev = await loader(bootstrapRelay, authorPubkey) - if (!ev) return fallback - const list = extractReadRelays(ev) - return list.length > 0 ? list : fallback + const fromList = ev ? extractReadRelays(ev) : [] + const seen = new Set() + const out: string[] = [] + for (const url of [...fromList, ...fallback]) { + const normalized = normalizeRelayUrl(url) + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + out.push(normalized) + } + return out } export interface FetchEventsResult { events: SignedEvent[] responded: string[] queried: string[] + /** + * Relays, die tatsaechlich mindestens ein event geliefert haben. + * Getrennt von `responded`, weil der fetcher bei timeout mit leerem + * array resolved — "hat geantwortet" heisst also nur "hat nicht + * geworfen" und ist als gesundheitsmass wertlos. + */ + withEvents: string[] } export type EventFetcher = (relay: string, pubkey: string) => Promise @@ -99,6 +139,7 @@ export async function fetchEvents( return { events, responded: results.filter((r) => r.ok).map((r) => r.url), + withEvents: results.filter((r) => r.events.length > 0).map((r) => r.url), queried: relays, } } diff --git a/snapshot/tests/checks.test.ts b/snapshot/tests/checks.test.ts index 65547c0..4b8217c 100644 --- a/snapshot/tests/checks.test.ts +++ b/snapshot/tests/checks.test.ts @@ -1,10 +1,10 @@ import { assertEquals, assertThrows } from '@std/assert' import { runChecks } from '../src/core/checks.ts' -Deno.test('runChecks: weniger als 60% relays geantwortet -> hard-fail', () => { +Deno.test('runChecks: nur ein relay mit events -> hard-fail', () => { assertThrows( () => runChecks({ - relaysQueried: 5, relaysResponded: 2, + relaysQueried: 11, relaysResponded: 11, relaysWithEvents: 1, eventCount: 27, minEvents: 1, lastKnownGoodCount: undefined, newDeletionsCount: 0, allowShrink: false, }), @@ -12,10 +12,21 @@ Deno.test('runChecks: weniger als 60% relays geantwortet -> hard-fail', () => { ) }) +Deno.test('runChecks: viele tote relays, aber 2 mit events -> ok', () => { + // Reale lage 2026-08-31: von 11 relays lieferten nur nos.lol und + // relay.tchncs.de events, der rest antwortete leer. Das ist kein + // fehlerfall — die 60%-antwortquote war das falsche mass. + runChecks({ + relaysQueried: 11, relaysResponded: 11, relaysWithEvents: 2, + eventCount: 27, minEvents: 1, lastKnownGoodCount: undefined, + newDeletionsCount: 0, allowShrink: false, + }) +}) + Deno.test('runChecks: event-count unter min-events -> hard-fail', () => { assertThrows( () => runChecks({ - relaysQueried: 5, relaysResponded: 5, + relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5, eventCount: 0, minEvents: 1, lastKnownGoodCount: undefined, newDeletionsCount: 0, allowShrink: false, }), @@ -26,7 +37,7 @@ Deno.test('runChecks: event-count unter min-events -> hard-fail', () => { Deno.test('runChecks: drop > 20% ohne kind:5 -> hard-fail', () => { assertThrows( () => runChecks({ - relaysQueried: 5, relaysResponded: 5, + relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5, eventCount: 20, minEvents: 1, lastKnownGoodCount: 27, newDeletionsCount: 0, allowShrink: false, }), @@ -36,7 +47,7 @@ Deno.test('runChecks: drop > 20% ohne kind:5 -> hard-fail', () => { Deno.test('runChecks: drop > 20% mit korrespondierenden kind:5 -> ok', () => { runChecks({ - relaysQueried: 5, relaysResponded: 5, + relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5, eventCount: 20, minEvents: 1, lastKnownGoodCount: 27, newDeletionsCount: 7, allowShrink: false, }) @@ -44,7 +55,7 @@ Deno.test('runChecks: drop > 20% mit korrespondierenden kind:5 -> ok', () => { Deno.test('runChecks: --allow-shrink umgeht drop-check', () => { runChecks({ - relaysQueried: 5, relaysResponded: 5, + relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5, eventCount: 1, minEvents: 1, lastKnownGoodCount: 27, newDeletionsCount: 0, allowShrink: true, }) @@ -52,7 +63,7 @@ Deno.test('runChecks: --allow-shrink umgeht drop-check', () => { Deno.test('runChecks: erstlauf ohne cache + min-events=1 -> ok', () => { runChecks({ - relaysQueried: 5, relaysResponded: 5, + relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5, eventCount: 1, minEvents: 1, lastKnownGoodCount: undefined, newDeletionsCount: 0, allowShrink: false, }) diff --git a/snapshot/tests/relays.test.ts b/snapshot/tests/relays.test.ts index 771ef33..685ffed 100644 --- a/snapshot/tests/relays.test.ts +++ b/snapshot/tests/relays.test.ts @@ -1,5 +1,10 @@ import { assertEquals } from '@std/assert' -import { extractReadRelays, type RelayListLoader, loadReadRelays } from '../src/core/relays.ts' +import { + extractReadRelays, + type RelayListLoader, + loadReadRelays, + normalizeRelayUrl, +} from '../src/core/relays.ts' import type { SignedEvent } from '../src/core/types.ts' const KIND_10002: SignedEvent = { @@ -26,8 +31,37 @@ Deno.test('loadReadRelays: nutzt fallback wenn kein kind:10002', async () => { assertEquals(relays, ['wss://fallback1', 'wss://fallback2']) }) -Deno.test('loadReadRelays: nutzt kind:10002 wenn vorhanden', async () => { +Deno.test('loadReadRelays: kind:10002-relays stehen vorn, fallback haengt an', async () => { const loader: RelayListLoader = async () => KIND_10002 const relays = await loadReadRelays('wss://bootstrap', 'P', loader, ['wss://fallback']) - assertEquals(relays, ['wss://relay.damus.io', 'wss://nos.lol']) + assertEquals(relays, ['wss://relay.damus.io', 'wss://nos.lol', 'wss://fallback']) +}) + +Deno.test('normalizeRelayUrl: trailing slash und case werden vereinheitlicht', () => { + assertEquals(normalizeRelayUrl('wss://NOS.lol/'), 'wss://nos.lol') + assertEquals(normalizeRelayUrl('wss://nos.lol'), 'wss://nos.lol') + assertEquals(normalizeRelayUrl(' wss://nos.lol/ '), 'wss://nos.lol') +}) + +Deno.test('loadReadRelays: union aus kind:10002 UND fallback', async () => { + // Regression fuer den 404 von protocol-anthropology: das event lag nur auf + // einem relay, das je nach codepfad nicht abgefragt wurde. Union statt + // entweder-oder. + const loader: RelayListLoader = async () => KIND_10002 + const relays = await loadReadRelays('wss://bootstrap', 'P', loader, [ + 'wss://relay.primal.net', + 'wss://nos.lol', + ]) + assertEquals(relays, [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.primal.net', + ]) +}) + +Deno.test('loadReadRelays: union dedupliziert ueber normalisierung', async () => { + const withSlash: SignedEvent = { ...KIND_10002, tags: [['r', 'wss://nos.lol/']] } + const loader: RelayListLoader = async () => withSlash + const relays = await loadReadRelays('wss://bootstrap', 'P', loader, ['wss://nos.lol']) + assertEquals(relays, ['wss://nos.lol']) })