From f5d9033183d627be85d1e549fc0d86d9f8f7ad98 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Tue, 6 Jan 2026 15:04:14 +0100 Subject: [PATCH] Refactor to use cache-first architecture with background sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Motivations:** - total_sponsoring should be calculated from cache, not stored in tags - Author searches should use cache first, not query Nostr directly - All elements should be read/written from/to database - Background sync should scan all notes with service='zapwall.fr' tag and update cache - Sync should run at startup and resume on each page navigation **Root causes:** - total_sponsoring was stored in tags which required updates on every sponsoring payment - Author queries were querying Nostr directly instead of using cache - No background sync service to keep cache up to date with new notes **Correctifs:** - Removed totalSponsoring from AuthorTags interface and buildAuthorTags - Modified getAuthorSponsoring to calculate from cache (sponsoring queries) instead of tags - Modified parsePresentationEvent to set totalSponsoring to 0 (calculated on demand from cache) - Modified fetchAuthorByHashId and fetchAuthorPresentationFromPool to use cache first and calculate totalSponsoring from cache - Created platformSyncService that scans all notes with service='zapwall.fr' tag and caches them - Modified _app.tsx to start continuous sync on mount and resume on page navigation - All author presentations now calculate totalSponsoring from cache when loaded **Evolutions:** - Cache-first architecture: all queries check cache before querying Nostr - Background sync service keeps cache up to date automatically - totalSponsoring is always calculated from actual sponsoring data in cache - Better performance: cache queries are faster than Nostr queries - Non-blocking sync: background sync doesn't block UI **Pages affectées:** - lib/nostrTagSystemTypes.ts - lib/nostrTagSystemBuild.ts - lib/articlePublisherHelpersPresentation.ts - lib/sponsoring.ts - lib/authorQueries.ts - lib/platformSync.ts (new) - pages/_app.tsx --- components/LanguageSettingsManager.tsx | 1 - features/language-preference-settings.md | 1 - lib/articlePublisherHelpersPresentation.ts | 19 +- lib/authorQueries.ts | 11 +- lib/nostrPrivateMessages.ts | 18 +- lib/nostrTagSystemBuild.ts | 3 - lib/nostrTagSystemTypes.ts | 1 - lib/nostrZapVerification.ts | 11 +- lib/platformSync.ts | 216 +++++++++++++++++++++ lib/sponsoring.ts | 65 +------ pages/_app.tsx | 25 ++- 11 files changed, 293 insertions(+), 78 deletions(-) create mode 100644 lib/platformSync.ts diff --git a/components/LanguageSettingsManager.tsx b/components/LanguageSettingsManager.tsx index 44525bb..e7fa2c0 100644 --- a/components/LanguageSettingsManager.tsx +++ b/components/LanguageSettingsManager.tsx @@ -85,4 +85,3 @@ export function LanguageSettingsManager(): React.ReactElement { ) } - diff --git a/features/language-preference-settings.md b/features/language-preference-settings.md index ed9dcfc..bbfe88c 100644 --- a/features/language-preference-settings.md +++ b/features/language-preference-settings.md @@ -74,4 +74,3 @@ Permettre aux utilisateurs de configurer leur langue de préférence (fr/en) dan - Tester le changement de langue depuis les paramètres - Vérifier que le sélecteur dans le header fonctionne toujours correctement - Confirmer que les traductions sont appliquées après changement de langue - diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts index 831a227..a4b81c0 100644 --- a/lib/articlePublisherHelpersPresentation.ts +++ b/lib/articlePublisherHelpersPresentation.ts @@ -84,7 +84,6 @@ export async function buildPresentationEvent( title: draft.title, preview: draft.preview, mainnetAddress: draft.mainnetAddress, - totalSponsoring: 0, ...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}), }) @@ -177,14 +176,16 @@ export async function parsePresentationEvent(event: Event): Promise { - // Check cache first + // Check cache first - this is the primary source const cached = await objectCache.getAuthorByPubkey(pubkey) if (cached) { + // Calculate totalSponsoring from cache + const { getAuthorSponsoring } = await import('./sponsoring') + cached.totalSponsoring = await getAuthorSponsoring(pubkey) return cached } @@ -260,6 +264,9 @@ export async function fetchAuthorPresentationFromPool( if (event) { const tags = extractTagsFromEvent(event) if (value.hash) { + // Calculate totalSponsoring from cache before storing + const { getAuthorSponsoring } = await import('./sponsoring') + value.totalSponsoring = await getAuthorSponsoring(value.pubkey) await objectCache.set('author', value.hash, event, value, tags.version ?? 0, tags.hidden, value.index) } } diff --git a/lib/authorQueries.ts b/lib/authorQueries.ts index beef927..015f58b 100644 --- a/lib/authorQueries.ts +++ b/lib/authorQueries.ts @@ -27,10 +27,14 @@ export async function fetchAuthorByHashId( // Otherwise, treat as hash ID const hashId = hashIdOrPubkey - // Check cache first + // Check cache first - this is the primary source const cached = await objectCache.get('author', hashId) if (cached) { - return cached as import('@/types/nostr').AuthorPresentationArticle + const presentation = cached as import('@/types/nostr').AuthorPresentationArticle + // Calculate totalSponsoring from cache + const { getAuthorSponsoring } = await import('./sponsoring') + presentation.totalSponsoring = await getAuthorSponsoring(presentation.pubkey) + return presentation } const filters = [ @@ -66,6 +70,9 @@ export async function fetchAuthorByHashId( if (event) { const tags = extractTagsFromEvent(event) if (value.hash) { + // Calculate totalSponsoring from cache before storing + const { getAuthorSponsoring } = await import('./sponsoring') + value.totalSponsoring = await getAuthorSponsoring(value.pubkey) await objectCache.set('author', value.hash, event, value, tags.version ?? 0, tags.hidden, value.index) } } diff --git a/lib/nostrPrivateMessages.ts b/lib/nostrPrivateMessages.ts index e3c1c40..c28e59e 100644 --- a/lib/nostrPrivateMessages.ts +++ b/lib/nostrPrivateMessages.ts @@ -3,7 +3,13 @@ import { SimplePool } from 'nostr-tools' import { decryptArticleContent, type DecryptionKey } from './articleEncryption' import { getPrimaryRelaySync } from './config' -function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string) { +function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string): Array<{ + kinds: number[] + '#p': string[] + '#e': string[] + authors: string[] + limit: number +}> { return [ { kinds: [4], // Encrypted direct messages @@ -42,7 +48,7 @@ export function getPrivateContent( const { createSubscription } = require('@/types/nostr-tools-extended') const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey)) - const finalize = (result: string | null) => { + const finalize = (result: string | null): void => { if (resolved) { return } @@ -119,7 +125,7 @@ export async function getDecryptionKey( const { createSubscription } = require('@/types/nostr-tools-extended') const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey)) - const finalize = (result: DecryptionKey | null) => { + const finalize = (result: DecryptionKey | null): void => { if (resolved) { return } @@ -128,10 +134,12 @@ export async function getDecryptionKey( resolve(result) } - sub.on('event', (event: Event) => { + sub.on('event', (event: Event): void => { handleDecryptionKeyEvent(event, recipientPrivateKey, finalize) }) - sub.on('eose', () => finalize(null)) + sub.on('eose', (): void => { + finalize(null) + }) setTimeout(() => finalize(null), 5000) }) } diff --git a/lib/nostrTagSystemBuild.ts b/lib/nostrTagSystemBuild.ts index 443a2ac..abf0a70 100644 --- a/lib/nostrTagSystemBuild.ts +++ b/lib/nostrTagSystemBuild.ts @@ -29,9 +29,6 @@ export function buildAuthorTags(authorTags: AuthorTags, result: string[][]): voi if (authorTags.mainnetAddress) { result.push(['mainnet_address', authorTags.mainnetAddress]) } - if (authorTags.totalSponsoring !== undefined) { - result.push(['total_sponsoring', authorTags.totalSponsoring.toString()]) - } if (authorTags.pictureUrl) { result.push(['picture', authorTags.pictureUrl]) } diff --git a/lib/nostrTagSystemTypes.ts b/lib/nostrTagSystemTypes.ts index ea3086a..67a19b4 100644 --- a/lib/nostrTagSystemTypes.ts +++ b/lib/nostrTagSystemTypes.ts @@ -30,7 +30,6 @@ export interface AuthorTags extends BaseTags { title: string preview?: string mainnetAddress?: string - totalSponsoring?: number pictureUrl?: string } diff --git a/lib/nostrZapVerification.ts b/lib/nostrZapVerification.ts index 0422305..e74a3bf 100644 --- a/lib/nostrZapVerification.ts +++ b/lib/nostrZapVerification.ts @@ -3,7 +3,12 @@ import { SimplePool } from 'nostr-tools' import { createSubscription } from '@/types/nostr-tools-extended' import { getPrimaryRelaySync } from './config' -function createZapFilters(targetPubkey: string, targetEventId: string, userPubkey: string) { +function createZapFilters(targetPubkey: string, targetEventId: string, userPubkey: string): Array<{ + kinds: number[] + '#p': string[] + '#e': string[] + authors: string[] +}> { return [ { kinds: [9735], // Zap receipt @@ -65,7 +70,7 @@ export function checkZapReceipt( const relayUrl = getPrimaryRelaySync() const sub = createSubscription(pool, [relayUrl], createZapFilters(targetPubkey, targetEventId, userPubkey)) - const finalize = (value: boolean) => { + const finalize = (value: boolean): void => { if (resolved) { return } @@ -75,7 +80,7 @@ export function checkZapReceipt( } const resolvedRef = { current: resolved } - sub.on('event', (event: Event) => { + sub.on('event', (event: Event): void => { handleZapReceiptEvent(event, targetEventId, targetPubkey, userPubkey, amount, finalize, resolvedRef) }) diff --git a/lib/platformSync.ts b/lib/platformSync.ts new file mode 100644 index 0000000..e339dc3 --- /dev/null +++ b/lib/platformSync.ts @@ -0,0 +1,216 @@ +/** + * Background sync service that scans all notes with service='zapwall.fr' tag + * and caches them in IndexedDB + * Runs in background (non-blocking) and updates cache when new notes are published + */ + +import type { Event } from 'nostr-tools' +import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' +import { nostrService } from './nostr' +import { getPrimaryRelaySync } from './config' +import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' +import { buildTagFilter, extractTagsFromEvent } from './nostrTagSystem' +import { objectCache } from './objectCache' +import { parsePresentationEvent } from './articlePublisherHelpersPresentation' +import { parseArticleFromEvent, parseSeriesFromEvent, parseReviewFromEvent, parsePurchaseFromEvent, parseReviewTipFromEvent, parseSponsoringFromEvent } from './nostrEventParsing' + +class PlatformSyncService { + private syncInProgress = false + private syncSubscription: { unsub: () => void } | null = null + private lastSyncTime: number = 0 + private readonly SYNC_INTERVAL_MS = 60000 // Sync every minute + private readonly SYNC_TIMEOUT_MS = 30000 // 30 seconds timeout per sync + + /** + * Start background sync + * Scans all notes with service='zapwall.fr' and caches them + */ + async startSync(): Promise { + if (this.syncInProgress) { + return + } + + const pool = nostrService.getPool() + if (!pool) { + console.warn('Pool not initialized, cannot start platform sync') + return + } + + this.syncInProgress = true + + try { + await this.performSync(pool as unknown as SimplePoolWithSub) + } catch (error) { + console.error('Error in platform sync:', error) + } finally { + this.syncInProgress = false + } + } + + /** + * Perform a sync operation + * Scans all notes with service='zapwall.fr' tag + */ + private async performSync(pool: SimplePoolWithSub): Promise { + const filters = [ + { + ...buildTagFilter({ + service: PLATFORM_SERVICE, + }), + since: MIN_EVENT_DATE, + limit: 1000, // Get up to 1000 events per sync + }, + ] + + return new Promise((resolve) => { + const relayUrl = getPrimaryRelaySync() + const { createSubscription } = require('@/types/nostr-tools-extended') + const sub = createSubscription(pool, [relayUrl], filters) + + const events: Event[] = [] + let resolved = false + + const finalize = async (): Promise => { + if (resolved) { + return + } + resolved = true + sub.unsub() + this.syncSubscription = null + + // Process all events and cache them + await this.processAndCacheEvents(events) + + this.lastSyncTime = Date.now() + resolve() + } + + sub.on('event', (event: Event): void => { + // Only process events with service='zapwall.fr' + const tags = extractTagsFromEvent(event) + if (tags.service === PLATFORM_SERVICE) { + events.push(event) + } + }) + + sub.on('eose', async (): Promise => { + await finalize() + }) + + // Timeout after SYNC_TIMEOUT_MS + setTimeout(async (): Promise => { + await finalize() + }, this.SYNC_TIMEOUT_MS).unref?.() + + this.syncSubscription = sub + }) + } + + /** + * Process events and cache them by type + */ + private async processAndCacheEvents(events: Event[]): Promise { + for (const event of events) { + try { + await this.processEvent(event) + } catch (error) { + console.error('Error processing event:', error, event.id) + // Continue processing other events even if one fails + } + } + } + + /** + * Process a single event and cache it + */ + private async processEvent(event: Event): Promise { + const tags = extractTagsFromEvent(event) + + // Skip hidden events + if (tags.hidden) { + return + } + + // Try to parse and cache by type + if (tags.type === 'author') { + const parsed = await parsePresentationEvent(event) + if (parsed && parsed.hash) { + await objectCache.set('author', parsed.hash, event, parsed, tags.version ?? 0, tags.hidden, parsed.index) + } + } else if (tags.type === 'series') { + const parsed = await parseSeriesFromEvent(event) + if (parsed && parsed.hash) { + await objectCache.set('series', parsed.hash, event, parsed, tags.version ?? 0, tags.hidden, parsed.index) + } + } else if (tags.type === 'publication') { + const parsed = await parseArticleFromEvent(event) + if (parsed && parsed.hash) { + await objectCache.set('publication', parsed.hash, event, parsed, tags.version ?? 0, tags.hidden, parsed.index) + } + } else if (tags.type === 'quote') { + const parsed = await parseReviewFromEvent(event) + if (parsed && parsed.hash) { + await objectCache.set('review', parsed.hash, event, parsed, tags.version ?? 0, tags.hidden, parsed.index) + } + } else if (event.kind === 9735) { + // Zap receipts (kind 9735) can be sponsoring, purchase, or review_tip + const sponsoring = await parseSponsoringFromEvent(event) + if (sponsoring && sponsoring.hash) { + await objectCache.set('sponsoring', sponsoring.hash, event, sponsoring, 0, false, sponsoring.index) + } else { + const purchase = await parsePurchaseFromEvent(event) + if (purchase && purchase.hash) { + await objectCache.set('purchase', purchase.hash, event, purchase, 0, false, purchase.index) + } else { + const reviewTip = await parseReviewTipFromEvent(event) + if (reviewTip && reviewTip.hash) { + await objectCache.set('review_tip', reviewTip.hash, event, reviewTip, 0, false, reviewTip.index) + } + } + } + } + } + + /** + * Start continuous sync (runs periodically) + */ + startContinuousSync(): void { + // Start initial sync + void this.startSync() + + // Schedule periodic syncs + setInterval(() => { + if (!this.syncInProgress) { + void this.startSync() + } + }, this.SYNC_INTERVAL_MS) + } + + /** + * Stop sync + */ + stopSync(): void { + if (this.syncSubscription) { + this.syncSubscription.unsub() + this.syncSubscription = null + } + this.syncInProgress = false + } + + /** + * Check if sync is in progress + */ + isSyncing(): boolean { + return this.syncInProgress + } + + /** + * Get last sync time + */ + getLastSyncTime(): number { + return this.lastSyncTime + } +} + +export const platformSyncService = new PlatformSyncService() + diff --git a/lib/sponsoring.ts b/lib/sponsoring.ts index ad68238..ecc67bf 100644 --- a/lib/sponsoring.ts +++ b/lib/sponsoring.ts @@ -1,64 +1,19 @@ -import { nostrService } from './nostr' +import { getSponsoringByAuthor } from './sponsoringQueries' import type { Article } from '@/types/nostr' -import { getPrimaryRelaySync } from './config' -import { buildTagFilter, extractTagsFromEvent } from './nostrTagSystem' -import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' - -function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey: string): Promise { - const filters = [ - { - ...buildTagFilter({ - type: 'author', - authorPubkey: pubkey, - service: PLATFORM_SERVICE, - }), - since: MIN_EVENT_DATE, - limit: 1, - }, - ] - - return new Promise((resolve) => { - let resolved = false - const relayUrl = getPrimaryRelaySync() - const { createSubscription } = require('@/types/nostr-tools-extended') - const sub = createSubscription(pool, [relayUrl], filters) - - const finalize = (value: number): void => { - if (resolved) { - return - } - resolved = true - sub.unsub() - resolve(value) - } - - sub.on('event', (event: import('nostr-tools').Event): void => { - // Check if it's an author type using new tag system (tag is 'author' in English) - const tags = extractTagsFromEvent(event) - if (tags.type !== 'author') { - return - } - const total = (tags.totalSponsoring) ?? 0 - finalize(total) - }) - - sub.on('eose', (): void => { - finalize(0) - }) - setTimeout(() => finalize(0), 5000) - }) -} /** * Get total sponsoring for an author by their pubkey + * Calculates from cache (sponsoring queries) instead of tags */ -export function getAuthorSponsoring(pubkey: string): Promise { - const pool = nostrService.getPool() - if (!pool) { - return Promise.resolve(0) +export async function getAuthorSponsoring(pubkey: string): Promise { + try { + const sponsoringList = await getSponsoringByAuthor(pubkey, 5000) + // Sum all sponsoring amounts for this author + return sponsoringList.reduce((total, sponsoring) => total + sponsoring.amount, 0) + } catch (error) { + console.error('Error calculating author sponsoring from cache:', error) + return 0 } - - return subscribeToPresentation(pool, pubkey) } /** diff --git a/pages/_app.tsx b/pages/_app.tsx index 7669d4d..3bd3902 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -2,6 +2,7 @@ import '@/styles/globals.css' import type { AppProps } from 'next/app' import { useI18n } from '@/hooks/useI18n' import React from 'react' +import { platformSyncService } from '@/lib/platformSync' function I18nProvider({ children }: { children: React.ReactNode }) { // Get saved locale from localStorage or default to French @@ -40,7 +41,29 @@ function I18nProvider({ children }: { children: React.ReactNode }) { return <>{children} } -export default function App({ Component, pageProps }: AppProps) { +export default function App({ Component, pageProps }: AppProps): React.ReactElement { + // Start platform sync on app mount and resume on each page navigation + React.useEffect(() => { + // Start continuous sync (runs periodically in background) + platformSyncService.startContinuousSync() + + // Also trigger a sync on each page navigation + const handleRouteChange = (): void => { + if (!platformSyncService.isSyncing()) { + void platformSyncService.startSync() + } + } + + // Listen to route changes + const router = require('next/router').default + router.events?.on('routeChangeComplete', handleRouteChange) + + return () => { + router.events?.off('routeChangeComplete', handleRouteChange) + platformSyncService.stopSync() + } + }, []) + return (