applesauce-core 6.1.0 → 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.
@@ -4,11 +4,15 @@ import type { ProfilePointer } from "../helpers/pointers.js";
4
4
  import { ChainableObservable } from "../observable/chainable.js";
5
5
  import { CastRefEventStore } from "./cast.js";
6
6
  export type PubkeyCastConstructor<C extends PubkeyCast> = (new (pointer: ProfilePointer, store: CastRefEventStore) => C) & {
7
- cache: Map<string, C>;
7
+ cache?: Map<string, WeakRef<C>>;
8
+ cacheRegistry?: FinalizationRegistry<string>;
8
9
  };
9
10
  /**
10
11
  * Cast a pubkey to a specific class instance.
11
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.
12
16
  */
13
17
  export declare function castPubkey<C extends PubkeyCast>(pubkey: string | NostrEvent | ProfilePointer, cls: PubkeyCastConstructor<C>, store: CastRefEventStore): C;
14
18
  /** Base class for pubkey-based casts (analogous to {@link EventCast} for events) */
@@ -16,8 +20,10 @@ export declare class PubkeyCast {
16
20
  #private;
17
21
  readonly pointer: ProfilePointer;
18
22
  readonly store: CastRefEventStore;
19
- /** A global cache of pubkey -> instance, populated by {@link castPubkey} */
20
- 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>;
21
27
  constructor(pointer: ProfilePointer, store: CastRefEventStore);
22
28
  /** The hex pubkey represented by this cast */
23
29
  get pubkey(): string;
@@ -4,6 +4,9 @@ import { chainable } from "../observable/chainable.js";
4
4
  /**
5
5
  * Cast a pubkey to a specific class instance.
6
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.
7
10
  */
8
11
  export function castPubkey(pubkey, cls, store) {
9
12
  if (isEvent(pubkey))
@@ -14,19 +17,30 @@ export function castPubkey(pubkey, cls, store) {
14
17
  const cacheKey = pointer.relays?.length ? `${pointer.pubkey}:${JSON.stringify(pointer.relays)}` : pointer.pubkey;
15
18
  if (!cls.cache)
16
19
  cls.cache = new Map();
17
- 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();
18
29
  if (existing)
19
30
  return existing;
20
31
  const instance = new cls(pointer, store);
21
- cls.cache.set(cacheKey, instance);
32
+ cls.cache.set(cacheKey, new WeakRef(instance));
33
+ cls.cacheRegistry.register(instance, cacheKey);
22
34
  return instance;
23
35
  }
24
36
  /** Base class for pubkey-based casts (analogous to {@link EventCast} for events) */
25
37
  export class PubkeyCast {
26
38
  pointer;
27
39
  store;
28
- /** A global cache of pubkey -> instance, populated by {@link castPubkey} */
40
+ /** A global cache of pubkey -> weak instance reference, populated by {@link castPubkey} */
29
41
  static cache = new Map();
42
+ /** Cleans up dead {@link cache} entries when their instances are garbage collected */
43
+ static cacheRegistry;
30
44
  constructor(pointer, store) {
31
45
  this.pointer = pointer;
32
46
  this.store = store;
@@ -11,8 +11,8 @@ export declare function castUser(event: NostrEvent, store: CastRefEventStore): U
11
11
  export declare function castUser(user: string | ProfilePointer, store: CastRefEventStore): User;
12
12
  /** A class representing a Nostr user */
13
13
  export declare class User extends PubkeyCast {
14
- /** A global cache of pubkey -> {@link User} */
15
- static cache: Map<string, User>;
14
+ /** A global cache of pubkey -> weak {@link User} reference */
15
+ static cache: Map<string, WeakRef<User>>;
16
16
  /** Returns the NIP-19 npub for this user */
17
17
  get npub(): `npub1${string}`;
18
18
  /** Returns the NIP-19 nprofile for this user */
@@ -8,7 +8,7 @@ export function castUser(user, store) {
8
8
  }
9
9
  /** A class representing a Nostr user */
10
10
  export class User extends PubkeyCast {
11
- /** A global cache of pubkey -> {@link User} */
11
+ /** A global cache of pubkey -> weak {@link User} reference */
12
12
  static cache = new Map();
13
13
  /** Returns the NIP-19 npub for this user */
14
14
  get npub() {
@@ -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 {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-core",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",