Support replaceable events and parameterized replaceable events (delete old versions upon insert)

This commit is contained in:
Alex Gleason 2023-12-29 23:21:05 -06:00
parent 08059f6b40
commit 529bc11da1
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
5 changed files with 77 additions and 27 deletions

View File

@ -16,4 +16,6 @@ lint:
test:
stage: test
script: deno task test
script: deno task test
variables:
DITTO_NSEC: nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwkhnav

View File

@ -7,7 +7,7 @@ const relayInfoController: AppController = (c) => {
description: 'Nostr and the Fediverse.',
pubkey: Conf.pubkey,
contact: `mailto:${Conf.adminEmail}`,
supported_nips: [1, 5, 9, 11, 45, 46, 98],
supported_nips: [1, 5, 9, 11, 16, 45, 46, 98],
software: 'Ditto',
version: '0.0.0',
limitation: {

View File

@ -1,6 +1,7 @@
import { assertEquals } from '@/deps-test.ts';
import { insertUser } from '@/db/users.ts';
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';
@ -38,13 +39,26 @@ Deno.test('query events with local filter', async () => {
assertEquals(await db.getEvents([{ local: true }]), []);
assertEquals(await db.getEvents([{ local: false }]), [event1]);
await insertUser({
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]);
});

View File

@ -1,8 +1,8 @@
import { Conf } from '@/config.ts';
import { db, type DittoDB } from '@/db.ts';
import { Debug, type Event, type SelectQueryBuilder } from '@/deps.ts';
import { Debug, type Event, Kysely, type SelectQueryBuilder } from '@/deps.ts';
import { type DittoFilter } from '@/filter.ts';
import { isDittoInternalKind, isParameterizedReplaceableKind } from '@/kinds.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';
@ -31,14 +31,14 @@ const tagConditions: Record<string, TagCondition> = {
};
/** Insert an event (and its tags) into the database. */
function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise<void> {
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 db.transaction().execute(async (trx) => {
return await db.transaction().execute(async (trx) => {
/** Insert the event into the database. */
async function addEvent() {
await trx.insertInto('events')
@ -66,6 +66,30 @@ function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise<void> {
.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(),
@ -106,7 +130,7 @@ type EventQuery = SelectQueryBuilder<DittoDB, 'events', {
}>;
/** Build the query for a filter. */
function getFilterQuery(filter: DittoFilter): EventQuery {
function getFilterQuery(db: Kysely<DittoDB>, filter: DittoFilter): EventQuery {
let query = db
.selectFrom('events')
.select([
@ -215,13 +239,13 @@ function getFilterQuery(filter: DittoFilter): EventQuery {
/** Combine filter queries into a single union query. */
function getEventsQuery(filters: DittoFilter[]) {
return filters
.map((filter) => db.selectFrom(() => getFilterQuery(filter).as('events')).selectAll())
.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({ kinds: [30361], authors: [Conf.pubkey] })
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')
@ -284,22 +308,28 @@ async function getEvents<K extends number>(
});
}
/** 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(async (trx) => {
const query = getEventsQuery(filters).clearSelect().select('id');
await trx.deleteFrom('events_fts')
.where('id', 'in', () => query)
.execute();
return trx.deleteFrom('events')
.where('id', 'in', () => query)
.execute();
});
await db.transaction().execute((trx) => deleteEventsTrx(trx, filters));
}
/** Get number of events that would be returned by filters. */

View File

@ -13,12 +13,11 @@ interface User {
admin: boolean;
}
/** Adds a user to the database. */
async function insertUser(user: User) {
function buildUserEvent(user: User) {
debug('insertUser', JSON.stringify(user));
const { origin, host } = Conf.url;
const event = await signAdminEvent({
return signAdminEvent({
kind: 30361,
tags: [
['d', user.pubkey],
@ -31,7 +30,12 @@ async function insertUser(user: User) {
content: '',
created_at: Math.floor(user.inserted_at.getTime() / 1000),
});
}
/** Adds a user to the database. */
async function insertUser(user: User) {
debug('insertUser', JSON.stringify(user));
const event = await buildUserEvent(user);
return pipeline.handleEvent(event);
}
@ -71,4 +75,4 @@ async function findUser(user: Partial<User>): Promise<User | undefined> {
}
}
export { findUser, insertUser, type User };
export { buildUserEvent, findUser, insertUser, type User };