feat: track event seen on relays

fix: dump/clear commands
This commit is contained in:
2024-09-16 10:55:15 +01:00
parent b49144399c
commit 21e88b06cb
13 changed files with 141 additions and 54 deletions

View File

@ -1,6 +1,6 @@
import "./ReactionsModal.css"; import "./ReactionsModal.css";
import { NostrLink, socialGraphInstance, TaggedNostrEvent } from "@snort/system"; import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react"; import { useEventReactions, useReactions } from "@snort/system-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FormattedMessage, MessageDescriptor, useIntl } from "react-intl"; import { FormattedMessage, MessageDescriptor, useIntl } from "react-intl";
@ -11,6 +11,7 @@ import Modal from "@/Components/Modal/Modal";
import TabSelectors, { Tab } from "@/Components/TabSelectors/TabSelectors"; import TabSelectors, { Tab } from "@/Components/TabSelectors/TabSelectors";
import ProfileImage from "@/Components/User/ProfileImage"; import ProfileImage from "@/Components/User/ProfileImage";
import ZapAmount from "@/Components/zap-amount"; import ZapAmount from "@/Components/zap-amount";
import useWoT from "@/Hooks/useWoT";
import messages from "../../messages"; import messages from "../../messages";
@ -29,10 +30,7 @@ const ReactionsModal = ({ onClose, event, initialTab = 0 }: ReactionsModalProps)
const { reactions, zaps, reposts } = useEventReactions(link, related); const { reactions, zaps, reposts } = useEventReactions(link, related);
const { positive, negative } = reactions; const { positive, negative } = reactions;
const sortEvents = (events: Array<TaggedNostrEvent>) => const { sortEvents } = useWoT();
events.sort(
(a, b) => socialGraphInstance.getFollowDistance(a.pubkey) - socialGraphInstance.getFollowDistance(b.pubkey),
);
const likes = useMemo(() => sortEvents([...positive]), [positive]); const likes = useMemo(() => sortEvents([...positive]), [positive]);
const dislikes = useMemo(() => sortEvents([...negative]), [negative]); const dislikes = useMemo(() => sortEvents([...negative]), [negative]);

View File

@ -1,7 +1,7 @@
import "./Timeline.css"; import "./Timeline.css";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { socialGraphInstance, TaggedNostrEvent } from "@snort/system"; import { TaggedNostrEvent } from "@snort/system";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector"; import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
@ -9,6 +9,7 @@ import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineFeed"; import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineFeed";
import useHistoryState from "@/Hooks/useHistoryState"; import useHistoryState from "@/Hooks/useHistoryState";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useWoT from "@/Hooks/useWoT";
import { dedupeByPubkey } from "@/Utils"; import { dedupeByPubkey } from "@/Utils";
export interface TimelineProps { export interface TimelineProps {
@ -41,6 +42,7 @@ const Timeline = (props: TimelineProps) => {
const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions); const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions);
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list"; const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial); const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const wot = useWoT();
const filterPosts = useCallback( const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => { (nts: readonly TaggedNostrEvent[]) => {
@ -48,7 +50,7 @@ const Timeline = (props: TimelineProps) => {
if (props.followDistance === undefined) { if (props.followDistance === undefined) {
return true; return true;
} }
const followDistance = socialGraphInstance.getFollowDistance(a.pubkey); const followDistance = wot.followDistance(a.pubkey);
return followDistance === props.followDistance; return followDistance === props.followDistance;
}; };
return nts return nts

View File

@ -0,0 +1,11 @@
import { TaggedNostrEvent } from "@snort/system";
import { socialGraphInstance } from "@snort/system/dist/SocialGraph/SocialGraph";
export default function useWoT() {
const sg = socialGraphInstance;
return {
sortEvents: (events: Array<TaggedNostrEvent>) =>
events.sort((a, b) => sg.getFollowDistance(a.pubkey) - sg.getFollowDistance(b.pubkey)),
followDistance: sg.getFollowDistance,
};
}

View File

@ -123,24 +123,35 @@ function RelayCacheStats() {
</table> </table>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<AsyncButton onClick={() => {}}> {Relay instanceof WorkerRelayInterface && (
<FormattedMessage defaultMessage="Clear" /> <>
</AsyncButton> <AsyncButton
<AsyncButton onClick={async () => {
onClick={async () => { if (Relay instanceof WorkerRelayInterface) {
const data = new Uint8Array(); await Relay.wipe();
const url = URL.createObjectURL( }
new File([data], "snort.db", { }}>
type: "application/octet-stream", <FormattedMessage defaultMessage="Clear" />
}), </AsyncButton>
); <AsyncButton
const a = document.createElement("a"); onClick={async () => {
a.href = url; const data = Relay instanceof WorkerRelayInterface ? await Relay.dump() : undefined;
a.download = "snort.db"; if (data) {
a.click(); const url = URL.createObjectURL(
}}> new File([data], "snort.db", {
<FormattedMessage defaultMessage="Dump" /> type: "application/octet-stream",
</AsyncButton> }),
);
const a = document.createElement("a");
a.href = url;
a.download = "snort.db";
a.click();
}
}}>
<FormattedMessage defaultMessage="Dump" />
</AsyncButton>
</>
)}
<AsyncButton onClick={() => navigate("/cache-debug")}> <AsyncButton onClick={() => navigate("/cache-debug")}>
<FormattedMessage defaultMessage="Debug" /> <FormattedMessage defaultMessage="Debug" />
</AsyncButton> </AsyncButton>

View File

@ -1,4 +1,4 @@
import { NostrEvent, OkResponse, ReqCommand } from "./nostr"; import { OkResponse, ReqCommand, TaggedNostrEvent } from "./nostr";
/** /**
* A cache relay is an always available local (local network / browser worker) relay * A cache relay is an always available local (local network / browser worker) relay
@ -8,12 +8,12 @@ export interface CacheRelay {
/** /**
* Write event to cache relay * Write event to cache relay
*/ */
event(ev: NostrEvent): Promise<OkResponse>; event(ev: TaggedNostrEvent): Promise<OkResponse>;
/** /**
* Read event from cache relay * Read event from cache relay
*/ */
query(req: ReqCommand): Promise<Array<NostrEvent>>; query(req: ReqCommand): Promise<Array<TaggedNostrEvent>>;
/** /**
* Delete events by filter * Delete events by filter

View File

@ -107,8 +107,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
// fetch results from cache first, flag qSend for sync // fetch results from cache first, flag qSend for sync
if (this.#system.cacheRelay) { if (this.#system.cacheRelay) {
const data = await this.#system.cacheRelay.query(["REQ", q.id, ...filters]); const data = await this.#system.cacheRelay.query(["REQ", q.id, ...filters]);
syncFrom = data;
if (data.length > 0) { if (data.length > 0) {
syncFrom = data.map(a => ({ ...a, relays: [] }));
this.#log("Adding from cache %s %O", q.id, data); this.#log("Adding from cache %s %O", q.id, data);
q.feed.add(syncFrom); q.feed.add(syncFrom);
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@snort/worker-relay", "name": "@snort/worker-relay",
"version": "1.2.0", "version": "1.3.0",
"description": "A nostr relay in a service worker", "description": "A nostr relay in a service worker",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -79,6 +79,10 @@ export class WorkerRelayInterface {
return await this.#workerRpc<void, Uint8Array>("dumpDb"); return await this.#workerRpc<void, Uint8Array>("dumpDb");
} }
async wipe() {
return await this.#workerRpc<void, boolean>("wipe");
}
async forYouFeed(pubkey: string) { async forYouFeed(pubkey: string) {
return await this.#workerRpc<string, Array<NostrEvent>>("forYouFeed", pubkey); return await this.#workerRpc<string, Array<NostrEvent>>("forYouFeed", pubkey);
} }

View File

@ -34,13 +34,19 @@ export class InMemoryRelay extends EventEmitter<RelayHandlerEvents> implements R
} }
dump(): Promise<Uint8Array> { dump(): Promise<Uint8Array> {
return Promise.resolve(new Uint8Array()); const enc = new TextEncoder();
return Promise.resolve(enc.encode(JSON.stringify(this.#events.values())));
} }
close(): void { close(): void {
// nothing // nothing
} }
wipe() {
this.#events = new Map();
return Promise.resolve();
}
event(ev: NostrEvent) { event(ev: NostrEvent) {
if (this.#events.has(ev.id)) return false; if (this.#events.has(ev.id)) return false;
this.#events.set(ev.id, ev); this.#events.set(ev.id, ev);

View File

@ -10,6 +10,7 @@ const migrations = [
{ version: 3, script: migrate_v3 }, { version: 3, script: migrate_v3 },
{ version: 4, script: migrate_v4 }, { version: 4, script: migrate_v4 },
{ version: 5, script: migrate_v5 }, { version: 5, script: migrate_v5 },
{ version: 6, script: migrate_v6 },
]; ];
async function migrate(relay: SqliteRelay) { async function migrate(relay: SqliteRelay) {
@ -103,4 +104,13 @@ async function migrate_v5(relay: SqliteRelay) {
}); });
} }
async function migrate_v6(relay: SqliteRelay) {
relay.db?.transaction(db => {
db.exec("ALTER TABLE events ADD COLUMN relays TEXT");
db.exec("insert into __migration values(6, ?)", {
bind: [new Date().getTime() / 1000],
});
});
}
export default migrate; export default migrate;

View File

@ -1,4 +1,4 @@
import sqlite3InitModule, { Database, Sqlite3Static } from "@sqlite.org/sqlite-wasm"; import sqlite3InitModule, { Database, SAHPoolUtil, Sqlite3Static } from "@sqlite.org/sqlite-wasm";
import { EventEmitter } from "eventemitter3"; import { EventEmitter } from "eventemitter3";
import { EventMetadata, NostrEvent, RelayHandler, RelayHandlerEvents, ReqFilter, unixNowMs } from "../types"; import { EventMetadata, NostrEvent, RelayHandler, RelayHandlerEvents, ReqFilter, unixNowMs } from "../types";
import migrate from "./migrations"; import migrate from "./migrations";
@ -12,6 +12,7 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
#sqlite?: Sqlite3Static; #sqlite?: Sqlite3Static;
#log = (msg: string, ...args: Array<any>) => debugLog("SqliteRelay", msg, ...args); #log = (msg: string, ...args: Array<any>) => debugLog("SqliteRelay", msg, ...args);
db?: Database; db?: Database;
#pool?: SAHPoolUtil;
#seenInserts = new Set<string>(); #seenInserts = new Set<string>();
/** /**
@ -45,8 +46,8 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
if (!this.#sqlite) throw new Error("Must call init first"); if (!this.#sqlite) throw new Error("Must call init first");
if (this.db) return; if (this.db) return;
const pool = await this.#sqlite.installOpfsSAHPoolVfs({}); this.#pool = await this.#sqlite.installOpfsSAHPoolVfs({});
this.db = new pool.OpfsSAHPoolDb(path); this.db = new this.#pool.OpfsSAHPoolDb(path);
this.#log(`Opened ${this.db.filename}`); this.#log(`Opened ${this.db.filename}`);
/*this.db.exec( /*this.db.exec(
`PRAGMA cache_size=${32 * 1024 `PRAGMA cache_size=${32 * 1024
@ -54,6 +55,19 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
);*/ );*/
} }
/**
* Delete all data
*/
async wipe() {
if (this.#pool && this.db) {
const dbName = this.db.filename;
this.close();
await this.#pool.wipeFiles();
await this.#open(dbName);
await migrate(this);
}
}
close() { close() {
this.db?.close(); this.db?.close();
this.db = undefined; this.db = undefined;
@ -157,8 +171,15 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
this.#deleteById(db, oldEvents); this.#deleteById(db, oldEvents);
} }
} }
db.exec("insert or ignore into events(id, pubkey, created, kind, json) values(?,?,?,?,?)", {
bind: [ev.id, ev.pubkey, ev.created_at, ev.kind, JSON.stringify(ev)], // remove relays from event json
const evInsert = {
...ev,
} as NostrEvent;
delete evInsert["relays"];
db.exec("insert or ignore into events(id, pubkey, created, kind, json, relays) values(?,?,?,?,?,?)", {
bind: [ev.id, ev.pubkey, ev.created_at, ev.kind, JSON.stringify(evInsert), (ev.relays ?? []).join(",")],
}); });
const insertedEvents = db.changes(); const insertedEvents = db.changes();
if (insertedEvents > 0) { if (insertedEvents > 0) {
@ -169,12 +190,33 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
} }
this.insertIntoSearchIndex(db, ev); this.insertIntoSearchIndex(db, ev);
} else { } else {
this.#updateRelays(db, ev);
return 0; return 0;
} }
this.#seenInserts.add(ev.id); this.#seenInserts.add(ev.id);
return insertedEvents; return insertedEvents;
} }
/**
* Append relays
*/
#updateRelays(db: Database, ev: NostrEvent) {
const relays = db.selectArrays("select relays from events where id = ?", [ev.id]);
const oldRelays = new Set((relays?.at(0)?.at(0) as string | null)?.split(",") ?? []);
let hasNew = false;
for (const r of ev.relays ?? []) {
if (!oldRelays.has(r)) {
oldRelays.add(r);
hasNew = true;
}
}
if (hasNew) {
db.exec("update events set relays = ? where id = ?", {
bind: [[...oldRelays].join(","), ev.id],
});
}
}
/** /**
* Query relay by nostr filter * Query relay by nostr filter
*/ */
@ -188,7 +230,11 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
if (req.ids_only === true) { if (req.ids_only === true) {
return a[0] as string; return a[0] as string;
} }
return JSON.parse(a[0] as string) as NostrEvent; const ev = JSON.parse(a[0] as string) as NostrEvent;
return {
...ev,
relays: (a[1] as string | null)?.split(","),
};
}) ?? []; }) ?? [];
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`);
@ -252,22 +298,14 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
*/ */
async dump() { async dump() {
const filePath = String(this.db?.filename ?? ""); const filePath = String(this.db?.filename ?? "");
try { if (this.db && this.#pool) {
this.db?.close(); try {
this.db = undefined; return await this.#pool.exportFile(`/${filePath}`);
const dir = await navigator.storage.getDirectory(); } catch (e) {
// @ts-expect-error console.error(e);
for await (const [name, file] of dir) { } finally {
if (`/${name}` === filePath) { await this.#open(filePath);
const fh = await (file as FileSystemFileHandle).getFile();
const ret = new Uint8Array(await fh.arrayBuffer());
return ret;
}
} }
} catch (e) {
console.error(e);
} finally {
await this.#open(filePath);
} }
return new Uint8Array(); return new Uint8Array();
} }
@ -276,7 +314,7 @@ 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 resultType = "json"; let resultType = "json,relays";
if (count) { if (count) {
resultType = "count(json)"; resultType = "count(json)";
} else if (req.ids_only === true) { } else if (req.ids_only === true) {

View File

@ -13,7 +13,8 @@ export type WorkerMessageCommand =
| "forYouFeed" | "forYouFeed"
| "setEventMetadata" | "setEventMetadata"
| "debug" | "debug"
| "delete"; | "delete"
| "wipe";
export interface WorkerMessage<T> { export interface WorkerMessage<T> {
id: string; id: string;
@ -73,6 +74,7 @@ export interface RelayHandler extends EventEmitter<RelayHandlerEvents> {
dump(): Promise<Uint8Array>; dump(): Promise<Uint8Array>;
delete(req: ReqFilter): Array<string>; delete(req: ReqFilter): Array<string>;
setEventMetadata(id: string, meta: EventMetadata): void; setEventMetadata(id: string, meta: EventMetadata): void;
wipe(): Promise<void>;
} }
export interface RelayHandlerEvents { export interface RelayHandlerEvents {

View File

@ -154,6 +154,11 @@ const handleMsg = async (port: MessagePort | DedicatedWorkerGlobalScope, ev: Mes
reply(msg.id, res); reply(msg.id, res);
break; break;
} }
case "wipe": {
await relay!.wipe();
reply(msg.id, true);
break;
}
case "forYouFeed": { case "forYouFeed": {
const res = await getForYouFeed(relay!, msg.args as string); const res = await getForYouFeed(relay!, msg.args as string);
reply(msg.id, res); reply(msg.id, res);