From 17e4b10b1f8659dc14a93d3710c3bbe97eb70d73 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Wed, 7 Jan 2026 03:10:40 +0100 Subject: [PATCH] lint fix wip --- .../code-analysis-duplication-optimization.md | 658 ++++++++++++++ lib/authorQueries.ts | 16 +- lib/helpers/eventCacheHelper.ts | 111 +++ lib/helpers/indexedDBHelper.ts | 616 +++++++++++++ lib/helpers/paymentNoteSyncHelper.ts | 132 +++ lib/helpers/queryHelpers.ts | 36 + lib/helpers/syncCacheHelpers.ts | 73 ++ lib/helpers/syncContentCacheHelpers.ts | 57 ++ lib/helpers/syncProgressHelper.ts | 54 ++ lib/helpers/syncSubscriptionHelper.ts | 127 +++ lib/localeStorage.ts | 77 +- lib/nostr.ts | 5 +- lib/notificationDetector.ts | 45 - lib/notificationService.ts | 229 +---- lib/objectCache.ts | 207 ++--- lib/platformTracking.ts | 1 - lib/publishLog.ts | 140 +-- lib/purchaseQueries.ts | 20 +- lib/reviewTipQueries.ts | 20 +- lib/seriesQueries.ts | 21 +- lib/settingsCache.ts | 77 +- lib/sponsoringQueries.ts | 20 +- lib/storage/indexedDB.ts | 189 +--- lib/sync/userContentSyncSteps.ts | 205 +++++ lib/userContentSync.ts | 846 +----------------- public/writeWorker.js | 152 ++-- 26 files changed, 2409 insertions(+), 1725 deletions(-) create mode 100644 docs/code-analysis-duplication-optimization.md create mode 100644 lib/helpers/eventCacheHelper.ts create mode 100644 lib/helpers/indexedDBHelper.ts create mode 100644 lib/helpers/paymentNoteSyncHelper.ts create mode 100644 lib/helpers/queryHelpers.ts create mode 100644 lib/helpers/syncCacheHelpers.ts create mode 100644 lib/helpers/syncContentCacheHelpers.ts create mode 100644 lib/helpers/syncProgressHelper.ts create mode 100644 lib/helpers/syncSubscriptionHelper.ts create mode 100644 lib/sync/userContentSyncSteps.ts diff --git a/docs/code-analysis-duplication-optimization.md b/docs/code-analysis-duplication-optimization.md new file mode 100644 index 0000000..1175666 --- /dev/null +++ b/docs/code-analysis-duplication-optimization.md @@ -0,0 +1,658 @@ +# Analyse de duplication, mutualisation et optimisation du code + +**Date** : 2024-12-19 +**Auteur** : Équipe 4NK + +## Résumé exécutif + +Cette analyse identifie les duplications de code, les opportunités de mutualisation/centralisation et les axes d'organisation et d'optimisation dans le codebase. L'objectif est de réduire la duplication, améliorer la maintenabilité et optimiser l'architecture sans dégrader les performances. + +## 1. Duplications identifiées + +### 1.1 Initialisation IndexedDB (Critique - Forte duplication) + +**Localisation** : Multiple fichiers avec pattern identique + +**Fichiers concernés** : +- `lib/notificationService.ts` (lignes 39-89) +- `lib/publishLog.ts` (lignes 24-72) +- `lib/storage/indexedDB.ts` (lignes 25-72) +- `lib/objectCache.ts` (lignes 33-86) +- `lib/localeStorage.ts` (lignes 14-43) +- `lib/settingsCache.ts` (lignes 24-54) +- `public/writeWorker.js` (lignes 404-479) + +**Pattern dupliqué** : +```typescript +private async init(): Promise { + if (this.db) return + if (this.initPromise) return this.initPromise + this.initPromise = this.openDatabase() + try { + await this.initPromise + } catch (error) { + this.initPromise = null + throw error + } +} + +private openDatabase(): Promise { + return new Promise((resolve, reject) => { + if (typeof window === 'undefined' || !window.indexedDB) { + reject(new Error('IndexedDB is not available')) + return + } + const request = window.indexedDB.open(DB_NAME, DB_VERSION) + request.onerror = () => reject(...) + request.onsuccess = () => { this.db = request.result; resolve() } + request.onupgradeneeded = (event) => { /* schema creation */ } + }) +} +``` + +**Impact** : +- ~200 lignes de code dupliquées +- Maintenance difficile (changements à appliquer en 7+ endroits) +- Risque d'incohérences entre implémentations + +**Solution proposée** : Créer un utilitaire générique `lib/indexedDBHelper.ts` avec factory pattern + +--- + +### 1.2 Pattern de subscription avec relay rotation (Critique - Forte duplication) + +**Localisation** : `lib/userContentSync.ts` + +**Fonctions concernées** : +- `fetchAndCachePublications` (lignes 22-146) +- `fetchAndCacheSeries` (lignes 151-281) +- `fetchAndCachePurchases` (lignes 286-383) +- `fetchAndCacheSponsoring` (lignes 388-485) +- `fetchAndCacheReviewTips` (lignes 490-587) +- `fetchAndCachePaymentNotes` (lignes 599-721) + +**Pattern dupliqué** (~50 lignes par fonction) : +```typescript +// 1. Récupération lastSyncDate +const { getLastSyncDate } = await import('./syncStorage') +const lastSyncDate = await getLastSyncDate() + +// 2. Construction des filters +const filters = [{ ...buildTagFilter(...), since: lastSyncDate, limit: 1000 }] + +// 3. Tentative avec relay rotation +const { createSubscription } = require('@/types/nostr-tools-extended') +let sub = null +let usedRelayUrl = '' +try { + const result = await tryWithRelayRotation( + pool as unknown as import('nostr-tools').SimplePool, + async (relayUrl, poolWithSub) => { + usedRelayUrl = relayUrl + // Notification syncProgressManager + const { syncProgressManager } = await import('./syncProgressManager') + const currentProgress = syncProgressManager.getProgress() + if (currentProgress) { + syncProgressManager.setProgress({ + ...currentProgress, + currentStep: 0, + currentRelay: relayUrl, + }) + } + return createSubscription(poolWithSub, [relayUrl], filters) + }, + 5000 + ) + sub = result +} catch { + // Fallback to primary relay + usedRelayUrl = getPrimaryRelaySync() + sub = createSubscription(pool, [usedRelayUrl], filters) +} + +// 4. Gestion des événements avec Promise + timeout +const events: Event[] = [] +return new Promise((resolve) => { + let finished = false + const done = async () => { /* ... */ } + sub.on('event', (event) => { events.push(event) }) + sub.on('eose', () => void done()) + setTimeout(() => void done(), 10000).unref?.() +}) +``` + +**Impact** : +- ~300 lignes de code dupliquées +- Logique de gestion d'événements répétée 6 fois +- Risque d'incohérences dans la gestion des erreurs et timeouts + +**Solution proposée** : Créer une fonction générique `createSyncSubscription` dans `lib/syncSubscriptionHelper.ts` + +--- + +### 1.3 Pattern de traitement d'événements avec groupement par hash (Moyenne duplication) + +**Localisation** : `lib/userContentSync.ts` + +**Fonctions concernées** : +- `fetchAndCachePublications` (lignes 88-126) +- `fetchAndCacheSeries` (lignes 218-256) + +**Pattern dupliqué** : +```typescript +// Group events by hash ID and cache the latest version of each +const eventsByHashId = new Map() +for (const event of events) { + const tags = extractTagsFromEvent(event) + if (tags.id) { + const parsed = parseObjectId(tags.id) + const hash = parsed.hash ?? tags.id + if (!eventsByHashId.has(hash)) { + eventsByHashId.set(hash, []) + } + eventsByHashId.get(hash)!.push(event) + } +} + +// Cache each publication/series +for (const [_hash, hashEvents] of eventsByHashId.entries()) { + const latestEvent = getLatestVersion(hashEvents) + if (latestEvent) { + const extracted = await extractPublicationFromEvent(latestEvent) // ou extractSeriesFromEvent + if (extracted) { + const publicationParsed = parseObjectId(extracted.id) + const extractedHash = publicationParsed.hash ?? extracted.id + const extractedIndex = publicationParsed.index ?? 0 + const tags = extractTagsFromEvent(latestEvent) + const { writeService } = await import('./writeService') + await writeService.writeObject( + 'publication', // ou 'series' + extractedHash, + latestEvent, + extracted, + tags.version ?? 0, + tags.hidden ?? false, + extractedIndex, + false + ) + } + } +} +``` + +**Impact** : +- ~40 lignes dupliquées +- Logique de groupement et cache répétée + +**Solution proposée** : Créer une fonction générique `groupAndCacheEventsByHash` dans `lib/eventCacheHelper.ts` + +--- + +### 1.4 Pattern de queries avec objectCache (Faible duplication mais répétitif) + +**Localisation** : Fichiers `*Queries.ts` + +**Fichiers concernés** : +- `lib/purchaseQueries.ts` +- `lib/seriesQueries.ts` +- `lib/articleQueries.ts` +- (et probablement d'autres fichiers queries) + +**Pattern répétitif** : +```typescript +export async function getXxxById(id: string, _timeoutMs: number = 5000): Promise { + const parsed = parseObjectId(id) + const hash = parsed.hash ?? id + + // Read only from IndexedDB cache + const cached = await objectCache.get('xxx', hash) + if (cached) { + return cached as Xxx + } + + // Also try by ID if hash lookup failed + const cachedById = await objectCache.getById('xxx', id) + if (cachedById) { + return cachedById as Xxx + } + + // Not found in cache - return null (no network request) + return null +} +``` + +**Impact** : +- Pattern répété dans plusieurs fichiers queries +- Logique de fallback identique + +**Solution proposée** : Créer une fonction helper `getCachedObjectById` dans `lib/queryHelpers.ts` + +--- + +### 1.5 Pattern de writeObject avec extraction (Moyenne duplication) + +**Localisation** : Multiple fichiers + +**Fichiers concernés** : +- `lib/userContentSync.ts` (lignes 107-123, 237-253, 351-359, 453-461, 555-563) +- `lib/platformSync.ts` (lignes 304-305, 310-311, 316-317) + +**Pattern répétitif** : +```typescript +const extracted = await extractXxxFromEvent(event) +if (extracted) { + const { writeService } = await import('./writeService') + await writeService.writeObject( + 'xxx', + extracted.hash, + event, + extracted, + tags.version ?? 0, + tags.hidden ?? false, + extracted.index ?? 0, + false + ) +} +``` + +**Impact** : +- Appels répétés avec mêmes paramètres par défaut +- Logique d'extraction + écriture répétée + +**Solution proposée** : Créer une fonction helper `cacheEventAsObject` dans `lib/eventCacheHelper.ts` + +--- + +### 1.6 Pattern de gestion de transactions IndexedDB (Moyenne duplication) + +**Localisation** : Multiple fichiers + +**Pattern répétitif** : +```typescript +const transaction = db.transaction([STORE_NAME], 'readonly' | 'readwrite') +const store = transaction.objectStore(STORE_NAME) +const index = store.index('xxx') + +return new Promise((resolve, reject) => { + const request = index.get(key) // ou openCursor, getAll, etc. + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) +}) +``` + +**Impact** : +- Wrapper Promise répété dans de nombreux endroits +- Gestion d'erreurs similaire partout + +**Solution proposée** : Créer des helpers dans `lib/indexedDBHelper.ts` : `getFromStore`, `getAllFromStore`, `putToStore`, `deleteFromStore`, `openCursor` + +--- + +### 1.7 Pattern de gestion de progress dans SyncProgressBar (Duplication interne) + +**Localisation** : `components/SyncProgressBar.tsx` + +**Pattern dupliqué** (lignes 104-126 et 177-199) : +```typescript +const { syncProgressManager } = await import('@/lib/syncProgressManager') +const checkProgress = (): void => { + const currentProgress = syncProgressManager.getProgress() + if (currentProgress) { + setSyncProgress(currentProgress) + if (currentProgress.completed) { + setIsSyncing(false) + void loadSyncStatus() + } + } +} +// Check progress periodically +const progressInterval = setInterval(() => { + checkProgress() + const currentProgress = syncProgressManager.getProgress() + if (currentProgress?.completed) { + clearInterval(progressInterval) + } +}, 500) +// Cleanup after 60 seconds max +setTimeout(() => { + clearInterval(progressInterval) + setIsSyncing(false) +}, 60000) +``` + +**Impact** : +- Code dupliqué dans deux fonctions (auto-sync et resynchronize) +- Logique de polling répétée + +**Solution proposée** : Extraire dans un hook custom `useSyncProgress` ou une fonction helper + +--- + +## 2. Opportunités de mutualisation/centralisation + +### 2.1 Service d'initialisation IndexedDB unifié + +**Objectif** : Centraliser toute la logique d'initialisation IndexedDB + +**Structure proposée** : +```typescript +// lib/indexedDBHelper.ts +export interface IndexedDBConfig { + dbName: string + version: number + storeName: string + keyPath: string + indexes?: Array<{ name: string; keyPath: string; unique?: boolean }> + onUpgrade?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void +} + +export class IndexedDBHelper { + private static instances = new Map() + private db: IDBDatabase | null = null + private initPromise: Promise | null = null + + static getInstance(config: IndexedDBConfig): IndexedDBHelper { + // Singleton par dbName + } + + async init(): Promise { /* ... */ } + async getStore(mode: 'readonly' | 'readwrite'): Promise { /* ... */ } + // Helpers pour opérations courantes +} +``` + +**Bénéfices** : +- Réduction de ~200 lignes de code dupliqué +- Maintenance centralisée +- Cohérence garantie entre services + +--- + +### 2.2 Helper de subscription avec relay rotation + +**Objectif** : Centraliser le pattern de subscription avec rotation de relais + +**Structure proposée** : +```typescript +// lib/syncSubscriptionHelper.ts +export interface SyncSubscriptionConfig { + pool: SimplePoolWithSub + filters: Filter[] + onEvent: (event: Event) => void | Promise + onComplete?: (events: Event[]) => void | Promise + timeout?: number + updateProgress?: (relayUrl: string) => void +} + +export async function createSyncSubscription( + config: SyncSubscriptionConfig +): Promise<{ subscription: Subscription; relayUrl: string; events: Event[] }> { + // Centralise toute la logique de rotation, gestion d'événements, timeout +} +``` + +**Bénéfices** : +- Réduction de ~300 lignes de code dupliqué +- Gestion d'erreurs unifiée +- Facilite les tests + +--- + +### 2.3 Helper de groupement et cache d'événements + +**Objectif** : Centraliser la logique de groupement par hash et cache + +**Structure proposée** : +```typescript +// lib/eventCacheHelper.ts +export interface EventCacheConfig { + objectType: ObjectType + extractor: (event: Event) => Promise + getHash: (extracted: ExtractedObject) => string + getIndex: (extracted: ExtractedObject) => number +} + +export async function groupAndCacheEventsByHash( + events: Event[], + config: EventCacheConfig +): Promise { + // Groupement par hash, sélection de la dernière version, cache +} +``` + +**Bénéfices** : +- Réduction de ~40 lignes de code dupliqué +- Logique de versioning centralisée + +--- + +### 2.4 Helper de queries unifié + +**Objectif** : Simplifier les queries avec fallback hash/ID + +**Structure proposée** : +```typescript +// lib/queryHelpers.ts +export async function getCachedObjectById( + objectType: ObjectType, + id: string +): Promise { + // Logique de fallback hash/ID centralisée +} +``` + +**Bénéfices** : +- Réduction de code répétitif dans les queries +- Cohérence des fallbacks + +--- + +### 2.5 Helper de cache d'événements + +**Objectif** : Simplifier l'écriture d'objets après extraction + +**Structure proposée** : +```typescript +// lib/eventCacheHelper.ts +export async function cacheEventAsObject( + event: Event, + objectType: ObjectType, + extractor: (event: Event) => Promise +): Promise { + // Extraction + écriture avec paramètres par défaut +} +``` + +**Bénéfices** : +- Réduction de code répétitif +- Paramètres par défaut cohérents + +--- + +## 3. Axes d'organisation et d'optimisation + +### 3.1 Organisation des helpers + +**Structure proposée** : +``` +lib/ + helpers/ + indexedDBHelper.ts # Initialisation et opérations IndexedDB + syncSubscriptionHelper.ts # Subscriptions avec relay rotation + eventCacheHelper.ts # Groupement et cache d'événements + queryHelpers.ts # Helpers pour queries + transactionHelpers.ts # Wrappers pour transactions IndexedDB +``` + +**Bénéfices** : +- Organisation claire par responsabilité +- Facilite la découverte et la réutilisation +- Séparation des préoccupations + +--- + +### 3.2 Optimisation des imports dynamiques + +**Problème identifié** : Imports dynamiques répétés dans les boucles + +**Exemples** : +- `const { writeService } = await import('./writeService')` dans des boucles +- `const { syncProgressManager } = await import('./syncProgressManager')` dans des callbacks + +**Solution** : Importer en début de fonction ou utiliser des imports statiques quand possible + +**Impact** : Réduction des latences et amélioration des performances + +--- + +### 3.3 Optimisation de la gestion des événements + +**Problème identifié** : Accumulation d'événements en mémoire avant traitement + +**Solution** : Traitement en streaming avec backpressure + +**Structure proposée** : +```typescript +export async function createStreamingSyncSubscription( + config: SyncSubscriptionConfig & { + processor: (event: Event) => Promise + batchSize?: number + } +): Promise<{ results: T[] }> { + // Traitement par batch au lieu d'accumulation complète +} +``` + +**Bénéfices** : +- Réduction de l'utilisation mémoire +- Traitement plus rapide pour de gros volumes + +--- + +### 3.4 Centralisation de la gestion d'erreurs IndexedDB + +**Problème identifié** : Gestion d'erreurs dispersée et parfois incohérente + +**Solution** : Créer un wrapper d'erreur IndexedDB avec logging structuré + +**Structure proposée** : +```typescript +// lib/indexedDBHelper.ts +export class IndexedDBError extends Error { + constructor( + message: string, + public readonly operation: string, + public readonly storeName?: string, + public readonly cause?: unknown + ) { + super(message) + // Logging structuré automatique + } +} +``` + +**Bénéfices** : +- Traçabilité améliorée +- Gestion d'erreurs cohérente + +--- + +### 3.5 Optimisation des transactions IndexedDB + +**Problème identifié** : Transactions multiples pour des opérations liées + +**Solution** : Regrouper les opérations dans une seule transaction quand possible + +**Exemple** : Dans `writeWorker.js`, `handleWriteMultiTable` pourrait optimiser les transactions par type + +--- + +### 3.6 Typage strict pour les helpers + +**Problème identifié** : Utilisation de `unknown` et `any` dans certains helpers + +**Solution** : Génériques TypeScript stricts pour tous les helpers + +**Bénéfices** : +- Sécurité de type améliorée +- Meilleure autocomplétion +- Détection d'erreurs à la compilation + +--- + +### 3.7 Documentation et exemples + +**Problème identifié** : Manque de documentation sur les patterns à utiliser + +**Solution** : Créer `docs/patterns/` avec : +- `indexedDB-patterns.md` : Patterns d'utilisation IndexedDB +- `subscription-patterns.md` : Patterns de subscription +- `caching-patterns.md` : Patterns de cache + +**Bénéfices** : +- Onboarding facilité +- Cohérence des implémentations futures + +--- + +## 4. Priorisation des actions + +### Priorité 1 (Critique - Impact élevé) +1. **Service d'initialisation IndexedDB unifié** (~200 lignes économisées) +2. **Helper de subscription avec relay rotation** (~300 lignes économisées) + +### Priorité 2 (Important - Impact moyen) +3. **Helper de groupement et cache d'événements** (~40 lignes économisées) +4. **Helper de cache d'événements** (réduction de code répétitif) +5. **Helper de queries unifié** (simplification des queries) + +### Priorité 3 (Amélioration - Impact faible mais bénéfique) +6. **Optimisation des imports dynamiques** +7. **Centralisation de la gestion d'erreurs** +8. **Documentation des patterns** + +--- + +## 5. Risques et précautions + +### Risques identifiés + +1. **Régression fonctionnelle** : Refactoring de code critique (IndexedDB, subscriptions) + - **Mitigation** : Tests unitaires avant refactoring, migration progressive + +2. **Performance** : Abstraction peut introduire overhead + - **Mitigation** : Benchmarks avant/après, optimisation si nécessaire + +3. **Compatibilité** : Changements d'API peuvent casser le code existant + - **Mitigation** : Déprecation progressive, migration guides + +### Précautions + +- Valider chaque refactoring avec des tests +- Maintenir la rétrocompatibilité quand possible +- Documenter les breaking changes +- Mesurer l'impact sur les performances + +--- + +## 6. Métriques de succès + +### Réduction de code +- **Objectif** : Réduction de ~600 lignes de code dupliqué +- **Mesure** : Comparaison avant/après avec `cloc` ou similaire + +### Maintenabilité +- **Objectif** : Réduction du temps de modification de patterns communs +- **Mesure** : Temps moyen pour appliquer un changement (avant/après) + +### Qualité +- **Objectif** : Réduction des bugs liés à l'incohérence +- **Mesure** : Nombre de bugs liés à la duplication (avant/après) + +--- + +## Conclusion + +Cette analyse identifie des opportunités significatives de réduction de duplication et d'amélioration de l'organisation du code. Les priorités 1 et 2 devraient être traitées en premier pour maximiser l'impact sur la maintenabilité et réduire les risques d'incohérences. + +Les refactorings proposés respectent l'architecture existante et les principes de séparation des responsabilités. Ils doivent être réalisés progressivement avec validation à chaque étape. diff --git a/lib/authorQueries.ts b/lib/authorQueries.ts index a4e09f3..a1a1bac 100644 --- a/lib/authorQueries.ts +++ b/lib/authorQueries.ts @@ -33,20 +33,10 @@ export async function fetchAuthorByHashId( // Otherwise, treat as hash ID const hashId = hashIdOrPubkey - // Read only from IndexedDB cache - const cached = await objectCache.get('author', hashId) + const { getCachedObjectById } = await import('./helpers/queryHelpers') + const cached = await getCachedObjectById('author', hashId) if (cached) { - 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 - } - - // Also try by ID if hash lookup failed - const cachedById = await objectCache.getById('author', hashId) - if (cachedById) { - const presentation = cachedById as import('@/types/nostr').AuthorPresentationArticle + const presentation = cached // Calculate totalSponsoring from cache const { getAuthorSponsoring } = await import('./sponsoring') presentation.totalSponsoring = await getAuthorSponsoring(presentation.pubkey) diff --git a/lib/helpers/eventCacheHelper.ts b/lib/helpers/eventCacheHelper.ts new file mode 100644 index 0000000..b794be3 --- /dev/null +++ b/lib/helpers/eventCacheHelper.ts @@ -0,0 +1,111 @@ +/** + * Helper for grouping and caching events by hash + * Centralizes the pattern of grouping events by hash ID and caching the latest version + */ + +import type { Event } from 'nostr-tools' +import { extractTagsFromEvent } from '../nostrTagSystem' +import { parseObjectId } from '../urlGenerator' +import { getLatestVersion } from '../versionManager' +import type { ObjectType } from '../objectCache' + +export interface EventCacheConfig { + objectType: ObjectType + extractor: (event: Event) => Promise + getHash?: (extracted: unknown) => string | null + getIndex?: (extracted: unknown) => number + getVersion?: (event: Event) => number + getHidden?: (event: Event) => boolean +} + + +interface ExtractedObjectWithId { + id?: string + index?: number +} + +/** + * Group events by hash ID and cache the latest version of each + */ +export async function groupAndCacheEventsByHash( + events: Event[], + config: EventCacheConfig +): Promise { + const { objectType, extractor, getHash, getIndex, getVersion, getHidden } = config + + // Group events by hash ID + const eventsByHashId = new Map() + for (const event of events) { + const tags = extractTagsFromEvent(event) + if (tags.id) { + // Extract hash from id (can be __ or just hash) + const parsed = parseObjectId(tags.id) + const hash = parsed.hash ?? tags.id + if (!eventsByHashId.has(hash)) { + eventsByHashId.set(hash, []) + } + eventsByHashId.get(hash)!.push(event) + } + } + + // Cache each object (latest version) + for (const [_hash, hashEvents] of eventsByHashId.entries()) { + const latestEvent = getLatestVersion(hashEvents) + if (!latestEvent) { + continue + } + + const extracted = await extractor(latestEvent) + if (!extracted) { + continue + } + + // Get hash, index, version, hidden + const extractedHash = getHash ? getHash(extracted) : null + const extractedObj = extracted as ExtractedObjectWithId + const extractedId = extractedHash ?? extractedObj.id + + if (!extractedId) { + continue + } + + const publicationParsed = parseObjectId(extractedId) + const hash = publicationParsed.hash ?? extractedId + const index = getIndex ? getIndex(extracted) : publicationParsed.index ?? extractedObj.index ?? 0 + const version = getVersion ? getVersion(latestEvent) : extractTagsFromEvent(latestEvent).version ?? 0 + const hidden = getHidden ? getHidden(latestEvent) : extractTagsFromEvent(latestEvent).hidden ?? false + + const { writeService } = await import('../writeService') + await writeService.writeObject(objectType, hash, latestEvent, extracted, version, hidden, index, false) + } +} + +/** + * Cache a single event as an object + * Simplified version for direct event caching + */ +export async function cacheEventAsObject( + event: Event, + objectType: ObjectType, + extractor: (event: Event) => Promise +): Promise { + const extracted = await extractor(event) + if (!extracted) { + return false + } + + const tags = extractTagsFromEvent(event) + const hash = (extracted as { hash?: string })?.hash ?? (extracted as { id?: string })?.id ?? '' + if (!hash) { + return false + } + + const index = (extracted as { index?: number })?.index ?? 0 + const version = tags.version ?? 0 + const hidden = tags.hidden ?? false + + const { writeService } = await import('../writeService') + await writeService.writeObject(objectType, hash, event, extracted, version, hidden, index, false) + + return true +} diff --git a/lib/helpers/indexedDBHelper.ts b/lib/helpers/indexedDBHelper.ts new file mode 100644 index 0000000..6612c5c --- /dev/null +++ b/lib/helpers/indexedDBHelper.ts @@ -0,0 +1,616 @@ +/** + * Centralized IndexedDB helper for initialization and transaction management + * Provides unified API for all IndexedDB operations across the application + */ + +export interface IndexedDBIndex { + name: string + keyPath: string | string[] + unique?: boolean +} + +export interface IndexedDBConfig { + dbName: string + version: number + storeName: string + keyPath: string + indexes?: IndexedDBIndex[] + onUpgrade?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void +} + +export class IndexedDBError extends Error { + public readonly operation: string + public readonly storeName: string | undefined + public override readonly cause: unknown | undefined + + public override readonly name = 'IndexedDBError' + + constructor(message: string, operation: string, storeName?: string, cause?: unknown) { + super(message) + this.operation = operation + this.storeName = storeName + this.cause = cause + console.error(`[IndexedDBError] ${operation}${storeName ? ` on ${storeName}` : ''}: ${message}`, cause) + } +} + +class IndexedDBHelper { + private db: IDBDatabase | null = null + private initPromise: Promise | null = null + private readonly config: IndexedDBConfig + + constructor(config: IndexedDBConfig) { + this.config = config + } + + /** + * Initialize the IndexedDB database + */ + async init(): Promise { + if (this.db) { + return this.db + } + + if (this.initPromise) { + await this.initPromise + if (this.db) { + return this.db + } + throw new IndexedDBError('Database initialization failed', 'init', this.config.storeName) + } + + this.initPromise = this.openDatabase() + + try { + await this.initPromise + if (!this.db) { + throw new IndexedDBError('Database not initialized after open', 'init', this.config.storeName) + } + return this.db + } catch (error) { + this.initPromise = null + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'init', + this.config.storeName, + error + ) + } + } + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + if (typeof window === 'undefined' || !window.indexedDB) { + reject(new IndexedDBError('IndexedDB is not available', 'openDatabase', this.config.storeName)) + return + } + + const request = window.indexedDB.open(this.config.dbName, this.config.version) + + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to open IndexedDB: ${request.error}`, + 'openDatabase', + this.config.storeName, + request.error + ) + ) + } + + request.onsuccess = (): void => { + this.db = request.result + resolve() + } + + request.onupgradeneeded = (event: IDBVersionChangeEvent): void => { + this.handleUpgrade(event) + } + }) + } + + private handleUpgrade(event: IDBVersionChangeEvent): void { + const db = (event.target as IDBOpenDBRequest).result + + // Create object store if it doesn't exist + if (!db.objectStoreNames.contains(this.config.storeName)) { + this.createObjectStore(db) + } else { + // Store exists, check for missing indexes + this.createMissingIndexes(db, event) + } + + // Call custom upgrade handler if provided + if (this.config.onUpgrade) { + this.config.onUpgrade(db, event) + } + } + + private createObjectStore(db: IDBDatabase): void { + const store = db.createObjectStore(this.config.storeName, { keyPath: this.config.keyPath }) + + // Create indexes + if (this.config.indexes) { + for (const index of this.config.indexes) { + if (!store.indexNames.contains(index.name)) { + store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false }) + } + } + } + } + + private createMissingIndexes(_db: IDBDatabase, event: IDBVersionChangeEvent): void { + const target = event.target as IDBOpenDBRequest + const { transaction } = target + if (!transaction) { + return + } + + const store = transaction.objectStore(this.config.storeName) + if (this.config.indexes) { + for (const index of this.config.indexes) { + if (!store.indexNames.contains(index.name)) { + store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false }) + } + } + } + } + + /** + * Get object store for read operations + */ + async getStore(mode: 'readonly'): Promise { + const db = await this.init() + const transaction = db.transaction([this.config.storeName], mode) + return transaction.objectStore(this.config.storeName) + } + + /** + * Get object store for write operations + */ + async getStoreWrite(mode: 'readwrite'): Promise { + const db = await this.init() + const transaction = db.transaction([this.config.storeName], mode) + return transaction.objectStore(this.config.storeName) + } + + /** + * Get a value from the store by key + */ + async get(key: string | number): Promise { + try { + const store = await this.getStore('readonly') + return new Promise((resolve, reject) => { + const request = store.get(key) + request.onsuccess = (): void => { + resolve((request.result as T) ?? null) + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to get value: ${request.error}`, + 'get', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'get', + this.config.storeName, + error + ) + } + } + + /** + * Get a value from an index + */ + async getByIndex(indexName: string, key: string | number): Promise { + try { + const store = await this.getStore('readonly') + const index = store.index(indexName) + return new Promise((resolve, reject) => { + const request = index.get(key) + request.onsuccess = (): void => { + resolve((request.result as T) ?? null) + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to get value by index: ${request.error}`, + 'getByIndex', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'getByIndex', + this.config.storeName, + error + ) + } + } + + /** + * Get all values from an index + */ + async getAllByIndex(indexName: string, key?: IDBValidKey | IDBKeyRange): Promise { + try { + const store = await this.getStore('readonly') + const index = store.index(indexName) + return new Promise((resolve, reject) => { + const request = key !== undefined ? index.getAll(key) : index.getAll() + request.onsuccess = (): void => { + resolve((request.result as T[]) ?? []) + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to get all values by index: ${request.error}`, + 'getAllByIndex', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'getAllByIndex', + this.config.storeName, + error + ) + } + } + + /** + * Put a value in the store + */ + async put(value: T): Promise { + try { + const store = await this.getStoreWrite('readwrite') + return new Promise((resolve, reject) => { + const request = store.put(value) + request.onsuccess = (): void => { + resolve() + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to put value: ${request.error}`, + 'put', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'put', + this.config.storeName, + error + ) + } + } + + /** + * Add a value to the store (fails if key exists) + */ + async add(value: T): Promise { + try { + const store = await this.getStoreWrite('readwrite') + return new Promise((resolve, reject) => { + const request = store.add(value) + request.onsuccess = (): void => { + resolve() + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to add value: ${request.error}`, + 'add', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'add', + this.config.storeName, + error + ) + } + } + + /** + * Delete a value from the store by key + */ + async delete(key: string | number): Promise { + try { + const store = await this.getStoreWrite('readwrite') + return new Promise((resolve, reject) => { + const request = store.delete(key) + request.onsuccess = (): void => { + resolve() + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to delete value: ${request.error}`, + 'delete', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'delete', + this.config.storeName, + error + ) + } + } + + /** + * Clear all values from the store + */ + async clear(): Promise { + try { + const store = await this.getStoreWrite('readwrite') + return new Promise((resolve, reject) => { + const request = store.clear() + request.onsuccess = (): void => { + resolve() + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to clear store: ${request.error}`, + 'clear', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'clear', + this.config.storeName, + error + ) + } + } + + /** + * Open a cursor on the store + */ + async openCursor( + direction?: IDBCursorDirection, + range?: IDBKeyRange + ): Promise { + try { + const store = await this.getStore('readonly') + return new Promise((resolve, reject) => { + const request = range ? store.openCursor(range, direction) : store.openCursor(direction) + request.onsuccess = (): void => { + resolve(request.result) + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to open cursor: ${request.error}`, + 'openCursor', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'openCursor', + this.config.storeName, + error + ) + } + } + + /** + * Open a cursor on an index + */ + async openCursorOnIndex( + indexName: string, + direction?: IDBCursorDirection, + range?: IDBKeyRange + ): Promise { + try { + const store = await this.getStore('readonly') + const index = store.index(indexName) + return new Promise((resolve, reject) => { + const request = range ? index.openCursor(range, direction) : index.openCursor(direction) + request.onsuccess = (): void => { + resolve(request.result) + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to open cursor on index: ${request.error}`, + 'openCursorOnIndex', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'openCursorOnIndex', + this.config.storeName, + error + ) + } + } + + /** + * Count records in the store + */ + async count(range?: IDBKeyRange): Promise { + try { + const store = await this.getStore('readonly') + return new Promise((resolve, reject) => { + const request = range ? store.count(range) : store.count() + request.onsuccess = (): void => { + resolve(request.result) + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to count records: ${request.error}`, + 'count', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'count', + this.config.storeName, + error + ) + } + } + + /** + * Count records in an index + */ + async countByIndex(indexName: string, range?: IDBKeyRange): Promise { + try { + const store = await this.getStore('readonly') + const index = store.index(indexName) + return new Promise((resolve, reject) => { + const request = range ? index.count(range) : index.count() + request.onsuccess = (): void => { + resolve(request.result) + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to count records by index: ${request.error}`, + 'countByIndex', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'countByIndex', + this.config.storeName, + error + ) + } + } + + /** + * Get all values from the store + */ + async getAll(range?: IDBKeyRange, count?: number): Promise { + try { + const store = await this.getStore('readonly') + return new Promise((resolve, reject) => { + const request = range ? store.getAll(range, count) : store.getAll(undefined, count) + request.onsuccess = (): void => { + resolve((request.result as T[]) ?? []) + } + request.onerror = (): void => { + reject( + new IndexedDBError( + `Failed to get all values: ${request.error}`, + 'getAll', + this.config.storeName, + request.error + ) + ) + } + }) + } catch (error) { + if (error instanceof IndexedDBError) { + throw error + } + throw new IndexedDBError( + error instanceof Error ? error.message : 'Unknown error', + 'getAll', + this.config.storeName, + error + ) + } + } +} + +/** + * Create a new IndexedDB helper instance + */ +export function createIndexedDBHelper(config: IndexedDBConfig): IndexedDBHelper { + return new IndexedDBHelper(config) +} + +export { IndexedDBHelper } diff --git a/lib/helpers/paymentNoteSyncHelper.ts b/lib/helpers/paymentNoteSyncHelper.ts new file mode 100644 index 0000000..e3b082b --- /dev/null +++ b/lib/helpers/paymentNoteSyncHelper.ts @@ -0,0 +1,132 @@ +/** + * Helper for syncing payment notes + * Handles the special case of payment notes which require multiple subscriptions + */ + +import type { Event, Filter } from 'nostr-tools' +import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' +import { PLATFORM_SERVICE } from '../platformConfig' +import { getPrimaryRelaySync } from '../config' +import { createSubscription } from '@/types/nostr-tools-extended' +import { tryWithRelayRotation } from '../relayRotation' +import { extractTagsFromEvent } from '../nostrTagSystem' +import { cachePaymentNotes } from './syncCacheHelpers' + +export function buildPaymentNoteFilters(userPubkey: string, lastSyncDate: number): Filter[] { + return [ + { + kinds: [1], + authors: [userPubkey], + '#payment': [''], + '#service': [PLATFORM_SERVICE], + since: lastSyncDate, + limit: 1000, + }, + { + kinds: [1], + '#recipient': [userPubkey], + '#payment': [''], + '#service': [PLATFORM_SERVICE], + since: lastSyncDate, + limit: 1000, + }, + ] +} + +export async function createPaymentNoteSubscriptions( + pool: SimplePoolWithSub, + filters: Filter[] +): Promise>> { + let subscriptions: Array> = [] + + try { + const result = await tryWithRelayRotation( + pool as unknown as import('nostr-tools').SimplePool, + async (relayUrl, poolWithSub) => { + await updateProgressForRelay(relayUrl) + return filters.map((filter) => createSubscription(poolWithSub, [relayUrl], [filter])) + }, + 5000 + ) + subscriptions = result.flat() + } catch { + const usedRelayUrl = getPrimaryRelaySync() + subscriptions = filters.map((filter) => createSubscription(pool, [usedRelayUrl], [filter])) + } + + if (subscriptions.length === 0) { + throw new Error('Failed to create subscriptions') + } + + return subscriptions +} + +async function updateProgressForRelay(relayUrl: string): Promise { + const { syncProgressManager } = await import('../syncProgressManager') + const currentProgress = syncProgressManager.getProgress() + if (currentProgress) { + syncProgressManager.setProgress({ + ...currentProgress, + currentStep: 0, + currentRelay: relayUrl, + }) + } +} + +export async function processPaymentNoteEvents( + subscriptions: Array> +): Promise { + const events: Event[] = [] + + return new Promise((resolve) => { + let finished = false + let eoseCount = 0 + + const done = async (): Promise => { + if (finished) { + return + } + finished = true + subscriptions.forEach((sub) => sub.unsub()) + await cachePaymentNotes(events) + resolve() + } + + const handleEose = (): void => { + eoseCount++ + if (eoseCount >= subscriptions.length) { + console.warn(`[Sync] EOSE for payment notes, received ${events.length} events`) + void done() + } + } + + setupPaymentNoteSubscriptions(subscriptions, events, handleEose) + + setTimeout((): void => { + if (!finished) { + console.warn(`[Sync] Timeout for payment notes, received ${events.length} events`) + } + void done() + }, 10000).unref?.() + }) +} + +function setupPaymentNoteSubscriptions( + subscriptions: Array>, + events: Event[], + onEose: () => void +): void { + subscriptions.forEach((sub) => { + sub.on('event', (event: Event): void => { + const tags = extractTagsFromEvent(event) + if (tags.type === 'payment' && tags.payment) { + console.warn('[Sync] Received payment note event:', event.id) + if (!events.some((e) => e.id === event.id)) { + events.push(event) + } + } + }) + + sub.on('eose', onEose) + }) +} diff --git a/lib/helpers/queryHelpers.ts b/lib/helpers/queryHelpers.ts new file mode 100644 index 0000000..543a546 --- /dev/null +++ b/lib/helpers/queryHelpers.ts @@ -0,0 +1,36 @@ +/** + * Helper for querying cached objects + * Centralizes the pattern of querying with fallback hash/ID lookup + */ + +import { objectCache } from '../objectCache' +import { parseObjectId } from '../urlGenerator' +import type { ObjectType } from '../objectCache' + +/** + * Get a cached object by ID with fallback to hash lookup + * Tries by hash first, then by full ID + */ +export async function getCachedObjectById( + objectType: ObjectType, + id: string +): Promise { + // Try to parse id as id format (__) or use it as hash + const parsed = parseObjectId(id) + const hash = parsed.hash ?? id + + // Read only from IndexedDB cache + const cached = await objectCache.get(objectType, hash) + if (cached) { + return cached as T + } + + // Also try by ID if hash lookup failed + const cachedById = await objectCache.getById(objectType, id) + if (cachedById) { + return cachedById as T + } + + // Not found in cache - return null (no network request) + return null +} diff --git a/lib/helpers/syncCacheHelpers.ts b/lib/helpers/syncCacheHelpers.ts new file mode 100644 index 0000000..f4d1042 --- /dev/null +++ b/lib/helpers/syncCacheHelpers.ts @@ -0,0 +1,73 @@ +/** + * Helper functions for caching synced events + * Centralizes the logic for caching purchases, sponsoring, review tips, and payment notes + */ + +import type { Event } from 'nostr-tools' +import { extractTagsFromEvent } from '../nostrTagSystem' +import { extractPurchaseFromEvent, extractSponsoringFromEvent, extractReviewTipFromEvent } from '../metadataExtractor' + +export async function cachePurchases(events: Event[]): Promise { + const { writeService } = await import('../writeService') + const { parsePurchaseFromEvent } = await import('../nostrEventParsing') + for (const event of events) { + const extracted = await extractPurchaseFromEvent(event) + if (extracted) { + const purchase = await parsePurchaseFromEvent(event) + if (purchase) { + const purchaseTyped = purchase + if (purchaseTyped.hash) { + await writeService.writeObject('purchase', purchaseTyped.hash, event, purchaseTyped, 0, false, purchaseTyped.index ?? 0, false) + } + } + } + } +} + +export async function cacheSponsoring(events: Event[]): Promise { + const { writeService } = await import('../writeService') + const { parseSponsoringFromEvent } = await import('../nostrEventParsing') + for (const event of events) { + const extracted = await extractSponsoringFromEvent(event) + if (extracted) { + const sponsoring = await parseSponsoringFromEvent(event) + if (sponsoring) { + const sponsoringTyped = sponsoring + if (sponsoringTyped.hash) { + await writeService.writeObject('sponsoring', sponsoringTyped.hash, event, sponsoringTyped, 0, false, sponsoringTyped.index ?? 0, false) + } + } + } + } +} + +export async function cacheReviewTips(events: Event[]): Promise { + const { writeService } = await import('../writeService') + const { parseReviewTipFromEvent } = await import('../nostrEventParsing') + for (const event of events) { + const extracted = await extractReviewTipFromEvent(event) + if (extracted) { + const reviewTip = await parseReviewTipFromEvent(event) + if (reviewTip) { + const reviewTipTyped = reviewTip + if (reviewTipTyped.hash) { + await writeService.writeObject('review_tip', reviewTipTyped.hash, event, reviewTipTyped, 0, false, reviewTipTyped.index ?? 0, false) + } + } + } + } +} + +export async function cachePaymentNotes(events: Event[]): Promise { + const { writeService } = await import('../writeService') + for (const event of events) { + const tags = extractTagsFromEvent(event) + if (tags.type === 'payment' && tags.payment) { + await writeService.writeObject('payment_note', event.id, event, { + id: event.id, + type: 'payment_note', + eventId: event.id, + }, 0, false, 0, false) + } + } +} diff --git a/lib/helpers/syncContentCacheHelpers.ts b/lib/helpers/syncContentCacheHelpers.ts new file mode 100644 index 0000000..8037d2e --- /dev/null +++ b/lib/helpers/syncContentCacheHelpers.ts @@ -0,0 +1,57 @@ +/** + * Helper functions for caching publications and series by hash + */ + +import type { Event } from 'nostr-tools' +import { parseObjectId } from '../urlGenerator' +import { extractTagsFromEvent } from '../nostrTagSystem' +import { extractPublicationFromEvent, extractSeriesFromEvent } from '../metadataExtractor' +import { groupAndCacheEventsByHash } from './eventCacheHelper' + +export async function cachePublicationsByHash(events: Event[]): Promise { + await groupAndCacheEventsByHash(events, { + objectType: 'publication', + extractor: extractPublicationFromEvent, + getHash: (extracted: unknown): string | null => { + const id = (extracted as { id?: string })?.id + if (!id) { + return null + } + const parsed = parseObjectId(id) + return parsed.hash ?? id + }, + getIndex: (extracted: unknown): number => { + return (extracted as { index?: number })?.index ?? 0 + }, + getVersion: (event: Event): number => { + return extractTagsFromEvent(event).version ?? 0 + }, + getHidden: (event: Event): boolean => { + return extractTagsFromEvent(event).hidden ?? false + }, + }) +} + +export async function cacheSeriesByHash(events: Event[]): Promise { + await groupAndCacheEventsByHash(events, { + objectType: 'series', + extractor: extractSeriesFromEvent, + getHash: (extracted: unknown): string | null => { + const id = (extracted as { id?: string })?.id + if (!id) { + return null + } + const parsed = parseObjectId(id) + return parsed.hash ?? id + }, + getIndex: (extracted: unknown): number => { + return (extracted as { index?: number })?.index ?? 0 + }, + getVersion: (event: Event): number => { + return extractTagsFromEvent(event).version ?? 0 + }, + getHidden: (event: Event): boolean => { + return extractTagsFromEvent(event).hidden ?? false + }, + }) +} diff --git a/lib/helpers/syncProgressHelper.ts b/lib/helpers/syncProgressHelper.ts new file mode 100644 index 0000000..e8d7039 --- /dev/null +++ b/lib/helpers/syncProgressHelper.ts @@ -0,0 +1,54 @@ +/** + * Helper for managing sync progress + */ + +export interface SyncProgress { + currentStep: number + totalSteps: number + completed: boolean + currentRelay?: string // URL of the relay currently being used +} + +export async function initializeSyncProgress( + onProgress?: (progress: SyncProgress) => void +): Promise<{ updateProgress: (step: number, completed?: boolean) => void }> { + const TOTAL_STEPS = 7 + const { relaySessionManager } = await import('../relaySessionManager') + const { syncProgressManager } = await import('../syncProgressManager') + const activeRelays = await relaySessionManager.getActiveRelays() + const initialRelay = activeRelays[0] ?? 'Connecting...' + + if (onProgress) { + onProgress({ currentStep: 0, totalSteps: TOTAL_STEPS, completed: false, currentRelay: initialRelay }) + } + syncProgressManager.setProgress({ currentStep: 0, totalSteps: TOTAL_STEPS, completed: false, currentRelay: initialRelay }) + + const updateProgress = (step: number, completed: boolean = false): void => { + const currentRelay = syncProgressManager.getProgress()?.currentRelay ?? initialRelay + const progressUpdate = { currentStep: step, totalSteps: TOTAL_STEPS, completed, currentRelay } + if (onProgress) { + onProgress(progressUpdate) + } + syncProgressManager.setProgress(progressUpdate) + } + + return { updateProgress } +} + +export async function finalizeSync(currentTimestamp: number): Promise { + const { setLastSyncDate } = await import('../syncStorage') + await setLastSyncDate(currentTimestamp) + + const { syncProgressManager } = await import('../syncProgressManager') + const { configStorage } = await import('../configStorage') + const config = await configStorage.getConfig() + const currentRelay = syncProgressManager.getProgress()?.currentRelay + if (currentRelay) { + const relayConfig = config.relays.find((r) => r.url === currentRelay) + if (relayConfig) { + await configStorage.updateRelay(relayConfig.id, { lastSyncDate: Date.now() }) + } + } + + console.warn('[Sync] Synchronization completed successfully') +} diff --git a/lib/helpers/syncSubscriptionHelper.ts b/lib/helpers/syncSubscriptionHelper.ts new file mode 100644 index 0000000..74ab4d1 --- /dev/null +++ b/lib/helpers/syncSubscriptionHelper.ts @@ -0,0 +1,127 @@ +/** + * Helper for creating sync subscriptions with relay rotation + * Centralizes the pattern of subscription creation, event handling, and timeout management + */ + +import type { Event, Filter } from 'nostr-tools' +import type { SimplePool } from 'nostr-tools' +import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' +import { tryWithRelayRotation } from '../relayRotation' +import { getPrimaryRelaySync } from '../config' +import { createSubscription } from '@/types/nostr-tools-extended' + +export interface SyncSubscriptionConfig { + pool: SimplePoolWithSub + filters: Filter[] + onEvent?: (event: Event) => void | Promise + onComplete?: (events: Event[]) => void | Promise + timeout?: number + updateProgress?: (relayUrl: string) => void + eventFilter?: (event: Event) => boolean +} + +export interface SyncSubscriptionResult { + subscription: ReturnType + relayUrl: string + events: Event[] +} + +/** + * Create a sync subscription with relay rotation + * Handles relay rotation, progress updates, event collection, and timeout + */ +export async function createSyncSubscription( + config: SyncSubscriptionConfig +): Promise { + const { pool, filters, onEvent, onComplete, timeout = 10000, updateProgress, eventFilter } = config + + const events: Event[] = [] + let sub: ReturnType | null = null + let usedRelayUrl = '' + + // Try relays with rotation + try { + const result = await tryWithRelayRotation( + pool as unknown as SimplePool, + async (relayUrl, poolWithSub) => { + usedRelayUrl = relayUrl + + // Update progress if callback provided + if (updateProgress) { + updateProgress(relayUrl) + } else { + // Default: notify progress manager + const { syncProgressManager } = await import('../syncProgressManager') + const currentProgress = syncProgressManager.getProgress() + if (currentProgress) { + syncProgressManager.setProgress({ + ...currentProgress, + currentStep: 0, + currentRelay: relayUrl, + }) + } + } + + return createSubscription(poolWithSub, [relayUrl], filters) + }, + 5000 // 5 second timeout per relay + ) + sub = result + } catch { + // Fallback to primary relay if rotation fails + usedRelayUrl = getPrimaryRelaySync() + sub = createSubscription(pool, [usedRelayUrl], filters) + } + + if (!sub) { + throw new Error('Failed to create subscription') + } + + return new Promise((resolve) => { + let finished = false + + const done = async (): Promise => { + if (finished) { + return + } + finished = true + sub?.unsub() + + // Call onComplete callback if provided + if (onComplete) { + await onComplete(events) + } + + resolve({ + subscription: sub, + relayUrl: usedRelayUrl, + events, + }) + } + + // Handle events + sub.on('event', (event: Event): void => { + // Apply event filter if provided + if (eventFilter && !eventFilter(event)) { + return + } + + events.push(event) + + // Call onEvent callback if provided + if (onEvent) { + void onEvent(event) + } + }) + + // Handle end of stream + sub.on('eose', (): void => { + void done() + }) + + // Timeout fallback + setTimeout((): void => { + void done() + }, timeout).unref?.() + }) +} diff --git a/lib/localeStorage.ts b/lib/localeStorage.ts index 306468d..f2080a2 100644 --- a/lib/localeStorage.ts +++ b/lib/localeStorage.ts @@ -2,6 +2,8 @@ * IndexedDB storage for locale preference */ +import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper' + const DB_NAME = 'nostr_paywall_settings' const DB_VERSION = 2 // Incremented to add locale store const STORE_NAME = 'locale' @@ -9,36 +11,14 @@ const STORE_NAME = 'locale' export type Locale = 'fr' | 'en' class LocaleStorageService { - private db: IDBDatabase | null = null + private readonly dbHelper: IndexedDBHelper - private async initDB(): Promise { - if (this.db) { - return this.db - } - - return new Promise((resolve, reject) => { - if (typeof window === 'undefined' || !window.indexedDB) { - reject(new Error('IndexedDB is not available')) - return - } - - const request = indexedDB.open(DB_NAME, DB_VERSION) - - request.onerror = () => { - reject(new Error(`Failed to open IndexedDB: ${request.error}`)) - } - - request.onsuccess = () => { - this.db = request.result - resolve(this.db) - } - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME, { keyPath: 'key' }) - } - } + constructor() { + this.dbHelper = createIndexedDBHelper({ + dbName: DB_NAME, + version: DB_VERSION, + storeName: STORE_NAME, + keyPath: 'key', }) } @@ -47,25 +27,12 @@ class LocaleStorageService { */ async getLocale(): Promise { try { - const db = await this.initDB() - const transaction = db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) - - return new Promise((resolve, reject) => { - const request = store.get('locale') - - request.onsuccess = () => { - const result = request.result as { key: string; value: Locale } | undefined - const locale = result?.value - if (locale === 'fr' || locale === 'en') { - resolve(locale) - } else { - resolve(null) - } - } - - request.onerror = () => reject(request.error) - }) + const result = await this.dbHelper.get<{ key: string; value: Locale }>('locale') + const locale = result?.value + if (locale === 'fr' || locale === 'en') { + return locale + } + return null } catch (error) { console.error('Error retrieving locale from IndexedDB:', error) return null @@ -77,19 +44,7 @@ class LocaleStorageService { */ async saveLocale(locale: Locale): Promise { try { - const db = await this.initDB() - const transaction = db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - - return new Promise((resolve, reject) => { - const request = store.put({ key: 'locale', value: locale }) - - request.onsuccess = () => { - resolve() - } - - request.onerror = () => reject(request.error) - }) + await this.dbHelper.put({ key: 'locale', value: locale }) } catch (error) { console.error('Error saving locale to IndexedDB:', error) throw error diff --git a/lib/nostr.ts b/lib/nostr.ts index 1d3a389..e5b7500 100644 --- a/lib/nostr.ts +++ b/lib/nostr.ts @@ -14,7 +14,7 @@ import { getPrimaryRelay, getPrimaryRelaySync } from './config' import { buildTagFilter } from './nostrTagSystem' import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' import type { PublishResult, RelayPublishStatus } from './publishResult' -import { objectCache } from './objectCache' +import { objectCache, type ObjectType } from './objectCache' class NostrService { private pool: SimplePool | null = null @@ -484,7 +484,8 @@ class NostrService { // If not found in unpublished, search all objects for (const objectType of objectTypes) { try { - const db = await objectCache['initDB'](objectType) + // Use private method via type assertion for direct database access + const db = await (objectCache as unknown as { initDB: (objectType: ObjectType) => Promise }).initDB(objectType) const transaction = db.transaction(['objects'], 'readonly') const store = transaction.objectStore('objects') const request = store.openCursor() diff --git a/lib/notificationDetector.ts b/lib/notificationDetector.ts index 3a8c73e..a30d033 100644 --- a/lib/notificationDetector.ts +++ b/lib/notificationDetector.ts @@ -238,51 +238,6 @@ class NotificationDetector { } } - /** - * Get notification title based on type - */ - private _getNotificationTitle(type: NotificationType, _obj: CachedObject): string { - switch (type) { - case 'purchase': - return 'Nouvel achat' - case 'review': - return 'Nouvel avis' - case 'sponsoring': - return 'Nouveau sponsoring' - case 'review_tip': - return 'Nouveau remerciement' - case 'payment_note': - return 'Nouvelle note de paiement' - case 'published': - return 'Publication réussie' - default: - return 'Nouvelle notification' - } - } - - /** - * Get notification message based on type - */ - private _getNotificationMessage(type: NotificationType, _obj: CachedObject): string { - switch (type) { - case 'purchase': - return `Vous avez acheté un article` - case 'review': - return `Un nouvel avis a été publié` - case 'sponsoring': - return `Vous avez reçu un sponsoring` - case 'review_tip': - return `Vous avez reçu un remerciement` - case 'payment_note': - return `Une note de paiement a été ajoutée` - case 'published': - const cachedObj = _obj - const relays = Array.isArray(cachedObj.published) ? cachedObj.published : [] - return `Votre contenu a été publié sur ${relays.length} relais` - default: - return 'Nouvelle notification' - } - } } export const notificationDetector = new NotificationDetector() diff --git a/lib/notificationService.ts b/lib/notificationService.ts index 2349518..c17a829 100644 --- a/lib/notificationService.ts +++ b/lib/notificationService.ts @@ -2,6 +2,8 @@ * Notification service - stores and manages notifications in IndexedDB */ +import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper' + const DB_NAME = 'nostr_notifications' const DB_VERSION = 1 const STORE_NAME = 'notifications' @@ -33,58 +35,22 @@ export interface Notification { } class NotificationService { - private db: IDBDatabase | null = null - private initPromise: Promise | null = null + private readonly dbHelper: IndexedDBHelper - private async init(): Promise { - if (this.db) { - return - } - - if (this.initPromise) { - return this.initPromise - } - - this.initPromise = this.openDatabase() - - try { - await this.initPromise - } catch (error) { - this.initPromise = null - throw error - } - } - - private openDatabase(): Promise { - return new Promise((resolve, reject) => { - if (typeof window === 'undefined' || !window.indexedDB) { - reject(new Error('IndexedDB is not available')) - return - } - - const request = window.indexedDB.open(DB_NAME, DB_VERSION) - - request.onerror = (): void => { - reject(new Error(`Failed to open IndexedDB: ${request.error}`)) - } - - request.onsuccess = (): void => { - this.db = request.result - resolve() - } - - request.onupgradeneeded = (event: IDBVersionChangeEvent): void => { - const db = (event.target as IDBOpenDBRequest).result - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }) - store.createIndex('type', 'type', { unique: false }) - store.createIndex('objectId', 'objectId', { unique: false }) - store.createIndex('eventId', 'eventId', { unique: false }) - store.createIndex('timestamp', 'timestamp', { unique: false }) - store.createIndex('read', 'read', { unique: false }) - store.createIndex('objectType', 'objectType', { unique: false }) - } - } + constructor() { + this.dbHelper = createIndexedDBHelper({ + dbName: DB_NAME, + version: DB_VERSION, + storeName: STORE_NAME, + keyPath: 'id', + indexes: [ + { name: 'type', keyPath: 'type', unique: false }, + { name: 'objectId', keyPath: 'objectId', unique: false }, + { name: 'eventId', keyPath: 'eventId', unique: false }, + { name: 'timestamp', keyPath: 'timestamp', unique: false }, + { name: 'read', keyPath: 'read', unique: false }, + { name: 'objectType', keyPath: 'objectType', unique: false }, + ], }) } @@ -120,25 +86,7 @@ class NotificationService { */ async getNotificationByEventId(eventId: string): Promise { try { - await this.init() - - if (!this.db) { - return null - } - - const transaction = this.db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) - const index = store.index('eventId') - - return new Promise((resolve, reject) => { - const request = index.get(eventId) - request.onsuccess = (): void => { - resolve((request.result as Notification) ?? null) - } - request.onerror = (): void => { - reject(request.error) - } - }) + return await this.dbHelper.getByIndex('eventId', eventId) } catch (error) { console.error('[NotificationService] Error getting notification by event ID:', error) return null @@ -150,19 +98,12 @@ class NotificationService { */ async getAllNotifications(limit: number = 100): Promise { try { - await this.init() - - if (!this.db) { - return [] - } - - const transaction = this.db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) + const notifications: Notification[] = [] + const store = await this.dbHelper.getStore('readonly') const index = store.index('timestamp') - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const request = index.openCursor(null, 'prev') // Descending order (newest first) - const notifications: Notification[] = [] request.onsuccess = (event: globalThis.Event): void => { const cursor = (event.target as IDBRequest).result @@ -177,8 +118,13 @@ class NotificationService { resolve(notifications) } } + request.onerror = (): void => { - reject(request.error) + if (request.error) { + reject(request.error) + } else { + reject(new Error('Unknown error opening cursor')) + } } }) } catch (error) { @@ -192,33 +138,7 @@ class NotificationService { */ async getUnreadCount(): Promise { try { - await this.init() - - if (!this.db) { - return 0 - } - - const transaction = this.db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) - const index = store.index('read') - - return new Promise((resolve, reject) => { - const request = index.openCursor(IDBKeyRange.only(false)) - let count = 0 - - request.onsuccess = (event: globalThis.Event): void => { - const cursor = (event.target as IDBRequest).result - if (cursor) { - count++ - cursor.continue() - } else { - resolve(count) - } - } - request.onerror = (): void => { - reject(request.error) - } - }) + return await this.dbHelper.countByIndex('read', IDBKeyRange.only(false)) } catch (error) { console.error('[NotificationService] Error getting unread count:', error) return 0 @@ -230,41 +150,17 @@ class NotificationService { */ async markAsRead(notificationId: string): Promise { try { - await this.init() - - if (!this.db) { - throw new Error('Database not initialized') + const notification = await this.dbHelper.get(notificationId) + if (!notification) { + throw new Error('Notification not found') } - const transaction = this.db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - const request = store.get(notificationId) + const updatedNotification: Notification = { + ...notification, + read: true, + } - await new Promise((resolve, reject) => { - request.onsuccess = (): void => { - const notification = request.result as Notification | undefined - if (!notification) { - reject(new Error('Notification not found')) - return - } - - const updatedNotification: Notification = { - ...notification, - read: true, - } - - const updateRequest = store.put(updatedNotification) - updateRequest.onsuccess = (): void => { - resolve() - } - updateRequest.onerror = (): void => { - reject(new Error(`Failed to update notification: ${updateRequest.error}`)) - } - } - request.onerror = (): void => { - reject(request.error) - } - }) + await this.dbHelper.put(updatedNotification) } catch (error) { console.error('[NotificationService] Error marking notification as read:', error) throw error @@ -276,35 +172,17 @@ class NotificationService { */ async markAllAsRead(): Promise { try { - await this.init() - - if (!this.db) { - throw new Error('Database not initialized') - } - const notifications = await this.getAllNotifications(10000) - const transaction = this.db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) + const unreadNotifications = notifications.filter((n) => !n.read) await Promise.all( - notifications - .filter((n) => !n.read) - .map( - (notification) => - new Promise((resolve, reject) => { - const updatedNotification: Notification = { - ...notification, - read: true, - } - const request = store.put(updatedNotification) - request.onsuccess = (): void => { - resolve() - } - request.onerror = (): void => { - reject(request.error) - } - }) - ) + unreadNotifications.map((notification) => { + const updatedNotification: Notification = { + ...notification, + read: true, + } + return this.dbHelper.put(updatedNotification) + }) ) } catch (error) { console.error('[NotificationService] Error marking all notifications as read:', error) @@ -317,24 +195,7 @@ class NotificationService { */ async deleteNotification(notificationId: string): Promise { try { - await this.init() - - if (!this.db) { - throw new Error('Database not initialized') - } - - const transaction = this.db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - - await new Promise((resolve, reject) => { - const request = store.delete(notificationId) - request.onsuccess = (): void => { - resolve() - } - request.onerror = (): void => { - reject(new Error(`Failed to delete notification: ${request.error}`)) - } - }) + await this.dbHelper.delete(notificationId) } catch (error) { console.error('[NotificationService] Error deleting notification:', error) throw error diff --git a/lib/objectCache.ts b/lib/objectCache.ts index cd8c3ea..c04d1a7 100644 --- a/lib/objectCache.ts +++ b/lib/objectCache.ts @@ -7,6 +7,7 @@ import type { Event as NostrEvent } from 'nostr-tools' import type { AuthorPresentationArticle } from '@/types/nostr' import { buildObjectId } from './urlGenerator' +import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper' export type ObjectType = 'author' | 'series' | 'publication' | 'review' | 'purchase' | 'sponsoring' | 'review_tip' | 'payment_note' @@ -26,50 +27,33 @@ export interface CachedObject { const DB_PREFIX = 'nostr_objects_' const DB_VERSION = 3 // Incremented to add published field +const STORE_NAME = 'objects' class ObjectCacheService { - private dbs: Map = new Map() + private readonly dbHelpers: Map = new Map() - private async initDB(objectType: ObjectType): Promise { - if (this.dbs.has(objectType)) { - return this.dbs.get(objectType)! - } - - return new Promise((resolve, reject) => { - if (typeof window === 'undefined' || !window.indexedDB) { - reject(new Error('IndexedDB is not available')) - return - } - - const dbName = `${DB_PREFIX}${objectType}` - const request = window.indexedDB.open(dbName, DB_VERSION) - - request.onerror = (): void => { - reject(new Error(`Failed to open IndexedDB: ${request.error}`)) - } - - request.onsuccess = (): void => { - const db = request.result - this.dbs.set(objectType, db) - resolve(db) - } - - request.onupgradeneeded = (event: IDBVersionChangeEvent): void => { - const db = (event.target as IDBOpenDBRequest).result - if (!db.objectStoreNames.contains('objects')) { - const store = db.createObjectStore('objects', { keyPath: 'id' }) - store.createIndex('hash', 'hash', { unique: false }) - store.createIndex('hashId', 'hashId', { unique: false }) // Legacy index - store.createIndex('version', 'version', { unique: false }) - store.createIndex('index', 'index', { unique: false }) - store.createIndex('hidden', 'hidden', { unique: false }) - store.createIndex('cachedAt', 'cachedAt', { unique: false }) - store.createIndex('published', 'published', { unique: false }) - } else { + private getDBHelper(objectType: ObjectType): IndexedDBHelper { + if (!this.dbHelpers.has(objectType)) { + const helper = createIndexedDBHelper({ + dbName: `${DB_PREFIX}${objectType}`, + version: DB_VERSION, + storeName: STORE_NAME, + keyPath: 'id', + indexes: [ + { name: 'hash', keyPath: 'hash', unique: false }, + { name: 'hashId', keyPath: 'hashId', unique: false }, // Legacy index + { name: 'version', keyPath: 'version', unique: false }, + { name: 'index', keyPath: 'index', unique: false }, + { name: 'hidden', keyPath: 'hidden', unique: false }, + { name: 'cachedAt', keyPath: 'cachedAt', unique: false }, + { name: 'published', keyPath: 'published', unique: false }, + ], + onUpgrade: (_db: IDBDatabase, event: IDBVersionChangeEvent): void => { // Migration: add new indexes if they don't exist - const {transaction} = (event.target as IDBOpenDBRequest) + const target = event.target as IDBOpenDBRequest + const { transaction } = target if (transaction) { - const store = transaction.objectStore('objects') + const store = transaction.objectStore(STORE_NAME) if (!store.indexNames.contains('hash')) { store.createIndex('hash', 'hash', { unique: false }) } @@ -80,9 +64,20 @@ class ObjectCacheService { store.createIndex('published', 'published', { unique: false }) } } - } - } - }) + }, + }) + this.dbHelpers.set(objectType, helper) + } + return this.dbHelpers.get(objectType)! + } + + /** + * Initialize database and return IDBDatabase instance + * Used for direct database access when needed + */ + private async initDB(objectType: ObjectType): Promise { + const helper = this.getDBHelper(objectType) + return await helper.init() } /** @@ -90,20 +85,8 @@ class ObjectCacheService { */ private async countObjectsWithHash(objectType: ObjectType, hash: string): Promise { try { - const db = await this.initDB(objectType) - const transaction = db.transaction(['objects'], 'readonly') - const store = transaction.objectStore('objects') - const index = store.index('hash') - - return new Promise((resolve, reject) => { - const request = index.count(IDBKeyRange.only(hash)) - request.onsuccess = (): void => { - resolve(request.result) - } - request.onerror = (): void => { - reject(request.error) - } - }) + const helper = this.getDBHelper(objectType) + return await helper.countByIndex('hash', IDBKeyRange.only(hash)) } catch (countError) { console.error(`Error counting objects with hash ${hash}:`, countError) return 0 @@ -126,7 +109,7 @@ class ObjectCacheService { published: false | string[] = false ): Promise { try { - const db = await this.initDB(objectType) + const helper = this.getDBHelper(objectType) // If index is not provided, calculate it by counting objects with the same hash let finalIndex = index @@ -137,19 +120,8 @@ class ObjectCacheService { const id = buildObjectId(hash, finalIndex, version) - const transaction = db.transaction(['objects'], 'readwrite') - const store = transaction.objectStore('objects') - // Check if object already exists to preserve published status if updating - const existing = await new Promise((resolve, reject) => { - const getRequest = store.get(id) - getRequest.onsuccess = (): void => { - resolve((getRequest.result as CachedObject) ?? null) - } - getRequest.onerror = (): void => { - reject(getRequest.error) - } - }).catch(() => null) + const existing = await helper.get(id).catch(() => null) // If updating and published is not provided, preserve existing published status const finalPublished = existing && published === false ? existing.published : published @@ -168,15 +140,7 @@ class ObjectCacheService { published: finalPublished, } - await new Promise((resolve, reject) => { - const request = store.put(cached) - request.onsuccess = (): void => { - resolve() - } - request.onerror = (): void => { - reject(request.error) - } - }) + await helper.put(cached) } catch (cacheError) { console.error(`Error caching ${objectType} object:`, cacheError) } @@ -191,19 +155,8 @@ class ObjectCacheService { published: false | string[] ): Promise { try { - const db = await this.initDB(objectType) - const transaction = db.transaction(['objects'], 'readwrite') - const store = transaction.objectStore('objects') - - const existing = await new Promise((resolve, reject) => { - const request = store.get(id) - request.onsuccess = (): void => { - resolve((request.result as CachedObject) ?? null) - } - request.onerror = (): void => { - reject(request.error) - } - }) + const helper = this.getDBHelper(objectType) + const existing = await helper.get(id) if (!existing) { console.warn(`Object ${id} not found in cache, cannot update published status`) @@ -216,15 +169,7 @@ class ObjectCacheService { published, } - await new Promise((resolve, reject) => { - const request = store.put(updated) - request.onsuccess = (): void => { - resolve() - } - request.onerror = (): void => { - reject(request.error) - } - }) + await helper.put(updated) // Notify about published status change (false -> array of relays) if (oldPublished === false && Array.isArray(published) && published.length > 0) { @@ -329,24 +274,12 @@ class ObjectCacheService { */ async getById(objectType: ObjectType, id: string): Promise { try { - const db = await this.initDB(objectType) - const transaction = db.transaction(['objects'], 'readonly') - const store = transaction.objectStore('objects') - - return new Promise((resolve, reject) => { - const request = store.get(id) - request.onsuccess = (): void => { - const obj = request.result as CachedObject | undefined - if (obj && !obj.hidden) { - resolve(obj.parsed) - } else { - resolve(null) - } - } - request.onerror = (): void => { - reject(request.error) - } - }) + const helper = this.getDBHelper(objectType) + const obj = await helper.get(id) + if (obj && !obj.hidden) { + return obj.parsed + } + return null } catch (retrieveByIdError) { console.error(`Error retrieving ${objectType} object by ID from cache:`, retrieveByIdError) return null @@ -358,24 +291,12 @@ class ObjectCacheService { */ async getEventById(objectType: ObjectType, id: string): Promise { try { - const db = await this.initDB(objectType) - const transaction = db.transaction(['objects'], 'readonly') - const store = transaction.objectStore('objects') - - return new Promise((resolve, reject) => { - const request = store.get(id) - request.onsuccess = (): void => { - const obj = request.result as CachedObject | undefined - if (obj && !obj.hidden) { - resolve(obj.event) - } else { - resolve(null) - } - } - request.onerror = (): void => { - reject(request.error) - } - }) + const helper = this.getDBHelper(objectType) + const obj = await helper.get(id) + if (obj && !obj.hidden) { + return obj.event + } + return null } catch (retrieveByIdError) { console.error(`Error retrieving ${objectType} event by ID from cache:`, retrieveByIdError) return null @@ -465,18 +386,8 @@ class ObjectCacheService { */ async clear(objectType: ObjectType): Promise { try { - const db = await this.initDB(objectType) - const transaction = db.transaction(['objects'], 'readwrite') - const store = transaction.objectStore('objects') - await new Promise((resolve, reject) => { - const request = store.clear() - request.onsuccess = (): void => { - resolve() - } - request.onerror = (): void => { - reject(request.error) - } - }) + const helper = this.getDBHelper(objectType) + await helper.clear() } catch (clearError) { console.error(`Error clearing ${objectType} cache:`, clearError) } diff --git a/lib/platformTracking.ts b/lib/platformTracking.ts index c9c5f8d..28a85a6 100644 --- a/lib/platformTracking.ts +++ b/lib/platformTracking.ts @@ -173,7 +173,6 @@ export class PlatformTrackingService { try { const { websocketService } = await import('./websocketService') const { getPrimaryRelaySync } = await import('./config') - const { getTrackingKind } = await import('./platformTrackingEvents') const { swClient } = await import('./swClient') const filters = [ diff --git a/lib/publishLog.ts b/lib/publishLog.ts index 72f206b..c228dff 100644 --- a/lib/publishLog.ts +++ b/lib/publishLog.ts @@ -2,6 +2,8 @@ * Publication log service - stores publication attempts and results in IndexedDB */ +import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper' + const DB_NAME = 'nostr_publish_log' const DB_VERSION = 1 const STORE_NAME = 'publications' @@ -18,48 +20,23 @@ interface PublicationLogEntry { } class PublishLogService { - private db: IDBDatabase | null = null - private initPromise: Promise | null = null + private readonly dbHelper: IndexedDBHelper - private async init(): Promise { - if (this.db) { - return - } - - if (this.initPromise) { - return this.initPromise - } - - this.initPromise = this.openDatabase() - - try { - await this.initPromise - } catch (error) { - this.initPromise = null - throw error - } - } - - private openDatabase(): Promise { - return new Promise((resolve, reject) => { - if (typeof window === 'undefined' || !window.indexedDB) { - reject(new Error('IndexedDB is not available')) - return - } - - const request = indexedDB.open(DB_NAME, DB_VERSION) - - request.onerror = (): void => { - reject(new Error(`Failed to open IndexedDB: ${request.error}`)) - } - - request.onsuccess = (): void => { - this.db = request.result - resolve() - } - - request.onupgradeneeded = (event: IDBVersionChangeEvent): void => { - const db = (event.target as IDBOpenDBRequest).result + constructor() { + this.dbHelper = createIndexedDBHelper({ + dbName: DB_NAME, + version: DB_VERSION, + storeName: STORE_NAME, + keyPath: 'id', + indexes: [ + { name: 'eventId', keyPath: 'eventId', unique: false }, + { name: 'relayUrl', keyPath: 'relayUrl', unique: false }, + { name: 'timestamp', keyPath: 'timestamp', unique: false }, + { name: 'success', keyPath: 'success', unique: false }, + ], + onUpgrade: (db: IDBDatabase): void => { + // Note: autoIncrement is handled in the store creation, but IndexedDBHelper doesn't support it directly + // We need to handle this in the upgrade handler if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }) store.createIndex('eventId', 'eventId', { unique: false }) @@ -67,7 +44,7 @@ class PublishLogService { store.createIndex('timestamp', 'timestamp', { unique: false }) store.createIndex('success', 'success', { unique: false }) } - } + }, }) } @@ -102,12 +79,6 @@ class PublishLogService { objectId?: string ): Promise { try { - await this.init() - - if (!this.db) { - throw new Error('Database not initialized') - } - const entry: PublicationLogEntry = { id: `${eventId}_${relayUrl}_${Date.now()}`, // Unique ID eventId, @@ -119,18 +90,7 @@ class PublishLogService { ...(objectId !== undefined ? { objectId } : {}), } - const transaction = this.db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - - await new Promise((resolve, reject) => { - const request = store.add(entry) - request.onsuccess = (): void => { - resolve() - } - request.onerror = (): void => { - reject(new Error(`Failed to log publication: ${request.error}`)) - } - }) + await this.dbHelper.add(entry) } catch (logError) { console.error('[PublishLog] Error logging publication:', logError) } @@ -141,25 +101,7 @@ class PublishLogService { */ async getLogsForEvent(eventId: string): Promise { try { - await this.init() - - if (!this.db) { - return [] - } - - const transaction = this.db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) - const index = store.index('eventId') - - return new Promise((resolve, reject) => { - const request = index.getAll(eventId) - request.onsuccess = (): void => { - resolve((request.result as PublicationLogEntry[]) ?? []) - } - request.onerror = (): void => { - reject(request.error) - } - }) + return await this.dbHelper.getAllByIndex('eventId', eventId) } catch (error) { console.error('[PublishLog] Error getting logs for event:', error) return [] @@ -171,19 +113,12 @@ class PublishLogService { */ async getLogsForRelay(relayUrl: string, limit: number = 100): Promise { try { - await this.init() - - if (!this.db) { - return [] - } - - const transaction = this.db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) + const entries: PublicationLogEntry[] = [] + const store = await this.dbHelper.getStore('readonly') const index = store.index('relayUrl') - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const request = index.openCursor(IDBKeyRange.only(relayUrl)) - const entries: PublicationLogEntry[] = [] request.onsuccess = (event: globalThis.Event): void => { const cursor = (event.target as IDBRequest).result @@ -198,8 +133,13 @@ class PublishLogService { resolve(entries.sort((a, b) => b.timestamp - a.timestamp)) } } + request.onerror = (): void => { - reject(request.error) + if (request.error) { + reject(request.error) + } else { + reject(new Error('Unknown error opening cursor')) + } } }) } catch (error) { @@ -213,19 +153,12 @@ class PublishLogService { */ async getAllLogs(limit: number = 1000): Promise { try { - await this.init() - - if (!this.db) { - return [] - } - - const transaction = this.db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) + const entries: PublicationLogEntry[] = [] + const store = await this.dbHelper.getStore('readonly') const index = store.index('timestamp') - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const request = index.openCursor(null, 'prev') // Descending order - const entries: PublicationLogEntry[] = [] request.onsuccess = (event: globalThis.Event): void => { const cursor = (event.target as IDBRequest).result @@ -240,8 +173,13 @@ class PublishLogService { resolve(entries) } } + request.onerror = (): void => { - reject(request.error) + if (request.error) { + reject(request.error) + } else { + reject(new Error('Unknown error opening cursor')) + } } }) } catch (error) { diff --git a/lib/purchaseQueries.ts b/lib/purchaseQueries.ts index 9b5ae50..7808cfa 100644 --- a/lib/purchaseQueries.ts +++ b/lib/purchaseQueries.ts @@ -1,25 +1,9 @@ import type { Purchase } from '@/types/nostr' +import { getCachedObjectById } from './helpers/queryHelpers' import { objectCache } from './objectCache' -import { parseObjectId } from './urlGenerator' export async function getPurchaseById(purchaseId: string, _timeoutMs: number = 5000): Promise { - const parsed = parseObjectId(purchaseId) - const hash = parsed.hash ?? purchaseId - - // Read only from IndexedDB cache - const cached = await objectCache.get('purchase', hash) - if (cached) { - return cached as Purchase - } - - // Also try by ID if hash lookup failed - const cachedById = await objectCache.getById('purchase', purchaseId) - if (cachedById) { - return cachedById as Purchase - } - - // Not found in cache - return null (no network request) - return null + return await getCachedObjectById('purchase', purchaseId) } export async function getPurchasesForArticle(articleId: string, _timeoutMs: number = 5000): Promise { diff --git a/lib/reviewTipQueries.ts b/lib/reviewTipQueries.ts index e9b476d..54001f1 100644 --- a/lib/reviewTipQueries.ts +++ b/lib/reviewTipQueries.ts @@ -1,25 +1,9 @@ import type { ReviewTip } from '@/types/nostr' import { objectCache } from './objectCache' -import { parseObjectId } from './urlGenerator' +import { getCachedObjectById } from './helpers/queryHelpers' export async function getReviewTipById(reviewTipId: string, _timeoutMs: number = 5000): Promise { - const parsed = parseObjectId(reviewTipId) - const hash = parsed.hash ?? reviewTipId - - // Read only from IndexedDB cache - const cached = await objectCache.get('review_tip', hash) - if (cached) { - return cached as ReviewTip - } - - // Also try by ID if hash lookup failed - const cachedById = await objectCache.getById('review_tip', reviewTipId) - if (cachedById) { - return cachedById as ReviewTip - } - - // Not found in cache - return null (no network request) - return null + return await getCachedObjectById('review_tip', reviewTipId) } export async function getReviewTipsForArticle(articleId: string, _timeoutMs: number = 5000): Promise { diff --git a/lib/seriesQueries.ts b/lib/seriesQueries.ts index d4f90ff..99ac42b 100644 --- a/lib/seriesQueries.ts +++ b/lib/seriesQueries.ts @@ -1,6 +1,6 @@ import type { Series } from '@/types/nostr' import { objectCache } from './objectCache' -import { parseObjectId } from './urlGenerator' +import { getCachedObjectById } from './helpers/queryHelpers' export async function getSeriesByAuthor(authorPubkey: string, _timeoutMs: number = 5000): Promise { // Read only from IndexedDB cache @@ -22,22 +22,5 @@ export async function getSeriesByAuthor(authorPubkey: string, _timeoutMs: number } export async function getSeriesById(seriesId: string, _timeoutMs: number = 2000): Promise { - // Try to parse seriesId as id format (__) or use it as hash - const parsed = parseObjectId(seriesId) - const hash = parsed.hash ?? seriesId - - // Read only from IndexedDB cache - const cached = await objectCache.get('series', hash) - if (cached) { - return cached as Series - } - - // Also try by ID if hash lookup failed - const cachedById = await objectCache.getById('series', seriesId) - if (cachedById) { - return cachedById as Series - } - - // Not found in cache - return null (no network request) - return null + return await getCachedObjectById('series', seriesId) } diff --git a/lib/settingsCache.ts b/lib/settingsCache.ts index 73785c7..ea0f73f 100644 --- a/lib/settingsCache.ts +++ b/lib/settingsCache.ts @@ -3,6 +3,8 @@ * Stores settings and last sync date for background synchronization */ +import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper' + const DB_NAME = 'nostr_paywall_settings' const DB_VERSION = 1 const STORE_NAME = 'settings' @@ -19,37 +21,15 @@ export interface SettingsData { } class SettingsCacheService { - private db: IDBDatabase | null = null + private readonly dbHelper: IndexedDBHelper - private async initDB(): Promise { - if (this.db) { - return this.db - } - - return new Promise((resolve, reject) => { - if (typeof window === 'undefined' || !window.indexedDB) { - reject(new Error('IndexedDB is not available')) - return - } - - const request = indexedDB.open(DB_NAME, DB_VERSION) - - request.onerror = () => { - reject(new Error(`Failed to open IndexedDB: ${request.error}`)) - } - - request.onsuccess = () => { - this.db = request.result - resolve(this.db) - } - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }) - store.createIndex('updatedAt', 'updatedAt', { unique: false }) - } - } + constructor() { + this.dbHelper = createIndexedDBHelper({ + dbName: DB_NAME, + version: DB_VERSION, + storeName: STORE_NAME, + keyPath: 'key', + indexes: [{ name: 'updatedAt', keyPath: 'updatedAt', unique: false }], }) } @@ -58,20 +38,8 @@ class SettingsCacheService { */ async getSettings(): Promise { try { - const db = await this.initDB() - const transaction = db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) - - return new Promise((resolve, reject) => { - const request = store.get('settings') - - request.onsuccess = () => { - const result = request.result as { key: string; value: SettingsData } | undefined - resolve(result?.value ?? null) - } - - request.onerror = () => reject(request.error) - }) + const result = await this.dbHelper.get<{ key: string; value: SettingsData }>('settings') + return result?.value ?? null } catch (error) { console.error('Error retrieving settings from cache:', error) return null @@ -83,21 +51,12 @@ class SettingsCacheService { */ async saveSettings(settings: SettingsData): Promise { try { - const db = await this.initDB() - const transaction = db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - - await new Promise((resolve, reject) => { - const request = store.put({ - key: 'settings', - value: { - ...settings, - updatedAt: Date.now(), - }, - }) - - request.onsuccess = () => resolve() - request.onerror = () => reject(request.error) + await this.dbHelper.put({ + key: 'settings', + value: { + ...settings, + updatedAt: Date.now(), + }, }) } catch (error) { console.error('Error saving settings to cache:', error) diff --git a/lib/sponsoringQueries.ts b/lib/sponsoringQueries.ts index fb42b35..4f3ca4b 100644 --- a/lib/sponsoringQueries.ts +++ b/lib/sponsoringQueries.ts @@ -1,25 +1,9 @@ import type { Sponsoring } from '@/types/nostr' import { objectCache } from './objectCache' -import { parseObjectId } from './urlGenerator' +import { getCachedObjectById } from './helpers/queryHelpers' export async function getSponsoringById(sponsoringId: string, _timeoutMs: number = 5000): Promise { - const parsed = parseObjectId(sponsoringId) - const hash = parsed.hash ?? sponsoringId - - // Read only from IndexedDB cache - const cached = await objectCache.get('sponsoring', hash) - if (cached) { - return cached as Sponsoring - } - - // Also try by ID if hash lookup failed - const cachedById = await objectCache.getById('sponsoring', sponsoringId) - if (cachedById) { - return cachedById as Sponsoring - } - - // Not found in cache - return null (no network request) - return null + return await getCachedObjectById('sponsoring', sponsoringId) } export async function getSponsoringByAuthor(authorPubkey: string, _timeoutMs: number = 5000): Promise { diff --git a/lib/storage/indexedDB.ts b/lib/storage/indexedDB.ts index e44239a..722f24d 100644 --- a/lib/storage/indexedDB.ts +++ b/lib/storage/indexedDB.ts @@ -1,4 +1,5 @@ import { decryptPayload, encryptPayload, type EncryptedPayload } from './cryptoHelpers' +import { createIndexedDBHelper, type IndexedDBHelper } from '../helpers/indexedDBHelper' const DB_NAME = 'nostr_paywall' const DB_VERSION = 1 @@ -16,58 +17,18 @@ interface DBData { * More robust than localStorage and supports larger data sizes */ export class IndexedDBStorage { - private db: IDBDatabase | null = null - private initPromise: Promise | null = null + private readonly dbHelper: IndexedDBHelper - /** - * Initialize the IndexedDB database - */ - private async init(): Promise { - if (this.db) { - return - } - - if (this.initPromise) { - return this.initPromise - } - - this.initPromise = this.openDatabase() - - try { - await this.initPromise - } catch (error) { - this.initPromise = null - throw error - } - } - - private openDatabase(): Promise { - return new Promise((resolve, reject) => { - if (typeof window === 'undefined' || !window.indexedDB) { - reject(new Error('IndexedDB is not available. This application requires IndexedDB support.')) - return - } - - const request = window.indexedDB.open(DB_NAME, DB_VERSION) - - request.onerror = (): void => { - reject(new Error(`Failed to open IndexedDB: ${request.error}`)) - } - - request.onsuccess = (): void => { - this.db = request.result - resolve() - } - - request.onupgradeneeded = (event): void => { - const db = (event.target as IDBOpenDBRequest).result - - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }) - store.createIndex('createdAt', 'createdAt', { unique: false }) - store.createIndex('expiresAt', 'expiresAt', { unique: false }) - } - } + constructor() { + this.dbHelper = createIndexedDBHelper({ + dbName: DB_NAME, + version: DB_VERSION, + storeName: STORE_NAME, + keyPath: 'id', + indexes: [ + { name: 'createdAt', keyPath: 'createdAt', unique: false }, + { name: 'expiresAt', keyPath: 'expiresAt', unique: false }, + ], }) } @@ -76,12 +37,6 @@ export class IndexedDBStorage { */ async set(key: string, value: unknown, secret: string, expiresIn?: number): Promise { try { - await this.init() - - if (!this.db) { - throw new Error('Database not initialized') - } - const encrypted = await encryptPayload(secret, value) const now = Date.now() const data: DBData = { @@ -91,23 +46,7 @@ export class IndexedDBStorage { ...(expiresIn ? { expiresAt: now + expiresIn } : {}), } - const {db} = this - if (!db) { - throw new Error('Database not initialized') - } - - return new Promise((resolve, reject) => { - const transaction = db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - const request = store.put(data) - - request.onsuccess = (): void => { - resolve() - } - request.onerror = (): void => { - reject(new Error(`Failed to store data: ${request.error}`)) - } - }) + await this.dbHelper.put(data) } catch (error) { console.error('Error storing in IndexedDB:', error) throw error @@ -119,85 +58,35 @@ export class IndexedDBStorage { */ async get(key: string, secret: string): Promise { try { - await this.init() + const result = await this.dbHelper.get(key) - if (!this.db) { - throw new Error('Database not initialized') + if (!result) { + return null } - return this.readValue(key, secret) + if (result.expiresAt && result.expiresAt < Date.now()) { + await this.delete(key).catch(console.error) + return null + } + + try { + return await decryptPayload(secret, result.data) + } catch (decryptError) { + console.error('Error decrypting from IndexedDB:', decryptError) + return null + } } catch (error) { console.error('Error getting from IndexedDB:', error) return null } } - private readValue(key: string, secret: string): Promise { - const {db} = this - if (!db) { - throw new Error('Database not initialized') - } - - return new Promise((resolve, reject) => { - const transaction = db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) - const request = store.get(key) - - request.onsuccess = (): void => { - const result = request.result as DBData | undefined - - if (!result) { - resolve(null) - return - } - - if (result.expiresAt && result.expiresAt < Date.now()) { - this.delete(key).catch(console.error) - resolve(null) - return - } - - decryptPayload(secret, result.data) - .then((value) => { - resolve(value) - }) - .catch((error) => { - console.error('Error decrypting from IndexedDB:', error) - resolve(null) - }) - } - - request.onerror = (): void => { - reject(new Error(`Failed to get data: ${request.error}`)) - } - }) - } - /** * Delete data from IndexedDB */ async delete(key: string): Promise { try { - await this.init() - - if (!this.db) { - throw new Error('Database not initialized') - } - - const {db} = this - - return new Promise((resolve, reject) => { - const transaction = db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - const request = store.delete(key) - - request.onsuccess = (): void => { - resolve() - } - request.onerror = (): void => { - reject(new Error(`Failed to delete data: ${request.error}`)) - } - }) + await this.dbHelper.delete(key) } catch (error) { console.error('Error deleting from IndexedDB:', error) throw error @@ -209,22 +98,14 @@ export class IndexedDBStorage { */ async clearExpired(): Promise { try { - await this.init() + const store = await this.dbHelper.getStoreWrite('readwrite') + const index = store.index('expiresAt') - if (!this.db) { - throw new Error('Database not initialized') - } - - const {db} = this - - return new Promise((resolve, reject) => { - const transaction = db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - const index = store.index('expiresAt') + return new Promise((resolve, reject) => { const request = index.openCursor(IDBKeyRange.upperBound(Date.now())) request.onsuccess = (event): void => { - const cursor = (event.target as IDBRequest).result + const cursor = (event.target as IDBRequest).result if (cursor) { cursor.delete() cursor.continue() @@ -234,7 +115,11 @@ export class IndexedDBStorage { } request.onerror = (): void => { - reject(new Error(`Failed to clear expired: ${request.error}`)) + if (request.error) { + reject(new Error(`Failed to clear expired: ${request.error}`)) + } else { + reject(new Error('Unknown error clearing expired entries')) + } } }) } catch (error) { diff --git a/lib/sync/userContentSyncSteps.ts b/lib/sync/userContentSyncSteps.ts new file mode 100644 index 0000000..116ac16 --- /dev/null +++ b/lib/sync/userContentSyncSteps.ts @@ -0,0 +1,205 @@ +/** + * Individual sync steps for user content + * Each function handles fetching and caching a specific type of content + */ + +import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' +import type { Event } from 'nostr-tools' +import { fetchAuthorPresentationFromPool } from '../articlePublisherHelpersPresentation' +import { buildTagFilter } from '../nostrTagSystemFilter' +import { PLATFORM_SERVICE } from '../platformConfig' +import { extractTagsFromEvent } from '../nostrTagSystem' +import { createSyncSubscription } from '../helpers/syncSubscriptionHelper' +import { cachePublicationsByHash, cacheSeriesByHash } from '../helpers/syncContentCacheHelpers' +import { cachePurchases, cacheSponsoring, cacheReviewTips } from '../helpers/syncCacheHelpers' +import { buildPaymentNoteFilters, createPaymentNoteSubscriptions, processPaymentNoteEvents } from '../helpers/paymentNoteSyncHelper' + +export async function syncAuthorProfile( + poolWithSub: SimplePoolWithSub, + userPubkey: string +): Promise { + console.warn('[Sync] Step 1/7: Fetching author profile...') + await fetchAuthorPresentationFromPool(poolWithSub, userPubkey) + console.warn('[Sync] Step 1/7: Author profile fetch completed') +} + +export async function syncSeries( + poolWithSub: SimplePoolWithSub, + userPubkey: string +): Promise { + console.warn('[Sync] Step 2/7: Fetching series...') + const { getLastSyncDate } = await import('../syncStorage') + const lastSyncDate = await getLastSyncDate() + + const filters = [ + { + ...buildTagFilter({ + type: 'series', + authorPubkey: userPubkey, + service: PLATFORM_SERVICE, + }), + since: lastSyncDate, + limit: 1000, + }, + ] + + const result = await createSyncSubscription({ + pool: poolWithSub, + filters, + eventFilter: (event: Event): boolean => { + const tags = extractTagsFromEvent(event) + return tags.type === 'series' && !tags.hidden + }, + timeout: 10000, + onEvent: (event: Event): void => { + console.warn('[Sync] Received series event:', event.id) + }, + onComplete: (events: Event[]): void => { + console.warn(`[Sync] EOSE for series, received ${events.length} events`) + }, + }) + + await cacheSeriesByHash(result.events) +} + +export async function syncPublications( + poolWithSub: SimplePoolWithSub, + userPubkey: string +): Promise { + console.warn('[Sync] Step 3/7: Fetching publications...') + const { getLastSyncDate } = await import('../syncStorage') + const lastSyncDate = await getLastSyncDate() + + const filters = [ + { + ...buildTagFilter({ + type: 'publication', + authorPubkey: userPubkey, + service: PLATFORM_SERVICE, + }), + since: lastSyncDate, + limit: 1000, + }, + ] + + const result = await createSyncSubscription({ + pool: poolWithSub, + filters, + eventFilter: (event: Event): boolean => { + const tags = extractTagsFromEvent(event) + return tags.type === 'publication' && !tags.hidden + }, + timeout: 10000, + }) + + await cachePublicationsByHash(result.events) +} + +export async function syncPurchases( + poolWithSub: SimplePoolWithSub, + userPubkey: string +): Promise { + console.warn('[Sync] Step 4/7: Fetching purchases...') + const { getLastSyncDate } = await import('../syncStorage') + const lastSyncDate = await getLastSyncDate() + + const filters = [ + { + kinds: [9735], + authors: [userPubkey], + '#kind_type': ['purchase'], + since: lastSyncDate, + limit: 1000, + }, + ] + + await createSyncSubscription({ + pool: poolWithSub, + filters, + timeout: 10000, + onEvent: (event: Event): void => { + console.warn('[Sync] Received purchase event:', event.id) + }, + onComplete: async (events: Event[]): Promise => { + console.warn(`[Sync] EOSE for purchases, received ${events.length} events`) + await cachePurchases(events) + }, + }) +} + +export async function syncSponsoring( + poolWithSub: SimplePoolWithSub, + userPubkey: string +): Promise { + console.warn('[Sync] Step 5/7: Fetching sponsoring...') + const { getLastSyncDate } = await import('../syncStorage') + const lastSyncDate = await getLastSyncDate() + + const filters = [ + { + kinds: [9735], + '#p': [userPubkey], + '#kind_type': ['sponsoring'], + since: lastSyncDate, + limit: 1000, + }, + ] + + await createSyncSubscription({ + pool: poolWithSub, + filters, + timeout: 10000, + onEvent: (event: Event): void => { + console.warn('[Sync] Received sponsoring event:', event.id) + }, + onComplete: async (events: Event[]): Promise => { + console.warn(`[Sync] EOSE for sponsoring, received ${events.length} events`) + await cacheSponsoring(events) + }, + }) +} + +export async function syncReviewTips( + poolWithSub: SimplePoolWithSub, + userPubkey: string +): Promise { + console.warn('[Sync] Step 6/7: Fetching review tips...') + const { getLastSyncDate } = await import('../syncStorage') + const lastSyncDate = await getLastSyncDate() + + const filters = [ + { + kinds: [9735], + '#p': [userPubkey], + '#kind_type': ['review_tip'], + since: lastSyncDate, + limit: 1000, + }, + ] + + await createSyncSubscription({ + pool: poolWithSub, + filters, + timeout: 10000, + onEvent: (event: Event): void => { + console.warn('[Sync] Received review tip event:', event.id) + }, + onComplete: async (events: Event[]): Promise => { + console.warn(`[Sync] EOSE for review tips, received ${events.length} events`) + await cacheReviewTips(events) + }, + }) +} + +export async function syncPaymentNotes( + poolWithSub: SimplePoolWithSub, + userPubkey: string +): Promise { + console.warn('[Sync] Step 7/7: Fetching payment notes...') + const { getLastSyncDate } = await import('../syncStorage') + const lastSyncDate = await getLastSyncDate() + + const filters = buildPaymentNoteFilters(userPubkey, lastSyncDate) + const subscriptions = await createPaymentNoteSubscriptions(poolWithSub, filters) + await processPaymentNoteEvents(subscriptions) +} diff --git a/lib/userContentSync.ts b/lib/userContentSync.ts index d099f48..5a264a3 100644 --- a/lib/userContentSync.ts +++ b/lib/userContentSync.ts @@ -3,722 +3,21 @@ * Called after key import to ensure all user content is cached locally */ -import type { Event } from 'nostr-tools' import { nostrService } from './nostr' -import { fetchAuthorPresentationFromPool } from './articlePublisherHelpersPresentation' -import { extractTagsFromEvent } from './nostrTagSystem' -import { extractSeriesFromEvent, extractPublicationFromEvent, extractPurchaseFromEvent, extractSponsoringFromEvent, extractReviewTipFromEvent } from './metadataExtractor' -import { getLatestVersion } from './versionManager' -import { buildTagFilter } from './nostrTagSystemFilter' -import { getPrimaryRelaySync } from './config' -import { tryWithRelayRotation } from './relayRotation' -import { PLATFORM_SERVICE } from './platformConfig' -import { parseObjectId } from './urlGenerator' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' - -/** - * Fetch all publications by an author and cache them - */ -async function fetchAndCachePublications( - pool: SimplePoolWithSub, - authorPubkey: string -): Promise { - const { getLastSyncDate } = await import('./syncStorage') - const lastSyncDate = await getLastSyncDate() - - const filters = [ - { - ...buildTagFilter({ - type: 'publication', - authorPubkey, - service: PLATFORM_SERVICE, - }), - since: lastSyncDate, - limit: 1000, // Get all publications - }, - ] - - // Try relays with rotation (no retry on failure, just move to next) - const { createSubscription } = require('@/types/nostr-tools-extended') - let sub: ReturnType | null = null - let usedRelayUrl = '' - - try { - const result = await tryWithRelayRotation( - pool as unknown as import('nostr-tools').SimplePool, - async (relayUrl, poolWithSub) => { - usedRelayUrl = relayUrl - // Notify progress manager that we're starting with a new relay (reset step counter) - const { syncProgressManager } = await import('./syncProgressManager') - const currentProgress = syncProgressManager.getProgress() - if (currentProgress) { - syncProgressManager.setProgress({ - ...currentProgress, - currentStep: 0, - currentRelay: relayUrl, - }) - } - return createSubscription(poolWithSub, [relayUrl], filters) - }, - 5000 // 5 second timeout per relay - ) - sub = result - } catch { - // Fallback to primary relay if rotation fails - usedRelayUrl = getPrimaryRelaySync() - sub = createSubscription(pool, [usedRelayUrl], filters) - } - - if (!sub) { - throw new Error('Failed to create subscription') - } - - const events: Event[] = [] - - return new Promise((resolve) => { - let finished = false - - const done = async (): Promise => { - if (finished) { - return - } - finished = true - sub.unsub() - - // Group events by hash ID and cache the latest version of each - const eventsByHashId = new Map() - for (const event of events) { - const tags = extractTagsFromEvent(event) - if (tags.id) { - // Extract hash from id (can be __ or just hash) - const parsed = parseObjectId(tags.id) - const hash = parsed.hash ?? tags.id - if (!eventsByHashId.has(hash)) { - eventsByHashId.set(hash, []) - } - eventsByHashId.get(hash)!.push(event) - } - } - - // Cache each publication - for (const [_hash, hashEvents] of eventsByHashId.entries()) { - const latestEvent = getLatestVersion(hashEvents) - if (latestEvent) { - const extracted = await extractPublicationFromEvent(latestEvent) - if (extracted) { - const publicationParsed = parseObjectId(extracted.id) - const extractedHash = publicationParsed.hash ?? extracted.id - const extractedIndex = publicationParsed.index ?? 0 - const tags = extractTagsFromEvent(latestEvent) - const { writeService } = await import('./writeService') - await writeService.writeObject( - 'publication', - extractedHash, - latestEvent, - extracted, - tags.version ?? 0, - tags.hidden ?? false, - extractedIndex, - false - ) - } - } - } - - resolve() - } - - sub.on('event', (event: Event): void => { - const tags = extractTagsFromEvent(event) - if (tags.type === 'publication' && !tags.hidden) { - events.push(event) - } - }) - - sub.on('eose', (): void => { - void done() - }) - - setTimeout((): void => { - void done() - }, 10000).unref?.() - }) -} - -/** - * Fetch all series by an author and cache them - */ -async function fetchAndCacheSeries( - pool: SimplePoolWithSub, - authorPubkey: string -): Promise { - const { getLastSyncDate } = await import('./syncStorage') - const lastSyncDate = await getLastSyncDate() - - // Fetch all events for series to cache them properly - const filters = [ - { - ...buildTagFilter({ - type: 'series', - authorPubkey, - service: PLATFORM_SERVICE, - }), - since: lastSyncDate, - limit: 1000, // Get all series events - }, - ] - - // Try relays with rotation (no retry on failure, just move to next) - const { createSubscription } = require('@/types/nostr-tools-extended') - let sub: ReturnType | null = null - let usedRelayUrl = '' - - try { - const result = await tryWithRelayRotation( - pool as unknown as import('nostr-tools').SimplePool, - async (relayUrl, poolWithSub) => { - usedRelayUrl = relayUrl - // Notify progress manager that we're starting with a new relay (reset step counter) - const { syncProgressManager } = await import('./syncProgressManager') - const currentProgress = syncProgressManager.getProgress() - if (currentProgress) { - syncProgressManager.setProgress({ - ...currentProgress, - currentStep: 0, - currentRelay: relayUrl, - }) - } - return createSubscription(poolWithSub, [relayUrl], filters) - }, - 5000 // 5 second timeout per relay - ) - sub = result - } catch { - // Fallback to primary relay if rotation fails - usedRelayUrl = getPrimaryRelaySync() - sub = createSubscription(pool, [usedRelayUrl], filters) - } - - if (!sub) { - throw new Error('Failed to create subscription') - } - - const events: Event[] = [] - - return new Promise((resolve) => { - let finished = false - - const done = async (): Promise => { - if (finished) { - return - } - finished = true - sub.unsub() - - // Group events by hash ID and cache the latest version of each - const eventsByHashId = new Map() - for (const event of events) { - const tags = extractTagsFromEvent(event) - if (tags.id) { - // Extract hash from id (can be __ or just hash) - const seriesParsed = parseObjectId(tags.id) - const hash = seriesParsed.hash ?? tags.id - if (!eventsByHashId.has(hash)) { - eventsByHashId.set(hash, []) - } - eventsByHashId.get(hash)!.push(event) - } - } - - // Cache each series - for (const [_hash, hashEvents] of eventsByHashId.entries()) { - const latestEvent = getLatestVersion(hashEvents) - if (latestEvent) { - const extracted = await extractSeriesFromEvent(latestEvent) - if (extracted) { - const publicationParsed = parseObjectId(extracted.id) - const extractedHash = publicationParsed.hash ?? extracted.id - const extractedIndex = publicationParsed.index ?? 0 - const tags = extractTagsFromEvent(latestEvent) - const { writeService } = await import('./writeService') - await writeService.writeObject( - 'series', - extractedHash, - latestEvent, - extracted, - tags.version ?? 0, - tags.hidden ?? false, - extractedIndex, - false - ) - } - } - } - - resolve() - } - - sub.on('event', (event: Event): void => { - const tags = extractTagsFromEvent(event) - if (tags.type === 'series' && !tags.hidden) { - console.warn('[Sync] Received series event:', event.id) - events.push(event) - } - }) - - sub.on('eose', (): void => { - console.warn(`[Sync] EOSE for series, received ${events.length} events`) - void done() - }) - - setTimeout((): void => { - if (!finished) { - console.warn(`[Sync] Timeout for series, received ${events.length} events`) - } - void done() - }, 10000).unref?.() - }) -} - -/** - * Fetch all purchases by a payer and cache them - */ -async function fetchAndCachePurchases( - pool: SimplePoolWithSub, - payerPubkey: string -): Promise { - const { getLastSyncDate } = await import('./syncStorage') - const lastSyncDate = await getLastSyncDate() - - const filters = [ - { - kinds: [9735], // Zap receipt - authors: [payerPubkey], - '#kind_type': ['purchase'], - since: lastSyncDate, - limit: 1000, - }, - ] - - // Try relays with rotation (no retry on failure, just move to next) - const { createSubscription } = require('@/types/nostr-tools-extended') - let sub: ReturnType | null = null - let usedRelayUrl = '' - - try { - const result = await tryWithRelayRotation( - pool as unknown as import('nostr-tools').SimplePool, - async (relayUrl, poolWithSub) => { - usedRelayUrl = relayUrl - // Notify progress manager that we're starting with a new relay (reset step counter) - const { syncProgressManager } = await import('./syncProgressManager') - const currentProgress = syncProgressManager.getProgress() - if (currentProgress) { - syncProgressManager.setProgress({ - ...currentProgress, - currentStep: 0, - currentRelay: relayUrl, - }) - } - return createSubscription(poolWithSub, [relayUrl], filters) - }, - 5000 // 5 second timeout per relay - ) - sub = result - } catch { - // Fallback to primary relay if rotation fails - usedRelayUrl = getPrimaryRelaySync() - sub = createSubscription(pool, [usedRelayUrl], filters) - } - - if (!sub) { - throw new Error('Failed to create subscription') - } - - const events: Event[] = [] - - return new Promise((resolve) => { - let finished = false - - const done = async (): Promise => { - if (finished) { - return - } - finished = true - sub.unsub() - - for (const event of events) { - const extracted = await extractPurchaseFromEvent(event) - if (extracted) { - // Parse to Purchase object for cache - const { parsePurchaseFromEvent } = await import('./nostrEventParsing') - const purchase = await parsePurchaseFromEvent(event) - if (purchase) { - const { writeService } = await import('./writeService') - await writeService.writeObject('purchase', purchase.hash, event, purchase, 0, false, purchase.index, false) - } - } - } - - resolve() - } - - sub.on('event', (event: Event): void => { - console.warn('[Sync] Received purchase event:', event.id) - events.push(event) - }) - - sub.on('eose', (): void => { - console.warn(`[Sync] EOSE for purchases, received ${events.length} events`) - void done() - }) - - setTimeout((): void => { - if (!finished) { - console.warn(`[Sync] Timeout for purchases, received ${events.length} events`) - } - void done() - }, 10000).unref?.() - }) -} - -/** - * Fetch all sponsoring by an author and cache them - */ -async function fetchAndCacheSponsoring( - pool: SimplePoolWithSub, - authorPubkey: string -): Promise { - const { getLastSyncDate } = await import('./syncStorage') - const lastSyncDate = await getLastSyncDate() - - const filters = [ - { - kinds: [9735], // Zap receipt - '#p': [authorPubkey], - '#kind_type': ['sponsoring'], - since: lastSyncDate, - limit: 1000, - }, - ] - - // Try relays with rotation (no retry on failure, just move to next) - const { createSubscription } = require('@/types/nostr-tools-extended') - let sub: ReturnType | null = null - let usedRelayUrl = '' - - try { - const result = await tryWithRelayRotation( - pool as unknown as import('nostr-tools').SimplePool, - async (relayUrl, poolWithSub) => { - usedRelayUrl = relayUrl - // Notify progress manager that we're starting with a new relay (reset step counter) - const { syncProgressManager } = await import('./syncProgressManager') - const currentProgress = syncProgressManager.getProgress() - if (currentProgress) { - syncProgressManager.setProgress({ - ...currentProgress, - currentStep: 0, - currentRelay: relayUrl, - }) - } - return createSubscription(poolWithSub, [relayUrl], filters) - }, - 5000 // 5 second timeout per relay - ) - sub = result - } catch { - // Fallback to primary relay if rotation fails - usedRelayUrl = getPrimaryRelaySync() - sub = createSubscription(pool, [usedRelayUrl], filters) - } - - if (!sub) { - throw new Error('Failed to create subscription') - } - - const events: Event[] = [] - - return new Promise((resolve) => { - let finished = false - - const done = async (): Promise => { - if (finished) { - return - } - finished = true - sub.unsub() - - for (const event of events) { - const extracted = await extractSponsoringFromEvent(event) - if (extracted) { - // Parse to Sponsoring object for cache - const { parseSponsoringFromEvent } = await import('./nostrEventParsing') - const sponsoring = await parseSponsoringFromEvent(event) - if (sponsoring) { - const { writeService } = await import('./writeService') - await writeService.writeObject('sponsoring', sponsoring.hash, event, sponsoring, 0, false, sponsoring.index, false) - } - } - } - - resolve() - } - - sub.on('event', (event: Event): void => { - console.warn('[Sync] Received sponsoring event:', event.id) - events.push(event) - }) - - sub.on('eose', (): void => { - console.warn(`[Sync] EOSE for sponsoring, received ${events.length} events`) - void done() - }) - - setTimeout((): void => { - if (!finished) { - console.warn(`[Sync] Timeout for sponsoring, received ${events.length} events`) - } - void done() - }, 10000).unref?.() - }) -} - -/** - * Fetch all review tips by an author and cache them - */ -async function fetchAndCacheReviewTips( - pool: SimplePoolWithSub, - authorPubkey: string -): Promise { - const { getLastSyncDate } = await import('./syncStorage') - const lastSyncDate = await getLastSyncDate() - - const filters = [ - { - kinds: [9735], // Zap receipt - '#p': [authorPubkey], - '#kind_type': ['review_tip'], - since: lastSyncDate, - limit: 1000, - }, - ] - - // Try relays with rotation (no retry on failure, just move to next) - const { createSubscription } = require('@/types/nostr-tools-extended') - let sub: ReturnType | null = null - let usedRelayUrl = '' - - try { - const result = await tryWithRelayRotation( - pool as unknown as import('nostr-tools').SimplePool, - async (relayUrl, poolWithSub) => { - usedRelayUrl = relayUrl - // Notify progress manager that we're starting with a new relay (reset step counter) - const { syncProgressManager } = await import('./syncProgressManager') - const currentProgress = syncProgressManager.getProgress() - if (currentProgress) { - syncProgressManager.setProgress({ - ...currentProgress, - currentStep: 0, - currentRelay: relayUrl, - }) - } - return createSubscription(poolWithSub, [relayUrl], filters) - }, - 5000 // 5 second timeout per relay - ) - sub = result - } catch { - // Fallback to primary relay if rotation fails - usedRelayUrl = getPrimaryRelaySync() - sub = createSubscription(pool, [usedRelayUrl], filters) - } - - if (!sub) { - throw new Error('Failed to create subscription') - } - - const events: Event[] = [] - - return new Promise((resolve) => { - let finished = false - - const done = async (): Promise => { - if (finished) { - return - } - finished = true - sub.unsub() - - for (const event of events) { - const extracted = await extractReviewTipFromEvent(event) - if (extracted) { - // Parse to ReviewTip object for cache - const { parseReviewTipFromEvent } = await import('./nostrEventParsing') - const reviewTip = await parseReviewTipFromEvent(event) - if (reviewTip) { - const { writeService } = await import('./writeService') - await writeService.writeObject('review_tip', reviewTip.hash, event, reviewTip, 0, false, reviewTip.index, false) - } - } - } - - resolve() - } - - sub.on('event', (event: Event): void => { - console.warn('[Sync] Received review tip event:', event.id) - events.push(event) - }) - - sub.on('eose', (): void => { - console.warn(`[Sync] EOSE for review tips, received ${events.length} events`) - void done() - }) - - setTimeout((): void => { - if (!finished) { - console.warn(`[Sync] Timeout for review tips, received ${events.length} events`) - } - void done() - }, 10000).unref?.() - }) -} - -export interface SyncProgress { - currentStep: number - totalSteps: number - completed: boolean - currentRelay?: string // URL of the relay currently being used -} - -/** - * Fetch all payment notes (kind 1 with type='payment') by a user and cache them - */ -async function fetchAndCachePaymentNotes( - pool: SimplePoolWithSub, - userPubkey: string -): Promise { - const { getLastSyncDate } = await import('./syncStorage') - const lastSyncDate = await getLastSyncDate() - - // Payment notes are kind 1 with type='payment' - // They can be: as payer (authors) or as recipient (#recipient tag) - const filters = [ - { - kinds: [1], - authors: [userPubkey], - '#payment': [''], - '#service': [PLATFORM_SERVICE], - since: lastSyncDate, - limit: 1000, - }, - { - kinds: [1], - '#recipient': [userPubkey], - '#payment': [''], - '#service': [PLATFORM_SERVICE], - since: lastSyncDate, - limit: 1000, - }, - ] - - // Try relays with rotation (no retry on failure, just move to next) - const { createSubscription } = require('@/types/nostr-tools-extended') - let subscriptions: Array> = [] - let usedRelayUrl = '' - - try { - const result = await tryWithRelayRotation( - pool as unknown as import('nostr-tools').SimplePool, - async (relayUrl, poolWithSub) => { - usedRelayUrl = relayUrl - // Notify progress manager that we're starting with a new relay (reset step counter) - const { syncProgressManager } = await import('./syncProgressManager') - const currentProgress = syncProgressManager.getProgress() - if (currentProgress) { - syncProgressManager.setProgress({ - ...currentProgress, - currentStep: 0, - currentRelay: relayUrl, - }) - } - // Create subscriptions for both filters (payer and recipient) - return filters.map((filter) => createSubscription(poolWithSub, [relayUrl], [filter])) - }, - 5000 // 5 second timeout per relay - ) - subscriptions = result.flat() - } catch { - // Fallback to primary relay if rotation fails - usedRelayUrl = getPrimaryRelaySync() - subscriptions = filters.map((filter) => createSubscription(pool, [usedRelayUrl], [filter])) - } - - if (subscriptions.length === 0) { - throw new Error('Failed to create subscriptions') - } - - const events: Event[] = [] - - return new Promise((resolve) => { - let finished = false - let eoseCount = 0 - - const done = async (): Promise => { - if (finished) { - return - } - finished = true - subscriptions.forEach((sub) => sub.unsub()) - - for (const event of events) { - const tags = extractTagsFromEvent(event) - if (tags.type === 'payment' && tags.payment) { - // Cache the payment note event - // Use event.id as hash since payment notes don't have a separate hash system - const { writeService } = await import('./writeService') - await writeService.writeObject('payment_note', event.id, event, { - id: event.id, - type: 'payment_note', - eventId: event.id, - }, 0, false, 0, false) - } - } - - resolve() - } - - subscriptions.forEach((sub) => { - sub.on('event', (event: Event): void => { - const tags = extractTagsFromEvent(event) - if (tags.type === 'payment' && tags.payment) { - console.warn('[Sync] Received payment note event:', event.id) - // Deduplicate events (same event might match both filters) - if (!events.some((e) => e.id === event.id)) { - events.push(event) - } - } - }) - - sub.on('eose', (): void => { - eoseCount++ - if (eoseCount >= subscriptions.length) { - console.warn(`[Sync] EOSE for payment notes, received ${events.length} events`) - void done() - } - }) - }) - - setTimeout((): void => { - if (!finished) { - console.warn(`[Sync] Timeout for payment notes, received ${events.length} events`) - } - void done() - }, 10000).unref?.() - }) -} +import type { SyncProgress } from './helpers/syncProgressHelper' +import { initializeSyncProgress, finalizeSync } from './helpers/syncProgressHelper' +import { + syncAuthorProfile, + syncSeries, + syncPublications, + syncPurchases, + syncSponsoring, + syncReviewTips, + syncPaymentNotes, +} from './sync/userContentSyncSteps' + +export type { SyncProgress } /** * Synchronize all user content to IndexedDB cache @@ -739,96 +38,45 @@ export async function syncUserContentToCache( } const poolWithSub = pool as unknown as SimplePoolWithSub - - // Get current timestamp for last sync date - const { setLastSyncDate, getCurrentTimestamp } = await import('./syncStorage') + const { getCurrentTimestamp } = await import('./syncStorage') const currentTimestamp = getCurrentTimestamp() - const TOTAL_STEPS = 7 + const { updateProgress } = await initializeSyncProgress(onProgress) - // Report initial progress - const { relaySessionManager } = await import('./relaySessionManager') - const { syncProgressManager } = await import('./syncProgressManager') - const activeRelays = await relaySessionManager.getActiveRelays() - const initialRelay = activeRelays[0] ?? 'Connecting...' + await executeSyncSteps(poolWithSub, userPubkey, updateProgress) - if (onProgress) { - onProgress({ currentStep: 0, totalSteps: TOTAL_STEPS, completed: false, currentRelay: initialRelay }) - } - syncProgressManager.setProgress({ currentStep: 0, totalSteps: TOTAL_STEPS, completed: false, currentRelay: initialRelay }) - - let currentStep = 0 - - // Helper function to update progress with current relay - const updateProgress = (step: number, completed: boolean = false): void => { - const currentRelay = syncProgressManager.getProgress()?.currentRelay ?? initialRelay - const progressUpdate = { currentStep: step, totalSteps: TOTAL_STEPS, completed, currentRelay } - if (onProgress) { - onProgress(progressUpdate) - } - syncProgressManager.setProgress(progressUpdate) - } - - // Fetch and cache author profile (already caches itself) - console.warn('[Sync] Step 1/7: Fetching author profile...') - await fetchAuthorPresentationFromPool(poolWithSub, userPubkey) - console.warn('[Sync] Step 1/7: Author profile fetch completed') - currentStep++ - updateProgress(currentStep) - - // Fetch and cache all series - console.warn('[Sync] Step 2/7: Fetching series...') - await fetchAndCacheSeries(poolWithSub, userPubkey) - currentStep++ - updateProgress(currentStep) - - // Fetch and cache all publications - console.warn('[Sync] Step 3/7: Fetching publications...') - await fetchAndCachePublications(poolWithSub, userPubkey) - currentStep++ - updateProgress(currentStep) - - // Fetch and cache all purchases (as payer) - console.warn('[Sync] Step 4/7: Fetching purchases...') - await fetchAndCachePurchases(poolWithSub, userPubkey) - currentStep++ - updateProgress(currentStep) - - // Fetch and cache all sponsoring (as author) - console.warn('[Sync] Step 5/7: Fetching sponsoring...') - await fetchAndCacheSponsoring(poolWithSub, userPubkey) - currentStep++ - updateProgress(currentStep) - - // Fetch and cache all review tips (as author) - console.warn('[Sync] Step 6/7: Fetching review tips...') - await fetchAndCacheReviewTips(poolWithSub, userPubkey) - currentStep++ - updateProgress(currentStep) - - // Fetch and cache all payment notes (kind 1 with type='payment') - console.warn('[Sync] Step 7/7: Fetching payment notes...') - await fetchAndCachePaymentNotes(poolWithSub, userPubkey) - currentStep++ - updateProgress(currentStep, true) - - // Store the current timestamp as last sync date - await setLastSyncDate(currentTimestamp) - - // Update lastSyncDate for all relays used during sync - const { configStorage } = await import('./configStorage') - const config = await configStorage.getConfig() - const currentRelay = syncProgressManager.getProgress()?.currentRelay - if (currentRelay) { - const relayConfig = config.relays.find((r) => r.url === currentRelay) - if (relayConfig) { - await configStorage.updateRelay(relayConfig.id, { lastSyncDate: Date.now() }) - } - } - - console.warn('[Sync] Synchronization completed successfully') + await finalizeSync(currentTimestamp) } catch (syncError) { console.error('Error syncing user content to cache:', syncError) - throw syncError // Re-throw to allow UI to handle it + throw syncError } } + +async function executeSyncSteps( + poolWithSub: SimplePoolWithSub, + userPubkey: string, + updateProgress: (step: number, completed?: boolean) => void +): Promise { + let currentStep = 0 + + await syncAuthorProfile(poolWithSub, userPubkey) + updateProgress(++currentStep) + + await syncSeries(poolWithSub, userPubkey) + updateProgress(++currentStep) + + await syncPublications(poolWithSub, userPubkey) + updateProgress(++currentStep) + + await syncPurchases(poolWithSub, userPubkey) + updateProgress(++currentStep) + + await syncSponsoring(poolWithSub, userPubkey) + updateProgress(++currentStep) + + await syncReviewTips(poolWithSub, userPubkey) + updateProgress(++currentStep) + + await syncPaymentNotes(poolWithSub, userPubkey) + updateProgress(++currentStep, true) +} diff --git a/public/writeWorker.js b/public/writeWorker.js index 60bcc2e..51f3220 100644 --- a/public/writeWorker.js +++ b/public/writeWorker.js @@ -120,11 +120,7 @@ async function handleWriteObject(data, taskId) { const store = transaction.objectStore('objects') // Vérifier si l'objet existe déjà pour préserver published - const existing = await new Promise((resolve, reject) => { - const request = store.get(finalId) - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }).catch(() => null) + const existing = await executeTransactionOperation(store, (s) => s.get(finalId)).catch(() => null) // Préserver published si existant et non fourni const finalPublished = existing && published === false ? existing.published : (published ?? false) @@ -144,11 +140,7 @@ async function handleWriteObject(data, taskId) { pubkey: event.pubkey, } - await new Promise((resolve, reject) => { - const request = store.put(object) - request.onsuccess = () => resolve() - request.onerror = () => reject(request.error) - }) + await executeTransactionOperation(store, (s) => s.put(object)) self.postMessage({ type: 'WRITE_OBJECT_SUCCESS', @@ -195,11 +187,7 @@ async function handleUpdatePublished(data, taskId) { const transaction = db.transaction(['objects'], 'readwrite') const store = transaction.objectStore('objects') - const existing = await new Promise((resolve, reject) => { - const request = store.get(id) - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) + const existing = await executeTransactionOperation(store, (s) => s.get(id)) if (!existing) { throw new Error(`Object ${id} not found`) @@ -262,11 +250,7 @@ async function handleWriteMultiTable(data, taskId) { finalId = `${hash}:${count}:${version}` } - const existing = await new Promise((resolve, reject) => { - const request = store.get(finalId) - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }).catch(() => null) + const existing = await executeTransactionOperation(store, (s) => s.get(finalId)).catch(() => null) const finalPublished = existing && published === false ? existing.published : (published ?? false) @@ -317,11 +301,7 @@ async function handleCreateNotification(data, taskId) { // Vérifier si la notification existe déjà const index = store.index('eventId') - const existing = await new Promise((resolve, reject) => { - const request = index.get(eventId) - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }).catch(() => null) + const existing = await executeTransactionOperation(index, (idx) => idx.get(eventId)).catch(() => null) if (existing) { // Notification déjà existante @@ -349,11 +329,7 @@ async function handleCreateNotification(data, taskId) { fromPubkey: notificationData?.fromPubkey, } - await new Promise((resolve, reject) => { - const request = store.add(notification) - request.onsuccess = () => resolve() - request.onerror = () => reject(request.error) - }) + await executeTransactionOperation(store, (s) => s.add(notification)) self.postMessage({ type: 'CREATE_NOTIFICATION_SUCCESS', @@ -386,11 +362,7 @@ async function handleLogPublication(data, taskId) { objectId, } - await new Promise((resolve, reject) => { - const request = store.add(entry) - request.onsuccess = () => resolve() - request.onerror = () => reject(request.error) - }) + await executeTransactionOperation(store, (s) => s.add(entry)) // Pas de réponse pour les logs (fire and forget) } catch (error) { // Don't throw for logs, just log the error @@ -399,34 +371,45 @@ async function handleLogPublication(data, taskId) { } /** - * Open IndexedDB for object type + * Generic helper to open IndexedDB database */ -function openDB(objectType) { +function openIndexedDB(dbName, version, upgradeHandler) { return new Promise((resolve, reject) => { - const dbName = `nostr_${objectType}_cache` - const version = DB_VERSIONS[objectType] ?? 1 - const request = indexedDB.open(dbName, version) request.onerror = () => reject(request.error) request.onsuccess = () => resolve(request.result) - request.onupgradeneeded = (event) => { - const db = event.target.result - if (!db.objectStoreNames.contains('objects')) { - const store = db.createObjectStore('objects', { keyPath: 'id' }) - store.createIndex('hash', 'hash', { unique: false }) - store.createIndex('index', 'index', { unique: false }) - store.createIndex('published', 'published', { unique: false }) - } else { - // Migration : ajouter l'index published si nécessaire - const transaction = event.target.transaction - const store = transaction.objectStore('objects') - if (!store.indexNames.contains('published')) { - store.createIndex('published', 'published', { unique: false }) - } - } + request.onupgradeneeded = (event) => { + const db = event.target.result + if (upgradeHandler) { + upgradeHandler(db, event) } + } + }) +} + +/** + * Open IndexedDB for object type + */ +function openDB(objectType) { + const dbName = `nostr_${objectType}_cache` + const version = DB_VERSIONS[objectType] ?? 1 + + return openIndexedDB(dbName, version, (db) => { + if (!db.objectStoreNames.contains('objects')) { + const store = db.createObjectStore('objects', { keyPath: 'id' }) + store.createIndex('hash', 'hash', { unique: false }) + store.createIndex('index', 'index', { unique: false }) + store.createIndex('published', 'published', { unique: false }) + } else { + // Migration : ajouter l'index published si nécessaire + const transaction = db.transaction(['objects'], 'readwrite') + const store = transaction.objectStore('objects') + if (!store.indexNames.contains('published')) { + store.createIndex('published', 'published', { unique: false }) + } + } }) } @@ -434,23 +417,15 @@ function openDB(objectType) { * Open IndexedDB for notifications */ function openNotificationDB() { - return new Promise((resolve, reject) => { - const request = indexedDB.open('nostr_notifications', 1) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result) - - request.onupgradeneeded = (event) => { - const db = event.target.result - if (!db.objectStoreNames.contains('notifications')) { - const store = db.createObjectStore('notifications', { keyPath: 'id' }) - store.createIndex('type', 'type', { unique: false }) - store.createIndex('objectId', 'objectId', { unique: false }) - store.createIndex('eventId', 'eventId', { unique: false }) - store.createIndex('timestamp', 'timestamp', { unique: false }) - store.createIndex('read', 'read', { unique: false }) - store.createIndex('objectType', 'objectType', { unique: false }) - } + return openIndexedDB('nostr_notifications', 1, (db) => { + if (!db.objectStoreNames.contains('notifications')) { + const store = db.createObjectStore('notifications', { keyPath: 'id' }) + store.createIndex('type', 'type', { unique: false }) + store.createIndex('objectId', 'objectId', { unique: false }) + store.createIndex('eventId', 'eventId', { unique: false }) + store.createIndex('timestamp', 'timestamp', { unique: false }) + store.createIndex('read', 'read', { unique: false }) + store.createIndex('objectType', 'objectType', { unique: false }) } }) } @@ -459,25 +434,28 @@ function openNotificationDB() { * Open IndexedDB for publish log */ function openPublishLogDB() { - return new Promise((resolve, reject) => { - const request = indexedDB.open('nostr_publish_log', 1) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result) - - request.onupgradeneeded = (event) => { - const db = event.target.result - if (!db.objectStoreNames.contains('publications')) { - const store = db.createObjectStore('publications', { keyPath: 'id', autoIncrement: true }) - store.createIndex('eventId', 'eventId', { unique: false }) - store.createIndex('relayUrl', 'relayUrl', { unique: false }) - store.createIndex('timestamp', 'timestamp', { unique: false }) - store.createIndex('success', 'success', { unique: false }) - } + return openIndexedDB('nostr_publish_log', 1, (db) => { + if (!db.objectStoreNames.contains('publications')) { + const store = db.createObjectStore('publications', { keyPath: 'id', autoIncrement: true }) + store.createIndex('eventId', 'eventId', { unique: false }) + store.createIndex('relayUrl', 'relayUrl', { unique: false }) + store.createIndex('timestamp', 'timestamp', { unique: false }) + store.createIndex('success', 'success', { unique: false }) } }) } +/** + * Helper to execute a transaction operation + */ +function executeTransactionOperation(store, operation) { + return new Promise((resolve, reject) => { + const request = operation(store) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + // Notify main thread that worker is ready self.postMessage({ type: 'WORKER_READY' })