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 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://hzrd149.github.io/applesauce/loaders/package.html) [typedoc](https://hzrd149.github.io/applesauce/typedoc/modules/applesauce-loaders.html)
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: AddressPointerWithoutD[]): Map<number, AddressPointerWithoutD[]>;
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: AddressPointerWithoutD[]): Map<string, AddressPointerWithoutD[]>;
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: AddressPointerWithoutD[]): Map<string, AddressPointerWithoutD[]> | Map<number, AddressPointerWithoutD[]>;
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
- if (replaceable.length > 0) {
20
- const groups = groupAddressPointersByPubkeyOrKind(replaceable);
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
- if (addressable.length > 0) {
24
- const groups = groupAddressPointersByPubkeyOrKind(addressable);
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
+ }
@@ -1,6 +1,7 @@
1
- export * from "./dns-identity.js";
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,6 +1,7 @@
1
- export * from "./dns-identity.js";
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 { AddressPointerLoader } from "./address-loader.js";
6
- /** A loader that loads the social graph of a user out to a set distance */
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?: Parameters<typeof mapEventsToStore>[0];
13
- /** The number of parallel requests to make (default 300) */
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(addressLoader: AddressPointerLoader, opts?: SocialGraphLoaderOptions): SocialGraphLoader;
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, lastValueFrom, toArray } from "rxjs";
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(addressLoader, opts) {
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 relays = mergeRelaySets(user.relays, isObservable(opts?.extraRelays) ? await firstValueFrom(opts?.extraRelays) : opts?.extraRelays);
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
- const promises = batch.map(async (pointer) => {
21
- const address = {
22
- kind: kinds.Contacts,
23
- pubkey: pointer.pubkey,
24
- relays: opts?.hints ? mergeRelaySets(pointer.relays, relays) : relays,
25
- };
26
- // load the contacts events
27
- const events = await lastValueFrom(addressLoader(address).pipe(
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
- // Conver to array
31
- toArray()));
32
- if (events.length === 0)
33
- return;
34
- const contacts = getPublicContacts(events[events.length - 1]);
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
- // Add to queue
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": "5.0.2",
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": "^5.0.0",
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": "^5.0.0",
61
+ "applesauce-signers": "^6.0.0",
62
62
  "rimraf": "^6.0.1",
63
63
  "typescript": "^5.8.3",
64
64
  "vitest": "^4.0.15",