Migrating from v6 to v7
v7 separates single relay interactions from the methods that manage authentication, retries, and reconnects. Most application code should use publish(), request(), subscription(), and sync(); keep event(), req(), and negentropy() for callers that manage their own lifecycle.
1. Upgrade dependencies together
Upgrade every applesauce-* package your application uses to major version 7. The packages share the major version, while minor and patch releases remain independent.
React 18 is still supported; React 19 is also supported. The SDK is built with TypeScript 7, but upgrading your application's compiler is not a required migration step.
The examples below assume existing relay, pool, relays, filters, eventStore, and signer values. Imports are shown when a new API is introduced.
2. Breaking changes at a glance
| v6 usage | v7 migration | Section |
|---|---|---|
event() for publishing with automatic auth handling | Use publish(event, options) | 3 |
event(event, "AUTH") | Use auth(event) | 3 |
req(filters, { waitForAuth, reconnect, resubscribe }) | Move those options to request() or subscription() | 4 |
Group/Pool request() errors treated as empty results | Handle RelayGroupError | 4 |
Group/Pool request() completing 5s after first EOSE | Defaults to completeAfterIdle() | 4 |
pool.ignoreOffline or pool.group(relays, true) | Pass sources to new RelayPool({ liveness }) | 4 |
authRequiredForRead$ / authRequiredForPublish$ | Use onAuthRequired or watch challenge$ | 5 |
Group/Pool count() entries with .count | Narrow .ok, then read .value.count | 6 |
Callback-based negentropy() | Consume negotiation rounds or use sync() | 7 |
sync() emitting events | Handle typed transfer messages | 7 |
Relay.fetchInformationDocument() Observable | Await the returned Promise | 8 |
| Custom model store generics | Insert the event type before the store parameter | 9 |
Mutating arrays watched by watchEventsUpdates | Re-emit the array after membership changes | 9 |
debug.enable() for applesauce logs | Use enableLoggerNamespaces() | 9 |
try/catch around malformed-content getters | Check for undefined | 10 |
getParsedContent with custom transformers | Define transformers at module level; pass cacheKey | 10 |
Reading LRU.items as an object | Use get() and has(), or the items Map | 10 |
loader() / loader(since) timeline paging | Pass a finite { since, until } window or window stream | 11 |
createSocialGraphLoader() returning events | Destructure { events$, status$ } | 11 |
createUserListsLoader() | Use createSyncLoader() | 11 |
Exhaustive maps over SyncRelayState | Handle the new "skipped" state | 11 |
Turso search options, Turso driver below 0.8 | Use LibSQL or Better SQLite3 for search; upgrade to 0.8 | 12 |
3. Relay: publishing and AUTH
event(event) sends one EVENT attempt and returns an Observable. It no longer accepts an EVENT/AUTH selector or manages authentication, retries, and reconnects. auth(event) sends one AUTH attempt and returns a Promise.
// Before
relay.event(signed, "AUTH").subscribe(handleResponse);
// After
const response = await relay.auth(signed);
handleResponse(response);For application publishing, move to publish() and supply authentication policy there:
const response = await relay.publish(signed, {
onAuthRequired: async ({ relay }) => {
await relay.authenticate(signer);
},
timeout: 30_000,
});
if (!response.ok) showRejection(response.message);A negative relay verdict resolves with ok: false, while client failures such as transport errors or exhausted timeouts reject the Promise. Handle both. Group/Pool publishing converts individual relay failures into response entries.
4. Relay: requests and subscriptions
req() accepts only { id } options and emits structured OPEN, EVENT, EOSE, and CLOSED messages (Group/Pool req() also emits ERROR). Raw requests do not authenticate, reconnect, or repeat, and EOSE alone does not complete them. Move policy options to the high-level methods:
// Before
relay.req(filters, { reconnect: true, waitForAuth: true });
// After: finite query, emitting NostrEvent values
relay.request(filters, { reconnect: true, waitForAuth: true });
// After: persistent stream, emitting NostrEvent or "EOSE"
relay.subscription(filters, { reconnect: true, waitForAuth: true });When replacing req(), remove handlers expecting structured messages. Single-relay subscription() emits events and the literal "EOSE"; use onlyEvents() from applesauce-relay/operators if a consumer expects only events. Group/Pool subscriptions emit events only.
request() defaults to a 30-second deadline over its entire lifetime; set a larger timeout for slow finite requests. subscription() has no built-in timeout; use RxJS takeUntil() or timeout() to bound it.
Group failures
Group/Pool request() and subscription() now error with RelayGroupError when every relay fails. A successful query with no events still completes normally, so don't treat errors as empty results.
import { RelayGroupError } from "applesauce-relay";
pool.request(relays, filters).subscribe({
next: (event) => eventStore.add(event),
error: (error) => {
if (error instanceof RelayGroupError) reportFailures(error.outcomes);
else reportError(error);
},
});error.outcomes contains causes keyed by normalized relay URL.
Group request completion
Group/Pool request() no longer completes five seconds after the first EOSE. It defaults to RelayGroup.completeAfterIdle(), which, starting from the first EOSE, waits at least 3 seconds (grace) for other relays, then completes once no event has arrived for 1.5 seconds (idle), and stops after 15 seconds (cap).
A relay that sends no EVENT or EOSE within firstResponseTimeout (default 10 seconds) fails with RelayFirstResponseTimeoutError, so a group of silent relays errors instead of waiting for the whole-request timeout.
// Tune the default for a completeness-first request
pool.request(relays, filters, { complete: RelayGroup.completeAfterIdle({ grace: 5_000, cap: 30_000 }) });
// Restore the v6 behavior
pool.request(relays, filters, { complete: RelayGroup.completeAfterFirstRelay(5_000), firstResponseTimeout: false });Relay health filtering
Remove pool.ignoreOffline and the second argument to pool.group(). Pass a liveness source to the pool instead, which connects it and skips its unhealthy relays in every group:
import { RelayLiveness, RelayPool } from "applesauce-relay";
const liveness = new RelayLiveness();
const pool = new RelayPool({ liveness });To filter a single relay stream instead, use pool.group(relays$.pipe(ignoreUnhealthyRelays(liveness))).
See relay liveness for persistence and health settings.
5. Relay: authentication
Remove authRequiredForRead$, authRequiredForPublish$, and the matching RelayStatus fields. A refusal now applies only to the operation that received it. Use onAuthRequired on high-level operations, or watch challenge$ to authenticate proactively.
relay.subscription(filters, {
waitForAuth: pubkey,
onAuthRequired: async ({ relay }) => {
await relay.authenticate(signer);
},
authTimeout: 30_000,
authRetries: 1,
});waitForAuthacceptstrue(any authenticated user), a pubkey, or an array of pubkeys (all required).falsefails immediately on an auth-required refusal.- Operations that waited indefinitely for authentication now fail after 30 seconds by default; pass
authTimeout: falseto wait for out-of-band authentication. - The handler receives
{ relay, url, challenge, request, requirement, missingPubkeys, reason }. Return or await authentication work so its failures reach the operation's error path.
authenticate(signer, { timeout, challengeRetries, signal }) now waits for a challenge and checks its freshness after signing. Negative AUTH verdicts resolve with ok: false; signer, timeout, transport, and abort failures reject.
For multiple accounts, use authenticatedPubkeys$ and authentications$. The single-user authenticatedAs$ is deprecated.
6. Relay: COUNT
Single-relay count() still emits a count response. Policy options are the third argument: relay.count(filters, undefined, options).
Group/Pool COUNT now emits progressive snapshots of per-relay outcomes. Pending relays have no entry; a failed relay has an error entry rather than disappearing.
pool.count(relays, filters).subscribe((outcomes) => {
for (const [url, outcome] of Object.entries(outcomes)) {
if (outcome.ok) showCount(url, outcome.value.count);
else showCountError(url, outcome.error);
}
});Use lastValueFrom() for the final snapshot. Counts can include approximate and hll; adding per-relay counts can double-count events shared by relays.
7. Relay: negentropy and sync
Raw negotiation
relay.negentropy() now takes a single filter and returns an Observable of { have, need } rounds. Remove the reconciliation callback and the NegentropySyncOptions / GroupNegentropySyncOptions types; raw options are NegentropyOptions (id, frameSizeLimit, signal).
relay
.negentropy(eventStore, filter, { frameSizeLimit: 60_000 })
.subscribe(({ have, need }) => reconcileIds(have, need));Completion does not wait for asynchronous subscriber work; compose it with concatMap() or use sync(). Group and Pool no longer expose negentropy(); use their sync() methods.
Transfer outcomes
sync() now owns authentication, reconnects, and transfers, and emits messages rather than bare events:
| Message | Meaning |
|---|---|
received | Downloaded event in .event |
sent | Accepted upload with .event and .response |
send-failed | Failed upload with .event, .error, and optional .response |
relay-failed | Group/Pool member failure with .from and .error |
import { SyncDirection } from "applesauce-relay";
pool.sync(relays, eventStore, filters, SyncDirection.BOTH).subscribe((message) => {
if (message.type === "received") showReceived(message.event);
else if (message.type === "sent") showSent(message.event);
else reportError(message.error);
});Writable stores receive downloaded events automatically. With a read-only store or an event array, select received messages and extract .event yourself.
The group-negentropy parallel option is removed; Group/Pool sync runs relays concurrently. Use concurrency (default 4) and batchSize (default 500) to bound transfers. There is no timeout option; use signal, unsubscribe, or an RxJS lifetime bound.
8. Relay: information documents
Relay.fetchInformationDocument(url) now returns Promise<RelayInformation | null> instead of an Observable:
// Before
Relay.fetchInformationDocument(url).subscribe(showInfo);
// After
showInfo(await Relay.fetchInformationDocument(url));9. Core: event stores and models
Stores, models, and casts are now generic over the event type. Code using default generics continues to work. If a custom model specifies its store type explicitly, insert the event type before that store parameter:
| v6 type | v7 type |
|---|---|
Model<T, TStore> | Model<T, NostrEvent, TStore> |
ModelEventStore<TStore> | ModelEventStore<NostrEvent, TStore> |
ModelConstructor<T, Args, TStore> | ModelConstructor<T, Args, NostrEvent, TStore> |
EventModels<TStore> | EventModels<NostrEvent, TStore> |
IEventModelMixin<TStore> | IEventModelMixin<NostrEvent, TStore> |
Import the model types from applesauce-core/event-store and NostrEvent from applesauce-core/helpers. For unsigned NIP-59 rumors, use the new RumorStore or AsyncRumorStore instead of casting rumors to signed events.
Explicit verifyEvent: undefined now disables EventStore verification. If your options builder included an undefined verifier to mean "use the default", omit the property instead:
const events = new EventStore(); // Default signature verifier
const trustedEvents = new EventStore({ verifyEvent: undefined }); // No verificationEventFactory.kind() has been removed. Use EventFactory.fromKind(kind) or the typed factory's create() method.
Watched event arrays
watchEventsUpdates (also watchTimelineUpdates) now indexes array membership each time the source emits. If you feed it a custom array source, emit the array again after adding, removing, or replacing events, even when reusing the same instance; mutating an emitted array in place no longer updates which events are watched. eventStore.timeline() sources need no changes. See watching event updates.
Logging
Applesauce logging no longer uses the debug package. Replace debug.enable() / debug.disable() with the core exports:
import { enableLoggerNamespaces, disableLoggerNamespaces } from "applesauce-core";
enableLoggerNamespaces("applesauce:*");
disableLoggerNamespaces();DEBUG in Node and localStorage.debug in the browser are still read at startup. Use setLoggerSink((message, record) => ...) for a custom destination and import the Debugger type from applesauce-core.
10. Helpers and content
Helper caches are now non-enumerable, so spreading an event no longer copies decrypted content or cached parsing results; keep the original instance when you need its decrypted state. Cache writes on frozen, sealed, or non-extensible events now throw, so copy them first: eventStore.add({ ...frozenEvent }).
LRU.items is now a Map instead of a plain object, so replace lru.items[key] and key in lru.items with lru.get(key) and lru.has(key).
Malformed-content getters such as getHiddenTags, getGiftWrapSeal, getSealRumor, getTokenContent, getHistoryContent, getNutzapP2PKPubkey, and getWalletNotification now return undefined instead of throwing. Replace exception-based fallbacks with return-value checks; imperative unlock methods still throw. Gift-wrap helpers also verify seal signatures, so update unsigned or malformed seal fixtures.
Parsed content caching
getParsedContent and useRenderedContent no longer cache custom transformer arrays under TextNoteContentSymbol. Each transformer array gets its own cache key, and a content override is only cached when you pass a cacheKey. Define custom transformer arrays at module level. removeParsedTextContent only clears the default-transformer cache.
11. Loaders
Timeline loader
Timeline loaders no longer page. Instead of calling loader() for the next older page, pass a finite { since, until } window, or a stream of windows, and widen it to load more:
// Before
const loader = createTimelineLoader(pool, relays, { kinds: [1] }, { eventStore });
loader().subscribe(); // Next older page
// After: loader creation is unchanged
const now = Math.floor(Date.now() / 1000);
const window$ = new BehaviorSubject({ since: now - 3600, until: now });
const sub = loader(window$).subscribe();
window$.next({ since: now - 7200, until: now }); // Load older- Calls without a window, numeric arguments, and infinite bounds are rejected.
- Keep one subscription per displayed timeline, and read
loader.status$instead of relying on completion to detect loading and failures. - Use a separate live subscription for new events.
cacheTimelineLoader,relaysTimelineLoader,loadBackwardBlocks,loadForwardBlocks, andloadBlocksForTimelineWindoware removed.
See the timeline loader for options such as prefetch and liveness.
Social graph loader
createSocialGraphLoader() now returns { events$, status$ } instead of an Observable of events. Subscribe to both in the same tick so they share one crawl:
const { events$, status$ } = socialGraphLoader({ pubkey, relays, distance: 2 });
combineLatest([events$.pipe(tap((event) => graph.handleEvent(event))), status$]).subscribe();The defaults are now batches of 100 users (parallel), 6 batches in flight (concurrency), and at most 4 open requests per relay (maxRequestsPerRelay). Lists found through cacheRequest are refreshed from relays with a since after the oldest cached list in each batch, and events$ only emits a list newer than the one already loaded for that user.
User lists
createUserListsLoader, UserListsLoader, UserListsLoaderOptions, COMMON_LIST_KINDS, and COMMON_SET_KINDS have been removed. Use createSyncLoader with the user's NIP-65 outbox relays and the list kinds your application supports:
const sync = createSyncLoader({ eventStore, pool });
const run = sync({ relays, filter: { authors: [pubkey], kinds: [3, 10000, 10003, 30000, 30003] } });
const sub = run.status$.subscribe(); // Starts sync and stores received eventsThe sync loader does not accept cacheRequest; hydrate cached events into the same EventStore before syncing. Read lists from store queries, and use a separate live subscription for updates.
Sync loader relay states
SyncRelayState gains "skipped" for relays the pool's liveness sources excluded. Add it to any exhaustive map or switch over relay states; a skipped relay counts as finished.
Custom adapters
Existing loaders use the pool's high-level request path; pass operation authentication options when loading from private relays. Custom SyncSyncMethod adapters must return Observable<SyncResult> and forward the optional third SyncMethodOptions argument, and request adapters should forward their options argument too. Custom pool adapters must preserve terminal authentication failures rather than converting them into empty results; see upstream pools.
12. Signers and SQLite
NostrConnectSigner requests now reject after 60 seconds by default, when publishing fails, or when the signer closes. Handle rejected signing and encryption requests; set timeout: 0 only to disable the deadline.
For client-initiated nostrconnect:// sessions, the remote signer must return the URI's secret in its connect response; a bare "ack" is no longer accepted. bunker:// connect responses still use "ack".
SQLite drivers are now optional peers. Install the backend your application imports, such as better-sqlite3 for applesauce-sqlite/better-sqlite3 or @libsql/client for applesauce-sqlite/libsql. Native Node and Bun drivers use runtime-provided SQLite. See SQLite databases.
The Turso backends now require @tursodatabase/database or @tursodatabase/database-wasm 0.8 or later. TursoEventDatabase no longer accepts search or searchContentFormatter or provides rebuildSearchIndex(), and search filters throw. Use the LibSQL or Better SQLite3 backends for full-text search. See Turso.
Best Practices
- Use high-level relay methods for application work; use raw methods only when you own retries and cancellation.
- Choose a specific
waitForAuthpubkey for private data in applications with multiple accounts. - Treat completion as "requests settled", not success: inspect
RelayGroupError, COUNT outcomes, sync failure messages, and timelinestatus$.
Verification
- Search application code for
event(..., "AUTH"), policy options passed toreq(), removed auth-required flags,ignoreOffline, COUNT.countreads, negentropy callbacks, sync consumers expecting events, andfetchInformationDocument(...).subscribe. - Search for argument-less or numeric timeline
loader()calls,createUserListsLoader, and custom arrays passed towatchEventsUpdates. - Update custom model generics and logging controls, then run your type checker.
- Test finite requests, persistent subscriptions, all-relay failures, publish rejections, private-relay authentication, partial COUNT results, cancelled syncs, and timeline scrolling.
- Test NIP-46 login and restored sessions against your supported remote signers, and verify the selected SQLite backend is installed.