Compare commits
5 Commits
c5e534a730
...
aefe8a8210
Author | SHA1 | Date | |
---|---|---|---|
aefe8a8210 | |||
a97e895cb8 | |||
7ceab04cbc | |||
5bc3c10d36 | |||
351a249a32 |
@ -1,11 +1,12 @@
|
||||
import { EventKind, NostrLink } from "@snort/system";
|
||||
import classNames from "classnames";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LRUCache } from "typescript-lru-cache";
|
||||
|
||||
import { Relay } from "@/Cache";
|
||||
import NoteHeader from "@/Components/Event/Note/NoteHeader";
|
||||
import { NoteText } from "@/Components/Event/Note/NoteText";
|
||||
import { TranslationInfo } from "@/Components/Event/Note/TranslationInfo";
|
||||
@ -38,6 +39,7 @@ export function Note(props: NoteProps) {
|
||||
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className ?? "");
|
||||
const { isEventMuted } = useModeration();
|
||||
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
|
||||
const { ref: setSeenAtRef, inView: setSeenAtInView } = useInView({ rootMargin: "0px", threshold: 1 });
|
||||
const [showTranslation, setShowTranslation] = useState(true);
|
||||
const [translated, setTranslated] = useState<NoteTranslation>(translationCache.get(ev.id));
|
||||
const cachedSetTranslated = useCallback(
|
||||
@ -48,6 +50,16 @@ export function Note(props: NoteProps) {
|
||||
[ev.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
if (setSeenAtInView) {
|
||||
timeout = setTimeout(() => {
|
||||
Relay.setEventMetadata(ev.id, { seen_at: Math.round(Date.now() / 1000) });
|
||||
}, 2000);
|
||||
}
|
||||
return () => clearTimeout(timeout);
|
||||
}, [setSeenAtInView]);
|
||||
|
||||
const optionsMerged = { ...defaultOptions, ...opt };
|
||||
const goToEvent = useGoToEvent(props, optionsMerged);
|
||||
|
||||
@ -76,6 +88,7 @@ export function Note(props: NoteProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={setSeenAtRef} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,16 +1,15 @@
|
||||
import { EventKind, TaggedNostrEvent } from "@snort/system";
|
||||
import { EventKind, NostrEvent } from "@snort/system";
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Relay } from "@/Cache";
|
||||
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
|
||||
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
|
||||
import { TaskList } from "@/Components/Tasks/TaskList";
|
||||
import { getForYouFeed } from "@/Db/getForYouFeed";
|
||||
import useTimelineFeed, { TimelineFeedOptions, TimelineSubject } from "@/Feed/TimelineFeed";
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import messages from "@/Pages/messages";
|
||||
import { System } from "@/system";
|
||||
|
||||
const FollowsHint = () => {
|
||||
const { publicKey: pubKey, follows } = useLogin();
|
||||
@ -32,14 +31,14 @@ const FollowsHint = () => {
|
||||
};
|
||||
|
||||
let forYouFeed = {
|
||||
events: [] as TaggedNostrEvent[],
|
||||
events: [] as NostrEvent[],
|
||||
created_at: 0,
|
||||
};
|
||||
|
||||
let getForYouFeedPromise: Promise<TaggedNostrEvent[]> | null = null;
|
||||
let getForYouFeedPromise: Promise<NostrEvent[]> | null = null;
|
||||
|
||||
export const ForYouTab = memo(function ForYouTab() {
|
||||
const [notes, setNotes] = useState<TaggedNostrEvent[]>(forYouFeed.events);
|
||||
const [notes, setNotes] = useState<NostrEvent[]>(forYouFeed.events);
|
||||
const { feedDisplayAs } = useLogin();
|
||||
const displayAsInitial = feedDisplayAs ?? "list";
|
||||
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
||||
@ -64,7 +63,7 @@ export const ForYouTab = memo(function ForYouTab() {
|
||||
const latestFeed = useTimelineFeed(subject, { method: "TIME_RANGE" } as TimelineFeedOptions);
|
||||
const filteredLatestFeed = useMemo(() => {
|
||||
// no replies
|
||||
return latestFeed.main?.filter((ev: TaggedNostrEvent) => !ev.tags.some((tag: string[]) => tag[0] === "e")) ?? [];
|
||||
return latestFeed.main?.filter((ev: NostrEvent) => !ev.tags.some((tag: string[]) => tag[0] === "e")) ?? [];
|
||||
}, [latestFeed.main]);
|
||||
|
||||
const getFeed = () => {
|
||||
@ -72,13 +71,13 @@ export const ForYouTab = memo(function ForYouTab() {
|
||||
return [];
|
||||
}
|
||||
if (!getForYouFeedPromise) {
|
||||
getForYouFeedPromise = getForYouFeed(publicKey);
|
||||
getForYouFeedPromise = Relay.forYouFeed(publicKey);
|
||||
}
|
||||
getForYouFeedPromise!.then(notes => {
|
||||
getForYouFeedPromise = null;
|
||||
if (notes.length < 10) {
|
||||
setTimeout(() => {
|
||||
getForYouFeed(publicKey);
|
||||
getForYouFeedPromise = Relay.forYouFeed(publicKey);
|
||||
}, 1000);
|
||||
}
|
||||
forYouFeed = {
|
||||
@ -86,11 +85,6 @@ export const ForYouTab = memo(function ForYouTab() {
|
||||
created_at: Date.now(),
|
||||
};
|
||||
setNotes(notes);
|
||||
notes.forEach(note => {
|
||||
queueMicrotask(() => {
|
||||
System.HandleEvent(note);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
2
packages/system/src/cache/events.ts
vendored
2
packages/system/src/cache/events.ts
vendored
@ -1,4 +1,4 @@
|
||||
import { NostrEvent } from "nostr";
|
||||
import { NostrEvent } from "../nostr";
|
||||
import { DexieTableLike, FeedCache } from "@snort/shared";
|
||||
|
||||
export class EventsCache extends FeedCache<NostrEvent> {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ReqFilter } from "nostr";
|
||||
import { ReqFilter } from "./nostr";
|
||||
|
||||
/**
|
||||
* Remove empty filters, filters which would result in no results
|
||||
|
@ -24,8 +24,8 @@ import { CachedTable } from "@snort/shared";
|
||||
import { EventsCache } from "../cache/events";
|
||||
import { RelayMetricHandler } from "../relay-metric-handler";
|
||||
import debug from "debug";
|
||||
import { ConnectionPool } from "connection-pool";
|
||||
import { CacheRelay } from "cache-relay";
|
||||
import { ConnectionPool } from "../connection-pool";
|
||||
import { CacheRelay } from "../cache-relay";
|
||||
|
||||
export class SystemWorker extends EventEmitter<NostrSystemEvents> implements SystemInterface {
|
||||
#log = debug("SystemWorker");
|
||||
|
@ -4,9 +4,10 @@ import { findTag } from "./utils";
|
||||
import { EventExt } from "./event-ext";
|
||||
import { NostrLink } from "./nostr-link";
|
||||
import debug from "debug";
|
||||
import {LRUCache} from "lru-cache";
|
||||
|
||||
const Log = debug("zaps");
|
||||
const ParsedZapCache = new Map<string, ParsedZap>();
|
||||
const ParsedZapCache = new LRUCache<string, ParsedZap>({ max: 1000 });
|
||||
|
||||
function getInvoice(zap: NostrEvent): InvoiceDetails | undefined {
|
||||
const bolt11 = findTag(zap, "bolt11");
|
||||
|
@ -1,49 +1,50 @@
|
||||
import { NostrEvent, parseZap } from "@snort/system";
|
||||
import debug from "debug";
|
||||
|
||||
import { Relay } from "@/Cache";
|
||||
import { RelayHandler, NostrEvent } from "./types";
|
||||
|
||||
// import { parseZap } from "../../system/src/zaps";
|
||||
// placeholder:
|
||||
const parseZap = (_zap: NostrEvent) => {
|
||||
return { event: null } as { event: null | NostrEvent };
|
||||
}
|
||||
|
||||
const log = debug("getForYouFeed");
|
||||
|
||||
export async function getForYouFeed(pubkey: string): Promise<NostrEvent[]> {
|
||||
export async function getForYouFeed(relay: RelayHandler, pubkey: string): Promise<NostrEvent[]> {
|
||||
console.time("For You feed generation time");
|
||||
|
||||
log("pubkey", pubkey);
|
||||
|
||||
// Get events reacted to by me
|
||||
const myReactedEventIds = await getMyReactedEvents(pubkey);
|
||||
const myReactedEventIds = await getMyReactedEvents(relay, pubkey);
|
||||
log("my reacted events", myReactedEventIds);
|
||||
|
||||
const myReactedAuthors = await getMyReactedAuthors(myReactedEventIds, pubkey);
|
||||
const myReactedAuthors = await getMyReactedAuthors(relay, myReactedEventIds, pubkey);
|
||||
log("my reacted authors", myReactedAuthors);
|
||||
|
||||
// Get others who reacted to the same events as me
|
||||
const othersWhoReacted = await getOthersWhoReacted(myReactedEventIds, pubkey);
|
||||
const othersWhoReacted = await getOthersWhoReacted(relay, myReactedEventIds, pubkey);
|
||||
// this tends to be small when the user has just logged in, we should maybe subscribe for more from relays
|
||||
log("others who reacted", othersWhoReacted);
|
||||
|
||||
// Get event ids reacted to by those others
|
||||
const reactedByOthers = await getEventIdsReactedByOthers(othersWhoReacted, myReactedEventIds, pubkey);
|
||||
const reactedByOthers = await getEventIdsReactedByOthers(relay, othersWhoReacted, myReactedEventIds, pubkey);
|
||||
log("reacted by others", reactedByOthers);
|
||||
|
||||
// Get full events in sorted order
|
||||
const feed = await getFeedEvents(reactedByOthers, myReactedAuthors);
|
||||
const feed = await getFeedEvents(relay, reactedByOthers, myReactedAuthors);
|
||||
log("feed.length", feed.length);
|
||||
|
||||
console.timeEnd("For You feed generation time");
|
||||
return feed;
|
||||
}
|
||||
|
||||
async function getMyReactedAuthors(myReactedEventIds: Set<string>, myPubkey: string) {
|
||||
async function getMyReactedAuthors(relay: RelayHandler, myReactedEventIds: Set<string>, myPubkey: string) {
|
||||
const myReactedAuthors = new Map<string, number>();
|
||||
|
||||
const myReactions = await Relay.query([
|
||||
"REQ",
|
||||
"getMyReactedAuthors",
|
||||
{
|
||||
const myReactions = relay.req("getMyReactedAuthors", {
|
||||
"#e": Array.from(myReactedEventIds),
|
||||
},
|
||||
]);
|
||||
}) as NostrEvent[];
|
||||
|
||||
myReactions.forEach(reaction => {
|
||||
if (reaction.pubkey !== myPubkey) {
|
||||
@ -54,19 +55,15 @@ async function getMyReactedAuthors(myReactedEventIds: Set<string>, myPubkey: str
|
||||
return myReactedAuthors;
|
||||
}
|
||||
|
||||
async function getMyReactedEvents(pubkey: string) {
|
||||
async function getMyReactedEvents(relay: RelayHandler, pubkey: string) {
|
||||
const myReactedEventIds = new Set<string>();
|
||||
|
||||
const myEvents = await Relay.query([
|
||||
"REQ",
|
||||
"getMyReactedEventIds",
|
||||
{
|
||||
const myEvents = relay.req("getMyReactedEventIds", {
|
||||
authors: [pubkey],
|
||||
kinds: [1, 6, 7, 9735],
|
||||
},
|
||||
]);
|
||||
}) as NostrEvent[];
|
||||
myEvents.forEach(ev => {
|
||||
const targetEventId = ev.kind === 9735 ? parseZap(ev).event?.id : ev.tags.find(tag => tag[0] === "e")?.[1];
|
||||
const targetEventId = ev.kind === 9735 ? parseZap(ev).event?.id : ev.tags.find((tag: string[]) => tag[0] === "e")?.[1];
|
||||
if (targetEventId) {
|
||||
myReactedEventIds.add(targetEventId);
|
||||
}
|
||||
@ -75,16 +72,12 @@ async function getMyReactedEvents(pubkey: string) {
|
||||
return myReactedEventIds;
|
||||
}
|
||||
|
||||
async function getOthersWhoReacted(myReactedEventIds: Set<string>, myPubkey: string) {
|
||||
async function getOthersWhoReacted(relay: RelayHandler, myReactedEventIds: Set<string>, myPubkey: string) {
|
||||
const othersWhoReacted = new Map<string, number>();
|
||||
|
||||
const otherReactions = await Relay.query([
|
||||
"REQ",
|
||||
"getOthersWhoReacted",
|
||||
{
|
||||
const otherReactions = relay.req("getOthersWhoReacted", {
|
||||
"#e": Array.from(myReactedEventIds),
|
||||
},
|
||||
]);
|
||||
}) as NostrEvent[];
|
||||
|
||||
otherReactions.forEach(reaction => {
|
||||
if (reaction.pubkey !== myPubkey) {
|
||||
@ -96,27 +89,24 @@ async function getOthersWhoReacted(myReactedEventIds: Set<string>, myPubkey: str
|
||||
}
|
||||
|
||||
async function getEventIdsReactedByOthers(
|
||||
relay: RelayHandler,
|
||||
othersWhoReacted: Map<string, number>,
|
||||
myReactedEvents: Set<string>,
|
||||
myPub: string,
|
||||
) {
|
||||
const eventIdsReactedByOthers = new Map<string, number>();
|
||||
|
||||
const events = await Relay.query([
|
||||
"REQ",
|
||||
"getEventIdsReactedByOthers",
|
||||
{
|
||||
const events = relay.req("getEventIdsReactedByOthers", {
|
||||
authors: [...othersWhoReacted.keys()],
|
||||
kinds: [1, 6, 7, 9735],
|
||||
},
|
||||
]);
|
||||
}) as NostrEvent[];
|
||||
|
||||
events.forEach(event => {
|
||||
if (event.pubkey === myPub || myReactedEvents.has(event.id)) {
|
||||
// NIP-113 NOT filter could improve performance by not selecting these events in the first place
|
||||
return;
|
||||
}
|
||||
event.tags.forEach(tag => {
|
||||
event.tags.forEach((tag: string[]) => {
|
||||
if (tag[0] === "e") {
|
||||
const score = Math.ceil(Math.sqrt(othersWhoReacted.get(event.pubkey) || 0));
|
||||
eventIdsReactedByOthers.set(tag[1], (eventIdsReactedByOthers.get(tag[1]) || 0) + score);
|
||||
@ -127,16 +117,20 @@ async function getEventIdsReactedByOthers(
|
||||
return eventIdsReactedByOthers;
|
||||
}
|
||||
|
||||
async function getFeedEvents(reactedToIds: Map<string, number>, reactedToAuthors: Map<string, number>) {
|
||||
const events = await Relay.query([
|
||||
"REQ",
|
||||
"getFeedEvents",
|
||||
{
|
||||
ids: Array.from(reactedToIds.keys()),
|
||||
kinds: [1],
|
||||
since: Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 7,
|
||||
},
|
||||
]);
|
||||
async function getFeedEvents(
|
||||
relay: RelayHandler,
|
||||
reactedToIds: Map<string, number>,
|
||||
reactedToAuthors: Map<string, number>,
|
||||
) {
|
||||
const events = relay
|
||||
.sql(
|
||||
`select json from events where id in (${Array.from(reactedToIds.keys())
|
||||
.map(() => "?")
|
||||
.join(", ")}) and kind = 1 order by seen_at ASC, created DESC limit 1000`,
|
||||
Array.from(reactedToIds.keys()),
|
||||
)
|
||||
.map(row => JSON.parse(row[0] as string) as NostrEvent);
|
||||
|
||||
const seen = new Set<string>(events.map(ev => ev.id));
|
||||
|
||||
log("reactedToAuthors", reactedToAuthors);
|
||||
@ -145,16 +139,14 @@ async function getFeedEvents(reactedToIds: Map<string, number>, reactedToAuthors
|
||||
.sort((a, b) => reactedToAuthors.get(b)! - reactedToAuthors.get(a)!)
|
||||
.slice(20);
|
||||
|
||||
const eventsByFavoriteAuthors = await Relay.query([
|
||||
"REQ",
|
||||
"getFeedEvents",
|
||||
{
|
||||
authors: favoriteAuthors,
|
||||
kinds: [1],
|
||||
since: Math.floor(Date.now() / 1000) - 60 * 60 * 24,
|
||||
limit: 100,
|
||||
},
|
||||
]);
|
||||
const eventsByFavoriteAuthors = relay
|
||||
.sql(
|
||||
`select json from events where pubkey in (${favoriteAuthors
|
||||
.map(() => "?")
|
||||
.join(", ")}) and kind = 1 order by seen_at ASC, created DESC limit 100`,
|
||||
favoriteAuthors,
|
||||
)
|
||||
.map(row => JSON.parse(row[0] as string) as NostrEvent);
|
||||
|
||||
eventsByFavoriteAuthors.forEach(ev => {
|
||||
if (!seen.has(ev.id)) {
|
||||
@ -163,7 +155,7 @@ async function getFeedEvents(reactedToIds: Map<string, number>, reactedToAuthors
|
||||
});
|
||||
|
||||
// Filter out replies
|
||||
const filteredEvents = events.filter(ev => !ev.tags.some(tag => tag[0] === "e"));
|
||||
const filteredEvents = events.filter(ev => !ev.tags.some((tag: string[]) => tag[0] === "e"));
|
||||
|
||||
// Define constants for normalization
|
||||
// const recentnessWeight = -1;
|
@ -1,4 +1,12 @@
|
||||
import { NostrEvent, OkResponse, ReqCommand, ReqFilter, WorkerMessage, WorkerMessageCommand } from "./types";
|
||||
import {
|
||||
EventMetadata,
|
||||
NostrEvent,
|
||||
OkResponse,
|
||||
ReqCommand,
|
||||
ReqFilter,
|
||||
WorkerMessage,
|
||||
WorkerMessageCommand,
|
||||
} from "./types";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
export class WorkerRelayInterface {
|
||||
@ -45,6 +53,14 @@ export class WorkerRelayInterface {
|
||||
return await this.#workerRpc<void, Uint8Array>("dumpDb");
|
||||
}
|
||||
|
||||
async forYouFeed(pubkey: string) {
|
||||
return await this.#workerRpc<string, Array<NostrEvent>>("forYouFeed", pubkey);
|
||||
}
|
||||
|
||||
setEventMetadata(id: string, meta: EventMetadata) {
|
||||
return this.#workerRpc<[string, EventMetadata], void>("setEventMetadata", [id, meta]);
|
||||
}
|
||||
|
||||
#workerRpc<T, R>(cmd: WorkerMessageCommand, args?: T) {
|
||||
const id = uuid();
|
||||
const msg = {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { NostrEvent, RelayHandler, RelayHandlerEvents, ReqFilter, eventMatchesFilter } from "./types";
|
||||
import { NostrEvent, RelayHandler, RelayHandlerEvents, ReqFilter, eventMatchesFilter, EventMetadata } from "./types";
|
||||
import debug from "debug";
|
||||
|
||||
/**
|
||||
@ -79,4 +79,8 @@ export class InMemoryRelay extends EventEmitter<RelayHandlerEvents> implements R
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
setEventMetadata(_id: string, _meta: EventMetadata) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
101
packages/worker-relay/src/migrations.ts
Normal file
101
packages/worker-relay/src/migrations.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { NostrEvent } from "./types";
|
||||
import { SqliteRelay } from "./sqlite-relay";
|
||||
import debug from "debug";
|
||||
|
||||
const log = debug("SqliteRelay:migrations");
|
||||
|
||||
/**
|
||||
* Do database migration
|
||||
*/
|
||||
function migrate(relay: SqliteRelay) {
|
||||
if (!relay.db) throw new Error("DB must be open");
|
||||
|
||||
relay.db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS "__migration" (version INTEGER,migrated NUMERIC, CONSTRAINT "__migration_PK" PRIMARY KEY (version))',
|
||||
);
|
||||
const res = relay.db.exec("select max(version) from __migration", {
|
||||
returnValue: "resultRows",
|
||||
});
|
||||
|
||||
const version = (res[0][0] as number | undefined) ?? 0;
|
||||
log(`Starting migration from: v${version}`);
|
||||
if (version < 1) {
|
||||
migrate_v1(relay);
|
||||
log("Migrated to v1");
|
||||
}
|
||||
if (version < 2) {
|
||||
migrate_v2(relay);
|
||||
log("Migrated to v2");
|
||||
}
|
||||
if (version < 3) {
|
||||
migrate_v3(relay);
|
||||
log("Migrated to v3");
|
||||
}
|
||||
if (version < 4) {
|
||||
migrate_v4(relay);
|
||||
log("Migrated to v4");
|
||||
}
|
||||
}
|
||||
|
||||
function migrate_v1(relay: SqliteRelay) {
|
||||
relay.db?.transaction(db => {
|
||||
db.exec(
|
||||
"CREATE TABLE events (\
|
||||
id TEXT(64) PRIMARY KEY, \
|
||||
pubkey TEXT(64), \
|
||||
created INTEGER, \
|
||||
kind INTEGER, \
|
||||
json TEXT \
|
||||
)",
|
||||
);
|
||||
db.exec(
|
||||
"CREATE TABLE tags (\
|
||||
event_id TEXT(64), \
|
||||
key TEXT, \
|
||||
value TEXT, \
|
||||
CONSTRAINT tags_FK FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE \
|
||||
)",
|
||||
);
|
||||
db.exec("CREATE INDEX tags_key_IDX ON tags (key,value)");
|
||||
db.exec("insert into __migration values(1, ?)", {
|
||||
bind: [new Date().getTime() / 1000],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function migrate_v2(relay: SqliteRelay) {
|
||||
relay.db?.transaction(db => {
|
||||
db.exec("CREATE INDEX pubkey_kind_IDX ON events (pubkey,kind)");
|
||||
db.exec("CREATE INDEX pubkey_created_IDX ON events (pubkey,created)");
|
||||
db.exec("insert into __migration values(2, ?)", {
|
||||
bind: [new Date().getTime() / 1000],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function migrate_v3(relay: SqliteRelay) {
|
||||
relay.db?.transaction(db => {
|
||||
db.exec("CREATE VIRTUAL TABLE search_content using fts5(id UNINDEXED, content)");
|
||||
const events = db.selectArrays("select json from events where kind in (?,?)", [0, 1]);
|
||||
for (const json of events) {
|
||||
const ev = JSON.parse(json[0] as string) as NostrEvent;
|
||||
if (ev) {
|
||||
relay.insertIntoSearchIndex(db, ev);
|
||||
}
|
||||
}
|
||||
db.exec("insert into __migration values(3, ?)", {
|
||||
bind: [new Date().getTime() / 1000],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function migrate_v4(relay: SqliteRelay) {
|
||||
relay.db?.transaction(db => {
|
||||
db.exec("ALTER TABLE events ADD COLUMN seen_at INTEGER");
|
||||
db.exec("insert into __migration values(4, ?)", {
|
||||
bind: [new Date().getTime() / 1000],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default migrate;
|
@ -1,12 +1,13 @@
|
||||
import sqlite3InitModule, { Database, Sqlite3Static } from "@sqlite.org/sqlite-wasm";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { NostrEvent, RelayHandler, RelayHandlerEvents, ReqFilter, unixNowMs } from "./types";
|
||||
import { EventMetadata, NostrEvent, RelayHandler, RelayHandlerEvents, ReqFilter, unixNowMs } from "./types";
|
||||
import debug from "debug";
|
||||
import migrate from "./migrations";
|
||||
|
||||
export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements RelayHandler {
|
||||
#sqlite?: Sqlite3Static;
|
||||
#log = debug("SqliteRelay");
|
||||
#db?: Database;
|
||||
db?: Database;
|
||||
#seenInserts = new Set<string>();
|
||||
|
||||
/**
|
||||
@ -17,7 +18,7 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
this.#sqlite = await sqlite3InitModule();
|
||||
this.#log(`Got SQLite version: ${this.#sqlite.version.libVersion}`);
|
||||
await this.#open(path);
|
||||
this.#migrate();
|
||||
this.db && migrate(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -25,13 +26,13 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
*/
|
||||
async #open(path: string) {
|
||||
if (!this.#sqlite) throw new Error("Must call init first");
|
||||
if (this.#db) return;
|
||||
if (this.db) return;
|
||||
|
||||
if ("opfs" in this.#sqlite) {
|
||||
try {
|
||||
this.#db = new this.#sqlite.oo1.OpfsDb(path, "cw");
|
||||
this.#log(`Opened ${this.#db.filename}`);
|
||||
this.#db.exec(
|
||||
this.db = new this.#sqlite.oo1.OpfsDb(path, "cw");
|
||||
this.#log(`Opened ${this.db.filename}`);
|
||||
this.db.exec(
|
||||
`PRAGMA cache_size=${
|
||||
32 * 1024
|
||||
}; PRAGMA page_size=8192; PRAGMA journal_mode=MEMORY; PRAGMA temp_store=MEMORY;`,
|
||||
@ -46,44 +47,15 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#db?.close();
|
||||
this.#db = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do database migration
|
||||
*/
|
||||
#migrate() {
|
||||
if (!this.#db) throw new Error("DB must be open");
|
||||
|
||||
this.#db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS "__migration" (version INTEGER,migrated NUMERIC, CONSTRAINT "__migration_PK" PRIMARY KEY (version))',
|
||||
);
|
||||
const res = this.#db.exec("select max(version) from __migration", {
|
||||
returnValue: "resultRows",
|
||||
});
|
||||
|
||||
const version = (res[0][0] as number | undefined) ?? 0;
|
||||
this.#log(`Starting migration from: v${version}`);
|
||||
if (version < 1) {
|
||||
this.#migrate_v1();
|
||||
this.#log("Migrated to v1");
|
||||
}
|
||||
if (version < 2) {
|
||||
this.#migrate_v2();
|
||||
this.#log("Migrated to v2");
|
||||
}
|
||||
if (version < 3) {
|
||||
this.#migrate_v3();
|
||||
this.#log("Migrated to v3");
|
||||
}
|
||||
this.db?.close();
|
||||
this.db = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an event to the database
|
||||
*/
|
||||
event(ev: NostrEvent) {
|
||||
if (this.#insertEvent(this.#db!, ev)) {
|
||||
if (this.#insertEvent(this.db!, ev)) {
|
||||
this.#log(`Inserted: kind=${ev.kind},authors=${ev.pubkey},id=${ev.id}`);
|
||||
this.emit("event", [ev]);
|
||||
return true;
|
||||
@ -92,7 +64,7 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
}
|
||||
|
||||
sql(sql: string, params: Array<any>) {
|
||||
return this.#db?.selectArrays(sql, params) as Array<Array<string | number>>;
|
||||
return this.db?.selectArrays(sql, params) as Array<Array<string | number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,7 +73,7 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
eventBatch(evs: Array<NostrEvent>) {
|
||||
const start = unixNowMs();
|
||||
let eventsInserted: Array<NostrEvent> = [];
|
||||
this.#db?.transaction(db => {
|
||||
this.db?.transaction(db => {
|
||||
for (const ev of evs) {
|
||||
if (this.#insertEvent(db, ev)) {
|
||||
eventsInserted.push(ev);
|
||||
@ -115,6 +87,14 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
return eventsInserted.length > 0;
|
||||
}
|
||||
|
||||
setEventMetadata(id: string, meta: EventMetadata) {
|
||||
if (meta.seen_at) {
|
||||
this.db?.exec("update events set seen_at = ? where id = ?", {
|
||||
bind: [meta.seen_at, id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#deleteById(db: Database, ids: Array<string>) {
|
||||
db.exec(`delete from events where id in (${this.#repeatParams(ids.length)})`, {
|
||||
bind: ids,
|
||||
@ -164,14 +144,14 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
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)],
|
||||
});
|
||||
let eventInserted = (this.#db?.changes() as number) > 0;
|
||||
let eventInserted = (this.db?.changes() as number) > 0;
|
||||
if (eventInserted) {
|
||||
for (const t of ev.tags.filter(a => a[0].length === 1)) {
|
||||
db.exec("insert into tags(event_id, key, value) values(?, ?, ?)", {
|
||||
bind: [ev.id, t[0], t[1]],
|
||||
});
|
||||
}
|
||||
this.#insertSearchIndex(db, ev);
|
||||
this.insertIntoSearchIndex(db, ev);
|
||||
}
|
||||
this.#seenInserts.add(ev.id);
|
||||
return eventInserted;
|
||||
@ -184,7 +164,7 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
const start = unixNowMs();
|
||||
|
||||
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 => {
|
||||
if (req.ids_only === true) {
|
||||
@ -203,7 +183,7 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
count(req: ReqFilter) {
|
||||
const start = unixNowMs();
|
||||
const [sql, params] = this.#buildQuery(req, true);
|
||||
const rows = this.#db?.exec(sql, {
|
||||
const rows = this.db?.exec(sql, {
|
||||
bind: params,
|
||||
returnValue: "resultRows",
|
||||
});
|
||||
@ -217,7 +197,7 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
* Get a summary about events table
|
||||
*/
|
||||
summary() {
|
||||
const res = this.#db?.exec("select kind, count(*) from events group by kind", {
|
||||
const res = this.db?.exec("select kind, count(*) from events group by kind", {
|
||||
returnValue: "resultRows",
|
||||
});
|
||||
return Object.fromEntries(res?.map(a => [String(a[0]), a[1] as number]) ?? []);
|
||||
@ -227,10 +207,10 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
* Dump the database file
|
||||
*/
|
||||
async dump() {
|
||||
const filePath = String(this.#db?.filename ?? "");
|
||||
const filePath = String(this.db?.filename ?? "");
|
||||
try {
|
||||
this.#db?.close();
|
||||
this.#db = undefined;
|
||||
this.db?.close();
|
||||
this.db = undefined;
|
||||
const dir = await navigator.storage.getDirectory();
|
||||
// @ts-expect-error
|
||||
for await (const [name, file] of dir) {
|
||||
@ -328,43 +308,7 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
return res;
|
||||
}
|
||||
|
||||
#migrate_v1() {
|
||||
this.#db?.transaction(db => {
|
||||
db.exec(
|
||||
"CREATE TABLE events (\
|
||||
id TEXT(64) PRIMARY KEY, \
|
||||
pubkey TEXT(64), \
|
||||
created INTEGER, \
|
||||
kind INTEGER, \
|
||||
json TEXT \
|
||||
)",
|
||||
);
|
||||
db.exec(
|
||||
"CREATE TABLE tags (\
|
||||
event_id TEXT(64), \
|
||||
key TEXT, \
|
||||
value TEXT, \
|
||||
CONSTRAINT tags_FK FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE \
|
||||
)",
|
||||
);
|
||||
db.exec("CREATE INDEX tags_key_IDX ON tags (key,value)");
|
||||
db.exec("insert into __migration values(1, ?)", {
|
||||
bind: [new Date().getTime() / 1000],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#migrate_v2() {
|
||||
this.#db?.transaction(db => {
|
||||
db.exec("CREATE INDEX pubkey_kind_IDX ON events (pubkey,kind)");
|
||||
db.exec("CREATE INDEX pubkey_created_IDX ON events (pubkey,created)");
|
||||
db.exec("insert into __migration values(2, ?)", {
|
||||
bind: [new Date().getTime() / 1000],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#insertSearchIndex(db: Database, ev: NostrEvent) {
|
||||
insertIntoSearchIndex(db: Database, ev: NostrEvent) {
|
||||
if (ev.kind === 0) {
|
||||
const profile = JSON.parse(ev.content) as {
|
||||
name?: string;
|
||||
@ -393,20 +337,4 @@ export class SqliteRelay extends EventEmitter<RelayHandlerEvents> implements Rel
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#migrate_v3() {
|
||||
this.#db?.transaction(db => {
|
||||
db.exec("CREATE VIRTUAL TABLE search_content using fts5(id UNINDEXED, content)");
|
||||
const events = db.selectArrays("select json from events where kind in (?,?)", [0, 1]);
|
||||
for (const json of events) {
|
||||
const ev = JSON.parse(json[0] as string) as NostrEvent;
|
||||
if (ev) {
|
||||
this.#insertSearchIndex(db, ev);
|
||||
}
|
||||
}
|
||||
db.exec("insert into __migration values(3, ?)", {
|
||||
bind: [new Date().getTime() / 1000],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,9 @@ export type WorkerMessageCommand =
|
||||
| "summary"
|
||||
| "close"
|
||||
| "dumpDb"
|
||||
| "emit-event";
|
||||
| "emit-event"
|
||||
| "forYouFeed"
|
||||
| "setEventMetadata";
|
||||
|
||||
export interface WorkerMessage<T> {
|
||||
id: string;
|
||||
@ -28,6 +30,10 @@ export interface NostrEvent {
|
||||
relays?: Array<string>;
|
||||
}
|
||||
|
||||
export interface EventMetadata {
|
||||
seen_at?: number;
|
||||
}
|
||||
|
||||
export type ReqCommand = ["REQ", id: string, ...filters: Array<ReqFilter>];
|
||||
|
||||
export interface ReqFilter {
|
||||
@ -64,6 +70,7 @@ export interface RelayHandler extends EventEmitter<RelayHandlerEvents> {
|
||||
count(req: ReqFilter): number;
|
||||
summary(): Record<string, number>;
|
||||
dump(): Promise<Uint8Array>;
|
||||
setEventMetadata(id: string, meta: EventMetadata): void;
|
||||
}
|
||||
|
||||
export interface RelayHandlerEvents {
|
||||
|
@ -3,7 +3,8 @@
|
||||
import { InMemoryRelay } from "./memory-relay";
|
||||
import { WorkQueueItem, barrierQueue, processWorkQueue } from "./queue";
|
||||
import { SqliteRelay } from "./sqlite-relay";
|
||||
import { NostrEvent, RelayHandler, ReqCommand, ReqFilter, WorkerMessage, unixNowMs } from "./types";
|
||||
import { NostrEvent, RelayHandler, ReqCommand, ReqFilter, WorkerMessage, unixNowMs, EventMetadata } from "./types";
|
||||
import { getForYouFeed } from "./forYouFeed";
|
||||
|
||||
let relay: RelayHandler | undefined;
|
||||
|
||||
@ -130,6 +131,20 @@ globalThis.onmessage = async ev => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "forYouFeed": {
|
||||
await barrierQueue(cmdQueue, async () => {
|
||||
const res = await getForYouFeed(relay!, msg.args as string);
|
||||
reply(msg.id, res);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "setEventMetadata": {
|
||||
await barrierQueue(cmdQueue, async () => {
|
||||
const [id, metadata] = msg.args as [string, EventMetadata];
|
||||
relay!.setEventMetadata(id, metadata);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
reply(msg.id, { error: "Unknown command" });
|
||||
break;
|
||||
|
Loading…
Reference in New Issue
Block a user