32 KiB
| name | description |
|---|---|
| welshman-domain | 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, Writers & Routing for Nostr Kinds
Overview
@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
TrustedEventwith synchronous getters (profile.name(),followList.pubkeys()), - a Writer — a mutable, chainable producer of an
EventTemplateplus the relays to publish it to (writer.renderTemplate()/writer.render()), and - a Query — a mutable, chainable producer of the
Filters 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
npm install @welshman/domain
# or
pnpm add @welshman/domain
Peer deps: @welshman/lib, @welshman/util, @welshman/signer, @welshman/net, @welshman/feeds, and nostr-tools.
Core mental model
- A kind is a
KindFactory; bind it once withconfigure. Each exported kind constant isnew KindFactory({kind, reader, writer, query})— e.g.export const Note = new KindFactory({kind: NOTE, reader: NoteReader, writer: NoteWriter, query: NoteQuery}). CallNote.configure(context)to get aConfiguredKindcarrying the app'sresolverand an optionalsigner, which is allKindContextholds. In an app you never callconfigureyourself —@welshman/app'sDomainplugin does it and memoizes the result. - 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. - Reading is sync unless the kind decrypts; getters are always sync. You enter through
configured.reader(event).parse(), which validatesevent.kind(throwingExpected a kind X event, got kind Y) and parses.parse()returns the reader for kinds that extendEventReaderand a promise of it for kinds that extendAsyncEventReader— only the six private-tag lists andAppData, which have to decrypt.awaitworks for both, andParsed<R>is the type of whichever one a reader yields. - Building is chainable; output is async. Setters return
this; you finish withawait w.renderTemplate()(anEventTemplate),await w.relays()(publish urls), orawait w.render()(both). None of these take arguments — the signer, resolver, and repository come from the context bound atconfigure. - 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. - 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.
import {Profile, FollowList} from "@welshman/domain"
// 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()
ConfiguredKind.reader / .writer / .query are instance arrow-function properties, so you can destructure them (const {reader} = Profile.configure(ctx)).
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
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— runsvalidate(), then renders tags and content. A tag that carries a relay hint fills its hint slot through the protectedhint(...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()andrelays()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:
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
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 forAuthors), or set/clear for the scalars (setSince,setUntil,setLimit,setSearch). Tag filters go throughsetTag(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) andmentionRoutes()(inboxes of the pubkeys a#pfilter names). Content kinds use both; author-scoped kinds (lists, app data) useauthorRoutes()alone; indexed kinds (Profile,FollowList,RelayList,MessagingRelayList) addindexers(); the 20requiresRelaysroom/relay-management kinds return[];DirectMessagereturns[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, andsetRoom(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 overridesrenderDomainFilters(), merged onto the base filter, one filter per variant.CommentQuery.forRoot(event)/forParent(event)emit a filter each for theE/eid andA/aaddress 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)setsforcedRoutes; when non-empty,scenario()publishes only there, bypassingrenderRoutes(). It takes routes rather than urls, soforceRoutes(userInbox())pins an event to the user's read relays without resolving them first.setRoom(url, room)setsforcedRoutes=[relay(url)]and thehtag (NIP-29 room events);clearForcedRoutes()/clearRoom()undo it.requiresRelays(a readonlytrueon a subclass) makesvalidate()demandforcedRoutes— throwingA 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. (RoomCreateandRoomJoinadditionally require aroomTag— callsetRoom.)- Per-kind overrides. Some writers replace
renderRoutes():FollowListWriter/MuteListWriter/ReportWriterpublish to[userOutbox()]only (p-tags are data, not recipients);RelayListWriteraddsindexers()and notifies every relay added to or removed from the list;DeleteWriteradds each deleted event'sseenrelays (and requires ane/atag).
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/renderTagsare async.reader(event).parse()is async only for the kinds that decrypt (ListReadersubclasses,AppData); every getter and every setter is synchronous. - Reading private list tags: a
ListReaderonly surfacesprivateTagswhen the context carries the author's own signer (it decrypts NIP-44 content only whensigner.getPubkey() === event.pubkey). Decryption failures are swallowed —decryptedstaysfalseand private tags stay empty. - Writing private list tags:
ListWriter.buildContentis 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 (basevalidatethrows 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). CallsetIdentifier().Pinadditionally requires a content reference (e/a/itag) — without a uniquedtag 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. CallsetRoom(url, room)(which sets both thehtag and the forced route) orforceRoutes(...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 (its reader has pubkeys(), words(), topics(), ids(), addresses() over public and decrypted private tags), 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. RoomAdminsReader: pubkeys(), rolesFor(pk), rolesByPubkey() over ["p", pubkey, ...roles] tags; writer addPubkey(pk, roles?) (omitting roles keeps the admin's existing ones), removePubkey, setPubkeys (keeps retained admins' roles).
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), rolesFor(pk), rolesByPubkey() over ["member", pubkey, ...roleIds] tags; writer addPubkey(pk, roles?) (omitting roles keeps existing ones)/removePubkey/setPubkeys (keeps retained members' roles) (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(address, relay?)/setCalendarEventFromEvent(event, relay?) (which also e-tags and p-tags the organizer; a given relay is the hint for a and e, otherwise the e hint comes from the organizer's outbox), 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:
import {Domain} from "@welshman/app"
import {FollowList, Note} from "@welshman/domain"
// read side (event decoder for a data plugin):
eventToItem: app.use(Domain).reader(Note) // ConfiguredKind.reader
// fetch side — query → filters + relays:
const {filters, relays} = await app.use(Domain).query(Note).setAuthors([pubkey]).render()
// 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)
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.
Gotchas
- 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 anEventTemplateyou sign yourself (signer.sign(stamp(await writer.renderTemplate()))), or hand the writer toDomain.command.renderTemplate/scenario/relays/rendertake 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
ListReaderyields only public tags;decryptedstaysfalse. 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()throwsUnable 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, sorender()throws unless you calledsetRoom(url, room)orforceRoutes(...routes).RoomCreate/RoomJoinalso require thehtag (usesetRoom). - Parameterized-replaceable kinds throw without a
dtag — callsetIdentifier()(or let it default to a random id). RoomJoin/RelayJoin/RelayInviteread the invite code viaclaim().
OLD → NEW migration
| Old API | New API |
|---|---|
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<EventTemplate> |
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-util— the rawTrustedEvent/EventTemplatetypes, kind constants, tag getters, and theRelaySelectionrouting DSL +Resolver/RelayScenariothese classes build on.welshman-app— the instance-based app layer whoseDomainplugin supplies theKindContext, whoseRouterplugin supplies the resolver, and whose data plugins use these readers aseventToItem.welshman-signer— theISignerinterface and NIP-44decrypt/encryptused for private list tags and for signing rendered templates.