19 changed files with 1835 additions and 182 deletions
@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Repository\HighlightRepository; |
||||
use App\Repository\ArticleRepository; |
||||
use App\Service\RedisCacheService; |
||||
use App\Service\RedisViewStore; |
||||
use App\ReadModel\RedisView\RedisViewFactory; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Input\InputOption; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
#[AsCommand( |
||||
name: 'app:cache-latest-highlights', |
||||
description: 'Cache the latest highlights list with articles and author profiles' |
||||
)] |
||||
class CacheLatestHighlightsCommand extends Command |
||||
{ |
||||
public function __construct( |
||||
private readonly HighlightRepository $highlightRepository, |
||||
private readonly ArticleRepository $articleRepository, |
||||
private readonly RedisCacheService $redisCacheService, |
||||
private readonly RedisViewStore $viewStore, |
||||
private readonly RedisViewFactory $viewFactory, |
||||
private readonly LoggerInterface $logger, |
||||
) { |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function configure(): void |
||||
{ |
||||
$this->addOption( |
||||
'limit', |
||||
'l', |
||||
InputOption::VALUE_OPTIONAL, |
||||
'Maximum number of highlights to cache', |
||||
50 |
||||
); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
$limit = (int) $input->getOption('limit'); |
||||
|
||||
$output->writeln("<comment>Fetching latest {$limit} highlights from database...</comment>"); |
||||
|
||||
try { |
||||
// Get latest highlights with their articles |
||||
$highlightsWithArticles = $this->highlightRepository->findLatestWithArticles($limit); |
||||
$output->writeln(sprintf('<info>Found %d highlights</info>', count($highlightsWithArticles))); |
||||
|
||||
if (empty($highlightsWithArticles)) { |
||||
$output->writeln('<comment>No highlights found in database</comment>'); |
||||
return Command::SUCCESS; |
||||
} |
||||
|
||||
// Collect unique pubkeys for batch metadata fetch |
||||
$pubkeys = []; |
||||
foreach ($highlightsWithArticles as $item) { |
||||
$highlight = $item['highlight']; |
||||
$article = $item['article']; |
||||
|
||||
if ($highlight->getPubkey()) { |
||||
$pubkeys[] = $highlight->getPubkey(); |
||||
} |
||||
if ($article && $article->getPubkey()) { |
||||
$pubkeys[] = $article->getPubkey(); |
||||
} |
||||
} |
||||
$pubkeys = array_unique(array_filter($pubkeys)); |
||||
|
||||
$output->writeln(sprintf('<comment>Pre-fetching metadata for %d unique authors...</comment>', count($pubkeys))); |
||||
$metadataMap = $this->redisCacheService->getMultipleMetadata($pubkeys); |
||||
$output->writeln(sprintf('<comment>Fetched %d author profiles</comment>', count($metadataMap))); |
||||
|
||||
// Build Redis view objects |
||||
$output->writeln('<comment>Building Redis view objects...</comment>'); |
||||
$baseObjects = []; |
||||
$skipped = 0; |
||||
|
||||
foreach ($highlightsWithArticles as $item) { |
||||
$highlight = $item['highlight']; |
||||
$article = $item['article']; |
||||
|
||||
// Skip if article not found |
||||
if (!$article) { |
||||
$skipped++; |
||||
$this->logger->debug('Skipping highlight - article not found', [ |
||||
'highlight_id' => $highlight->getEventId(), |
||||
'coordinate' => $highlight->getArticleCoordinate(), |
||||
]); |
||||
continue; |
||||
} |
||||
|
||||
// Get metadata for both authors |
||||
$highlightAuthorMeta = $metadataMap[$highlight->getPubkey()] ?? null; |
||||
$articleAuthorMeta = $metadataMap[$article->getPubkey()] ?? null; |
||||
|
||||
// Build base object |
||||
try { |
||||
$baseObject = $this->viewFactory->highlightBaseObject( |
||||
$highlight, |
||||
$article, |
||||
$highlightAuthorMeta, |
||||
$articleAuthorMeta |
||||
); |
||||
$baseObjects[] = $baseObject; |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Failed to build highlight base object', [ |
||||
'highlight_id' => $highlight->getEventId(), |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
$skipped++; |
||||
} |
||||
} |
||||
|
||||
if ($skipped > 0) { |
||||
$output->writeln(sprintf('<comment>Skipped %d highlights (missing articles or errors)</comment>', $skipped)); |
||||
} |
||||
|
||||
// Store to Redis views |
||||
if (!empty($baseObjects)) { |
||||
$this->viewStore->storeLatestHighlights($baseObjects); |
||||
$output->writeln(sprintf('<info>✓ Stored %d highlights to Redis views (view:highlights:latest)</info>', count($baseObjects))); |
||||
} else { |
||||
$output->writeln('<error>No valid highlights to cache</error>'); |
||||
return Command::FAILURE; |
||||
} |
||||
|
||||
return Command::SUCCESS; |
||||
|
||||
} catch (\Exception $e) { |
||||
$output->writeln(sprintf('<error>Error: %s</error>', $e->getMessage())); |
||||
$this->logger->error('Failed to cache highlights', [ |
||||
'error' => $e->getMessage(), |
||||
'trace' => $e->getTraceAsString(), |
||||
]); |
||||
return Command::FAILURE; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
|
||||
<?php |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Util\CommonMark\Converter; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Input\InputOption; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
use Symfony\Component\Console\Style\SymfonyStyle; |
||||
|
||||
#[AsCommand( |
||||
name: 'articles:process-html', |
||||
description: 'Process and cache HTML for articles that are missing processed HTML content' |
||||
)] |
||||
class ProcessArticleHtmlCommand extends Command |
||||
{ |
||||
public function __construct( |
||||
private readonly EntityManagerInterface $entityManager, |
||||
private readonly Converter $converter |
||||
) { |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function configure(): void |
||||
{ |
||||
$this |
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force reprocessing of all articles (including those with existing HTML)') |
||||
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit the number of articles to process', null) |
||||
->setHelp( |
||||
'This command processes markdown content to HTML for articles and caches the result in the database. ' . |
||||
'By default, it only processes articles that are missing processed HTML. ' . |
||||
'Use --force to reprocess all articles.' |
||||
); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
$io = new SymfonyStyle($input, $output); |
||||
$force = $input->getOption('force'); |
||||
$limit = $input->getOption('limit'); |
||||
|
||||
$io->title('Article HTML Processing'); |
||||
|
||||
// Build query |
||||
$queryBuilder = $this->entityManager->createQueryBuilder() |
||||
->select('a') |
||||
->from(Article::class, 'a') |
||||
->where('a.content IS NOT NULL'); |
||||
|
||||
if (!$force) { |
||||
$queryBuilder->andWhere('a.processedHtml IS NULL'); |
||||
} |
||||
|
||||
if ($limit) { |
||||
$queryBuilder->setMaxResults((int) $limit); |
||||
} |
||||
|
||||
$articles = $queryBuilder->getQuery()->getResult(); |
||||
$total = count($articles); |
||||
|
||||
if ($total === 0) { |
||||
$io->success('No articles to process.'); |
||||
return Command::SUCCESS; |
||||
} |
||||
|
||||
$io->info(sprintf('Found %d article(s) to process', $total)); |
||||
$io->newLine(); |
||||
|
||||
$progressBar = $io->createProgressBar($total); |
||||
$progressBar->setFormat('very_verbose'); |
||||
$progressBar->start(); |
||||
|
||||
$processed = 0; |
||||
$failed = 0; |
||||
$batchSize = 20; |
||||
|
||||
foreach ($articles as $index => $article) { |
||||
try { |
||||
$html = $this->converter->convertToHTML($article->getContent()); |
||||
$article->setProcessedHtml($html); |
||||
$processed++; |
||||
|
||||
// Flush in batches for better performance |
||||
if (($index + 1) % $batchSize === 0) { |
||||
$this->entityManager->flush(); |
||||
$this->entityManager->clear(); |
||||
} |
||||
} catch (\Exception $e) { |
||||
$failed++; |
||||
$io->writeln(''); |
||||
$io->warning(sprintf( |
||||
'Failed to process article %s: %s', |
||||
$article->getEventId() ?? $article->getId(), |
||||
$e->getMessage() |
||||
)); |
||||
} |
||||
|
||||
$progressBar->advance(); |
||||
} |
||||
|
||||
// Final flush for remaining articles |
||||
$this->entityManager->flush(); |
||||
$this->entityManager->clear(); |
||||
|
||||
$progressBar->finish(); |
||||
$io->newLine(2); |
||||
|
||||
// Summary |
||||
$io->success(sprintf( |
||||
'Processing complete: %d processed, %d failed', |
||||
$processed, |
||||
$failed |
||||
)); |
||||
|
||||
if ($failed > 0) { |
||||
$io->note('Some articles failed to process. Check the warnings above for details.'); |
||||
} |
||||
|
||||
return Command::SUCCESS; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
|
||||
<?php |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Service\ArticleEventProjector; |
||||
use App\Service\NostrRelayPool; |
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
use Symfony\Component\Console\Style\SymfonyStyle; |
||||
|
||||
#[AsCommand( |
||||
name: 'articles:subscribe-local-relay', |
||||
description: 'Subscribe to local relay for new article events and save them to the database in real-time' |
||||
)] |
||||
class SubscribeLocalRelayCommand extends Command |
||||
{ |
||||
public function __construct( |
||||
private readonly NostrRelayPool $relayPool, |
||||
private readonly ArticleEventProjector $projector, |
||||
private readonly LoggerInterface $logger |
||||
) { |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function configure(): void |
||||
{ |
||||
$this->setHelp( |
||||
'This command subscribes to the local Nostr relay for article events (kind 30023) ' . |
||||
'and automatically persists them to the database. It runs as a long-lived daemon process.' |
||||
); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
$io = new SymfonyStyle($input, $output); |
||||
|
||||
$localRelay = $this->relayPool->getLocalRelay(); |
||||
if (!$localRelay) { |
||||
$io->error('Local relay not configured. Please set NOSTR_DEFAULT_RELAY environment variable.'); |
||||
return Command::FAILURE; |
||||
} |
||||
|
||||
$io->title('Article Hydration Worker'); |
||||
$io->info(sprintf('Subscribing to local relay: %s', $localRelay)); |
||||
$io->info('Listening for article events (kind 30023)...'); |
||||
$io->newLine(); |
||||
|
||||
try { |
||||
// Start the long-lived subscription |
||||
// This blocks forever and processes events via the callback |
||||
$this->relayPool->subscribeLocalArticles( |
||||
function (object $event, string $relayUrl) use ($io) { |
||||
$timestamp = date('Y-m-d H:i:s'); |
||||
$eventId = substr($event->id ?? 'unknown', 0, 16) . '...'; |
||||
$pubkey = substr($event->pubkey ?? 'unknown', 0, 16) . '...'; |
||||
$kind = $event->kind ?? 'unknown'; |
||||
$title = ''; |
||||
|
||||
// Extract title from tags if available |
||||
if (isset($event->tags) && is_array($event->tags)) { |
||||
foreach ($event->tags as $tag) { |
||||
if (is_array($tag) && isset($tag[0]) && $tag[0] === 'title' && isset($tag[1])) { |
||||
$title = mb_substr($tag[1], 0, 50); |
||||
if (mb_strlen($tag[1]) > 50) { |
||||
$title .= '...'; |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Log to console |
||||
$io->writeln(sprintf( |
||||
'[%s] <fg=green>Event received:</> %s (kind: %s, pubkey: %s)%s', |
||||
$timestamp, |
||||
$eventId, |
||||
$kind, |
||||
$pubkey, |
||||
$title ? ' - ' . $title : '' |
||||
)); |
||||
|
||||
// Project the event to the database |
||||
try { |
||||
$this->projector->projectArticleFromEvent($event, $relayUrl); |
||||
$io->writeln(sprintf( |
||||
'[%s] <fg=green>✓</> Article saved to database', |
||||
date('Y-m-d H:i:s') |
||||
)); |
||||
} catch (\InvalidArgumentException $e) { |
||||
// Invalid event (wrong kind, bad signature, etc.) |
||||
$io->writeln(sprintf( |
||||
'[%s] <fg=yellow>⚠</> Skipped invalid event: %s', |
||||
date('Y-m-d H:i:s'), |
||||
$e->getMessage() |
||||
)); |
||||
} catch (\Exception $e) { |
||||
// Database or other errors |
||||
$io->writeln(sprintf( |
||||
'[%s] <fg=red>✗</> Error saving article: %s', |
||||
date('Y-m-d H:i:s'), |
||||
$e->getMessage() |
||||
)); |
||||
} |
||||
|
||||
$io->newLine(); |
||||
} |
||||
); |
||||
|
||||
// @phpstan-ignore-next-line - This line should never be reached (infinite loop in subscribeLocalArticles) |
||||
return Command::SUCCESS; |
||||
|
||||
} catch (\Exception $e) { |
||||
$io->error('Subscription failed: ' . $e->getMessage()); |
||||
$this->logger->error('Article hydration worker failed', [ |
||||
'error' => $e->getMessage(), |
||||
'exception' => get_class($e), |
||||
'trace' => $e->getTraceAsString() |
||||
]); |
||||
return Command::FAILURE; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
<?php |
||||
|
||||
namespace App\ReadModel\RedisView; |
||||
|
||||
/** |
||||
* Redis view model for Article - MATCHES TEMPLATE EXPECTATIONS |
||||
* Property names match what Twig templates expect from Article entities |
||||
* This eliminates need for mapping layers |
||||
*/ |
||||
final class RedisArticleView |
||||
{ |
||||
public function __construct( |
||||
public string $id, |
||||
public string $slug, // Template expects: article.slug |
||||
public string $title, // Template expects: article.title |
||||
public string $pubkey, // Template expects: article.pubkey |
||||
public ?\DateTimeImmutable $createdAt = null, // Template expects: article.createdAt |
||||
public ?string $summary = null, // Template expects: article.summary |
||||
public ?string $image = null, // Template expects: article.image |
||||
public ?string $eventId = null, // Nostr event id |
||||
public ?string $contentHtml = null, // processedHtml for article detail pages |
||||
public ?\DateTimeImmutable $publishedAt = null, |
||||
public array $topics = [], // For topic filtering |
||||
) {} |
||||
} |
||||
|
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
<?php |
||||
|
||||
namespace App\ReadModel\RedisView; |
||||
|
||||
/** |
||||
* Redis base object - the fundamental unit stored in Redis view lists |
||||
* Contains all data needed to render an item without additional queries |
||||
* |
||||
* This is the top-level structure stored in Redis as JSON for: |
||||
* - view:articles:latest |
||||
* - view:highlights:latest |
||||
* - view:user:articles:<pubkey> |
||||
*/ |
||||
final readonly class RedisBaseObject |
||||
{ |
||||
/** |
||||
* @param RedisArticleView|null $article Article data (if applicable) |
||||
* @param RedisHighlightView|null $highlight Highlight data (if applicable) |
||||
* @param RedisProfileView|null $author Primary author (article or highlight author) |
||||
* @param array<string, RedisProfileView> $profiles Map of all referenced profiles (pubkey => profile) |
||||
* @param array $meta Extensible metadata (zaps, counts, etc.) |
||||
*/ |
||||
public function __construct( |
||||
public ?RedisArticleView $article = null, |
||||
public ?RedisHighlightView $highlight = null, |
||||
public ?RedisProfileView $author = null, |
||||
public array $profiles = [], |
||||
public array $meta = [], |
||||
) {} |
||||
} |
||||
|
||||
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
<?php |
||||
|
||||
namespace App\ReadModel\RedisView; |
||||
|
||||
/** |
||||
* Redis view model for Highlight entity (kind 9802, NIP-84) |
||||
* Compact representation of highlight event |
||||
*/ |
||||
final readonly class RedisHighlightView |
||||
{ |
||||
public function __construct( |
||||
public string $eventId, // highlight event id |
||||
public string $pubkey, // highlight author pubkey |
||||
public \DateTimeImmutable $createdAt, |
||||
public ?string $content = null, // optional note/comment |
||||
public ?string $context = null, // highlighted text |
||||
public array $refs = [], // article references |
||||
) {} |
||||
} |
||||
|
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
<?php |
||||
|
||||
namespace App\ReadModel\RedisView; |
||||
|
||||
/** |
||||
* Redis view model for Profile - MATCHES TEMPLATE EXPECTATIONS |
||||
* Property names match what RedisCacheService::getMetadata() returns |
||||
* Templates expect: user.name, user.picture, user.nip05, user.display_name |
||||
*/ |
||||
final class RedisProfileView |
||||
{ |
||||
public function __construct( |
||||
public string $pubkey, |
||||
public ?string $name = null, // PRIMARY - Template expects: user.name |
||||
public ?string $display_name = null, // Template expects: user.display_name |
||||
public ?string $picture = null, // Template expects: user.picture |
||||
public ?string $nip05 = null, // Template expects: user.nip05 |
||||
public ?string $about = null, |
||||
public ?string $website = null, |
||||
public ?string $lud16 = null, |
||||
public ?string $banner = null, |
||||
) {} |
||||
} |
||||
@ -0,0 +1,301 @@
@@ -0,0 +1,301 @@
|
||||
<?php |
||||
|
||||
namespace App\ReadModel\RedisView; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Entity\Highlight; |
||||
use App\Service\RedisCacheService; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
/** |
||||
* Factory service for converting entities into Redis view objects |
||||
* Handles normalization/denormalization for Redis storage |
||||
*/ |
||||
class RedisViewFactory |
||||
{ |
||||
public function __construct( |
||||
private readonly RedisCacheService $redisCacheService, |
||||
private readonly LoggerInterface $logger, |
||||
) {} |
||||
|
||||
/** |
||||
* Convert Nostr kind 0 profile metadata to RedisProfileView |
||||
* @param array|\stdClass|null $metadata Profile metadata (from RedisCacheService) |
||||
*/ |
||||
public function profileToView(array|\stdClass|null $metadata, string $pubkey): ?RedisProfileView |
||||
{ |
||||
if (empty($metadata)) { |
||||
$this->logger->debug('No metadata found for pubkey', ['pubkey' => $pubkey]); |
||||
return null; |
||||
} |
||||
|
||||
// Convert stdClass to array if needed |
||||
if ($metadata instanceof \stdClass) { |
||||
$metadata = json_decode(json_encode($metadata), true) ?? []; |
||||
} |
||||
|
||||
// Helper to extract string from array or return string/null |
||||
$getString = function($value): ?string { |
||||
if (is_array($value)) { |
||||
return !empty($value) ? (string)$value[0] : null; |
||||
} |
||||
return $value ? (string)$value : null; |
||||
}; |
||||
|
||||
return new RedisProfileView( |
||||
pubkey: $pubkey, |
||||
name: $metadata['name'] ?? $metadata['display_name'] ?? null, // PRIMARY name field |
||||
display_name: $metadata['display_name'] ?? null, |
||||
picture: $metadata['picture'] ?? null, // Match template expectation |
||||
nip05: $getString($metadata['nip05'] ?? null), |
||||
about: $metadata['about'] ?? null, |
||||
website: $metadata['website'] ?? null, |
||||
lud16: $getString($metadata['lud16'] ?? null), |
||||
banner: $metadata['banner'] ?? null, |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Convert Article entity to RedisArticleView |
||||
*/ |
||||
public function articleToView(Article $article): RedisArticleView |
||||
{ |
||||
return new RedisArticleView( |
||||
id: (string) $article->getId(), |
||||
slug: $article->getSlug() ?? '', // Template expects: article.slug |
||||
title: $article->getTitle() ?? '', // Template expects: article.title |
||||
pubkey: $article->getPubkey() ?? '', // Template expects: article.pubkey |
||||
createdAt: $article->getCreatedAt(), // Template expects: article.createdAt |
||||
summary: $article->getSummary(), // Template expects: article.summary |
||||
image: $article->getImage(), // Template expects: article.image |
||||
eventId: $article->getEventId() ?? '', |
||||
contentHtml: $article->getProcessedHtml(), |
||||
publishedAt: $article->getPublishedAt(), |
||||
topics: $article->getTopics() ?? [], |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Convert Highlight entity to RedisHighlightView |
||||
*/ |
||||
public function highlightToView(Highlight $highlight): RedisHighlightView |
||||
{ |
||||
// Convert Unix timestamp to DateTimeImmutable |
||||
$createdAt = $highlight->getCreatedAt(); |
||||
$createdAtDt = $createdAt instanceof \DateTimeImmutable |
||||
? $createdAt |
||||
: new \DateTimeImmutable('@' . $createdAt); |
||||
|
||||
return new RedisHighlightView( |
||||
eventId: $highlight->getEventId() ?? '', |
||||
pubkey: $highlight->getPubkey() ?? '', |
||||
createdAt: $createdAtDt, |
||||
content: $highlight->getContent(), |
||||
context: $highlight->getContext(), |
||||
refs: [ |
||||
'article_coordinate' => $highlight->getArticleCoordinate(), |
||||
], |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Build a complete RedisBaseObject for an article |
||||
* Fetches author profile from Redis metadata cache |
||||
* @param Article $article |
||||
* @param array|\stdClass|null $authorMetadata Author profile metadata (from RedisCacheService) |
||||
*/ |
||||
public function articleBaseObject(Article $article, array|\stdClass|null $authorMetadata = null): RedisBaseObject |
||||
{ |
||||
$articleView = $this->articleToView($article); |
||||
|
||||
// Fetch author metadata if not provided |
||||
if ($authorMetadata === null) { |
||||
$authorMetadata = $this->redisCacheService->getMetadata($article->getPubkey()); |
||||
} |
||||
|
||||
$authorView = $this->profileToView($authorMetadata, $article->getPubkey()); |
||||
|
||||
$profiles = []; |
||||
if ($authorView !== null) { |
||||
$profiles[$authorView->pubkey] = $authorView; |
||||
} |
||||
|
||||
return new RedisBaseObject( |
||||
article: $articleView, |
||||
highlight: null, |
||||
author: $authorView, |
||||
profiles: $profiles, |
||||
meta: [], |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Build a complete RedisBaseObject for a highlight |
||||
* Requires the highlighted article and fetches both author profiles |
||||
* @param Highlight $highlight |
||||
* @param Article $article |
||||
* @param array|\stdClass|null $highlightAuthorMetadata Highlight author metadata |
||||
* @param array|\stdClass|null $articleAuthorMetadata Article author metadata |
||||
*/ |
||||
public function highlightBaseObject( |
||||
Highlight $highlight, |
||||
Article $article, |
||||
array|\stdClass|null $highlightAuthorMetadata = null, |
||||
array|\stdClass|null $articleAuthorMetadata = null |
||||
): RedisBaseObject { |
||||
$articleView = $this->articleToView($article); |
||||
$highlightView = $this->highlightToView($highlight); |
||||
|
||||
// Fetch metadata if not provided |
||||
if ($highlightAuthorMetadata === null) { |
||||
$highlightAuthorMetadata = $this->redisCacheService->getMetadata($highlight->getPubkey()); |
||||
} |
||||
if ($articleAuthorMetadata === null) { |
||||
$articleAuthorMetadata = $this->redisCacheService->getMetadata($article->getPubkey()); |
||||
} |
||||
|
||||
$highlightAuthorView = $this->profileToView($highlightAuthorMetadata, $highlight->getPubkey()); |
||||
$articleAuthorView = $this->profileToView($articleAuthorMetadata, $article->getPubkey()); |
||||
|
||||
$profiles = []; |
||||
if ($articleAuthorView !== null) { |
||||
$profiles[$articleAuthorView->pubkey] = $articleAuthorView; |
||||
} |
||||
if ($highlightAuthorView !== null) { |
||||
$profiles[$highlightAuthorView->pubkey] = $highlightAuthorView; |
||||
} |
||||
|
||||
return new RedisBaseObject( |
||||
article: $articleView, |
||||
highlight: $highlightView, |
||||
author: $highlightAuthorView, // Primary author is the highlight author |
||||
profiles: $profiles, |
||||
meta: [], |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Normalize RedisBaseObject to array for JSON storage |
||||
*/ |
||||
public function normalizeBaseObject(RedisBaseObject $obj): array |
||||
{ |
||||
return [ |
||||
'article' => $obj->article ? $this->normalizeArticleView($obj->article) : null, |
||||
'highlight' => $obj->highlight ? $this->normalizeHighlightView($obj->highlight) : null, |
||||
'author' => $obj->author ? $this->normalizeProfileView($obj->author) : null, |
||||
'profiles' => array_map( |
||||
fn(RedisProfileView $profile) => $this->normalizeProfileView($profile), |
||||
$obj->profiles |
||||
), |
||||
'meta' => $obj->meta, |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* Denormalize array back to RedisBaseObject |
||||
*/ |
||||
public function denormalizeBaseObject(array $data): RedisBaseObject |
||||
{ |
||||
$profiles = []; |
||||
foreach ($data['profiles'] ?? [] as $pubkey => $profileData) { |
||||
$profiles[$pubkey] = $this->denormalizeProfileView($profileData); |
||||
} |
||||
|
||||
return new RedisBaseObject( |
||||
article: isset($data['article']) ? $this->denormalizeArticleView($data['article']) : null, |
||||
highlight: isset($data['highlight']) ? $this->denormalizeHighlightView($data['highlight']) : null, |
||||
author: isset($data['author']) ? $this->denormalizeProfileView($data['author']) : null, |
||||
profiles: $profiles, |
||||
meta: $data['meta'] ?? [], |
||||
); |
||||
} |
||||
|
||||
private function normalizeProfileView(RedisProfileView $view): array |
||||
{ |
||||
return [ |
||||
'pubkey' => $view->pubkey, |
||||
'name' => $view->name, |
||||
'display_name' => $view->display_name, |
||||
'picture' => $view->picture, |
||||
'nip05' => $view->nip05, |
||||
'about' => $view->about, |
||||
'website' => $view->website, |
||||
'lud16' => $view->lud16, |
||||
'banner' => $view->banner, |
||||
]; |
||||
} |
||||
|
||||
private function denormalizeProfileView(array $data): RedisProfileView |
||||
{ |
||||
return new RedisProfileView( |
||||
pubkey: $data['pubkey'], |
||||
name: $data['name'] ?? null, |
||||
display_name: $data['display_name'] ?? null, |
||||
picture: $data['picture'] ?? null, |
||||
nip05: $data['nip05'] ?? null, |
||||
about: $data['about'] ?? null, |
||||
website: $data['website'] ?? null, |
||||
lud16: $data['lud16'] ?? null, |
||||
banner: $data['banner'] ?? null, |
||||
); |
||||
} |
||||
|
||||
private function normalizeArticleView(RedisArticleView $view): array |
||||
{ |
||||
return [ |
||||
'id' => $view->id, |
||||
'slug' => $view->slug, |
||||
'title' => $view->title, |
||||
'pubkey' => $view->pubkey, |
||||
'createdAt' => $view->createdAt?->format(\DateTimeInterface::ATOM), |
||||
'summary' => $view->summary, |
||||
'image' => $view->image, |
||||
'eventId' => $view->eventId, |
||||
'contentHtml' => $view->contentHtml, |
||||
'publishedAt' => $view->publishedAt?->format(\DateTimeInterface::ATOM), |
||||
'topics' => $view->topics, |
||||
]; |
||||
} |
||||
|
||||
private function denormalizeArticleView(array $data): RedisArticleView |
||||
{ |
||||
return new RedisArticleView( |
||||
id: $data['id'], |
||||
slug: $data['slug'], |
||||
title: $data['title'], |
||||
pubkey: $data['pubkey'], |
||||
createdAt: isset($data['createdAt']) ? new \DateTimeImmutable($data['createdAt']) : null, |
||||
summary: $data['summary'] ?? null, |
||||
image: $data['image'] ?? null, |
||||
eventId: $data['eventId'] ?? null, |
||||
contentHtml: $data['contentHtml'] ?? null, |
||||
publishedAt: isset($data['publishedAt']) ? new \DateTimeImmutable($data['publishedAt']) : null, |
||||
topics: $data['topics'] ?? [], |
||||
); |
||||
} |
||||
|
||||
private function normalizeHighlightView(RedisHighlightView $view): array |
||||
{ |
||||
return [ |
||||
'eventId' => $view->eventId, |
||||
'pubkey' => $view->pubkey, |
||||
'createdAt' => $view->createdAt->format(\DateTimeInterface::ATOM), |
||||
'content' => $view->content, |
||||
'context' => $view->context, |
||||
'refs' => $view->refs, |
||||
]; |
||||
} |
||||
|
||||
private function denormalizeHighlightView(array $data): RedisHighlightView |
||||
{ |
||||
return new RedisHighlightView( |
||||
eventId: $data['eventId'], |
||||
pubkey: $data['pubkey'], |
||||
createdAt: new \DateTimeImmutable($data['createdAt']), |
||||
content: $data['content'] ?? null, |
||||
context: $data['context'] ?? null, |
||||
refs: $data['refs'] ?? [], |
||||
); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
|
||||
<?php |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Factory\ArticleFactory; |
||||
use App\Util\CommonMark\Converter; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Doctrine\Persistence\ManagerRegistry; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
/** |
||||
* Projects Nostr article events into the database |
||||
* Handles the conversion from event format to Article entity and persistence |
||||
* Also processes markdown content to HTML for performance optimization |
||||
*/ |
||||
class ArticleEventProjector |
||||
{ |
||||
public function __construct( |
||||
private readonly ArticleFactory $articleFactory, |
||||
private readonly EntityManagerInterface $entityManager, |
||||
private readonly ManagerRegistry $managerRegistry, |
||||
private readonly LoggerInterface $logger, |
||||
private readonly Converter $converter |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* Project a Nostr event into the database |
||||
* Creates or updates an Article entity from the event data |
||||
* |
||||
* @param object $event The Nostr event object (stdClass with id, kind, pubkey, content, tags, etc.) |
||||
* @param string $relayUrl The relay URL where the event was received from |
||||
* @throws \Exception |
||||
*/ |
||||
public function projectArticleFromEvent(object $event, string $relayUrl): void |
||||
{ |
||||
try { |
||||
// Create Article entity from the event using the factory |
||||
$article = $this->articleFactory->createFromLongFormContentEvent($event); |
||||
|
||||
// Process markdown content to HTML for performance optimization |
||||
if ($article->getContent()) { |
||||
try { |
||||
$processedHtml = $this->converter->convertToHTML($article->getContent()); |
||||
$article->setProcessedHtml($processedHtml); |
||||
|
||||
$this->logger->debug('Processed article HTML', [ |
||||
'event_id' => $article->getEventId(), |
||||
'content_length' => strlen($article->getContent()), |
||||
'html_length' => strlen($processedHtml) |
||||
]); |
||||
} catch (\Exception $e) { |
||||
// If HTML conversion fails, log but continue (HTML will be null) |
||||
$this->logger->warning('Failed to process article HTML', [ |
||||
'event_id' => $article->getEventId(), |
||||
'error' => $e->getMessage() |
||||
]); |
||||
} |
||||
} |
||||
|
||||
// Check if article with same eventId already exists in the database |
||||
$existingArticle = $this->entityManager |
||||
->getRepository(Article::class) |
||||
->findOneBy(['eventId' => $article->getEventId()]); |
||||
|
||||
if (!$existingArticle) { |
||||
// New article - persist it |
||||
$this->logger->info('Persisting new article from relay', [ |
||||
'event_id' => $article->getEventId(), |
||||
'kind' => $article->getKind()?->value, |
||||
'pubkey' => $article->getPubkey(), |
||||
'title' => $article->getTitle(), |
||||
'has_processed_html' => $article->getProcessedHtml() !== null, |
||||
'relay' => $relayUrl |
||||
]); |
||||
|
||||
$this->entityManager->persist($article); |
||||
$this->entityManager->flush(); |
||||
|
||||
$this->logger->info('Article successfully saved to database', [ |
||||
'event_id' => $article->getEventId(), |
||||
'db_id' => $article->getId() |
||||
]); |
||||
} else { |
||||
$this->logger->debug('Article already exists in database, skipping', [ |
||||
'event_id' => $article->getEventId(), |
||||
'db_id' => $existingArticle->getId() |
||||
]); |
||||
} |
||||
} catch (\InvalidArgumentException $e) { |
||||
// Invalid event (wrong kind, invalid signature, etc.) |
||||
$this->logger->warning('Invalid article event received', [ |
||||
'event_id' => $event->id ?? 'unknown', |
||||
'error' => $e->getMessage(), |
||||
'relay' => $relayUrl |
||||
]); |
||||
throw $e; |
||||
} catch (\Exception $e) { |
||||
// Database or other errors |
||||
$this->logger->error('Error projecting article event', [ |
||||
'event_id' => $event->id ?? 'unknown', |
||||
'error' => $e->getMessage(), |
||||
'relay' => $relayUrl |
||||
]); |
||||
|
||||
// Reset entity manager on error to prevent further issues |
||||
$this->managerRegistry->resetManager(); |
||||
throw $e; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
|
||||
<?php |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\ReadModel\RedisView\RedisViewFactory; |
||||
use App\ReadModel\RedisView\RedisBaseObject; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
/** |
||||
* Service for storing and fetching Redis view objects |
||||
* Enables single-GET page rendering by caching complete view objects |
||||
*/ |
||||
class RedisViewStore |
||||
{ |
||||
private const KEY_LATEST_ARTICLES = 'view:articles:latest'; |
||||
private const KEY_LATEST_HIGHLIGHTS = 'view:highlights:latest'; |
||||
private const KEY_USER_ARTICLES = 'view:user:articles:%s'; // sprintf with pubkey |
||||
|
||||
private const DEFAULT_TTL = 3600; // 1 hour |
||||
|
||||
public function __construct( |
||||
private readonly \Redis $redis, |
||||
private readonly RedisViewFactory $factory, |
||||
private readonly LoggerInterface $logger, |
||||
) {} |
||||
|
||||
/** |
||||
* Store latest articles view |
||||
* @param array<RedisBaseObject> $baseObjects |
||||
*/ |
||||
public function storeLatestArticles(array $baseObjects): void |
||||
{ |
||||
$this->storeView(self::KEY_LATEST_ARTICLES, $baseObjects, self::DEFAULT_TTL); |
||||
} |
||||
|
||||
/** |
||||
* Fetch latest articles view |
||||
* @return array|null Array of denormalized base objects or null if not cached |
||||
*/ |
||||
public function fetchLatestArticles(): ?array |
||||
{ |
||||
return $this->fetchView(self::KEY_LATEST_ARTICLES); |
||||
} |
||||
|
||||
/** |
||||
* Store latest highlights view |
||||
* @param array<RedisBaseObject> $baseObjects |
||||
*/ |
||||
public function storeLatestHighlights(array $baseObjects): void |
||||
{ |
||||
$this->storeView(self::KEY_LATEST_HIGHLIGHTS, $baseObjects, self::DEFAULT_TTL); |
||||
} |
||||
|
||||
/** |
||||
* Fetch latest highlights view |
||||
* @return array|null Array of denormalized base objects or null if not cached |
||||
*/ |
||||
public function fetchLatestHighlights(): ?array |
||||
{ |
||||
return $this->fetchView(self::KEY_LATEST_HIGHLIGHTS); |
||||
} |
||||
|
||||
/** |
||||
* Store user articles view |
||||
* @param string $pubkey User's pubkey |
||||
* @param array<RedisBaseObject> $baseObjects |
||||
*/ |
||||
public function storeUserArticles(string $pubkey, array $baseObjects): void |
||||
{ |
||||
$key = sprintf(self::KEY_USER_ARTICLES, $pubkey); |
||||
$this->storeView($key, $baseObjects, self::DEFAULT_TTL); |
||||
} |
||||
|
||||
/** |
||||
* Fetch user articles view |
||||
* @param string $pubkey User's pubkey |
||||
* @return array|null Array of denormalized base objects or null if not cached |
||||
*/ |
||||
public function fetchUserArticles(string $pubkey): ?array |
||||
{ |
||||
$key = sprintf(self::KEY_USER_ARTICLES, $pubkey); |
||||
return $this->fetchView($key); |
||||
} |
||||
|
||||
/** |
||||
* Invalidate user articles cache |
||||
*/ |
||||
public function invalidateUserArticles(string $pubkey): void |
||||
{ |
||||
$key = sprintf(self::KEY_USER_ARTICLES, $pubkey); |
||||
try { |
||||
$this->redis->del($key); |
||||
$this->logger->debug('Invalidated user articles cache', ['pubkey' => $pubkey, 'key' => $key]); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Failed to invalidate user articles cache', [ |
||||
'pubkey' => $pubkey, |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Invalidate all view caches |
||||
*/ |
||||
public function invalidateAll(): void |
||||
{ |
||||
try { |
||||
$this->redis->del(self::KEY_LATEST_ARTICLES); |
||||
$this->redis->del(self::KEY_LATEST_HIGHLIGHTS); |
||||
$this->logger->info('Invalidated all view caches'); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Failed to invalidate all caches', ['error' => $e->getMessage()]); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Store normalized view objects to Redis |
||||
* @param string $key Redis key |
||||
* @param array<RedisBaseObject> $baseObjects |
||||
* @param int|null $ttl Time to live in seconds |
||||
*/ |
||||
private function storeView(string $key, array $baseObjects, ?int $ttl = null): void |
||||
{ |
||||
try { |
||||
// Normalize all base objects to arrays |
||||
$normalizedObjects = array_map( |
||||
fn(RedisBaseObject $obj) => $this->factory->normalizeBaseObject($obj), |
||||
$baseObjects |
||||
); |
||||
|
||||
// Encode to JSON |
||||
$json = json_encode($normalizedObjects, JSON_THROW_ON_ERROR); |
||||
|
||||
// Optionally compress for large payloads |
||||
$data = $this->shouldCompress($json) ? gzcompress($json, 6) : $json; |
||||
$isCompressed = $data !== $json; |
||||
|
||||
// Store in Redis |
||||
if ($ttl !== null) { |
||||
$this->redis->setex($key, $ttl, $data); |
||||
} else { |
||||
$this->redis->set($key, $data); |
||||
} |
||||
|
||||
// Store compression flag |
||||
if ($isCompressed) { |
||||
$this->redis->setex($key . ':compressed', $ttl ?? self::DEFAULT_TTL, '1'); |
||||
} |
||||
|
||||
$this->logger->debug('Stored view in Redis', [ |
||||
'key' => $key, |
||||
'count' => count($baseObjects), |
||||
'size_bytes' => strlen($data), |
||||
'compressed' => $isCompressed, |
||||
'ttl' => $ttl, |
||||
]); |
||||
} catch (\JsonException $e) { |
||||
$this->logger->error('JSON encoding failed', [ |
||||
'key' => $key, |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
throw $e; |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Failed to store view in Redis', [ |
||||
'key' => $key, |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
throw $e; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetch and denormalize view objects from Redis |
||||
* @return array|null Array of arrays (not denormalized to objects) or null if not found |
||||
*/ |
||||
private function fetchView(string $key): ?array |
||||
{ |
||||
try { |
||||
$data = $this->redis->get($key); |
||||
|
||||
if ($data === false || $data === null) { |
||||
$this->logger->debug('View not found in Redis', ['key' => $key]); |
||||
return null; |
||||
} |
||||
|
||||
// Check if data is compressed |
||||
$isCompressed = $this->redis->get($key . ':compressed') !== false; |
||||
|
||||
// Decompress if needed |
||||
if ($isCompressed) { |
||||
$json = gzuncompress($data); |
||||
if ($json === false) { |
||||
$this->logger->error('Failed to decompress data', ['key' => $key]); |
||||
return null; |
||||
} |
||||
} else { |
||||
$json = $data; |
||||
} |
||||
|
||||
// Decode JSON |
||||
$normalized = json_decode($json, true, 512, JSON_THROW_ON_ERROR); |
||||
|
||||
$this->logger->debug('Fetched view from Redis', [ |
||||
'key' => $key, |
||||
'count' => count($normalized), |
||||
'compressed' => $isCompressed, |
||||
]); |
||||
|
||||
return $normalized; |
||||
} catch (\JsonException $e) { |
||||
$this->logger->error('JSON decoding failed', [ |
||||
'key' => $key, |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
return null; |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Failed to fetch view from Redis', [ |
||||
'key' => $key, |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Determine if data should be compressed |
||||
* Compress if larger than 10KB |
||||
*/ |
||||
private function shouldCompress(string $data): bool |
||||
{ |
||||
return strlen($data) > 10240; // 10KB |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue