From 030473f19715392705e48ed51b415789a66b2711 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 25 Sep 2026 14:51:03 -0700 Subject: [PATCH] Bump welshman --- .agents/skills/welshman-app/SKILL.md | 13 +- .agents/skills/welshman-content/SKILL.md | 20 +- .agents/skills/welshman-domain/SKILL.md | 479 +++++++++++++++-------- .agents/skills/welshman-feeds/SKILL.md | 1 + .agents/skills/welshman-net/SKILL.md | 26 +- .agents/skills/welshman-signer/SKILL.md | 1 + .agents/skills/welshman-store/SKILL.md | 2 +- .agents/skills/welshman-util/SKILL.md | 8 +- package.json | 20 +- pnpm-lock.yaml | Bin 400694 -> 400694 bytes 10 files changed, 381 insertions(+), 189 deletions(-) diff --git a/.agents/skills/welshman-app/SKILL.md b/.agents/skills/welshman-app/SKILL.md index 4d2391e0..a2b59903 100644 --- a/.agents/skills/welshman-app/SKILL.md +++ b/.agents/skills/welshman-app/SKILL.md @@ -122,7 +122,7 @@ Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) i | `Zappers` | LNURL zapper info (HTTP) | `forPubkey(pk)`, `validateZapReceipt(...)`, `validateZapReceipts(...)`, `validZapReceipts(...)` | | `Topics` | hashtags w/ counts | `all`, `byName` (`Projection`s); `topicSearch` | | `Reactions` / `Deletes` | kind-7 reactions and kind-5 deletes over the repository | reactive lookups | -| `Rooms` | NIP-29 rooms, keyed `${url}'${h}` | `forRoom(url, h)`, `forUrl(url)`, `members(url, h)`, `membershipStatus(...)`, `pendingJoins(url, h?)`, `createRoom`/`editRoom`/`deleteRoom`/`joinRoom`/`leaveRoom`/`addMember`/`removeMember(url, room, …)` → `Command` | +| `Rooms` | NIP-29 rooms, keyed `${url}'${h}` | `forRoom(url, h)`, `forUrl(url)`, `members(url, h)`, `membershipStatus(...)`, `pendingJoins(url, h?)`, `createRoom`/`editRoom`/`deleteRoom`/`joinRoom`/`leaveRoom`/`addMember`/`removeMember`/`deleteEvent(url, room, …)` → `Command` | | `Plaintext` | decrypted-content cache, keyed by ciphertext | `ensure(ciphertext, decrypt)`, `get(ciphertext)` | ```typescript @@ -186,7 +186,7 @@ const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]}) app.use(Thunks).publish({event, relays, pow: 20}) ``` -`ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. +`ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`. An unwrap that throws is tried again up to `MAX_UNWRAP_ATTEMPTS` times, so a signer that was busy or disconnected doesn't cost the message. ## Commands (deferred publishing) @@ -223,7 +223,10 @@ await app.use(Rooms).join(relayUrl, roomMeta).then(publishToRelays([relayUrl])) import {Network, Sync} from "@welshman/app" const net = app.use(Network) -const events = await net.load({filters: [{kinds: [1], authors: [pk]}], relays}) +// loadComplete waits for every relay, so an empty result means every relay answered +// nothing. loadLenient resolves once half of them have closed, for the cases where +// nothing routes on the returned array. +const events = await net.loadComplete({filters: [{kinds: [1], authors: [pk]}], relays}) await net.request({filters, relays, autoClose: true}) // Outbox-model author load (resolves the author's write relays automatically). @@ -232,7 +235,7 @@ const profileEvent = await net.loadUsingOutbox(pk, {kinds: [0]}) const allFeeds = await net.loadAllUsingOutbox(pk, {kinds: [31890]}) // A loader with different batching, still bound to this app's net context: -const slowLoad = net.makeLoader({delay: 500, timeout: 5000, threshold: 0.5}) +const slowLoad = net.makeLoader({delay: 500, timeout: 5000}) // Negentropy-aware reconciliation (falls back to request/publish when unsupported): await app.use(Sync).pull({relays, filters: [{authors: [pk]}]}) @@ -439,7 +442,7 @@ const app = new App({user, policies: [appPolicyIngest, appPolicyAuthNever]}) | `deriveProfileDisplay(pk)` | `app.use(Profiles).display(pk).$` | | `publishThunk({...})` | `app.use(Thunks).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) | | `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag).then(publish)` / `app.use(MuteLists).mutePublicly(tag).then(publish)`, which return a [`Command`](#commands-deferred-publishing) | -| `load({...})` / `request({...})` | `app.use(Network).load({...})` / `request({...})` | +| `load({...})` / `request({...})` | `app.use(Network).loadComplete({...})` / `request({...})` | | `Router.get().FromUser()` / `router.Event(e)` | `app.use(Router).resolver` + the `RelaySelection` DSL (`resolver.relays([userOutbox()])`, `resolver.relay([seen(e)])`) | | `app.use(Tags).tagEventForReply(e)` | domain writer tagging (`NoteWriter.setParent(e)`, `writer.tagPubkey/addQuote/addZapSplit`) | | `relays` / `handles` / `zappers` stores | `app.use(Relays)` / `Handles` / `Zappers` | diff --git a/.agents/skills/welshman-content/SKILL.md b/.agents/skills/welshman-content/SKILL.md index 89667fdb..96a3a64a 100644 --- a/.agents/skills/welshman-content/SKILL.md +++ b/.agents/skills/welshman-content/SKILL.md @@ -75,6 +75,8 @@ isText isTopic | `makeTextRenderer(options?)` | Creates a `Renderer` pre-configured for text output. | | `makeHtmlRenderer(options?)` | Creates a `Renderer` pre-configured for HTML output. | | `Renderer` | Class with `addText`, `addLink`, `addEntityLink`, `addNewlines`, `toString`. | +| `summarize(parsed, getName?)` | Replaces every element that is not prose (entities, links, invoices) with `Text` naming it — "another message", "a link to example.com", "an image". Returns `Parsed[]`, so it composes with `truncate`, `reduceLinks` and either renderer. | +| `defaultSummaryName(parsed)` | The name `summarize` uses for an element its `getName` returned `undefined` for, or `undefined` itself for prose. | `RenderOptions` fields (all optional when using the convenience functions): @@ -123,6 +125,22 @@ const preview = truncate(withGrids, { minLength: 300, maxLength: 500 }) const html = renderAsHtml(preview).toString() ``` +### Read a message as one line of prose + +```typescript +import { parse, ParsedType, renderAsText, summarize } from '@welshman/content' + +// A notification body or a speech synthesizer wants each entity named as it reads +const summary = renderAsText( + summarize(parse({ content, tags }), p => { + if (p.type === ParsedType.Profile) { + return profilesByPubkey.get(p.value.pubkey)?.name + } + }), +).toString() +// → 'hodlbod said another message look at an image' +``` + ### Extract all mentioned pubkeys ```typescript @@ -210,7 +228,7 @@ const emojiElements = parsed.filter(isEmoji) - **`reduceLinks` requires block-level links**: a link is only pulled into a grid if it appears at the start of a block (i.e. preceded by a newline or at the very beginning). Inline links in the middle of a sentence are left as `ParsedLink`. - **`isImage` is stricter than `urlIsMedia`**: `isImage` only matches `.jpg/.jpeg/.png/.gif/.webp` — it will not match `.mp4` or `.webm`. Use `urlIsMedia` directly if you need to detect video; note that `urlIsMedia` takes a URL string, not a `Parsed` element — usage would be: `urlIsMedia(parsed.value.url.toString())`. - **`Renderer.toString()`** is how you get the final string out. `renderAsHtml` and `renderAsText` both return a `Renderer` instance, not a string. -- **`LinkGrid` is not rendered by default renderers**: `renderOne` has no case for `ParsedType.LinkGrid`. You must handle it yourself when building a custom UI (e.g. render each `value.links` entry as an image or card grid). +- **`LinkGrid` is not rendered by default renderers**: `renderOne` has no case for `ParsedType.LinkGrid`. You must handle it yourself when building a custom UI (e.g. render each `value.links` entry as an image or card grid). `summarize` does name it ("some images"), so a summary is not affected. - **Legacy mentions** (`#[0]`, `#[1]`) are parsed automatically from the `tags` array and emitted as `ParsedProfile` or `ParsedEvent` elements. - **Numeric hashtags are skipped**: `#42` will not produce a `Topic` element. - **A `Command` is syntax, not a promise that anyone answers to it**: the parser has no idea which NIP-CD definitions exist, so `/nonsense` parses as a `Command` too. Match `value.command` (and `value.pubkey`, when the invocation names an executor) against the definitions you have, and render the element's `raw` as text when nothing matches. diff --git a/.agents/skills/welshman-domain/SKILL.md b/.agents/skills/welshman-domain/SKILL.md index 4041bb34..c9404b2a 100644 --- a/.agents/skills/welshman-domain/SKILL.md +++ b/.agents/skills/welshman-domain/SKILL.md @@ -1,197 +1,350 @@ --- name: welshman-domain -description: "Use this skill when working with @welshman/domain: reading or writing a specific nostr event kind, parsing tags, building events to publish, or adding support for a new kind. Provides a typed Reader/Writer pair per kind so you never hand-parse tags." +description: "Use this skill when working with @welshman/domain: reading, building, querying, and routing typed nostr events by kind — the Reader/Writer/Query trio (EventReader/EventWriter/EventQuery, ListReader/ListWriter) bound to dependencies through KindFactory.configure → ConfiguredKind, that sits between @welshman/util's raw event types and @welshman/app's data plugins. Covers Profile, NIP-51 lists (follows, mutes, pins, relays, bookmarks, topics, emoji, feeds, blossom), NIP-29 rooms, Flotilla relay/space membership, NIP-89 handlers, NIP-57/75 zaps, notes/deletes/reactions, and content kinds (comment, thread, classified, poll, calendar, report). Use it to parse an event into domain getters, build/edit an event template, build the filters and relays that fetch a kind's events, resolve publish relays via the RelaySelection routing DSL (routes/forceRoutes/requiresRelays/scenario), handle NIP-44 private-list encryption, or migrate from the old Reader/Builder core (fromEvent/factory/toTemplate/toEvent/EventBuilder/ListBuilder/XBuilder/commandFromBuilder)." --- -# welshman/domain — Typed Readers and Writers per Kind +# welshman/domain — Typed Readers, Writers & Routing for Nostr Kinds -`@welshman/domain` replaces ad-hoc tag digging. Every supported kind gets a **Reader** (typed -accessors over an event) and a **Writer** (a builder that renders a new event), paired in a -`KindFactory`. +## Overview -**The rule this package exists to enforce:** never reach into `event.tags` yourself. Use the -reader's getter (`note.content()`, `roomMeta.name()`, `goal.amount()`). If a kind has no reader, -use `tagValue`/`tagValues` from `@welshman/util` — not `tags.find(...)`. +`@welshman/domain` translates nostr events to and from typed domain objects, and works out where to publish and fetch them. For each event kind it ships a matched trio: + +- a **Reader** — a read-only view over a `TrustedEvent` with synchronous getters (`profile.name()`, `followList.pubkeys()`), +- a **Writer** — a mutable, chainable producer of an `EventTemplate` plus the relays to publish it to (`writer.renderTemplate()` / `writer.render()`), and +- a **Query** — a mutable, chainable producer of the `Filter`s that fetch the kind's events and the relays to request them from (`query.renderFilters()` / `query.render()`). + +A kind is packaged as a **`KindFactory`**; you bind it to app dependencies once with `factory.configure(context)`, which returns a **`ConfiguredKind`** that mints readers, writers, and queries. Routing runs through the `@welshman/util` `RelaySelection` DSL and a `Resolver` supplied in the context. + +It sits one layer above `@welshman/util` (raw `TrustedEvent`/`EventTemplate` types, tag getters, kind constants, the routing DSL) and one layer below `@welshman/app` (whose `Domain` plugin supplies the context and whose data plugins use these readers as their `eventToItem`). The package holds no stores, no network, no globals — dependencies arrive through the `KindContext`. + +This replaces the old free-function helpers that used to live in `@welshman/util` (`makeProfile`/`readProfile`, `makeList`/`readList`, `readHandlers`, `Encryptable`, `makeRoomMetaEvent`/`makeRoomEditEvent`) **and** the earlier Reader/Builder core (`EventBuilder`/`ListBuilder`, `X.fromEvent`, `Kind.factory`, `builder.toTemplate`/`toEvent`). See the migration table below. ## Installation ```bash -npm i @welshman/domain +npm install @welshman/domain +# or +pnpm add @welshman/domain ``` -## The three pieces +Peer deps: `@welshman/lib`, `@welshman/util`, `@welshman/signer`, `@welshman/net`, `@welshman/feeds`, and `nostr-tools`. + +## Core mental model + +1. **A kind is a `KindFactory`; bind it once with `configure`.** Each exported kind constant is `new KindFactory({kind, reader, writer, query})` — e.g. `export const Note = new KindFactory({kind: NOTE, reader: NoteReader, writer: NoteWriter, query: NoteQuery})`. Call `Note.configure(context)` to get a `ConfiguredKind` carrying the app's `resolver` and an optional `signer`, which is all `KindContext` holds. In an app you never call `configure` yourself — `@welshman/app`'s `Domain` plugin does it and memoizes the result. +2. **Readers wrap an event; Writers produce a template + relays; Queries produce filters + relays.** `configured.reader(event)` builds a Reader (unparsed) and `.parse()` populates it; `configured.writer(reader?)` builds a Writer, optionally seeded from a Reader (the edit flow); `configured.query()` builds a Query. +3. **Reading is sync unless the kind decrypts; getters are always sync.** You enter through `configured.reader(event).parse()`, which validates `event.kind` (throwing `Expected a kind X event, got kind Y`) and parses. `parse()` returns the reader for kinds that extend `EventReader` and a promise of it for kinds that extend `AsyncEventReader` — only the six private-tag lists and `AppData`, which have to decrypt. `await` works for both, and `Parsed` is the type of whichever one a reader yields. +4. **Building is chainable; output is async.** Setters return `this`; you finish with `await w.renderTemplate()` (an `EventTemplate`), `await w.relays()` (publish urls), or `await w.render()` (both). None of these take arguments — the signer, resolver, and repository come from the context bound at `configure`. +5. **The signer is optional and lazy.** Most kinds ignore it. Only NIP-51 lists need it — to *decrypt* private tags on read, and to *encrypt* them (NIP-44, self-encrypted) on build. The app supplies it as a lazy getter so auth policies can swap it after `configure`. +6. **Round-trips preserve unknown tags.** Seed a Writer from a Reader and any tags the class doesn't model are carried through verbatim into the rebuilt template. + +## Reading an event + +Go through a `ConfiguredKind`. `configured.reader` validates `event.kind === reader.kind` and builds the reader; chain `parse()` to populate it. ```typescript -import {KindFactory} from "@welshman/domain" +import {Profile, FollowList} from "@welshman/domain" -KindFactory // the exported per-kind object, e.g. `Note`, `Profile`, `RoomMeta` - .configure(ctx) // binds a resolver/signer -> ConfiguredKind - .reader(event) // -> Reader instance (call .parse() before use) - .writer(reader?) // -> Writer, optionally seeded from an existing reader to edit it +// Bind dependencies once (the app's Domain plugin does this for you): +const profiles = Profile.configure(context) // context: KindContext +const follows = FollowList.configure(context) // context.signer decrypts private tags + +// kind 0 parses without IO, so there is nothing to await: +const profile = profiles.reader(event).parse() // validates + parse() + +// follow lists are public, but a mute list would decrypt — and then `parse()` +// returns a promise the type makes you await: +const list = follows.reader(event).parse() +const mutes = await MuteList.configure(context).reader(event).parse() ``` -You rarely call `configure` directly — the `Domain` plugin does it for you. +`ConfiguredKind.reader` / `.writer` / `.query` are instance arrow-function properties, so you can destructure them (`const {reader} = Profile.configure(ctx)`). -## Usage through an app +Common base getters available on every reader (all synchronous): `id()`, `author()`, `content()`, `tags()`, `createdAt()`, `identifier()` (d-tag), `address()` (`kind:pubkey:d`), `room()` (NIP-29 h-tag), `protect()` (has `["-"]`), `expiration()`, `contentWarning()` (has a NIP-36 `content-warning` tag), `contentWarningReason()`, `client()` (the NIP-89 `client` tag as `{name, address?, relay?}`). Each kind adds its own — e.g. `profile.name()`, `profile.display()`, `followList.pubkeys()`, `followList.includes(pk)`. + +The getters for tags any kind can carry delegate to standalone functions in `src/behaviors/` — `isProtected`, `getExpiration`, `getContentWarning`, `getClient`, `getEmojis`, `getZapSplits` (plus `splitZapAmount`). Use those on a bare event: `reader()` throws on a kind mismatch, so a cross-kind check can't go through a reader. + +## Building / editing an event + +```typescript +import {Profile, FollowList} from "@welshman/domain" + +// Build from scratch: +const {writer} = Profile.configure(context) +const template = await writer() + .setName("alice") + .setAbout("hi") + .renderTemplate() // EventTemplate {kind, content, tags} + +// Edit an existing event (seed the writer from a reader): +const reader = FollowList.configure(context).reader(event).parse() +const w = FollowList.configure(context).writer(reader) // seeded from reader + .follow(pubkey) +const {event: template2, relays} = await w.render() // template + publish urls +``` + +- Construct empty (`configured.writer()`) or from a reader (`configured.writer(reader)`) for the edit flow. +- Base behavior setters (chainable): `setContent`, `setRoom(url, room)`/`clearRoom` (h-tag + forced routes), `forceRoutes(...routes)`/`clearForcedRoutes`, `setProtected(bool)`, `setExpiration`/`clearExpiration`, `setContentWarning(reason?)`/`clearContentWarning`, `setClient(name, address?)`/`clearClient`, `setIdentifier`/`clearIdentifier` (d-tag, defaults to a random id), `addTags`, `keepTags(pred)`, `dropTags(pred)`. Shared tag/hint helpers: `tagPubkey(pubkey, petname?)`, `addQuote(event, relay?)`, `addZapSplit(pubkey, split?)`. Each kind adds its own setters. +- Output methods (all async, no arguments): + - `renderTemplate()` → `EventTemplate` — runs `validate()`, then renders tags and content. A tag that carries a relay hint fills its hint slot through the protected `hint(...routes)` helper, which resolves routes to a single url; `renderTags()` awaits every in-flight hint before returning. List content is encrypted here. + - `scenario()` → `RelayScenario` (chainable: `.limit()`, `.policy()`, `.allowLocal()`, …); `relays()` → `string[]` (the resolved publish urls). + - `render()` → `{event: EventTemplate; relays: string[]}` — `renderTemplate()` and `relays()` together. + +There is **no** `toEvent`/`toRumor`/`toTemplate` on the writer — the caller signs the template. In an app you hand the writer to `Domain.command(writer)` (which calls `render()` and wraps it in a `Command`). Directly, you sign the `renderTemplate()` output: + +```typescript +import {stamp} from "@welshman/util" +const signed = await signer.sign(stamp(await writer.renderTemplate())) // SignedEvent +``` + +**Round-trip / extra-tag passthrough.** When a writer is seeded from a reader, every tag in `event.tags` starts in `extraTags`. The base constructor lifts out `h`/`-`/`expiration`/`content-warning`/`client`/`d` (into `roomTag`/`protectTag`/`expirationTag`/`contentWarningTag`/`clientTag`/`identifierTag`); each subclass lifts the tags it models. Whatever is left is re-emitted unchanged, so unmodeled tags survive an edit. Tag assembly is `[...renderBehaviorTags(), ...renderDomainTags(), ...extraTags]`. + +## Querying a kind's events + +```typescript +import {Note, Comment} from "@welshman/domain" + +// Filters + the relays to request them from: +const {filters, relays} = await Note.configure(context) + .query() + .setAuthors([pubkey]) + .setSince(since) + .setLimit(50) + .render() + +// Kind-specific queries add methods for the relationships the kind models: +const thread = await Comment.configure(context).query().forRoot(event).render() +``` + +- Every filter field has a set/add/remove/clear group (`setIds`/`addIds`/`removeIds`/`clearIds`, same for `Authors`), or set/clear for the scalars (`setSince`, `setUntil`, `setLimit`, `setSearch`). Tag filters go through `setTag(key, values)`/`addTag`/`removeTag`/`clearTag`/`clearTags`, with or without the `#` prefix. The kind itself is fixed by the factory. +- A field left alone is unconstrained; one set to an explicitly empty array matches nothing. Adding no values is a no-op. +- **`renderRoutes()` is abstract, so every kind states where its events live.** Two protected helpers cover the common shapes: `authorRoutes()` (the queried authors' outboxes) and `mentionRoutes()` (inboxes of the pubkeys a `#p` filter names). Content kinds use both; author-scoped kinds (lists, app data) use `authorRoutes()` alone; indexed kinds (`Profile`, `FollowList`, `RelayList`, `MessagingRelayList`) add `indexers()`; the 20 `requiresRelays` room/relay-management kinds return `[]`; `DirectMessage` returns `[userMessaging()]`. +- A query with nothing to route on resolves to **no relays**; there is no fallback to the user's own. `setRoutes(routes)` replaces the rendered routes (`[relay(url)]`, `[userInbox()]`, …), `addRoutes(routes)` requests those alongside them, and `setRoom(url, room)` scopes a NIP-29 query to one relay's room. +- Output methods mirror the writer's: `renderFilters()` → `Filter[]`, `relays()`/`scenario()` → the request relays, `render()` → both. +- Past `renderRoutes`, most query subclasses are empty. A kind that models a relationship overrides `renderDomainFilters()`, merged onto the base filter, one filter per variant. `CommentQuery.forRoot(event)`/`forParent(event)` emit a filter each for the `E`/`e` id and `A`/`a` address reference forms, since a relay ANDs a filter's fields, and route to each target author's inbox, where comments p-tagged to that author are delivered. + +## Routing: routes / forceRoutes / requiresRelays + +Publishing targets come from the `@welshman/util` `RelaySelection` DSL (`outbox`, `inbox`, `inboxes`, `userOutbox`, `seen`, `relay`, `relays`, `indexers`, …). A writer resolves them through `context.resolver`. + +- **Default routing** (`EventWriter.renderRoutes`): `[userOutbox(), ...inboxes(pTaggedPubkeys, 0.5)]` — deliver to the author's write relays (weight 1) and to every p-tagged pubkey's read relays (weight 0.5). Most kinds use this (`NoteWriter`, …). +- **`forceRoutes(...routes)`** sets `forcedRoutes`; when non-empty, `scenario()` publishes **only** there, bypassing `renderRoutes()`. It takes routes rather than urls, so `forceRoutes(userInbox())` pins an event to the user's read relays without resolving them first. `setRoom(url, room)` sets `forcedRoutes=[relay(url)]` **and** the `h` tag (NIP-29 room events); `clearForcedRoutes()`/`clearRoom()` undo it. +- **`requiresRelays`** (a readonly `true` on a subclass) makes `validate()` demand `forcedRoutes` — throwing `A kind N event must publish to explicit relays (via setRoom or forceRoutes)`. The 20 kinds that set it are all NIP-29 room ops/state and relay-management ops/state: `RoomCreate`, `RoomEdit`, `RoomDelete`, `RoomDeleteEvent`, `RoomJoin`, `RoomLeave`, `RoomAddMember`, `RoomRemoveMember`, `RoomMembers`, `RoomAdmins`, `RoomMeta`, `RoomPins`, `RoomUpdatePins`, `RoomCreatePermission`, `RelayJoin`, `RelayLeave`, `RelayInvite`, `RelayAddMember`, `RelayRemoveMember`, `RelayRole`, `RelayMembers`. (`RoomCreate` and `RoomJoin` additionally require a `roomTag` — call `setRoom`.) +- **Per-kind overrides.** Some writers replace `renderRoutes()`: `FollowListWriter`/`MuteListWriter`/`ReportWriter` publish to `[userOutbox()]` only (p-tags are data, not recipients); `RelayListWriter` adds `indexers()` and notifies every relay added to or removed from the list; `DeleteWriter` adds each deleted event's `seen` relays (and requires an `e`/`a` tag). + +The DSL constructors `relays(urls)` and `inboxes(pubkeys)` return **arrays** (spread with `...`); the others return a single `RelaySelection`. Note `relay(url)`/`relays(urls)` replaced the old `relayHint`/`relayHints`. + +## Async & signer notes + +- **Async surface:** all of `renderTemplate`/`render`/`scenario`/`relays`/`renderTags` are async. `reader(event).parse()` is async only for the kinds that decrypt (`ListReader` subclasses, `AppData`); every getter and every setter is synchronous. +- **Reading private list tags:** a `ListReader` only surfaces `privateTags` when the context carries the **author's own** signer (it decrypts NIP-44 content only when `signer.getPubkey() === event.pubkey`). Decryption failures are swallowed — `decrypted` stays `false` and private tags stay empty. +- **Writing private list tags:** `ListWriter.buildContent` is where encryption happens. If there are private tags it requires a signer and NIP-44-encrypts them to the author's own pubkey (`A signer is required to encrypt private tags`). If the source was never decrypted, the original ciphertext is preserved untouched (so you don't clobber tags you couldn't see). +- **`renderTemplate()`** needs a signer only for list kinds with non-empty private tags. All other kinds ignore it. Signing (`signer.sign(stamp(...))`) always needs a signer. +- **`d`-tag required** (base `validate` throws otherwise) for parameterized-replaceable kinds: `RelaySet` (30002), `BadgeDefinition` (30009), `Pinboard` (30067), `Classified` (30402), `Feed` (31890), `DateEvent` (31922), `TimeEvent` (31923), `CalendarRsvp` (31925), `HandlerRecommendation` (31989), `Handler` (31990), `RoomMeta` (39000), `RoomAdmins` (39001), `RoomMembers` (39002), `Pin` (39067). Call `setIdentifier()`. `Pin` additionally requires a content reference (`e`/`a`/`i` tag) — without a unique `d` tag per pin, every pin from the same author would collide at the same address, since kind 39067 is itself addressable. +- **Explicit relays required** (`requiresRelays`, see above): all NIP-29 room and relay-management ops. Call `setRoom(url, room)` (which sets both the `h` tag and the forced route) or `forceRoutes(...routes)`. + +## Kind classes + +Each row: kind# — NIP — Reader / Writer. + +### Profile + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 0 | NIP-01 | `ProfileReader` / `ProfileWriter` (factory `Profile`) | + +`ProfileReader`: `name`, `nip05`, `lnurl`, `about`, `banner`, `picture`, `website`, `display(fallback?)`. Writer: `update`, `setName`, `setNip05`, `setAbout`, `setBanner`, `setPicture`, `setWebsite`. (Also exports `parseLnUrl`, `displayPubkey`.) + +### Core notes / deletes / reactions + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 1 | NIP-01 / NIP-10 | `NoteReader` / `NoteWriter` (factory `Note`) | +| 5 | NIP-09 | `DeleteReader` / `DeleteWriter` (factory `Delete`) | +| 7 | NIP-25 | `ReactionReader` / `ReactionWriter` (factory `Reaction`) | +| 14 | NIP-17 | `DirectMessageReader` / `DirectMessageWriter` (factory `DirectMessage`) | +| 9802 | NIP-84 | `HighlightReader` / `HighlightWriter` (factory `Highlight`) | + +`DirectMessageReader`: `recipients()`, `subject()`, `parentId()`; writer `addRecipient`/`removeRecipient`/`setSubject`/`setParent`. The **writer** renders **no** routes (`[]`) — a NIP-17 message is published as one gift wrap per recipient, each to its own relays, so the caller fans it out (`app.use(Wraps).publish`). The **query** routes to `[userMessaging()]`. + +`HighlightReader`: `sources()`, `attributions()`, `mentions()`, `sourceContext()`, `comment()`, `references()`, `topics()`; writer `setSourceEvent`/`setSourceExternal`/`setSourceReference`, `addAttribution`/`removeAttribution`, `addMention`, `setSourceContext`/`clearSourceContext`, `setComment`/`clearComment`, `setTopics`. + +`NoteWriter.setParent(event)` — NIP-10 reply threading (p-tags the parent's participants, e/a-tags the parent and thread root with markers + relay hints). `DeleteReader`: `ids()`, `addresses()`, `kinds()`, `reason()`; `DeleteWriter`: `addEvent(event)`, `setReason(reason)` (routes to the deleted events' `seen` relays; requires an `e`/`a` tag). + +### Lists (ListReader/ListWriter — public/private split, NIP-44 encryption) + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 3 | NIP-02 | `FollowListReader` / `FollowListWriter` | +| 10000 | NIP-51 | `MuteListReader` / `MuteListWriter` | +| 10001 | NIP-51 | `PinListReader` / `PinListWriter` | +| 10002 | NIP-65 | `RelayListReader` / `RelayListWriter` | +| 10003 | NIP-51 | `BookmarkListReader` / `BookmarkListWriter` | +| 10004 | NIP-51 | `CommunityListReader` / `CommunityListWriter` | +| 10006 | NIP-51 | `BlockedRelayListReader` / `BlockedRelayListWriter` | +| 10007 | NIP-51 | `SearchRelayListReader` / `SearchRelayListWriter` | +| 10009 | NIP-51 | `RoomListReader` / `RoomListWriter` | +| 10014 | NIP-51 | `FeedListReader` / `FeedListWriter` | +| 10015 | NIP-51 | `TopicListReader` / `TopicListWriter` | +| 10030 | NIP-51 | `EmojiListReader` / `EmojiListWriter` | +| 10050 | NIP-17 | `MessagingRelayListReader` / `MessagingRelayListWriter` | +| 10063 | Blossom BUD-03 | `BlossomServerListReader` / `BlossomServerListWriter` | +| 30002 | NIP-51 | `RelaySetReader` / `RelaySetWriter` | + +`ListWriter` mutators (public/private split): `addPublic`/`addPrivate`, `keepPublic`/`keepPrivate`/`keepTags`, `dropPublic`/`dropPrivate`/`dropTags`. Each kind also exposes intent-named helpers, e.g. `FollowListWriter.follow(pubkey, relayHint?, petname?)`/`unfollow`, `MuteListWriter.mutePublicly`/`mutePrivately`/`unmute`, `RelayListWriter.addReadUrl`/`addWriteUrl`/`removeReadUrl`/`removeWriteUrl`/`setReadUrls`/`setWriteUrls`/`setTags`, `RoomListWriter.addRoom`/`removeRoom`/`addRelay`/`removeRelay`/`setRelays`. (Note `FollowList` is a plain `EventWriter`, not a `ListWriter` — follows are public.) + +### Rooms (NIP-29) + +Room ops are scoped by the `h` tag and must publish to explicit relays — use `setRoom(url, room)`. Metadata kinds 39000–39002 are addressable per room. + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 9000 | NIP-29 | `RoomAddMemberReader` / `RoomAddMemberWriter` | +| 9001 | NIP-29 | `RoomRemoveMemberReader` / `RoomRemoveMemberWriter` | +| 9002 | NIP-29 | `RoomEditReader` / `RoomEditWriter` | +| 9007 | NIP-29 | `RoomCreateReader` / `RoomCreateWriter` | +| 9005 | NIP-29 | `RoomDeleteEventReader` / `RoomDeleteEventWriter` | +| 9008 | NIP-29 | `RoomDeleteReader` / `RoomDeleteWriter` | +| 9021 | NIP-29 | `RoomJoinReader` / `RoomJoinWriter` | +| 9022 | NIP-29 | `RoomLeaveReader` / `RoomLeaveWriter` | +| 19004 | NIP-29 / Flotilla | `RoomCreatePermissionReader` / `RoomCreatePermissionWriter` | +| 39000 | NIP-29 | `RoomMetaReader` / `RoomMetaWriter` | +| 39001 | NIP-29 | `RoomAdminsReader` / `RoomAdminsWriter` | +| 39002 | NIP-29 | `RoomMembersReader` / `RoomMembersWriter` | +| 9010 | NIP-29 | `RoomUpdatePinsReader` / `RoomUpdatePinsWriter` — the pin op | +| 39005 | NIP-29 | `RoomPinsReader` / `RoomPinsWriter` — the relay-signed pin snapshot | + +`RoomMetaReader`: `name`, `about`, `picture`, `pictureMeta`, `isClosed`/`isHidden`/`isPrivate`/`isRestricted`/`hasLivekit`; writer `setName`/`setAbout`/`setPicture`/`setClosed`/`setHidden`/`setPrivate`/`setRestricted`/`setLivekit`. `RoomJoinReader`: `claim()`, `reason()` (free-text `content`); writer `setClaim`/`setReason`. `RoomAddMemberWriter.addPubkey`. + +### Relay membership (Flotilla "spaces" — relay-level, NIP-29-adjacent) + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 8000 | Flotilla | `RelayAddMemberReader` / `RelayAddMemberWriter` | +| 8001 | Flotilla | `RelayRemoveMemberReader` / `RelayRemoveMemberWriter` | +| — | Flotilla | `RelayRoleReader` / `RelayRoleWriter` | +| 13534 | Flotilla | `RelayMembersReader` / `RelayMembersWriter` | +| 28934 | Flotilla | `RelayJoinReader` / `RelayJoinWriter` | +| 28935 | NIP-29 | `RelayInviteReader` / `RelayInviteWriter` | +| 28936 | Flotilla | `RelayLeaveReader` / `RelayLeaveWriter` | + +`RelayMembersReader`: `pubkeys()`, `isMember(pk)`; writer `addPubkey(pk, role?)`/`removePubkey`/`setPubkeys` (its constructor calls `setProtected(true)` per NIP-43). All of these set `requiresRelays` — publish with `forceRoutes(relay(url))` or `setRoom`. + +### Handlers (NIP-89) + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 31989 | NIP-89 | `HandlerRecommendationReader` / `HandlerRecommendationWriter` | +| 31990 | NIP-89 | `HandlerReader` / `HandlerWriter` | + +`HandlerReader`: JSON content → `values: HandlerMeta`; getters `name`, `about`, `picture`, `website`, `lud16`, `nip05`, `kinds()`; writer `setName`/…/`setKinds(number[])`. Exports type `HandlerMeta`. + +### Zaps (NIP-57 / NIP-75) + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 9041 | NIP-75 | `ZapGoalReader` / `ZapGoalWriter` | +| 9734 | NIP-57 | `ZapRequestReader` / `ZapRequestWriter` | +| 9735 | NIP-57 | `ZapReceiptReader` / `ZapReceiptWriter` | + +`ZapRequestReader`: `amount`, `lnurl`, `recipient`, `eventId`, `urls` (the comment is the base `content()`). `ZapReceiptReader`: `bolt11`, `invoiceAmount`, `request`, `sender`, `recipient`, `eventId`, `comment`, `preimage`, plus `verify(zapper)`. + +### Content + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 11 | NIP-7D | `ThreadReader` / `ThreadWriter` | +| 20 | NIP-68 | `PictureReader` / `PictureWriter` | +| 1018 | NIP-88 | `PollResponseReader` / `PollResponseWriter` | +| 1068 | NIP-88 | `PollReader` / `PollWriter` | +| 1111 | NIP-22 | `CommentReader` / `CommentWriter` | +| 1984 | NIP-56 | `ReportReader` / `ReportWriter` | +| 30067 | Pinboards | `PinboardReader` / `PinboardWriter` | +| 30402 | NIP-99 | `ClassifiedReader` / `ClassifiedWriter` | +| 30078 | NIP-78 | `AppDataReader` / `AppDataWriter` | +| 31890 | NIP-51 | `FeedReader` / `FeedWriter` | +| 31922 | NIP-52 | `DateEventReader` / `DateEventWriter` | +| 31923 | NIP-52 | `TimeEventReader` / `TimeEventWriter` | +| 31925 | NIP-52 | `CalendarRsvpReader` / `CalendarRsvpWriter` | +| 39067 | Pinboards | `PinReader` / `PinWriter` | +| 30023 | NIP-23 | `ArticleReader` / `ArticleWriter` (factory `Article`) | +| 31992 | slash commands | `CommandReader` / `CommandWriter` (factory `Command`) | + +`CommentReader`: `root()`/`parent()`; writer `setRoot`/`setParent`/`setRootFromEvent`/`setParentFromEvent`, plus `replyTo(event)`, which parents on `event` and roots on the thread it is in — the root tags of a comment, `event` itself for any other kind. `PollReader`: `title`, `options`, `pollType`, `endsAt`, `isClosed`, `urls`, plus `results(responses)`; writer `addOption`, `setPollType`, `setEndsAt`. `ReportWriter`: `setPubkey`/`setEventId`/`setReason` (routes to `[userOutbox()]`). `DateEventReader` (31922): the all-day sibling of `TimeEvent`, with `start`/`end` as `YYYY-MM-DD` strings and `end` exclusive; writer `setTitle`/`setLocation`/`setStart`/`setEnd`, deriving the same `D` day-bucket tags (the start day alone when there is no end). `CalendarRsvpReader` (31925): `calendarEvent()` (the event's address), `calendarEventId()`, `status()`, `freebusy()`; writer `setCalendarEvent`/`setCalendarEventFromEvent` (which also e-tags and p-tags the organizer), `setStatus`/`setFreebusy`/`clearFreebusy`, where declining clears free/busy and `validate()` requires an event and a status; query `forCalendarEvent(address)`, which asks the organizer's inbox. `PictureReader` (20): `title`, `topics()`, `location`, `geohash`, plus the shared `imeta()`; writer `setTitle`/`setTopics`/`setLocation`/`setGeohash` and the shared `addImeta`/`removeImeta`. It derives the top-level `x` and `m` tags from its attached images, and `validate()` requires at least one image. Exported types: `CommentRef`, `ClassifiedPrice`, `PollType`, `PollOption`, `PollResult`, `PinReference`, `RsvpStatus`, `RsvpFreebusy`. + +`ArticleReader` (long-form, 30023): `title`, `summary`, `image`, `publishedAt`, `topics()`; writer `setTitle`/`setSummary`/`setImage`/`setPublishedAt`/`setTopics`. + +`CommandReader` (31992): `command`, `title`, `description`, `args()`, `scopes()`, plus `matches(target: CommandScopeTarget)`; writer `setCommand`/`setTitle`/`setDescription`/`setArgs`/`setScopes`. The invocation grammar itself (parsing/rendering `/command arg…`) lives in `@welshman/util`'s `Command.ts`, so non-domain callers can use it. + +`RoomPinsReader`/`RoomUpdatePinsReader`: `pins()`, `ids()`, `addresses()` (`RoomPinsReader` adds `isPinned`); writer `setPins`. Both set `requiresRelays`. + +`PinboardReader` (30067): `title`, `description`, `image`, `topics()`, `collaborative()`; writer `setTitle`/`setDescription`/`setImage`/`setTopics`/`setCollaborative`. `PinReader` (39067): `boards()`, `isProfilePin()`, `reference()` (a `PinReference` discriminated union), `title`, `topics()`; writer `addBoard`/`removeBoard`, `setEvent`/`setAddress`/`setExternal`, `setTitle`/`setTopics`. + +### Badges (NIP-58) + +| Kind | NIP | Reader / Writer | +|---|---|---| +| 8 | NIP-58 | `BadgeAwardReader` / `BadgeAwardWriter` | +| 30008 | NIP-58 | `ProfileBadgesReader` / `ProfileBadgesWriter` | +| 30009 | NIP-58 | `BadgeDefinitionReader` / `BadgeDefinitionWriter` | + +`BadgeDefinitionReader` (30009): `name`, `description`, `image()`, `thumbs()` (both `BadgeImage`, `{url, dim?}`); writer `setName`/`setDescription`/`setImage`/`setThumbs`, and `setIdentifier()` for the badge's slug. `BadgeAwardReader` (8): `badge()` (the definition's address), `awardees()`; writer `setBadge`/`addAwardee`/`removeAwardee`, and `validate()` requires both a badge and an awardee. `ProfileBadgesReader` (30008): `badges()` (a `ProfileBadge[]` of `{address, awardId, relay?}` read from consecutive `a`/`e` pairs, in display order), `includes(address)`; writer `setBadges`/`addBadge`/`removeBadge`. Its `d` tag is fixed at `profile_badges` by the writer, so it never needs `setIdentifier()`. Exported types: `BadgeImage`, `ProfileBadge`. + +## Using it from @welshman/app + +`@welshman/app`'s `Domain` plugin binds the app's dependencies (resolver from the `Router` plugin, repository, and a lazy signer getter) and memoizes one `ConfiguredKind` per factory: ```typescript import {Domain} from "@welshman/app" -import {Note, Profile} from "@welshman/domain" +import {FollowList, Note} from "@welshman/domain" -// Read: returns a parsed reader, ready to use -const note = app.use(Domain).reader(Note)(event) +// read side (event decoder for a data plugin): +eventToItem: app.use(Domain).reader(Note) // ConfiguredKind.reader -note.content() // typed accessors, no tag digging -note.author() -note.createdAt() +// fetch side — query → filters + relays: +const {filters, relays} = await app.use(Domain).query(Note).setAuthors([pubkey]).render() -// Write: build, then wrap in a Command to publish -const writer = app.use(Domain).writer(Note).setContent("hello").addMention(pubkey) -const command = await app.use(Domain).command(writer) - -await command.publish().waitForError() - -// Edit an existing event: seed the writer with its reader -const profile = app.use(Domain).reader(Profile)(profileEvent) -const edit = app.use(Domain).writer(Profile, profile).setName("new name") +// mutation side — writer → Command → publish: +const reader = existingReader // from a prior read, or undefined +const writer = app.use(Domain).writer(FollowList, reader).follow(pubkey) +const command = await app.use(Domain).command(writer) // render() + wrap +command.publish() // or .publishToRelays(urls) ``` -## Sync vs async readers +`Domain.command(writer)` requires a signed-in user, calls `writer.render()`, and returns a `Command` (`.publish()` / `.publishToRelays(urls)`). This replaces the old `Router.commandFromBuilder(builder)`. The `Router` plugin's `resolver` (a `Resolver`) is what dereferences every route to concrete relay urls. -Some kinds decrypt on parse (encrypted lists, app data), so `parse()` is async for them: +## Gotchas -- `EventReader.parse(): this` -- `AsyncEventReader.parse(): Promise` +- **Enter through a `ConfiguredKind`.** `Profile.configure(ctx).reader(event).parse()` to read, `configure(ctx).writer()` to build. In an app, `app.use(Domain).reader(Profile)` and `.writer(Profile)`. +- **The writer never signs.** `renderTemplate()` gives an `EventTemplate` you sign yourself (`signer.sign(stamp(await writer.renderTemplate()))`), or hand the writer to `Domain.command`. `renderTemplate`/`scenario`/`relays`/`render` take no arguments; dependencies come from the context. +- **Private list tags need the author's signer in the context.** With no signer (or someone else's) a `ListReader` yields only public tags; `decrypted` stays `false`. Bind the author's own signer to see private entries. +- **Don't clobber undecryptable lists.** If you edit a list you couldn't decrypt and try to write private tags, `validate()` throws `Unable to modify list when decryption was not performed`. Editing only public tags is fine — the original ciphertext is preserved. +- **Room / relay-management kinds need explicit relays.** They set `requiresRelays`, so `render()` throws unless you called `setRoom(url, room)` or `forceRoutes(...routes)`. `RoomCreate`/`RoomJoin` also require the `h` tag (use `setRoom`). +- **Parameterized-replaceable kinds throw without a `d` tag** — call `setIdentifier()` (or let it default to a random id). +- **`RoomJoin`/`RelayJoin`/`RelayInvite` read the invite code via `claim()`.** -`Parsed = ReturnType` captures which one you get. `app.use(Domain).reader(F)` -returns `Parsed` — the reader itself for sync kinds, a promise for async ones. That's exactly -what `EventToItem` accepts, so collections keep their synchronous path where the kind allows one. +## OLD → NEW migration -```typescript -const note = app.use(Domain).reader(Note)(event) // NoteReader -const list = await app.use(Domain).reader(MuteList)(event) // MuteListReader (decrypts) -``` - -## Reader base API - -Every reader inherits from `BaseEventReader`: - -| Method | Returns | +| Old API | New API | |---|---| -| `id()` | event id | -| `author()` | pubkey | -| `content()` | content string | -| `tags()` | raw tags (prefer a typed getter when one exists) | -| `createdAt()` | timestamp | -| `identifier()` | `d` tag | -| `address()` | `kind:pubkey:d` | -| `room()` | `h` tag | -| `protect()` | whether the event carries `-` (NIP-70) | -| `expiration()` | `expiration` tag as a number | -| `emojis()` | parsed `emoji` tags | -| `zapSplits()` | parsed `zap` tags | - -Kind-specific readers add their own on top — `RoomMetaReader.name()`, `ZapGoalReader.amount()`, -`RelayMembersReader.isMember(pubkey)`, and so on. - -## Writer base API - -Writers are chainable and validate on render: - -```typescript -writer - .setContent(content) - .addTags(...tags) - .dropTags(pred) // also keepTags(pred) - .setIdentifier(d) // defaults to a random id - .setRoom(url, h) // NIP-29 `h` tag + forced relay - .setProtected(true) // NIP-70 `-` - .setExpiration(ts) - .addMention(pubkey) - .addQuote(event) - .addZapSplit(pubkey, split) - .addEmoji(shortcode, url) - .forceRelays(...urls) // bypass routing for relay-scoped kinds -``` - -`readonly requiresRelays` marks kinds that are meaningless without an explicit relay (NIP-29 room -ops, relay membership). Subclasses override `validate()` to enforce kind-specific invariants — -e.g. `RoomUpdatePins` throws without a room, `ZapGoal` requires an amount and at least one relay. - -## Display helpers - -A few kinds export display helpers alongside their reader: - -```typescript -import {displayPubkey} from "@welshman/domain" - -profile.display("Anonymous") // ProfileReader: name/display_name, or the fallback -displayPubkey(pubkey) // 'npub1abc...xyz' -``` - -## Adding a new kind - -1. Add the kind constant to `@welshman/util`'s `Kinds.ts`. -2. Create `packages/domain/src/kinds/YourKind.ts` with a Reader, a Writer, and the factory: - -```typescript -import {uniq, spec} from "@welshman/lib" -import {YOUR_KIND, hexTags, tagValues} from "@welshman/util" -import {EventReader} from "../core/EventReader.js" -import {EventWriter} from "../core/EventWriter.js" -import {KindFactory} from "../core/Kind.js" - -export class YourKindReader extends EventReader { - pubkeys() { - return uniq(tagValues(hexTags("p"), this.event.tags)) - } -} - -export class YourKindWriter extends EventWriter { - readonly requiresRelays = true - - validate() { - super.validate() - - if (!this.roomTag) throw new Error("YourKind requires a room") - } - - setPubkeys(pubkeys: string[]) { - return this.dropTags(spec(["p"])).addTags(...uniq(pubkeys).map(pk => ["p", pk])) - } -} - -export const YourKind = new KindFactory({ - kind: YOUR_KIND, - reader: YourKindReader, - writer: YourKindWriter, -}) -``` - -3. Export it from `packages/domain/src/index.ts`. -4. Add `packages/domain/__tests__/YourKind.test.ts` — every kind has one. Use the `read`, `write`, - and `buildTemplate` helpers from `./helpers.js`, and cover: reading represented tags, - round-tripping without duplicating tags, and any `validate()` rule. - -## Available kinds - -Profile, Note, Comment, Thread, Delete, Reaction, Report, Poll, PollResponse, Classified, -TimeEvent, AppData, Handler, HandlerRecommendation, Feed, Pin, Pinboard. - -Lists: FollowList, MuteList, PinList, BookmarkList, TopicList, EmojiList, RelayList, RelaySet, -SearchRelayList, MessagingRelayList, BlossomServerList, BlockedRelayList, CommunityList, -FeedList, RoomList. - -Zaps: ZapRequest, ZapReceipt, ZapGoal. - -NIP-29 rooms: RoomMeta, RoomCreate, RoomEdit, RoomDelete, RoomJoin, RoomLeave, RoomAddMember, -RoomRemoveMember, RoomMembers, RoomAdmins, RoomCreatePermission, RoomPins, RoomUpdatePins. - -Relay membership: RelayInvite, RelayJoin, RelayLeave, RelayAddMember, RelayRemoveMember, -RelayMembers, RelayRole. +| `new Kind({reader, builder, router})` | `new KindFactory({kind, reader, writer, query})` | +| `Kind` class | `KindFactory` (+ `ConfiguredKind` after `.configure`) | +| `EventBuilder` (base) / `XBuilder` | `EventWriter` / `XWriter` | +| `ListBuilder` | `ListWriter` | +| `X.fromEvent(event)` / `Kind.factory(event)` / `Kind.read(event)` | `factory.configure(ctx).reader(event).parse()` (validates kind, then parses — await only for lists / app data) | +| `Kind.builder(...)` / `new XBuilder(...)` | `factory.configure(ctx).writer(reader?)` | +| `builder.toTemplate()` | `writer.renderTemplate()` → `Promise` | +| `builder.toEvent(signer)` | `signer.sign(stamp(await writer.renderTemplate()))` | +| `builder.toRumor(signer)` | `prep(await writer.renderTemplate(), await signer.getPubkey())` | +| `Router.commandFromBuilder(builder)` | `app.use(Domain).command(writer)` | +| `parse(signer)` | `parse()` (reads `def.context.signer`) | +| `builder.finalize(context)` | `writer.render()` (no arg; context bound at `configure`) | +| standalone `resolve(...)` | `new Resolver(routeResolver, options)` → `.scenario`/`.relays`/`.relay` | +| `relayHint(url)` / `relayHints(urls)` | `relay(url)` / `relays(urls)` | +| — (new) | `inboxes(pubkeys, weight?)`, `Resolver`, `RelayScenario`, `KindContext` | ## Related skills -- `welshman-app` — the `Domain` plugin, `Command`, and the collections that decode events for you -- `welshman-util` — kind constants, tag specs (`tagValue`, `hexTags`, `addressTags`), filters -- `welshman-store` — indexing decoded readers into reactive collections +- `welshman-util` — the raw `TrustedEvent`/`EventTemplate` types, kind constants, tag getters, and the `RelaySelection` routing DSL + `Resolver`/`RelayScenario` these classes build on. +- `welshman-app` — the instance-based app layer whose `Domain` plugin supplies the `KindContext`, whose `Router` plugin supplies the resolver, and whose data plugins use these readers as `eventToItem`. +- `welshman-signer` — the `ISigner` interface and NIP-44 `decrypt`/`encrypt` used for private list tags and for signing rendered templates. diff --git a/.agents/skills/welshman-feeds/SKILL.md b/.agents/skills/welshman-feeds/SKILL.md index 35c02879..e9df2de1 100644 --- a/.agents/skills/welshman-feeds/SKILL.md +++ b/.agents/skills/welshman-feeds/SKILL.md @@ -326,6 +326,7 @@ console.log('Authors in feed:', [...authors]) - **Always use factory functions** (`makeAuthorFeed`, etc.) rather than constructing raw tuples — the tuple structure is internal and type safety depends on using factories. - **`useWindowing: true`** is for relays that may return events out of chronological order. Do not use it for DVM/algorithmic feeds where order is part of the result. +- **Windowing is calibrated with COUNT.** From the second page on, a windowed loader asks the relays how many events its next window holds (NIP-45, through `countPage`) and scales the window toward a full page, up to three probes. The first page keeps the window `guessFilterDelta` picked, so no events wait on a count, and relays that do not support COUNT get the blind widening instead. - **`FeedController.load()` is stateful** — each call continues from where the last left off (pagination). Create a new controller to reset. - **`canCompile` returns `false` only for `FeedType.Difference`** (and recursively for `Union`/`Intersection` whose sub-feeds include a `Difference`). DVM and List feeds return `true` from `canCompile` and are compiled asynchronously by `_compileDvms` and `_compileLists` inside the compiler's `compile` method. The feeds handled specially by `FeedController` (outside the compiled request flow) are `Difference`, `Union`, and `Intersection` — but only when `canCompile` returns `false` for them. - **`simplifyFeed`** flattens nested same-type set operations (e.g. `union(union(a,b), c)` → `union(a,b,c)`). Run it before storing or serializing feed definitions. diff --git a/.agents/skills/welshman-net/SKILL.md b/.agents/skills/welshman-net/SKILL.md index dadf880b..5c1e6037 100644 --- a/.agents/skills/welshman-net/SKILL.md +++ b/.agents/skills/welshman-net/SKILL.md @@ -54,12 +54,13 @@ Every entry point (`request`, `requestOne`, `publish`, `publishOne`, `makeLoader | `requestOne(options)` | Subscribe to a single relay; returns `Promise` | | `request(options)` | Subscribe to multiple relays in parallel; returns `Promise` | | `makeLoader(options)` | Creates a batching `load` function with configurable delay/timeout/threshold | -| `load(options)` | Pre-built loader with a 30 ms batch delay, 3 s timeout and 0.5 threshold. It auto-closes after EOSE, timeout, or disconnect, and resolves when half the relays' subscriptions have closed. It carries no context, so it only works where no pool or repository is needed. | +| `loadComplete(options)` | Pre-built loader with a 30 ms batch delay and a 3 s timeout. It auto-closes after EOSE, timeout, or disconnect, and resolves once every relay's subscription has closed, so an empty result means every relay answered nothing. It carries no context, so it only works where no pool or repository is needed. | +| `loadLenient(options)` | The same loader with a 0.5 threshold, resolving once half the relays have closed. The rest stay open and their events still arrive, so use it where nothing routes on the returned array. | `request` / `requestOne` options (`BaseRequestOptions`): - `relay` / `relays` — relay url(s) - `filters` — array of nostr `Filter` objects -- `autoClose?: boolean` — close the subscription after EOSE or on socket disconnect +- `autoClose?: boolean` — close the subscription after EOSE, on socket disconnect, or 30 seconds in, whichever comes first, so a relay that answers nothing can't hold the request open - `signal?: AbortSignal` — cancellation - `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`) - `context?: AdapterContext` @@ -73,6 +74,17 @@ Every entry point (`request`, `requestOne`, `publish`, `publishOne`, `makeLoader Without `autoClose` or a `signal`, `requestOne` streams indefinitely. The returned promise only resolves if the relay sends CLOSED for all active subscription ids. +### Count + +| Export | Description | +|--------|-------------| +| `countOne(options)` | Asks one relay how many events match some filters (NIP-45); returns `Promise` | +| `count(options)` | Asks several relays and takes the highest answer; returns `Promise` | +| `supportsCount(relay)` | Whether that relay has answered a COUNT this session — `true`, `false`, or `undefined` if it has not been asked | +| `COUNT_TIMEOUT` | 3000 ms, how long a relay has to answer before it counts as refusing | + +Options are `{relay | relays, filters, context?, signal?}`. `undefined` means no relay supports COUNT, which is a different answer from `0`. NIP-45 is optional and a NIP-11 document is not always honest about it, so support is read off the wire: a relay that answers with CLOSED, or says nothing at all, is remembered and not asked again. Relays hold overlapping subsets of the network, so what `count` returns is a lower bound on the total rather than the total. + ### Publish | Export | Description | @@ -169,8 +181,8 @@ Emits `"update"` with a `RepositoryUpdate` on every change. | Export | Description | |--------|-------------| | `RelayMessageType` / `ClientMessageType` | Enums of relay→client and client→relay message types | -| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, … | Type guards for relay messages | -| `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, … | Type guards for client messages | +| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, `isRelayCount()`, … | Type guards for relay messages | +| `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, `isClientCount()`, … | Type guards for client messages | | `matchReason(prefix, reason)` / `RelayReasonPrefix` | Match a relay's machine-readable `OK`/`CLOSED` reason prefix (`auth-required:`, `restricted:`, …) | ### WrapManager @@ -221,8 +233,10 @@ socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}]) import {makeLoader} from '@welshman/net' // Bind a loader to the context once; concurrent calls within `delay` collapse -// into a single REQ per relay. -const load = makeLoader({delay: 30, timeout: 3000, threshold: 0.5, context}) +// into a single REQ per relay. Without a `threshold` it resolves when every relay has closed +// or timed out, which is the exported `loadComplete`; `threshold: 0.5` resolves at half, which is +// `loadLenient`. +const load = makeLoader({delay: 30, timeout: 3000, context}) const events = await load({ relays: ['wss://relay.example.com', 'wss://relay2.example.com'], diff --git a/.agents/skills/welshman-signer/SKILL.md b/.agents/skills/welshman-signer/SKILL.md index 4c36ea14..42edf859 100644 --- a/.agents/skills/welshman-signer/SKILL.md +++ b/.agents/skills/welshman-signer/SKILL.md @@ -237,6 +237,7 @@ const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext) - **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first. - **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app, after calling `setNip55Plugin(NostrSignerPlugin)` and confirming `getNip55()` returns apps. Without the plugin, `getNip55()` returns `[]` rather than throwing, so it doubles as the feature check. - **`waitForNostrconnect` holds an open subscription.** Always pass an `AbortSignal` (e.g., from `new AbortController().signal`) so you can cancel if the user navigates away. +- **A wrap that needs proof of work is mined by its wrapper.** `Nip59` knows nothing about NIP-13; pass your own wrapper with `withWrapper`, then `wrapper.sign(await minePow(wrap, difficulty))` on what comes back. Mining changes the id, so only the key that signed the wrap can sign it again — an ephemeral wrapper `Nip59` made for itself is gone by then. - **`makeSecret()`** (from `@welshman/util`) generates a cryptographically secure random hex private key. Use it for the `clientSecret` in NIP-46 — never reuse the user's actual private key as the client secret. - **`nip59.wrap()` returns the gift-wrap `SignedEvent` directly** — the return value itself is the kind-1059 event to publish. There is no `.wrap` sub-property on the return value. - **Both `nip04` and `nip44` are supported** on all signers. Prefer `nip44` for new code; `nip04` is provided for backwards compatibility with older clients. diff --git a/.agents/skills/welshman-store/SKILL.md b/.agents/skills/welshman-store/SKILL.md index 46fcc2a3..ed38f111 100644 --- a/.agents/skills/welshman-store/SKILL.md +++ b/.agents/skills/welshman-store/SKILL.md @@ -320,7 +320,7 @@ const loadBookmark = makeLoadItem( async (pubkey: string) => { const scenario = await app.use(Router).resolve([outbox(pubkey)]) - await app.use(Network).load({ + await app.use(Network).loadComplete({ relays: scenario.getUrls(), filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }], }) diff --git a/.agents/skills/welshman-util/SKILL.md b/.agents/skills/welshman-util/SKILL.md index 3ac02347..a994160e 100644 --- a/.agents/skills/welshman-util/SKILL.md +++ b/.agents/skills/welshman-util/SKILL.md @@ -75,8 +75,10 @@ NIP-10 and NIP-22 threading lives on the `Note` and `Comment` classes in `@welsh | Export | Description | |--------|-------------| -| `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits; returns a `ProofOfWork` | -| `getPow(event)` | Leading zero bits on an event's id | +| `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits, in a web worker; returns a `ProofOfWork` | +| `minePow(event, difficulty, signal?)` | The same search without a worker, so it runs under node; returns `Promise` | +| `countLeadingZeroes(id)` | Leading zero bits on an id | +| `getPow(event)` | The difficulty an event's `nonce` tag commits to, or 0 when its id does not reach it | | `estimateWork(difficulty)` / `benchmarkDifficulty` | Rough cost estimate for a difficulty target | ### Event Kinds (constants) @@ -542,7 +544,7 @@ sendManagementRequest(url: string, request: ManagementRequest, authEvent: Signed // ManagementResponse = { result?: any; error?: string } ``` -Requests are built by `make*` factories rather than an enum: `makeBanPubkey`, `makeAllowPubkey`, `makeBanEvent`, `makeAllowEvent`, `makeCreateRole`/`makeEditRole`/`makeDeleteRole`, `makeAssignRole`/`makeUnassignRole`, `makeAssignMethod`/`makeUnassignMethod`, `makeCreateClaim`/`makeDeleteClaim`/`makeListClaims`, `makeChangeRelayName`/`Description`/`Icon`, `makeAllowKind`/`makeDisallowKind`, `makeBlockIp`/`makeUnblockIp`, `makeSignEvent`, `makeSupportedMethods`, and the matching `makeList*` readers. +Requests are built by `make*` factories rather than an enum: `makeBanPubkey`, `makeAllowPubkey`, `makeBanEvent`, `makeAllowEvent`, `makeCreateRole`/`makeEditRole`/`makeDeleteRole`, `makeAssignRole`/`makeUnassignRole`, `makeAssignMethod`/`makeUnassignMethod`/`makeListMethodAssignees`, `makeCreateClaim`/`makeDeleteClaim`/`makeListClaims`, `makeChangeRelayName`/`Description`/`Icon`, `makeAllowKind`/`makeDisallowKind`, `makeBlockIp`/`makeUnblockIp`, `makeSupportedMethods`, and the matching `makeList*` readers. `ManagementApi` is a client class that pairs a relay url with a `ManagementSign` function so you don't have to build the NIP-98 auth event per call. `app.use(RelayManagement).forUrl(url)` returns one bound to the app's user. diff --git a/package.json b/package.json index 28ab6781..160511dc 100644 --- a/package.json +++ b/package.json @@ -97,16 +97,16 @@ "@types/throttle-debounce": "^5.0.2", "@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/sveltekit": "^1.1.0", - "@welshman/app": "^0.11.0", - "@welshman/content": "^0.11.0", - "@welshman/domain": "^0.11.0", - "@welshman/editor": "^0.11.0", - "@welshman/feeds": "^0.11.0", - "@welshman/lib": "^0.11.0", - "@welshman/net": "^0.11.0", - "@welshman/signer": "^0.11.0", - "@welshman/store": "^0.11.0", - "@welshman/util": "^0.11.0", + "@welshman/app": "^0.11.2", + "@welshman/content": "^0.11.2", + "@welshman/domain": "^0.11.2", + "@welshman/editor": "^0.11.2", + "@welshman/feeds": "^0.11.2", + "@welshman/lib": "^0.11.2", + "@welshman/net": "^0.11.2", + "@welshman/signer": "^0.11.2", + "@welshman/store": "^0.11.2", + "@welshman/util": "^0.11.2", "cheerio": "^1.2.0", "compressorjs-next": "^1.1.2", "dompurify": "^3.4.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee8b0ef1ad1617e6cdcde40413d5c1bc5bd7b8a7..f38b7b30fff7a8071c3ae53f92101d1bfb9c8dd1 100644 GIT binary patch delta 3812 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