applesauce-loaders 5.0.2 → 6.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 +1 -1
- package/dist/helpers/address-pointer.d.ts +3 -3
- package/dist/helpers/address-pointer.js +7 -7
- package/dist/helpers/async-map.d.ts +24 -0
- package/dist/helpers/async-map.js +64 -0
- package/dist/helpers/index.d.ts +3 -2
- package/dist/helpers/index.js +3 -2
- package/dist/loaders/social-graph.d.ts +19 -7
- package/dist/loaders/social-graph.js +79 -21
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A collection of functional loading methods to make common event loading patterns easier.
|
|
4
4
|
|
|
5
|
-
[Documentation](https://
|
|
5
|
+
[Documentation](https://applesauce.build/loaders/package.html) [typedoc](https://applesauce.build/typedoc/modules/applesauce-loaders.html)
|
|
6
6
|
|
|
7
7
|
## Address Loader
|
|
8
8
|
|
|
@@ -7,8 +7,8 @@ export declare function createFiltersFromAddressPointers(pointers: AddressPointe
|
|
|
7
7
|
/** Checks if a relay will understand an address pointer */
|
|
8
8
|
export declare function isLoadableAddressPointer<T extends AddressPointerWithoutD>(pointer: T): boolean;
|
|
9
9
|
/** Group an array of address pointers by kind */
|
|
10
|
-
export declare function groupAddressPointersByKind(pointers:
|
|
10
|
+
export declare function groupAddressPointersByKind<T extends AddressPointerWithoutD>(pointers: T[]): Map<number, T[]>;
|
|
11
11
|
/** Group an array of address pointers by pubkey */
|
|
12
|
-
export declare function groupAddressPointersByPubkey(pointers:
|
|
12
|
+
export declare function groupAddressPointersByPubkey<T extends AddressPointerWithoutD>(pointers: T[]): Map<string, T[]>;
|
|
13
13
|
/** Groups address pointers by kind or pubkey depending on which is most optimal */
|
|
14
|
-
export declare function groupAddressPointersByPubkeyOrKind(pointers:
|
|
14
|
+
export declare function groupAddressPointersByPubkeyOrKind<T extends AddressPointerWithoutD>(pointers: T[]): Map<string, T[]> | Map<number, T[]>;
|
|
@@ -16,14 +16,14 @@ export function createFiltersFromAddressPointers(pointers) {
|
|
|
16
16
|
const replaceable = pointers.filter((p) => isReplaceableKind(p.kind));
|
|
17
17
|
const addressable = pointers.filter((p) => isAddressableKind(p.kind));
|
|
18
18
|
const filters = [];
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const addGroupFilters = (group) => {
|
|
20
|
+
if (group.length === 0)
|
|
21
|
+
return;
|
|
22
|
+
const groups = groupAddressPointersByPubkeyOrKind(group);
|
|
21
23
|
filters.push(...Array.from(groups.values()).map(createFilterFromAddressPointers));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
filters.push(...Array.from(groups.values()).map(createFilterFromAddressPointers));
|
|
26
|
-
}
|
|
24
|
+
};
|
|
25
|
+
addGroupFilters(replaceable);
|
|
26
|
+
addGroupFilters(addressable);
|
|
27
27
|
return filters;
|
|
28
28
|
}
|
|
29
29
|
/** Checks if a relay will understand an address pointer */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves multiple promises concurrently, similar to RxJS combineLatest but for async operations.
|
|
3
|
+
* Each promise races against the provided timeout. If a promise doesn't resolve within the timeout,
|
|
4
|
+
* its value will be `undefined` in the returned object.
|
|
5
|
+
*
|
|
6
|
+
* @param map - An object where each value is a Promise
|
|
7
|
+
* @param timeout - Global timeout in milliseconds for all fields. If a field doesn't resolve within this time, it will be `undefined`
|
|
8
|
+
* @returns A promise that resolves to an object with the same keys, where each value is either the resolved value or `undefined`
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const { profile, mailboxes, notes } = await loadAsyncMap(
|
|
13
|
+
* {
|
|
14
|
+
* profile: user.profile$.$first(),
|
|
15
|
+
* mailboxes: user.mailboxes$.$first(1000),
|
|
16
|
+
* notes: lastValueFrom(someObservable),
|
|
17
|
+
* },
|
|
18
|
+
* 30 * 1000, // 30 second timeout
|
|
19
|
+
* );
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadAsyncMap<T extends Record<string, Promise<any>>>(map: T, timeout: number): Promise<{
|
|
23
|
+
[K in keyof T]: Awaited<T[K]> | undefined;
|
|
24
|
+
}>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves multiple promises concurrently, similar to RxJS combineLatest but for async operations.
|
|
3
|
+
* Each promise races against the provided timeout. If a promise doesn't resolve within the timeout,
|
|
4
|
+
* its value will be `undefined` in the returned object.
|
|
5
|
+
*
|
|
6
|
+
* @param map - An object where each value is a Promise
|
|
7
|
+
* @param timeout - Global timeout in milliseconds for all fields. If a field doesn't resolve within this time, it will be `undefined`
|
|
8
|
+
* @returns A promise that resolves to an object with the same keys, where each value is either the resolved value or `undefined`
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const { profile, mailboxes, notes } = await loadAsyncMap(
|
|
13
|
+
* {
|
|
14
|
+
* profile: user.profile$.$first(),
|
|
15
|
+
* mailboxes: user.mailboxes$.$first(1000),
|
|
16
|
+
* notes: lastValueFrom(someObservable),
|
|
17
|
+
* },
|
|
18
|
+
* 30 * 1000, // 30 second timeout
|
|
19
|
+
* );
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export async function loadAsyncMap(map, timeout) {
|
|
23
|
+
// Create a timeout promise that resolves with a special marker after the timeout
|
|
24
|
+
const TIMEOUT_MARKER = Symbol("timeout");
|
|
25
|
+
const createTimeoutPromise = () => {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
setTimeout(() => resolve(TIMEOUT_MARKER), timeout);
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
// Race each promise against the timeout
|
|
31
|
+
// If the promise resolves first, use its value
|
|
32
|
+
// If the timeout happens first, use undefined
|
|
33
|
+
// If the promise rejects, catch it and return undefined
|
|
34
|
+
const entries = Object.entries(map);
|
|
35
|
+
const results = await Promise.allSettled(entries.map(async ([key, promise]) => {
|
|
36
|
+
// Wrap promise to handle rejections gracefully and prevent unhandled rejections
|
|
37
|
+
const safePromise = promise
|
|
38
|
+
.then((value) => ({ type: "resolved", value }))
|
|
39
|
+
.catch(() => ({ type: "rejected" }));
|
|
40
|
+
const result = await Promise.race([
|
|
41
|
+
safePromise,
|
|
42
|
+
createTimeoutPromise().then(() => ({ type: "timeout" })),
|
|
43
|
+
]);
|
|
44
|
+
if (result.type === "timeout" || result.type === "rejected") {
|
|
45
|
+
return [key, undefined];
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
return [key, result.value];
|
|
49
|
+
}
|
|
50
|
+
}));
|
|
51
|
+
// Extract values from settled results, defaulting to undefined if anything went wrong
|
|
52
|
+
const extractedResults = results.map((settled, index) => {
|
|
53
|
+
if (settled.status === "fulfilled") {
|
|
54
|
+
return settled.value;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// If the outer promise somehow rejected, return undefined for that key
|
|
58
|
+
const key = entries[index][0];
|
|
59
|
+
return [key, undefined];
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// Reconstruct the object with the same keys
|
|
63
|
+
return Object.fromEntries(extractedResults);
|
|
64
|
+
}
|
package/dist/helpers/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export * from "./
|
|
1
|
+
export * from "./address-pointer.js";
|
|
2
|
+
export * from "./async-map.js";
|
|
2
3
|
export * from "./cache.js";
|
|
4
|
+
export * from "./dns-identity.js";
|
|
3
5
|
export * from "./event-pointer.js";
|
|
4
|
-
export * from "./address-pointer.js";
|
|
5
6
|
export * from "./loaders.js";
|
|
6
7
|
export * from "./upstream.js";
|
package/dist/helpers/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export * from "./
|
|
1
|
+
export * from "./address-pointer.js";
|
|
2
|
+
export * from "./async-map.js";
|
|
2
3
|
export * from "./cache.js";
|
|
4
|
+
export * from "./dns-identity.js";
|
|
3
5
|
export * from "./event-pointer.js";
|
|
4
|
-
export * from "./address-pointer.js";
|
|
5
6
|
export * from "./loaders.js";
|
|
6
7
|
export * from "./upstream.js";
|
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
import { IAsyncEventStoreActions, IAsyncEventStoreRead, IEventStoreActions, IEventStoreRead } from "applesauce-core";
|
|
1
2
|
import { NostrEvent } from "applesauce-core/helpers/event";
|
|
2
3
|
import { ProfilePointer } from "applesauce-core/helpers/pointers";
|
|
3
|
-
import { mapEventsToStore } from "applesauce-core/observable";
|
|
4
4
|
import { Observable } from "rxjs";
|
|
5
|
-
import {
|
|
6
|
-
/**
|
|
5
|
+
import { CacheRequest, UpstreamPool } from "../types.js";
|
|
6
|
+
/**
|
|
7
|
+
* A loader that loads the social graph of a user out to a set distance.
|
|
8
|
+
*
|
|
9
|
+
* Pass `since` (unix seconds) to skip follow lists the relay already knows are older.
|
|
10
|
+
* When `since` is set and the relay returns nothing for a user, the loader falls back
|
|
11
|
+
* to `eventStore.getReplaceable(kinds.Contacts, pubkey)` so crawl expansion still
|
|
12
|
+
* happens from the cached copy.
|
|
13
|
+
*/
|
|
7
14
|
export type SocialGraphLoader = (user: ProfilePointer & {
|
|
8
15
|
distance: number;
|
|
16
|
+
since?: number;
|
|
9
17
|
}) => Observable<NostrEvent>;
|
|
18
|
+
/** An event store that the social graph loader can both write to and read from */
|
|
19
|
+
export type SocialGraphEventStore = (IEventStoreActions | IAsyncEventStoreActions) & (IEventStoreRead | IAsyncEventStoreRead);
|
|
10
20
|
export type SocialGraphLoaderOptions = Partial<{
|
|
11
|
-
/** An event store to send all the events to */
|
|
12
|
-
eventStore?:
|
|
13
|
-
/**
|
|
21
|
+
/** An event store to send all the events to and fall back to when the relay returns nothing */
|
|
22
|
+
eventStore?: SocialGraphEventStore;
|
|
23
|
+
/** A method used to load events from a local cache */
|
|
24
|
+
cacheRequest: CacheRequest;
|
|
25
|
+
/** The number of parallel contacts to load at once (default 300) */
|
|
14
26
|
parallel: number;
|
|
15
27
|
/** Extra relays to load from */
|
|
16
28
|
extraRelays?: string[] | Observable<string[]>;
|
|
@@ -18,4 +30,4 @@ export type SocialGraphLoaderOptions = Partial<{
|
|
|
18
30
|
hints?: boolean;
|
|
19
31
|
}>;
|
|
20
32
|
/** Create a social graph loader */
|
|
21
|
-
export declare function createSocialGraphLoader(
|
|
33
|
+
export declare function createSocialGraphLoader(pool: UpstreamPool, opts?: SocialGraphLoaderOptions): SocialGraphLoader;
|
|
@@ -2,50 +2,108 @@ import { getPublicContacts } from "applesauce-core/helpers";
|
|
|
2
2
|
import { kinds } from "applesauce-core/helpers/event";
|
|
3
3
|
import { mergeRelaySets } from "applesauce-core/helpers/relays";
|
|
4
4
|
import { mapEventsToStore } from "applesauce-core/observable";
|
|
5
|
-
import { firstValueFrom, identity, isObservable,
|
|
5
|
+
import { catchError, EMPTY, filter, firstValueFrom, identity, isObservable, tap } from "rxjs";
|
|
6
|
+
import { makeCacheRequest } from "../helpers/cache.js";
|
|
7
|
+
import { wrapUpstreamPool } from "../helpers/upstream.js";
|
|
6
8
|
import { wrapGeneratorFunction } from "../operators/generator.js";
|
|
9
|
+
/** Create filters for loading contact lists, keeping different since windows separate. */
|
|
10
|
+
function createContactsFilters(pointers) {
|
|
11
|
+
const bySince = new Map();
|
|
12
|
+
for (const pointer of pointers) {
|
|
13
|
+
const authors = bySince.get(pointer.since);
|
|
14
|
+
if (authors)
|
|
15
|
+
authors.push(pointer.pubkey);
|
|
16
|
+
else
|
|
17
|
+
bySince.set(pointer.since, [pointer.pubkey]);
|
|
18
|
+
}
|
|
19
|
+
return Array.from(bySince.entries()).map(([since, authors]) => {
|
|
20
|
+
const filter = { kinds: [kinds.Contacts], authors };
|
|
21
|
+
if (since !== undefined)
|
|
22
|
+
filter.since = since;
|
|
23
|
+
return filter;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function isRequestedContactsEvent(event, pointers) {
|
|
27
|
+
return event.kind === kinds.Contacts && pointers.some((pointer) => pointer.pubkey === event.pubkey);
|
|
28
|
+
}
|
|
29
|
+
function getBatchRelays(pointers, baseRelays, hints) {
|
|
30
|
+
if (hints)
|
|
31
|
+
return mergeRelaySets(baseRelays, ...pointers.map((pointer) => pointer.relays));
|
|
32
|
+
else
|
|
33
|
+
return baseRelays;
|
|
34
|
+
}
|
|
7
35
|
/** Create a social graph loader */
|
|
8
|
-
export function createSocialGraphLoader(
|
|
36
|
+
export function createSocialGraphLoader(pool, opts) {
|
|
37
|
+
const request = wrapUpstreamPool(pool);
|
|
9
38
|
return wrapGeneratorFunction(async function* (user) {
|
|
10
39
|
const seen = new Set();
|
|
40
|
+
// Carry `since` on every queue entry so descendants share the same window
|
|
11
41
|
const queue = [user];
|
|
12
42
|
// Maximum parallel requests (default to 300)
|
|
13
43
|
const maxParallel = opts?.parallel ?? 300;
|
|
14
44
|
// get the relays to load from
|
|
15
|
-
const
|
|
45
|
+
const baseRelays = mergeRelaySets(user.relays, isObservable(opts?.extraRelays) ? await firstValueFrom(opts?.extraRelays) : opts?.extraRelays);
|
|
16
46
|
// Keep loading while the queue has items
|
|
17
47
|
while (queue.length > 0) {
|
|
18
48
|
// Process up to maxParallel items at once
|
|
19
49
|
const batch = queue.splice(0, maxParallel);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
let remaining = batch;
|
|
51
|
+
// Track the latest contacts event per pubkey so we can expand the queue once
|
|
52
|
+
// the batch observable completes. Using a side-effect here lets us stream every
|
|
53
|
+
// event out to subscribers as it arrives rather than buffering to arrays.
|
|
54
|
+
const latestByPubkey = new Map();
|
|
55
|
+
const trackLatest = tap((event) => {
|
|
56
|
+
const current = latestByPubkey.get(event.pubkey);
|
|
57
|
+
if (!current || event.created_at > current.created_at)
|
|
58
|
+
latestByPubkey.set(event.pubkey, event);
|
|
59
|
+
});
|
|
60
|
+
if (opts?.cacheRequest) {
|
|
61
|
+
yield makeCacheRequest(opts.cacheRequest, createContactsFilters(batch)).pipe(filter((event) => isRequestedContactsEvent(event, batch)),
|
|
28
62
|
// Pass all events to the store if set
|
|
29
63
|
opts?.eventStore ? mapEventsToStore(opts.eventStore) : identity,
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
64
|
+
// Remember the newest contacts event per pubkey for queue expansion
|
|
65
|
+
trackLatest,
|
|
66
|
+
// If the cache throws an error, skip it
|
|
67
|
+
catchError(() => EMPTY));
|
|
68
|
+
remaining = batch.filter((pointer) => pointer.since !== undefined || !latestByPubkey.has(pointer.pubkey));
|
|
69
|
+
}
|
|
70
|
+
const relays = getBatchRelays(remaining, baseRelays, opts?.hints);
|
|
71
|
+
if (remaining.length > 0 && relays.length > 0) {
|
|
72
|
+
// Yield the relay observable so every event streams out to subscribers
|
|
73
|
+
// as it arrives from the relay.
|
|
74
|
+
yield request(relays, createContactsFilters(remaining)).pipe(filter((event) => isRequestedContactsEvent(event, remaining)),
|
|
75
|
+
// Pass all events to the store if set
|
|
76
|
+
opts?.eventStore ? mapEventsToStore(opts.eventStore) : identity,
|
|
77
|
+
// Remember the newest contacts event per pubkey for queue expansion
|
|
78
|
+
trackLatest,
|
|
79
|
+
// If the relay request throws an error, continue expanding from cache/store
|
|
80
|
+
catchError(() => EMPTY));
|
|
81
|
+
}
|
|
82
|
+
// Batch has completed — expand the queue using the latest contacts event
|
|
83
|
+
// for each pointer, falling back to the event store if the relay returned
|
|
84
|
+
// nothing (typically because `since` let it skip).
|
|
85
|
+
for (const pointer of batch) {
|
|
86
|
+
let latest = latestByPubkey.get(pointer.pubkey);
|
|
87
|
+
if (!latest && opts?.eventStore) {
|
|
88
|
+
const cached = await opts.eventStore.getReplaceable(kinds.Contacts, pointer.pubkey);
|
|
89
|
+
if (cached)
|
|
90
|
+
latest = cached;
|
|
91
|
+
}
|
|
92
|
+
if (!latest)
|
|
93
|
+
continue;
|
|
35
94
|
// if the distance is greater than 0, add the contacts to the queue
|
|
36
95
|
if (pointer.distance > 0) {
|
|
96
|
+
const contacts = getPublicContacts(latest);
|
|
37
97
|
for (const contact of contacts) {
|
|
38
98
|
// Dont add any contacts that have already been seen
|
|
39
99
|
if (seen.has(contact.pubkey))
|
|
40
100
|
continue;
|
|
41
101
|
seen.add(contact.pubkey);
|
|
42
|
-
//
|
|
43
|
-
queue.push({ ...contact, distance: pointer.distance - 1 });
|
|
102
|
+
// Forward `since` onto descendants so the whole crawl shares the window
|
|
103
|
+
queue.push({ ...contact, distance: pointer.distance - 1, since: pointer.since });
|
|
44
104
|
}
|
|
45
105
|
}
|
|
46
|
-
}
|
|
47
|
-
// Wait for all parallel operations to complete
|
|
48
|
-
await Promise.all(promises);
|
|
106
|
+
}
|
|
49
107
|
}
|
|
50
108
|
});
|
|
51
109
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "applesauce-loaders",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "A collection of observable based loaders built on rx-nostr",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"applesauce-core": "^
|
|
55
|
+
"applesauce-core": "^6.0.0",
|
|
56
56
|
"nanoid": "^5.0.9",
|
|
57
57
|
"rxjs": "^7.8.1"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@hirez_io/observer-spy": "^2.2.0",
|
|
61
|
-
"applesauce-signers": "^
|
|
61
|
+
"applesauce-signers": "^6.0.0",
|
|
62
62
|
"rimraf": "^6.0.1",
|
|
63
63
|
"typescript": "^5.8.3",
|
|
64
64
|
"vitest": "^4.0.15",
|