From f7c9a967199df523d979ca6f038e10764245bd42 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 24 May 2024 17:40:51 -0500 Subject: [PATCH] Nuke the old stats module, support emoji reactions on posts --- scripts/stats-recompute.ts | 9 +- src/db/DittoTables.ts | 2 +- .../migrations/022_event_stats_reactions.ts | 18 ++ src/interfaces/DittoEvent.ts | 2 +- src/pipeline.ts | 5 +- src/stats.ts | 273 ------------------ src/storages/hydrate.ts | 21 +- src/utils/stats.test.ts | 12 +- src/utils/stats.ts | 76 ++++- src/views/mastodon/statuses.ts | 15 +- 10 files changed, 134 insertions(+), 299 deletions(-) create mode 100644 src/db/migrations/022_event_stats_reactions.ts delete mode 100644 src/stats.ts diff --git a/scripts/stats-recompute.ts b/scripts/stats-recompute.ts index 4037a85b..107a3167 100644 --- a/scripts/stats-recompute.ts +++ b/scripts/stats-recompute.ts @@ -1,6 +1,8 @@ import { nip19 } from 'nostr-tools'; -import { refreshAuthorStats } from '@/stats.ts'; +import { DittoDB } from '@/db/DittoDB.ts'; +import { Storages } from '@/storages.ts'; +import { refreshAuthorStats } from '@/utils/stats.ts'; let pubkey: string; try { @@ -15,4 +17,7 @@ try { Deno.exit(1); } -await refreshAuthorStats(pubkey); +const store = await Storages.db(); +const kysely = await DittoDB.getInstance(); + +await refreshAuthorStats({ pubkey, kysely, store }); diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 42d39ea9..37512cb0 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -19,7 +19,7 @@ interface EventStatsRow { event_id: string; replies_count: number; reposts_count: number; - reactions_count: number; + reactions: string; } interface EventRow { diff --git a/src/db/migrations/022_event_stats_reactions.ts b/src/db/migrations/022_event_stats_reactions.ts new file mode 100644 index 00000000..9a89296c --- /dev/null +++ b/src/db/migrations/022_event_stats_reactions.ts @@ -0,0 +1,18 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('event_stats') + .addColumn('reactions', 'text', (col) => col.defaultTo('{}')) + .execute(); + + await db.schema + .alterTable('event_stats') + .dropColumn('reactions_count') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('event_stats').dropColumn('reactions').execute(); + await db.schema.alterTable('event_stats').addColumn('reactions_count', 'integer').execute(); +} diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index 41847fb1..b9f95e43 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -11,7 +11,7 @@ export interface AuthorStats { export interface EventStats { replies_count: number; reposts_count: number; - reactions_count: number; + reactions: Record; } /** Internal Event representation used by Ditto, including extra keys. */ diff --git a/src/pipeline.ts b/src/pipeline.ts index bfb0577e..7bab6d09 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -10,7 +10,6 @@ import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DVM } from '@/pipeline/DVM.ts'; import { RelayError } from '@/RelayError.ts'; -import { updateStats } from '@/stats.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; import { eventAge, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts'; @@ -21,6 +20,7 @@ import { verifyEventWorker } from '@/workers/verify.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { lnurlCache } from '@/utils/lnurl.ts'; import { nip05Cache } from '@/utils/nip05.ts'; +import { updateStats } from '@/utils/stats.ts'; import { getTagSet } from '@/utils/tags.ts'; import { MuteListPolicy } from '@/policies/MuteListPolicy.ts'; @@ -121,8 +121,9 @@ async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise { if (NKinds.ephemeral(event.kind)) return; const store = await Storages.db(); + const kysely = await DittoDB.getInstance(); - await updateStats(event).catch(debug); + await updateStats({ event, store, kysely }).catch(debug); await store.event(event, { signal }); } diff --git a/src/stats.ts b/src/stats.ts deleted file mode 100644 index 6ffe5f7e..00000000 --- a/src/stats.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { Semaphore } from '@lambdalisue/async'; -import { NKinds, NostrEvent, NStore } from '@nostrify/nostrify'; -import Debug from '@soapbox/stickynotes/debug'; -import { InsertQueryBuilder, Kysely } from 'kysely'; -import { LRUCache } from 'lru-cache'; -import { SetRequired } from 'type-fest'; - -import { DittoDB } from '@/db/DittoDB.ts'; -import { DittoTables } from '@/db/DittoTables.ts'; -import { Storages } from '@/storages.ts'; -import { findReplyTag, getTagSet } from '@/utils/tags.ts'; - -type AuthorStat = keyof Omit; -type EventStat = keyof Omit; - -type AuthorStatDiff = ['author_stats', pubkey: string, stat: AuthorStat, diff: number]; -type EventStatDiff = ['event_stats', eventId: string, stat: EventStat, diff: number]; -type StatDiff = AuthorStatDiff | EventStatDiff; - -const debug = Debug('ditto:stats'); - -/** Store stats for the event. */ -async function updateStats(event: NostrEvent) { - let prev: NostrEvent | undefined; - const queries: InsertQueryBuilder[] = []; - - // Kind 3 is a special case - replace the count with the new list. - if (event.kind === 3) { - prev = await getPrevEvent(event); - if (!prev || event.created_at >= prev.created_at) { - queries.push(await updateFollowingCountQuery(event)); - } - } - - const statDiffs = await getStatsDiff(event, prev); - const pubkeyDiffs = statDiffs.filter(([table]) => table === 'author_stats') as AuthorStatDiff[]; - const eventDiffs = statDiffs.filter(([table]) => table === 'event_stats') as EventStatDiff[]; - - if (statDiffs.length) { - debug(JSON.stringify({ id: event.id, pubkey: event.pubkey, kind: event.kind, tags: event.tags, statDiffs })); - } - - pubkeyDiffs.forEach(([_, pubkey]) => refreshAuthorStatsDebounced(pubkey)); - - const kysely = await DittoDB.getInstance(); - - if (pubkeyDiffs.length) queries.push(authorStatsQuery(kysely, pubkeyDiffs)); - if (eventDiffs.length) queries.push(eventStatsQuery(kysely, eventDiffs)); - - if (queries.length) { - await Promise.all(queries.map((query) => query.execute())); - } -} - -/** Calculate stats changes ahead of time so we can build an efficient query. */ -async function getStatsDiff(event: NostrEvent, prev: NostrEvent | undefined): Promise { - const store = await Storages.db(); - const statDiffs: StatDiff[] = []; - - const firstTaggedId = event.tags.find(([name]) => name === 'e')?.[1]; - const inReplyToId = findReplyTag(event.tags)?.[1]; - - switch (event.kind) { - case 1: - statDiffs.push(['author_stats', event.pubkey, 'notes_count', 1]); - if (inReplyToId) { - statDiffs.push(['event_stats', inReplyToId, 'replies_count', 1]); - } - break; - case 3: - statDiffs.push(...getFollowDiff(event, prev)); - break; - case 5: { - if (!firstTaggedId) break; - - const [repostedEvent] = await store.query( - [{ kinds: [6], ids: [firstTaggedId], authors: [event.pubkey] }], - { limit: 1 }, - ); - // Check if the event being deleted is of kind 6, - // if it is then proceed, else just break - if (!repostedEvent) break; - - const eventBeingRepostedId = repostedEvent.tags.find(([name]) => name === 'e')?.[1]; - const eventBeingRepostedPubkey = repostedEvent.tags.find(([name]) => name === 'p')?.[1]; - if (!eventBeingRepostedId || !eventBeingRepostedPubkey) break; - - const [eventBeingReposted] = await store.query( - [{ kinds: [1], ids: [eventBeingRepostedId], authors: [eventBeingRepostedPubkey] }], - { limit: 1 }, - ); - if (!eventBeingReposted) break; - - statDiffs.push(['event_stats', eventBeingRepostedId, 'reposts_count', -1]); - break; - } - case 6: - if (firstTaggedId) { - statDiffs.push(['event_stats', firstTaggedId, 'reposts_count', 1]); - } - break; - case 7: - if (firstTaggedId) { - statDiffs.push(['event_stats', firstTaggedId, 'reactions_count', 1]); - } - } - - return statDiffs; -} - -/** Create an author stats query from the list of diffs. */ -function authorStatsQuery(kysely: Kysely, diffs: AuthorStatDiff[]) { - const values: DittoTables['author_stats'][] = diffs.map(([_, pubkey, stat, diff]) => { - const row: DittoTables['author_stats'] = { - pubkey, - followers_count: 0, - following_count: 0, - notes_count: 0, - }; - row[stat] = diff; - return row; - }); - - return kysely.insertInto('author_stats') - .values(values) - .onConflict((oc) => - oc - .column('pubkey') - .doUpdateSet((eb) => ({ - followers_count: eb('author_stats.followers_count', '+', eb.ref('excluded.followers_count')), - following_count: eb('author_stats.following_count', '+', eb.ref('excluded.following_count')), - notes_count: eb('author_stats.notes_count', '+', eb.ref('excluded.notes_count')), - })) - ); -} - -/** Create an event stats query from the list of diffs. */ -function eventStatsQuery(kysely: Kysely, diffs: EventStatDiff[]) { - const values: DittoTables['event_stats'][] = diffs.map(([_, event_id, stat, diff]) => { - const row: DittoTables['event_stats'] = { - event_id, - replies_count: 0, - reposts_count: 0, - reactions_count: 0, - }; - row[stat] = diff; - return row; - }); - - return kysely.insertInto('event_stats') - .values(values) - .onConflict((oc) => - oc - .column('event_id') - .doUpdateSet((eb) => ({ - replies_count: eb('event_stats.replies_count', '+', eb.ref('excluded.replies_count')), - reposts_count: eb('event_stats.reposts_count', '+', eb.ref('excluded.reposts_count')), - reactions_count: eb('event_stats.reactions_count', '+', eb.ref('excluded.reactions_count')), - })) - ); -} - -/** Get the last version of the event, if any. */ -async function getPrevEvent(event: NostrEvent): Promise { - if (NKinds.replaceable(event.kind) || NKinds.parameterizedReplaceable(event.kind)) { - const store = await Storages.db(); - - const [prev] = await store.query([ - { kinds: [event.kind], authors: [event.pubkey], limit: 1 }, - ]); - - return prev; - } -} - -/** Set the following count to the total number of unique "p" tags in the follow list. */ -async function updateFollowingCountQuery({ pubkey, tags }: NostrEvent) { - const following_count = new Set( - tags - .filter(([name]) => name === 'p') - .map(([_, value]) => value), - ).size; - - const kysely = await DittoDB.getInstance(); - return kysely.insertInto('author_stats') - .values({ - pubkey, - following_count, - followers_count: 0, - notes_count: 0, - }) - .onConflict((oc) => - oc - .column('pubkey') - .doUpdateSet({ following_count }) - ); -} - -/** Compare the old and new follow events (if any), and return a diff array. */ -function getFollowDiff(event: NostrEvent, prev?: NostrEvent): AuthorStatDiff[] { - const prevTags = prev?.tags ?? []; - - const prevPubkeys = new Set( - prevTags - .filter(([name]) => name === 'p') - .map(([_, value]) => value), - ); - - const pubkeys = new Set( - event.tags - .filter(([name]) => name === 'p') - .map(([_, value]) => value), - ); - - const added = [...pubkeys].filter((pubkey) => !prevPubkeys.has(pubkey)); - const removed = [...prevPubkeys].filter((pubkey) => !pubkeys.has(pubkey)); - - return [ - ...added.map((pubkey): AuthorStatDiff => ['author_stats', pubkey, 'followers_count', 1]), - ...removed.map((pubkey): AuthorStatDiff => ['author_stats', pubkey, 'followers_count', -1]), - ]; -} - -/** Refresh the author's stats in the database. */ -async function refreshAuthorStats(pubkey: string): Promise { - const store = await Storages.db(); - const stats = await countAuthorStats(store, pubkey); - - const kysely = await DittoDB.getInstance(); - await kysely.insertInto('author_stats') - .values(stats) - .onConflict((oc) => oc.column('pubkey').doUpdateSet(stats)) - .execute(); - - return stats; -} - -/** Calculate author stats from the database. */ -async function countAuthorStats( - store: SetRequired, - pubkey: string, -): Promise { - const [{ count: followers_count }, { count: notes_count }, [followList]] = await Promise.all([ - store.count([{ kinds: [3], '#p': [pubkey] }]), - store.count([{ kinds: [1], authors: [pubkey] }]), - store.query([{ kinds: [3], authors: [pubkey], limit: 1 }]), - ]); - - return { - pubkey, - followers_count, - following_count: getTagSet(followList?.tags ?? [], 'p').size, - notes_count, - }; -} - -const authorStatsSemaphore = new Semaphore(10); -const refreshedAuthors = new LRUCache({ max: 1000 }); - -/** Calls `refreshAuthorStats` only once per author. */ -function refreshAuthorStatsDebounced(pubkey: string): void { - if (refreshedAuthors.get(pubkey)) { - return; - } - - refreshedAuthors.set(pubkey, true); - debug('refreshing author stats:', pubkey); - - authorStatsSemaphore - .lock(() => refreshAuthorStats(pubkey).catch(() => {})); -} - -export { refreshAuthorStats, refreshAuthorStatsDebounced, updateStats }; diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 68dc0bdb..1c7b9b3f 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -2,10 +2,11 @@ import { NostrEvent, NStore } from '@nostrify/nostrify'; import { matchFilter } from 'nostr-tools'; import { DittoDB } from '@/db/DittoDB.ts'; -import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoTables } from '@/db/DittoTables.ts'; import { Conf } from '@/config.ts'; -import { refreshAuthorStatsDebounced } from '@/stats.ts'; +import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { Storages } from '@/storages.ts'; +import { refreshAuthorStatsDebounced } from '@/utils/stats.ts'; import { findQuoteTag } from '@/utils/tags.ts'; interface HydrateOpts { @@ -77,6 +78,11 @@ function assembleEvents( ): DittoEvent[] { const admin = Conf.pubkey; + const eventStats = stats.events.map((stat) => ({ + ...stat, + reactions: JSON.parse(stat.reactions), + })); + for (const event of a) { event.author = b.find((e) => matchFilter({ kinds: [0], authors: [event.pubkey] }, e)); event.user = b.find((e) => matchFilter({ kinds: [30361], authors: [admin], '#d': [event.pubkey] }, e)); @@ -120,7 +126,7 @@ function assembleEvents( } event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey); - event.event_stats = stats.events.find((stats) => stats.event_id === event.id); + event.event_stats = eventStats.find((stats) => stats.event_id === event.id); } return a; @@ -270,7 +276,10 @@ async function gatherAuthorStats(events: DittoEvent[]): Promise( events .filter((event) => event.kind === 0) @@ -282,7 +291,7 @@ function refreshMissingAuthorStats(events: NostrEvent[], stats: DittoTables['aut ); for (const pubkey of missing) { - refreshAuthorStatsDebounced(pubkey); + refreshAuthorStatsDebounced({ pubkey, store, kysely }); } } @@ -309,8 +318,8 @@ async function gatherEventStats(events: DittoEvent[]): Promise ({ event_id: row.event_id, reposts_count: Math.max(0, row.reposts_count), - reactions_count: Math.max(0, row.reactions_count), replies_count: Math.max(0, row.replies_count), + reactions: row.reactions, })); } diff --git a/src/utils/stats.test.ts b/src/utils/stats.test.ts index 17f36c0a..278aa0ec 100644 --- a/src/utils/stats.test.ts +++ b/src/utils/stats.test.ts @@ -113,15 +113,13 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => { const note = genEvent({ kind: 1 }); await updateStats({ ...db, event: note }); - await db.store.event(note); - const reaction = genEvent({ kind: 7, tags: [['e', note.id]] }); - await updateStats({ ...db, event: reaction }); - await db.store.event(reaction); + await updateStats({ ...db, event: genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }) }); + await updateStats({ ...db, event: genEvent({ kind: 7, content: '😂', tags: [['e', note.id]] }) }); const stats = await getEventStats(db.kysely, note.id); - assertEquals(stats!.reactions_count, 1); + assertEquals(stats!.reactions, JSON.stringify({ '+': 1, '😂': 1 })); }); Deno.test('updateStats with kind 5 decrements reactions count', async () => { @@ -132,7 +130,7 @@ Deno.test('updateStats with kind 5 decrements reactions count', async () => { await db.store.event(note); const sk = generateSecretKey(); - const reaction = genEvent({ kind: 7, tags: [['e', note.id]] }, sk); + const reaction = genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }, sk); await updateStats({ ...db, event: reaction }); await db.store.event(reaction); @@ -140,7 +138,7 @@ Deno.test('updateStats with kind 5 decrements reactions count', async () => { const stats = await getEventStats(db.kysely, note.id); - assertEquals(stats!.reactions_count, 0); + assertEquals(stats!.reactions, JSON.stringify({})); }); Deno.test('countAuthorStats counts author stats from the database', async () => { diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 2652cbee..cc05917d 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -1,5 +1,7 @@ +import { Semaphore } from '@lambdalisue/async'; import { NostrEvent, NStore } from '@nostrify/nostrify'; import { Kysely, UpdateObject } from 'kysely'; +import { LRUCache } from 'lru-cache'; import { SetRequired } from 'type-fest'; import { DittoTables } from '@/db/DittoTables.ts'; @@ -31,7 +33,7 @@ export async function updateStats({ event, kysely, store, x = 1 }: UpdateStatsOp /** Update stats for kind 1 event. */ async function handleEvent1(kysely: Kysely, event: NostrEvent, x: number): Promise { - await updateAuthorStats(kysely, event.pubkey, ({ notes_count }) => ({ notes_count: notes_count + x })); + await updateAuthorStats(kysely, event.pubkey, ({ notes_count }) => ({ notes_count: Math.max(0, notes_count + x) })); } /** Update stats for kind 3 event. */ @@ -47,11 +49,19 @@ async function handleEvent3(kysely: Kysely, event: NostrEvent, x: n const { added, removed } = getFollowDiff(event.tags, prev?.tags); for (const pubkey of added) { - await updateAuthorStats(kysely, pubkey, ({ followers_count }) => ({ followers_count: followers_count + x })); + await updateAuthorStats( + kysely, + pubkey, + ({ followers_count }) => ({ followers_count: Math.max(0, followers_count + x) }), + ); } for (const pubkey of removed) { - await updateAuthorStats(kysely, pubkey, ({ followers_count }) => ({ followers_count: followers_count - x })); + await updateAuthorStats( + kysely, + pubkey, + ({ followers_count }) => ({ followers_count: Math.max(0, followers_count - x) }), + ); } } @@ -70,15 +80,33 @@ async function handleEvent5(kysely: Kysely, event: NostrEvent, x: - async function handleEvent6(kysely: Kysely, event: NostrEvent, x: number): Promise { const id = event.tags.find(([name]) => name === 'e')?.[1]; if (id) { - await updateEventStats(kysely, id, ({ reposts_count }) => ({ reposts_count: reposts_count + x })); + await updateEventStats(kysely, id, ({ reposts_count }) => ({ reposts_count: Math.max(0, reposts_count + x) })); } } /** Update stats for kind 7 event. */ async function handleEvent7(kysely: Kysely, event: NostrEvent, x: number): Promise { const id = event.tags.find(([name]) => name === 'e')?.[1]; - if (id) { - await updateEventStats(kysely, id, ({ reactions_count }) => ({ reactions_count: reactions_count + x })); + 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); + + // 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]; + } + } + + return { + reactions: JSON.stringify(data), + }; + }); } } @@ -160,6 +188,7 @@ export async function updateEventStats( replies_count: 0, reposts_count: 0, reactions_count: 0, + reactions: '{}', }; const prev = await getEventStats(kysely, eventId); @@ -196,3 +225,38 @@ export async function countAuthorStats( notes_count, }; } + +export interface RefreshAuthorStatsOpts { + pubkey: string; + kysely: Kysely; + store: SetRequired; +} + +/** Refresh the author's stats in the database. */ +export async function refreshAuthorStats( + { pubkey, kysely, store }: RefreshAuthorStatsOpts, +): Promise { + const stats = await countAuthorStats(store, pubkey); + + await kysely.insertInto('author_stats') + .values(stats) + .onConflict((oc) => oc.column('pubkey').doUpdateSet(stats)) + .execute(); + + return stats; +} + +const authorStatsSemaphore = new Semaphore(10); +const refreshedAuthors = new LRUCache({ max: 1000 }); + +/** Calls `refreshAuthorStats` only once per author. */ +export function refreshAuthorStatsDebounced(opts: RefreshAuthorStatsOpts): void { + if (refreshedAuthors.get(opts.pubkey)) { + return; + } + + refreshedAuthors.set(opts.pubkey, true); + + authorStatsSemaphore + .lock(() => refreshAuthorStats(opts).catch(() => {})); +} diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index cc7cc36b..41824935 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -82,6 +82,15 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< const media = imeta.length ? imeta : getMediaLinks(links); + /** Pleroma emoji reactions object. */ + const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => { + if (['+', '-'].includes(emoji)) return acc; + acc.push({ name: emoji, count, me: reactionEvent?.content === emoji }); + return acc; + }, [] as { name: string; count: number; me: boolean }[]); + + const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000); + return { id: event.id, account, @@ -96,7 +105,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null, replies_count: event.event_stats?.replies_count ?? 0, reblogs_count: event.event_stats?.reposts_count ?? 0, - favourites_count: event.event_stats?.reactions_count ?? 0, + favourites_count: event.event_stats?.reactions['+'] ?? 0, favourited: reactionEvent?.content === '+', reblogged: Boolean(repostEvent), muted: false, @@ -114,6 +123,10 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< uri: Conf.external(note), url: Conf.external(note), zapped: Boolean(zapEvent), + pleroma: { + emoji_reactions: reactions, + expires_at: !isNaN(expiresAt.getTime()) ? expiresAt.toISOString() : undefined, + }, }; }