Remove relations filters, switch some stuff to use optimizer (requires bravery)

This commit is contained in:
Alex Gleason 2024-03-06 12:55:02 -06:00
parent 1499f9b417
commit 8b9566d79b
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
8 changed files with 59 additions and 146 deletions

View File

@ -15,6 +15,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { DittoFilter } from '@/interfaces/DittoFilter.ts'; import { DittoFilter } from '@/interfaces/DittoFilter.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
const usernameSchema = z const usernameSchema = z
.string().min(1).max(30) .string().min(1).max(30)
@ -147,7 +148,6 @@ const accountStatusesController: AppController = async (c) => {
const filter: DittoFilter = { const filter: DittoFilter = {
authors: [pubkey], authors: [pubkey],
kinds: [1], kinds: [1],
relations: ['author', 'event_stats', 'author_stats'],
since, since,
until, until,
limit, limit,
@ -157,11 +157,16 @@ const accountStatusesController: AppController = async (c) => {
filter['#t'] = [tagged]; filter['#t'] = [tagged];
} }
let events = await eventsDB.query([filter], { signal }); const events = await eventsDB.query([filter], { signal })
.then((events) =>
if (exclude_replies) { hydrateEvents({ events, relations: ['author', 'event_stats', 'author_stats'], storage: eventsDB, signal })
events = events.filter((event) => !findReplyTag(event.tags)); )
} .then((events) => {
if (exclude_replies) {
return events.filter((event) => !findReplyTag(event.tags));
}
return events;
});
const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey'))));
return paginated(c, events, statuses); return paginated(c, events, statuses);
@ -304,10 +309,10 @@ const favouritesController: AppController = async (c) => {
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1]) .map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
.filter((id): id is string => !!id); .filter((id): id is string => !!id);
const events1 = await eventsDB.query( const events1 = await eventsDB.query([{ kinds: [1], ids }], { signal })
[{ kinds: [1], ids, relations: ['author', 'event_stats', 'author_stats'] }], .then((events) =>
{ signal }, hydrateEvents({ events, relations: ['author', 'event_stats', 'author_stats'], storage: eventsDB, signal })
); );
const statuses = await Promise.all(events1.map((event) => renderStatus(event, c.get('pubkey')))); const statuses = await Promise.all(events1.map((event) => renderStatus(event, c.get('pubkey'))));
return paginated(c, events1, statuses); return paginated(c, events1, statuses);

View File

@ -8,6 +8,7 @@ import { dedupeEvents } from '@/utils.ts';
import { nip05Cache } from '@/utils/nip05.ts'; import { nip05Cache } from '@/utils/nip05.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
/** Matches NIP-05 names with or without an @ in front. */ /** Matches NIP-05 names with or without an @ in front. */
const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/;
@ -69,7 +70,6 @@ function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: Abort
const filter: DittoFilter = { const filter: DittoFilter = {
kinds: typeToKinds(type), kinds: typeToKinds(type),
search: q, search: q,
relations: ['author', 'event_stats', 'author_stats'],
limit, limit,
}; };
@ -77,7 +77,10 @@ function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: Abort
filter.authors = [account_id]; filter.authors = [account_id];
} }
return searchStore.query([filter], { signal }); return searchStore.query([filter], { signal })
.then((events) =>
hydrateEvents({ events, relations: ['author', 'event_stats', 'author_stats'], storage: searchStore, signal })
);
} }
/** Get event kinds to search from `type` query param. */ /** Get event kinds to search from `type` query param. */
@ -95,8 +98,12 @@ function typeToKinds(type: SearchQuery['type']): number[] {
/** Resolve a searched value into an event, if applicable. */ /** Resolve a searched value into an event, if applicable. */
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> { async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> {
const filters = await getLookupFilters(query, signal); const filters = await getLookupFilters(query, signal);
const [event] = await searchStore.query(filters, { limit: 1, signal });
return event; return searchStore.query(filters, { limit: 1, signal })
.then((events) =>
hydrateEvents({ events, relations: ['author', 'event_stats', 'author_stats'], storage: searchStore, signal })
)
.then(([event]) => event);
} }
/** Get filters to lookup the input value. */ /** Get filters to lookup the input value. */
@ -115,19 +122,19 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
const result = nip19.decode(q); const result = nip19.decode(q);
switch (result.type) { switch (result.type) {
case 'npub': case 'npub':
if (accounts) filters.push({ kinds: [0], authors: [result.data], relations: ['author_stats'] }); if (accounts) filters.push({ kinds: [0], authors: [result.data] });
break; break;
case 'nprofile': case 'nprofile':
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey], relations: ['author_stats'] }); if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] });
break; break;
case 'note': case 'note':
if (statuses) { if (statuses) {
filters.push({ kinds: [1], ids: [result.data], relations: ['author', 'event_stats', 'author_stats'] }); filters.push({ kinds: [1], ids: [result.data] });
} }
break; break;
case 'nevent': case 'nevent':
if (statuses) { if (statuses) {
filters.push({ kinds: [1], ids: [result.data.id], relations: ['author', 'event_stats', 'author_stats'] }); filters.push({ kinds: [1], ids: [result.data.id] });
} }
break; break;
} }
@ -141,7 +148,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
try { try {
const { pubkey } = await nip05Cache.fetch(q, { signal }); const { pubkey } = await nip05Cache.fetch(q, { signal });
if (pubkey) { if (pubkey) {
filters.push({ kinds: [0], authors: [pubkey], relations: ['author_stats'] }); filters.push({ kinds: [0], authors: [pubkey] });
} }
} catch (_e) { } catch (_e) {
// do nothing // do nothing

View File

@ -9,6 +9,4 @@ export type DittoRelation = Exclude<keyof DittoEvent, keyof NostrEvent>;
export interface DittoFilter extends NostrFilter { export interface DittoFilter extends NostrFilter {
/** Whether the event was authored by a local user. */ /** Whether the event was authored by a local user. */
local?: boolean; local?: boolean;
/** Additional fields to add to the returned event. */
relations?: DittoRelation[];
} }

View File

@ -1,9 +1,9 @@
import { cache, eventsDB, reqmeister } from '@/storages.ts'; import { eventsDB, optimizer } from '@/storages.ts';
import { Debug, type NostrEvent } from '@/deps.ts'; import { Debug, type NostrEvent, type NostrFilter } from '@/deps.ts';
import { type AuthorMicrofilter, type IdMicrofilter } from '@/filter.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { type DittoFilter, type DittoRelation } from '@/interfaces/DittoFilter.ts'; import { type DittoRelation } from '@/interfaces/DittoFilter.ts';
import { findReplyTag, getTagSet } from '@/tags.ts'; import { findReplyTag, getTagSet } from '@/tags.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
const debug = Debug('ditto:queries'); const debug = Debug('ditto:queries');
@ -22,76 +22,25 @@ const getEvent = async (
opts: GetEventOpts = {}, opts: GetEventOpts = {},
): Promise<DittoEvent | undefined> => { ): Promise<DittoEvent | undefined> => {
debug(`getEvent: ${id}`); debug(`getEvent: ${id}`);
const { kind, relations, signal = AbortSignal.timeout(1000) } = opts; const { kind, relations = [], signal = AbortSignal.timeout(1000) } = opts;
const microfilter: IdMicrofilter = { ids: [id] };
const [memoryEvent] = await cache.query([microfilter]) as DittoEvent[]; const filter: NostrFilter = { ids: [id], limit: 1 };
if (memoryEvent && !relations) {
debug(`getEvent: ${id.slice(0, 8)} found in memory`);
return memoryEvent;
}
const filter: DittoFilter = { ids: [id], relations, limit: 1 };
if (kind) { if (kind) {
filter.kinds = [kind]; filter.kinds = [kind];
} }
const dbEvent = await eventsDB.query([filter], { limit: 1, signal }) return await optimizer.query([filter], { limit: 1, signal })
.then(([event]) => hydrateEvents({ events: [event], relations, storage: optimizer, signal }))
.then(([event]) => event); .then(([event]) => event);
// TODO: make this DRY-er.
if (dbEvent && !dbEvent.author) {
const [author] = await cache.query([{ kinds: [0], authors: [dbEvent.pubkey] }]);
dbEvent.author = author;
}
if (dbEvent) {
debug(`getEvent: ${id.slice(0, 8)} found in db`);
return dbEvent;
}
if (memoryEvent && !memoryEvent.author) {
const [author] = await cache.query([{ kinds: [0], authors: [memoryEvent.pubkey] }]);
memoryEvent.author = author;
}
if (memoryEvent) {
debug(`getEvent: ${id.slice(0, 8)} found in memory`);
return memoryEvent;
}
const reqEvent = await reqmeister.req(microfilter, opts).catch(() => undefined);
if (reqEvent) {
debug(`getEvent: ${id.slice(0, 8)} found by reqmeister`);
return reqEvent;
}
debug(`getEvent: ${id.slice(0, 8)} not found`);
}; };
/** Get a Nostr `set_medatadata` event for a user's pubkey. */ /** Get a Nostr `set_medatadata` event for a user's pubkey. */
const getAuthor = async (pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> => { const getAuthor = async (pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> => {
const { relations, signal = AbortSignal.timeout(1000) } = opts; const { relations = [], signal = AbortSignal.timeout(1000) } = opts;
const microfilter: AuthorMicrofilter = { kinds: [0], authors: [pubkey] };
const [memoryEvent] = await cache.query([microfilter]); return await optimizer.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, signal })
.then(([event]) => hydrateEvents({ events: [event], relations, storage: optimizer, signal }))
if (memoryEvent && !relations) { .then(([event]) => event);
return memoryEvent;
}
const dbEvent = await eventsDB.query(
[{ authors: [pubkey], relations, kinds: [0], limit: 1 }],
{ limit: 1, signal },
).then(([event]) => event);
if (dbEvent) return dbEvent;
if (memoryEvent) return memoryEvent;
return reqmeister.req(microfilter, opts).catch(() => undefined);
}; };
/** Get users the given pubkey follows. */ /** Get users the given pubkey follows. */
@ -132,10 +81,10 @@ async function getAncestors(event: NostrEvent, result: NostrEvent[] = []): Promi
} }
function getDescendants(eventId: string, signal = AbortSignal.timeout(2000)): Promise<NostrEvent[]> { function getDescendants(eventId: string, signal = AbortSignal.timeout(2000)): Promise<NostrEvent[]> {
return eventsDB.query( return eventsDB.query([{ kinds: [1], '#e': [eventId] }], { limit: 200, signal })
[{ kinds: [1], '#e': [eventId], relations: ['author', 'event_stats', 'author_stats'] }], .then((events) =>
{ limit: 200, signal }, hydrateEvents({ events, relations: ['author', 'event_stats', 'author_stats'], storage: eventsDB, signal })
); );
} }
/** Returns whether the pubkey is followed by a local user. */ /** Returns whether the pubkey is followed by a local user. */

View File

@ -198,49 +198,6 @@ class EventsDB implements NStore {
.where('users.d_tag', filter.local ? 'is not' : 'is', null); .where('users.d_tag', filter.local ? 'is not' : 'is', null);
} }
if (filter.relations?.includes('author')) {
query = query
.leftJoin(
(eb) =>
eb
.selectFrom('events')
.selectAll()
.where('kind', '=', 0)
.groupBy('pubkey')
.as('authors'),
(join) => join.onRef('authors.pubkey', '=', 'events.pubkey'),
)
.select([
'authors.id as author_id',
'authors.kind as author_kind',
'authors.pubkey as author_pubkey',
'authors.content as author_content',
'authors.tags as author_tags',
'authors.created_at as author_created_at',
'authors.sig as author_sig',
]);
}
if (filter.relations?.includes('author_stats')) {
query = query
.leftJoin('author_stats', 'author_stats.pubkey', 'events.pubkey')
.select((eb) => [
eb.fn.coalesce('author_stats.followers_count', eb.val(0)).as('author_stats_followers_count'),
eb.fn.coalesce('author_stats.following_count', eb.val(0)).as('author_stats_following_count'),
eb.fn.coalesce('author_stats.notes_count', eb.val(0)).as('author_stats_notes_count'),
]);
}
if (filter.relations?.includes('event_stats')) {
query = query
.leftJoin('event_stats', 'event_stats.event_id', 'events.id')
.select((eb) => [
eb.fn.coalesce('event_stats.replies_count', eb.val(0)).as('stats_replies_count'),
eb.fn.coalesce('event_stats.reposts_count', eb.val(0)).as('stats_reposts_count'),
eb.fn.coalesce('event_stats.reactions_count', eb.val(0)).as('stats_reactions_count'),
]);
}
if (filter.search) { if (filter.search) {
query = query query = query
.innerJoin('events_fts', 'events_fts.id', 'events.id') .innerJoin('events_fts', 'events_fts.id', 'events.id')

View File

@ -13,13 +13,13 @@ interface HydrateEventOpts {
async function hydrateEvents(opts: HydrateEventOpts): Promise<DittoEvent[]> { async function hydrateEvents(opts: HydrateEventOpts): Promise<DittoEvent[]> {
const { events, relations, storage, signal } = opts; const { events, relations, storage, signal } = opts;
if (events.length === 0) { if (!events.length || !relations.length) {
return events; return events;
} }
if (relations.includes('author')) { if (relations.includes('author')) {
const pubkeys = new Set([...events].map((event) => event.pubkey)); const pubkeys = new Set([...events].map((event) => event.pubkey));
const authors = await storage.query([{ kinds: [0], authors: [...pubkeys] }], { signal }); const authors = await storage.query([{ kinds: [0], authors: [...pubkeys], limit: pubkeys.size }], { signal });
for (const event of events) { for (const event of events) {
event.author = authors.find((author) => author.pubkey === event.pubkey); event.author = authors.find((author) => author.pubkey === event.pubkey);

View File

@ -23,7 +23,7 @@ class Optimizer implements NStore {
this.#client = opts.client; this.#client = opts.client;
} }
async event(event: DittoEvent, opts?: NStoreOpts | undefined): Promise<void> { async event(event: DittoEvent, opts?: NStoreOpts): Promise<void> {
if (opts?.signal?.aborted) return Promise.reject(abortError()); if (opts?.signal?.aborted) return Promise.reject(abortError());
await Promise.all([ await Promise.all([

View File

@ -4,6 +4,7 @@ import { eventsDB } from '@/storages.ts';
import { renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { paginated, paginationSchema } from '@/utils/api.ts'; import { paginated, paginationSchema } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts';
/** Render account objects for the author of each event. */ /** Render account objects for the author of each event. */
async function renderEventAccounts(c: AppContext, filters: NostrFilter[], signal = AbortSignal.timeout(1000)) { async function renderEventAccounts(c: AppContext, filters: NostrFilter[], signal = AbortSignal.timeout(1000)) {
@ -18,10 +19,8 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], signal
return c.json([]); return c.json([]);
} }
const authors = await eventsDB.query( const authors = await eventsDB.query([{ kinds: [0], authors: [...pubkeys] }], { signal })
[{ kinds: [0], authors: [...pubkeys], relations: ['author_stats'] }], .then((events) => hydrateEvents({ events, relations: ['author_stats'], storage: eventsDB, signal }));
{ signal },
);
const accounts = await Promise.all( const accounts = await Promise.all(
authors.map((event) => renderAccount(event)), authors.map((event) => renderAccount(event)),
@ -33,10 +32,8 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], signal
async function renderAccounts(c: AppContext, authors: string[], signal = AbortSignal.timeout(1000)) { async function renderAccounts(c: AppContext, authors: string[], signal = AbortSignal.timeout(1000)) {
const { since, until, limit } = paginationSchema.parse(c.req.query()); const { since, until, limit } = paginationSchema.parse(c.req.query());
const events = await eventsDB.query( const events = await eventsDB.query([{ kinds: [0], authors, since, until, limit }], { signal })
[{ kinds: [0], authors, relations: ['author_stats'], since, until, limit }], .then((events) => hydrateEvents({ events, relations: ['author_stats'], storage: eventsDB, signal }));
{ signal },
);
const accounts = await Promise.all( const accounts = await Promise.all(
events.map((event) => renderAccount(event)), events.map((event) => renderAccount(event)),
@ -53,10 +50,10 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
const { limit } = paginationSchema.parse(c.req.query()); const { limit } = paginationSchema.parse(c.req.query());
const events = await eventsDB.query( const events = await eventsDB.query([{ kinds: [1], ids, limit }], { signal })
[{ kinds: [1], ids, relations: ['author', 'event_stats', 'author_stats'], limit }], .then((events) =>
{ signal }, hydrateEvents({ events, relations: ['author', 'event_stats', 'author_stats'], storage: eventsDB, signal })
); );
if (!events.length) { if (!events.length) {
return c.json([]); return c.json([]);