applesauce-core 0.10.0 → 0.11.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 (120) hide show
  1. package/dist/__tests__/fixtures.d.ts +8 -0
  2. package/dist/__tests__/fixtures.js +20 -0
  3. package/dist/event-store/__tests__/event-store.test.js +259 -0
  4. package/dist/event-store/database.d.ts +6 -4
  5. package/dist/event-store/database.js +13 -7
  6. package/dist/event-store/event-store.d.ts +30 -16
  7. package/dist/event-store/event-store.js +248 -309
  8. package/dist/helpers/__tests__/blossom.test.js +13 -0
  9. package/dist/helpers/__tests__/comment.test.js +235 -0
  10. package/dist/helpers/__tests__/emoji.test.d.ts +1 -0
  11. package/dist/helpers/__tests__/emoji.test.js +15 -0
  12. package/dist/helpers/__tests__/event.test.d.ts +1 -0
  13. package/dist/helpers/__tests__/event.test.js +36 -0
  14. package/dist/helpers/__tests__/file-metadata.test.d.ts +1 -0
  15. package/dist/helpers/__tests__/file-metadata.test.js +103 -0
  16. package/dist/helpers/__tests__/hidden-tags.test.d.ts +1 -0
  17. package/dist/helpers/{hidden-tags.test.js → __tests__/hidden-tags.test.js} +2 -1
  18. package/dist/helpers/__tests__/mailboxes.test.d.ts +1 -0
  19. package/dist/helpers/{mailboxes.test.js → __tests__/mailboxes.test.js} +1 -1
  20. package/dist/helpers/__tests__/relays.test.d.ts +1 -0
  21. package/dist/helpers/__tests__/relays.test.js +21 -0
  22. package/dist/helpers/__tests__/tags.test.d.ts +1 -0
  23. package/dist/helpers/__tests__/tags.test.js +24 -0
  24. package/dist/helpers/__tests__/threading.test.d.ts +1 -0
  25. package/dist/helpers/{threading.test.js → __tests__/threading.test.js} +1 -1
  26. package/dist/helpers/blossom.d.ts +9 -0
  27. package/dist/helpers/blossom.js +22 -0
  28. package/dist/helpers/bookmarks.d.ts +15 -0
  29. package/dist/helpers/bookmarks.js +27 -0
  30. package/dist/helpers/channels.d.ts +10 -0
  31. package/dist/helpers/channels.js +27 -0
  32. package/dist/helpers/comment.d.ts +3 -4
  33. package/dist/helpers/comment.js +20 -16
  34. package/dist/helpers/contacts.d.ts +3 -0
  35. package/dist/helpers/contacts.js +25 -0
  36. package/dist/helpers/dns-identity.d.ts +7 -0
  37. package/dist/helpers/dns-identity.js +10 -0
  38. package/dist/helpers/emoji.d.ts +3 -1
  39. package/dist/helpers/emoji.js +2 -2
  40. package/dist/helpers/event.d.ts +8 -2
  41. package/dist/helpers/event.js +29 -11
  42. package/dist/helpers/file-metadata.d.ts +55 -0
  43. package/dist/helpers/file-metadata.js +99 -0
  44. package/dist/helpers/filter.d.ts +4 -0
  45. package/dist/helpers/filter.js +34 -1
  46. package/dist/helpers/groups.d.ts +24 -0
  47. package/dist/helpers/groups.js +39 -0
  48. package/dist/helpers/hidden-tags.d.ts +15 -15
  49. package/dist/helpers/hidden-tags.js +9 -31
  50. package/dist/helpers/index.d.ts +13 -1
  51. package/dist/helpers/index.js +13 -1
  52. package/dist/helpers/lists.d.ts +28 -0
  53. package/dist/helpers/lists.js +65 -0
  54. package/dist/helpers/mailboxes.js +16 -9
  55. package/dist/helpers/mutes.d.ts +14 -0
  56. package/dist/helpers/mutes.js +23 -0
  57. package/dist/helpers/picture-post.d.ts +4 -0
  58. package/dist/helpers/picture-post.js +6 -0
  59. package/dist/helpers/pointers.js +13 -17
  60. package/dist/helpers/profile.d.ts +6 -1
  61. package/dist/helpers/profile.js +4 -0
  62. package/dist/helpers/relays.d.ts +6 -3
  63. package/dist/helpers/relays.js +25 -18
  64. package/dist/helpers/share.d.ts +4 -0
  65. package/dist/helpers/share.js +12 -0
  66. package/dist/helpers/tags.d.ts +17 -0
  67. package/dist/helpers/tags.js +28 -6
  68. package/dist/helpers/threading.js +3 -3
  69. package/dist/helpers/url.d.ts +7 -0
  70. package/dist/helpers/url.js +27 -0
  71. package/dist/helpers/user-status.d.ts +18 -0
  72. package/dist/helpers/user-status.js +21 -0
  73. package/dist/observable/__tests__/claim-events.test.d.ts +1 -0
  74. package/dist/observable/__tests__/claim-events.test.js +23 -0
  75. package/dist/observable/__tests__/claim-latest.test.d.ts +1 -0
  76. package/dist/observable/__tests__/claim-latest.test.js +37 -0
  77. package/dist/observable/__tests__/simple-timeout.test.d.ts +1 -0
  78. package/dist/observable/__tests__/simple-timeout.test.js +34 -0
  79. package/dist/observable/claim-events.d.ts +5 -0
  80. package/dist/observable/claim-events.js +28 -0
  81. package/dist/observable/claim-latest.d.ts +4 -0
  82. package/dist/observable/claim-latest.js +20 -0
  83. package/dist/observable/{get-value.d.ts → get-observable-value.d.ts} +1 -1
  84. package/dist/observable/{get-value.js → get-observable-value.js} +3 -8
  85. package/dist/observable/index.d.ts +2 -1
  86. package/dist/observable/index.js +2 -1
  87. package/dist/observable/share-latest-value.d.ts +2 -4
  88. package/dist/observable/share-latest-value.js +19 -16
  89. package/dist/observable/simple-timeout.d.ts +4 -0
  90. package/dist/observable/simple-timeout.js +6 -0
  91. package/dist/queries/blossom.d.ts +2 -0
  92. package/dist/queries/blossom.js +10 -0
  93. package/dist/queries/bookmarks.d.ts +8 -0
  94. package/dist/queries/bookmarks.js +23 -0
  95. package/dist/queries/channels.d.ts +11 -0
  96. package/dist/queries/channels.js +73 -0
  97. package/dist/queries/contacts.d.ts +3 -0
  98. package/dist/queries/contacts.js +12 -0
  99. package/dist/queries/index.d.ts +6 -0
  100. package/dist/queries/index.js +6 -0
  101. package/dist/queries/mutes.d.ts +8 -0
  102. package/dist/queries/mutes.js +23 -0
  103. package/dist/queries/pins.d.ts +3 -0
  104. package/dist/queries/pins.js +12 -0
  105. package/dist/queries/simple.d.ts +2 -2
  106. package/dist/queries/thread.js +1 -1
  107. package/dist/queries/user-status.d.ts +11 -0
  108. package/dist/queries/user-status.js +39 -0
  109. package/dist/query-store/index.d.ts +1 -57
  110. package/dist/query-store/index.js +1 -66
  111. package/dist/query-store/query-store.d.ts +51 -0
  112. package/dist/query-store/query-store.js +88 -0
  113. package/dist/query-store/query-store.test.d.ts +1 -0
  114. package/dist/query-store/query-store.test.js +33 -0
  115. package/package.json +19 -8
  116. package/dist/helpers/media-attachment.d.ts +0 -33
  117. package/dist/helpers/media-attachment.js +0 -60
  118. /package/dist/{helpers/hidden-tags.test.d.ts → event-store/__tests__/event-store.test.d.ts} +0 -0
  119. /package/dist/helpers/{mailboxes.test.d.ts → __tests__/blossom.test.d.ts} +0 -0
  120. /package/dist/helpers/{threading.test.d.ts → __tests__/comment.test.d.ts} +0 -0
@@ -1,57 +1,49 @@
1
1
  import { kinds } from "nostr-tools";
2
2
  import { insertEventIntoDescendingList } from "nostr-tools/utils";
3
3
  import { isParameterizedReplaceableKind } from "nostr-tools/kinds";
4
- import { Observable } from "rxjs";
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, getReplaceableUID, getTagValue, isReplaceable } from "../helpers/event.js";
6
+ import { getEventUID, getReplaceableIdentifier, getReplaceableUID, getTagValue, isReplaceable, } from "../helpers/event.js";
7
7
  import { matchFilters } from "../helpers/filter.js";
8
- import { addSeenRelay } from "../helpers/relays.js";
8
+ import { addSeenRelay, getSeenRelays } from "../helpers/relays.js";
9
9
  import { getDeleteCoordinates, getDeleteIds } from "../helpers/delete.js";
10
+ import { claimEvents } from "../observable/claim-events.js";
11
+ import { claimLatest } from "../observable/claim-latest.js";
12
+ function sortDesc(a, b) {
13
+ return b.created_at - a.created_at;
14
+ }
10
15
  export class EventStore {
11
16
  database;
12
17
  /** Enable this to keep old versions of replaceable events */
13
18
  keepOldVersions = false;
19
+ /** A method used to verify new events before added them */
20
+ verifyEvent;
14
21
  constructor() {
15
22
  this.database = new Database();
23
+ this.database.onBeforeInsert = (event) => {
24
+ // reject events that are invalid
25
+ if (this.verifyEvent && this.verifyEvent(event) === false)
26
+ throw new Error("Invalid event");
27
+ };
16
28
  }
17
- /** Adds an event to the database and update subscriptions */
18
- add(event, fromRelay) {
19
- if (event.kind === kinds.EventDeletion)
20
- this.handleDeleteEvent(event);
21
- // ignore if the event was deleted
22
- if (this.checkDeleted(event))
23
- return event;
24
- // insert event into database
25
- const inserted = this.database.addEvent(event);
26
- // remove all old version of the replaceable event
27
- if (!this.keepOldVersions && isReplaceable(event.kind)) {
28
- const current = this.database.getReplaceable(event.kind, event.pubkey, getTagValue(event, "d"));
29
- if (current) {
30
- const older = Array.from(current).filter((e) => e.created_at < event.created_at);
31
- for (const old of older)
32
- this.database.deleteEvent(old);
33
- // skip inserting this event because its not the newest
34
- if (current.length !== older.length)
35
- return current[0];
36
- }
37
- }
38
- // attach relay this event was from
39
- if (fromRelay)
40
- addSeenRelay(inserted, fromRelay);
41
- return inserted;
42
- }
43
- /** Removes an event from the database and updates subscriptions */
44
- remove(event) {
29
+ // delete state
30
+ deletedIds = new Set();
31
+ deletedCoords = new Map();
32
+ checkDeleted(event) {
45
33
  if (typeof event === "string")
46
- return this.database.deleteEvent(event);
47
- else if (this.database.hasEvent(event.id)) {
48
- return this.database.deleteEvent(event.id);
49
- }
50
- else
34
+ return this.deletedIds.has(event);
35
+ else {
36
+ if (this.deletedIds.has(event.id))
37
+ return true;
38
+ if (isParameterizedReplaceableKind(event.kind)) {
39
+ const deleted = this.deletedCoords.get(getEventUID(event));
40
+ if (deleted)
41
+ return deleted > event.created_at;
42
+ }
51
43
  return false;
44
+ }
52
45
  }
53
- deletedIds = new Set();
54
- deletedCoords = new Map();
46
+ // handling delete events
55
47
  handleDeleteEvent(deleteEvent) {
56
48
  const ids = getDeleteIds(deleteEvent);
57
49
  for (const id of ids) {
@@ -59,7 +51,7 @@ export class EventStore {
59
51
  // remove deleted events in the database
60
52
  const event = this.database.getEvent(id);
61
53
  if (event)
62
- this.database.deleteEvent(event);
54
+ this.database.removeEvent(event);
63
55
  }
64
56
  const coords = getDeleteCoordinates(deleteEvent);
65
57
  for (const coord of coords) {
@@ -67,18 +59,53 @@ export class EventStore {
67
59
  // remove deleted events in the database
68
60
  const event = this.database.getEvent(coord);
69
61
  if (event && event.created_at < deleteEvent.created_at)
70
- this.database.deleteEvent(event);
62
+ this.database.removeEvent(event);
71
63
  }
72
64
  }
73
- checkDeleted(event) {
74
- if (this.deletedIds.has(event.id))
75
- return true;
76
- if (isParameterizedReplaceableKind(event.kind)) {
77
- const deleted = this.deletedCoords.get(getEventUID(event));
78
- if (deleted)
79
- return deleted > event.created_at;
65
+ /** Copies important metadata from and identical event to another */
66
+ static mergeDuplicateEvent(source, dest) {
67
+ const relays = getSeenRelays(source);
68
+ if (relays) {
69
+ for (const relay of relays)
70
+ addSeenRelay(dest, relay);
71
+ }
72
+ }
73
+ /**
74
+ * Adds an event to the database and update subscriptions
75
+ * @throws
76
+ */
77
+ add(event, fromRelay) {
78
+ if (event.kind === kinds.EventDeletion)
79
+ this.handleDeleteEvent(event);
80
+ // Ignore if the event was deleted
81
+ if (this.checkDeleted(event))
82
+ return event;
83
+ // Insert event into database
84
+ const inserted = this.database.addEvent(event);
85
+ // Copy cached data if its a duplicate event
86
+ if (event !== inserted)
87
+ EventStore.mergeDuplicateEvent(event, inserted);
88
+ // attach relay this event was from
89
+ if (fromRelay)
90
+ addSeenRelay(inserted, fromRelay);
91
+ // remove all old version of the replaceable event
92
+ if (!this.keepOldVersions && isReplaceable(event.kind)) {
93
+ const existing = this.database.getReplaceable(event.kind, event.pubkey, getTagValue(event, "d"));
94
+ if (existing) {
95
+ const older = Array.from(existing).filter((e) => e.created_at < event.created_at);
96
+ for (const old of older)
97
+ this.database.removeEvent(old);
98
+ // return the newest version of the replaceable event
99
+ // most of the time this will be === event, but not always
100
+ if (existing.length !== older.length)
101
+ return existing[0];
102
+ }
80
103
  }
81
- return false;
104
+ return inserted;
105
+ }
106
+ /** Removes an event from the database and updates subscriptions */
107
+ remove(event) {
108
+ return this.database.removeEvent(event);
82
109
  }
83
110
  /** Removes any event that is not being used by a subscription */
84
111
  prune(max) {
@@ -88,15 +115,18 @@ export class EventStore {
88
115
  update(event) {
89
116
  return this.database.updateEvent(event);
90
117
  }
118
+ /** Get all events matching a filter */
91
119
  getAll(filters) {
92
120
  return this.database.getForFilters(filters);
93
121
  }
122
+ /** Check if the store has an event */
94
123
  hasEvent(uid) {
95
124
  return this.database.hasEvent(uid);
96
125
  }
97
126
  getEvent(uid) {
98
127
  return this.database.getEvent(uid);
99
128
  }
129
+ /** Check if the store has a replaceable event */
100
130
  hasReplaceable(kind, pubkey, d) {
101
131
  return this.database.hasReplaceable(kind, pubkey, d);
102
132
  }
@@ -108,282 +138,191 @@ export class EventStore {
108
138
  getReplaceableHistory(kind, pubkey, d) {
109
139
  return this.database.getReplaceable(kind, pubkey, d);
110
140
  }
111
- /** Creates an observable that updates a single event */
141
+ /**
142
+ * Creates an observable that streams all events that match the filter and remains open
143
+ * @param filters
144
+ * @param [onlyNew=false] Only subscribe to new events
145
+ */
146
+ filters(filters, onlyNew = false) {
147
+ filters = Array.isArray(filters) ? filters : [filters];
148
+ return merge(
149
+ // merge existing events
150
+ onlyNew ? EMPTY : from(this.getAll(filters)),
151
+ // subscribe to future events
152
+ this.database.inserted.pipe(filter((e) => matchFilters(filters, e))));
153
+ }
154
+ /** Returns an observable that completes when an event is removed */
155
+ removed(id) {
156
+ const deleted = this.checkDeleted(id);
157
+ if (deleted)
158
+ return EMPTY;
159
+ return this.database.removed.pipe(
160
+ // listen for removed events
161
+ filter((e) => e.id === id),
162
+ // complete as soon as we find a matching removed event
163
+ take(1),
164
+ // switch to empty
165
+ mergeMap(() => EMPTY));
166
+ }
167
+ /** Creates an observable that emits when event is updated */
168
+ updated(id) {
169
+ return this.database.updated.pipe(filter((e) => e.id === id));
170
+ }
171
+ /** Creates an observable that subscribes to a single event */
112
172
  event(id) {
113
- return new Observable((observer) => {
114
- let current = this.database.getEvent(id);
115
- if (current) {
116
- observer.next(current);
117
- this.database.claimEvent(current, observer);
118
- }
119
- // subscribe to future events
120
- const inserted = this.database.inserted.subscribe((event) => {
121
- if (event.id === id) {
122
- current = event;
123
- observer.next(event);
124
- this.database.claimEvent(event, observer);
125
- }
126
- });
127
- // subscribe to updated events
128
- const updated = this.database.updated.subscribe((event) => {
129
- if (event.id === id)
130
- observer.next(current);
131
- });
132
- // subscribe to deleted events
133
- const deleted = this.database.deleted.subscribe((event) => {
134
- if (current?.id === event.id) {
135
- this.database.removeClaim(current, observer);
136
- current = undefined;
137
- observer.next(undefined);
138
- }
139
- });
140
- return () => {
141
- deleted.unsubscribe();
142
- updated.unsubscribe();
143
- inserted.unsubscribe();
144
- if (current)
145
- this.database.removeClaim(current, observer);
146
- };
147
- });
173
+ return merge(
174
+ // get current event and ignore if there is none
175
+ defer(() => {
176
+ let event = this.getEvent(id);
177
+ return event ? of(event) : EMPTY;
178
+ }),
179
+ // subscribe to updates
180
+ this.database.inserted.pipe(filter((e) => e.id === id)),
181
+ // subscribe to updates
182
+ this.updated(id),
183
+ // emit undefined when deleted
184
+ this.removed(id).pipe(endWith(undefined))).pipe(
185
+ // claim all events
186
+ claimEvents(this.database));
148
187
  }
149
188
  /** Creates an observable that subscribes to multiple events */
150
189
  events(ids) {
151
- return new Observable((observer) => {
152
- const events = new Map();
153
- for (const id of ids) {
154
- const event = this.getEvent(id);
155
- if (event) {
156
- events.set(id, event);
157
- this.database.claimEvent(event, observer);
158
- }
190
+ return merge(
191
+ // lazily get existing events
192
+ defer(() => from(ids.map((id) => this.getEvent(id)))),
193
+ // subscribe to new events
194
+ this.database.inserted.pipe(filter((e) => ids.includes(e.id))),
195
+ // subscribe to updates
196
+ this.database.updated.pipe(filter((e) => ids.includes(e.id)))).pipe(
197
+ // ignore empty messages
198
+ filter((e) => !!e),
199
+ // claim all events until cleanup
200
+ claimEvents(this.database),
201
+ // watch for removed events
202
+ mergeWith(this.database.removed.pipe(filter((e) => ids.includes(e.id)), map((e) => e.id))),
203
+ // merge all events into a directory
204
+ scan((dir, event) => {
205
+ if (typeof event === "string") {
206
+ // delete event by id
207
+ const clone = { ...dir };
208
+ delete clone[event];
209
+ return clone;
159
210
  }
160
- observer.next(events);
161
- // subscribe to future events
162
- const inserted = this.database.inserted.subscribe((event) => {
163
- const id = event.id;
164
- if (ids.includes(id) && !events.has(id)) {
165
- events.set(id, event);
166
- observer.next(events);
167
- // claim new event
168
- this.database.claimEvent(event, observer);
169
- }
170
- });
171
- // subscribe to updated events
172
- const updated = this.database.updated.subscribe((event) => {
173
- if (ids.includes(event.id))
174
- observer.next(events);
175
- });
176
- // subscribe to deleted events
177
- const deleted = this.database.deleted.subscribe((event) => {
178
- const id = event.id;
179
- if (ids.includes(id)) {
180
- const current = events.get(id);
181
- if (current) {
182
- this.database.removeClaim(current, observer);
183
- events.delete(id);
184
- observer.next(events);
185
- }
186
- }
187
- });
188
- return () => {
189
- inserted.unsubscribe();
190
- deleted.unsubscribe();
191
- updated.unsubscribe();
192
- for (const [_uid, event] of events) {
193
- this.database.removeClaim(event, observer);
194
- }
195
- };
196
- });
211
+ else {
212
+ // add even to directory
213
+ return { ...dir, [event.id]: event };
214
+ }
215
+ }, {}));
197
216
  }
198
- /** Creates an observable with the latest version of a replaceable event */
217
+ /** Creates an observable that subscribes to the latest version of a replaceable event */
199
218
  replaceable(kind, pubkey, d) {
200
- return new Observable((observer) => {
201
- const uid = getReplaceableUID(kind, pubkey, d);
202
- // get latest version
203
- let current = this.database.getReplaceable(kind, pubkey, d)?.[0];
204
- if (current) {
205
- observer.next(current);
206
- this.database.claimEvent(current, observer);
207
- }
208
- // subscribe to future events
209
- const inserted = this.database.inserted.subscribe((event) => {
210
- if (getEventUID(event) === uid && (!current || event.created_at > current.created_at)) {
211
- // remove old claim
212
- if (current)
213
- this.database.removeClaim(current, observer);
214
- current = event;
215
- observer.next(event);
216
- // claim new event
217
- this.database.claimEvent(current, observer);
218
- }
219
- });
220
- // subscribe to updated events
221
- const updated = this.database.updated.subscribe((event) => {
222
- if (event === current)
223
- observer.next(event);
224
- });
225
- // subscribe to deleted events
226
- const deleted = this.database.deleted.subscribe((event) => {
227
- if (getEventUID(event) === uid && event === current) {
228
- this.database.removeClaim(current, observer);
229
- current = undefined;
230
- observer.next(undefined);
231
- }
232
- });
233
- return () => {
234
- inserted.unsubscribe();
235
- deleted.unsubscribe();
236
- updated.unsubscribe();
237
- if (current)
238
- this.database.removeClaim(current, observer);
239
- };
240
- });
219
+ let current = undefined;
220
+ return merge(
221
+ // lazily get current event
222
+ defer(() => {
223
+ let event = this.getReplaceable(kind, pubkey, d);
224
+ return event ? of(event) : EMPTY;
225
+ }),
226
+ // subscribe to new events
227
+ this.database.inserted.pipe(filter((e) => e.pubkey == pubkey && e.kind === kind && (d !== undefined ? getReplaceableIdentifier(e) === d : true)))).pipe(
228
+ // only update if event is newer
229
+ distinctUntilChanged((prev, event) => prev.created_at >= event.created_at),
230
+ // Hacky way to extract the current event so takeUntil can access it
231
+ tap((event) => (current = event)),
232
+ // complete when event is removed
233
+ takeUntil(this.database.removed.pipe(filter((e) => e.id === current?.id))),
234
+ // emit undefined when removed
235
+ endWith(undefined),
236
+ // keep the observable hot
237
+ repeat(),
238
+ // claim latest event
239
+ claimLatest(this.database));
241
240
  }
242
- /** Creates an observable with the latest versions of replaceable events */
241
+ /** Creates an observable that subscribes to the latest version of an array of replaceable events*/
243
242
  replaceableSet(pointers) {
244
- return new Observable((observer) => {
245
- const coords = pointers.map((p) => getReplaceableUID(p.kind, p.pubkey, p.identifier));
246
- const events = new Map();
247
- const handleEvent = (event) => {
248
- const uid = getEventUID(event);
249
- const current = events.get(uid);
250
- if (current) {
251
- if (event.created_at > current.created_at) {
252
- this.database.removeClaim(current, observer);
253
- }
254
- else
255
- return;
256
- }
257
- events.set(uid, event);
258
- this.database.claimEvent(event, observer);
259
- };
260
- // get latest version
261
- for (const pointer of pointers) {
262
- const events = this.database.getReplaceable(pointer.kind, pointer.pubkey, pointer.identifier);
263
- if (events)
264
- handleEvent(events[0]);
243
+ const uids = new Set(pointers.map((p) => getReplaceableUID(p.kind, p.pubkey, p.identifier)));
244
+ return merge(
245
+ // start with existing events
246
+ defer(() => from(pointers.map((p) => this.getReplaceable(p.kind, p.pubkey, p.identifier)))),
247
+ // subscribe to new events
248
+ this.database.inserted.pipe(filter((e) => isReplaceable(e.kind) && uids.has(getEventUID(e))))).pipe(
249
+ // filter out undefined
250
+ filter((e) => !!e),
251
+ // claim all events
252
+ claimEvents(this.database),
253
+ // convert events to add commands
254
+ map((e) => ["add", e]),
255
+ // watch for removed events
256
+ mergeWith(this.database.removed.pipe(filter((e) => isReplaceable(e.kind) && uids.has(getEventUID(e))), map((e) => ["remove", e]))),
257
+ // reduce events into directory
258
+ scan((dir, [action, event]) => {
259
+ const uid = getEventUID(event);
260
+ if (action === "add") {
261
+ // add event to dir if its newer
262
+ if (!dir[uid] || dir[uid].created_at < event.created_at)
263
+ return { ...dir, [uid]: event };
265
264
  }
266
- observer.next(events);
267
- // subscribe to future events
268
- const inserted = this.database.inserted.subscribe((event) => {
269
- if (isReplaceable(event.kind) && coords.includes(getEventUID(event))) {
270
- handleEvent(event);
271
- observer.next(events);
272
- }
273
- });
274
- // subscribe to updated events
275
- const updated = this.database.updated.subscribe((event) => {
276
- if (isReplaceable(event.kind) && coords.includes(getEventUID(event))) {
277
- observer.next(events);
278
- }
279
- });
280
- // subscribe to deleted events
281
- const deleted = this.database.deleted.subscribe((event) => {
282
- const uid = getEventUID(event);
283
- if (events.has(uid)) {
284
- events.delete(uid);
285
- this.database.removeClaim(event, observer);
286
- observer.next(events);
287
- }
288
- });
289
- return () => {
290
- inserted.unsubscribe();
291
- deleted.unsubscribe();
292
- updated.unsubscribe();
293
- for (const [_id, event] of events) {
294
- this.database.removeClaim(event, observer);
295
- }
296
- };
297
- });
298
- }
299
- /**
300
- * Creates an observable that streams all events that match the filter
301
- * @param filters
302
- * @param [onlyNew=false] Only subscribe to new events
303
- */
304
- stream(filters, onlyNew = false) {
305
- filters = Array.isArray(filters) ? filters : [filters];
306
- return new Observable((observer) => {
307
- if (!onlyNew) {
308
- let events = this.database.getForFilters(filters);
309
- for (const event of events)
310
- observer.next(event);
265
+ else if (action === "remove" && dir[uid] === event) {
266
+ // remove event from dir
267
+ let newDir = { ...dir };
268
+ delete newDir[uid];
269
+ return newDir;
311
270
  }
312
- // subscribe to future events
313
- const sub = this.database.inserted.subscribe((event) => {
314
- if (matchFilters(filters, event))
315
- observer.next(event);
316
- });
317
- return () => sub.unsubscribe();
318
- });
271
+ return dir;
272
+ }, {}),
273
+ // ignore changes that do not modify the directory
274
+ distinctUntilChanged());
319
275
  }
320
276
  /** Creates an observable that updates with an array of sorted events */
321
277
  timeline(filters, keepOldVersions = false) {
322
278
  filters = Array.isArray(filters) ? filters : [filters];
323
- return new Observable((observer) => {
324
- const seen = new Map();
325
- const timeline = [];
326
- // NOTE: only call this if we know the event is in timeline
327
- const removeFromTimeline = (event) => {
328
- timeline.splice(timeline.indexOf(event), 1);
329
- if (!keepOldVersions && isReplaceable(event.kind))
330
- seen.delete(getEventUID(event));
331
- this.database.removeClaim(event, observer);
332
- };
333
- // inserts an event into the timeline and handles replaceable events
334
- const insertIntoTimeline = (event) => {
335
- // remove old versions
336
- if (!keepOldVersions && isReplaceable(event.kind)) {
337
- const uid = getEventUID(event);
338
- const old = seen.get(uid);
339
- if (old) {
340
- if (event.created_at > old.created_at)
341
- removeFromTimeline(old);
342
- else
343
- return;
344
- }
345
- seen.set(uid, event);
346
- }
347
- // insert into timeline
348
- insertEventIntoDescendingList(timeline, event);
349
- this.database.claimEvent(event, observer);
350
- };
351
- // build initial timeline
352
- const events = this.database.getForFilters(filters);
353
- for (const event of events)
354
- insertIntoTimeline(event);
355
- observer.next([...timeline]);
356
- // subscribe to future events
357
- const inserted = this.database.inserted.subscribe((event) => {
358
- if (matchFilters(filters, event)) {
359
- insertIntoTimeline(event);
360
- observer.next([...timeline]);
361
- }
362
- });
363
- // subscribe to updated events
364
- const updated = this.database.updated.subscribe((event) => {
365
- if (timeline.includes(event)) {
366
- observer.next([...timeline]);
279
+ const seen = new Map();
280
+ // get current events
281
+ return defer(() => of(Array.from(this.database.getForFilters(filters)).sort(sortDesc))).pipe(
282
+ // claim existing events
283
+ claimEvents(this.database),
284
+ // subscribe to newer events
285
+ mergeWith(this.database.inserted.pipe(filter((e) => matchFilters(filters, e)),
286
+ // claim all new events
287
+ claimEvents(this.database))),
288
+ // subscribe to delete events
289
+ mergeWith(this.database.removed.pipe(filter((e) => matchFilters(filters, e)), map((e) => e.id))),
290
+ // build a timeline
291
+ scan((timeline, event) => {
292
+ // filter out removed events from timeline
293
+ if (typeof event === "string")
294
+ return timeline.filter((e) => e.id !== event);
295
+ // initial timeline array
296
+ if (Array.isArray(event)) {
297
+ if (!keepOldVersions) {
298
+ for (const e of event)
299
+ if (isReplaceable(e.kind))
300
+ seen.set(getEventUID(e), e);
367
301
  }
368
- });
369
- // subscribe to deleted events
370
- const deleted = this.database.deleted.subscribe((event) => {
371
- if (timeline.includes(event)) {
372
- removeFromTimeline(event);
373
- observer.next([...timeline]);
374
- }
375
- });
376
- return () => {
377
- inserted.unsubscribe();
378
- deleted.unsubscribe();
379
- updated.unsubscribe();
380
- // remove all claims
381
- for (const event of timeline) {
382
- this.database.removeClaim(event, observer);
383
- }
384
- // forget seen replaceable events
385
- seen.clear();
386
- };
387
- });
302
+ return event;
303
+ }
304
+ // create a new timeline and insert the event into it
305
+ let newTimeline = [...timeline];
306
+ // remove old replaceable events if enabled
307
+ if (!keepOldVersions && isReplaceable(event.kind)) {
308
+ const uid = getEventUID(event);
309
+ const existing = seen.get(uid);
310
+ // if this is an older replaceable event, exit
311
+ if (existing && event.created_at < existing.created_at)
312
+ return timeline;
313
+ // update latest version
314
+ seen.set(uid, event);
315
+ // remove old event from timeline
316
+ if (existing)
317
+ newTimeline.slice(newTimeline.indexOf(existing), 1);
318
+ }
319
+ // add event into timeline
320
+ insertEventIntoDescendingList(newTimeline, event);
321
+ return newTimeline;
322
+ }, []),
323
+ // ignore changes that do not modify the timeline instance
324
+ distinctUntilChanged(),
325
+ // hacky hack to clear seen on unsubscribe
326
+ finalize(() => seen.clear()));
388
327
  }
389
328
  }
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { areBlossomServersEqual } from "../blossom.js";
3
+ describe("areBlossomServersEqual", () => {
4
+ it("should ignore path", () => {
5
+ expect(areBlossomServersEqual("https://cdn.server.com/pathname", "https://cdn.server.com")).toBe(true);
6
+ });
7
+ it("should not ignore protocol", () => {
8
+ expect(areBlossomServersEqual("http://cdn.server.com", "https://cdn.server.com")).toBe(false);
9
+ });
10
+ it("should not ignore port", () => {
11
+ expect(areBlossomServersEqual("http://cdn.server.com:4658", "https://cdn.server.com")).toBe(false);
12
+ });
13
+ });