applesauce-core 0.12.0 → 1.0.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.
Files changed (68) hide show
  1. package/README.md +28 -10
  2. package/dist/event-store/__tests__/event-store.test.js +83 -1
  3. package/dist/event-store/database.d.ts +1 -0
  4. package/dist/event-store/database.js +7 -8
  5. package/dist/event-store/event-store.d.ts +4 -0
  6. package/dist/event-store/event-store.js +61 -22
  7. package/dist/event-store/interface.d.ts +11 -5
  8. package/dist/helpers/__tests__/bookmarks.test.d.ts +1 -0
  9. package/dist/helpers/__tests__/bookmarks.test.js +88 -0
  10. package/dist/helpers/__tests__/comment.test.js +14 -0
  11. package/dist/helpers/__tests__/contacts.test.d.ts +1 -0
  12. package/dist/helpers/__tests__/contacts.test.js +34 -0
  13. package/dist/helpers/__tests__/events.test.d.ts +1 -0
  14. package/dist/helpers/__tests__/events.test.js +32 -0
  15. package/dist/helpers/__tests__/mutes.test.d.ts +1 -0
  16. package/dist/helpers/__tests__/mutes.test.js +55 -0
  17. package/dist/helpers/bookmarks.d.ts +6 -1
  18. package/dist/helpers/bookmarks.js +52 -7
  19. package/dist/helpers/comment.d.ts +7 -3
  20. package/dist/helpers/comment.js +6 -1
  21. package/dist/helpers/contacts.d.ts +11 -0
  22. package/dist/helpers/contacts.js +34 -0
  23. package/dist/helpers/event.d.ts +8 -3
  24. package/dist/helpers/event.js +21 -13
  25. package/dist/helpers/lists.d.ts +40 -12
  26. package/dist/helpers/lists.js +62 -23
  27. package/dist/helpers/mutes.d.ts +8 -0
  28. package/dist/helpers/mutes.js +66 -5
  29. package/dist/helpers/nip-19.d.ts +14 -0
  30. package/dist/helpers/nip-19.js +29 -0
  31. package/dist/helpers/pointers.js +6 -6
  32. package/dist/observable/__tests__/listen-latest-updates.test.d.ts +1 -0
  33. package/dist/observable/__tests__/listen-latest-updates.test.js +55 -0
  34. package/dist/observable/defined.d.ts +3 -0
  35. package/dist/observable/defined.js +5 -0
  36. package/dist/observable/get-observable-value.d.ts +4 -1
  37. package/dist/observable/get-observable-value.js +4 -1
  38. package/dist/observable/index.d.ts +3 -1
  39. package/dist/observable/index.js +3 -1
  40. package/dist/observable/listen-latest-updates.d.ts +5 -0
  41. package/dist/observable/listen-latest-updates.js +12 -0
  42. package/dist/queries/blossom.js +1 -6
  43. package/dist/queries/bookmarks.d.ts +5 -5
  44. package/dist/queries/bookmarks.js +18 -17
  45. package/dist/queries/channels.js +41 -53
  46. package/dist/queries/comments.js +6 -9
  47. package/dist/queries/contacts.d.ts +6 -1
  48. package/dist/queries/contacts.js +21 -9
  49. package/dist/queries/index.d.ts +1 -0
  50. package/dist/queries/index.js +1 -0
  51. package/dist/queries/mailboxes.js +4 -7
  52. package/dist/queries/mutes.d.ts +6 -6
  53. package/dist/queries/mutes.js +20 -19
  54. package/dist/queries/pins.d.ts +1 -0
  55. package/dist/queries/pins.js +4 -6
  56. package/dist/queries/profile.js +1 -6
  57. package/dist/queries/reactions.js +11 -14
  58. package/dist/queries/relays.d.ts +27 -0
  59. package/dist/queries/relays.js +44 -0
  60. package/dist/queries/simple.js +5 -22
  61. package/dist/queries/thread.js +45 -51
  62. package/dist/queries/user-status.js +23 -29
  63. package/dist/queries/zaps.js +10 -13
  64. package/dist/query-store/query-store.d.ts +7 -6
  65. package/dist/query-store/query-store.js +13 -8
  66. package/package.json +3 -3
  67. package/dist/observable/share-latest-value.d.ts +0 -6
  68. package/dist/observable/share-latest-value.js +0 -24
package/README.md CHANGED
@@ -1,37 +1,55 @@
1
1
  # applesauce-core
2
2
 
3
- AppleSauce Core is an interpretation layer for nostr clients, Push events into the in-memory [database](https://hzrd149.github.io/applesauce/typedoc/classes/Database.html) and get nicely formatted data out with [queries](https://hzrd149.github.io/applesauce/typedoc/modules/Queries)
3
+ AppleSauce is a collection of utilities for building reactive nostr applications. The core package provides an in-memory event database and reactive queries to help you build nostr UIs with less code.
4
4
 
5
- # Example
5
+ ## Key Components
6
+
7
+ - **Helpers**: Core utility methods for parsing and extracting data from nostr events
8
+ - **EventStore**: In-memory database for storing and subscribing to nostr events
9
+ - **QueryStore**: Manages queries and ensures efficient subscription handling
10
+ - **Queries**: Complex subscriptions for common nostr data patterns
11
+
12
+ ## Documentation
13
+
14
+ For detailed documentation and guides, visit:
15
+
16
+ - [Getting Started](https://hzrd149.github.io/applesauce/introduction/getting-started)
17
+ - [API Reference](https://hzrd149.github.io/applesauce/typedoc/)
18
+
19
+ ## Example
6
20
 
7
21
  ```js
8
22
  import { EventStore, QueryStore } from "applesauce-core";
9
23
  import { Relay } from "nostr-tools/relay";
10
24
 
11
- // The EventStore handles all the events
25
+ // Create a single EventStore instance for your app
12
26
  const eventStore = new EventStore();
13
27
 
14
- // The QueryStore handles queries and makes sure not to run multiple of the same query
28
+ // Create a QueryStore to manage subscriptions efficiently
15
29
  const queryStore = new QueryStore(eventStore);
16
30
 
17
- // Use nostr-tools or anything else to talk to relays
31
+ // Use any nostr library for relay connections (nostr-tools, ndk, nostrify, etc...)
18
32
  const relay = await Relay.connect("wss://relay.example.com");
19
33
 
20
- const sub = relay.subscribe([{ authors: ["266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5"] }], {
34
+ // Subscribe to events and add them to the store
35
+ const sub = relay.subscribe([{ authors: ["3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"] }], {
21
36
  onevent(event) {
22
37
  eventStore.add(event);
23
38
  },
24
39
  });
25
40
 
26
- // This will return an Observable<ProfileContent | undefined> of the parsed metadata
27
- const profile = queryStore.profile("266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5");
41
+ // Subscribe to profile changes using ProfileQuery
42
+ const profile = queryStore.createQuery(
43
+ ProfileQuery,
44
+ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
45
+ );
28
46
 
29
47
  profile.subscribe((parsed) => {
30
48
  if (parsed) console.log(parsed);
31
49
  });
32
50
 
33
- // This will return an Observable<NostrEvent[]> of all kind 1 events sorted by created_at
34
- const timeline = queryStore.timeline({ kinds: [1] });
51
+ // Subscribe to a timeline of events
52
+ const timeline = queryStore.createQuery(TimelineQuery, { kinds: [1] });
35
53
 
36
54
  timeline.subscribe((events) => {
37
55
  console.log(events);
@@ -31,7 +31,7 @@ describe("add", () => {
31
31
  expect(eventStore.getEvent(profile.id)).toBeDefined();
32
32
  expect([...getSeenRelays(eventStore.getEvent(profile.id))]).toEqual(expect.arrayContaining(["wss://relay.a.com", "wss://relay.b.com"]));
33
33
  });
34
- it("should ignore deleted events", () => {
34
+ it("should ignore old deleted events but not newer ones", () => {
35
35
  const deleteEvent = {
36
36
  id: "delete event id",
37
37
  kind: kinds.EventDeletion,
@@ -45,7 +45,82 @@ describe("add", () => {
45
45
  eventStore.add(deleteEvent);
46
46
  // now event should be ignored
47
47
  eventStore.add(profile);
48
+ const newProfile = user.profile({ name: "new name" }, { created_at: profile.created_at + 1000 });
49
+ eventStore.add(newProfile);
48
50
  expect(eventStore.getEvent(profile.id)).toBeUndefined();
51
+ expect(eventStore.getEvent(newProfile.id)).toBeDefined();
52
+ });
53
+ it("should remove profile events when delete event is added", () => {
54
+ // Add initial replaceable event
55
+ eventStore.add(profile);
56
+ expect(eventStore.getEvent(profile.id)).toBeDefined();
57
+ const newProfile = user.profile({ name: "new name" }, { created_at: profile.created_at + 1000 });
58
+ eventStore.add(newProfile);
59
+ const deleteEvent = {
60
+ id: "delete event id",
61
+ kind: kinds.EventDeletion,
62
+ created_at: profile.created_at + 100,
63
+ pubkey: user.pubkey,
64
+ tags: [["a", `${profile.kind}:${profile.pubkey}`]],
65
+ sig: "this should be ignored for the test",
66
+ content: "test",
67
+ };
68
+ // Add delete event with coordinate
69
+ eventStore.add(deleteEvent);
70
+ // Profile should be removed since delete event is newer
71
+ expect(eventStore.getEvent(profile.id)).toBeUndefined();
72
+ expect(eventStore.getEvent(newProfile.id)).toBeDefined();
73
+ expect(eventStore.getReplaceable(profile.kind, profile.pubkey)).toBe(newProfile);
74
+ });
75
+ it("should remove addressable replaceable events when delete event is added", () => {
76
+ // Add initial replaceable event
77
+ const event = user.event({ content: "test", kind: 30000, tags: [["d", "test"]] });
78
+ eventStore.add(event);
79
+ expect(eventStore.getEvent(event.id)).toBeDefined();
80
+ const newEvent = user.event({
81
+ ...event,
82
+ created_at: event.created_at + 500,
83
+ });
84
+ eventStore.add(newEvent);
85
+ const deleteEvent = {
86
+ id: "delete event id",
87
+ kind: kinds.EventDeletion,
88
+ created_at: event.created_at + 100,
89
+ pubkey: user.pubkey,
90
+ tags: [["a", `${event.kind}:${event.pubkey}:test`]],
91
+ sig: "this should be ignored for the test",
92
+ content: "test",
93
+ };
94
+ // Add delete event with coordinate
95
+ eventStore.add(deleteEvent);
96
+ // Profile should be removed since delete event is newer
97
+ expect(eventStore.getEvent(event.id)).toBeUndefined();
98
+ expect(eventStore.getEvent(newEvent.id)).toBeDefined();
99
+ expect(eventStore.getReplaceable(event.kind, event.pubkey, "test")).toBe(newEvent);
100
+ });
101
+ });
102
+ describe("inserts", () => {
103
+ it("should emit newer replaceable events", () => {
104
+ const spy = subscribeSpyTo(eventStore.inserts);
105
+ eventStore.add(profile);
106
+ const newer = user.profile({ name: "new name" }, { created_at: profile.created_at + 100 });
107
+ eventStore.add(newer);
108
+ expect(spy.getValues()).toEqual([profile, newer]);
109
+ });
110
+ it("should not emit when older replaceable event is added", () => {
111
+ const spy = subscribeSpyTo(eventStore.inserts);
112
+ eventStore.add(profile);
113
+ eventStore.add(user.profile({ name: "new name" }, { created_at: profile.created_at - 1000 }));
114
+ expect(spy.getValues()).toEqual([profile]);
115
+ });
116
+ });
117
+ describe("removes", () => {
118
+ it("should emit older replaceable events when the newest replaceable event is added", () => {
119
+ const spy = subscribeSpyTo(eventStore.removes);
120
+ eventStore.add(profile);
121
+ const newer = user.profile({ name: "new name" }, { created_at: profile.created_at + 1000 });
122
+ eventStore.add(newer);
123
+ expect(spy.getValues()).toEqual([profile]);
49
124
  });
50
125
  });
51
126
  describe("verifyEvent", () => {
@@ -187,6 +262,13 @@ describe("replaceable", () => {
187
262
  eventStore.add(user.profile({ name: "really old name" }, { created_at: profile.created_at - 1000 }));
188
263
  expect(spy.getValues()).toEqual([profile]);
189
264
  });
265
+ it("should emit newer events", () => {
266
+ const spy = subscribeSpyTo(eventStore.replaceable(0, user.pubkey));
267
+ eventStore.add(profile);
268
+ const newProfile = user.profile({ name: "new name" }, { created_at: profile.created_at + 500 });
269
+ eventStore.add(newProfile);
270
+ expect(spy.getValues()).toEqual([profile, newProfile]);
271
+ });
190
272
  });
191
273
  describe("timeline", () => {
192
274
  it("should emit an empty array if there are not events", () => {
@@ -14,6 +14,7 @@ export declare class Database {
14
14
  protected created_at: NostrEvent[];
15
15
  /** LRU cache of last events touched */
16
16
  events: LRU<import("nostr-tools").Event>;
17
+ /** A sorted array of replaceable events by uid */
17
18
  protected replaceable: Map<string, import("nostr-tools").Event[]>;
18
19
  /** A stream of events inserted into the database */
19
20
  inserted: Subject<import("nostr-tools").Event>;
@@ -1,6 +1,6 @@
1
1
  import { binarySearch, insertEventIntoDescendingList } from "nostr-tools/utils";
2
2
  import { Subject } from "rxjs";
3
- import { FromCacheSymbol, getEventUID, getIndexableTags, getReplaceableUID, isReplaceable } from "../helpers/event.js";
3
+ import { getEventUID, getIndexableTags, createReplaceableAddress, isReplaceable } from "../helpers/event.js";
4
4
  import { INDEXABLE_TAGS } from "./common.js";
5
5
  import { logger } from "../logger.js";
6
6
  import { LRU } from "../helpers/lru.js";
@@ -17,6 +17,7 @@ export class Database {
17
17
  created_at = [];
18
18
  /** LRU cache of last events touched */
19
19
  events = new LRU();
20
+ /** A sorted array of replaceable events by uid */
20
21
  replaceable = new Map();
21
22
  /** A stream of events inserted into the database */
22
23
  inserted = new Subject();
@@ -72,23 +73,19 @@ export class Database {
72
73
  }
73
74
  /** Checks if the database contains a replaceable event without touching it */
74
75
  hasReplaceable(kind, pubkey, d) {
75
- const events = this.replaceable.get(getReplaceableUID(kind, pubkey, d));
76
+ const events = this.replaceable.get(createReplaceableAddress(kind, pubkey, d));
76
77
  return !!events && events.length > 0;
77
78
  }
78
79
  /** Gets an array of replaceable events */
79
80
  getReplaceable(kind, pubkey, d) {
80
- return this.replaceable.get(getReplaceableUID(kind, pubkey, d));
81
+ return this.replaceable.get(createReplaceableAddress(kind, pubkey, d));
81
82
  }
82
83
  /** Inserts an event into the database and notifies all subscriptions */
83
84
  addEvent(event) {
84
85
  const id = event.id;
85
86
  const current = this.events.get(id);
86
- if (current) {
87
- // if this is a duplicate event, transfer some important symbols
88
- if (event[FromCacheSymbol])
89
- current[FromCacheSymbol] = event[FromCacheSymbol];
87
+ if (current)
90
88
  return current;
91
- }
92
89
  this.onBeforeInsert?.(event);
93
90
  this.events.set(id, event);
94
91
  this.getKindIndex(event.kind).add(event);
@@ -105,9 +102,11 @@ export class Database {
105
102
  const uid = getEventUID(event);
106
103
  let array = this.replaceable.get(uid);
107
104
  if (!this.replaceable.has(uid)) {
105
+ // add an empty array if there is no array
108
106
  array = [];
109
107
  this.replaceable.set(uid, array);
110
108
  }
109
+ // insert the event into the sorted array
111
110
  insertEventIntoDescendingList(array, event);
112
111
  }
113
112
  this.inserted.next(event);
@@ -9,8 +9,12 @@ export declare class EventStore implements IEventStore {
9
9
  keepOldVersions: boolean;
10
10
  /** A method used to verify new events before added them */
11
11
  verifyEvent?: (event: NostrEvent) => boolean;
12
+ /** A stream of new events added to the store */
13
+ inserts: Observable<NostrEvent>;
12
14
  /** A stream of events that have been updated */
13
15
  updates: Observable<NostrEvent>;
16
+ /** A stream of events that have been removed */
17
+ removes: Observable<NostrEvent>;
14
18
  constructor();
15
19
  protected deletedIds: Set<string>;
16
20
  protected deletedCoords: Map<string, number>;
@@ -1,14 +1,15 @@
1
1
  import { kinds } from "nostr-tools";
2
2
  import { insertEventIntoDescendingList } from "nostr-tools/utils";
3
- import { isParameterizedReplaceableKind } from "nostr-tools/kinds";
3
+ import { isAddressableKind } from "nostr-tools/kinds";
4
4
  import { defer, distinctUntilChanged, EMPTY, endWith, filter, finalize, from, map, merge, mergeMap, mergeWith, of, repeat, scan, take, takeUntil, tap, } from "rxjs";
5
5
  import { Database } from "./database.js";
6
- import { getEventUID, getReplaceableIdentifier, getReplaceableUID, getTagValue, isReplaceable, } from "../helpers/event.js";
6
+ import { FromCacheSymbol, getEventUID, getReplaceableIdentifier, createReplaceableAddress, getTagValue, isReplaceable, } from "../helpers/event.js";
7
7
  import { matchFilters } from "../helpers/filter.js";
8
8
  import { addSeenRelay, getSeenRelays } from "../helpers/relays.js";
9
9
  import { getDeleteCoordinates, getDeleteIds } from "../helpers/delete.js";
10
10
  import { claimEvents } from "../observable/claim-events.js";
11
11
  import { claimLatest } from "../observable/claim-latest.js";
12
+ import { parseCoordinate } from "../helpers/pointers.js";
12
13
  export const EventStoreSymbol = Symbol.for("event-store");
13
14
  function sortDesc(a, b) {
14
15
  return b.created_at - a.created_at;
@@ -19,8 +20,12 @@ export class EventStore {
19
20
  keepOldVersions = false;
20
21
  /** A method used to verify new events before added them */
21
22
  verifyEvent;
23
+ /** A stream of new events added to the store */
24
+ inserts;
22
25
  /** A stream of events that have been updated */
23
26
  updates;
27
+ /** A stream of events that have been removed */
28
+ removes;
24
29
  constructor() {
25
30
  this.database = new Database();
26
31
  this.database.onBeforeInsert = (event) => {
@@ -36,7 +41,9 @@ export class EventStore {
36
41
  this.database.removed.subscribe((event) => {
37
42
  Reflect.deleteProperty(event, EventStoreSymbol);
38
43
  });
44
+ this.inserts = this.database.inserted;
39
45
  this.updates = this.database.updated;
46
+ this.removes = this.database.removed;
40
47
  }
41
48
  // delete state
42
49
  deletedIds = new Set();
@@ -47,7 +54,7 @@ export class EventStore {
47
54
  else {
48
55
  if (this.deletedIds.has(event.id))
49
56
  return true;
50
- if (isParameterizedReplaceableKind(event.kind)) {
57
+ if (isAddressableKind(event.kind)) {
51
58
  const deleted = this.deletedCoords.get(getEventUID(event));
52
59
  if (deleted)
53
60
  return deleted > event.created_at;
@@ -68,10 +75,16 @@ export class EventStore {
68
75
  const coords = getDeleteCoordinates(deleteEvent);
69
76
  for (const coord of coords) {
70
77
  this.deletedCoords.set(coord, Math.max(this.deletedCoords.get(coord) ?? 0, deleteEvent.created_at));
71
- // remove deleted events in the database
72
- const event = this.database.getEvent(coord);
73
- if (event && event.created_at < deleteEvent.created_at)
74
- this.database.removeEvent(event);
78
+ // Parse the nostr address coordinate
79
+ const parsed = parseCoordinate(coord);
80
+ if (!parsed)
81
+ continue;
82
+ // Remove older versions of replaceable events
83
+ const events = this.database.getReplaceable(parsed.kind, parsed.pubkey, parsed.identifier) ?? [];
84
+ for (const event of events) {
85
+ if (event.created_at < deleteEvent.created_at)
86
+ this.database.removeEvent(event);
87
+ }
75
88
  }
76
89
  }
77
90
  /** Copies important metadata from and identical event to another */
@@ -81,6 +94,10 @@ export class EventStore {
81
94
  for (const relay of relays)
82
95
  addSeenRelay(dest, relay);
83
96
  }
97
+ // copy the from cache symbol only if its true
98
+ const fromCache = Reflect.get(source, FromCacheSymbol);
99
+ if (fromCache && !Reflect.get(dest, FromCacheSymbol))
100
+ Reflect.set(dest, FromCacheSymbol, fromCache);
84
101
  }
85
102
  /**
86
103
  * Adds an event to the database and update subscriptions
@@ -92,6 +109,25 @@ export class EventStore {
92
109
  // Ignore if the event was deleted
93
110
  if (this.checkDeleted(event))
94
111
  return event;
112
+ // Get the replaceable identifier
113
+ const d = isReplaceable(event.kind) ? getTagValue(event, "d") : undefined;
114
+ // Don't insert the event if there is already a newer version
115
+ if (!this.keepOldVersions && isReplaceable(event.kind)) {
116
+ const existing = this.database.getReplaceable(event.kind, event.pubkey, d);
117
+ // If there is already a newer version, copy cached symbols and return existing event
118
+ if (existing && existing.length > 0 && existing[0].created_at >= event.created_at) {
119
+ EventStore.mergeDuplicateEvent(event, existing[0]);
120
+ return existing[0];
121
+ }
122
+ }
123
+ else if (this.database.hasEvent(event.id)) {
124
+ // Duplicate event, copy symbols and return existing event
125
+ const existing = this.database.getEvent(event.id);
126
+ if (existing) {
127
+ EventStore.mergeDuplicateEvent(event, existing);
128
+ return existing;
129
+ }
130
+ }
95
131
  // Insert event into database
96
132
  const inserted = this.database.addEvent(event);
97
133
  // Copy cached data if its a duplicate event
@@ -102,7 +138,7 @@ export class EventStore {
102
138
  addSeenRelay(inserted, fromRelay);
103
139
  // remove all old version of the replaceable event
104
140
  if (!this.keepOldVersions && isReplaceable(event.kind)) {
105
- const existing = this.database.getReplaceable(event.kind, event.pubkey, getTagValue(event, "d"));
141
+ const existing = this.database.getReplaceable(event.kind, event.pubkey, d);
106
142
  if (existing) {
107
143
  const older = Array.from(existing).filter((e) => e.created_at < event.created_at);
108
144
  for (const old of older)
@@ -165,14 +201,14 @@ export class EventStore {
165
201
  // merge existing events
166
202
  onlyNew ? EMPTY : from(this.getAll(filters)),
167
203
  // subscribe to future events
168
- this.database.inserted.pipe(filter((e) => matchFilters(filters, e))));
204
+ this.inserts.pipe(filter((e) => matchFilters(filters, e))));
169
205
  }
170
206
  /** Returns an observable that completes when an event is removed */
171
207
  removed(id) {
172
208
  const deleted = this.checkDeleted(id);
173
209
  if (deleted)
174
210
  return EMPTY;
175
- return this.database.removed.pipe(
211
+ return this.removes.pipe(
176
212
  // listen for removed events
177
213
  filter((e) => e.id === id),
178
214
  // complete as soon as we find a matching removed event
@@ -193,7 +229,7 @@ export class EventStore {
193
229
  return event ? of(event) : EMPTY;
194
230
  }),
195
231
  // subscribe to updates
196
- this.database.inserted.pipe(filter((e) => e.id === id)),
232
+ this.inserts.pipe(filter((e) => e.id === id)),
197
233
  // subscribe to updates
198
234
  this.updated(id),
199
235
  // emit undefined when deleted
@@ -207,15 +243,15 @@ export class EventStore {
207
243
  // lazily get existing events
208
244
  defer(() => from(ids.map((id) => this.getEvent(id)))),
209
245
  // subscribe to new events
210
- this.database.inserted.pipe(filter((e) => ids.includes(e.id))),
246
+ this.inserts.pipe(filter((e) => ids.includes(e.id))),
211
247
  // subscribe to updates
212
- this.database.updated.pipe(filter((e) => ids.includes(e.id)))).pipe(
248
+ this.updates.pipe(filter((e) => ids.includes(e.id)))).pipe(
213
249
  // ignore empty messages
214
250
  filter((e) => !!e),
215
251
  // claim all events until cleanup
216
252
  claimEvents(this.database),
217
253
  // watch for removed events
218
- mergeWith(this.database.removed.pipe(filter((e) => ids.includes(e.id)), map((e) => e.id))),
254
+ mergeWith(this.removes.pipe(filter((e) => ids.includes(e.id)), map((e) => e.id))),
219
255
  // merge all events into a directory
220
256
  scan((dir, event) => {
221
257
  if (typeof event === "string") {
@@ -240,13 +276,16 @@ export class EventStore {
240
276
  return event ? of(event) : EMPTY;
241
277
  }),
242
278
  // subscribe to new events
243
- this.database.inserted.pipe(filter((e) => e.pubkey == pubkey && e.kind === kind && (d !== undefined ? getReplaceableIdentifier(e) === d : true)))).pipe(
279
+ this.inserts.pipe(filter((e) => e.pubkey == pubkey && e.kind === kind && (d !== undefined ? getReplaceableIdentifier(e) === d : true)))).pipe(
244
280
  // only update if event is newer
245
- distinctUntilChanged((prev, event) => prev.created_at >= event.created_at),
281
+ distinctUntilChanged((prev, event) => {
282
+ // are the events the same? i.e. is the prev event older
283
+ return prev.created_at >= event.created_at;
284
+ }),
246
285
  // Hacky way to extract the current event so takeUntil can access it
247
286
  tap((event) => (current = event)),
248
287
  // complete when event is removed
249
- takeUntil(this.database.removed.pipe(filter((e) => e.id === current?.id))),
288
+ takeUntil(this.removes.pipe(filter((e) => e.id === current?.id))),
250
289
  // emit undefined when removed
251
290
  endWith(undefined),
252
291
  // keep the observable hot
@@ -256,12 +295,12 @@ export class EventStore {
256
295
  }
257
296
  /** Creates an observable that subscribes to the latest version of an array of replaceable events*/
258
297
  replaceableSet(pointers) {
259
- const uids = new Set(pointers.map((p) => getReplaceableUID(p.kind, p.pubkey, p.identifier)));
298
+ const uids = new Set(pointers.map((p) => createReplaceableAddress(p.kind, p.pubkey, p.identifier)));
260
299
  return merge(
261
300
  // start with existing events
262
301
  defer(() => from(pointers.map((p) => this.getReplaceable(p.kind, p.pubkey, p.identifier)))),
263
302
  // subscribe to new events
264
- this.database.inserted.pipe(filter((e) => isReplaceable(e.kind) && uids.has(getEventUID(e))))).pipe(
303
+ this.inserts.pipe(filter((e) => isReplaceable(e.kind) && uids.has(getEventUID(e))))).pipe(
265
304
  // filter out undefined
266
305
  filter((e) => !!e),
267
306
  // claim all events
@@ -269,7 +308,7 @@ export class EventStore {
269
308
  // convert events to add commands
270
309
  map((e) => ["add", e]),
271
310
  // watch for removed events
272
- mergeWith(this.database.removed.pipe(filter((e) => isReplaceable(e.kind) && uids.has(getEventUID(e))), map((e) => ["remove", e]))),
311
+ mergeWith(this.removes.pipe(filter((e) => isReplaceable(e.kind) && uids.has(getEventUID(e))), map((e) => ["remove", e]))),
273
312
  // reduce events into directory
274
313
  scan((dir, [action, event]) => {
275
314
  const uid = getEventUID(event);
@@ -298,11 +337,11 @@ export class EventStore {
298
337
  // claim existing events
299
338
  claimEvents(this.database),
300
339
  // subscribe to newer events
301
- mergeWith(this.database.inserted.pipe(filter((e) => matchFilters(filters, e)),
340
+ mergeWith(this.inserts.pipe(filter((e) => matchFilters(filters, e)),
302
341
  // claim all new events
303
342
  claimEvents(this.database))),
304
343
  // subscribe to delete events
305
- mergeWith(this.database.removed.pipe(filter((e) => matchFilters(filters, e)), map((e) => e.id))),
344
+ mergeWith(this.removes.pipe(filter((e) => matchFilters(filters, e)), map((e) => e.id))),
306
345
  // build a timeline
307
346
  scan((timeline, event) => {
308
347
  // filter out removed events from timeline
@@ -1,10 +1,6 @@
1
1
  import { Filter, NostrEvent } from "nostr-tools";
2
2
  import { Observable } from "rxjs";
3
- export interface IEventStore {
4
- updates: Observable<NostrEvent>;
5
- add(event: NostrEvent, fromRelay?: string): NostrEvent;
6
- remove(event: string | NostrEvent): boolean;
7
- update(event: NostrEvent): NostrEvent;
3
+ export interface ISyncEventStore {
8
4
  hasEvent(id: string): boolean;
9
5
  hasReplaceable(kind: number, pubkey: string, identifier?: string): boolean;
10
6
  getEvent(id: string): NostrEvent | undefined;
@@ -12,6 +8,11 @@ export interface IEventStore {
12
8
  getReplaceableHistory(kind: number, pubkey: string, identifier?: string): NostrEvent[] | undefined;
13
9
  getAll(filters: Filter | Filter[]): Set<NostrEvent>;
14
10
  getTimeline(filters: Filter | Filter[]): NostrEvent[];
11
+ }
12
+ export interface IStreamEventStore {
13
+ inserts: Observable<NostrEvent>;
14
+ updates: Observable<NostrEvent>;
15
+ removes: Observable<NostrEvent>;
15
16
  filters(filters: Filter | Filter[]): Observable<NostrEvent>;
16
17
  updated(id: string | NostrEvent): Observable<NostrEvent>;
17
18
  removed(id: string): Observable<never>;
@@ -25,3 +26,8 @@ export interface IEventStore {
25
26
  }[]): Observable<Record<string, NostrEvent>>;
26
27
  timeline(filters: Filter | Filter[], includeOldVersion?: boolean): Observable<NostrEvent[]>;
27
28
  }
29
+ export interface IEventStore extends ISyncEventStore, IStreamEventStore {
30
+ add(event: NostrEvent, fromRelay?: string): NostrEvent;
31
+ remove(event: string | NostrEvent): boolean;
32
+ update(event: NostrEvent): NostrEvent;
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { kinds } from "nostr-tools";
3
+ import { mergeBookmarks } from "../bookmarks.js";
4
+ describe("mergeBookmarks", () => {
5
+ it("should merge bookmarks and handle duplicates", () => {
6
+ // Create test data with some duplicates
7
+ const eventPointer1 = {
8
+ id: "event1",
9
+ relays: ["wss://relay1.com/", "wss://relay2.com/"],
10
+ author: "author1",
11
+ };
12
+ const eventPointer2 = {
13
+ id: "event1", // Same ID as eventPointer1
14
+ relays: ["wss://relay2.com/", "wss://relay3.com/"],
15
+ author: "author1",
16
+ };
17
+ const eventPointer3 = {
18
+ id: "event2",
19
+ relays: ["wss://relay1.com/"],
20
+ author: "author2",
21
+ };
22
+ const addressPointer1 = {
23
+ kind: kinds.LongFormArticle,
24
+ pubkey: "pubkey1",
25
+ identifier: "article1",
26
+ relays: ["wss://relay1.com/", "wss://relay2.com/"],
27
+ };
28
+ const addressPointer2 = {
29
+ kind: kinds.LongFormArticle,
30
+ pubkey: "pubkey1",
31
+ identifier: "article1", // Same as addressPointer1
32
+ relays: ["wss://relay3.com/"],
33
+ };
34
+ const bookmark1 = {
35
+ notes: [eventPointer1],
36
+ articles: [addressPointer1],
37
+ hashtags: ["tag1", "tag2"],
38
+ urls: ["https://example1.com/"],
39
+ };
40
+ const bookmark2 = {
41
+ notes: [eventPointer2, eventPointer3],
42
+ articles: [addressPointer2],
43
+ hashtags: ["tag2", "tag3"],
44
+ urls: ["https://example1.com/", "https://example2.com/"],
45
+ };
46
+ const result = mergeBookmarks(bookmark1, bookmark2);
47
+ // Check that duplicates are properly merged
48
+ expect(result.notes).toHaveLength(2); // event1 should be merged, plus event2
49
+ expect(result.articles).toHaveLength(1); // article1 should be merged
50
+ expect(result.hashtags).toHaveLength(3); // unique tags
51
+ expect(result.urls).toHaveLength(2); // unique urls
52
+ // Check that relays are merged for duplicate event
53
+ const mergedEvent = result.notes.find((note) => note.id === "event1");
54
+ expect(mergedEvent?.relays).toHaveLength(3);
55
+ expect(mergedEvent?.relays).toContain("wss://relay1.com/");
56
+ expect(mergedEvent?.relays).toContain("wss://relay2.com/");
57
+ expect(mergedEvent?.relays).toContain("wss://relay3.com/");
58
+ // Check that relays are merged for duplicate article
59
+ const mergedArticle = result.articles[0];
60
+ expect(mergedArticle.relays).toHaveLength(3);
61
+ expect(mergedArticle.relays).toContain("wss://relay1.com/");
62
+ expect(mergedArticle.relays).toContain("wss://relay2.com/");
63
+ expect(mergedArticle.relays).toContain("wss://relay3.com/");
64
+ // Check that hashtags are unique
65
+ expect(result.hashtags).toContain("tag1");
66
+ expect(result.hashtags).toContain("tag2");
67
+ expect(result.hashtags).toContain("tag3");
68
+ // Check that urls are unique
69
+ expect(result.urls).toContain("https://example1.com/");
70
+ expect(result.urls).toContain("https://example2.com/");
71
+ });
72
+ it("should handle undefined bookmarks", () => {
73
+ const bookmark = {
74
+ notes: [{ id: "event1", relays: ["wss://relay1.com/"] }],
75
+ articles: [],
76
+ hashtags: ["tag1"],
77
+ urls: ["https://example.com/"],
78
+ };
79
+ const result = mergeBookmarks(bookmark, undefined);
80
+ expect(result).toEqual(bookmark);
81
+ expect(mergeBookmarks(undefined, undefined)).toEqual({
82
+ notes: [],
83
+ articles: [],
84
+ hashtags: [],
85
+ urls: [],
86
+ });
87
+ });
88
+ });