Support replaceable events and parameterized replaceable events (delete old versions upon insert)
This commit is contained in:
parent
08059f6b40
commit
529bc11da1
|
@ -17,3 +17,5 @@ lint:
|
|||
test:
|
||||
stage: test
|
||||
script: deno task test
|
||||
variables:
|
||||
DITTO_NSEC: nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwkhnav
|
|
@ -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: {
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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 };
|
||||
|
|
Loading…
Reference in New Issue