diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 6ffed988..d9d05135 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -25,7 +25,7 @@ interface EventStatsRow { reposts_count: number; reactions_count: number; quotes_count: number; - reactions: string; + reactions: { [key: string]: number }; zaps_amount: number; } diff --git a/src/db/migrations/042_event_stats_reactions_to_jsonb.ts b/src/db/migrations/042_event_stats_reactions_to_jsonb.ts new file mode 100644 index 00000000..b3ac7e02 --- /dev/null +++ b/src/db/migrations/042_event_stats_reactions_to_jsonb.ts @@ -0,0 +1,41 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('event_stats') + .alterColumn('reactions', (ac) => ac.setNotNull()).execute(); + + await db.schema + .alterTable('event_stats') + .alterColumn('reactions', (ac) => ac.dropDefault()).execute(); + + // Type 'text' cannot be converted automatically to 'jsonb', + // so the 'USING' keyword must be used, and there's no way to do this with kysely, + // this is why raw SQL is used. + await sql` + ALTER TABLE event_stats + ALTER COLUMN reactions TYPE jsonb USING reactions::jsonb; + `.execute(db); + + await db.schema + .alterTable('event_stats') + .alterColumn('reactions', (ac) => ac.setDefault('{}')).execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('event_stats') + .alterColumn('reactions', (ac) => ac.dropNotNull()).execute(); + + await db.schema + .alterTable('event_stats') + .alterColumn('reactions', (ac) => ac.dropDefault()).execute(); + + await db.schema + .alterTable('event_stats') + .alterColumn('reactions', (ac) => ac.setDataType('text')).execute(); + + await db.schema + .alterTable('event_stats') + .alterColumn('reactions', (ac) => ac.setDefault('{}')).execute(); +} diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 28dcea47..5897e322 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -91,7 +91,7 @@ export function assembleEvents( const eventStats = stats.events.map((stat) => ({ ...stat, - reactions: JSON.parse(stat.reactions), + reactions: stat.reactions, })); for (const event of a) { diff --git a/src/utils/stats.test.ts b/src/utils/stats.test.ts index 797f78da..4cc83405 100644 --- a/src/utils/stats.test.ts +++ b/src/utils/stats.test.ts @@ -138,7 +138,7 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => { const stats = await getEventStats(db.kysely, note.id); - assertEquals(stats!.reactions, JSON.stringify({ '+': 1, '😂': 1 })); + assertEquals(stats!.reactions, { '+': 1, '😂': 1 }); assertEquals(stats!.reactions_count, 2); }); @@ -158,7 +158,7 @@ Deno.test('updateStats with kind 5 decrements reactions count', async () => { const stats = await getEventStats(db.kysely, note.id); - assertEquals(stats!.reactions, JSON.stringify({})); + assertEquals(stats!.reactions, {}); }); Deno.test('countAuthorStats counts author stats from the database', async () => { diff --git a/src/utils/stats.ts b/src/utils/stats.ts index e2fab440..072d8eea 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -1,5 +1,5 @@ import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; -import { Kysely, UpdateObject } from 'kysely'; +import { Kysely, sql, UpdateObject, ValueExpression } from 'kysely'; import { SetRequired } from 'type-fest'; import { z } from 'zod'; @@ -107,30 +107,45 @@ async function handleEvent6(kysely: Kysely, event: NostrEvent, x: n /** Update stats for kind 7 event. */ async function handleEvent7(kysely: Kysely, event: NostrEvent, x: number): Promise { const id = event.tags.findLast(([name]) => name === 'e')?.[1]; + // the '+' and '-' signs are considered emojis. const emoji = event.content; if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) { - await updateEventStats(kysely, id, ({ reactions }) => { - const data: Record = JSON.parse(reactions); + const empty = { + ...getEmpty_event_stats(id), + reactions: { [emoji]: x }, + reactions_count: x, + }; - // Increment or decrement the emoji count. - data[emoji] = (data[emoji] ?? 0) + x; + await kysely.insertInto('event_stats') + .values(empty) + .onConflict((oc) => + oc.column('event_id').doUpdateSet((eb) => { + // Updated reactions. + const result = eb.fn('jsonb_set', [ + eb.ref('event_stats.reactions'), // Target. + sql`ARRAY[${emoji}]`, // Path. + eb.case() + .when(sql`event_stats.reactions -> ${emoji}`, 'is', null) + .then(sql`${x}::jsonb`) // New value: Initialize the emoji count for the specific 'emoji'. + .else(eb.fn('to_jsonb', [sql`(event_stats.reactions -> ${emoji})::int + ${x}`])) // New value: Increment or decrement the emoji count. + .end(), + ]); - // Remove reactions with a count of 0 or less. - for (const key of Object.keys(data)) { - if (data[key] < 1) { - delete data[key]; - } - } + // Only reactions with a count greater than zero. + const cleanedReactions = eb.case() + .when(sql`(${result} -> ${emoji})::int`, '<', 1) + .then(sql`${result} - ${emoji}`) + .else(result) + .end() as ValueExpression; - // Total reactions count. - const count = Object.values(data).reduce((result, value) => result + value, 0); - - return { - reactions: JSON.stringify(data), - reactions_count: count, - }; - }); + return { + reactions: cleanedReactions, + reactions_count: eb('event_stats.reactions_count', '+', x), + }; + }) + ) + .execute(); } } @@ -236,15 +251,7 @@ export async function updateEventStats( eventId: string, fn: (prev: DittoTables['event_stats']) => UpdateObject, ): Promise { - const empty: DittoTables['event_stats'] = { - event_id: eventId, - replies_count: 0, - reposts_count: 0, - reactions_count: 0, - quotes_count: 0, - zaps_amount: 0, - reactions: '{}', - }; + const empty = getEmpty_event_stats(eventId); const prev = await kysely .selectFrom('event_stats') @@ -312,3 +319,18 @@ export async function refreshAuthorStats( return stats; } + +/** Returns an empty event_stats object. */ +function getEmpty_event_stats(id: string) { + const empty: DittoTables['event_stats'] = { + event_id: id, + replies_count: 0, + reposts_count: 0, + reactions_count: 0, + quotes_count: 0, + zaps_amount: 0, + reactions: {}, + }; + + return empty; +}