From aae00cdc10a35f1e0e58f4d9e8556b76719b14d0 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sat, 28 Dec 2024 19:56:07 -0300 Subject: [PATCH 1/4] checkpoint: handleEvent7 function uses raw SQL directly (temporarily) to update reactions --- src/utils/stats.ts | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 4573bb60..ac6f0953 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 } from 'kysely'; import { SetRequired } from 'type-fest'; import { z } from 'zod'; @@ -107,30 +107,23 @@ 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 baka = await sql` + UPDATE event_stats + SET reactions = jsonb_set( + reactions::jsonb, + ARRAY[${emoji}], + CASE WHEN reactions::jsonb->${emoji} IS NULL THEN ${x} + ELSE to_jsonb((reactions::jsonb->${emoji})::int + ${x}::int) + END + ) + WHERE event_id = ${id}` + .execute(kysely); - // Increment or decrement the emoji count. - data[emoji] = (data[emoji] ?? 0) + x; - - // Remove reactions with a count of 0 or less. - for (const key of Object.keys(data)) { - if (data[key] < 1) { - delete data[key]; - } - } - - // Total reactions count. - const count = Object.values(data).reduce((result, value) => result + value, 0); - - return { - reactions: JSON.stringify(data), - reactions_count: count, - }; - }); + console.log(baka); } } From bc24bb975cfb1f5df8e00c0eadc2d334224344ed Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sun, 29 Dec 2024 18:16:19 -0300 Subject: [PATCH 2/4] feat(handleEvent7): update keys/reactions directly TODO: convert reactions to jsonb TODO: convert to kysely rather than raw SQL --- src/utils/stats.ts | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/utils/stats.ts b/src/utils/stats.ts index ac6f0953..c8931909 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -112,17 +112,29 @@ async function handleEvent7(kysely: Kysely, event: NostrEvent, x: n if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) { const baka = await sql` - UPDATE event_stats - SET reactions = jsonb_set( - reactions::jsonb, - ARRAY[${emoji}], - CASE WHEN reactions::jsonb->${emoji} IS NULL THEN ${x} - ELSE to_jsonb((reactions::jsonb->${emoji})::int + ${x}::int) - END - ) - WHERE event_id = ${id}` + UPDATE + event_stats + SET + reactions = CASE WHEN ( + jsonb_set( + reactions :: jsonb, + ARRAY[${emoji}], + CASE WHEN reactions :: jsonb -> ${emoji} IS NULL THEN ${x} ELSE to_jsonb( + (reactions :: jsonb -> ${emoji}):: int + ${x} :: int + ) END + ):: jsonb ->> ${emoji} + ):: int = 0 THEN reactions :: jsonb - ${emoji} ELSE jsonb_set( + reactions :: jsonb, + ARRAY[${emoji}], + CASE WHEN reactions :: jsonb -> ${emoji} IS NULL THEN ${x} ELSE to_jsonb( + (reactions :: jsonb -> ${emoji}):: int + ${x} :: int + ) END + ) END, + reactions_count = reactions_count + ${x} + WHERE + event_id = ${id} + ` .execute(kysely); - console.log(baka); } } From a1078de07b2e97c333253eafae20187f0623fbac Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 6 Jan 2025 19:14:43 -0300 Subject: [PATCH 3/4] feat(handleEvent7): update keys/reactions directly with kysely, not raw SQL TODO: create migration to convert event_stats to jsonb --- src/utils/stats.ts | 54 ++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/utils/stats.ts b/src/utils/stats.ts index c8931909..f45ab750 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, sql, UpdateObject } from 'kysely'; +import { Kysely, sql, UpdateObject, ValueExpression } from 'kysely'; import { SetRequired } from 'type-fest'; import { z } from 'zod'; @@ -111,31 +111,33 @@ async function handleEvent7(kysely: Kysely, event: NostrEvent, x: n const emoji = event.content; if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) { - const baka = await sql` - UPDATE - event_stats - SET - reactions = CASE WHEN ( - jsonb_set( - reactions :: jsonb, - ARRAY[${emoji}], - CASE WHEN reactions :: jsonb -> ${emoji} IS NULL THEN ${x} ELSE to_jsonb( - (reactions :: jsonb -> ${emoji}):: int + ${x} :: int - ) END - ):: jsonb ->> ${emoji} - ):: int = 0 THEN reactions :: jsonb - ${emoji} ELSE jsonb_set( - reactions :: jsonb, - ARRAY[${emoji}], - CASE WHEN reactions :: jsonb -> ${emoji} IS NULL THEN ${x} ELSE to_jsonb( - (reactions :: jsonb -> ${emoji}):: int + ${x} :: int - ) END - ) END, - reactions_count = reactions_count + ${x} - WHERE - event_id = ${id} - ` - .execute(kysely); - console.log(baka); + 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(), + ]); + + // 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('reactions_count', '+', x), + }; + }) + .where('event_id', '=', id) + .execute(); } } From 799774760a8f778295e4e08dbc87f5fab50214ce Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 8 Jan 2025 21:02:53 -0300 Subject: [PATCH 4/4] 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; +}