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.
- package/README.md +28 -10
- package/dist/event-store/__tests__/event-store.test.js +83 -1
- package/dist/event-store/database.d.ts +1 -0
- package/dist/event-store/database.js +7 -8
- package/dist/event-store/event-store.d.ts +4 -0
- package/dist/event-store/event-store.js +61 -22
- package/dist/event-store/interface.d.ts +11 -5
- package/dist/helpers/__tests__/bookmarks.test.d.ts +1 -0
- package/dist/helpers/__tests__/bookmarks.test.js +88 -0
- package/dist/helpers/__tests__/comment.test.js +14 -0
- package/dist/helpers/__tests__/contacts.test.d.ts +1 -0
- package/dist/helpers/__tests__/contacts.test.js +34 -0
- package/dist/helpers/__tests__/events.test.d.ts +1 -0
- package/dist/helpers/__tests__/events.test.js +32 -0
- package/dist/helpers/__tests__/mutes.test.d.ts +1 -0
- package/dist/helpers/__tests__/mutes.test.js +55 -0
- package/dist/helpers/bookmarks.d.ts +6 -1
- package/dist/helpers/bookmarks.js +52 -7
- package/dist/helpers/comment.d.ts +7 -3
- package/dist/helpers/comment.js +6 -1
- package/dist/helpers/contacts.d.ts +11 -0
- package/dist/helpers/contacts.js +34 -0
- package/dist/helpers/event.d.ts +8 -3
- package/dist/helpers/event.js +21 -13
- package/dist/helpers/lists.d.ts +40 -12
- package/dist/helpers/lists.js +62 -23
- package/dist/helpers/mutes.d.ts +8 -0
- package/dist/helpers/mutes.js +66 -5
- package/dist/helpers/nip-19.d.ts +14 -0
- package/dist/helpers/nip-19.js +29 -0
- package/dist/helpers/pointers.js +6 -6
- package/dist/observable/__tests__/listen-latest-updates.test.d.ts +1 -0
- package/dist/observable/__tests__/listen-latest-updates.test.js +55 -0
- package/dist/observable/defined.d.ts +3 -0
- package/dist/observable/defined.js +5 -0
- package/dist/observable/get-observable-value.d.ts +4 -1
- package/dist/observable/get-observable-value.js +4 -1
- package/dist/observable/index.d.ts +3 -1
- package/dist/observable/index.js +3 -1
- package/dist/observable/listen-latest-updates.d.ts +5 -0
- package/dist/observable/listen-latest-updates.js +12 -0
- package/dist/queries/blossom.js +1 -6
- package/dist/queries/bookmarks.d.ts +5 -5
- package/dist/queries/bookmarks.js +18 -17
- package/dist/queries/channels.js +41 -53
- package/dist/queries/comments.js +6 -9
- package/dist/queries/contacts.d.ts +6 -1
- package/dist/queries/contacts.js +21 -9
- package/dist/queries/index.d.ts +1 -0
- package/dist/queries/index.js +1 -0
- package/dist/queries/mailboxes.js +4 -7
- package/dist/queries/mutes.d.ts +6 -6
- package/dist/queries/mutes.js +20 -19
- package/dist/queries/pins.d.ts +1 -0
- package/dist/queries/pins.js +4 -6
- package/dist/queries/profile.js +1 -6
- package/dist/queries/reactions.js +11 -14
- package/dist/queries/relays.d.ts +27 -0
- package/dist/queries/relays.js +44 -0
- package/dist/queries/simple.js +5 -22
- package/dist/queries/thread.js +45 -51
- package/dist/queries/user-status.js +23 -29
- package/dist/queries/zaps.js +10 -13
- package/dist/query-store/query-store.d.ts +7 -6
- package/dist/query-store/query-store.js +13 -8
- package/package.json +3 -3
- package/dist/observable/share-latest-value.d.ts +0 -6
- 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
|
|
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
|
-
|
|
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
|
-
//
|
|
25
|
+
// Create a single EventStore instance for your app
|
|
12
26
|
const eventStore = new EventStore();
|
|
13
27
|
|
|
14
|
-
//
|
|
28
|
+
// Create a QueryStore to manage subscriptions efficiently
|
|
15
29
|
const queryStore = new QueryStore(eventStore);
|
|
16
30
|
|
|
17
|
-
// Use nostr
|
|
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
|
-
|
|
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
|
-
//
|
|
27
|
-
const profile = queryStore.
|
|
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
|
-
//
|
|
34
|
-
const timeline = queryStore.
|
|
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 {
|
|
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(
|
|
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(
|
|
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 {
|
|
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,
|
|
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 (
|
|
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
|
-
//
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
246
|
+
this.inserts.pipe(filter((e) => ids.includes(e.id))),
|
|
211
247
|
// subscribe to updates
|
|
212
|
-
this.
|
|
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.
|
|
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.
|
|
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) =>
|
|
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.
|
|
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) =>
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
+
});
|