feat: @snort/system CacheRelay

This commit is contained in:
Kieran 2024-01-23 15:35:28 +00:00
parent d6c578fafc
commit 5cea096067
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
29 changed files with 296 additions and 380 deletions

View File

@ -14,9 +14,14 @@ export class EventCacheWorker extends EventEmitter<CacheEvents> implements Cache
} }
async preload() { async preload() {
const ids = await this.#relay.sql("select id from events", []); const ids = await this.#relay.query([
this.#keys = new Set<string>(ids.map(a => a[0] as string)); "REQ",
return Promise.resolve(); "preload-event-cache",
{
ids_only: true,
},
]);
this.#keys = new Set<string>(ids as unknown as Array<string>);
} }
keysOnTable(): string[] { keysOnTable(): string[] {
@ -43,18 +48,17 @@ export class EventCacheWorker extends EventEmitter<CacheEvents> implements Cache
} }
async bulkGet(keys: string[]): Promise<NostrEvent[]> { async bulkGet(keys: string[]): Promise<NostrEvent[]> {
const results = await this.#relay.req({ const results = await this.#relay.query([
id: "EventCacheWorker.bulkGet", "REQ",
filters: [ "EventCacheWorker.bulkGet",
{ {
ids: keys, ids: keys,
}, },
], ]);
}); for (const ev of results) {
for (const ev of results.result) {
this.#cache.set(ev.id, ev); this.#cache.set(ev.id, ev);
} }
return results.result; return results;
} }
async set(obj: NostrEvent): Promise<void> { async set(obj: NostrEvent): Promise<void> {

View File

@ -1,12 +1,14 @@
import { CachedTable, CacheEvents, removeUndefined } from "@snort/shared"; import { CachedTable, CacheEvents, removeUndefined, unixNowMs, unwrap } from "@snort/shared";
import { CachedMetadata, mapEventToProfile, NostrEvent } from "@snort/system"; import { CachedMetadata, mapEventToProfile, NostrEvent } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay"; import { WorkerRelayInterface } from "@snort/worker-relay";
import debug from "debug";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implements CachedTable<CachedMetadata> { export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implements CachedTable<CachedMetadata> {
#relay: WorkerRelayInterface; #relay: WorkerRelayInterface;
#keys = new Set<string>(); #keys = new Set<string>();
#cache = new Map<string, CachedMetadata>(); #cache = new Map<string, CachedMetadata>();
#log = debug("ProfileCacheRelayWorker");
constructor(relay: WorkerRelayInterface) { constructor(relay: WorkerRelayInterface) {
super(); super();
@ -14,8 +16,17 @@ export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implement
} }
async preload() { async preload() {
const ids = await this.#relay.sql("select distinct(pubkey) from events where kind = ?", [0]); const start = unixNowMs();
this.#keys = new Set<string>(ids.map(a => a[0] as string)); const profiles = await this.#relay.query([
"REQ",
"profiles-preload",
{
kinds: [0],
},
]);
this.#cache = new Map<string, CachedMetadata>(profiles.map(a => [a.pubkey, unwrap(mapEventToProfile(a))]));
this.#keys = new Set<string>(this.#cache.keys());
this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString());
} }
keysOnTable(): string[] { keysOnTable(): string[] {
@ -50,16 +61,15 @@ export class ProfileCacheRelayWorker extends EventEmitter<CacheEvents> implement
async bulkGet(keys: string[]) { async bulkGet(keys: string[]) {
if (keys.length === 0) return []; if (keys.length === 0) return [];
const results = await this.#relay.req({ const results = await this.#relay.query([
id: "ProfileCacheRelayWorker.bulkGet", "REQ",
filters: [ "ProfileCacheRelayWorker.bulkGet",
{ {
authors: keys, authors: keys,
kinds: [0], kinds: [0],
}, },
], ]);
}); const mapped = removeUndefined(results.map(a => mapEventToProfile(a)));
const mapped = removeUndefined(results.result.map(a => mapEventToProfile(a)));
for (const pf of mapped) { for (const pf of mapped) {
this.#cache.set(this.key(pf), pf); this.#cache.set(this.key(pf), pf);
} }

View File

@ -1,5 +1,5 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system"; import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react"; import { useEventReactions, useReactions } from "@snort/system-react";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { FooterZapButton } from "@/Components/Event/Note/NoteFooter/FooterZapButton"; import { FooterZapButton } from "@/Components/Event/Note/NoteFooter/FooterZapButton";
@ -8,7 +8,6 @@ import { PowIcon } from "@/Components/Event/Note/NoteFooter/PowIcon";
import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton"; import { ReplyButton } from "@/Components/Event/Note/NoteFooter/ReplyButton";
import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton"; import { RepostButton } from "@/Components/Event/Note/NoteFooter/RepostButton";
import ReactionsModal from "@/Components/Event/Note/ReactionsModal"; import ReactionsModal from "@/Components/Event/Note/ReactionsModal";
import { useReactionsView } from "@/Feed/WorkerRelayView";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
export interface NoteFooterProps { export interface NoteFooterProps {
@ -22,7 +21,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const ids = useMemo(() => [link], [link]); const ids = useMemo(() => [link], [link]);
const [showReactions, setShowReactions] = useState(false); const [showReactions, setShowReactions] = useState(false);
const related = useReactionsView(ids, false); const related = useReactions("reactions", ids, undefined, false);
const { reactions, zaps, reposts } = useEventReactions(link, related); const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive } = reactions; const { positive } = reactions;

View File

@ -21,19 +21,18 @@ export function LocalSearch({ term, kind }: { term: string; kind: EventKind }) {
useEffect(() => { useEffect(() => {
setFrag(undefined); setFrag(undefined);
if (term) { if (term) {
Relay.req({ Relay.query([
id: "local-search", "REQ",
filters: [ "local-search",
{ {
kinds: [kind], kinds: [kind],
limit: 100, limit: 100,
search: term, search: term,
}, },
], ]).then(res => {
}).then(res => {
setFrag({ setFrag({
refTime: 0, refTime: 0,
events: res.result as Array<TaggedNostrEvent>, events: res as Array<TaggedNostrEvent>,
}); });
}); });
} }

View File

@ -43,19 +43,17 @@ export const addEventToFuzzySearch = (ev: NostrEvent) => {
}; };
export const addCachedMetadataToFuzzySearch = (profile: CachedMetadata) => { export const addCachedMetadataToFuzzySearch = (profile: CachedMetadata) => {
queueMicrotask(() => { const existing = profileTimestamps.get(profile.pubkey);
const existing = profileTimestamps.get(profile.pubkey); if (existing) {
if (existing) { if (existing > profile.created) {
if (existing > profile.created) { return;
return;
}
fuzzySearch.remove(doc => doc.pubkey === profile.pubkey);
} }
profileTimestamps.set(profile.pubkey, profile.created); fuzzySearch.remove(doc => doc.pubkey === profile.pubkey);
if (profile.pubkey && (profile.name || profile.display_name || profile.nip05)) { }
fuzzySearch.add(profile); profileTimestamps.set(profile.pubkey, profile.created);
} if (profile.pubkey && (profile.name || profile.display_name || profile.nip05)) {
}); fuzzySearch.add(profile);
}
}; };
export default fuzzySearch; export default fuzzySearch;

View File

@ -23,9 +23,6 @@ import {
SnortAppData, SnortAppData,
} from "@/Utils/Login"; } from "@/Utils/Login";
import { SubscriptionEvent } from "@/Utils/Subscription"; import { SubscriptionEvent } from "@/Utils/Subscription";
import { useFollowsContactListView } from "./WorkerRelayView";
/** /**
* Managed loading data for the current logged in user * Managed loading data for the current logged in user
*/ */
@ -34,7 +31,6 @@ export default function useLoginFeed() {
const { publicKey: pubKey, follows } = login; const { publicKey: pubKey, follows } = login;
const { publisher, system } = useEventPublisher(); const { publisher, system } = useEventPublisher();
useFollowsContactListView();
useEffect(() => { useEffect(() => {
system.checkSigs = login.appData.item.preferences.checkSigs; system.checkSigs = login.appData.item.preferences.checkSigs;
}, [login]); }, [login]);

View File

@ -86,9 +86,10 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
const sub = useMemo(() => { const sub = useMemo(() => {
const rb = createBuilder(); const rb = createBuilder();
console.debug(rb?.builder.id, options);
if (rb) { if (rb) {
if (options.method === "LIMIT_UNTIL") { if (options.method === "LIMIT_UNTIL") {
rb.filter.until(until).limit(100); rb.filter.until(until).limit(50);
} else { } else {
rb.filter.since(since).until(until); rb.filter.since(since).until(until);
if (since === undefined) { if (since === undefined) {
@ -112,8 +113,8 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
.limit(1) .limit(1)
.since(now); .since(now);
} }
return rb.builder;
} }
return rb?.builder ?? null;
}, [until, since, options.method, pref, createBuilder]); }, [until, since, options.method, pref, createBuilder]);
const mainQuery = useRequestBuilderAdvanced(sub); const mainQuery = useRequestBuilderAdvanced(sub);
@ -135,8 +136,8 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
}); });
rb.builder.id = `${rb.builder.id}:latest`; rb.builder.id = `${rb.builder.id}:latest`;
rb.filter.limit(1).since(now); rb.filter.limit(1).since(now);
return rb.builder;
} }
return rb?.builder ?? null;
}, [pref.autoShowLatest, createBuilder]); }, [pref.autoShowLatest, createBuilder]);
const latestQuery = useRequestBuilderAdvanced(subRealtime); const latestQuery = useRequestBuilderAdvanced(subRealtime);

View File

@ -1,200 +1,39 @@
import { unixNow } from "@snort/shared"; import { EventKind, RequestBuilder } from "@snort/system";
import { EventKind, NostrEvent, NostrLink, ReqFilter, RequestBuilder, TaggedNostrEvent } from "@snort/system"; import { useRequestBuilder } from "@snort/system-react";
import { SnortContext, useRequestBuilder } from "@snort/system-react"; import { useMemo } from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { LRUCache } from "typescript-lru-cache";
import { Relay } from "@/Cache"; //import { LRUCache } from "typescript-lru-cache";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { Day } from "@/Utils/Const";
const cache = new LRUCache<string, NostrEvent[]>({ maxSize: 100 }); //const cache = new LRUCache<string, NostrEvent[]>({ maxSize: 100 });
export function useWorkerRelayView(id: string, filters: Array<ReqFilter>, leaveOpen?: boolean, maxWindow?: number) {
const cacheKey = useMemo(() => JSON.stringify(filters), [filters]);
const [events, setEvents] = useState<Array<NostrEvent>>(cache.get(cacheKey) ?? []);
const [rb, setRb] = useState<RequestBuilder>();
const system = useContext(SnortContext);
const cacheAndSetEvents = useCallback(
(evs: Array<NostrEvent>) => {
cache.set(cacheKey, evs);
setEvents(evs);
},
[cacheKey],
);
useEffect(() => {
if (rb) {
const q = system.Query(rb);
q.uncancel();
return () => q.cancel();
}
}, [rb, system]);
useEffect(() => {
setRb(undefined);
Relay.req({
id: `${id}+latest`,
filters: filters.map(f => ({
...f,
until: undefined,
since: undefined,
limit: 1,
})),
}).then(latest => {
const rb = new RequestBuilder(id);
rb.withOptions({ fillStore: false });
filters
.map((f, i) => {
const since = latest.result?.at(i)?.created_at;
return {
...f,
limit: undefined,
until: undefined,
since: since ? since + 1 : maxWindow ? unixNow() - maxWindow : f.since,
};
})
.forEach(f => rb.withBareFilter(f));
setRb(rb);
});
Relay.req({ id, filters, leaveOpen }).then(res => {
cacheAndSetEvents(res.result);
if (res.port) {
res.port.addEventListener("message", ev => {
const evs = ev.data as Array<NostrEvent>;
if (evs.length > 0) {
cacheAndSetEvents([...events, ...evs]);
}
});
res.port.start();
}
});
return () => {
Relay.close(id);
};
}, [id, filters, maxWindow]);
return events as Array<TaggedNostrEvent>;
}
export function useWorkerRelayViewCount(id: string, filters: Array<ReqFilter>, maxWindow?: number) {
const [count, setCount] = useState(0);
const [rb, setRb] = useState<RequestBuilder>();
useRequestBuilder(rb);
useEffect(() => {
Relay.req({
id: `${id}+latest`,
filters: filters.map(f => ({
...f,
until: undefined,
since: undefined,
limit: 1,
})),
}).then(latest => {
const rb = new RequestBuilder(id);
filters
.map((f, i) => ({
...f,
limit: undefined,
until: undefined,
since: latest.result?.at(i)?.created_at ?? (maxWindow ? unixNow() - maxWindow : undefined),
}))
.forEach(f => rb.withBareFilter(f));
setRb(rb);
});
Relay.count({ id, filters }).then(setCount);
}, [id, filters, maxWindow]);
return count;
}
export function useFollowsTimelineView(limit = 20) { export function useFollowsTimelineView(limit = 20) {
const follows = useLogin(s => s.follows.item); const follows = useLogin(s => s.follows.item);
const kinds = [EventKind.TextNote, EventKind.Repost, EventKind.Polls]; const kinds = [EventKind.TextNote, EventKind.Repost, EventKind.Polls];
const filter = useMemo(() => { const req = useMemo(() => {
return [ const rb = new RequestBuilder("follows-timeline");
{ rb.withOptions({
authors: follows, leaveOpen: true,
kinds, });
limit, rb.withFilter().kinds(kinds).authors(follows).limit(limit);
}, return rb;
];
}, [follows, limit]); }, [follows, limit]);
return useWorkerRelayView("follows-timeline", filter, true, Day * 7); return useRequestBuilder(req);
} }
export function useNotificationsView() { export function useNotificationsView() {
const publicKey = useLogin(s => s.publicKey); const publicKey = useLogin(s => s.publicKey);
const kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]; const kinds = [EventKind.TextNote, EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt];
const req = useMemo(() => { const req = useMemo(() => {
return [ if (publicKey) {
{ const rb = new RequestBuilder("notifications");
"#p": [publicKey ?? ""], rb.withOptions({
kinds, leaveOpen: true,
since: unixNow() - Day * 7, });
}, rb.withFilter().kinds(kinds).tag("p", [publicKey]).limit(1000);
]; return rb;
}
}, [publicKey]); }, [publicKey]);
return useWorkerRelayView("notifications", req, true, Day * 30); return useRequestBuilder(req);
}
export function useReactionsView(ids: Array<NostrLink>, leaveOpen = true) {
const req = useMemo(() => {
const rb = new RequestBuilder("reactions");
rb.withOptions({ leaveOpen });
const grouped = ids.reduce(
(acc, v) => {
acc[v.type] ??= [];
acc[v.type].push(v);
return acc;
},
{} as Record<string, Array<NostrLink>>,
);
for (const [, v] of Object.entries(grouped)) {
rb.withFilter().kinds([EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]).replyToLink(v);
}
return rb.buildRaw();
}, [ids]);
return useWorkerRelayView("reactions", req, leaveOpen, undefined);
}
export function useReactionsViewCount(ids: Array<NostrLink>, leaveOpen = true) {
const req = useMemo(() => {
const rb = new RequestBuilder("reactions");
rb.withOptions({ leaveOpen });
const grouped = ids.reduce(
(acc, v) => {
acc[v.type] ??= [];
acc[v.type].push(v);
return acc;
},
{} as Record<string, Array<NostrLink>>,
);
for (const [, v] of Object.entries(grouped)) {
rb.withFilter().kinds([EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]).replyToLink(v);
}
return rb.buildRaw();
}, [ids]);
return useWorkerRelayViewCount("reactions", req, undefined);
}
export function useFollowsContactListView() {
const follows = useLogin(s => s.follows.item);
const kinds = [EventKind.ContactList, EventKind.Relays];
const filter = useMemo(() => {
return [
{
authors: follows,
kinds,
},
];
}, [follows]);
return useWorkerRelayView("follows-contacts-relays", filter, undefined, undefined);
} }

View File

@ -9,7 +9,9 @@ export function useLinkList(id: string, fn: (rb: RequestBuilder) => void) {
const sub = useMemo(() => { const sub = useMemo(() => {
const rb = new RequestBuilder(id); const rb = new RequestBuilder(id);
fn(rb); fn(rb);
return rb; if (rb.numFilters > 0) {
return rb;
}
}, [id, fn]); }, [id, fn]);
const listStore = useRequestBuilder(sub); const listStore = useRequestBuilder(sub);

View File

@ -163,7 +163,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
)} )}
<div className="profile-wrapper w-max"> <div className="profile-wrapper w-max">
<AvatarSection id={id} loginPubKey={loginPubKey} user={user} readonly={readonly} lnurl={lnurl} /> <AvatarSection id={id} loginPubKey={loginPubKey} user={user} readonly={readonly} lnurl={lnurl} />
<ProfileDetails user={user} loginPubKey={loginPubKey} id={id} aboutText={aboutText} lnurl={lnurl} /> <ProfileDetails user={user} loginPubKey={loginPubKey} id={id} aboutText={aboutText} lnurl={lnurl} showLnQr={true} />
</div> </div>
</div> </div>
<div className="main-content"> <div className="main-content">

View File

@ -11,6 +11,7 @@ import FollowsList from "@/Components/User/FollowListBase";
import useFollowersFeed from "@/Feed/FollowersFeed"; import useFollowersFeed from "@/Feed/FollowersFeed";
import useFollowsFeed from "@/Feed/FollowsFeed"; import useFollowsFeed from "@/Feed/FollowsFeed";
import useRelaysFeed from "@/Feed/RelaysFeed"; import useRelaysFeed from "@/Feed/RelaysFeed";
import { TimelineSubject } from "@/Feed/TimelineFeed";
import useZapsFeed from "@/Feed/ZapsFeed"; import useZapsFeed from "@/Feed/ZapsFeed";
import { useBookmarkList, usePinList } from "@/Hooks/useLists"; import { useBookmarkList, usePinList } from "@/Hooks/useLists";
import messages from "@/Pages/messages"; import messages from "@/Pages/messages";
@ -52,7 +53,6 @@ export function BookMarksTab({ id }: { id: HexKey }) {
} }
export function ProfileNotesTab({ id, relays, isMe }: { id: HexKey; relays?: Array<string>; isMe: boolean }) { export function ProfileNotesTab({ id, relays, isMe }: { id: HexKey; relays?: Array<string>; isMe: boolean }) {
console.count("ProfileNotesTab");
const pinned = usePinList(id); const pinned = usePinList(id);
const options = useMemo(() => ({ showTime: false, showPinned: true, canUnpin: isMe }), [isMe]); const options = useMemo(() => ({ showTime: false, showPinned: true, canUnpin: isMe }), [isMe]);
const subject = useMemo( const subject = useMemo(
@ -61,7 +61,7 @@ export function ProfileNotesTab({ id, relays, isMe }: { id: HexKey; relays?: Arr
items: [id], items: [id],
discriminator: id.slice(0, 12), discriminator: id.slice(0, 12),
relay: relays, relay: relays,
}), } as TimelineSubject),
[id, relays], [id, relays],
); );
return ( return (
@ -76,7 +76,7 @@ export function ProfileNotesTab({ id, relays, isMe }: { id: HexKey; relays?: Arr
subject={subject} subject={subject}
postsOnly={false} postsOnly={false}
method={"LIMIT_UNTIL"} method={"LIMIT_UNTIL"}
loadMore={false} loadMore={true}
ignoreModeration={true} ignoreModeration={true}
window={60 * 60 * 6} window={60 * 60 * 6}
/> />

View File

@ -8,11 +8,11 @@ import { StrictMode } from "react";
import * as ReactDOM from "react-dom/client"; import * as ReactDOM from "react-dom/client";
import { createBrowserRouter, RouteObject, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouteObject, RouterProvider } from "react-router-dom";
import { initRelayWorker, preload, Relay } from "@/Cache"; import { initRelayWorker, preload, Relay, UserCache } from "@/Cache";
import { ThreadRoute } from "@/Components/Event/Thread"; import { ThreadRoute } from "@/Components/Event/Thread";
import { IntlProvider } from "@/Components/IntlProvider/IntlProvider"; import { IntlProvider } from "@/Components/IntlProvider/IntlProvider";
import { db } from "@/Db"; import { db } from "@/Db";
import { addEventToFuzzySearch } from "@/Db/FuzzySearch"; import { addCachedMetadataToFuzzySearch } from "@/Db/FuzzySearch";
import { updateRelayConnections } from "@/Hooks/useLoginRelays"; import { updateRelayConnections } from "@/Hooks/useLoginRelays";
import { AboutPage } from "@/Pages/About"; import { AboutPage } from "@/Pages/About";
import { SnortDeckLayout } from "@/Pages/DeckLayout"; import { SnortDeckLayout } from "@/Pages/DeckLayout";
@ -54,39 +54,34 @@ async function initSite() {
const login = LoginStore.takeSnapshot(); const login = LoginStore.takeSnapshot();
db.ready = await db.isAvailable(); db.ready = await db.isAvailable();
if (db.ready) { if (db.ready) {
await preload(login.follows.item); preload(login.follows.item);
} }
updateRelayConnections(System, login.relays.item).catch(console.error); updateRelayConnections(System, login.relays.item).catch(console.error);
try {
if ("registerProtocolHandler" in window.navigator) {
window.navigator.registerProtocolHandler("web+nostr", `${window.location.protocol}//${window.location.host}/%s`);
console.info("Registered protocol handler for 'web+nostr'");
}
} catch (e) {
console.error("Failed to register protocol handler", e);
}
setupWebLNWalletConfig(Wallets); setupWebLNWalletConfig(Wallets);
Relay.sql("select json from events where kind = ?", [3]).then(res => { Relay.query(["REQ", "preload-social-graph", {
for (const [json] of res) { kinds: [3]
}]).then(res => {
for (const ev of res) {
try { try {
socialGraphInstance.handleEvent(JSON.parse(json as string)); socialGraphInstance.handleEvent(ev);
} catch (e) { } catch (e) {
console.error("Failed to handle contact list event from sql db", e); console.error("Failed to handle contact list event from sql db", e);
} }
} }
}); });
Relay.sql("select json from events where kind = ?", [0]).then(res => {
for (const [json] of res) { queueMicrotask(() => {
for (const ev of UserCache.snapshot()) {
try { try {
addEventToFuzzySearch(JSON.parse(json as string)); addCachedMetadataToFuzzySearch(ev);
} catch (e) { } catch (e) {
console.error("Failed to handle metadata event from sql db", e); console.error("Failed to handle metadata event from sql db", e);
} }
} }
}); });
return null; return null;
} }

View File

@ -1,5 +1,5 @@
import { removeUndefined, throwIfOffline } from "@snort/shared"; import { removeUndefined, throwIfOffline } from "@snort/shared";
import { mapEventToProfile, NostrEvent, NostrSystem, ProfileLoaderService, socialGraphInstance } from "@snort/system"; import { mapEventToProfile, NostrEvent, NostrSystem, socialGraphInstance } from "@snort/system";
import inMemoryDB from "@snort/system/src/InMemoryDB"; import inMemoryDB from "@snort/system/src/InMemoryDB";
import { EventsCache, Relay, RelayMetrics, SystemDb, UserCache, UserRelays } from "@/Cache"; import { EventsCache, Relay, RelayMetrics, SystemDb, UserCache, UserRelays } from "@/Cache";
@ -15,6 +15,7 @@ export const System = new NostrSystem({
eventsCache: EventsCache, eventsCache: EventsCache,
profileCache: UserCache, profileCache: UserCache,
relayMetrics: RelayMetrics, relayMetrics: RelayMetrics,
cacheRelay: Relay,
optimizer: hasWasm ? WasmOptimizer : undefined, optimizer: hasWasm ? WasmOptimizer : undefined,
db: SystemDb, db: SystemDb,
}); });
@ -58,9 +59,4 @@ export async function fetchProfile(key: string) {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
/**
* Singleton user profile loader
*/
export const ProfileLoader = new ProfileLoaderService(System, UserCache);

View File

@ -0,0 +1,17 @@
import { NostrEvent, OkResponse, ReqCommand } from "./nostr";
/**
* A cache relay is an always available local (local network / browser worker) relay
* Which should contain all of the content we're looking for and respond quickly.
*/
export interface CacheRelay {
/**
* Write event to cache relay
*/
event(ev: NostrEvent): Promise<OkResponse>;
/**
* Read event from cache relay
*/
query(req: ReqCommand): Promise<Array<NostrEvent>>;
}

View File

@ -2,8 +2,8 @@ import { removeUndefined, sanitizeRelayUrl, unwrap } from "@snort/shared";
import debug from "debug"; import debug from "debug";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { Connection, ConnectionStateSnapshot, OkResponse, RelaySettings } from "./connection"; import { Connection, ConnectionStateSnapshot, RelaySettings } from "./connection";
import { NostrEvent, TaggedNostrEvent } from "./nostr"; import { NostrEvent, OkResponse, TaggedNostrEvent } from "./nostr";
import { pickRelaysForReply } from "./outbox-model"; import { pickRelaysForReply } from "./outbox-model";
import { SystemInterface } from "."; import { SystemInterface } from ".";

View File

@ -6,7 +6,7 @@ import EventEmitter from "eventemitter3";
import { DefaultConnectTimeout } from "./const"; import { DefaultConnectTimeout } from "./const";
import { ConnectionStats } from "./connection-stats"; import { ConnectionStats } from "./connection-stats";
import { NostrEvent, ReqCommand, ReqFilter, TaggedNostrEvent, u256 } from "./nostr"; import { NostrEvent, OkResponse, ReqCommand, ReqFilter, TaggedNostrEvent, u256 } from "./nostr";
import { RelayInfo } from "./relay-info"; import { RelayInfo } from "./relay-info";
import EventKind from "./event-kind"; import EventKind from "./event-kind";
import { EventExt } from "./event-ext"; import { EventExt } from "./event-ext";
@ -19,14 +19,6 @@ export interface RelaySettings {
write: boolean; write: boolean;
} }
export interface OkResponse {
ok: boolean;
id: string;
relay: string;
message?: string;
event: NostrEvent;
}
/** /**
* Snapshot of connection stats * Snapshot of connection stats
*/ */

View File

@ -1,14 +1,15 @@
import { RelaySettings, ConnectionStateSnapshot, OkResponse } from "./connection"; import { RelaySettings, ConnectionStateSnapshot } from "./connection";
import { RequestBuilder } from "./request-builder"; import { RequestBuilder } from "./request-builder";
import { NostrEvent, ReqFilter, TaggedNostrEvent } from "./nostr"; import { NostrEvent, OkResponse, ReqFilter, TaggedNostrEvent } from "./nostr";
import { ProfileLoaderService } from "./profile-cache"; import { ProfileLoaderService } from "./profile-cache";
import { RelayCache, RelayMetadataLoader } from "./outbox-model"; import { AuthorsRelaysCache, RelayMetadataLoader } from "./outbox-model";
import { Optimizer } from "./query-optimizer"; import { Optimizer } from "./query-optimizer";
import { base64 } from "@scure/base"; import { base64 } from "@scure/base";
import { CachedTable } from "@snort/shared"; import { CachedTable } from "@snort/shared";
import { ConnectionPool } from "./connection-pool"; import { ConnectionPool } from "./connection-pool";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { QueryEvents } from "./query"; import { QueryEvents } from "./query";
import { CacheRelay } from "./cache-relay";
export { NostrSystem } from "./nostr-system"; export { NostrSystem } from "./nostr-system";
export { default as EventKind } from "./event-kind"; export { default as EventKind } from "./event-kind";
@ -133,7 +134,7 @@ export interface SystemInterface {
/** /**
* Relay cache for "Gossip" model * Relay cache for "Gossip" model
*/ */
get relayCache(): RelayCache; get relayCache(): AuthorsRelaysCache;
/** /**
* Query optimizer * Query optimizer
@ -154,6 +155,11 @@ export interface SystemInterface {
* Main connection pool * Main connection pool
*/ */
get pool(): ConnectionPool; get pool(): ConnectionPool;
/**
* Local relay cache service
*/
get cacheRelay(): CacheRelay | undefined;
} }
export interface SystemSnapshot { export interface SystemSnapshot {

View File

@ -2,8 +2,8 @@ import debug from "debug";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { CachedTable } from "@snort/shared"; import { CachedTable } from "@snort/shared";
import { NostrEvent, TaggedNostrEvent } from "./nostr"; import { NostrEvent, TaggedNostrEvent, OkResponse } from "./nostr";
import { RelaySettings, ConnectionStateSnapshot, OkResponse } from "./connection"; import { RelaySettings, ConnectionStateSnapshot } from "./connection";
import { BuiltRawReqFilter, RequestBuilder } from "./request-builder"; import { BuiltRawReqFilter, RequestBuilder } from "./request-builder";
import { RelayMetricHandler } from "./relay-metric-handler"; import { RelayMetricHandler } from "./relay-metric-handler";
import { import {
@ -24,6 +24,7 @@ import { RelayMetadataLoader } from "./outbox-model";
import { Optimizer, DefaultOptimizer } from "./query-optimizer"; import { Optimizer, DefaultOptimizer } from "./query-optimizer";
import { ConnectionPool, DefaultConnectionPool } from "./connection-pool"; import { ConnectionPool, DefaultConnectionPool } from "./connection-pool";
import { QueryManager } from "./query-manager"; import { QueryManager } from "./query-manager";
import { CacheRelay } from "./cache-relay";
export interface NostrSystemEvents { export interface NostrSystemEvents {
change: (state: SystemSnapshot) => void; change: (state: SystemSnapshot) => void;
@ -37,6 +38,7 @@ export interface NostrsystemProps {
profileCache?: CachedTable<CachedMetadata>; profileCache?: CachedTable<CachedMetadata>;
relayMetrics?: CachedTable<RelayMetrics>; relayMetrics?: CachedTable<RelayMetrics>;
eventsCache?: CachedTable<NostrEvent>; eventsCache?: CachedTable<NostrEvent>;
cacheRelay?: CacheRelay;
optimizer?: Optimizer; optimizer?: Optimizer;
db?: SnortSystemDb; db?: SnortSystemDb;
checkSigs?: boolean; checkSigs?: boolean;
@ -82,6 +84,7 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
readonly pool: ConnectionPool; readonly pool: ConnectionPool;
readonly eventsCache: CachedTable<NostrEvent>; readonly eventsCache: CachedTable<NostrEvent>;
readonly relayLoader: RelayMetadataLoader; readonly relayLoader: RelayMetadataLoader;
readonly cacheRelay: CacheRelay | undefined;
/** /**
* Check event signatures (reccomended) * Check event signatures (reccomended)
@ -95,6 +98,7 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
this.relayMetricsCache = props.relayMetrics ?? new RelayMetricCache(props.db?.relayMetrics); this.relayMetricsCache = props.relayMetrics ?? new RelayMetricCache(props.db?.relayMetrics);
this.eventsCache = props.eventsCache ?? new EventsCache(props.db?.events); this.eventsCache = props.eventsCache ?? new EventsCache(props.db?.events);
this.optimizer = props.optimizer ?? DefaultOptimizer; this.optimizer = props.optimizer ?? DefaultOptimizer;
this.cacheRelay = props.cacheRelay;
this.profileLoader = new ProfileLoaderService(this, this.profileCache); this.profileLoader = new ProfileLoaderService(this, this.profileCache);
this.relayMetricsHandler = new RelayMetricHandler(this.relayMetricsCache); this.relayMetricsHandler = new RelayMetricHandler(this.relayMetricsCache);

View File

@ -57,8 +57,8 @@ export interface ReqFilter {
since?: number; since?: number;
until?: number; until?: number;
limit?: number; limit?: number;
not?: ReqFilter; ids_only?: boolean;
[key: string]: Array<string> | Array<number> | string | number | undefined | ReqFilter; [key: string]: Array<string> | Array<number> | string | number | undefined | boolean;
} }
/** /**
@ -92,3 +92,11 @@ export interface IMeta {
alt?: string; alt?: string;
fallback?: Array<string>; fallback?: Array<string>;
} }
export interface OkResponse {
ok: boolean;
id: string;
relay: string;
message?: string;
event: NostrEvent;
}

View File

@ -33,14 +33,14 @@ export interface RelayTaggedFilters {
const logger = debug("OutboxModel"); const logger = debug("OutboxModel");
export interface RelayCache { export interface AuthorsRelaysCache {
getFromCache(pubkey?: string): UsersRelays | undefined; getFromCache(pubkey?: string): UsersRelays | undefined;
update(obj: UsersRelays): Promise<"new" | "updated" | "refresh" | "no_change">; update(obj: UsersRelays): Promise<"new" | "updated" | "refresh" | "no_change">;
buffer(keys: Array<string>): Promise<Array<string>>; buffer(keys: Array<string>): Promise<Array<string>>;
bulkSet(objs: Array<UsersRelays>): Promise<void>; bulkSet(objs: Array<UsersRelays>): Promise<void>;
} }
export function splitAllByWriteRelays(cache: RelayCache, filters: Array<ReqFilter>) { export function splitAllByWriteRelays(cache: AuthorsRelaysCache, filters: Array<ReqFilter>) {
const allSplit = filters const allSplit = filters
.map(a => splitByWriteRelays(cache, a)) .map(a => splitByWriteRelays(cache, a))
.reduce((acc, v) => { .reduce((acc, v) => {
@ -66,7 +66,7 @@ export function splitAllByWriteRelays(cache: RelayCache, filters: Array<ReqFilte
/** /**
* Split filters by authors * Split filters by authors
*/ */
export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter, pickN?: number): Array<RelayTaggedFilter> { export function splitByWriteRelays(cache: AuthorsRelaysCache, filter: ReqFilter, pickN?: number): Array<RelayTaggedFilter> {
const authors = filter.authors; const authors = filter.authors;
if ((authors?.length ?? 0) === 0) { if ((authors?.length ?? 0) === 0) {
return [ return [
@ -108,7 +108,7 @@ export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter, pickN?:
* Split filters by author * Split filters by author
*/ */
export function splitFlatByWriteRelays( export function splitFlatByWriteRelays(
cache: RelayCache, cache: AuthorsRelaysCache,
input: Array<FlatReqFilter>, input: Array<FlatReqFilter>,
pickN?: number, pickN?: number,
): Array<RelayTaggedFlatFilters> { ): Array<RelayTaggedFlatFilters> {
@ -146,7 +146,7 @@ export function splitFlatByWriteRelays(
/** /**
* Pick most popular relays for each authors * Pick most popular relays for each authors
*/ */
export function pickTopRelays(cache: RelayCache, authors: Array<string>, n: number, type: "write" | "read") { export function pickTopRelays(cache: AuthorsRelaysCache, authors: Array<string>, n: number, type: "write" | "read") {
// map of pubkey -> [write relays] // map of pubkey -> [write relays]
const allRelays = authors.map(a => { const allRelays = authors.map(a => {
return { return {

View File

@ -1,6 +1,6 @@
import debug from "debug"; import debug from "debug";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { BuiltRawReqFilter, RequestBuilder, SystemInterface, TaggedNostrEvent } from "."; import { BuiltRawReqFilter, RequestBuilder, RequestStrategy, SystemInterface, TaggedNostrEvent } from ".";
import { Query, TraceReport } from "./query"; import { Query, TraceReport } from "./query";
import { FilterCacheLayer, IdsFilterCacheLayer } from "./filter-cache-layer"; import { FilterCacheLayer, IdsFilterCacheLayer } from "./filter-cache-layer";
import { trimFilters } from "./request-trim"; import { trimFilters } from "./request-trim";
@ -105,9 +105,17 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
} }
async #send(q: Query, qSend: BuiltRawReqFilter) { async #send(q: Query, qSend: BuiltRawReqFilter) {
if (qSend.strategy === RequestStrategy.CacheRelay && this.#system.cacheRelay) {
const qt = q.insertCompletedTrace(qSend, []);
const res = await this.#system.cacheRelay.query(["REQ", qt.id, ...qSend.filters]);
q.feed.add(res?.map(a => ({ ...a, relays: [] }) as TaggedNostrEvent));
return;
}
for (const qfl of this.#queryCacheLayers) { for (const qfl of this.#queryCacheLayers) {
qSend = await qfl.processFilter(q, qSend); qSend = await qfl.processFilter(q, qSend);
} }
// automated outbox model, load relays for queried authors
for (const f of qSend.filters) { for (const f of qSend.filters) {
if (f.authors) { if (f.authors) {
this.#system.relayLoader.TrackKeys(f.authors); this.#system.relayLoader.TrackKeys(f.authors);
@ -156,6 +164,13 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
} }
} }
/**
* Split request into 2 branches.
* 1. Request cache for results
* 2. Send query to relays
*/
#splitSyncRequest(req: BuiltRawReqFilter) {}
#cleanup() { #cleanup() {
let changed = false; let changed = false;
for (const [k, v] of this.#queries) { for (const [k, v] of this.#queries) {

View File

@ -323,15 +323,15 @@ export class Query extends EventEmitter<QueryEvents> {
} }
} }
#emitFilters() { async #emitFilters() {
this.#log("Starting emit of %s", this.id); this.#log("Starting emit of %s", this.id);
const existing = this.filters; const existing = this.filters;
if (!(this.request.options?.skipDiff ?? false) && existing.length > 0) { if (!(this.request.options?.skipDiff ?? false) && existing.length > 0) {
const filters = this.request.buildDiff(this.#system, existing); const filters = await this.request.buildDiff(this.#system, existing);
this.#log("Build %s %O", this.id, filters); this.#log("Build %s %O", this.id, filters);
filters.forEach(f => this.emit("request", this.id, f)); filters.forEach(f => this.emit("request", this.id, f));
} else { } else {
const filters = this.request.build(this.#system); const filters = await this.request.build(this.#system);
this.#log("Build %s %O", this.id, filters); this.#log("Build %s %O", this.id, filters);
filters.forEach(f => this.emit("request", this.id, f)); filters.forEach(f => this.emit("request", this.id, f));
} }

View File

@ -5,27 +5,33 @@ import { appendDedupe, dedupe, sanitizeRelayUrl, unixNowMs, unwrap } from "@snor
import EventKind from "./event-kind"; import EventKind from "./event-kind";
import { NostrLink, NostrPrefix, SystemInterface } from "."; import { NostrLink, NostrPrefix, SystemInterface } from ".";
import { ReqFilter, u256, HexKey } from "./nostr"; import { ReqFilter, u256, HexKey } from "./nostr";
import { RelayCache, splitByWriteRelays, splitFlatByWriteRelays } from "./outbox-model"; import { AuthorsRelaysCache, splitByWriteRelays, splitFlatByWriteRelays } from "./outbox-model";
import { CacheRelay } from "cache-relay";
/** /**
* Which strategy is used when building REQ filters * Which strategy is used when building REQ filters
*/ */
export enum RequestStrategy { export const enum RequestStrategy {
/** /**
* Use the users default relays to fetch events, * Use the users default relays to fetch events,
* this is the fallback option when there is no better way to query a given filter set * this is the fallback option when there is no better way to query a given filter set
*/ */
DefaultRelays = 1, DefaultRelays = "default",
/** /**
* Using a cached copy of the authors relay lists NIP-65, split a given set of request filters by pubkey * Using a cached copy of the authors relay lists NIP-65, split a given set of request filters by pubkey
*/ */
AuthorsRelays = 2, AuthorsRelays = "authors-relays",
/** /**
* Use pre-determined relays for query * Use pre-determined relays for query
*/ */
ExplicitRelays = 3, ExplicitRelays = "explicit-relays",
/**
* Query the cache relay
*/
CacheRelay = "cache-relay",
} }
/** /**
@ -121,21 +127,24 @@ export class RequestBuilder {
return this.#builders.map(f => f.filter); return this.#builders.map(f => f.filter);
} }
build(system: SystemInterface): Array<BuiltRawReqFilter> { async build(system: SystemInterface): Promise<Array<BuiltRawReqFilter>> {
const expanded = this.#builders.flatMap(a => a.build(system.relayCache, this.#options)); const expanded = (
await Promise.all(this.#builders.map(a => a.build(system.relayCache, system.cacheRelay, this.#options)))
).flat();
return this.#groupByRelay(system, expanded); return this.#groupByRelay(system, expanded);
} }
/** /**
* Detects a change in request from a previous set of filters * Detects a change in request from a previous set of filters
*/ */
buildDiff(system: SystemInterface, prev: Array<ReqFilter>): Array<BuiltRawReqFilter> { async buildDiff(system: SystemInterface, prev: Array<ReqFilter>): Promise<Array<BuiltRawReqFilter>> {
const start = unixNowMs(); const start = unixNowMs();
const diff = system.optimizer.getDiff(prev, this.buildRaw()); const diff = system.optimizer.getDiff(prev, this.buildRaw());
const ts = unixNowMs() - start; const ts = unixNowMs() - start;
this.#log("buildDiff %s %d ms +%d", this.id, ts, diff.length); this.#log("buildDiff %s %d ms +%d", this.id, ts, diff.length);
if (diff.length > 0) { if (diff.length > 0) {
// todo: fix
return splitFlatByWriteRelays(system.relayCache, diff).map(a => { return splitFlatByWriteRelays(system.relayCache, diff).map(a => {
return { return {
strategy: RequestStrategy.AuthorsRelays, strategy: RequestStrategy.AuthorsRelays,
@ -143,8 +152,6 @@ export class RequestBuilder {
relay: a.relay, relay: a.relay,
}; };
}); });
} else {
this.#log(`Wasted ${ts} ms detecting no changes!`);
} }
return []; return [];
} }
@ -284,12 +291,48 @@ export class RequestFilterBuilder {
/** /**
* Build/expand this filter into a set of relay specific queries * Build/expand this filter into a set of relay specific queries
*/ */
build(relays: RelayCache, options?: RequestBuilderOptions): Array<BuiltRawReqFilter> { async build(
relays: AuthorsRelaysCache,
cacheRelay?: CacheRelay,
options?: RequestBuilderOptions,
): Promise<Array<BuiltRawReqFilter>> {
// if since/until are set ignore sync split, cache relay wont be used
if (cacheRelay && this.#filter.since === undefined && this.#filter.until === undefined) {
const latest = await cacheRelay.query([
"REQ",
uuid(),
{
...this.#filter,
since: undefined,
until: undefined,
limit: 1,
},
]);
if (latest.length === 1) {
return [
...this.#buildFromFilter(relays, {
...this.#filter,
since: latest[0].created_at,
until: undefined,
limit: undefined,
}),
{
filters: [this.#filter],
relay: "==CACHE==",
strategy: RequestStrategy.CacheRelay,
},
];
}
}
return this.#buildFromFilter(relays, this.#filter, options);
}
#buildFromFilter(relays: AuthorsRelaysCache, f: ReqFilter, options?: RequestBuilderOptions) {
// use the explicit relay list first // use the explicit relay list first
if (this.#relays.size > 0) { if (this.#relays.size > 0) {
return [...this.#relays].map(r => { return [...this.#relays].map(r => {
return { return {
filters: [this.#filter], filters: [f],
relay: r, relay: r,
strategy: RequestStrategy.ExplicitRelays, strategy: RequestStrategy.ExplicitRelays,
}; };
@ -297,8 +340,8 @@ export class RequestFilterBuilder {
} }
// If any authors are set use the gossip model to fetch data for each author // If any authors are set use the gossip model to fetch data for each author
if (this.#filter.authors) { if (f.authors) {
const split = splitByWriteRelays(relays, this.#filter, options?.outboxPickN); const split = splitByWriteRelays(relays, f, options?.outboxPickN);
return split.map(a => { return split.map(a => {
return { return {
filters: [a.filter], filters: [a.filter],
@ -310,7 +353,7 @@ export class RequestFilterBuilder {
return [ return [
{ {
filters: [this.#filter], filters: [f],
relay: "", relay: "",
strategy: RequestStrategy.DefaultRelays, strategy: RequestStrategy.DefaultRelays,
}, },

View File

@ -26,6 +26,7 @@ import { EventsCache } from "../cache/events";
import { RelayMetricHandler } from "../relay-metric-handler"; import { RelayMetricHandler } from "../relay-metric-handler";
import debug from "debug"; import debug from "debug";
import { ConnectionPool } from "connection-pool"; import { ConnectionPool } from "connection-pool";
import { CacheRelay } from "cache-relay";
export class SystemWorker extends EventEmitter<NostrSystemEvents> implements SystemInterface { export class SystemWorker extends EventEmitter<NostrSystemEvents> implements SystemInterface {
#log = debug("SystemWorker"); #log = debug("SystemWorker");
@ -38,6 +39,7 @@ export class SystemWorker extends EventEmitter<NostrSystemEvents> implements Sys
readonly relayMetricsHandler: RelayMetricHandler; readonly relayMetricsHandler: RelayMetricHandler;
readonly eventsCache: CachedTable<NostrEvent>; readonly eventsCache: CachedTable<NostrEvent>;
readonly relayLoader: RelayMetadataLoader; readonly relayLoader: RelayMetadataLoader;
readonly cacheRelay: CacheRelay | undefined;
get checkSigs() { get checkSigs() {
return true; return true;

View File

@ -1,4 +1,4 @@
import { NostrEvent, ReqCommand, WorkerMessage, WorkerMessageCommand } from "./types"; import { NostrEvent, OkResponse, ReqCommand, ReqFilter, WorkerMessage, WorkerMessageCommand } from "./types";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
export class WorkerRelayInterface { export class WorkerRelayInterface {
@ -18,35 +18,31 @@ export class WorkerRelayInterface {
} }
async init(path: string) { async init(path: string) {
return (await this.#workerRpc<string, boolean>("init", path)).result; return await this.#workerRpc<string, boolean>("init", path);
} }
async event(ev: NostrEvent) { async event(ev: NostrEvent) {
return (await this.#workerRpc<NostrEvent, boolean>("event", ev)).result; return await this.#workerRpc<NostrEvent, OkResponse>("event", ev);
} }
async req(req: ReqCommand) { async query(req: ReqCommand) {
return await this.#workerRpc<ReqCommand, Array<NostrEvent>>("req", req); return await this.#workerRpc<ReqCommand, Array<NostrEvent>>("req", req);
} }
async count(req: ReqCommand) { async count(req: ReqCommand) {
return (await this.#workerRpc<ReqCommand, number>("count", req)).result; return await this.#workerRpc<ReqCommand, number>("count", req);
} }
async summary() { async summary() {
return (await this.#workerRpc<void, Record<string, number>>("summary")).result; return await this.#workerRpc<void, Record<string, number>>("summary");
} }
async close(id: string) { async close(id: string) {
return (await this.#workerRpc<string, boolean>("close", id)).result; return await this.#workerRpc<string, boolean>("close", id);
} }
async dump() { async dump() {
return (await this.#workerRpc<void, Uint8Array>("dumpDb")).result; return await this.#workerRpc<void, Uint8Array>("dumpDb");
}
async sql(sql: string, params: Array<string | number>) {
return (await this.#workerRpc<object, Array<Array<any>>>("sql", { sql, params })).result;
} }
#workerRpc<T, R>(cmd: WorkerMessageCommand, args?: T) { #workerRpc<T, R>(cmd: WorkerMessageCommand, args?: T) {
@ -57,16 +53,10 @@ export class WorkerRelayInterface {
args, args,
} as WorkerMessage<T>; } as WorkerMessage<T>;
this.#worker.postMessage(msg); this.#worker.postMessage(msg);
return new Promise<{ return new Promise<R>(resolve => {
result: R;
port: MessagePort | undefined;
}>(resolve => {
this.#commandQueue.set(id, (v, port) => { this.#commandQueue.set(id, (v, port) => {
const cmdReply = v as WorkerMessage<R>; const cmdReply = v as WorkerMessage<R>;
resolve({ resolve(cmdReply.args);
result: cmdReply.args,
port: port.length > 0 ? port[0] : undefined,
});
}); });
}); });
} }

View File

@ -69,7 +69,11 @@ export class InMemoryRelay extends EventEmitter<RelayHandlerEvents> implements R
const ret = []; const ret = [];
for (const [, e] of this.#events) { for (const [, e] of this.#events) {
if (eventMatchesFilter(e, filter)) { if (eventMatchesFilter(e, filter)) {
ret.push(e); if (filter.ids_only === true) {
ret.push(e.id);
} else {
ret.push(e);
}
} }
} }
return ret; return ret;

View File

@ -184,7 +184,13 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
const [sql, params] = this.#buildQuery(req); const [sql, params] = this.#buildQuery(req);
const res = this.#db?.selectArrays(sql, params); const res = this.#db?.selectArrays(sql, params);
const results = res?.map(a => JSON.parse(a[0] as string) as NostrEvent) ?? []; const results =
res?.map(a => {
if (req.ids_only === true) {
return a[0] as string;
}
return JSON.parse(a[0] as string) as NostrEvent;
}) ?? [];
const time = unixNowMs() - start; const time = unixNowMs() - start;
this.#log(`Query ${id} results took ${time.toLocaleString()}ms`); this.#log(`Query ${id} results took ${time.toLocaleString()}ms`);
return results; return results;
@ -245,7 +251,13 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
const conditions: Array<string> = []; const conditions: Array<string> = [];
const params: Array<any> = []; const params: Array<any> = [];
let sql = `select ${count ? "count(json)" : "json"} from events`; let resultType = "json";
if (count) {
resultType = "count(json)";
} else if (req.ids_only === true) {
resultType = "id";
}
let sql = `select ${resultType} from events`;
const tags = Object.entries(req).filter(([k]) => k.startsWith("#")); const tags = Object.entries(req).filter(([k]) => k.startsWith("#"));
for (const [key, values] of tags) { for (const [key, values] of tags) {
const vArray = values as Array<string>; const vArray = values as Array<string>;

View File

@ -9,7 +9,7 @@ export type WorkerMessageCommand =
| "summary" | "summary"
| "close" | "close"
| "dumpDb" | "dumpDb"
| "sql"; | "emit-event";
export interface WorkerMessage<T> { export interface WorkerMessage<T> {
id: string; id: string;
@ -27,11 +27,7 @@ export interface NostrEvent {
sig: string; sig: string;
} }
export interface ReqCommand { export type ReqCommand = ["REQ", id: string, ...filters: Array<ReqFilter>];
id: string;
filters: Array<ReqFilter>;
leaveOpen?: boolean;
}
export interface ReqFilter { export interface ReqFilter {
ids?: string[]; ids?: string[];
@ -41,8 +37,16 @@ export interface ReqFilter {
since?: number; since?: number;
until?: number; until?: number;
limit?: number; limit?: number;
not?: ReqFilter; ids_only?: boolean;
[key: string]: Array<string> | Array<number> | string | number | undefined | ReqFilter; [key: string]: Array<string> | Array<number> | string | number | undefined | boolean;
}
export interface OkResponse {
ok: boolean;
id: string;
relay: string;
message?: string;
event: NostrEvent;
} }
export interface RelayHandler extends EventEmitter<RelayHandlerEvents> { export interface RelayHandler extends EventEmitter<RelayHandlerEvents> {
@ -55,7 +59,7 @@ export interface RelayHandler extends EventEmitter<RelayHandlerEvents> {
* Run any SQL command * Run any SQL command
*/ */
sql(sql: string, params: Array<string | number>): Array<Array<string | number>>; sql(sql: string, params: Array<string | number>): Array<Array<string | number>>;
req(id: string, req: ReqFilter): Array<NostrEvent>; req(id: string, req: ReqFilter): Array<NostrEvent | string>;
count(req: ReqFilter): number; count(req: ReqFilter): number;
summary(): Record<string, number>; summary(): Record<string, number>;
dump(): Promise<Uint8Array>; dump(): Promise<Uint8Array>;

View File

@ -15,15 +15,12 @@ const ActiveSubscriptions = new Map<string, PortedFilter>();
let relay: RelayHandler | undefined; let relay: RelayHandler | undefined;
async function reply<T>(id: string, obj?: T, transferables?: Transferable[]) { async function reply<T>(id: string, obj?: T) {
globalThis.postMessage( globalThis.postMessage({
{ id,
id, cmd: "reply",
cmd: "reply", args: obj,
args: obj, } as WorkerMessage<T>);
} as WorkerMessage<T>,
transferables ?? [],
);
} }
// Event inserter queue // Event inserter queue
@ -108,25 +105,18 @@ globalThis.onmessage = async ev => {
break; break;
} }
case "close": { case "close": {
ActiveSubscriptions.delete(msg.args as string);
reply(msg.id, true); reply(msg.id, true);
break; break;
} }
case "req": { case "req": {
await barrierQueue(cmdQueue, async () => { await barrierQueue(cmdQueue, async () => {
const req = msg.args as ReqCommand; const req = msg.args as ReqCommand;
const chan = new MessageChannel(); const filters = req.slice(2) as Array<ReqFilter>;
if (req.leaveOpen) {
ActiveSubscriptions.set(req.id, {
filters: req.filters,
port: chan.port1,
});
}
const results = []; const results = [];
for (const r of req.filters) { for (const r of filters) {
results.push(...relay!.req(req.id, r as ReqFilter)); results.push(...relay!.req(req[1], r));
} }
reply(msg.id, results, req.leaveOpen ? [chan.port2] : undefined); reply(msg.id, results);
}); });
break; break;
} }
@ -134,8 +124,9 @@ globalThis.onmessage = async ev => {
await barrierQueue(cmdQueue, async () => { await barrierQueue(cmdQueue, async () => {
const req = msg.args as ReqCommand; const req = msg.args as ReqCommand;
let results = 0; let results = 0;
for (const r of req.filters) { const filters = req.slice(2) as Array<ReqFilter>;
const c = relay!.count(r as ReqFilter); for (const r of filters) {
const c = relay!.count(r);
results += c; results += c;
} }
reply(msg.id, results); reply(msg.id, results);
@ -156,17 +147,6 @@ globalThis.onmessage = async ev => {
}); });
break; break;
} }
case "sql": {
await barrierQueue(cmdQueue, async () => {
const req = msg.args as {
sql: string;
params: Array<any>;
};
const res = relay!.sql(req.sql, req.params);
reply(msg.id, res);
});
break;
}
default: { default: {
reply(msg.id, { error: "Unknown command" }); reply(msg.id, { error: "Unknown command" });
break; break;