feat: event_stats reactions is now jsonb and updates keys directly

This commit is contained in:
P. Reis 2025-01-08 21:02:53 -03:00
parent a1078de07b
commit 799774760a
5 changed files with 94 additions and 38 deletions

View file

@ -30,7 +30,7 @@ interface EventStatsRow {
reposts_count: number; reposts_count: number;
reactions_count: number; reactions_count: number;
quotes_count: number; quotes_count: number;
reactions: string; reactions: { [key: string]: number };
zaps_amount: number; zaps_amount: number;
} }

View file

@ -0,0 +1,41 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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();
}

View file

@ -91,7 +91,7 @@ export function assembleEvents(
const eventStats = stats.events.map((stat) => ({ const eventStats = stats.events.map((stat) => ({
...stat, ...stat,
reactions: JSON.parse(stat.reactions), reactions: stat.reactions,
})); }));
for (const event of a) { for (const event of a) {

View file

@ -138,7 +138,7 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => {
const stats = await getEventStats(db.kysely, note.id); 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); 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); 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 () => { Deno.test('countAuthorStats counts author stats from the database', async () => {

View file

@ -107,20 +107,28 @@ async function handleEvent6(kysely: Kysely<DittoTables>, event: NostrEvent, x: n
/** Update stats for kind 7 event. */ /** Update stats for kind 7 event. */
async function handleEvent7(kysely: Kysely<DittoTables>, event: NostrEvent, x: number): Promise<void> { async function handleEvent7(kysely: Kysely<DittoTables>, event: NostrEvent, x: number): Promise<void> {
const id = event.tags.findLast(([name]) => name === 'e')?.[1]; 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; const emoji = event.content;
if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) { if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) {
await kysely.updateTable('event_stats') const empty = {
.set((eb) => { ...getEmpty_event_stats(id),
reactions: { [emoji]: x },
reactions_count: x,
};
await kysely.insertInto('event_stats')
.values(empty)
.onConflict((oc) =>
oc.column('event_id').doUpdateSet((eb) => {
// Updated reactions. // Updated reactions.
const result = eb.fn('jsonb_set', [ const result = eb.fn('jsonb_set', [
sql`${eb.ref('reactions')}::jsonb`, eb.ref('event_stats.reactions'), // Target.
sql<string[]>`ARRAY[${emoji}]`, sql<string[]>`ARRAY[${emoji}]`, // Path.
eb.case() eb.case()
.when(sql`reactions::jsonb -> ${emoji}`, 'is', null) .when(sql`event_stats.reactions -> ${emoji}`, 'is', null)
.then(sql`${x}::jsonb`) // Set the emoji count for the first time. .then(sql`${x}::jsonb`) // New value: Initialize the emoji count for the specific 'emoji'.
.else(eb.fn('to_jsonb', [sql`(reactions::jsonb -> ${emoji})::int + ${x}`])) // Increment or decrement the emoji count. .else(eb.fn('to_jsonb', [sql`(event_stats.reactions -> ${emoji})::int + ${x}`])) // New value: Increment or decrement the emoji count.
.end(), .end(),
]); ]);
@ -129,14 +137,14 @@ async function handleEvent7(kysely: Kysely<DittoTables>, event: NostrEvent, x: n
.when(sql`(${result} -> ${emoji})::int`, '<', 1) .when(sql`(${result} -> ${emoji})::int`, '<', 1)
.then(sql`${result} - ${emoji}`) .then(sql`${result} - ${emoji}`)
.else(result) .else(result)
.end() as ValueExpression<DittoTables, 'event_stats', string>; .end() as ValueExpression<DittoTables, 'event_stats', { [key: string]: number }>;
return { return {
reactions: cleanedReactions, reactions: cleanedReactions,
reactions_count: eb('reactions_count', '+', x), reactions_count: eb('event_stats.reactions_count', '+', x),
}; };
}) })
.where('event_id', '=', id) )
.execute(); .execute();
} }
} }
@ -243,15 +251,7 @@ export async function updateEventStats(
eventId: string, eventId: string,
fn: (prev: DittoTables['event_stats']) => UpdateObject<DittoTables, 'event_stats'>, fn: (prev: DittoTables['event_stats']) => UpdateObject<DittoTables, 'event_stats'>,
): Promise<void> { ): Promise<void> {
const empty: DittoTables['event_stats'] = { const empty = getEmpty_event_stats(eventId);
event_id: eventId,
replies_count: 0,
reposts_count: 0,
reactions_count: 0,
quotes_count: 0,
zaps_amount: 0,
reactions: '{}',
};
const prev = await kysely const prev = await kysely
.selectFrom('event_stats') .selectFrom('event_stats')
@ -319,3 +319,18 @@ export async function refreshAuthorStats(
return stats; 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;
}