Merge remote-tracking branch 'origin/main' into tag-queries

This commit is contained in:
Alex Gleason 2024-07-29 14:14:35 -05:00
commit 51bdd977e1
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
17 changed files with 518 additions and 153 deletions

View file

@ -35,11 +35,11 @@ test:
postgres: postgres:
stage: test stage: test
script: deno task db:migrate script: deno task db:migrate && deno task test
services: services:
- postgres:16 - postgres:16
variables: variables:
DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz
DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
ALLOW_TO_USE_DATABASE_URL: true

View file

@ -8,7 +8,7 @@
"db:migrate": "deno run -A scripts/db-migrate.ts", "db:migrate": "deno run -A scripts/db-migrate.ts",
"nostr:pull": "deno run -A scripts/nostr-pull.ts", "nostr:pull": "deno run -A scripts/nostr-pull.ts",
"debug": "deno run -A --inspect src/server.ts", "debug": "deno run -A --inspect src/server.ts",
"test": "DATABASE_URL=\"sqlite://:memory:\" deno test -A --junit-path=./deno-test.xml", "test": "deno test -A --junit-path=./deno-test.xml",
"check": "deno check src/server.ts", "check": "deno check src/server.ts",
"nsec": "deno run scripts/nsec.ts", "nsec": "deno run scripts/nsec.ts",
"admin:event": "deno run -A scripts/admin-event.ts", "admin:event": "deno run -A scripts/admin-event.ts",

View file

@ -5,9 +5,6 @@ import { logger } from '@hono/hono/logger';
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify'; import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug'; import Debug from '@soapbox/stickynotes/debug';
import { Conf } from '@/config.ts';
import { cron } from '@/cron.ts';
import { startFirehose } from '@/firehose.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
import { import {
@ -42,8 +39,10 @@ import { bookmarksController } from '@/controllers/api/bookmarks.ts';
import { import {
adminRelaysController, adminRelaysController,
adminSetRelaysController, adminSetRelaysController,
deleteZapSplitsController,
nameRequestController, nameRequestController,
nameRequestsController, nameRequestsController,
updateZapSplitsController,
} from '@/controllers/api/ditto.ts'; } from '@/controllers/api/ditto.ts';
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts'; import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
import { import {
@ -111,6 +110,7 @@ import {
import { errorHandler } from '@/controllers/error.ts'; import { errorHandler } from '@/controllers/error.ts';
import { metricsController } from '@/controllers/metrics.ts'; import { metricsController } from '@/controllers/metrics.ts';
import { indexController } from '@/controllers/site.ts'; import { indexController } from '@/controllers/site.ts';
import '@/startup.ts';
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
import { nostrController } from '@/controllers/well-known/nostr.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts';
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
@ -143,13 +143,6 @@ const app = new Hono<AppEnv>({ strict: false });
const debug = Debug('ditto:http'); const debug = Debug('ditto:http');
if (Conf.firehoseEnabled) {
startFirehose();
}
if (Conf.cronEnabled) {
cron();
}
app.use('*', rateLimitMiddleware(300, Time.minutes(5))); app.use('*', rateLimitMiddleware(300, Time.minutes(5)));
app.use('/api/*', metricsMiddleware, logger(debug)); app.use('/api/*', metricsMiddleware, logger(debug));
@ -270,6 +263,9 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro
app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController);
app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController);
app.post('/api/v1/ditto/zap', requireSigner, zapController); app.post('/api/v1/ditto/zap', requireSigner, zapController);
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController); app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);

View file

@ -1,14 +1,18 @@
import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { booleanParamSchema } from '@/schema.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { booleanParamSchema } from '@/schema.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { createEvent, paginated, paginationSchema } from '@/utils/api.ts'; import { createEvent, paginated, paginationSchema, parseBody } from '@/utils/api.ts';
import { renderNameRequest } from '@/views/ditto.ts'; import { renderNameRequest } from '@/views/ditto.ts';
import { getZapSplits } from '@/utils/zap-split.ts';
import { updateListAdminEvent } from '@/utils/api.ts';
import { addTag } from '@/utils/tags.ts';
import { deleteTag } from '@/utils/tags.ts';
const markerSchema = z.enum(['read', 'write']); const markerSchema = z.enum(['read', 'write']);
@ -148,3 +152,74 @@ export const nameRequestsController: AppController = async (c) => {
return paginated(c, orig, nameRequests); return paginated(c, orig, nameRequests);
}; };
const zapSplitSchema = z.record(
n.id(),
z.object({
amount: z.number().int().min(1).max(100),
message: z.string().max(500),
}),
);
export const updateZapSplitsController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = zapSplitSchema.safeParse(body);
const store = c.get('store');
if (!result.success) {
return c.json({ error: result.error }, 400);
}
const zap_split = await getZapSplits(store, Conf.pubkey);
if (!zap_split) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
}
const { data } = result;
const pubkeys = Object.keys(data);
if (pubkeys.length < 1) {
return c.json(200);
}
await updateListAdminEvent(
{ kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) =>
pubkeys.reduce((accumulator, pubkey) => {
return addTag(accumulator, ['p', pubkey, data[pubkey].amount.toString(), data[pubkey].message]);
}, tags),
c,
);
return c.json(200);
};
const deleteZapSplitSchema = z.array(n.id()).min(1);
export const deleteZapSplitsController: AppController = async (c) => {
const body = await parseBody(c.req.raw);
const result = deleteZapSplitSchema.safeParse(body);
const store = c.get('store');
if (!result.success) {
return c.json({ error: result.error }, 400);
}
const zap_split = await getZapSplits(store, Conf.pubkey);
if (!zap_split) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
}
const { data } = result;
await updateListAdminEvent(
{ kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) =>
data.reduce((accumulator, currentValue) => {
return deleteTag(accumulator, ['p', currentValue]);
}, tags),
c,
);
return c.json(200);
};

View file

@ -4,12 +4,16 @@ import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
const version = `3.0.0 (compatible; Ditto ${denoJson.version})`; const version = `3.0.0 (compatible; Ditto ${denoJson.version})`;
const instanceV1Controller: AppController = async (c) => { const instanceV1Controller: AppController = async (c) => {
const { host, protocol } = Conf.url; const { host, protocol } = Conf.url;
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
const store = c.get('store');
const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {};
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -68,6 +72,9 @@ const instanceV1Controller: AppController = async (c) => {
}, },
}, },
rules: [], rules: [],
ditto: {
zap_split,
},
}); });
}; };

View file

@ -6,10 +6,15 @@ import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { addTag, deleteTag } from '@/utils/tags.ts';
import { asyncReplaceAll } from '@/utils/text.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
import { lookupPubkey } from '@/utils/lookup.ts';
import { renderEventAccounts } from '@/views.ts'; import { renderEventAccounts } from '@/views.ts';
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
@ -24,11 +29,7 @@ import {
updateListEvent, updateListEvent,
} from '@/utils/api.ts'; } from '@/utils/api.ts';
import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
import { lookupPubkey } from '@/utils/lookup.ts'; import { getZapSplits } from '@/utils/zap-split.ts';
import { addTag, deleteTag } from '@/utils/tags.ts';
import { asyncReplaceAll } from '@/utils/text.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
const createStatusSchema = z.object({ const createStatusSchema = z.object({
in_reply_to_id: n.id().nullish(), in_reply_to_id: n.id().nullish(),
@ -71,6 +72,7 @@ const createStatusController: AppController = async (c) => {
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = createStatusSchema.safeParse(body); const result = createStatusSchema.safeParse(body);
const kysely = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
const store = c.get('store');
if (!result.success) { if (!result.success) {
return c.json({ error: 'Bad request', schema: result.error }, 400); return c.json({ error: 'Bad request', schema: result.error }, 400);
@ -173,14 +175,28 @@ const createStatusController: AppController = async (c) => {
const quoteCompat = data.quote_id ? `\n\nnostr:${nip19.noteEncode(data.quote_id)}` : ''; const quoteCompat = data.quote_id ? `\n\nnostr:${nip19.noteEncode(data.quote_id)}` : '';
const mediaCompat = mediaUrls.length ? `\n\n${mediaUrls.join('\n')}` : ''; const mediaCompat = mediaUrls.length ? `\n\n${mediaUrls.join('\n')}` : '';
const author = await getAuthor(await c.get('signer')?.getPublicKey()!);
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta);
const zap_split = await getZapSplits(store, Conf.pubkey);
if (lnurl && zap_split) {
let totalSplit = 0;
for (const pubkey in zap_split) {
totalSplit += zap_split[pubkey].amount;
tags.push(['zap', pubkey, Conf.relay, zap_split[pubkey].amount.toString()]);
}
if (totalSplit) {
tags.push(['zap', author?.pubkey as string, Conf.relay, Math.max(0, 100 - totalSplit).toString()]);
}
}
const event = await createEvent({ const event = await createEvent({
kind: 1, kind: 1,
content: content + quoteCompat + mediaCompat, content: content + quoteCompat + mediaCompat,
tags, tags,
}, c); }, c);
const author = await getAuthor(event.pubkey);
if (data.quote_id) { if (data.quote_id) {
await hydrateEvents({ await hydrateEvents({
events: [event], events: [event],
@ -189,7 +205,7 @@ const createStatusController: AppController = async (c) => {
}); });
} }
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: await c.get('signer')?.getPublicKey() })); return c.json(await renderStatus({ ...event, author }, { viewerPubkey: author?.pubkey }));
}; };
const deleteStatusController: AppController = async (c) => { const deleteStatusController: AppController = async (c) => {

View file

@ -1,11 +1,11 @@
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { generateSecretKey } from 'nostr-tools'; import { generateSecretKey } from 'nostr-tools';
import { genEvent, getTestDB } from '@/test.ts'; import { createTestDB, genEvent, getTestDB } from '@/test.ts';
import { handleZaps } from '@/pipeline.ts'; import { handleZaps } from '@/pipeline.ts';
Deno.test('store one zap receipt in nostr_events; convert it into event_zaps table format and store it', async () => { Deno.test('store one zap receipt in nostr_events; convert it into event_zaps table format and store it', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const kysely = db.kysely; const kysely = db.kysely;
const sk = generateSecretKey(); const sk = generateSecretKey();

22
src/schema.test.ts Normal file
View file

@ -0,0 +1,22 @@
import { assertEquals } from '@std/assert';
import { percentageSchema } from '@/schema.ts';
Deno.test('Value is any percentage from 1 to 100', () => {
assertEquals(percentageSchema.safeParse('latvia' as unknown).success, false);
assertEquals(percentageSchema.safeParse(1.5).success, false);
assertEquals(percentageSchema.safeParse(Infinity).success, false);
assertEquals(percentageSchema.safeParse('Infinity').success, false);
assertEquals(percentageSchema.safeParse('0').success, false);
assertEquals(percentageSchema.safeParse(0).success, false);
assertEquals(percentageSchema.safeParse(-1).success, false);
assertEquals(percentageSchema.safeParse('-10').success, false);
assertEquals(percentageSchema.safeParse([]).success, false);
assertEquals(percentageSchema.safeParse(undefined).success, false);
for (let i = 1; i < 100; i++) {
assertEquals(percentageSchema.safeParse(String(i)).success, true);
}
assertEquals(percentageSchema.safeParse('1e1').success, true);
});

View file

@ -38,4 +38,14 @@ const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value
/** Schema for `File` objects. */ /** Schema for `File` objects. */
const fileSchema = z.custom<File>((value) => value instanceof File); const fileSchema = z.custom<File>((value) => value instanceof File);
export { booleanParamSchema, decode64Schema, fileSchema, filteredArray, hashtagSchema, safeUrlSchema }; const percentageSchema = z.coerce.number().int().gte(1).lte(100);
export {
booleanParamSchema,
decode64Schema,
fileSchema,
filteredArray,
hashtagSchema,
percentageSchema,
safeUrlSchema,
};

16
src/startup.ts Normal file
View file

@ -0,0 +1,16 @@
// Starts up applications required to run before the HTTP server is on.
import { Conf } from '@/config.ts';
import { seedZapSplits } from '@/utils/zap-split.ts';
import { cron } from '@/cron.ts';
import { startFirehose } from '@/firehose.ts';
if (Conf.firehoseEnabled) {
startFirehose();
}
if (Conf.cronEnabled) {
cron();
}
await seedZapSplits();

View file

@ -1,87 +1,75 @@
import { Database as Sqlite } from '@db/sqlite';
import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite';
import { assertEquals, assertRejects } from '@std/assert'; import { assertEquals, assertRejects } from '@std/assert';
import { Kysely } from 'kysely';
import { generateSecretKey } from 'nostr-tools'; import { generateSecretKey } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { RelayError } from '@/RelayError.ts'; import { RelayError } from '@/RelayError.ts';
import { EventsDB } from '@/storages/EventsDB.ts';
import { eventFixture, genEvent } from '@/test.ts'; import { eventFixture, genEvent } from '@/test.ts';
import { Conf } from '@/config.ts';
/** Create in-memory database for testing. */ import { createTestDB } from '@/test.ts';
const createDB = async () => {
const kysely = new Kysely<DittoTables>({
dialect: new DenoSqlite3Dialect({
database: new Sqlite(':memory:'),
}),
});
const eventsDB = new EventsDB(kysely);
await DittoDB.migrate(kysely);
return { eventsDB, kysely };
};
Deno.test('count filters', async () => { Deno.test('count filters', async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
const event1 = await eventFixture('event-1'); const event1 = await eventFixture('event-1');
assertEquals((await eventsDB.count([{ kinds: [1] }])).count, 0); assertEquals((await store.count([{ kinds: [1] }])).count, 0);
await eventsDB.event(event1); await store.event(event1);
assertEquals((await eventsDB.count([{ kinds: [1] }])).count, 1); assertEquals((await store.count([{ kinds: [1] }])).count, 1);
}); });
Deno.test('insert and filter events', async () => { Deno.test('insert and filter events', async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
const event1 = await eventFixture('event-1'); const event1 = await eventFixture('event-1');
await eventsDB.event(event1); await store.event(event1);
assertEquals(await eventsDB.query([{ kinds: [1] }]), [event1]); assertEquals(await store.query([{ kinds: [1] }]), [event1]);
assertEquals(await eventsDB.query([{ kinds: [3] }]), []); assertEquals(await store.query([{ kinds: [3] }]), []);
assertEquals(await eventsDB.query([{ since: 1691091000 }]), [event1]); assertEquals(await store.query([{ since: 1691091000 }]), [event1]);
assertEquals(await eventsDB.query([{ until: 1691091000 }]), []); assertEquals(await store.query([{ until: 1691091000 }]), []);
assertEquals( assertEquals(
await eventsDB.query([{ '#proxy': ['https://gleasonator.com/objects/8f6fac53-4f66-4c6e-ac7d-92e5e78c3e79'] }]), await store.query([{ '#proxy': ['https://gleasonator.com/objects/8f6fac53-4f66-4c6e-ac7d-92e5e78c3e79'] }]),
[event1], [event1],
); );
}); });
Deno.test('query events with domain search filter', async () => { Deno.test('query events with domain search filter', async () => {
const { eventsDB, kysely } = await createDB(); await using db = await createTestDB();
const { store, kysely } = db;
const event1 = await eventFixture('event-1'); const event1 = await eventFixture('event-1');
await eventsDB.event(event1); await store.event(event1);
assertEquals(await eventsDB.query([{}]), [event1]); assertEquals(await store.query([{}]), [event1]);
assertEquals(await eventsDB.query([{ search: 'domain:localhost:4036' }]), []); assertEquals(await store.query([{ search: 'domain:localhost:4036' }]), []);
assertEquals(await eventsDB.query([{ search: '' }]), [event1]); assertEquals(await store.query([{ search: '' }]), [event1]);
await kysely await kysely
.insertInto('pubkey_domains') .insertInto('pubkey_domains')
.values({ pubkey: event1.pubkey, domain: 'localhost:4036', last_updated_at: event1.created_at }) .values({ pubkey: event1.pubkey, domain: 'localhost:4036', last_updated_at: event1.created_at })
.execute(); .execute();
assertEquals(await eventsDB.query([{ kinds: [1], search: 'domain:localhost:4036' }]), [event1]); assertEquals(await store.query([{ kinds: [1], search: 'domain:localhost:4036' }]), [event1]);
assertEquals(await eventsDB.query([{ kinds: [1], search: 'domain:example.com' }]), []); assertEquals(await store.query([{ kinds: [1], search: 'domain:example.com' }]), []);
}); });
Deno.test('delete events', async () => { Deno.test('delete events', async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
const [one, two] = [ const [one, two] = [
{ id: '1', kind: 1, pubkey: 'abc', content: 'hello world', created_at: 1, sig: '', tags: [] }, { id: '1', kind: 1, pubkey: 'abc', content: 'hello world', created_at: 1, sig: '', tags: [] },
{ id: '2', kind: 1, pubkey: 'abc', content: 'yolo fam', created_at: 2, sig: '', tags: [] }, { id: '2', kind: 1, pubkey: 'abc', content: 'yolo fam', created_at: 2, sig: '', tags: [] },
]; ];
await eventsDB.event(one); await store.event(one);
await eventsDB.event(two); await store.event(two);
// Sanity check // Sanity check
assertEquals(await eventsDB.query([{ kinds: [1] }]), [two, one]); assertEquals(await store.query([{ kinds: [1] }]), [two, one]);
await eventsDB.event({ await store.event({
kind: 5, kind: 5,
pubkey: one.pubkey, pubkey: one.pubkey,
tags: [['e', one.id]], tags: [['e', one.id]],
@ -91,19 +79,20 @@ Deno.test('delete events', async () => {
sig: '', sig: '',
}); });
assertEquals(await eventsDB.query([{ kinds: [1] }]), [two]); assertEquals(await store.query([{ kinds: [1] }]), [two]);
}); });
Deno.test("user cannot delete another user's event", async () => { Deno.test("user cannot delete another user's event", async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
const event = { id: '1', kind: 1, pubkey: 'abc', content: 'hello world', created_at: 1, sig: '', tags: [] }; const event = { id: '1', kind: 1, pubkey: 'abc', content: 'hello world', created_at: 1, sig: '', tags: [] };
await eventsDB.event(event); await store.event(event);
// Sanity check // Sanity check
assertEquals(await eventsDB.query([{ kinds: [1] }]), [event]); assertEquals(await store.query([{ kinds: [1] }]), [event]);
await eventsDB.event({ await store.event({
kind: 5, kind: 5,
pubkey: 'def', // different pubkey pubkey: 'def', // different pubkey
tags: [['e', event.id]], tags: [['e', event.id]],
@ -113,24 +102,25 @@ Deno.test("user cannot delete another user's event", async () => {
sig: '', sig: '',
}); });
assertEquals(await eventsDB.query([{ kinds: [1] }]), [event]); assertEquals(await store.query([{ kinds: [1] }]), [event]);
}); });
Deno.test('admin can delete any event', async () => { Deno.test('admin can delete any event', async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
const [one, two] = [ const [one, two] = [
{ id: '1', kind: 1, pubkey: 'abc', content: 'hello world', created_at: 1, sig: '', tags: [] }, { id: '1', kind: 1, pubkey: 'abc', content: 'hello world', created_at: 1, sig: '', tags: [] },
{ id: '2', kind: 1, pubkey: 'abc', content: 'yolo fam', created_at: 2, sig: '', tags: [] }, { id: '2', kind: 1, pubkey: 'abc', content: 'yolo fam', created_at: 2, sig: '', tags: [] },
]; ];
await eventsDB.event(one); await store.event(one);
await eventsDB.event(two); await store.event(two);
// Sanity check // Sanity check
assertEquals(await eventsDB.query([{ kinds: [1] }]), [two, one]); assertEquals(await store.query([{ kinds: [1] }]), [two, one]);
await eventsDB.event({ await store.event({
kind: 5, kind: 5,
pubkey: Conf.pubkey, // Admin pubkey pubkey: Conf.pubkey, // Admin pubkey
tags: [['e', one.id]], tags: [['e', one.id]],
@ -140,83 +130,89 @@ Deno.test('admin can delete any event', async () => {
sig: '', sig: '',
}); });
assertEquals(await eventsDB.query([{ kinds: [1] }]), [two]); assertEquals(await store.query([{ kinds: [1] }]), [two]);
}); });
Deno.test('throws a RelayError when inserting an event deleted by the admin', async () => { Deno.test('throws a RelayError when inserting an event deleted by the admin', async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
const event = genEvent(); const event = genEvent();
await eventsDB.event(event); await store.event(event);
const deletion = genEvent({ kind: 5, tags: [['e', event.id]] }, Conf.seckey); const deletion = genEvent({ kind: 5, tags: [['e', event.id]] }, Conf.seckey);
await eventsDB.event(deletion); await store.event(deletion);
await assertRejects( await assertRejects(
() => eventsDB.event(event), () => store.event(event),
RelayError, RelayError,
'event deleted by admin', 'event deleted by admin',
); );
}); });
Deno.test('throws a RelayError when inserting an event deleted by a user', async () => { Deno.test('throws a RelayError when inserting an event deleted by a user', async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
const sk = generateSecretKey(); const sk = generateSecretKey();
const event = genEvent({}, sk); const event = genEvent({}, sk);
await eventsDB.event(event); await store.event(event);
const deletion = genEvent({ kind: 5, tags: [['e', event.id]] }, sk); const deletion = genEvent({ kind: 5, tags: [['e', event.id]] }, sk);
await eventsDB.event(deletion); await store.event(deletion);
await assertRejects( await assertRejects(
() => eventsDB.event(event), () => store.event(event),
RelayError, RelayError,
'event deleted by user', 'event deleted by user',
); );
}); });
Deno.test('inserting replaceable events', async () => { Deno.test('inserting replaceable events', async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
const event = await eventFixture('event-0'); const event = await eventFixture('event-0');
await eventsDB.event(event); await store.event(event);
const olderEvent = { ...event, id: '123', created_at: event.created_at - 1 }; const olderEvent = { ...event, id: '123', created_at: event.created_at - 1 };
await eventsDB.event(olderEvent); await store.event(olderEvent);
assertEquals(await eventsDB.query([{ kinds: [0], authors: [event.pubkey] }]), [event]); assertEquals(await store.query([{ kinds: [0], authors: [event.pubkey] }]), [event]);
const newerEvent = { ...event, id: '123', created_at: event.created_at + 1 }; const newerEvent = { ...event, id: '123', created_at: event.created_at + 1 };
await eventsDB.event(newerEvent); await store.event(newerEvent);
assertEquals(await eventsDB.query([{ kinds: [0] }]), [newerEvent]); assertEquals(await store.query([{ kinds: [0] }]), [newerEvent]);
}); });
Deno.test("throws a RelayError when querying an event with a large 'since'", async () => { Deno.test("throws a RelayError when querying an event with a large 'since'", async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
await assertRejects( await assertRejects(
() => eventsDB.query([{ since: 33333333333333 }]), () => store.query([{ since: 33333333333333 }]),
RelayError, RelayError,
'since filter too far into the future', 'since filter too far into the future',
); );
}); });
Deno.test("throws a RelayError when querying an event with a large 'until'", async () => { Deno.test("throws a RelayError when querying an event with a large 'until'", async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
await assertRejects( await assertRejects(
() => eventsDB.query([{ until: 66666666666666 }]), () => store.query([{ until: 66666666666666 }]),
RelayError, RelayError,
'until filter too far into the future', 'until filter too far into the future',
); );
}); });
Deno.test("throws a RelayError when querying an event with a large 'kind'", async () => { Deno.test("throws a RelayError when querying an event with a large 'kind'", async () => {
const { eventsDB } = await createDB(); await using db = await createTestDB();
const { store } = db;
await assertRejects( await assertRejects(
() => eventsDB.query([{ kinds: [99999999999999] }]), () => store.query([{ kinds: [99999999999999] }]),
RelayError, RelayError,
'kind filter too far into the future', 'kind filter too far into the future',
); );

View file

@ -3,21 +3,23 @@ import { assertEquals } from '@std/assert';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { eventFixture } from '@/test.ts'; import { createTestDB, eventFixture } from '@/test.ts';
Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => {
const db = new MockRelay(); const relay = new MockRelay();
await using db = await createTestDB();
const event0 = await eventFixture('event-0'); const event0 = await eventFixture('event-0');
const event1 = await eventFixture('event-1'); const event1 = await eventFixture('event-1');
// Save events to database // Save events to database
await db.event(event0); await relay.event(event0);
await db.event(event1); await relay.event(event1);
await hydrateEvents({ await hydrateEvents({
events: [event1], events: [event1],
store: db, store: relay,
kysely: db.kysely,
}); });
const expectedEvent = { ...event1, author: event0 }; const expectedEvent = { ...event1, author: event0 };
@ -25,7 +27,8 @@ Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => {
}); });
Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
const db = new MockRelay(); const relay = new MockRelay();
await using db = await createTestDB();
const event0madePost = await eventFixture('event-0-the-one-who-post-and-users-repost'); const event0madePost = await eventFixture('event-0-the-one-who-post-and-users-repost');
const event0madeRepost = await eventFixture('event-0-the-one-who-repost'); const event0madeRepost = await eventFixture('event-0-the-one-who-repost');
@ -33,14 +36,15 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
const event6 = await eventFixture('event-6'); const event6 = await eventFixture('event-6');
// Save events to database // Save events to database
await db.event(event0madePost); await relay.event(event0madePost);
await db.event(event0madeRepost); await relay.event(event0madeRepost);
await db.event(event1reposted); await relay.event(event1reposted);
await db.event(event6); await relay.event(event6);
await hydrateEvents({ await hydrateEvents({
events: [event6], events: [event6],
store: db, store: relay,
kysely: db.kysely,
}); });
const expectedEvent6 = { const expectedEvent6 = {
@ -52,7 +56,8 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
}); });
Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
const db = new MockRelay(); const relay = new MockRelay();
await using db = await createTestDB();
const event0madeQuoteRepost = await eventFixture('event-0-the-one-who-quote-repost'); const event0madeQuoteRepost = await eventFixture('event-0-the-one-who-quote-repost');
const event0 = await eventFixture('event-0'); const event0 = await eventFixture('event-0');
@ -60,14 +65,15 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
const event1willBeQuoteReposted = await eventFixture('event-1-that-will-be-quote-reposted'); const event1willBeQuoteReposted = await eventFixture('event-1-that-will-be-quote-reposted');
// Save events to database // Save events to database
await db.event(event0madeQuoteRepost); await relay.event(event0madeQuoteRepost);
await db.event(event0); await relay.event(event0);
await db.event(event1quoteRepost); await relay.event(event1quoteRepost);
await db.event(event1willBeQuoteReposted); await relay.event(event1willBeQuoteReposted);
await hydrateEvents({ await hydrateEvents({
events: [event1quoteRepost], events: [event1quoteRepost],
store: db, store: relay,
kysely: db.kysely,
}); });
const expectedEvent1quoteRepost = { const expectedEvent1quoteRepost = {
@ -80,7 +86,8 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
}); });
Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async () => {
const db = new MockRelay(); const relay = new MockRelay();
await using db = await createTestDB();
const author = await eventFixture('event-0-makes-repost-with-quote-repost'); const author = await eventFixture('event-0-makes-repost-with-quote-repost');
const event1 = await eventFixture('event-1-will-be-reposted-with-quote-repost'); const event1 = await eventFixture('event-1-will-be-reposted-with-quote-repost');
@ -88,14 +95,15 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async ()
const event1quote = await eventFixture('event-1-quote-repost-will-be-reposted'); const event1quote = await eventFixture('event-1-quote-repost-will-be-reposted');
// Save events to database // Save events to database
await db.event(author); await relay.event(author);
await db.event(event1); await relay.event(event1);
await db.event(event1quote); await relay.event(event1quote);
await db.event(event6); await relay.event(event6);
await hydrateEvents({ await hydrateEvents({
events: [event6], events: [event6],
store: db, store: relay,
kysely: db.kysely,
}); });
const expectedEvent6 = { const expectedEvent6 = {
@ -107,7 +115,8 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async ()
}); });
Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stats', async () => { Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stats', async () => {
const db = new MockRelay(); const relay = new MockRelay();
await using db = await createTestDB();
const authorDictator = await eventFixture('kind-0-dictator'); const authorDictator = await eventFixture('kind-0-dictator');
const authorVictim = await eventFixture('kind-0-george-orwell'); const authorVictim = await eventFixture('kind-0-george-orwell');
@ -115,14 +124,15 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat
const event1 = await eventFixture('kind-1-author-george-orwell'); const event1 = await eventFixture('kind-1-author-george-orwell');
// Save events to database // Save events to database
await db.event(authorDictator); await relay.event(authorDictator);
await db.event(authorVictim); await relay.event(authorVictim);
await db.event(reportEvent); await relay.event(reportEvent);
await db.event(event1); await relay.event(event1);
await hydrateEvents({ await hydrateEvents({
events: [reportEvent], events: [reportEvent],
store: db, store: relay,
kysely: db.kysely,
}); });
const expectedEvent: DittoEvent = { const expectedEvent: DittoEvent = {

View file

@ -7,16 +7,18 @@ import { Conf } from '@/config.ts';
import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
import { findQuoteTag } from '@/utils/tags.ts'; import { findQuoteTag } from '@/utils/tags.ts';
import { findQuoteInContent } from '@/utils/note.ts'; import { findQuoteInContent } from '@/utils/note.ts';
import { Kysely } from 'kysely';
interface HydrateOpts { interface HydrateOpts {
events: DittoEvent[]; events: DittoEvent[];
store: NStore; store: NStore;
signal?: AbortSignal; signal?: AbortSignal;
kysely?: Kysely<DittoTables>;
} }
/** Hydrate events using the provided storage. */ /** Hydrate events using the provided storage. */
async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> { async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
const { events, store, signal } = opts; const { events, store, signal, kysely = await DittoDB.getInstance() } = opts;
if (!events.length) { if (!events.length) {
return events; return events;
@ -57,8 +59,8 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
} }
const stats = { const stats = {
authors: await gatherAuthorStats(cache), authors: await gatherAuthorStats(cache, kysely),
events: await gatherEventStats(cache), events: await gatherEventStats(cache, kysely),
}; };
// Dedupe events. // Dedupe events.
@ -276,7 +278,10 @@ function gatherReportedProfiles({ events, store, signal }: HydrateOpts): Promise
} }
/** Collect author stats from the events. */ /** Collect author stats from the events. */
async function gatherAuthorStats(events: DittoEvent[]): Promise<DittoTables['author_stats'][]> { async function gatherAuthorStats(
events: DittoEvent[],
kysely: Kysely<DittoTables>,
): Promise<DittoTables['author_stats'][]> {
const pubkeys = new Set<string>( const pubkeys = new Set<string>(
events events
.filter((event) => event.kind === 0) .filter((event) => event.kind === 0)
@ -287,8 +292,6 @@ async function gatherAuthorStats(events: DittoEvent[]): Promise<DittoTables['aut
return Promise.resolve([]); return Promise.resolve([]);
} }
const kysely = await DittoDB.getInstance();
const rows = await kysely const rows = await kysely
.selectFrom('author_stats') .selectFrom('author_stats')
.selectAll() .selectAll()
@ -304,7 +307,10 @@ async function gatherAuthorStats(events: DittoEvent[]): Promise<DittoTables['aut
} }
/** Collect event stats from the events. */ /** Collect event stats from the events. */
async function gatherEventStats(events: DittoEvent[]): Promise<DittoTables['event_stats'][]> { async function gatherEventStats(
events: DittoEvent[],
kysely: Kysely<DittoTables>,
): Promise<DittoTables['event_stats'][]> {
const ids = new Set<string>( const ids = new Set<string>(
events events
.filter((event) => event.kind === 1) .filter((event) => event.kind === 1)
@ -315,8 +321,6 @@ async function gatherEventStats(events: DittoEvent[]): Promise<DittoTables['even
return Promise.resolve([]); return Promise.resolve([]);
} }
const kysely = await DittoDB.getInstance();
const rows = await kysely const rows = await kysely
.selectFrom('event_stats') .selectFrom('event_stats')
.selectAll() .selectAll()

View file

@ -2,13 +2,19 @@ import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { Database as Sqlite } from '@db/sqlite'; import { Database as Sqlite } from '@db/sqlite';
import { NDatabase, NostrEvent } from '@nostrify/nostrify';
import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite'; import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite';
import { FileMigrationProvider, Kysely, Migrator } from 'kysely';
import { finalizeEvent, generateSecretKey } from 'nostr-tools'; import { finalizeEvent, generateSecretKey } from 'nostr-tools';
import { NDatabase, NostrEvent } from '@nostrify/nostrify';
import { FileMigrationProvider, Kysely, Migrator } from 'kysely';
import postgres from 'postgres';
import { PostgresJSDialect, PostgresJSDialectConfig } from 'kysely-postgres-js';
import { DittoDB } from '@/db/DittoDB.ts';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { purifyEvent } from '@/storages/hydrate.ts'; import { purifyEvent } from '@/storages/hydrate.ts';
import { KyselyLogger } from '@/db/KyselyLogger.ts';
import { EventsDB } from '@/storages/EventsDB.ts';
import { Conf } from '@/config.ts';
/** Import an event fixture by name in tests. */ /** Import an event fixture by name in tests. */
export async function eventFixture(name: string): Promise<NostrEvent> { export async function eventFixture(name: string): Promise<NostrEvent> {
@ -63,6 +69,91 @@ export async function getTestDB() {
}; };
} }
/** Create an database for testing. */
export const createTestDB = async (databaseUrl?: string) => {
databaseUrl ??= Deno.env.get('DATABASE_URL') ?? 'sqlite://:memory:';
let dialect: 'sqlite' | 'postgres' = (() => {
const protocol = databaseUrl.split(':')[0];
switch (protocol) {
case 'sqlite':
return 'sqlite';
case 'postgres':
return protocol;
case 'postgresql':
return 'postgres';
default:
throw new Error(`Unsupported protocol: ${protocol}`);
}
})();
const allowToUseDATABASE_URL = Deno.env.get('ALLOW_TO_USE_DATABASE_URL')?.toLowerCase() ?? '';
if (allowToUseDATABASE_URL !== 'true' && dialect === 'postgres') {
console.warn(
'%cRunning tests with sqlite, if you meant to use Postgres, run again with ALLOW_TO_USE_DATABASE_URL environment variable set to true',
'color: yellow;',
);
dialect = 'sqlite';
}
console.warn(`Using: ${dialect}`);
let kysely: Kysely<DittoTables>;
if (dialect === 'sqlite') {
// migration 021_pgfts_index.ts calls 'Conf.db.dialect',
// and this calls the DATABASE_URL environment variable.
// The following line ensures to NOT use the DATABASE_URL that may exist in an .env file.
Deno.env.set('DATABASE_URL', 'sqlite://:memory:');
kysely = new Kysely<DittoTables>({
dialect: new DenoSqlite3Dialect({
database: new Sqlite(':memory:'),
}),
});
} else {
kysely = new Kysely({
dialect: new PostgresJSDialect({
postgres: postgres(Conf.databaseUrl, {
max: Conf.pg.poolSize,
}) as unknown as PostgresJSDialectConfig['postgres'],
}),
log: KyselyLogger,
});
}
await DittoDB.migrate(kysely);
const store = new EventsDB(kysely);
return {
store,
kysely,
[Symbol.asyncDispose]: async () => {
if (dialect === 'postgres') {
for (
const table of [
'author_stats',
'event_stats',
'event_zaps',
'kysely_migration',
'kysely_migration_lock',
'nip46_tokens',
'pubkey_domains',
'unattached_media',
'nostr_events',
'nostr_tags',
'nostr_pgfts',
'event_zaps',
]
) {
await kysely.schema.dropTable(table).ifExists().cascade().execute();
}
await kysely.destroy();
}
},
};
};
export function sleep(ms: number): Promise<void> { export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }

View file

@ -1,11 +1,11 @@
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { genEvent, getTestDB } from '@/test.ts'; import { createTestDB, genEvent } from '@/test.ts';
import { countAuthorStats, getAuthorStats, getEventStats, getFollowDiff, updateStats } from '@/utils/stats.ts'; import { countAuthorStats, getAuthorStats, getEventStats, getFollowDiff, updateStats } from '@/utils/stats.ts';
Deno.test('updateStats with kind 1 increments notes count', async () => { Deno.test('updateStats with kind 1 increments notes count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const sk = generateSecretKey(); const sk = generateSecretKey();
const pubkey = getPublicKey(sk); const pubkey = getPublicKey(sk);
@ -18,7 +18,7 @@ Deno.test('updateStats with kind 1 increments notes count', async () => {
}); });
Deno.test('updateStats with kind 1 increments replies count', async () => { Deno.test('updateStats with kind 1 increments replies count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const sk = generateSecretKey(); const sk = generateSecretKey();
@ -36,7 +36,7 @@ Deno.test('updateStats with kind 1 increments replies count', async () => {
}); });
Deno.test('updateStats with kind 5 decrements notes count', async () => { Deno.test('updateStats with kind 5 decrements notes count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const sk = generateSecretKey(); const sk = generateSecretKey();
const pubkey = getPublicKey(sk); const pubkey = getPublicKey(sk);
@ -54,7 +54,7 @@ Deno.test('updateStats with kind 5 decrements notes count', async () => {
}); });
Deno.test('updateStats with kind 3 increments followers count', async () => { Deno.test('updateStats with kind 3 increments followers count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) });
await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) }); await updateStats({ ...db, event: genEvent({ kind: 3, tags: [['p', 'alex']] }) });
@ -66,7 +66,7 @@ Deno.test('updateStats with kind 3 increments followers count', async () => {
}); });
Deno.test('updateStats with kind 3 decrements followers count', async () => { Deno.test('updateStats with kind 3 decrements followers count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const sk = generateSecretKey(); const sk = generateSecretKey();
const follow = genEvent({ kind: 3, tags: [['p', 'alex']], created_at: 0 }, sk); const follow = genEvent({ kind: 3, tags: [['p', 'alex']], created_at: 0 }, sk);
@ -92,7 +92,7 @@ Deno.test('getFollowDiff returns added and removed followers', () => {
}); });
Deno.test('updateStats with kind 6 increments reposts count', async () => { Deno.test('updateStats with kind 6 increments reposts count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...db, event: note }); await updateStats({ ...db, event: note });
@ -108,7 +108,7 @@ Deno.test('updateStats with kind 6 increments reposts count', async () => {
}); });
Deno.test('updateStats with kind 5 decrements reposts count', async () => { Deno.test('updateStats with kind 5 decrements reposts count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...db, event: note }); await updateStats({ ...db, event: note });
@ -127,7 +127,7 @@ Deno.test('updateStats with kind 5 decrements reposts count', async () => {
}); });
Deno.test('updateStats with kind 7 increments reactions count', async () => { Deno.test('updateStats with kind 7 increments reactions count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...db, event: note }); await updateStats({ ...db, event: note });
@ -143,7 +143,7 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => {
}); });
Deno.test('updateStats with kind 5 decrements reactions count', async () => { Deno.test('updateStats with kind 5 decrements reactions count', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...db, event: note }); await updateStats({ ...db, event: note });
@ -162,7 +162,7 @@ Deno.test('updateStats with kind 5 decrements reactions count', async () => {
}); });
Deno.test('countAuthorStats counts author stats from the database', async () => { Deno.test('countAuthorStats counts author stats from the database', async () => {
await using db = await getTestDB(); await using db = await createTestDB();
const sk = generateSecretKey(); const sk = generateSecretKey();
const pubkey = getPublicKey(sk); const pubkey = getPublicKey(sk);

View file

@ -0,0 +1,60 @@
import { assertEquals } from '@std/assert';
import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { genEvent } from '@/test.ts';
import { getZapSplits } from '@/utils/zap-split.ts';
import { getTestDB } from '@/test.ts';
Deno.test('Get zap splits in DittoZapSplits format', async () => {
const { store } = await getTestDB();
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
const event = genEvent({
kind: 30078,
tags: [
['d', 'pub.ditto.zapSplits'],
['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', '2', 'Patrick developer'],
['p', '0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd', '3', 'Alex creator of Ditto'],
],
}, sk);
await store.event(event);
const eventFromDb = await store.query([{ kinds: [30078], authors: [pubkey] }]);
assertEquals(eventFromDb.length, 1);
const zapSplits = await getZapSplits(store, pubkey);
assertEquals(zapSplits, {
'0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd': { amount: 3, message: 'Alex creator of Ditto' },
'47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4': { amount: 2, message: 'Patrick developer' },
});
assertEquals(await getZapSplits(store, 'garbage'), undefined);
});
Deno.test('Zap split is empty', async () => {
const { store } = await getTestDB();
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
const event = genEvent({
kind: 30078,
tags: [
['d', 'pub.ditto.zapSplits'],
['p', 'baka'],
],
}, sk);
await store.event(event);
const eventFromDb = await store.query([{ kinds: [30078], authors: [pubkey] }]);
assertEquals(eventFromDb.length, 1);
const zapSplits = await getZapSplits(store, pubkey);
assertEquals(zapSplits, {});
});

62
src/utils/zap-split.ts Normal file
View file

@ -0,0 +1,62 @@
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Conf } from '@/config.ts';
import { handleEvent } from '@/pipeline.ts';
import { NSchema as n, NStore } from '@nostrify/nostrify';
import { nostrNow } from '@/utils.ts';
import { percentageSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts';
type Pubkey = string;
type ExtraMessage = string;
/** Number from 1 to 100, stringified. */
type splitPercentages = number;
export type DittoZapSplits = {
[key: Pubkey]: { amount: splitPercentages; message: ExtraMessage };
};
/** Gets zap splits from NIP-78 in DittoZapSplits format. */
export async function getZapSplits(store: NStore, pubkey: string): Promise<DittoZapSplits | undefined> {
const zapSplits: DittoZapSplits = {};
const [event] = await store.query([{
authors: [pubkey],
kinds: [30078],
'#d': ['pub.ditto.zapSplits'],
limit: 1,
}]);
if (!event) return;
for (const tag of event.tags) {
if (
tag[0] === 'p' && n.id().safeParse(tag[1]).success &&
percentageSchema.safeParse(tag[2]).success
) {
zapSplits[tag[1]] = { amount: Number(tag[2]), message: tag[3] };
}
}
return zapSplits;
}
export async function seedZapSplits() {
const store = await Storages.admin();
const zap_split: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey);
if (!zap_split) {
const dittoPubkey = '781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5';
const dittoMsg = 'Official Ditto Account';
const signer = new AdminSigner();
const event = await signer.signEvent({
content: '',
created_at: nostrNow(),
kind: 30078,
tags: [
['d', 'pub.ditto.zapSplits'],
['p', dittoPubkey, '5', dittoMsg],
],
});
await handleEvent(event, AbortSignal.timeout(5000));
}
}