fix(snapshot): relay-union statt entweder-oder, quorum an events statt antworten

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) <noreply@anthropic.com>
This commit is contained in:
Claude Code 2026-08-31 16:45:29 +02:00
parent dcabc5f75e
commit bcd3585f09
6 changed files with 331 additions and 17 deletions

View File

@ -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/<lang>/<slug>/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: `<YYYY-MM-DD>-<slug>` 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/<datum>`, 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.

View File

@ -38,8 +38,13 @@ async function main(): Promise<number> {
const fetched = await fetchEvents(readRelays, cfg.authorPubkeyHex) const fetched = await fetchEvents(readRelays, cfg.authorPubkeyHex)
console.log( console.log(
`snapshot: ${fetched.responded.length}/${fetched.queried.length} relays geantwortet, ` + `snapshot: ${fetched.responded.length}/${fetched.queried.length} relays geantwortet, ` +
`${fetched.withEvents.length} davon mit events, ` +
`${fetched.events.length} events roh`, `${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 posts: SignedEvent[] = []
const deletions: SignedEvent[] = [] const deletions: SignedEvent[] = []
@ -59,6 +64,7 @@ async function main(): Promise<number> {
runChecks({ runChecks({
relaysQueried: fetched.queried.length, relaysQueried: fetched.queried.length,
relaysResponded: fetched.responded.length, relaysResponded: fetched.responded.length,
relaysWithEvents: fetched.withEvents.length,
eventCount: filtered.length, eventCount: filtered.length,
minEvents, minEvents,
lastKnownGoodCount: cache?.lastKnownGoodCount, lastKnownGoodCount: cache?.lastKnownGoodCount,

View File

@ -1,6 +1,8 @@
export interface CheckInput { export interface CheckInput {
relaysQueried: number relaysQueried: number
relaysResponded: number relaysResponded: number
/** Relays, die mindestens ein event geliefert haben (nicht nur geantwortet). */
relaysWithEvents: number
eventCount: number eventCount: number
minEvents: number minEvents: number
lastKnownGoodCount: number | undefined lastKnownGoodCount: number | undefined
@ -8,12 +10,22 @@ export interface CheckInput {
allowShrink: boolean 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 { export function runChecks(input: CheckInput): void {
const quorum = Math.ceil(input.relaysQueried * 0.6) // Frueher: 60% der angefragten relays mussten antworten. Zwei probleme —
if (input.relaysResponded < quorum) { // (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( throw new Error(
`Relay-Quorum nicht erreicht: ${input.relaysResponded}/${input.relaysQueried} ` + `Relay-Quorum nicht erreicht: nur ${input.relaysWithEvents} relay(s) mit events ` +
`(brauche mindestens ${quorum})`, `(von ${input.relaysResponded}/${input.relaysQueried} antwortenden, ` +
`brauche mindestens ${MIN_RELAYS_WITH_EVENTS})`,
) )
} }
if (input.eventCount < input.minEvents) { if (input.eventCount < input.minEvents) {

View File

@ -13,8 +13,23 @@ export const FALLBACK_READ_RELAYS = [
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://relay.tchncs.de', 'wss://relay.tchncs.de',
'wss://relay.edufeed.org', '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[] { export function extractReadRelays(kind10002: SignedEvent): string[] {
const out: string[] = [] const out: string[] = []
for (const tag of kind10002.tags) { 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( export async function loadReadRelays(
bootstrapRelay: string, bootstrapRelay: string,
authorPubkey: string, authorPubkey: string,
@ -46,15 +72,29 @@ export async function loadReadRelays(
fallback: string[] = FALLBACK_READ_RELAYS, fallback: string[] = FALLBACK_READ_RELAYS,
): Promise<string[]> { ): Promise<string[]> {
const ev = await loader(bootstrapRelay, authorPubkey) const ev = await loader(bootstrapRelay, authorPubkey)
if (!ev) return fallback const fromList = ev ? extractReadRelays(ev) : []
const list = extractReadRelays(ev) const seen = new Set<string>()
return list.length > 0 ? list : fallback 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 { export interface FetchEventsResult {
events: SignedEvent[] events: SignedEvent[]
responded: string[] responded: string[]
queried: 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<SignedEvent[]> export type EventFetcher = (relay: string, pubkey: string) => Promise<SignedEvent[]>
@ -99,6 +139,7 @@ export async function fetchEvents(
return { return {
events, events,
responded: results.filter((r) => r.ok).map((r) => r.url), responded: results.filter((r) => r.ok).map((r) => r.url),
withEvents: results.filter((r) => r.events.length > 0).map((r) => r.url),
queried: relays, queried: relays,
} }
} }

View File

@ -1,10 +1,10 @@
import { assertEquals, assertThrows } from '@std/assert' import { assertEquals, assertThrows } from '@std/assert'
import { runChecks } from '../src/core/checks.ts' 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( assertThrows(
() => runChecks({ () => runChecks({
relaysQueried: 5, relaysResponded: 2, relaysQueried: 11, relaysResponded: 11, relaysWithEvents: 1,
eventCount: 27, minEvents: 1, lastKnownGoodCount: undefined, eventCount: 27, minEvents: 1, lastKnownGoodCount: undefined,
newDeletionsCount: 0, allowShrink: false, 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', () => { Deno.test('runChecks: event-count unter min-events -> hard-fail', () => {
assertThrows( assertThrows(
() => runChecks({ () => runChecks({
relaysQueried: 5, relaysResponded: 5, relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5,
eventCount: 0, minEvents: 1, lastKnownGoodCount: undefined, eventCount: 0, minEvents: 1, lastKnownGoodCount: undefined,
newDeletionsCount: 0, allowShrink: false, 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', () => { Deno.test('runChecks: drop > 20% ohne kind:5 -> hard-fail', () => {
assertThrows( assertThrows(
() => runChecks({ () => runChecks({
relaysQueried: 5, relaysResponded: 5, relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5,
eventCount: 20, minEvents: 1, lastKnownGoodCount: 27, eventCount: 20, minEvents: 1, lastKnownGoodCount: 27,
newDeletionsCount: 0, allowShrink: false, 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', () => { Deno.test('runChecks: drop > 20% mit korrespondierenden kind:5 -> ok', () => {
runChecks({ runChecks({
relaysQueried: 5, relaysResponded: 5, relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5,
eventCount: 20, minEvents: 1, lastKnownGoodCount: 27, eventCount: 20, minEvents: 1, lastKnownGoodCount: 27,
newDeletionsCount: 7, allowShrink: false, 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', () => { Deno.test('runChecks: --allow-shrink umgeht drop-check', () => {
runChecks({ runChecks({
relaysQueried: 5, relaysResponded: 5, relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5,
eventCount: 1, minEvents: 1, lastKnownGoodCount: 27, eventCount: 1, minEvents: 1, lastKnownGoodCount: 27,
newDeletionsCount: 0, allowShrink: true, 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', () => { Deno.test('runChecks: erstlauf ohne cache + min-events=1 -> ok', () => {
runChecks({ runChecks({
relaysQueried: 5, relaysResponded: 5, relaysQueried: 5, relaysResponded: 5, relaysWithEvents: 5,
eventCount: 1, minEvents: 1, lastKnownGoodCount: undefined, eventCount: 1, minEvents: 1, lastKnownGoodCount: undefined,
newDeletionsCount: 0, allowShrink: false, newDeletionsCount: 0, allowShrink: false,
}) })

View File

@ -1,5 +1,10 @@
import { assertEquals } from '@std/assert' 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' import type { SignedEvent } from '../src/core/types.ts'
const KIND_10002: SignedEvent = { const KIND_10002: SignedEvent = {
@ -26,8 +31,37 @@ Deno.test('loadReadRelays: nutzt fallback wenn kein kind:10002', async () => {
assertEquals(relays, ['wss://fallback1', 'wss://fallback2']) 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 loader: RelayListLoader = async () => KIND_10002
const relays = await loadReadRelays('wss://bootstrap', 'P', loader, ['wss://fallback']) 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'])
}) })