Merge branch 'storages' into 'main'
Support external search See merge request soapbox-pub/ditto!95
This commit is contained in:
commit
4e6549407e
|
@ -1,8 +1,8 @@
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { db } from '@/db.ts';
|
import { db } from '@/db.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import { type Kysely } from '@/deps.ts';
|
import { type Kysely } from '@/deps.ts';
|
||||||
import { signAdminEvent } from '@/sign.ts';
|
import { signAdminEvent } from '@/sign.ts';
|
||||||
|
import { eventsDB } from '@/storages.ts';
|
||||||
|
|
||||||
interface DB {
|
interface DB {
|
||||||
users: {
|
users: {
|
||||||
|
|
|
@ -1,18 +1,23 @@
|
||||||
import { Debug, type Event, type Filter, matchFilters } from '@/deps.ts';
|
import { Debug, type Event, type Filter, matchFilters } from '@/deps.ts';
|
||||||
|
import { normalizeFilters } from '@/filter.ts';
|
||||||
import * as pipeline from '@/pipeline.ts';
|
import * as pipeline from '@/pipeline.ts';
|
||||||
import { activeRelays, pool } from '@/pool.ts';
|
import { activeRelays, pool } from '@/pool.ts';
|
||||||
import { type EventStore, type GetEventsOpts, type StoreEventOpts } from '@/store.ts';
|
import { type EventStore, type GetEventsOpts, type StoreEventOpts } from '@/storages/types.ts';
|
||||||
|
import { EventSet } from '@/utils/event-set.ts';
|
||||||
|
|
||||||
const debug = Debug('ditto:client');
|
const debug = Debug('ditto:client');
|
||||||
|
|
||||||
/** Get events from a NIP-01 filter. */
|
/** Get events from a NIP-01 filter. */
|
||||||
function getEvents<K extends number>(filters: Filter<K>[], opts: GetEventsOpts = {}): Promise<Event<K>[]> {
|
function getEvents<K extends number>(filters: Filter<K>[], opts: GetEventsOpts = {}): Promise<Event<K>[]> {
|
||||||
|
filters = normalizeFilters(filters);
|
||||||
|
|
||||||
if (opts.signal?.aborted) return Promise.resolve([]);
|
if (opts.signal?.aborted) return Promise.resolve([]);
|
||||||
if (!filters.length) return Promise.resolve([]);
|
if (!filters.length) return Promise.resolve([]);
|
||||||
|
|
||||||
debug('REQ', JSON.stringify(filters));
|
debug('REQ', JSON.stringify(filters));
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const results: Event[] = [];
|
const results = new EventSet<Event<K>>();
|
||||||
|
|
||||||
const unsub = pool.subscribe(
|
const unsub = pool.subscribe(
|
||||||
filters,
|
filters,
|
||||||
|
@ -20,9 +25,9 @@ function getEvents<K extends number>(filters: Filter<K>[], opts: GetEventsOpts =
|
||||||
(event: Event | null) => {
|
(event: Event | null) => {
|
||||||
if (event && matchFilters(filters, event)) {
|
if (event && matchFilters(filters, event)) {
|
||||||
pipeline.handleEvent(event).catch(() => {});
|
pipeline.handleEvent(event).catch(() => {});
|
||||||
results.push({
|
results.add({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
kind: event.kind,
|
kind: event.kind as K,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
tags: event.tags,
|
tags: event.tags,
|
||||||
|
@ -30,21 +35,21 @@ function getEvents<K extends number>(filters: Filter<K>[], opts: GetEventsOpts =
|
||||||
sig: event.sig,
|
sig: event.sig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (typeof opts.limit === 'number' && results.length >= opts.limit) {
|
if (typeof opts.limit === 'number' && results.size >= opts.limit) {
|
||||||
unsub();
|
unsub();
|
||||||
resolve(results as Event<K>[]);
|
resolve([...results]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
() => {
|
() => {
|
||||||
unsub();
|
unsub();
|
||||||
resolve(results as Event<K>[]);
|
resolve([...results]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
opts.signal?.addEventListener('abort', () => {
|
opts.signal?.addEventListener('abort', () => {
|
||||||
unsub();
|
unsub();
|
||||||
resolve(results as Event<K>[]);
|
resolve([...results]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -59,6 +64,7 @@ function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const client: EventStore = {
|
const client: EventStore = {
|
||||||
|
supportedNips: [1],
|
||||||
getEvents,
|
getEvents,
|
||||||
storeEvent,
|
storeEvent,
|
||||||
countEvents: () => Promise.reject(new Error('COUNT not implemented')),
|
countEvents: () => Promise.reject(new Error('COUNT not implemented')),
|
||||||
|
|
|
@ -42,6 +42,10 @@ const Conf = {
|
||||||
const { protocol, host } = Conf.url;
|
const { protocol, host } = Conf.url;
|
||||||
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
|
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
|
||||||
},
|
},
|
||||||
|
/** Relay to use for NIP-50 `search` queries. */
|
||||||
|
get searchRelay() {
|
||||||
|
return Deno.env.get('SEARCH_RELAY');
|
||||||
|
},
|
||||||
/** Origin of the Ditto server, including the protocol and port. */
|
/** Origin of the Ditto server, including the protocol and port. */
|
||||||
get localDomain() {
|
get localDomain() {
|
||||||
return Deno.env.get('LOCAL_DOMAIN') || 'http://localhost:8000';
|
return Deno.env.get('LOCAL_DOMAIN') || 'http://localhost:8000';
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import { insertUser } from '@/db/users.ts';
|
import { insertUser } from '@/db/users.ts';
|
||||||
import { findReplyTag, nip19, z } from '@/deps.ts';
|
import { findReplyTag, nip19, z } from '@/deps.ts';
|
||||||
import { type DittoFilter } from '@/filter.ts';
|
import { type DittoFilter } from '@/filter.ts';
|
||||||
import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
|
import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
|
||||||
import { booleanParamSchema, fileSchema } from '@/schema.ts';
|
import { booleanParamSchema, fileSchema } from '@/schema.ts';
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||||
|
import { eventsDB } from '@/storages.ts';
|
||||||
import { addTag, deleteTag, getTagSet } from '@/tags.ts';
|
import { addTag, deleteTag, getTagSet } from '@/tags.ts';
|
||||||
import { uploadFile } from '@/upload.ts';
|
import { uploadFile } from '@/upload.ts';
|
||||||
import { lookupAccount, nostrNow } from '@/utils.ts';
|
import { lookupAccount, nostrNow } from '@/utils.ts';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
import { eventsDB } from '@/storages.ts';
|
||||||
import { getTagSet } from '@/tags.ts';
|
import { getTagSet } from '@/tags.ts';
|
||||||
import { renderAccounts } from '@/views.ts';
|
import { renderAccounts } from '@/views.ts';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
import { eventsDB } from '@/storages.ts';
|
||||||
import { getTagSet } from '@/tags.ts';
|
import { getTagSet } from '@/tags.ts';
|
||||||
import { renderStatuses } from '@/views.ts';
|
import { renderStatuses } from '@/views.ts';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
import { eventsDB } from '@/storages.ts';
|
||||||
import { paginated, paginationSchema } from '@/utils/api.ts';
|
import { paginated, paginationSchema } from '@/utils/api.ts';
|
||||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import { z } from '@/deps.ts';
|
import { z } from '@/deps.ts';
|
||||||
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
|
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
|
||||||
|
import { eventsDB } from '@/storages.ts';
|
||||||
import { createAdminEvent } from '@/utils/api.ts';
|
import { createAdminEvent } from '@/utils/api.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import { type Event, nip19, z } from '@/deps.ts';
|
import { type Event, nip19, z } from '@/deps.ts';
|
||||||
import { type DittoFilter } from '@/filter.ts';
|
import { type DittoFilter } from '@/filter.ts';
|
||||||
import { booleanParamSchema } from '@/schema.ts';
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
||||||
|
import { searchStore } from '@/storages.ts';
|
||||||
import { dedupeEvents } from '@/utils.ts';
|
import { dedupeEvents } from '@/utils.ts';
|
||||||
import { lookupNip05Cached } from '@/utils/nip05.ts';
|
import { lookupNip05Cached } from '@/utils/nip05.ts';
|
||||||
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
|
@ -30,9 +30,11 @@ const searchController: AppController = async (c) => {
|
||||||
return c.json({ error: 'Bad request', schema: result.error }, 422);
|
return c.json({ error: 'Bad request', schema: result.error }, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signal = AbortSignal.timeout(1000);
|
||||||
|
|
||||||
const [event, events] = await Promise.all([
|
const [event, events] = await Promise.all([
|
||||||
lookupEvent(result.data),
|
lookupEvent(result.data, signal),
|
||||||
searchEvents(result.data),
|
searchEvents(result.data, signal),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
|
@ -62,7 +64,7 @@ const searchController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get events for the search params. */
|
/** Get events for the search params. */
|
||||||
function searchEvents({ q, type, limit, account_id }: SearchQuery): Promise<Event[]> {
|
function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise<Event[]> {
|
||||||
if (type === 'hashtags') return Promise.resolve([]);
|
if (type === 'hashtags') return Promise.resolve([]);
|
||||||
|
|
||||||
const filter: DittoFilter = {
|
const filter: DittoFilter = {
|
||||||
|
@ -76,7 +78,7 @@ function searchEvents({ q, type, limit, account_id }: SearchQuery): Promise<Even
|
||||||
filter.authors = [account_id];
|
filter.authors = [account_id];
|
||||||
}
|
}
|
||||||
|
|
||||||
return eventsDB.getEvents([filter]);
|
return searchStore.getEvents([filter], { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get event kinds to search from `type` query param. */
|
/** Get event kinds to search from `type` query param. */
|
||||||
|
@ -92,9 +94,9 @@ 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.timeout(1000)): Promise<Event | undefined> {
|
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<Event | undefined> {
|
||||||
const filters = await getLookupFilters(query);
|
const filters = await getLookupFilters(query);
|
||||||
const [event] = await eventsDB.getEvents(filters, { limit: 1, signal });
|
const [event] = await searchStore.getEvents(filters, { limit: 1, signal });
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { eventsDB } from '@/db/events.ts';
|
import { eventsDB } from '@/storages.ts';
|
||||||
import { z } from '@/deps.ts';
|
import { z } from '@/deps.ts';
|
||||||
import { type DittoFilter } from '@/filter.ts';
|
import { type DittoFilter } from '@/filter.ts';
|
||||||
import { getFeedPubkeys } from '@/queries.ts';
|
import { getFeedPubkeys } from '@/queries.ts';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
|
import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
import { eventsDB } from '@/storages.ts';
|
||||||
import * as pipeline from '@/pipeline.ts';
|
import * as pipeline from '@/pipeline.ts';
|
||||||
import { jsonSchema } from '@/schema.ts';
|
import { jsonSchema } from '@/schema.ts';
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
import { assertEquals, assertRejects } from '@/deps-test.ts';
|
|
||||||
import { buildUserEvent } from '@/db/users.ts';
|
|
||||||
|
|
||||||
import event0 from '~/fixtures/events/event-0.json' assert { type: 'json' };
|
|
||||||
import event1 from '~/fixtures/events/event-1.json' assert { type: 'json' };
|
|
||||||
|
|
||||||
import { eventsDB as db } from './events.ts';
|
|
||||||
|
|
||||||
Deno.test('count filters', async () => {
|
|
||||||
assertEquals(await db.countEvents([{ kinds: [1] }]), 0);
|
|
||||||
await db.storeEvent(event1);
|
|
||||||
assertEquals(await db.countEvents([{ kinds: [1] }]), 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('insert and filter events', async () => {
|
|
||||||
await db.storeEvent(event1);
|
|
||||||
|
|
||||||
assertEquals(await db.getEvents([{ kinds: [1] }]), [event1]);
|
|
||||||
assertEquals(await db.getEvents([{ kinds: [3] }]), []);
|
|
||||||
assertEquals(await db.getEvents([{ since: 1691091000 }]), [event1]);
|
|
||||||
assertEquals(await db.getEvents([{ until: 1691091000 }]), []);
|
|
||||||
assertEquals(
|
|
||||||
await db.getEvents([{ '#proxy': ['https://gleasonator.com/objects/8f6fac53-4f66-4c6e-ac7d-92e5e78c3e79'] }]),
|
|
||||||
[event1],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('delete events', async () => {
|
|
||||||
await db.storeEvent(event1);
|
|
||||||
assertEquals(await db.getEvents([{ kinds: [1] }]), [event1]);
|
|
||||||
await db.deleteEvents([{ kinds: [1] }]);
|
|
||||||
assertEquals(await db.getEvents([{ kinds: [1] }]), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('query events with local filter', async () => {
|
|
||||||
await db.storeEvent(event1);
|
|
||||||
|
|
||||||
assertEquals(await db.getEvents([{}]), [event1]);
|
|
||||||
assertEquals(await db.getEvents([{ local: true }]), []);
|
|
||||||
assertEquals(await db.getEvents([{ local: false }]), [event1]);
|
|
||||||
|
|
||||||
const userEvent = await buildUserEvent({
|
|
||||||
username: 'alex',
|
|
||||||
pubkey: event1.pubkey,
|
|
||||||
inserted_at: new Date(),
|
|
||||||
admin: false,
|
|
||||||
});
|
|
||||||
await db.storeEvent(userEvent);
|
|
||||||
|
|
||||||
assertEquals(await db.getEvents([{ kinds: [1], local: true }]), [event1]);
|
|
||||||
assertEquals(await db.getEvents([{ kinds: [1], local: false }]), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test('inserting replaceable events', async () => {
|
|
||||||
assertEquals(await db.countEvents([{ kinds: [0], authors: [event0.pubkey] }]), 0);
|
|
||||||
|
|
||||||
await db.storeEvent(event0);
|
|
||||||
await assertRejects(() => db.storeEvent(event0));
|
|
||||||
assertEquals(await db.countEvents([{ kinds: [0], authors: [event0.pubkey] }]), 1);
|
|
||||||
|
|
||||||
const changeEvent = { ...event0, id: '123', created_at: event0.created_at + 1 };
|
|
||||||
await db.storeEvent(changeEvent);
|
|
||||||
assertEquals(await db.getEvents([{ kinds: [0] }]), [changeEvent]);
|
|
||||||
});
|
|
410
src/db/events.ts
410
src/db/events.ts
|
@ -1,410 +0,0 @@
|
||||||
import { Conf } from '@/config.ts';
|
|
||||||
import { db, type DittoDB } from '@/db.ts';
|
|
||||||
import { Debug, type Event, Kysely, type SelectQueryBuilder } from '@/deps.ts';
|
|
||||||
import { type DittoFilter } from '@/filter.ts';
|
|
||||||
import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts';
|
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
|
||||||
import { type DittoEvent, EventStore, type GetEventsOpts, type StoreEventOpts } from '@/store.ts';
|
|
||||||
import { isNostrId, isURL } from '@/utils.ts';
|
|
||||||
|
|
||||||
const debug = Debug('ditto:db:events');
|
|
||||||
|
|
||||||
/** Function to decide whether or not to index a tag. */
|
|
||||||
type TagCondition = ({ event, count, value }: {
|
|
||||||
event: Event;
|
|
||||||
opts: StoreEventOpts;
|
|
||||||
count: number;
|
|
||||||
value: string;
|
|
||||||
}) => boolean;
|
|
||||||
|
|
||||||
/** Conditions for when to index certain tags. */
|
|
||||||
const tagConditions: Record<string, TagCondition> = {
|
|
||||||
'd': ({ event, count }) => count === 0 && isParameterizedReplaceableKind(event.kind),
|
|
||||||
'e': ({ event, count, value, opts }) => ((opts.data?.user && event.kind === 10003) || count < 15) && isNostrId(value),
|
|
||||||
'media': ({ count, value, opts }) => (opts.data?.user || count < 4) && isURL(value),
|
|
||||||
'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value),
|
|
||||||
'proxy': ({ count, value }) => count === 0 && isURL(value),
|
|
||||||
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
|
|
||||||
't': ({ count, value }) => count < 5 && value.length < 50,
|
|
||||||
'name': ({ event, count }) => event.kind === 30361 && count === 0,
|
|
||||||
'role': ({ event, count }) => event.kind === 30361 && count === 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Insert an event (and its tags) into the database. */
|
|
||||||
async function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise<void> {
|
|
||||||
debug('EVENT', JSON.stringify(event));
|
|
||||||
|
|
||||||
if (isDittoInternalKind(event.kind) && event.pubkey !== Conf.pubkey) {
|
|
||||||
throw new Error('Internal events can only be stored by the server keypair');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await db.transaction().execute(async (trx) => {
|
|
||||||
/** Insert the event into the database. */
|
|
||||||
async function addEvent() {
|
|
||||||
await trx.insertInto('events')
|
|
||||||
.values({ ...event, tags: JSON.stringify(event.tags) })
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add search data to the FTS table. */
|
|
||||||
async function indexSearch() {
|
|
||||||
const searchContent = buildSearchContent(event);
|
|
||||||
if (!searchContent) return;
|
|
||||||
await trx.insertInto('events_fts')
|
|
||||||
.values({ id: event.id, content: searchContent.substring(0, 1000) })
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Index event tags depending on the conditions defined above. */
|
|
||||||
async function indexTags() {
|
|
||||||
const tags = filterIndexableTags(event, opts);
|
|
||||||
const rows = tags.map(([tag, value]) => ({ event_id: event.id, tag, value }));
|
|
||||||
|
|
||||||
if (!tags.length) return;
|
|
||||||
await trx.insertInto('tags')
|
|
||||||
.values(rows)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isReplaceableKind(event.kind)) {
|
|
||||||
const prevEvents = await getFilterQuery(trx, { kinds: [event.kind], authors: [event.pubkey] }).execute();
|
|
||||||
for (const prevEvent of prevEvents) {
|
|
||||||
if (prevEvent.created_at >= event.created_at) {
|
|
||||||
throw new Error('Cannot replace an event with an older event');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await deleteEventsTrx(trx, [{ kinds: [event.kind], authors: [event.pubkey] }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isParameterizedReplaceableKind(event.kind)) {
|
|
||||||
const d = event.tags.find(([tag]) => tag === 'd')?.[1];
|
|
||||||
if (d) {
|
|
||||||
const prevEvents = await getFilterQuery(trx, { kinds: [event.kind], authors: [event.pubkey], '#d': [d] })
|
|
||||||
.execute();
|
|
||||||
for (const prevEvent of prevEvents) {
|
|
||||||
if (prevEvent.created_at >= event.created_at) {
|
|
||||||
throw new Error('Cannot replace an event with an older event');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await deleteEventsTrx(trx, [{ kinds: [event.kind], authors: [event.pubkey], '#d': [d] }]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the queries.
|
|
||||||
await Promise.all([
|
|
||||||
addEvent(),
|
|
||||||
indexTags(),
|
|
||||||
indexSearch(),
|
|
||||||
]);
|
|
||||||
}).catch((error) => {
|
|
||||||
// Don't throw for duplicate events.
|
|
||||||
if (error.message.includes('UNIQUE constraint failed')) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventQuery = SelectQueryBuilder<DittoDB, 'events', {
|
|
||||||
id: string;
|
|
||||||
tags: string;
|
|
||||||
kind: number;
|
|
||||||
pubkey: string;
|
|
||||||
content: string;
|
|
||||||
created_at: number;
|
|
||||||
sig: string;
|
|
||||||
stats_replies_count?: number;
|
|
||||||
stats_reposts_count?: number;
|
|
||||||
stats_reactions_count?: number;
|
|
||||||
author_id?: string;
|
|
||||||
author_tags?: string;
|
|
||||||
author_kind?: number;
|
|
||||||
author_pubkey?: string;
|
|
||||||
author_content?: string;
|
|
||||||
author_created_at?: number;
|
|
||||||
author_sig?: string;
|
|
||||||
author_stats_followers_count?: number;
|
|
||||||
author_stats_following_count?: number;
|
|
||||||
author_stats_notes_count?: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/** Build the query for a filter. */
|
|
||||||
function getFilterQuery(db: Kysely<DittoDB>, filter: DittoFilter): EventQuery {
|
|
||||||
let query = db
|
|
||||||
.selectFrom('events')
|
|
||||||
.select([
|
|
||||||
'events.id',
|
|
||||||
'events.kind',
|
|
||||||
'events.pubkey',
|
|
||||||
'events.content',
|
|
||||||
'events.tags',
|
|
||||||
'events.created_at',
|
|
||||||
'events.sig',
|
|
||||||
])
|
|
||||||
.orderBy('events.created_at', 'desc');
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(filter)) {
|
|
||||||
if (value === undefined) continue;
|
|
||||||
|
|
||||||
switch (key as keyof DittoFilter) {
|
|
||||||
case 'ids':
|
|
||||||
query = query.where('events.id', 'in', filter.ids!);
|
|
||||||
break;
|
|
||||||
case 'kinds':
|
|
||||||
query = query.where('events.kind', 'in', filter.kinds!);
|
|
||||||
break;
|
|
||||||
case 'authors':
|
|
||||||
query = query.where('events.pubkey', 'in', filter.authors!);
|
|
||||||
break;
|
|
||||||
case 'since':
|
|
||||||
query = query.where('events.created_at', '>=', filter.since!);
|
|
||||||
break;
|
|
||||||
case 'until':
|
|
||||||
query = query.where('events.created_at', '<=', filter.until!);
|
|
||||||
break;
|
|
||||||
case 'limit':
|
|
||||||
query = query.limit(filter.limit!);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.startsWith('#')) {
|
|
||||||
const tag = key.replace(/^#/, '');
|
|
||||||
const value = filter[key as `#${string}`] as string[];
|
|
||||||
query = query
|
|
||||||
.leftJoin('tags', 'tags.event_id', 'events.id')
|
|
||||||
.where('tags.tag', '=', tag)
|
|
||||||
.where('tags.value', 'in', value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filter.local === 'boolean') {
|
|
||||||
query = query
|
|
||||||
.leftJoin(usersQuery, (join) => join.onRef('users.d_tag', '=', 'events.pubkey'))
|
|
||||||
.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) {
|
|
||||||
query = query
|
|
||||||
.innerJoin('events_fts', 'events_fts.id', 'events.id')
|
|
||||||
.where('events_fts.content', 'match', JSON.stringify(filter.search));
|
|
||||||
}
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Combine filter queries into a single union query. */
|
|
||||||
function getEventsQuery(filters: DittoFilter[]) {
|
|
||||||
return filters
|
|
||||||
.map((filter) => db.selectFrom(() => getFilterQuery(db, filter).as('events')).selectAll())
|
|
||||||
.reduce((result, query) => result.unionAll(query));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Query to get user events, joined by tags. */
|
|
||||||
function usersQuery() {
|
|
||||||
return getFilterQuery(db, { kinds: [30361], authors: [Conf.pubkey] })
|
|
||||||
.leftJoin('tags', 'tags.event_id', 'events.id')
|
|
||||||
.where('tags.tag', '=', 'd')
|
|
||||||
.select('tags.value as d_tag')
|
|
||||||
.as('users');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get events for filters from the database. */
|
|
||||||
async function getEvents<K extends number>(
|
|
||||||
filters: DittoFilter<K>[],
|
|
||||||
opts: GetEventsOpts = {},
|
|
||||||
): Promise<DittoEvent<K>[]> {
|
|
||||||
if (opts.signal?.aborted) return Promise.resolve([]);
|
|
||||||
if (!filters.length) return Promise.resolve([]);
|
|
||||||
debug('REQ', JSON.stringify(filters));
|
|
||||||
let query = getEventsQuery(filters);
|
|
||||||
|
|
||||||
if (typeof opts.limit === 'number') {
|
|
||||||
query = query.limit(opts.limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await query.execute()).map((row) => {
|
|
||||||
const event: DittoEvent<K> = {
|
|
||||||
id: row.id,
|
|
||||||
kind: row.kind as K,
|
|
||||||
pubkey: row.pubkey,
|
|
||||||
content: row.content,
|
|
||||||
created_at: row.created_at,
|
|
||||||
tags: JSON.parse(row.tags),
|
|
||||||
sig: row.sig,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (row.author_id) {
|
|
||||||
event.author = {
|
|
||||||
id: row.author_id,
|
|
||||||
kind: row.author_kind! as 0,
|
|
||||||
pubkey: row.author_pubkey!,
|
|
||||||
content: row.author_content!,
|
|
||||||
created_at: row.author_created_at!,
|
|
||||||
tags: JSON.parse(row.author_tags!),
|
|
||||||
sig: row.author_sig!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof row.author_stats_followers_count === 'number') {
|
|
||||||
event.author_stats = {
|
|
||||||
followers_count: row.author_stats_followers_count,
|
|
||||||
following_count: row.author_stats_following_count!,
|
|
||||||
notes_count: row.author_stats_notes_count!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof row.stats_replies_count === 'number') {
|
|
||||||
event.event_stats = {
|
|
||||||
replies_count: row.stats_replies_count,
|
|
||||||
reposts_count: row.stats_reposts_count!,
|
|
||||||
reactions_count: row.stats_reactions_count!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return event;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete events from each table. Should be run in a transaction! */
|
|
||||||
async function deleteEventsTrx(db: Kysely<DittoDB>, filters: DittoFilter[]) {
|
|
||||||
if (!filters.length) return Promise.resolve();
|
|
||||||
debug('DELETE', JSON.stringify(filters));
|
|
||||||
|
|
||||||
const query = getEventsQuery(filters).clearSelect().select('id');
|
|
||||||
|
|
||||||
await db.deleteFrom('events_fts')
|
|
||||||
.where('id', 'in', () => query)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return db.deleteFrom('events')
|
|
||||||
.where('id', 'in', () => query)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete events based on filters from the database. */
|
|
||||||
async function deleteEvents<K extends number>(filters: DittoFilter<K>[]): Promise<void> {
|
|
||||||
if (!filters.length) return Promise.resolve();
|
|
||||||
debug('DELETE', JSON.stringify(filters));
|
|
||||||
|
|
||||||
await db.transaction().execute((trx) => deleteEventsTrx(trx, filters));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get number of events that would be returned by filters. */
|
|
||||||
async function countEvents<K extends number>(filters: DittoFilter<K>[]): Promise<number> {
|
|
||||||
if (!filters.length) return Promise.resolve(0);
|
|
||||||
debug('COUNT', JSON.stringify(filters));
|
|
||||||
const query = getEventsQuery(filters);
|
|
||||||
|
|
||||||
const [{ count }] = await query
|
|
||||||
.clearSelect()
|
|
||||||
.select((eb) => eb.fn.count('id').as('count'))
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return Number(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return only the tags that should be indexed. */
|
|
||||||
function filterIndexableTags(event: Event, opts: StoreEventOpts): string[][] {
|
|
||||||
const tagCounts: Record<string, number> = {};
|
|
||||||
|
|
||||||
function getCount(name: string) {
|
|
||||||
return tagCounts[name] || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function incrementCount(name: string) {
|
|
||||||
tagCounts[name] = getCount(name) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkCondition(name: string, value: string, condition: TagCondition) {
|
|
||||||
return condition({
|
|
||||||
event,
|
|
||||||
opts,
|
|
||||||
count: getCount(name),
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return event.tags.reduce<string[][]>((results, tag) => {
|
|
||||||
const [name, value] = tag;
|
|
||||||
const condition = tagConditions[name] as TagCondition | undefined;
|
|
||||||
|
|
||||||
if (value && condition && value.length < 200 && checkCondition(name, value, condition)) {
|
|
||||||
results.push(tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
incrementCount(name);
|
|
||||||
return results;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a search index from the event. */
|
|
||||||
function buildSearchContent(event: Event): string {
|
|
||||||
switch (event.kind) {
|
|
||||||
case 0:
|
|
||||||
return buildUserSearchContent(event as Event<0>);
|
|
||||||
case 1:
|
|
||||||
return event.content;
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build search content for a user. */
|
|
||||||
function buildUserSearchContent(event: Event<0>): string {
|
|
||||||
const { name, nip05, about } = jsonMetaContentSchema.parse(event.content);
|
|
||||||
return [name, nip05, about].filter(Boolean).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SQLite database storage adapter for Nostr events. */
|
|
||||||
const eventsDB: EventStore = {
|
|
||||||
getEvents,
|
|
||||||
storeEvent,
|
|
||||||
countEvents,
|
|
||||||
deleteEvents,
|
|
||||||
};
|
|
||||||
|
|
||||||
export { eventsDB };
|
|
|
@ -1,69 +0,0 @@
|
||||||
import { Debug, type Event, type Filter, LRUCache } from '@/deps.ts';
|
|
||||||
import { getFilterId, getMicroFilters, isMicrofilter } from '@/filter.ts';
|
|
||||||
import { type EventStore, type GetEventsOpts } from '@/store.ts';
|
|
||||||
|
|
||||||
const debug = Debug('ditto:memorelay');
|
|
||||||
|
|
||||||
const events = new LRUCache<string, Event>({
|
|
||||||
max: 3000,
|
|
||||||
maxEntrySize: 5000,
|
|
||||||
sizeCalculation: (event) => JSON.stringify(event).length,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Get events from memory. */
|
|
||||||
function getEvents<K extends number>(filters: Filter<K>[], opts: GetEventsOpts = {}): Promise<Event<K>[]> {
|
|
||||||
if (opts.signal?.aborted) return Promise.resolve([]);
|
|
||||||
if (!filters.length) return Promise.resolve([]);
|
|
||||||
debug('REQ', JSON.stringify(filters));
|
|
||||||
|
|
||||||
const results: Event<K>[] = [];
|
|
||||||
|
|
||||||
for (const filter of filters) {
|
|
||||||
if (isMicrofilter(filter)) {
|
|
||||||
const event = events.get(getFilterId(filter));
|
|
||||||
if (event) {
|
|
||||||
results.push(event as Event<K>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insert an event into memory. */
|
|
||||||
function storeEvent(event: Event): Promise<void> {
|
|
||||||
for (const microfilter of getMicroFilters(event)) {
|
|
||||||
const filterId = getFilterId(microfilter);
|
|
||||||
const existing = events.get(filterId);
|
|
||||||
if (!existing || event.created_at > existing.created_at) {
|
|
||||||
events.set(filterId, event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Count events in memory for the filters. */
|
|
||||||
async function countEvents(filters: Filter[]): Promise<number> {
|
|
||||||
const events = await getEvents(filters);
|
|
||||||
return events.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete events from memory. */
|
|
||||||
function deleteEvents(filters: Filter[]): Promise<void> {
|
|
||||||
for (const filter of filters) {
|
|
||||||
if (isMicrofilter(filter)) {
|
|
||||||
events.delete(getFilterId(filter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-memory data store for events using microfilters. */
|
|
||||||
const memorelay: EventStore = {
|
|
||||||
getEvents,
|
|
||||||
storeEvent,
|
|
||||||
countEvents,
|
|
||||||
deleteEvents,
|
|
||||||
};
|
|
||||||
|
|
||||||
export { memorelay };
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { Debug, type Filter } from '@/deps.ts';
|
import { Debug, type Filter } from '@/deps.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import * as pipeline from '@/pipeline.ts';
|
import * as pipeline from '@/pipeline.ts';
|
||||||
import { signAdminEvent } from '@/sign.ts';
|
import { signAdminEvent } from '@/sign.ts';
|
||||||
|
import { eventsDB } from '@/storages.ts';
|
||||||
|
|
||||||
const debug = Debug('ditto:users');
|
const debug = Debug('ditto:users');
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ export {
|
||||||
getEventHash,
|
getEventHash,
|
||||||
getPublicKey,
|
getPublicKey,
|
||||||
getSignature,
|
getSignature,
|
||||||
|
matchFilter,
|
||||||
matchFilters,
|
matchFilters,
|
||||||
nip04,
|
nip04,
|
||||||
nip05,
|
nip05,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { assertEquals } from '@/deps-test.ts';
|
||||||
import event0 from '~/fixtures/events/event-0.json' assert { type: 'json' };
|
import event0 from '~/fixtures/events/event-0.json' assert { type: 'json' };
|
||||||
import event1 from '~/fixtures/events/event-1.json' assert { type: 'json' };
|
import event1 from '~/fixtures/events/event-1.json' assert { type: 'json' };
|
||||||
|
|
||||||
import { eventToMicroFilter, getFilterId, getMicroFilters, isMicrofilter } from './filter.ts';
|
import { eventToMicroFilter, getFilterId, getFilterLimit, getMicroFilters, isMicrofilter } from './filter.ts';
|
||||||
|
|
||||||
Deno.test('getMicroFilters', () => {
|
Deno.test('getMicroFilters', () => {
|
||||||
const event = event0 as Event<0>;
|
const event = event0 as Event<0>;
|
||||||
|
@ -35,3 +35,13 @@ Deno.test('getFilterId', () => {
|
||||||
'{"authors":["79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"],"kinds":[0]}',
|
'{"authors":["79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"],"kinds":[0]}',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('getFilterLimit', () => {
|
||||||
|
assertEquals(getFilterLimit({ ids: [event0.id] }), 1);
|
||||||
|
assertEquals(getFilterLimit({ ids: [event0.id], limit: 2 }), 1);
|
||||||
|
assertEquals(getFilterLimit({ ids: [event0.id], limit: 0 }), 0);
|
||||||
|
assertEquals(getFilterLimit({ ids: [event0.id], limit: -1 }), 0);
|
||||||
|
assertEquals(getFilterLimit({ kinds: [0], authors: [event0.pubkey] }), 1);
|
||||||
|
assertEquals(getFilterLimit({ kinds: [1], authors: [event0.pubkey] }), Infinity);
|
||||||
|
assertEquals(getFilterLimit({}), Infinity);
|
||||||
|
});
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Conf } from '@/config.ts';
|
||||||
import { type Event, type Filter, matchFilters, stringifyStable, z } from '@/deps.ts';
|
import { type Event, type Filter, matchFilters, stringifyStable, z } from '@/deps.ts';
|
||||||
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
||||||
import { type EventData } from '@/types.ts';
|
import { type EventData } from '@/types.ts';
|
||||||
|
import { isReplaceableKind } from '@/kinds.ts';
|
||||||
|
|
||||||
/** Additional properties that may be added by Ditto to events. */
|
/** Additional properties that may be added by Ditto to events. */
|
||||||
type Relation = 'author' | 'author_stats' | 'event_stats';
|
type Relation = 'author' | 'author_stats' | 'event_stats';
|
||||||
|
@ -82,15 +83,50 @@ function isMicrofilter(filter: Filter): filter is MicroFilter {
|
||||||
return microFilterSchema.safeParse(filter).success;
|
return microFilterSchema.safeParse(filter).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Calculate the intrinsic limit of a filter. */
|
||||||
|
function getFilterLimit(filter: Filter): number {
|
||||||
|
if (filter.ids && !filter.ids.length) return 0;
|
||||||
|
if (filter.kinds && !filter.kinds.length) return 0;
|
||||||
|
if (filter.authors && !filter.authors.length) return 0;
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
Math.max(0, filter.limit ?? Infinity),
|
||||||
|
filter.ids?.length ?? Infinity,
|
||||||
|
filter.authors?.length &&
|
||||||
|
filter.kinds?.every((kind) => isReplaceableKind(kind))
|
||||||
|
? filter.authors.length * filter.kinds.length
|
||||||
|
: Infinity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the filter could potentially return any stored events at all. */
|
||||||
|
function canFilter(filter: Filter): boolean {
|
||||||
|
return getFilterLimit(filter) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize the `limit` of each filter, and remove filters that can't produce any events. */
|
||||||
|
function normalizeFilters<F extends Filter>(filters: F[]): F[] {
|
||||||
|
return filters.reduce<F[]>((acc, filter) => {
|
||||||
|
const limit = getFilterLimit(filter);
|
||||||
|
if (limit > 0) {
|
||||||
|
acc.push(limit === Infinity ? filter : { ...filter, limit });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type AuthorMicrofilter,
|
type AuthorMicrofilter,
|
||||||
|
canFilter,
|
||||||
type DittoFilter,
|
type DittoFilter,
|
||||||
eventToMicroFilter,
|
eventToMicroFilter,
|
||||||
getFilterId,
|
getFilterId,
|
||||||
|
getFilterLimit,
|
||||||
getMicroFilters,
|
getMicroFilters,
|
||||||
type IdMicrofilter,
|
type IdMicrofilter,
|
||||||
isMicrofilter,
|
isMicrofilter,
|
||||||
matchDittoFilters,
|
matchDittoFilters,
|
||||||
type MicroFilter,
|
type MicroFilter,
|
||||||
|
normalizeFilters,
|
||||||
type Relation,
|
type Relation,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { client } from '@/client.ts';
|
import { client } from '@/client.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import { memorelay } from '@/db/memorelay.ts';
|
|
||||||
import { addRelays } from '@/db/relays.ts';
|
import { addRelays } from '@/db/relays.ts';
|
||||||
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
|
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
|
||||||
import { findUser } from '@/db/users.ts';
|
import { findUser } from '@/db/users.ts';
|
||||||
|
@ -10,6 +8,7 @@ import { isEphemeralKind } from '@/kinds.ts';
|
||||||
import { isLocallyFollowed } from '@/queries.ts';
|
import { isLocallyFollowed } from '@/queries.ts';
|
||||||
import { reqmeister } from '@/reqmeister.ts';
|
import { reqmeister } from '@/reqmeister.ts';
|
||||||
import { updateStats } from '@/stats.ts';
|
import { updateStats } from '@/stats.ts';
|
||||||
|
import { eventsDB, memorelay } from '@/storages.ts';
|
||||||
import { Sub } from '@/subs.ts';
|
import { Sub } from '@/subs.ts';
|
||||||
import { getTagSet } from '@/tags.ts';
|
import { getTagSet } from '@/tags.ts';
|
||||||
import { type EventData } from '@/types.ts';
|
import { type EventData } from '@/types.ts';
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { eventsDB } from '@/db/events.ts';
|
import { eventsDB, memorelay } from '@/storages.ts';
|
||||||
import { memorelay } from '@/db/memorelay.ts';
|
|
||||||
import { Debug, type Event, findReplyTag } from '@/deps.ts';
|
import { Debug, type Event, findReplyTag } from '@/deps.ts';
|
||||||
import { type AuthorMicrofilter, type DittoFilter, type IdMicrofilter, type Relation } from '@/filter.ts';
|
import { type AuthorMicrofilter, type DittoFilter, type IdMicrofilter, type Relation } from '@/filter.ts';
|
||||||
import { reqmeister } from '@/reqmeister.ts';
|
import { reqmeister } from '@/reqmeister.ts';
|
||||||
import { type DittoEvent } from '@/store.ts';
|
import { type DittoEvent } from '@/storages/types.ts';
|
||||||
import { getTagSet } from '@/tags.ts';
|
import { getTagSet } from '@/tags.ts';
|
||||||
|
|
||||||
const debug = Debug('ditto:queries');
|
const debug = Debug('ditto:queries');
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import { client } from '@/client.ts';
|
import { client } from '@/client.ts';
|
||||||
import { Debug, type Event, EventEmitter, type Filter } from '@/deps.ts';
|
import { Debug, type Event, EventEmitter, type Filter } from '@/deps.ts';
|
||||||
import { AuthorMicrofilter, eventToMicroFilter, getFilterId, IdMicrofilter, type MicroFilter } from '@/filter.ts';
|
import {
|
||||||
|
AuthorMicrofilter,
|
||||||
|
eventToMicroFilter,
|
||||||
|
getFilterId,
|
||||||
|
IdMicrofilter,
|
||||||
|
isMicrofilter,
|
||||||
|
type MicroFilter,
|
||||||
|
} from '@/filter.ts';
|
||||||
|
import { type EventStore, GetEventsOpts } from '@/storages/types.ts';
|
||||||
import { Time } from '@/utils/time.ts';
|
import { Time } from '@/utils/time.ts';
|
||||||
|
|
||||||
const debug = Debug('ditto:reqmeister');
|
const debug = Debug('ditto:reqmeister');
|
||||||
|
@ -18,12 +26,14 @@ interface ReqmeisterReqOpts {
|
||||||
type ReqmeisterQueueItem = [string, MicroFilter, WebSocket['url'][]];
|
type ReqmeisterQueueItem = [string, MicroFilter, WebSocket['url'][]];
|
||||||
|
|
||||||
/** Batches requests to Nostr relays using microfilters. */
|
/** Batches requests to Nostr relays using microfilters. */
|
||||||
class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => any }> {
|
class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => any }> implements EventStore {
|
||||||
#opts: ReqmeisterOpts;
|
#opts: ReqmeisterOpts;
|
||||||
#queue: ReqmeisterQueueItem[] = [];
|
#queue: ReqmeisterQueueItem[] = [];
|
||||||
#promise!: Promise<void>;
|
#promise!: Promise<void>;
|
||||||
#resolve!: () => void;
|
#resolve!: () => void;
|
||||||
|
|
||||||
|
supportedNips = [];
|
||||||
|
|
||||||
constructor(opts: ReqmeisterOpts = {}) {
|
constructor(opts: ReqmeisterOpts = {}) {
|
||||||
super();
|
super();
|
||||||
this.#opts = opts;
|
this.#opts = opts;
|
||||||
|
@ -119,6 +129,33 @@ class Reqmeister extends EventEmitter<{ [filterId: string]: (event: Event) => an
|
||||||
const filterId = getFilterId(eventToMicroFilter(event));
|
const filterId = getFilterId(eventToMicroFilter(event));
|
||||||
return this.#queue.some(([id]) => id === filterId);
|
return this.#queue.some(([id]) => id === filterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEvents<K extends number>(filters: Filter<K>[], opts?: GetEventsOpts | undefined): Promise<Event<K>[]> {
|
||||||
|
if (opts?.signal?.aborted) return Promise.resolve([]);
|
||||||
|
if (!filters.length) return Promise.resolve([]);
|
||||||
|
|
||||||
|
const promises = filters.reduce<Promise<Event<K>>[]>((result, filter) => {
|
||||||
|
if (isMicrofilter(filter)) {
|
||||||
|
result.push(this.req(filter) as Promise<Event<K>>);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
storeEvent(event: Event): Promise<void> {
|
||||||
|
this.encounter(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
countEvents(_filters: Filter[]): Promise<number> {
|
||||||
|
throw new Error('COUNT not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEvents(_filters: Filter[]): Promise<void> {
|
||||||
|
throw new Error('DELETE not implemented.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reqmeister = new Reqmeister({
|
const reqmeister = new Reqmeister({
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { type AuthorStatsRow, db, type DittoDB, type EventStatsRow } from '@/db.ts';
|
import { type AuthorStatsRow, db, type DittoDB, type EventStatsRow } from '@/db.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import { Debug, type Event, findReplyTag, type InsertQueryBuilder } from '@/deps.ts';
|
import { Debug, type Event, findReplyTag, type InsertQueryBuilder } from '@/deps.ts';
|
||||||
|
import { eventsDB } from '@/storages.ts';
|
||||||
|
|
||||||
type AuthorStat = keyof Omit<AuthorStatsRow, 'pubkey'>;
|
type AuthorStat = keyof Omit<AuthorStatsRow, 'pubkey'>;
|
||||||
type EventStat = keyof Omit<EventStatsRow, 'event_id'>;
|
type EventStat = keyof Omit<EventStatsRow, 'event_id'>;
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { db } from '@/db.ts';
|
||||||
|
import { EventsDB } from '@/storages/events-db.ts';
|
||||||
|
import { Memorelay } from '@/storages/memorelay.ts';
|
||||||
|
import { Optimizer } from '@/storages/optimizer.ts';
|
||||||
|
import { SearchStore } from '@/storages/search-store.ts';
|
||||||
|
import { reqmeister } from '@/reqmeister.ts';
|
||||||
|
|
||||||
|
/** SQLite database to store events this Ditto server cares about. */
|
||||||
|
const eventsDB = new EventsDB(db);
|
||||||
|
|
||||||
|
/** In-memory data store for cached events. */
|
||||||
|
const memorelay = new Memorelay({ max: 3000 });
|
||||||
|
|
||||||
|
/** Main Ditto storage adapter */
|
||||||
|
const optimizer = new Optimizer({
|
||||||
|
db: eventsDB,
|
||||||
|
cache: memorelay,
|
||||||
|
client: reqmeister,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Storage to use for remote search. */
|
||||||
|
const searchStore = new SearchStore({
|
||||||
|
relay: Conf.searchRelay,
|
||||||
|
fallback: optimizer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { eventsDB, memorelay, optimizer, searchStore };
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { db } from '@/db.ts';
|
||||||
|
import { buildUserEvent } from '@/db/users.ts';
|
||||||
|
import { assertEquals, assertRejects } from '@/deps-test.ts';
|
||||||
|
|
||||||
|
import event0 from '~/fixtures/events/event-0.json' assert { type: 'json' };
|
||||||
|
import event1 from '~/fixtures/events/event-1.json' assert { type: 'json' };
|
||||||
|
|
||||||
|
import { EventsDB } from './events-db.ts';
|
||||||
|
|
||||||
|
const eventsDB = new EventsDB(db);
|
||||||
|
|
||||||
|
Deno.test('count filters', async () => {
|
||||||
|
assertEquals(await eventsDB.countEvents([{ kinds: [1] }]), 0);
|
||||||
|
await eventsDB.storeEvent(event1);
|
||||||
|
assertEquals(await eventsDB.countEvents([{ kinds: [1] }]), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('insert and filter events', async () => {
|
||||||
|
await eventsDB.storeEvent(event1);
|
||||||
|
|
||||||
|
assertEquals(await eventsDB.getEvents([{ kinds: [1] }]), [event1]);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ kinds: [3] }]), []);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ since: 1691091000 }]), [event1]);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ until: 1691091000 }]), []);
|
||||||
|
assertEquals(
|
||||||
|
await eventsDB.getEvents([{ '#proxy': ['https://gleasonator.com/objects/8f6fac53-4f66-4c6e-ac7d-92e5e78c3e79'] }]),
|
||||||
|
[event1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('delete events', async () => {
|
||||||
|
await eventsDB.storeEvent(event1);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ kinds: [1] }]), [event1]);
|
||||||
|
await eventsDB.deleteEvents([{ kinds: [1] }]);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ kinds: [1] }]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('query events with local filter', async () => {
|
||||||
|
await eventsDB.storeEvent(event1);
|
||||||
|
|
||||||
|
assertEquals(await eventsDB.getEvents([{}]), [event1]);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ local: true }]), []);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ local: false }]), [event1]);
|
||||||
|
|
||||||
|
const userEvent = await buildUserEvent({
|
||||||
|
username: 'alex',
|
||||||
|
pubkey: event1.pubkey,
|
||||||
|
inserted_at: new Date(),
|
||||||
|
admin: false,
|
||||||
|
});
|
||||||
|
await eventsDB.storeEvent(userEvent);
|
||||||
|
|
||||||
|
assertEquals(await eventsDB.getEvents([{ kinds: [1], local: true }]), [event1]);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ kinds: [1], local: false }]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('inserting replaceable events', async () => {
|
||||||
|
assertEquals(await eventsDB.countEvents([{ kinds: [0], authors: [event0.pubkey] }]), 0);
|
||||||
|
|
||||||
|
await eventsDB.storeEvent(event0);
|
||||||
|
await assertRejects(() => eventsDB.storeEvent(event0));
|
||||||
|
assertEquals(await eventsDB.countEvents([{ kinds: [0], authors: [event0.pubkey] }]), 1);
|
||||||
|
|
||||||
|
const changeEvent = { ...event0, id: '123', created_at: event0.created_at + 1 };
|
||||||
|
await eventsDB.storeEvent(changeEvent);
|
||||||
|
assertEquals(await eventsDB.getEvents([{ kinds: [0] }]), [changeEvent]);
|
||||||
|
});
|
|
@ -0,0 +1,414 @@
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { type DittoDB } from '@/db.ts';
|
||||||
|
import { Debug, type Event, Kysely, type SelectQueryBuilder } from '@/deps.ts';
|
||||||
|
import { type DittoFilter } from '@/filter.ts';
|
||||||
|
import { isDittoInternalKind, isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts';
|
||||||
|
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||||
|
import { isNostrId, isURL } from '@/utils.ts';
|
||||||
|
|
||||||
|
import { type DittoEvent, EventStore, type GetEventsOpts, type StoreEventOpts } from './types.ts';
|
||||||
|
|
||||||
|
/** Function to decide whether or not to index a tag. */
|
||||||
|
type TagCondition = ({ event, count, value }: {
|
||||||
|
event: Event;
|
||||||
|
opts: StoreEventOpts;
|
||||||
|
count: number;
|
||||||
|
value: string;
|
||||||
|
}) => boolean;
|
||||||
|
|
||||||
|
/** Conditions for when to index certain tags. */
|
||||||
|
const tagConditions: Record<string, TagCondition> = {
|
||||||
|
'd': ({ event, count }) => count === 0 && isParameterizedReplaceableKind(event.kind),
|
||||||
|
'e': ({ event, count, value, opts }) => ((opts.data?.user && event.kind === 10003) || count < 15) && isNostrId(value),
|
||||||
|
'media': ({ count, value, opts }) => (opts.data?.user || count < 4) && isURL(value),
|
||||||
|
'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value),
|
||||||
|
'proxy': ({ count, value }) => count === 0 && isURL(value),
|
||||||
|
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
|
||||||
|
't': ({ count, value }) => count < 5 && value.length < 50,
|
||||||
|
'name': ({ event, count }) => event.kind === 30361 && count === 0,
|
||||||
|
'role': ({ event, count }) => event.kind === 30361 && count === 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventQuery = SelectQueryBuilder<DittoDB, 'events', {
|
||||||
|
id: string;
|
||||||
|
tags: string;
|
||||||
|
kind: number;
|
||||||
|
pubkey: string;
|
||||||
|
content: string;
|
||||||
|
created_at: number;
|
||||||
|
sig: string;
|
||||||
|
stats_replies_count?: number;
|
||||||
|
stats_reposts_count?: number;
|
||||||
|
stats_reactions_count?: number;
|
||||||
|
author_id?: string;
|
||||||
|
author_tags?: string;
|
||||||
|
author_kind?: number;
|
||||||
|
author_pubkey?: string;
|
||||||
|
author_content?: string;
|
||||||
|
author_created_at?: number;
|
||||||
|
author_sig?: string;
|
||||||
|
author_stats_followers_count?: number;
|
||||||
|
author_stats_following_count?: number;
|
||||||
|
author_stats_notes_count?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** SQLite database storage adapter for Nostr events. */
|
||||||
|
class EventsDB implements EventStore {
|
||||||
|
#db: Kysely<DittoDB>;
|
||||||
|
#debug = Debug('ditto:db:events');
|
||||||
|
|
||||||
|
/** NIPs supported by this storage method. */
|
||||||
|
supportedNips = [1, 45, 50];
|
||||||
|
|
||||||
|
constructor(db: Kysely<DittoDB>) {
|
||||||
|
this.#db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert an event (and its tags) into the database. */
|
||||||
|
async storeEvent(event: Event, opts: StoreEventOpts = {}): Promise<void> {
|
||||||
|
this.#debug('EVENT', JSON.stringify(event));
|
||||||
|
|
||||||
|
if (isDittoInternalKind(event.kind) && event.pubkey !== Conf.pubkey) {
|
||||||
|
throw new Error('Internal events can only be stored by the server keypair');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.#db.transaction().execute(async (trx) => {
|
||||||
|
/** Insert the event into the database. */
|
||||||
|
async function addEvent() {
|
||||||
|
await trx.insertInto('events')
|
||||||
|
.values({ ...event, tags: JSON.stringify(event.tags) })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add search data to the FTS table. */
|
||||||
|
async function indexSearch() {
|
||||||
|
const searchContent = buildSearchContent(event);
|
||||||
|
if (!searchContent) return;
|
||||||
|
await trx.insertInto('events_fts')
|
||||||
|
.values({ id: event.id, content: searchContent.substring(0, 1000) })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index event tags depending on the conditions defined above. */
|
||||||
|
async function indexTags() {
|
||||||
|
const tags = filterIndexableTags(event, opts);
|
||||||
|
const rows = tags.map(([tag, value]) => ({ event_id: event.id, tag, value }));
|
||||||
|
|
||||||
|
if (!tags.length) return;
|
||||||
|
await trx.insertInto('tags')
|
||||||
|
.values(rows)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReplaceableKind(event.kind)) {
|
||||||
|
const prevEvents = await this.getFilterQuery(trx, { kinds: [event.kind], authors: [event.pubkey] }).execute();
|
||||||
|
for (const prevEvent of prevEvents) {
|
||||||
|
if (prevEvent.created_at >= event.created_at) {
|
||||||
|
throw new Error('Cannot replace an event with an older event');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.deleteEventsTrx(trx, [{ kinds: [event.kind], authors: [event.pubkey] }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isParameterizedReplaceableKind(event.kind)) {
|
||||||
|
const d = event.tags.find(([tag]) => tag === 'd')?.[1];
|
||||||
|
if (d) {
|
||||||
|
const prevEvents = await this.getFilterQuery(trx, { kinds: [event.kind], authors: [event.pubkey], '#d': [d] })
|
||||||
|
.execute();
|
||||||
|
for (const prevEvent of prevEvents) {
|
||||||
|
if (prevEvent.created_at >= event.created_at) {
|
||||||
|
throw new Error('Cannot replace an event with an older event');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.deleteEventsTrx(trx, [{ kinds: [event.kind], authors: [event.pubkey], '#d': [d] }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the queries.
|
||||||
|
await Promise.all([
|
||||||
|
addEvent(),
|
||||||
|
indexTags(),
|
||||||
|
indexSearch(),
|
||||||
|
]);
|
||||||
|
}).catch((error) => {
|
||||||
|
// Don't throw for duplicate events.
|
||||||
|
if (error.message.includes('UNIQUE constraint failed')) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the query for a filter. */
|
||||||
|
getFilterQuery(db: Kysely<DittoDB>, filter: DittoFilter): EventQuery {
|
||||||
|
let query = db
|
||||||
|
.selectFrom('events')
|
||||||
|
.select([
|
||||||
|
'events.id',
|
||||||
|
'events.kind',
|
||||||
|
'events.pubkey',
|
||||||
|
'events.content',
|
||||||
|
'events.tags',
|
||||||
|
'events.created_at',
|
||||||
|
'events.sig',
|
||||||
|
])
|
||||||
|
.orderBy('events.created_at', 'desc');
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
|
||||||
|
switch (key as keyof DittoFilter) {
|
||||||
|
case 'ids':
|
||||||
|
query = query.where('events.id', 'in', filter.ids!);
|
||||||
|
break;
|
||||||
|
case 'kinds':
|
||||||
|
query = query.where('events.kind', 'in', filter.kinds!);
|
||||||
|
break;
|
||||||
|
case 'authors':
|
||||||
|
query = query.where('events.pubkey', 'in', filter.authors!);
|
||||||
|
break;
|
||||||
|
case 'since':
|
||||||
|
query = query.where('events.created_at', '>=', filter.since!);
|
||||||
|
break;
|
||||||
|
case 'until':
|
||||||
|
query = query.where('events.created_at', '<=', filter.until!);
|
||||||
|
break;
|
||||||
|
case 'limit':
|
||||||
|
query = query.limit(filter.limit!);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith('#')) {
|
||||||
|
const tag = key.replace(/^#/, '');
|
||||||
|
const value = filter[key as `#${string}`] as string[];
|
||||||
|
query = query
|
||||||
|
.leftJoin('tags', 'tags.event_id', 'events.id')
|
||||||
|
.where('tags.tag', '=', tag)
|
||||||
|
.where('tags.value', 'in', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.local === 'boolean') {
|
||||||
|
query = query
|
||||||
|
.leftJoin(() => this.usersQuery(), (join) => join.onRef('users.d_tag', '=', 'events.pubkey'))
|
||||||
|
.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) {
|
||||||
|
query = query
|
||||||
|
.innerJoin('events_fts', 'events_fts.id', 'events.id')
|
||||||
|
.where('events_fts.content', 'match', JSON.stringify(filter.search));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combine filter queries into a single union query. */
|
||||||
|
getEventsQuery(filters: DittoFilter[]) {
|
||||||
|
return filters
|
||||||
|
.map((filter) => this.#db.selectFrom(() => this.getFilterQuery(this.#db, filter).as('events')).selectAll())
|
||||||
|
.reduce((result, query) => result.unionAll(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query to get user events, joined by tags. */
|
||||||
|
usersQuery() {
|
||||||
|
return this.getFilterQuery(this.#db, { kinds: [30361], authors: [Conf.pubkey] })
|
||||||
|
.leftJoin('tags', 'tags.event_id', 'events.id')
|
||||||
|
.where('tags.tag', '=', 'd')
|
||||||
|
.select('tags.value as d_tag')
|
||||||
|
.as('users');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get events for filters from the database. */
|
||||||
|
async getEvents<K extends number>(
|
||||||
|
filters: DittoFilter<K>[],
|
||||||
|
opts: GetEventsOpts = {},
|
||||||
|
): Promise<DittoEvent<K>[]> {
|
||||||
|
if (opts.signal?.aborted) return Promise.resolve([]);
|
||||||
|
if (!filters.length) return Promise.resolve([]);
|
||||||
|
this.#debug('REQ', JSON.stringify(filters));
|
||||||
|
let query = this.getEventsQuery(filters);
|
||||||
|
|
||||||
|
if (typeof opts.limit === 'number') {
|
||||||
|
query = query.limit(opts.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await query.execute()).map((row) => {
|
||||||
|
const event: DittoEvent<K> = {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind as K,
|
||||||
|
pubkey: row.pubkey,
|
||||||
|
content: row.content,
|
||||||
|
created_at: row.created_at,
|
||||||
|
tags: JSON.parse(row.tags),
|
||||||
|
sig: row.sig,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (row.author_id) {
|
||||||
|
event.author = {
|
||||||
|
id: row.author_id,
|
||||||
|
kind: row.author_kind! as 0,
|
||||||
|
pubkey: row.author_pubkey!,
|
||||||
|
content: row.author_content!,
|
||||||
|
created_at: row.author_created_at!,
|
||||||
|
tags: JSON.parse(row.author_tags!),
|
||||||
|
sig: row.author_sig!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof row.author_stats_followers_count === 'number') {
|
||||||
|
event.author_stats = {
|
||||||
|
followers_count: row.author_stats_followers_count,
|
||||||
|
following_count: row.author_stats_following_count!,
|
||||||
|
notes_count: row.author_stats_notes_count!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof row.stats_replies_count === 'number') {
|
||||||
|
event.event_stats = {
|
||||||
|
replies_count: row.stats_replies_count,
|
||||||
|
reposts_count: row.stats_reposts_count!,
|
||||||
|
reactions_count: row.stats_reactions_count!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete events from each table. Should be run in a transaction! */
|
||||||
|
async deleteEventsTrx(db: Kysely<DittoDB>, filters: DittoFilter[]) {
|
||||||
|
if (!filters.length) return Promise.resolve();
|
||||||
|
this.#debug('DELETE', JSON.stringify(filters));
|
||||||
|
|
||||||
|
const query = this.getEventsQuery(filters).clearSelect().select('id');
|
||||||
|
|
||||||
|
await db.deleteFrom('events_fts')
|
||||||
|
.where('id', 'in', () => query)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return db.deleteFrom('events')
|
||||||
|
.where('id', 'in', () => query)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete events based on filters from the database. */
|
||||||
|
async deleteEvents<K extends number>(filters: DittoFilter<K>[]): Promise<void> {
|
||||||
|
if (!filters.length) return Promise.resolve();
|
||||||
|
this.#debug('DELETE', JSON.stringify(filters));
|
||||||
|
|
||||||
|
await this.#db.transaction().execute((trx) => this.deleteEventsTrx(trx, filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get number of events that would be returned by filters. */
|
||||||
|
async countEvents<K extends number>(filters: DittoFilter<K>[]): Promise<number> {
|
||||||
|
if (!filters.length) return Promise.resolve(0);
|
||||||
|
this.#debug('COUNT', JSON.stringify(filters));
|
||||||
|
const query = this.getEventsQuery(filters);
|
||||||
|
|
||||||
|
const [{ count }] = await query
|
||||||
|
.clearSelect()
|
||||||
|
.select((eb) => eb.fn.count('id').as('count'))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return Number(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return only the tags that should be indexed. */
|
||||||
|
function filterIndexableTags(event: Event, opts: StoreEventOpts): string[][] {
|
||||||
|
const tagCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
function getCount(name: string) {
|
||||||
|
return tagCounts[name] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementCount(name: string) {
|
||||||
|
tagCounts[name] = getCount(name) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCondition(name: string, value: string, condition: TagCondition) {
|
||||||
|
return condition({
|
||||||
|
event,
|
||||||
|
opts,
|
||||||
|
count: getCount(name),
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.tags.reduce<string[][]>((results, tag) => {
|
||||||
|
const [name, value] = tag;
|
||||||
|
const condition = tagConditions[name] as TagCondition | undefined;
|
||||||
|
|
||||||
|
if (value && condition && value.length < 200 && checkCondition(name, value, condition)) {
|
||||||
|
results.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementCount(name);
|
||||||
|
return results;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a search index from the event. */
|
||||||
|
function buildSearchContent(event: Event): string {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 0:
|
||||||
|
return buildUserSearchContent(event as Event<0>);
|
||||||
|
case 1:
|
||||||
|
return event.content;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build search content for a user. */
|
||||||
|
function buildUserSearchContent(event: Event<0>): string {
|
||||||
|
const { name, nip05, about } = jsonMetaContentSchema.parse(event.content);
|
||||||
|
return [name, nip05, about].filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EventsDB };
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { type DittoFilter } from '@/filter.ts';
|
||||||
|
import { type DittoEvent, type EventStore } from '@/storages/types.ts';
|
||||||
|
|
||||||
|
interface HydrateEventOpts<K extends number> {
|
||||||
|
events: DittoEvent<K>[];
|
||||||
|
filters: DittoFilter<K>[];
|
||||||
|
storage: EventStore;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrate event relationships using the provided storage. */
|
||||||
|
async function hydrateEvents<K extends number>(opts: HydrateEventOpts<K>): Promise<DittoEvent<K>[]> {
|
||||||
|
const { events, filters, storage, signal } = opts;
|
||||||
|
|
||||||
|
if (filters.some((filter) => filter.relations?.includes('author'))) {
|
||||||
|
const pubkeys = new Set([...events].map((event) => event.pubkey));
|
||||||
|
const authors = await storage.getEvents([{ kinds: [0], authors: [...pubkeys] }], { signal });
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
event.author = authors.find((author) => author.pubkey === event.pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hydrateEvents };
|
|
@ -2,7 +2,13 @@ import { assertEquals } from '@/deps-test.ts';
|
||||||
|
|
||||||
import event1 from '~/fixtures/events/event-1.json' assert { type: 'json' };
|
import event1 from '~/fixtures/events/event-1.json' assert { type: 'json' };
|
||||||
|
|
||||||
import { memorelay } from './memorelay.ts';
|
import { Memorelay } from './memorelay.ts';
|
||||||
|
|
||||||
|
const memorelay = new Memorelay({
|
||||||
|
max: 3000,
|
||||||
|
maxEntrySize: 5000,
|
||||||
|
sizeCalculation: (event) => JSON.stringify(event).length,
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test('memorelay', async () => {
|
Deno.test('memorelay', async () => {
|
||||||
assertEquals(await memorelay.countEvents([{ ids: [event1.id] }]), 0);
|
assertEquals(await memorelay.countEvents([{ ids: [event1.id] }]), 0);
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { Debug, type Event, type Filter, LRUCache, matchFilter } from '@/deps.ts';
|
||||||
|
import { normalizeFilters } from '@/filter.ts';
|
||||||
|
import { EventSet } from '@/utils/event-set.ts';
|
||||||
|
|
||||||
|
import { type EventStore, type GetEventsOpts } from './types.ts';
|
||||||
|
|
||||||
|
/** In-memory data store for events. */
|
||||||
|
class Memorelay implements EventStore {
|
||||||
|
#debug = Debug('ditto:memorelay');
|
||||||
|
#cache: LRUCache<string, Event>;
|
||||||
|
|
||||||
|
/** NIPs supported by this storage method. */
|
||||||
|
supportedNips = [1, 45];
|
||||||
|
|
||||||
|
constructor(...args: ConstructorParameters<typeof LRUCache<string, Event>>) {
|
||||||
|
this.#cache = new LRUCache<string, Event>(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Iterate stored events. */
|
||||||
|
*#events(): Generator<Event> {
|
||||||
|
for (const event of this.#cache.values()) {
|
||||||
|
if (event && !(event instanceof Promise)) {
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get events from memory. */
|
||||||
|
getEvents<K extends number>(filters: Filter<K>[], opts: GetEventsOpts = {}): Promise<Event<K>[]> {
|
||||||
|
filters = normalizeFilters(filters);
|
||||||
|
|
||||||
|
if (opts.signal?.aborted) return Promise.resolve([]);
|
||||||
|
if (!filters.length) return Promise.resolve([]);
|
||||||
|
|
||||||
|
this.#debug('REQ', JSON.stringify(filters));
|
||||||
|
|
||||||
|
/** Event results to return. */
|
||||||
|
const results = new EventSet<Event<K>>();
|
||||||
|
|
||||||
|
/** Number of times an event has been added to results for each filter. */
|
||||||
|
const filterUsages: number[] = [];
|
||||||
|
|
||||||
|
/** Check if all filters have been satisfied. */
|
||||||
|
function checkSatisfied() {
|
||||||
|
return results.size >= (opts.limit ?? Infinity) ||
|
||||||
|
filters.every((filter, index) => filter.limit && (filterUsages[index] >= filter.limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimize for filters with IDs.
|
||||||
|
filters.forEach((filter, index) => {
|
||||||
|
if (filter.ids) {
|
||||||
|
for (const id of filter.ids) {
|
||||||
|
const event = this.#cache.get(id);
|
||||||
|
if (event && matchFilter(filter, event)) {
|
||||||
|
results.add(event as Event<K>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterUsages[index] = Infinity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return early if all filters are satisfied.
|
||||||
|
if (checkSatisfied()) {
|
||||||
|
return Promise.resolve([...results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek through all events in memory.
|
||||||
|
for (const event of this.#events()) {
|
||||||
|
filters.forEach((filter, index) => {
|
||||||
|
const limit = filter.limit ?? Infinity;
|
||||||
|
const usage = filterUsages[index] ?? 0;
|
||||||
|
|
||||||
|
if (usage >= limit) {
|
||||||
|
return;
|
||||||
|
} else if (matchFilter(filter, event)) {
|
||||||
|
results.add(event as Event<K>);
|
||||||
|
this.#cache.get(event.id);
|
||||||
|
filterUsages[index] = usage + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check after each event if we can return.
|
||||||
|
if (checkSatisfied()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve([...results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert an event into memory. */
|
||||||
|
storeEvent(event: Event): Promise<void> {
|
||||||
|
this.#cache.set(event.id, event);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count events in memory for the filters. */
|
||||||
|
async countEvents(filters: Filter[]): Promise<number> {
|
||||||
|
const events = await this.getEvents(filters);
|
||||||
|
return events.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete events from memory. */
|
||||||
|
async deleteEvents(filters: Filter[]): Promise<void> {
|
||||||
|
for (const event of await this.getEvents(filters)) {
|
||||||
|
this.#cache.delete(event.id);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Memorelay };
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { Debug } from '@/deps.ts';
|
||||||
|
import { type DittoFilter, normalizeFilters } from '@/filter.ts';
|
||||||
|
import { EventSet } from '@/utils/event-set.ts';
|
||||||
|
|
||||||
|
import { type DittoEvent, type EventStore, type GetEventsOpts, type StoreEventOpts } from './types.ts';
|
||||||
|
|
||||||
|
interface OptimizerOpts {
|
||||||
|
db: EventStore;
|
||||||
|
cache: EventStore;
|
||||||
|
client: EventStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Optimizer implements EventStore {
|
||||||
|
#debug = Debug('ditto:optimizer');
|
||||||
|
|
||||||
|
#db: EventStore;
|
||||||
|
#cache: EventStore;
|
||||||
|
#client: EventStore;
|
||||||
|
|
||||||
|
supportedNips = [1];
|
||||||
|
|
||||||
|
constructor(opts: OptimizerOpts) {
|
||||||
|
this.#db = opts.db;
|
||||||
|
this.#cache = opts.cache;
|
||||||
|
this.#client = opts.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeEvent(event: DittoEvent<number>, opts?: StoreEventOpts | undefined): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.#db.storeEvent(event, opts),
|
||||||
|
this.#cache.storeEvent(event, opts),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEvents<K extends number>(
|
||||||
|
filters: DittoFilter<K>[],
|
||||||
|
opts: GetEventsOpts | undefined = {},
|
||||||
|
): Promise<DittoEvent<K>[]> {
|
||||||
|
this.#debug('REQ', JSON.stringify(filters));
|
||||||
|
|
||||||
|
const { limit = Infinity } = opts;
|
||||||
|
filters = normalizeFilters(filters);
|
||||||
|
|
||||||
|
if (opts?.signal?.aborted) return Promise.resolve([]);
|
||||||
|
if (!filters.length) return Promise.resolve([]);
|
||||||
|
|
||||||
|
const results = new EventSet<DittoEvent<K>>();
|
||||||
|
|
||||||
|
// Filters with IDs are immutable, so we can take them straight from the cache if we have them.
|
||||||
|
for (let i = 0; i < filters.length; i++) {
|
||||||
|
const filter = filters[i];
|
||||||
|
if (filter.ids) {
|
||||||
|
this.#debug(`Filter[${i}] is an IDs filter; querying cache...`);
|
||||||
|
const ids = new Set<string>(filter.ids);
|
||||||
|
for (const event of await this.#cache.getEvents([filter], opts)) {
|
||||||
|
ids.delete(event.id);
|
||||||
|
results.add(event);
|
||||||
|
if (results.size >= limit) return getResults();
|
||||||
|
}
|
||||||
|
filters[i] = { ...filter, ids: [...ids] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filters = normalizeFilters(filters);
|
||||||
|
if (!filters.length) return getResults();
|
||||||
|
|
||||||
|
// Query the database for events.
|
||||||
|
this.#debug('Querying database...');
|
||||||
|
for (const dbEvent of await this.#db.getEvents(filters, opts)) {
|
||||||
|
results.add(dbEvent);
|
||||||
|
if (results.size >= limit) return getResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// We already searched the DB, so stop if this is a search filter.
|
||||||
|
if (filters.some((filter) => typeof filter.search === 'string')) {
|
||||||
|
this.#debug(`Bailing early for search filter: "${filters[0]?.search}"`);
|
||||||
|
return getResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query the cache again.
|
||||||
|
this.#debug('Querying cache...');
|
||||||
|
for (const cacheEvent of await this.#cache.getEvents(filters, opts)) {
|
||||||
|
results.add(cacheEvent);
|
||||||
|
if (results.size >= limit) return getResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, query the client.
|
||||||
|
this.#debug('Querying client...');
|
||||||
|
for (const clientEvent of await this.#client.getEvents(filters, opts)) {
|
||||||
|
results.add(clientEvent);
|
||||||
|
if (results.size >= limit) return getResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get return type from map. */
|
||||||
|
function getResults() {
|
||||||
|
return [...results.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return getResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
countEvents<K extends number>(_filters: DittoFilter<K>[]): Promise<number> {
|
||||||
|
throw new Error('COUNT not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEvents<K extends number>(_filters: DittoFilter<K>[]): Promise<void> {
|
||||||
|
throw new Error('DELETE not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Optimizer };
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { NiceRelay } from 'https://gitlab.com/soapbox-pub/nostr-machina/-/raw/5f4fb59c90c092e5aa59c01e6556a4bec264c167/mod.ts';
|
||||||
|
|
||||||
|
import { Debug, type Event, type Filter } from '@/deps.ts';
|
||||||
|
import { type DittoFilter, normalizeFilters } from '@/filter.ts';
|
||||||
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
|
import { type DittoEvent, type EventStore, type GetEventsOpts, type StoreEventOpts } from '@/storages/types.ts';
|
||||||
|
import { EventSet } from '@/utils/event-set.ts';
|
||||||
|
|
||||||
|
interface SearchStoreOpts {
|
||||||
|
relay: string | undefined;
|
||||||
|
fallback: EventStore;
|
||||||
|
hydrator?: EventStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchStore implements EventStore {
|
||||||
|
#debug = Debug('ditto:storages:search');
|
||||||
|
|
||||||
|
#fallback: EventStore;
|
||||||
|
#hydrator: EventStore;
|
||||||
|
#relay: NiceRelay | undefined;
|
||||||
|
|
||||||
|
supportedNips = [50];
|
||||||
|
|
||||||
|
constructor(opts: SearchStoreOpts) {
|
||||||
|
this.#fallback = opts.fallback;
|
||||||
|
this.#hydrator = opts.hydrator ?? this;
|
||||||
|
|
||||||
|
if (opts.relay) {
|
||||||
|
this.#relay = new NiceRelay(opts.relay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storeEvent(_event: Event, _opts?: StoreEventOpts | undefined): Promise<void> {
|
||||||
|
throw new Error('EVENT not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEvents<K extends number>(
|
||||||
|
filters: DittoFilter<K>[],
|
||||||
|
opts?: GetEventsOpts | undefined,
|
||||||
|
): Promise<DittoEvent<K>[]> {
|
||||||
|
filters = normalizeFilters(filters);
|
||||||
|
|
||||||
|
if (opts?.signal?.aborted) return Promise.resolve([]);
|
||||||
|
if (!filters.length) return Promise.resolve([]);
|
||||||
|
|
||||||
|
this.#debug('REQ', JSON.stringify(filters));
|
||||||
|
const query = filters[0]?.search;
|
||||||
|
|
||||||
|
if (this.#relay) {
|
||||||
|
this.#debug(`Searching for "${query}" at ${this.#relay.socket.url}...`);
|
||||||
|
|
||||||
|
const sub = this.#relay.req(filters, opts);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
sub.close();
|
||||||
|
opts?.signal?.removeEventListener('abort', close);
|
||||||
|
sub.eoseSignal.removeEventListener('abort', close);
|
||||||
|
};
|
||||||
|
|
||||||
|
opts?.signal?.addEventListener('abort', close, { once: true });
|
||||||
|
sub.eoseSignal.addEventListener('abort', close, { once: true });
|
||||||
|
|
||||||
|
const events = new EventSet<DittoEvent<K>>();
|
||||||
|
|
||||||
|
for await (const event of sub) {
|
||||||
|
events.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hydrateEvents({ events: [...events], filters, storage: this.#hydrator, signal: opts?.signal });
|
||||||
|
} else {
|
||||||
|
this.#debug(`Searching for "${query}" locally...`);
|
||||||
|
return this.#fallback.getEvents(filters, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countEvents<K extends number>(_filters: Filter<K>[]): Promise<number> {
|
||||||
|
throw new Error('COUNT not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEvents<K extends number>(_filters: Filter<K>[]): Promise<void> {
|
||||||
|
throw new Error('DELETE not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SearchStore };
|
|
@ -33,6 +33,8 @@ interface DittoEvent<K extends number = number> extends Event<K> {
|
||||||
|
|
||||||
/** Storage interface for Nostr events. */
|
/** Storage interface for Nostr events. */
|
||||||
interface EventStore {
|
interface EventStore {
|
||||||
|
/** Indicates NIPs supported by this data store, similar to NIP-11. For example, `50` would indicate support for `search` filters. */
|
||||||
|
supportedNips: readonly number[];
|
||||||
/** Add an event to the store. */
|
/** Add an event to the store. */
|
||||||
storeEvent(event: Event, opts?: StoreEventOpts): Promise<void>;
|
storeEvent(event: Event, opts?: StoreEventOpts): Promise<void>;
|
||||||
/** Get events from filters. */
|
/** Get events from filters. */
|
|
@ -13,8 +13,8 @@ import {
|
||||||
} from '@/deps.ts';
|
} from '@/deps.ts';
|
||||||
import * as pipeline from '@/pipeline.ts';
|
import * as pipeline from '@/pipeline.ts';
|
||||||
import { signAdminEvent, signEvent } from '@/sign.ts';
|
import { signAdminEvent, signEvent } from '@/sign.ts';
|
||||||
|
import { eventsDB } from '@/storages.ts';
|
||||||
import { nostrNow } from '@/utils.ts';
|
import { nostrNow } from '@/utils.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
|
|
||||||
const debug = Debug('ditto:api');
|
const debug = Debug('ditto:api');
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { assertEquals } from '@/deps-test.ts';
|
||||||
|
|
||||||
|
import { EventSet } from './event-set.ts';
|
||||||
|
|
||||||
|
Deno.test('EventSet', () => {
|
||||||
|
const set = new EventSet();
|
||||||
|
assertEquals(set.size, 0);
|
||||||
|
|
||||||
|
const event = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] };
|
||||||
|
set.add(event);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event), true);
|
||||||
|
|
||||||
|
set.add(event);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event), true);
|
||||||
|
|
||||||
|
set.delete(event);
|
||||||
|
assertEquals(set.size, 0);
|
||||||
|
assertEquals(set.has(event), false);
|
||||||
|
|
||||||
|
set.delete(event);
|
||||||
|
assertEquals(set.size, 0);
|
||||||
|
assertEquals(set.has(event), false);
|
||||||
|
|
||||||
|
set.add(event);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event), true);
|
||||||
|
|
||||||
|
set.clear();
|
||||||
|
assertEquals(set.size, 0);
|
||||||
|
assertEquals(set.has(event), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('EventSet.add (replaceable)', () => {
|
||||||
|
const event0 = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] };
|
||||||
|
const event1 = { id: '2', kind: 0, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [] };
|
||||||
|
const event2 = { id: '3', kind: 0, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [] };
|
||||||
|
|
||||||
|
const set = new EventSet();
|
||||||
|
set.add(event0);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event0), true);
|
||||||
|
|
||||||
|
set.add(event1);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event0), false);
|
||||||
|
assertEquals(set.has(event1), true);
|
||||||
|
|
||||||
|
set.add(event2);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event0), false);
|
||||||
|
assertEquals(set.has(event1), false);
|
||||||
|
assertEquals(set.has(event2), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('EventSet.add (parameterized)', () => {
|
||||||
|
const event0 = { id: '1', kind: 30000, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [['d', 'a']] };
|
||||||
|
const event1 = { id: '2', kind: 30000, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [['d', 'a']] };
|
||||||
|
const event2 = { id: '3', kind: 30000, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [['d', 'a']] };
|
||||||
|
|
||||||
|
const set = new EventSet();
|
||||||
|
set.add(event0);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event0), true);
|
||||||
|
|
||||||
|
set.add(event1);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event0), false);
|
||||||
|
assertEquals(set.has(event1), true);
|
||||||
|
|
||||||
|
set.add(event2);
|
||||||
|
assertEquals(set.size, 1);
|
||||||
|
assertEquals(set.has(event0), false);
|
||||||
|
assertEquals(set.has(event1), false);
|
||||||
|
assertEquals(set.has(event2), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('EventSet.eventReplaces', () => {
|
||||||
|
const event0 = { id: '1', kind: 0, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [] };
|
||||||
|
const event1 = { id: '2', kind: 0, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [] };
|
||||||
|
const event2 = { id: '3', kind: 0, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [] };
|
||||||
|
const event3 = { id: '4', kind: 0, pubkey: 'def', content: '', created_at: 0, sig: '', tags: [] };
|
||||||
|
|
||||||
|
assertEquals(EventSet.eventReplaces(event1, event0), true);
|
||||||
|
assertEquals(EventSet.eventReplaces(event2, event0), true);
|
||||||
|
assertEquals(EventSet.eventReplaces(event2, event1), true);
|
||||||
|
|
||||||
|
assertEquals(EventSet.eventReplaces(event0, event1), false);
|
||||||
|
assertEquals(EventSet.eventReplaces(event0, event2), false);
|
||||||
|
assertEquals(EventSet.eventReplaces(event1, event2), false);
|
||||||
|
|
||||||
|
assertEquals(EventSet.eventReplaces(event3, event1), false);
|
||||||
|
assertEquals(EventSet.eventReplaces(event1, event3), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('EventSet.eventReplaces (parameterized)', () => {
|
||||||
|
const event0 = { id: '1', kind: 30000, pubkey: 'abc', content: '', created_at: 0, sig: '', tags: [['d', 'a']] };
|
||||||
|
const event1 = { id: '2', kind: 30000, pubkey: 'abc', content: '', created_at: 1, sig: '', tags: [['d', 'a']] };
|
||||||
|
const event2 = { id: '3', kind: 30000, pubkey: 'abc', content: '', created_at: 2, sig: '', tags: [['d', 'a']] };
|
||||||
|
|
||||||
|
assertEquals(EventSet.eventReplaces(event1, event0), true);
|
||||||
|
assertEquals(EventSet.eventReplaces(event2, event0), true);
|
||||||
|
assertEquals(EventSet.eventReplaces(event2, event1), true);
|
||||||
|
|
||||||
|
assertEquals(EventSet.eventReplaces(event0, event1), false);
|
||||||
|
assertEquals(EventSet.eventReplaces(event0, event2), false);
|
||||||
|
assertEquals(EventSet.eventReplaces(event1, event2), false);
|
||||||
|
});
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { type Event } from '@/deps.ts';
|
||||||
|
import { isParameterizedReplaceableKind, isReplaceableKind } from '@/kinds.ts';
|
||||||
|
|
||||||
|
/** In-memory store for Nostr events with replaceable event functionality. */
|
||||||
|
class EventSet<E extends Event = Event> implements Set<E> {
|
||||||
|
#map = new Map<string, E>();
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this.#map.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(event: E): this {
|
||||||
|
if (isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind)) {
|
||||||
|
for (const e of this.values()) {
|
||||||
|
if (EventSet.eventReplaces(event, e)) {
|
||||||
|
this.delete(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#map.set(event.id, event);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.#map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(event: E): boolean {
|
||||||
|
return this.#map.delete(event.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(callbackfn: (event: E, key: E, set: Set<E>) => void, thisArg?: any): void {
|
||||||
|
return this.#map.forEach((event, _id) => callbackfn(event, event, this), thisArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(event: E): boolean {
|
||||||
|
return this.#map.has(event.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
*entries(): IterableIterator<[E, E]> {
|
||||||
|
for (const event of this.#map.values()) {
|
||||||
|
yield [event, event];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys(): IterableIterator<E> {
|
||||||
|
return this.#map.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
values(): IterableIterator<E> {
|
||||||
|
return this.#map.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.iterator](): IterableIterator<E> {
|
||||||
|
return this.#map.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.toStringTag]: string = 'EventSet';
|
||||||
|
|
||||||
|
/** Returns true if both events are replaceable, belong to the same kind and pubkey (and `d` tag, for parameterized events), and the first event is newer than the second one. */
|
||||||
|
static eventReplaces(event: Event, target: Event): boolean {
|
||||||
|
if (isReplaceableKind(event.kind)) {
|
||||||
|
return event.kind === target.kind && event.pubkey === target.pubkey && event.created_at > target.created_at;
|
||||||
|
} else if (isParameterizedReplaceableKind(event.kind)) {
|
||||||
|
const d = event.tags.find(([name]) => name === 'd')?.[1] || '';
|
||||||
|
const d2 = target.tags.find(([name]) => name === 'd')?.[1] || '';
|
||||||
|
|
||||||
|
return event.kind === target.kind &&
|
||||||
|
event.pubkey === target.pubkey &&
|
||||||
|
d === d2 &&
|
||||||
|
event.created_at > target.created_at;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EventSet };
|
|
@ -1,6 +1,6 @@
|
||||||
import { AppContext } from '@/app.ts';
|
import { AppContext } from '@/app.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import { type Filter } from '@/deps.ts';
|
import { type Filter } from '@/deps.ts';
|
||||||
|
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';
|
||||||
|
|
|
@ -2,8 +2,8 @@ import { Conf } from '@/config.ts';
|
||||||
import { findUser } from '@/db/users.ts';
|
import { findUser } from '@/db/users.ts';
|
||||||
import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
|
import { lodash, nip19, type UnsignedEvent } from '@/deps.ts';
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||||
|
import { type DittoEvent } from '@/storages/types.ts';
|
||||||
import { verifyNip05Cached } from '@/utils/nip05.ts';
|
import { verifyNip05Cached } from '@/utils/nip05.ts';
|
||||||
import { type DittoEvent } from '@/store.ts';
|
|
||||||
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
||||||
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { eventsDB } from '@/db/events.ts';
|
import { eventsDB } from '@/storages.ts';
|
||||||
import { hasTag } from '@/tags.ts';
|
import { hasTag } from '@/tags.ts';
|
||||||
|
|
||||||
async function renderRelationship(sourcePubkey: string, targetPubkey: string) {
|
async function renderRelationship(sourcePubkey: string, targetPubkey: string) {
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts';
|
import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { eventsDB } from '@/db/events.ts';
|
|
||||||
import { findReplyTag, nip19 } from '@/deps.ts';
|
import { findReplyTag, nip19 } from '@/deps.ts';
|
||||||
import { getMediaLinks, parseNoteContent } from '@/note.ts';
|
import { getMediaLinks, parseNoteContent } from '@/note.ts';
|
||||||
import { getAuthor } from '@/queries.ts';
|
import { getAuthor } from '@/queries.ts';
|
||||||
import { jsonMediaDataSchema } from '@/schemas/nostr.ts';
|
import { jsonMediaDataSchema } from '@/schemas/nostr.ts';
|
||||||
import { DittoEvent } from '@/store.ts';
|
import { eventsDB } from '@/storages.ts';
|
||||||
|
import { type DittoEvent } from '@/storages/types.ts';
|
||||||
import { nostrDate } from '@/utils.ts';
|
import { nostrDate } from '@/utils.ts';
|
||||||
import { unfurlCardCached } from '@/utils/unfurl.ts';
|
import { unfurlCardCached } from '@/utils/unfurl.ts';
|
||||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
|
|
Loading…
Reference in New Issue