applesauce-core 6.0.3 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,6 @@
1
- import { Observable } from "rxjs";
2
1
  import { EventModels, IEventStoreStreams, IEventSubscriptions } from "../event-store/index.js";
3
2
  import { NostrEvent } from "../helpers/event.js";
4
- import { ChainableObservable } from "../observable/chainable.js";
5
- import { User } from "./user.js";
3
+ import { EventCast } from "./event.js";
6
4
  /** The type of event store that is passed to cast references */
7
5
  export type CastRefEventStore = IEventSubscriptions & EventModels & IEventStoreStreams;
8
6
  /** A symbol used to store all the cast instances for a given event */
@@ -13,19 +11,3 @@ export declare const CASTS_SYMBOL: unique symbol;
13
11
  export type CastConstructor<C extends EventCast<NostrEvent>> = new (event: NostrEvent, store: CastRefEventStore) => C;
14
12
  /** Cast a Nostr event to a specific class */
15
13
  export declare function castEvent<C extends EventCast<NostrEvent>>(event: NostrEvent, cls: CastConstructor<C>, store?: CastRefEventStore): C;
16
- /** The base class for all casts */
17
- export declare class EventCast<T extends NostrEvent = NostrEvent> {
18
- #private;
19
- readonly event: T;
20
- readonly store: CastRefEventStore;
21
- get id(): string;
22
- get uid(): string;
23
- get createdAt(): Date;
24
- /** Get the {@link User} that authored this event */
25
- get author(): User;
26
- /** Return the set of relays this event was seen on */
27
- get seen(): Set<string> | undefined;
28
- constructor(event: T, store: CastRefEventStore);
29
- /** Internal method for creating a reference */
30
- protected $$ref<Return extends unknown>(key: string, builder: (store: CastRefEventStore) => Observable<Return>): ChainableObservable<Return>;
31
- }
@@ -1,7 +1,4 @@
1
- import { getEventUID, getParentEventStore } from "../helpers/event.js";
2
- import { getSeenRelays } from "../helpers/relays.js";
3
- import { chainable } from "../observable/chainable.js";
4
- import { castUser } from "./user.js";
1
+ import { getParentEventStore } from "../helpers/event.js";
5
2
  /** A symbol used to store all the cast instances for a given event */
6
3
  export const CAST_REF_SYMBOL = Symbol.for("cast-ref");
7
4
  /** A symbol used to store all the casts for an event */
@@ -26,42 +23,3 @@ export function castEvent(event, cls, store) {
26
23
  casts.set(cls, cast);
27
24
  return cast;
28
25
  }
29
- /** The base class for all casts */
30
- export class EventCast {
31
- event;
32
- store;
33
- get id() {
34
- return this.event.id;
35
- }
36
- get uid() {
37
- return getEventUID(this.event);
38
- }
39
- get createdAt() {
40
- return new Date(this.event.created_at * 1000);
41
- }
42
- /** Get the {@link User} that authored this event */
43
- get author() {
44
- return castUser(this.event, this.store);
45
- }
46
- /** Return the set of relays this event was seen on */
47
- get seen() {
48
- return getSeenRelays(this.event);
49
- }
50
- // Enfore kind check in constructor. this will force child classes to verify the event before calling super()
51
- constructor(event, store) {
52
- this.event = event;
53
- this.store = store;
54
- }
55
- /** A cache of observable references */
56
- #refs = {};
57
- /** Internal method for creating a reference */
58
- $$ref(key, builder) {
59
- // Return cached observable
60
- if (this.#refs[key])
61
- return this.#refs[key];
62
- // Build a new observable and cache it
63
- const observable = chainable(builder(this.store));
64
- this.#refs[key] = observable;
65
- return observable;
66
- }
67
- }
@@ -0,0 +1,33 @@
1
+ import { Observable } from "rxjs";
2
+ import { NostrEvent } from "../helpers/event.js";
3
+ import { AddressPointer, EventPointer } from "../helpers/pointers.js";
4
+ import { ChainableObservable } from "../observable/chainable.js";
5
+ import { CastRefEventStore } from "./cast.js";
6
+ import { User } from "./user.js";
7
+ /** The base class for all casts */
8
+ export declare class EventCast<T extends NostrEvent = NostrEvent> {
9
+ #private;
10
+ readonly event: T;
11
+ readonly store: CastRefEventStore;
12
+ /** Alias for event.id */
13
+ get id(): string;
14
+ /** Alias for event.kind */
15
+ get kind(): number;
16
+ /** Returns the unique identifier for this event */
17
+ get uid(): string;
18
+ /** Returns the created_at timestamp as a Date object */
19
+ get createdAt(): Date;
20
+ /** Get the {@link User} that authored this event */
21
+ get author(): User;
22
+ /** Return the set of relays this event was seen on */
23
+ get seen(): Set<string> | undefined;
24
+ /** Returns the NIP-01 address string for this event if its replaceable or addressable, otherwise returns null */
25
+ get coordinate(): string | null;
26
+ /** Alias for {@link coordinate} */
27
+ get replaceableAddress(): string | null;
28
+ /** Returns a single {@link EventPointer} or {@link AddressPointer} for this event */
29
+ get pointer(): EventPointer | AddressPointer;
30
+ constructor(event: T, store: CastRefEventStore);
31
+ /** Internal method for creating a reference */
32
+ protected $$ref<Return extends unknown>(key: string, builder: (store: CastRefEventStore) => Observable<Return>): ChainableObservable<Return>;
33
+ }
@@ -0,0 +1,65 @@
1
+ import { getEventUID, isAddressableKind, isReplaceableKind } from "../helpers/event.js";
2
+ import { getAddressPointerForEvent, getEventPointerForEvent, getReplaceableAddressForEvent, } from "../helpers/pointers.js";
3
+ import { getSeenRelays } from "../helpers/relays.js";
4
+ import { chainable } from "../observable/chainable.js";
5
+ import { castUser } from "./user.js";
6
+ /** The base class for all casts */
7
+ export class EventCast {
8
+ event;
9
+ store;
10
+ /** Alias for event.id */
11
+ get id() {
12
+ return this.event.id;
13
+ }
14
+ /** Alias for event.kind */
15
+ get kind() {
16
+ return this.event.kind;
17
+ }
18
+ /** Returns the unique identifier for this event */
19
+ get uid() {
20
+ return getEventUID(this.event);
21
+ }
22
+ /** Returns the created_at timestamp as a Date object */
23
+ get createdAt() {
24
+ return new Date(this.event.created_at * 1000);
25
+ }
26
+ /** Get the {@link User} that authored this event */
27
+ get author() {
28
+ return castUser(this.event, this.store);
29
+ }
30
+ /** Return the set of relays this event was seen on */
31
+ get seen() {
32
+ return getSeenRelays(this.event);
33
+ }
34
+ /** Returns the NIP-01 address string for this event if its replaceable or addressable, otherwise returns null */
35
+ get coordinate() {
36
+ return getReplaceableAddressForEvent(this.event);
37
+ }
38
+ /** Alias for {@link coordinate} */
39
+ get replaceableAddress() {
40
+ return this.coordinate;
41
+ }
42
+ /** Returns a single {@link EventPointer} or {@link AddressPointer} for this event */
43
+ get pointer() {
44
+ if (isReplaceableKind(this.kind) || isAddressableKind(this.kind))
45
+ return getAddressPointerForEvent(this.event) || getEventPointerForEvent(this.event);
46
+ return getEventPointerForEvent(this.event);
47
+ }
48
+ // Enfore kind check in constructor. this will force child classes to verify the event before calling super()
49
+ constructor(event, store) {
50
+ this.event = event;
51
+ this.store = store;
52
+ }
53
+ /** A cache of observable references */
54
+ #refs = {};
55
+ /** Internal method for creating a reference */
56
+ $$ref(key, builder) {
57
+ // Return cached observable
58
+ if (this.#refs[key])
59
+ return this.#refs[key];
60
+ // Build a new observable and cache it
61
+ const observable = chainable(builder(this.store));
62
+ this.#refs[key] = observable;
63
+ return observable;
64
+ }
65
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./cast.js";
2
+ export * from "./event.js";
2
3
  export * from "./pubkey.js";
3
4
  export * from "./user.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./cast.js";
2
+ export * from "./event.js";
2
3
  export * from "./pubkey.js";
3
4
  export * from "./user.js";
@@ -1,15 +1,18 @@
1
- /** A constructor type for {@link PubkeyCast} subclasses */
2
1
  import { Observable } from "rxjs";
3
2
  import { NostrEvent } from "../helpers/event.js";
4
3
  import type { ProfilePointer } from "../helpers/pointers.js";
5
4
  import { ChainableObservable } from "../observable/chainable.js";
6
5
  import { CastRefEventStore } from "./cast.js";
7
6
  export type PubkeyCastConstructor<C extends PubkeyCast> = (new (pointer: ProfilePointer, store: CastRefEventStore) => C) & {
8
- cache: Map<string, C>;
7
+ cache?: Map<string, WeakRef<C>>;
8
+ cacheRegistry?: FinalizationRegistry<string>;
9
9
  };
10
10
  /**
11
11
  * Cast a pubkey to a specific class instance.
12
12
  * Works like {@link castUser} - returns a cached singleton per pubkey+relay-hints combination.
13
+ *
14
+ * @note Instances are held weakly so unused casts can be garbage collected instead of
15
+ * accumulating one instance per pubkey for the lifetime of the process.
13
16
  */
14
17
  export declare function castPubkey<C extends PubkeyCast>(pubkey: string | NostrEvent | ProfilePointer, cls: PubkeyCastConstructor<C>, store: CastRefEventStore): C;
15
18
  /** Base class for pubkey-based casts (analogous to {@link EventCast} for events) */
@@ -17,8 +20,10 @@ export declare class PubkeyCast {
17
20
  #private;
18
21
  readonly pointer: ProfilePointer;
19
22
  readonly store: CastRefEventStore;
20
- /** A global cache of pubkey -> instance, populated by {@link castPubkey} */
21
- static cache: Map<string, PubkeyCast>;
23
+ /** A global cache of pubkey -> weak instance reference, populated by {@link castPubkey} */
24
+ static cache: Map<string, WeakRef<PubkeyCast>>;
25
+ /** Cleans up dead {@link cache} entries when their instances are garbage collected */
26
+ static cacheRegistry?: FinalizationRegistry<string>;
22
27
  constructor(pointer: ProfilePointer, store: CastRefEventStore);
23
28
  /** The hex pubkey represented by this cast */
24
29
  get pubkey(): string;
@@ -1,10 +1,12 @@
1
- /** A constructor type for {@link PubkeyCast} subclasses */
2
1
  import { isEvent } from "../helpers/event.js";
3
2
  import { isHexKey } from "../helpers/string.js";
4
3
  import { chainable } from "../observable/chainable.js";
5
4
  /**
6
5
  * Cast a pubkey to a specific class instance.
7
6
  * Works like {@link castUser} - returns a cached singleton per pubkey+relay-hints combination.
7
+ *
8
+ * @note Instances are held weakly so unused casts can be garbage collected instead of
9
+ * accumulating one instance per pubkey for the lifetime of the process.
8
10
  */
9
11
  export function castPubkey(pubkey, cls, store) {
10
12
  if (isEvent(pubkey))
@@ -15,19 +17,30 @@ export function castPubkey(pubkey, cls, store) {
15
17
  const cacheKey = pointer.relays?.length ? `${pointer.pubkey}:${JSON.stringify(pointer.relays)}` : pointer.pubkey;
16
18
  if (!cls.cache)
17
19
  cls.cache = new Map();
18
- const existing = cls.cache.get(cacheKey);
20
+ // Clean up dead cache entries when their instances are garbage collected
21
+ if (!cls.cacheRegistry)
22
+ cls.cacheRegistry = new FinalizationRegistry((key) => {
23
+ // Only drop the entry if it still points at a collected instance; a re-created
24
+ // instance under the same key has a live ref and must be kept.
25
+ if (cls.cache.get(key)?.deref() === undefined)
26
+ cls.cache.delete(key);
27
+ });
28
+ const existing = cls.cache.get(cacheKey)?.deref();
19
29
  if (existing)
20
30
  return existing;
21
31
  const instance = new cls(pointer, store);
22
- cls.cache.set(cacheKey, instance);
32
+ cls.cache.set(cacheKey, new WeakRef(instance));
33
+ cls.cacheRegistry.register(instance, cacheKey);
23
34
  return instance;
24
35
  }
25
36
  /** Base class for pubkey-based casts (analogous to {@link EventCast} for events) */
26
37
  export class PubkeyCast {
27
38
  pointer;
28
39
  store;
29
- /** A global cache of pubkey -> instance, populated by {@link castPubkey} */
40
+ /** A global cache of pubkey -> weak instance reference, populated by {@link castPubkey} */
30
41
  static cache = new Map();
42
+ /** Cleans up dead {@link cache} entries when their instances are garbage collected */
43
+ static cacheRegistry;
31
44
  constructor(pointer, store) {
32
45
  this.pointer = pointer;
33
46
  this.store = store;
@@ -1,19 +1,31 @@
1
+ import { Observable } from "rxjs";
1
2
  import { NostrEvent } from "../helpers/event.js";
3
+ import { Filter } from "../helpers/filter.js";
2
4
  import { ProfilePointer } from "../helpers/pointers.js";
3
5
  import { ChainableObservable } from "../observable/chainable.js";
4
- import type { CastRefEventStore } from "./cast.js";
6
+ import type { CastConstructor, CastRefEventStore } from "./cast.js";
5
7
  import { PubkeyCast } from "./pubkey.js";
8
+ import { EventCast } from "./event.js";
6
9
  /** Cast a Nostr event or pointer into a {@link User} */
7
10
  export declare function castUser(event: NostrEvent, store: CastRefEventStore): User;
8
11
  export declare function castUser(user: string | ProfilePointer, store: CastRefEventStore): User;
9
12
  /** A class representing a Nostr user */
10
13
  export declare class User extends PubkeyCast {
11
- /** A global cache of pubkey -> {@link User} */
12
- static cache: Map<string, User>;
14
+ /** A global cache of pubkey -> weak {@link User} reference */
15
+ static cache: Map<string, WeakRef<User>>;
16
+ /** Returns the NIP-19 npub for this user */
13
17
  get npub(): `npub1${string}`;
18
+ /** Returns the NIP-19 nprofile for this user */
14
19
  get nprofile(): `nprofile1${string}`;
15
20
  /** Subscribe to a replaceable event for this user */
16
21
  replaceable(kind: number, identifier?: string, relays?: string[]): ChainableObservable<NostrEvent | undefined>;
17
22
  /** Subscribe to an addressable event for this user */
18
23
  addressable(kind: number, identifier: string, relays?: string[]): ChainableObservable<NostrEvent | undefined>;
24
+ /**
25
+ * Creates an observable of a timeline of events created by this user
26
+ * @param input - The filter(s) for the timeline or kind(s)
27
+ * @returns A timeline observable of events by the user rom the event store
28
+ */
29
+ timeline$(input: Omit<Filter, "authors"> | Omit<Filter, "authors">[] | number | number[]): Observable<NostrEvent[]>;
30
+ timeline$<T extends EventCast>(input: Omit<Filter, "authors"> | Omit<Filter, "authors">[] | number | number[], cast?: CastConstructor<T>): Observable<T[]>;
19
31
  }
@@ -1,16 +1,20 @@
1
+ import hash_sum from "hash-sum";
1
2
  import { nprofileEncode, npubEncode } from "../helpers/pointers.js";
2
3
  import { chainable } from "../observable/chainable.js";
3
4
  import { castPubkey, PubkeyCast } from "./pubkey.js";
5
+ import { castTimelineStream } from "../observable/cast-stream.js";
4
6
  export function castUser(user, store) {
5
7
  return castPubkey(user, User, store);
6
8
  }
7
9
  /** A class representing a Nostr user */
8
10
  export class User extends PubkeyCast {
9
- /** A global cache of pubkey -> {@link User} */
11
+ /** A global cache of pubkey -> weak {@link User} reference */
10
12
  static cache = new Map();
13
+ /** Returns the NIP-19 npub for this user */
11
14
  get npub() {
12
15
  return npubEncode(this.pubkey);
13
16
  }
17
+ /** Returns the NIP-19 nprofile for this user */
14
18
  get nprofile() {
15
19
  return nprofileEncode(this.pointer);
16
20
  }
@@ -22,4 +26,24 @@ export class User extends PubkeyCast {
22
26
  addressable(kind, identifier, relays) {
23
27
  return chainable(this.store.addressable({ kind, pubkey: this.pointer.pubkey, identifier, relays }));
24
28
  }
29
+ timeline$(input, cast) {
30
+ let filters = [];
31
+ if (typeof input === "number") {
32
+ filters.push({ kinds: [input] });
33
+ }
34
+ else if (Array.isArray(input)) {
35
+ filters.push(...input.map((f) => (typeof f === "number" ? { kinds: [f] } : f)));
36
+ }
37
+ else if (input instanceof Object) {
38
+ filters.push(input);
39
+ }
40
+ // Use hash_sum to create a unique key for the timeline observable
41
+ const key = "timeline$|" + hash_sum(filters);
42
+ const base$ = this.$$ref(key, (store) => store.timeline(filters.map((f) => ({ ...f, authors: [this.pubkey] }))));
43
+ // Cast the timeline stream into a cast if provided
44
+ if (cast)
45
+ return base$.pipe(castTimelineStream(cast));
46
+ else
47
+ return base$;
48
+ }
25
49
  }
@@ -52,6 +52,8 @@ export declare class AsyncEventStore extends EventModels implements IAsyncEventS
52
52
  * A method that will be called when an event isn't found in the store
53
53
  */
54
54
  eventLoader?: (pointer: EventPointer | AddressPointer | AddressPointerWithoutD) => Observable<NostrEvent> | Promise<NostrEvent | undefined>;
55
+ /** Internal subscriptions (delete + expiration managers) torn down on dispose */
56
+ private internalSubscriptions;
55
57
  constructor(options: AsyncEventStoreOptions);
56
58
  /** A method to add all events to memory to ensure there is only ever a single instance of an event */
57
59
  private mapToMemory;
@@ -98,4 +100,12 @@ export declare class AsyncEventStore extends EventModels implements IAsyncEventS
98
100
  unclaimed(): Generator<NostrEvent>;
99
101
  /** Removes any event that is not being used by a subscription */
100
102
  prune(limit?: number): number;
103
+ /**
104
+ * Tears down the store: disposes the attached event loader, completes the event streams, releases
105
+ * model keep-warm timers, unsubscribes internal manager listeners, and cancels any pending expiration timer.
106
+ * @note This is a terminal operation; the store should be discarded after calling it.
107
+ */
108
+ dispose(): void;
109
+ /** Allows the store to be used with the `using` keyword */
110
+ [Symbol.dispose](): void;
101
111
  }
@@ -1,5 +1,5 @@
1
1
  import { verifyEvent as coreVerifyEvent } from "nostr-tools/pure";
2
- import { Subject } from "rxjs";
2
+ import { Subject, Subscription } from "rxjs";
3
3
  import { EventStoreSymbol, getReplaceableIdentifier, isReplaceable, kinds } from "../helpers/event.js";
4
4
  import { getExpirationTimestamp } from "../helpers/expiration.js";
5
5
  import { eventMatchesPointer, isAddressPointer, isEventPointer, } from "../helpers/pointers.js";
@@ -47,6 +47,8 @@ export class AsyncEventStore extends EventModels {
47
47
  * A method that will be called when an event isn't found in the store
48
48
  */
49
49
  eventLoader;
50
+ /** Internal subscriptions (delete + expiration managers) torn down on dispose */
51
+ internalSubscriptions = new Subscription();
50
52
  constructor(options) {
51
53
  super();
52
54
  this.database = options.database;
@@ -63,19 +65,19 @@ export class AsyncEventStore extends EventModels {
63
65
  // Use provided delete manager or create a default one
64
66
  this.deletes = options.deleteManager ?? new AsyncDeleteManager();
65
67
  // Listen to delete notifications and remove matching events
66
- this.deletes.deleted$.subscribe((notification) => {
68
+ this.internalSubscriptions.add(this.deletes.deleted$.subscribe((notification) => {
67
69
  this.handleDeleteNotification(notification).catch((error) => {
68
70
  console.error("[applesauce-core] Error handling delete notification:", error);
69
71
  });
70
- });
72
+ }));
71
73
  // Create expiration manager
72
74
  this.expiration = options.expirationManager ?? new ExpirationManager();
73
75
  // Listen to expired events and remove them from the store
74
- this.expiration.expired$.subscribe((id) => {
76
+ this.internalSubscriptions.add(this.expiration.expired$.subscribe((id) => {
75
77
  this.handleExpiredNotification(id).catch((error) => {
76
78
  console.error("[applesauce-core] Error handling expired notification:", error);
77
79
  });
78
- });
80
+ }));
79
81
  }
80
82
  mapToMemory(event) {
81
83
  if (event === undefined)
@@ -339,4 +341,30 @@ export class AsyncEventStore extends EventModels {
339
341
  prune(limit) {
340
342
  return this.memory.prune(limit) ?? 0;
341
343
  }
344
+ /**
345
+ * Tears down the store: disposes the attached event loader, completes the event streams, releases
346
+ * model keep-warm timers, unsubscribes internal manager listeners, and cancels any pending expiration timer.
347
+ * @note This is a terminal operation; the store should be discarded after calling it.
348
+ */
349
+ dispose() {
350
+ // Tear down the attached event loader if it supports disposal
351
+ const loader = this.eventLoader;
352
+ if (loader && typeof loader[Symbol.dispose] === "function")
353
+ loader[Symbol.dispose]();
354
+ this.eventLoader = undefined;
355
+ // Complete all models and release their keep-warm timers
356
+ super.dispose();
357
+ // Tear down internal manager subscriptions
358
+ this.internalSubscriptions.unsubscribe();
359
+ // Cancel any pending expiration timer
360
+ this.expiration.dispose?.();
361
+ // Complete the event streams
362
+ this.insert$.complete();
363
+ this.update$.complete();
364
+ this.remove$.complete();
365
+ }
366
+ /** Allows the store to be used with the `using` keyword */
367
+ [Symbol.dispose]() {
368
+ this.dispose();
369
+ }
342
370
  }
@@ -97,16 +97,36 @@ export class EventMemory {
97
97
  // only remove events that are known
98
98
  if (!this.events.has(id))
99
99
  return false;
100
- this.getAuthorsIndex(event.pubkey).delete(event);
101
- this.getKindIndex(event.kind).delete(event);
102
- // Remove from composite kind+author index
100
+ // Remove from author index, dropping the index entry when it becomes empty so
101
+ // the map does not accumulate empty Sets keyed by every pubkey ever seen
102
+ const authorIndex = this.authors.get(event.pubkey);
103
+ if (authorIndex) {
104
+ authorIndex.delete(event);
105
+ if (authorIndex.size === 0)
106
+ this.authors.delete(event.pubkey);
107
+ }
108
+ // Remove from kind index, dropping the index entry when it becomes empty
109
+ const kindIndex = this.kinds.get(event.kind);
110
+ if (kindIndex) {
111
+ kindIndex.delete(event);
112
+ if (kindIndex.size === 0)
113
+ this.kinds.delete(event.kind);
114
+ }
115
+ // Remove from composite kind+author index, dropping the entry when empty
103
116
  const kindAuthorKey = `${event.kind}:${event.pubkey}`;
104
- if (this.kindAuthor.has(kindAuthorKey)) {
105
- this.kindAuthor.get(kindAuthorKey).delete(event);
117
+ const kindAuthorIndex = this.kindAuthor.get(kindAuthorKey);
118
+ if (kindAuthorIndex) {
119
+ kindAuthorIndex.delete(event);
120
+ if (kindAuthorIndex.size === 0)
121
+ this.kindAuthor.delete(kindAuthorKey);
106
122
  }
107
123
  for (const tag of getIndexableTags(event)) {
108
124
  if (this.tags.has(tag)) {
109
- this.getTagIndex(tag).delete(event);
125
+ const tagIndex = this.getTagIndex(tag);
126
+ tagIndex.delete(event);
127
+ // Drop the tag index when empty so it does not retain the key forever
128
+ if (tagIndex.size === 0)
129
+ this.tags.delete(tag);
110
130
  }
111
131
  }
112
132
  // remove from created_at index using binary search
@@ -117,8 +137,12 @@ export class EventMemory {
117
137
  const identifier = event.tags.find((t) => t[0] === "d")?.[1];
118
138
  const address = createReplaceableAddress(event.kind, event.pubkey, identifier);
119
139
  const array = this.replaceable.get(address);
120
- if (array)
140
+ if (array) {
121
141
  this.removeFromSortedArray(array, event);
142
+ // Drop the empty array so the map does not retain an address key forever
143
+ if (array.length === 0)
144
+ this.replaceable.delete(address);
145
+ }
122
146
  }
123
147
  // remove any claims this event has
124
148
  this.claims.delete(event);
@@ -1,4 +1,4 @@
1
- import { Observable } from "rxjs";
1
+ import { Observable, ReplaySubject } from "rxjs";
2
2
  import { NostrEvent } from "../helpers/event.js";
3
3
  import { Filter } from "../helpers/filter.js";
4
4
  import { AddressPointer, AddressPointerWithoutD, EventPointer, ProfilePointer } from "../helpers/pointers.js";
@@ -30,6 +30,11 @@ export declare class EventModels<TStore extends IEventStore | IAsyncEventStore =
30
30
  models: Map<ModelConstructor<any, any[], TStore>, Map<string, Observable<any>>>;
31
31
  /** How long a model should be kept "warm" while nothing is subscribed to it */
32
32
  modelKeepWarm: number;
33
+ /**
34
+ * Emits when {@link EventModels.dispose} is called to tear down all models.
35
+ * A {@link ReplaySubject} so reset notifiers subscribed after disposal fire immediately.
36
+ */
37
+ protected destroy$: ReplaySubject<void>;
33
38
  /** Get or create a model on the event store */
34
39
  model<T extends unknown, Args extends Array<any>>(constructor: ModelConstructor<T, Args, TStore>, ...args: Args): Observable<T>;
35
40
  /**
@@ -56,4 +61,9 @@ export declare class EventModels<TStore extends IEventStore | IAsyncEventStore =
56
61
  inboxes: string[];
57
62
  outboxes: string[];
58
63
  } | undefined>;
64
+ /**
65
+ * Tears down all models, completing active subscriptions and releasing their keep-warm timers immediately.
66
+ * @note This is a terminal operation; the instance should be discarded after calling it.
67
+ */
68
+ dispose(): void;
59
69
  }
@@ -1,5 +1,5 @@
1
1
  import hash_sum from "hash-sum";
2
- import { finalize, ReplaySubject, share, timer } from "rxjs";
2
+ import { finalize, merge, ReplaySubject, share, takeUntil, timer } from "rxjs";
3
3
  import { isEventPointer, } from "../helpers/pointers.js";
4
4
  import { EventModel, FiltersModel, ReplaceableModel, TimelineModel } from "../models/base.js";
5
5
  import { ContactsModel } from "../models/contacts.js";
@@ -32,6 +32,11 @@ export class EventModels {
32
32
  models = new Map();
33
33
  /** How long a model should be kept "warm" while nothing is subscribed to it */
34
34
  modelKeepWarm = 60_000;
35
+ /**
36
+ * Emits when {@link EventModels.dispose} is called to tear down all models.
37
+ * A {@link ReplaySubject} so reset notifiers subscribed after disposal fire immediately.
38
+ */
39
+ destroy$ = new ReplaySubject(1);
35
40
  /** Get or create a model on the event store */
36
41
  model(constructor, ...args) {
37
42
  let models = this.models.get(constructor);
@@ -54,9 +59,12 @@ export class EventModels {
54
59
  // only subscribe to models once for all subscriptions
55
60
  share({
56
61
  connector: () => new ReplaySubject(1),
57
- resetOnComplete: () => timer(this.modelKeepWarm),
58
- resetOnRefCountZero: () => timer(this.modelKeepWarm),
59
- }));
62
+ // Reset after the keep-warm window OR immediately when the store is disposed
63
+ resetOnComplete: () => merge(timer(this.modelKeepWarm), this.destroy$),
64
+ resetOnRefCountZero: () => merge(timer(this.modelKeepWarm), this.destroy$),
65
+ }),
66
+ // Complete any active subscriptions when the store is disposed
67
+ takeUntil(this.destroy$));
60
68
  // Add the model to the cache
61
69
  models.set(key, model);
62
70
  }
@@ -118,4 +126,12 @@ export class EventModels {
118
126
  user = { pubkey: user };
119
127
  return this.model(MailboxesModel, user);
120
128
  }
129
+ /**
130
+ * Tears down all models, completing active subscriptions and releasing their keep-warm timers immediately.
131
+ * @note This is a terminal operation; the instance should be discarded after calling it.
132
+ */
133
+ dispose() {
134
+ this.destroy$.next();
135
+ this.destroy$.complete();
136
+ }
121
137
  }
@@ -50,6 +50,8 @@ export declare class EventStore extends EventModels implements IEventStore {
50
50
  remove$: Subject<NostrEvent>;
51
51
  /** A method that will be called when an event isn't found in the store */
52
52
  eventLoader?: (pointer: EventPointer | AddressPointer | AddressPointerWithoutD) => Observable<NostrEvent> | Promise<NostrEvent | undefined>;
53
+ /** Internal subscriptions (delete + expiration managers) torn down on dispose */
54
+ private internalSubscriptions;
53
55
  constructor(options?: EventStoreOptions);
54
56
  /** A method to add all events to memory to ensure there is only ever a single instance of an event */
55
57
  private mapToMemory;
@@ -98,4 +100,12 @@ export declare class EventStore extends EventModels implements IEventStore {
98
100
  unclaimed(): Generator<NostrEvent>;
99
101
  /** Removes any event that is not being used by a subscription */
100
102
  prune(limit?: number): number;
103
+ /**
104
+ * Tears down the store: disposes the attached event loader, completes the event streams, releases
105
+ * model keep-warm timers, and unsubscribes internal manager listeners.
106
+ * @note This is a terminal operation; the store should be discarded after calling it.
107
+ */
108
+ dispose(): void;
109
+ /** Allows the store to be used with the `using` keyword */
110
+ [Symbol.dispose](): void;
101
111
  }
@@ -1,5 +1,5 @@
1
1
  import { verifyEvent as coreVerifyEvent, verifiedSymbol } from "nostr-tools/pure";
2
- import { Subject } from "rxjs";
2
+ import { Subject, Subscription } from "rxjs";
3
3
  import { EncryptedContentSymbol } from "../helpers/encrypted-content.js";
4
4
  import { EventStoreSymbol, FromCacheSymbol, getReplaceableIdentifier, isRegularKind, isReplaceable, kinds, } from "../helpers/event.js";
5
5
  import { getExpirationTimestamp } from "../helpers/expiration.js";
@@ -45,6 +45,8 @@ export class EventStore extends EventModels {
45
45
  remove$ = new Subject();
46
46
  /** A method that will be called when an event isn't found in the store */
47
47
  eventLoader;
48
+ /** Internal subscriptions (delete + expiration managers) torn down on dispose */
49
+ internalSubscriptions = new Subscription();
48
50
  constructor(options) {
49
51
  super();
50
52
  if (options?.database) {
@@ -67,11 +69,11 @@ export class EventStore extends EventModels {
67
69
  // Use provided delete manager or create a default one
68
70
  this.deletes = options?.deleteManager ?? new DeleteManager();
69
71
  // Listen to delete notifications and remove matching events
70
- this.deletes.deleted$.subscribe(this.handleDeleteNotification.bind(this));
72
+ this.internalSubscriptions.add(this.deletes.deleted$.subscribe(this.handleDeleteNotification.bind(this)));
71
73
  // Create expiration manager
72
74
  this.expiration = options?.expirationManager ?? new ExpirationManager();
73
75
  // Listen to expired events and remove them from the store
74
- this.expiration.expired$.subscribe(this.handleExpiredNotification.bind(this));
76
+ this.internalSubscriptions.add(this.expiration.expired$.subscribe(this.handleExpiredNotification.bind(this)));
75
77
  }
76
78
  mapToMemory(event) {
77
79
  if (event === undefined)
@@ -364,4 +366,30 @@ export class EventStore extends EventModels {
364
366
  prune(limit) {
365
367
  return this.memory.prune(limit) ?? 0;
366
368
  }
369
+ /**
370
+ * Tears down the store: disposes the attached event loader, completes the event streams, releases
371
+ * model keep-warm timers, and unsubscribes internal manager listeners.
372
+ * @note This is a terminal operation; the store should be discarded after calling it.
373
+ */
374
+ dispose() {
375
+ // Tear down the attached event loader if it supports disposal
376
+ const loader = this.eventLoader;
377
+ if (loader && typeof loader[Symbol.dispose] === "function")
378
+ loader[Symbol.dispose]();
379
+ this.eventLoader = undefined;
380
+ // Complete all models and release their keep-warm timers
381
+ super.dispose();
382
+ // Tear down internal manager subscriptions
383
+ this.internalSubscriptions.unsubscribe();
384
+ // Cancel any pending expiration timer
385
+ this.expiration.dispose?.();
386
+ // Complete the event streams
387
+ this.insert$.complete();
388
+ this.update$.complete();
389
+ this.remove$.complete();
390
+ }
391
+ /** Allows the store to be used with the `using` keyword */
392
+ [Symbol.dispose]() {
393
+ this.dispose();
394
+ }
367
395
  }
@@ -30,6 +30,13 @@ export declare class ExpirationManager implements IExpirationManager {
30
30
  * @returns true if the event has expired, false otherwise
31
31
  */
32
32
  check(event: NostrEvent): boolean;
33
+ /**
34
+ * Tears down the manager: cancels any pending timer and completes the expired$ stream
35
+ * @note This is a terminal operation; the manager should be discarded after calling it.
36
+ */
37
+ dispose(): void;
38
+ /** Allows the manager to be used with the `using` keyword */
39
+ [Symbol.dispose](): void;
33
40
  /**
34
41
  * Remove expired events from the store and emit them
35
42
  */
@@ -62,6 +62,22 @@ export class ExpirationManager {
62
62
  return false;
63
63
  return expiration <= unixNow();
64
64
  }
65
+ /**
66
+ * Tears down the manager: cancels any pending timer and completes the expired$ stream
67
+ * @note This is a terminal operation; the manager should be discarded after calling it.
68
+ */
69
+ dispose() {
70
+ if (this.timer)
71
+ clearTimeout(this.timer);
72
+ this.timer = null;
73
+ this.nextCheck = null;
74
+ this.expirations.clear();
75
+ this.expiredSubject.complete();
76
+ }
77
+ /** Allows the manager to be used with the `using` keyword */
78
+ [Symbol.dispose]() {
79
+ this.dispose();
80
+ }
65
81
  /**
66
82
  * Remove expired events from the store and emit them
67
83
  */
@@ -172,6 +172,8 @@ export interface IExpirationManager {
172
172
  forget(eventId: string): void;
173
173
  /** Check if an event is expired */
174
174
  check(event: NostrEvent): boolean;
175
+ /** Tears down the manager and cancels any pending timers */
176
+ dispose?(): void;
175
177
  }
176
178
  /** The base interface for a database of events */
177
179
  export interface IEventDatabase extends IEventStoreRead {
@@ -39,6 +39,8 @@ export declare function isProfilePointer(pointer: any): pointer is ProfilePointe
39
39
  export declare function isEventPointer(pointer: any): pointer is EventPointer;
40
40
  /** Returns the stringified NIP-19 encoded naddr address pointer for an AddressPointer. */
41
41
  export declare function getReplaceableAddressFromPointer(pointer: AddressPointer): string;
42
+ /** Returns the replaceable coordinate for a replaceable event ( used for "a" tags to reference replaceable events ). */
43
+ export declare function getReplaceableAddressForEvent(event: NostrEvent): string | null;
42
44
  /** Returns an AddressPointer for a replaceable event. Returns null if the event is not addressable or replaceable. */
43
45
  export declare function getAddressPointerForEvent(event: NostrEvent, relays?: string[]): AddressPointer | null;
44
46
  /** Returns an EventPointer for an event */
@@ -204,6 +204,13 @@ export function isEventPointer(pointer) {
204
204
  export function getReplaceableAddressFromPointer(pointer) {
205
205
  return pointer.kind + ":" + pointer.pubkey + ":" + pointer.identifier;
206
206
  }
207
+ /** Returns the replaceable coordinate for a replaceable event ( used for "a" tags to reference replaceable events ). */
208
+ export function getReplaceableAddressForEvent(event) {
209
+ if (!isReplaceableKind(event.kind) && !isAddressableKind(event.kind))
210
+ return null;
211
+ const d = getReplaceableIdentifier(event);
212
+ return event.kind + ":" + event.pubkey + ":" + d;
213
+ }
207
214
  /** Returns an AddressPointer for a replaceable event. Returns null if the event is not addressable or replaceable. */
208
215
  export function getAddressPointerForEvent(event, relays) {
209
216
  if (!isAddressableKind(event.kind) && !isReplaceableKind(event.kind))
@@ -106,6 +106,7 @@ export function TimelineModel(filters, includeOldVersion) {
106
106
  filters = Array.isArray(filters) ? filters : [filters];
107
107
  return (store) => {
108
108
  const seen = new Map();
109
+ const getTimelineUID = (event) => (includeOldVersion ? event.id : getEventUID(event));
109
110
  // get current events
110
111
  return defer(() => {
111
112
  const r = store.getTimeline(filters);
@@ -125,27 +126,37 @@ export function TimelineModel(filters, includeOldVersion) {
125
126
  // build a timeline
126
127
  scan((timeline, event) => {
127
128
  // filter out removed events from timeline
128
- if (typeof event === "string")
129
+ if (typeof event === "string") {
130
+ // Forget the removed event so the seen map does not grow unbounded on long-lived
131
+ // feeds. Only forget when the seen entry is the event being removed (not a newer
132
+ // replaceable version that happens to share a UID).
133
+ const removed = timeline.find((e) => e.id === event);
134
+ if (removed) {
135
+ const uid = getTimelineUID(removed);
136
+ if (seen.get(uid)?.id === event)
137
+ seen.delete(uid);
138
+ }
129
139
  return timeline.filter((e) => e.id !== event);
140
+ }
130
141
  // initial timeline array
131
142
  if (Array.isArray(event)) {
132
- if (!includeOldVersion) {
133
- for (const e of event)
134
- if (isReplaceable(e.kind))
135
- seen.set(getEventUID(e), e);
136
- }
143
+ seen.clear();
144
+ for (const e of event)
145
+ seen.set(getTimelineUID(e), e);
137
146
  // Always return a new array instance to ensure UI libraries detect changes
138
147
  return [...event];
139
148
  }
140
149
  // create a new timeline and insert the event into it
141
150
  let newTimeline = [...timeline];
151
+ const uid = getTimelineUID(event);
152
+ const existing = seen.get(uid);
153
+ // Ignore duplicate regular events and older replaceable versions.
154
+ if (existing &&
155
+ (!isReplaceable(event.kind) || (!includeOldVersion && event.created_at < existing.created_at))) {
156
+ return [...timeline];
157
+ }
142
158
  // remove old replaceable events if enabled
143
159
  if (!includeOldVersion && isReplaceable(event.kind)) {
144
- const uid = getEventUID(event);
145
- const existing = seen.get(uid);
146
- // if this is an older replaceable event, return a new array instance
147
- if (existing && event.created_at < existing.created_at)
148
- return [...timeline];
149
160
  // update latest version
150
161
  seen.set(uid, event);
151
162
  // remove old event from timeline
@@ -155,6 +166,9 @@ export function TimelineModel(filters, includeOldVersion) {
155
166
  newTimeline.splice(index, 1);
156
167
  }
157
168
  }
169
+ else {
170
+ seen.set(uid, event);
171
+ }
158
172
  // add event into timeline
159
173
  insertEventIntoDescendingList(newTimeline, event);
160
174
  return newTimeline;
@@ -0,0 +1,8 @@
1
+ import { OperatorFunction } from "rxjs";
2
+ import type { CastConstructor, CastRefEventStore } from "../casts/cast.js";
3
+ import { EventCast } from "../casts/index.js";
4
+ import { NostrEvent } from "../helpers/event.js";
5
+ /** Casts an event to a specific type */
6
+ export declare function castEventStream<C extends EventCast>(cls: CastConstructor<C>, store?: CastRefEventStore): OperatorFunction<NostrEvent | undefined, C | undefined>;
7
+ /** Casts and array of events to an array of casted events and filters out undefined values */
8
+ export declare function castTimelineStream<C extends EventCast>(cls: CastConstructor<C>, store?: CastRefEventStore): OperatorFunction<NostrEvent[], C[]>;
@@ -0,0 +1,29 @@
1
+ import { map } from "rxjs";
2
+ import { castEvent } from "../casts/index.js";
3
+ import { defined } from "./defined.js";
4
+ /** Casts an event to a specific type */
5
+ export function castEventStream(cls, store) {
6
+ return (source) => source.pipe(map((event) => {
7
+ if (!event)
8
+ return undefined;
9
+ try {
10
+ return castEvent(event, cls, store);
11
+ }
12
+ catch { }
13
+ return undefined;
14
+ }));
15
+ }
16
+ /** Casts and array of events to an array of casted events and filters out undefined values */
17
+ export function castTimelineStream(cls, store) {
18
+ return (source) => source.pipe(map((events) => {
19
+ const castedEvents = [];
20
+ for (const event of events) {
21
+ try {
22
+ const casted = castEvent(event, cls, store);
23
+ castedEvents.push(casted);
24
+ }
25
+ catch { }
26
+ }
27
+ return castedEvents;
28
+ }), defined());
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-core",
3
- "version": "6.0.3",
3
+ "version": "6.2.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",