Skip to content

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 usagev7 migrationSection
event() for publishing with automatic auth handlingUse 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 resultsHandle RelayGroupError4
Group/Pool request() completing 5s after first EOSEDefaults 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 .countNarrow .ok, then read .value.count6
Callback-based negentropy()Consume negotiation rounds or use sync()7
sync() emitting eventsHandle typed transfer messages7
Relay.fetchInformationDocument() ObservableAwait the returned Promise8
Custom model store genericsInsert the event type before the store parameter9
Mutating arrays watched by watchEventsUpdatesRe-emit the array after membership changes9
debug.enable() for applesauce logsUse enableLoggerNamespaces()9
try/catch around malformed-content gettersCheck for undefined10
getParsedContent with custom transformersDefine transformers at module level; pass cacheKey10
Reading LRU.items as an objectUse get() and has(), or the items Map10
loader() / loader(since) timeline pagingPass a finite { since, until } window or window stream11
createSocialGraphLoader() returning eventsDestructure { events$, status$ }11
createUserListsLoader()Use createSyncLoader()11
Exhaustive maps over SyncRelayStateHandle the new "skipped" state11
Turso search options, Turso driver below 0.8Use LibSQL or Better SQLite3 for search; upgrade to 0.812

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.

ts
// 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:

ts
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:

ts
// 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.

ts
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.

ts
// 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:

ts
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.

ts
relay.subscription(filters, {
  waitForAuth: pubkey,
  onAuthRequired: async ({ relay }) => {
    await relay.authenticate(signer);
  },
  authTimeout: 30_000,
  authRetries: 1,
});
  • waitForAuth accepts true (any authenticated user), a pubkey, or an array of pubkeys (all required). false fails immediately on an auth-required refusal.
  • Operations that waited indefinitely for authentication now fail after 30 seconds by default; pass authTimeout: false to 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.

ts
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).

ts
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:

MessageMeaning
receivedDownloaded event in .event
sentAccepted upload with .event and .response
send-failedFailed upload with .event, .error, and optional .response
relay-failedGroup/Pool member failure with .from and .error
ts
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:

ts
// 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 typev7 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:

ts
const events = new EventStore(); // Default signature verifier
const trustedEvents = new EventStore({ verifyEvent: undefined }); // No verification

EventFactory.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:

ts
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:

ts
// 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, and loadBlocksForTimelineWindow are 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:

ts
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:

ts
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 events

The 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 waitForAuth pubkey for private data in applications with multiple accounts.
  • Treat completion as "requests settled", not success: inspect RelayGroupError, COUNT outcomes, sync failure messages, and timeline status$.

Verification ​

  1. Search application code for event(..., "AUTH"), policy options passed to req(), removed auth-required flags, ignoreOffline, COUNT .count reads, negentropy callbacks, sync consumers expecting events, and fetchInformationDocument(...).subscribe.
  2. Search for argument-less or numeric timeline loader() calls, createUserListsLoader, and custom arrays passed to watchEventsUpdates.
  3. Update custom model generics and logging controls, then run your type checker.
  4. Test finite requests, persistent subscriptions, all-relay failures, publish rejections, private-relay authentication, partial COUNT results, cancelled syncs, and timeline scrolling.
  5. Test NIP-46 login and restored sessions against your supported remote signers, and verify the selected SQLite backend is installed.