Readers & Writers
Every kind in @welshman/domain is a thin subclass of four base classes:
EventReader— read-only view over one event.EventWriter— mutable producer of an event template.ListReader—EventReaderwith a public/private (encrypted) tag split.ListWriter—EventWriterwith the same split, and NIP-44 encryption baked into its build step.
Understanding these four classes means you understand every kind: the per-kind files (Profile, FollowList, ZapReceipt, …) only add getters and setters on top of the machinery described here.
Configuring a kind
Readers and writers need dependencies — a Resolver to turn abstract relay routes into urls, an optional ISigner for encryption/decryption, and an optional Repository so routers can find event parents. Those are bundled in a KindContext:
export type KindContext = {
resolver: Resolver // from @welshman/util
signer?: ISigner // from @welshman/signer
repository?: Repository // from @welshman/net
}Each exported kind is a KindFactory — the pairing of a reader class and a writer class, with no dependencies bound yet:
export const Profile = new KindFactory({reader: ProfileReader, writer: ProfileWriter})You bind dependencies once with configure(context), which returns a ConfiguredKind. The configured kind is your entry point for building readers and writers:
import {Profile} from "@welshman/domain"
const profile = Profile.configure(context) // ConfiguredKind
const reader = profile.reader(event).parse() // reader, parsed
const writer = profile.writer() // fresh writer
const writer2 = profile.writer(reader) // writer seeded from a reader (edit)reader, writer, and router are instance arrow-function properties, so they are safe to destructure or pass point-free.
In @welshman/app you never call configure yourself — the Domain plugin does it for you, memoized per factory, wiring in the app's Router.resolver, repository, and a lazy signer. See With @welshman/app below.
EventReader
A Reader wraps a single TrustedEvent and answers questions about it. It is abstract — each kind pins its kind — but the construction, parsing, and base getters all live here.
Construction and parse
ConfiguredKind.reader(event) validates the event's kind (throwing Expected a kind X event, got kind Y on mismatch) and constructs the reader unparsed. Chain parse() to populate it — it returns the reader, so you keep reading straight off the call:
import {Profile, MuteList} from "@welshman/domain"
// Most kinds parse without IO, so there is nothing to await
const profile = Profile.configure(context).reader(event).parse()
// Kinds that decrypt return a promise, and the type makes you await it
const mutes = await MuteList.configure(context).reader(event).parse()Whether parse() is async is a property of the kind, declared by the base class its reader extends:
abstract class EventReader extends BaseEventReader {
parse(): this // no IO — Profile, Note, RelayList, …
}
abstract class AsyncEventReader extends BaseEventReader {
abstract parse(): Promise<this> // decrypts — the ListReader kinds, AppData
}Only the six private-tag lists (ListReader subclasses) and AppDataReader are async; the other 46 kinds parse synchronously. Code that doesn't know the kind can always write await kind.reader(event).parse() — awaiting a non-promise is a no-op — and the exported Parsed<R> type names whichever result a given reader yields.
parse takes no arguments — a subclass that needs the signer reads it from this.context.signer. The base implementation is a no-op; subclasses override it to decode what they need and return this:
ProfileReader.parseJSON-parsesevent.contentinto avaluesobject.ZapReceiptReader.parsedecodes the embedded zap-request JSON out of thedescriptiontag.ListReader.parsedecrypts the private tags (see below).
Everything downstream — the getters — is synchronous either way.
Getters
All base getters are synchronous reads over the wrapped event:
| Getter | Returns |
|---|---|
id() | event.id |
author() | event.pubkey |
content() | event.content |
tags() | event.tags (overridden by ListReader to merge public + private) |
createdAt() | event.created_at |
identifier() | the d tag value |
address() | the replaceable address kind:pubkey:d (via getAddress) |
room() | the NIP-29 h tag value |
protect() | true if a ["-"] tag is present |
expiration() | the parsed expiration tag as a number, or undefined |
const reader = await SomeKind.configure(context).reader(event).parse()
reader.author() // pubkey
reader.identifier() // d tag, if any
reader.expiration() // number | undefinedEach subclass adds its own getters on top — profile.name(), followList.pubkeys(), zapGoal.amount(), and so on.
Readers are getter-only — they do not compute routes. Routing (below) is a writer concern, for publishing; deciding where to fetch a kind's events is the request/loader layer's job, not the reader's.
A room (h-tagged) event routes to the relays it was seen on; otherwise it routes to its author's outbox. scenario() resolves those routes through the configured Resolver into a RelayScenario.
EventWriter
A Writer is a mutable, chainable producer of an EventTemplate. Get an empty one with configuredKind.writer() to author a new event, or seed one from a reader with configuredKind.writer(reader) to edit an existing one. Every setter returns this.
Construction and extra-tag passthrough
constructor(readonly def: AnyConfiguredKind, readonly reader?: Reader)When you pass a reader, the writer seeds content from reader.event.content and copies all of event.tags into extraTags. It then consumes the tags it manages — h, -, expiration, and d — lifting each out of extraTags into a dedicated field (roomTag, protectTag, expirationTag, identifierTag).
Whatever remains in extraTags is passed through verbatim when the event is rebuilt. This is the extra-tag passthrough guarantee: tags the package does not model (or a subclass does not claim) survive an edit round-trip instead of being silently dropped.
protected consumeTags(key: string): Tag[]Subclasses call consumeTags in their own constructors to lift the tags they understand out of the passthrough set. For example ListWriter takes over everything left as publicTags.
Setters
The base behavior setters, all chainable:
someKind.writer()
.setContent("…")
.setRoom(relayUrl, roomId) // h tag + forcedRelays / clearRoom()
.setProtected(true) // ["-"] tag
.setExpiration(timestamp) // / clearExpiration()
.setIdentifier() // d tag (defaults to a random id) / clearIdentifier()setIdentifier(identifier = randomId()) defaults to a freshly generated id, which is what you want for new parameterized-replaceable events. Each subclass adds its own setters (setName, follow, setAmount, …) on top of these.
Free-form tags can be added and filtered directly:
writer
.addTags(["t", "nostr"]) // append to extraTags
.keepTags(tag => tag[0] !== "t") // keep matching
.dropTags(tag => tag[0] === "t") // drop matchingShared tag / hint helpers
Several helpers emit tags carrying a relay hint — a deferred Hint occupying the relay-hint slot, which render() later dereferences to a single url:
writer.tagPubkey(pubkey, petname?) // ["p", pubkey, <hint>, petname]
writer.addQuote(event, relay?) // ["q", id, relay ?? <hint>, pubkey]
writer.addZapSplit(pubkey, split = 1) // ["zap", pubkey, <hint>, String(split)]The build pipeline
Subclasses customize the output by overriding two protected hooks (both may be async, both receive the optional signer) and validate:
protected buildTags(signer?): MaybeAsync<Tag[]> // default: [] — kind-specific tags
protected buildContent(signer?): MaybeAsync<string> // default: this.content
protected validate(): voidvalidate by default throws:
A d tag is required for kind Xfor parameterized-replaceable kinds with no identifier;A room event requires a relay url (set the room via setRoom)when aroomTagis set butforcedRelaysis empty;A kind X event must publish to explicit relays (via setRoom or forceRelays)whenrequiresRelaysis true butforcedRelaysis empty.
Subclasses call super.validate() and add their own checks — DeleteWriter requires at least one e/a tag, and so on.
Tags are assembled as:
[...buildTags(), ...behaviorTags, ...extraTags]where behaviorTags are the present ones among roomTag, protectTag, expirationTag, identifierTag. That ordering is the passthrough in action: kind-specific tags first, then the behavior tags, then the untouched leftovers.
Output methods
There is no toTemplate/toEvent/toRumor on the writer. Instead it produces an unsigned template, and the caller signs it. All are async and take no arguments — the context (signer, resolver) was injected at configure:
const template = await writer.renderTemplate() // EventTemplate {kind, content, tags}
const relays = await writer.relays() // string[] — where to publish
const {event, relays} = await writer.render() // both at oncerenderTemplate() is the heart of it: it runs validate(), resolves every in-tag Hint to a single url via context.resolver.relay(...) (falling back to ""), builds the content (encrypting for list kinds), and returns {kind, content, tags}. render() calls it and pairs the template with the resolved relay list.
To sign, hand the template to a signer — this is what the app and the test helpers do:
import {stamp} from "@welshman/util"
const signed = await signer.sign(stamp(await writer.renderTemplate()))Routing a writer
A writer computes where the event should be published. The default renderRoutes() targets the author's outbox plus every p-tagged pubkey's inbox:
protected async renderRoutes(): Promise<RelaySelection[]> {
return [userOutbox(), ...inboxes(tagValues(hexTags("p"), await this.renderTags()), 0.5)]
}renderTags() returns the fully-assembled tags with every Hint rendered as "" — the view used for routing. scenario()/relays() resolve these routes through the configured Resolver.
Kinds override renderRoutes() when the default is wrong. For instance FollowListWriter and MuteListWriter route to [userOutbox()] only (their p-tags are data, not recipients), and DeleteWriter adds each deleted event's seen relays.
Forced relays and required relays
Some events must go to specific relays regardless of outbox/inbox routing — NIP-29 room events, relay-management ops, and so on. Two mechanisms cover this:
writer.setRoom(url, room) // forcedRelays = [url] AND h tag = ["h", room]
writer.forceRelays(...urls) // forcedRelays = urls (no h tag)
writer.clearRoom() // clears both
writer.clearForcedRelays() // clears forcedRelaysWhen forcedRelays is non-empty, scenario() publishes only to those relays, bypassing renderRoutes() entirely.
A kind can also declare it requires explicit relays by overriding the readonly field:
readonly requiresRelays = truevalidate() then refuses to render until forcedRelays is set (via setRoom or forceRelays). The kinds that set this are all NIP-29 room ops/state and all relay-management ops/state — RoomCreate, RoomEdit, RoomDelete, RoomJoin, RoomLeave, RoomAddMember, RoomRemoveMember, RoomMembers, RoomAdmins, RoomMeta, RoomCreatePermission, RelayJoin, RelayLeave, RelayInvite, RelayAddMember, RelayRemoveMember, RelayRole, and RelayMembers. (RoomCreate additionally requires a roomTag.)
ListReader
NIP-51-style lists split their tags into a public set and a private (encrypted) set. ListReader extends AsyncEventReader — decryption is exactly what makes these kinds' parse() a promise — and handles the decryption.
decrypted = false
publicTags: string[][] = []
privateTags: string[][] = []Its parse override:
- Sets
publicTags = event.tags. - If
event.contentis empty, there is nothing to decrypt →decrypted = true. - Otherwise, if a signer is configured and it belongs to the event's author (
signer.getPubkey() === event.pubkey), it decrypts the content, marksdecrypted = true, parses the JSON array, and keeps only well-formed string-tuple tags intoprivateTags. A decryption failure is swallowed —decryptedsimply staysfalse.
The practical consequence: private tags only appear when the configured signer is the list author's. Reading someone else's list, or your own list without a signer, gives you the public tags only.
// context.signer must be the list author's for private tags to decrypt
const list = await MuteList.configure(context).reader(event).parse()
list.pubkeys() // includes both public and private mutes, when decryptedtags() is overridden to return [...publicTags, ...privateTags], so every inherited getter that reads this.tags() transparently sees the merged view.
ListWriter
ListWriter extends EventWriter with the same public/private split and a chainable set of tag mutators. Its constructor takes over the leftover extraTags as publicTags (this.publicTags = this.extraTags.splice(0)) and copies privateTags from the reader.
Tag mutators
All chainable (return this):
writer
.addPublic(...tags) // append to public set
.addPrivate(...tags) // append to private (encrypted) set
.keepPublic(pred) // filter public to matches; also keepPrivate, keepTags (both)
.dropPublic(pred) // filter out matches; also dropPrivate, dropTags (both)Subclasses build their domain methods on these. For instance MuteListWriter exposes mutePublicly (public) vs mutePrivately (private), and RoomListWriter exposes addRoom/removeRoom/addRelay/removeRelay.
validate
protected validate()ListWriter.validate throws Unable to modify list when decryption was not performed if the source event had encrypted content that was never decrypted (because the configured signer was not the author's) yet you are trying to write private tags. This guards against clobbering private data you could not read.
buildContent: where encryption lives
This is the important part. In the old @welshman/util design, encryption was a separate Encryptable wrapper you composed around an event. In @welshman/domain it is folded directly into the list writer's buildContent:
protected async buildContent(signer?: ISigner): Promise<string> {
// Preserve the original ciphertext when we never decrypted it.
if (this.reader?.decrypted === false) return this.reader.event.content
// No need to encrypt an empty array
if (this.privateTags.length === 0) return ""
if (!signer) {
throw new Error("A signer is required to encrypt private tags")
}
const pubkey = await signer.getPubkey()
return signer.nip44.encrypt(pubkey, JSON.stringify(this.privateTags))
}Three branches:
- Never decrypted — return the original ciphertext untouched. You can edit public tags on a list you could not decrypt without destroying its private contents.
- No private tags — return
"". Nothing to encrypt. - Has private tags — require a signer (else throw
A signer is required to encrypt private tags), thensigner.nip44.encrypt(pubkey, JSON.stringify(privateTags)). The encryption is NIP-44, self-encrypted to the author's own pubkey.
buildTags simply returns publicTags. The signer used here comes from def.context.signer, injected at configure:
import {MuteList} from "@welshman/domain"
// context.signer is the author's; render() encrypts the private tag
const template = await MuteList.configure(context)
.writer()
.mutePrivately(targetPubkey)
.render()With @welshman/app: Domain & Command
The Domain plugin owns the KindContext, so app code never touches configure. It exposes three helpers:
const domain = app.use(Domain)
// Read side — pass as a data plugin's eventToItem decoder:
eventToItem: domain.reader(Note)
// Write side:
const writer = domain.writer(FollowList, existingReader).follow(pubkey)
const command = await domain.command(writer) // requires a signed-in user
command.publish()domain.reader(factory)returns an(event) => Parsed<Reader>function — it builds and parses, so the result is the reader for sync kinds and a promise of it for kinds that decrypt. That is exactly theEventToItemshape a collection wants, and it keeps the sync path for kinds that have one.domain.writer(factory, reader?)returns a fresh writer, optionally seeded for editing.domain.command(writer)requires the signed-in user, callswriter.render(), and wraps the{event, relays}in aCommandyou can.publish().
The context Domain builds wires in app.use(Router).resolver, app.repository, and a lazy signer getter (so auth policies can swap the signer after configuration).
Old API → new API
Old (@welshman/domain) | New (@welshman/domain) |
|---|---|
new Kind({reader, builder, router}) | new KindFactory({reader, writer, router?}) |
EventBuilder / ListBuilder / XBuilder | EventWriter / ListWriter / XWriter |
X.fromEvent(event) / Kind.read(event) / Kind.factory(signer) | factory.configure(ctx).reader(event).parse() (async only for lists / app data) |
Kind.builder(reader?) / new XBuilder(reader?) | factory.configure(ctx).writer(reader?) |
parse(signer) | parse() (reads def.context.signer) |
builder.toTemplate(signer?) | writer.renderTemplate() (context injected at configure) |
builder.toEvent(signer) | signer.sign(stamp(await writer.renderTemplate())) |
builder.toRumor(signer) | prep(await writer.renderTemplate(), await signer.getPubkey()) |
builder.finalize(context) | writer.render() (no arg) |
Router.commandFromBuilder(builder) | app.use(Domain).command(writer) |
Encryptable wrapper around an event | ListWriter.buildContent (NIP-44, self-encrypted) — no separate wrapper |