From 799774760a8f778295e4e08dbc87f5fab50214ce Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 8 Jan 2025 21:02:53 -0300 Subject: [PATCH] feat: event_stats reactions is now jsonb and updates keys directly --- src/db/DittoTables.ts | 2 +- .../042_event_stats_reactions_to_jsonb.ts | 41 +++++++++ src/storages/hydrate.ts | 2 +- src/utils/stats.test.ts | 4 +- src/utils/stats.ts | 83 +++++++++++-------- 5 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 src/db/migrations/042_event_stats_reactions_to_jsonb.ts diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index ec21170e..336ab478 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -30,7 +30,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 7f5c8125..30c635a6 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 f45ab750..36373446 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -107,36 +107,44 @@ 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 + // the '+' and '-' signs are considered emojis. const emoji = event.content; if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) { - await kysely.updateTable('event_stats') - .set((eb) => { - // Updated reactions. - const result = eb.fn('jsonb_set', [ - sql`${eb.ref('reactions')}::jsonb`, - sql`ARRAY[${emoji}]`, - eb.case() - .when(sql`reactions::jsonb -> ${emoji}`, 'is', null) - .then(sql`${x}::jsonb`) // Set the emoji count for the first time. - .else(eb.fn('to_jsonb', [sql`(reactions::jsonb -> ${emoji})::int + ${x}`])) // Increment or decrement the emoji count. - .end(), - ]); + const empty = { + ...getEmpty_event_stats(id), + reactions: { [emoji]: x }, + reactions_count: x, + }; - // 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; + 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(), + ]); - return { - reactions: cleanedReactions, - reactions_count: eb('reactions_count', '+', x), - }; - }) - .where('event_id', '=', id) + // 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; + + return { + reactions: cleanedReactions, + reactions_count: eb('event_stats.reactions_count', '+', x), + }; + }) + ) .execute(); } } @@ -243,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') @@ -319,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; +}