diff --git a/compose.yaml b/compose.yaml
index cfa2027..680ed14 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -89,6 +89,26 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-17}&charset=${POSTGRES_CHARSET:-utf8}}
NOSTR_DEFAULT_RELAY: ${NOSTR_DEFAULT_RELAY:-}
+ article_hydration_worker:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ working_dir: /app
+ entrypoint: ["php"] # run PHP CLI, not Caddy/FrankenPHP
+ command:
+ - bin/console
+ - articles:subscribe-local-relay
+ - -vv
+ restart: unless-stopped
+ depends_on:
+ - php
+ - database
+ - strfry
+ environment:
+ APP_ENV: prod
+ DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-17}&charset=${POSTGRES_CHARSET:-utf8}}
+ NOSTR_DEFAULT_RELAY: ${NOSTR_DEFAULT_RELAY:-ws://strfry:7777}
+
###> strfry relay ###
strfry:
image: dockurr/strfry:latest
diff --git a/src/Command/CacheLatestArticlesCommand.php b/src/Command/CacheLatestArticlesCommand.php
index 95ff542..7e79d09 100644
--- a/src/Command/CacheLatestArticlesCommand.php
+++ b/src/Command/CacheLatestArticlesCommand.php
@@ -4,112 +4,142 @@ declare(strict_types=1);
namespace App\Command;
+use App\Entity\Article;
+use App\Repository\ArticleRepository;
use App\Service\RedisCacheService;
-use App\Util\NostrKeyUtil;
-use FOS\ElasticaBundle\Finder\FinderInterface;
+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 Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
-use Elastica\Query\BoolQuery;
-use Elastica\Query;
-use Elastica\Collapse;
-use Elastica\Query\Terms;
-use Psr\Cache\CacheItemPoolInterface;
use swentel\nostr\Key\Key;
-#[AsCommand(name: 'app:cache_latest_articles', description: 'Cache the latest articles list')]
+#[AsCommand(
+ name: 'app:cache_latest_articles',
+ description: 'Cache the latest articles list to Redis views'
+)]
class CacheLatestArticlesCommand extends Command
{
- private FinderInterface $finder;
- private CacheItemPoolInterface $articlesCache;
- private ParameterBagInterface $params;
- private RedisCacheService $redisCacheService;
-
public function __construct(
- FinderInterface $finder,
- CacheItemPoolInterface $articlesCache,
- ParameterBagInterface $params,
- RedisCacheService $redisCacheService
- )
- {
+ private readonly ArticleRepository $articleRepository,
+ private readonly RedisCacheService $redisCacheService,
+ private readonly RedisViewStore $viewStore,
+ private readonly RedisViewFactory $viewFactory,
+ ) {
parent::__construct();
- $this->finder = $finder;
- $this->articlesCache = $articlesCache;
- $this->params = $params;
- $this->redisCacheService = $redisCacheService;
+ }
+
+ protected function configure(): void
+ {
+ $this->addOption(
+ 'limit',
+ 'l',
+ InputOption::VALUE_OPTIONAL,
+ 'Maximum number of articles to cache',
+ 50
+ );
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
- $env = $this->params->get('kernel.environment');
- $cacheKey = 'latest_articles_list_' . $env;
- $cacheItem = $this->articlesCache->getItem($cacheKey);
+ $limit = (int) $input->getOption('limit');
+ // Define excluded pubkeys (bots, spam accounts)
$key = new Key();
$excludedPubkeys = [
$key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot)
$key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot)
$key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot)
$key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot)
- $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying)
- $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed 𝚋𝚘𝚝 (Just annoying)
+ $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz
+ $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace
$key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW
- $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs, job offers feed
+ $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs
$key->convertToHex('npub1sztw66ap7gdrwyaag7js8m3h0vxw9uv8k0j68t5sngjmsjgpqx9sm9wyaq'), // Now Playing bot
-
];
- // if (!$cacheItem->isHit()) {
- $boolQuery = new BoolQuery();
- $boolQuery->addMustNot(new Terms('pubkey', $excludedPubkeys));
-
- $query = new Query($boolQuery);
- $query->setSize(50);
- $query->setSort(['createdAt' => ['order' => 'desc']]);
-
- $collapse = new Collapse();
- $collapse->setFieldname('slug');
- $query->setCollapse($collapse);
-
- $collapse2 = new Collapse();
- $collapse2->setFieldname('pubkey');
- $query->setCollapse($collapse2);
-
- $articles = $this->finder->find($query);
-
- // Pre-fetch and cache author metadata for all articles
- $authorPubkeys = [];
- foreach ($articles as $article) {
- // Debug: check what type of object we have
- if ($article instanceof \App\Entity\Article) {
- $pubkey = $article->getPubkey();
- if ($pubkey && NostrKeyUtil::isHexPubkey($pubkey)) {
- $authorPubkeys[] = $pubkey;
- }
- } elseif (is_object($article)) {
- // Elastica result object
- if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) {
- $authorPubkeys[] = $article->pubkey;
- } elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) {
- $authorPubkeys[] = NostrKeyUtil::npubToHex($article->npub);
- }
- }
+ $output->writeln('Querying database for latest articles...');
+
+ // Query database directly - get latest published articles, one per author
+ // Using DQL to get the most recent article per pubkey
+ $qb = $this->articleRepository->createQueryBuilder('a');
+ $qb->where('a.publishedAt IS NOT NULL')
+ ->andWhere('a.slug IS NOT NULL')
+ ->andWhere('a.title IS NOT NULL')
+ ->andWhere($qb->expr()->notIn('a.pubkey', ':excludedPubkeys'))
+ ->setParameter('excludedPubkeys', $excludedPubkeys)
+ ->orderBy('a.createdAt', 'DESC')
+ ->setMaxResults($limit * 2); // Get more initially, will dedupe by author
+
+ /** @var Article[] $allArticles */
+ $allArticles = $qb->getQuery()->getResult();
+
+ $output->writeln(sprintf('Found %d articles from database', count($allArticles)));
+
+ // Deduplicate: Keep only the most recent article per author
+ $articlesByAuthor = [];
+ foreach ($allArticles as $article) {
+ $pubkey = $article->getPubkey();
+ if (!$pubkey) {
+ continue;
+ }
+
+ // Keep first (most recent) article per author
+ if (!isset($articlesByAuthor[$pubkey])) {
+ $articlesByAuthor[$pubkey] = $article;
+ }
+ }
+
+ // Take only the requested limit
+ $articles = array_slice($articlesByAuthor, 0, $limit);
+
+ $output->writeln(sprintf('Selected %d articles (one per author)', count($articles)));
+
+ if (empty($articles)) {
+ $output->writeln('No articles found matching criteria');
+ return Command::FAILURE;
+ }
+
+ // Collect author pubkeys for metadata fetching
+ $authorPubkeys = array_keys($articlesByAuthor);
+
+ $output->writeln(sprintf('Fetching metadata for %d authors...', count($authorPubkeys)));
+ $authorsMetadata = $this->redisCacheService->getMultipleMetadata($authorPubkeys);
+ $output->writeln(sprintf('✓ Fetched %d author profiles', count($authorsMetadata)));
+
+ // Build Redis view objects - we now have exact Article entities!
+ $output->writeln('Building Redis view objects...');
+ $baseObjects = [];
+
+ foreach ($articles as $article) {
+ $authorMeta = $authorsMetadata[$article->getPubkey()] ?? null;
+
+ try {
+ $baseObject = $this->viewFactory->articleBaseObject($article, $authorMeta);
+ $baseObjects[] = $baseObject;
+ } catch (\Exception $e) {
+ $output->writeln(sprintf(
+ 'Failed to build view object for article %s: %s',
+ $article->getSlug(),
+ $e->getMessage()
+ ));
}
- $authorPubkeys = array_unique($authorPubkeys);
-
- $output->writeln('Pre-fetching metadata for ' . count($authorPubkeys) . ' authors...');
- $authorsMetadata = $this->redisCacheService->getMultipleMetadata($authorPubkeys);
- $output->writeln('Fetched ' . count($authorsMetadata) . ' author profiles.');
-
- $cacheItem->set($articles);
- $cacheItem->expiresAfter(3600); // Cache for 1 hour
- $this->articlesCache->save($cacheItem);
- $output->writeln('Cached ' . count($articles) . ' articles with author metadata.');
-// } else {
-// $output->writeln('Cache already exists for key: ' . $cacheKey . '');
-// }
+ }
+
+ if (empty($baseObjects)) {
+ $output->writeln('No view objects created');
+ return Command::FAILURE;
+ }
+
+ // Store to Redis views
+ $output->writeln('Storing to Redis...');
+ $this->viewStore->storeLatestArticles($baseObjects);
+
+ $output->writeln('');
+ $output->writeln(sprintf('✓ Successfully cached %d articles to Redis views', count($baseObjects)));
+ $output->writeln(' Key: view:articles:latest');
return Command::SUCCESS;
}
diff --git a/src/Command/CacheLatestHighlightsCommand.php b/src/Command/CacheLatestHighlightsCommand.php
new file mode 100644
index 0000000..7bcea87
--- /dev/null
+++ b/src/Command/CacheLatestHighlightsCommand.php
@@ -0,0 +1,148 @@
+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("Fetching latest {$limit} highlights from database...");
+
+ try {
+ // Get latest highlights with their articles
+ $highlightsWithArticles = $this->highlightRepository->findLatestWithArticles($limit);
+ $output->writeln(sprintf('Found %d highlights', count($highlightsWithArticles)));
+
+ if (empty($highlightsWithArticles)) {
+ $output->writeln('No highlights found in database');
+ 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('Pre-fetching metadata for %d unique authors...', count($pubkeys)));
+ $metadataMap = $this->redisCacheService->getMultipleMetadata($pubkeys);
+ $output->writeln(sprintf('Fetched %d author profiles', count($metadataMap)));
+
+ // Build Redis view objects
+ $output->writeln('Building Redis view objects...');
+ $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('Skipped %d highlights (missing articles or errors)', $skipped));
+ }
+
+ // Store to Redis views
+ if (!empty($baseObjects)) {
+ $this->viewStore->storeLatestHighlights($baseObjects);
+ $output->writeln(sprintf('✓ Stored %d highlights to Redis views (view:highlights:latest)', count($baseObjects)));
+ } else {
+ $output->writeln('No valid highlights to cache');
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+
+ } catch (\Exception $e) {
+ $output->writeln(sprintf('Error: %s', $e->getMessage()));
+ $this->logger->error('Failed to cache highlights', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+ return Command::FAILURE;
+ }
+ }
+}
+
diff --git a/src/Command/ProcessArticleHtmlCommand.php b/src/Command/ProcessArticleHtmlCommand.php
new file mode 100644
index 0000000..c9fd789
--- /dev/null
+++ b/src/Command/ProcessArticleHtmlCommand.php
@@ -0,0 +1,126 @@
+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;
+ }
+}
+
diff --git a/src/Command/SubscribeLocalRelayCommand.php b/src/Command/SubscribeLocalRelayCommand.php
new file mode 100644
index 0000000..2f9cd1f
--- /dev/null
+++ b/src/Command/SubscribeLocalRelayCommand.php
@@ -0,0 +1,126 @@
+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] 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] ✓> 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] ⚠> Skipped invalid event: %s',
+ date('Y-m-d H:i:s'),
+ $e->getMessage()
+ ));
+ } catch (\Exception $e) {
+ // Database or other errors
+ $io->writeln(sprintf(
+ '[%s] ✗> 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;
+ }
+ }
+}
+
diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php
index 3a43673..5dceaf6 100644
--- a/src/Controller/ArticleController.php
+++ b/src/Controller/ArticleController.php
@@ -128,8 +128,8 @@ class ArticleController extends AbstractController
$slug,
EntityManagerInterface $entityManager,
RedisCacheService $redisCacheService,
- CacheItemPoolInterface $articlesCache,
Converter $converter,
+ LoggerInterface $logger,
HighlightService $highlightService
): Response
{
@@ -143,12 +143,23 @@ class ArticleController extends AbstractController
if (!$article) {
throw $this->createNotFoundException('The article could not be found');
}
- $cacheKey = 'article_' . $article->getEventId();
- $cacheItem = $articlesCache->getItem($cacheKey);
- if (!$cacheItem->isHit()) {
- $cacheItem->set($converter->convertToHTML($article->getContent()));
- $articlesCache->save($cacheItem);
+
+ // Use cached processedHtml from database if available
+ $htmlContent = $article->getProcessedHtml();
+ $logger->info('Article content retrieval', [
+ 'article_id' => $article->getId(),
+ 'slug' => $article->getSlug(),
+ 'pubkey' => $article->getPubkey(),
+ 'has_cached_html' => $htmlContent !== null
+ ]);
+
+ if (!$htmlContent) {
+ // Fall back to converting on-the-fly and save for future requests
+ $htmlContent = $converter->convertToHTML($article->getContent());
+ $article->setProcessedHtml($htmlContent);
+ $entityManager->flush();
}
+
$author = $redisCacheService->getMetadata($article->getPubkey());
$canEdit = false;
$user = $this->getUser();
@@ -170,7 +181,7 @@ class ArticleController extends AbstractController
'article' => $article,
'author' => $author,
'npub' => $npub,
- 'content' => $cacheItem->get(),
+ 'content' => $htmlContent,
'canEdit' => $canEdit,
'canonical' => $canonical,
'highlights' => $highlights
diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php
index d0eb2d7..8f8aee7 100644
--- a/src/Controller/AuthorController.php
+++ b/src/Controller/AuthorController.php
@@ -232,26 +232,67 @@ class AuthorController extends AbstractController
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
#[Route('/p/{npub}/articles', name: 'author-articles', requirements: ['npub' => '^npub1.*'])]
public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder,
- MessageBusInterface $messageBus): Response
+ MessageBusInterface $messageBus, RedisViewStore $viewStore,
+ RedisViewFactory $viewFactory): Response
{
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
$author = $redisCacheService->getMetadata($pubkey);
- // Get articles using Elasticsearch with collapse on slug
- $boolQuery = new BoolQuery();
- $boolQuery->addMust(new Term(['pubkey' => $pubkey]));
- $query = new \Elastica\Query($boolQuery);
- $query->setSort(['createdAt' => ['order' => 'desc']]);
- $collapse = new Collapse();
- $collapse->setFieldname('slug');
- $query->setCollapse($collapse);
- $articles = $finder->find($query);
+ // Try to get cached view first
+ $cachedArticles = $viewStore->fetchUserArticles($pubkey);
+ $fromCache = false;
+
+ if ($cachedArticles !== null) {
+ // Redis view data already matches template - just extract articles
+ $articles = [];
+ foreach ($cachedArticles as $baseObject) {
+ if (isset($baseObject['article'])) {
+ $articles[] = (object) $baseObject['article'];
+ }
+ }
+ $fromCache = true;
+ } else {
+ // Cache miss - query from Elasticsearch
+ $boolQuery = new BoolQuery();
+ $boolQuery->addMust(new Term(['pubkey' => $pubkey]));
+ $query = new \Elastica\Query($boolQuery);
+ $query->setSort(['createdAt' => ['order' => 'desc']]);
+ $collapse = new Collapse();
+ $collapse->setFieldname('slug');
+ $query->setCollapse($collapse);
+ $articles = $finder->find($query);
+
+ // Build and cache Redis views for next time
+ if (!empty($articles)) {
+ try {
+ $baseObjects = [];
+ foreach ($articles as $article) {
+ if ($article instanceof Article) {
+ $baseObjects[] = $viewFactory->articleBaseObject($article, $author);
+ }
+ }
+ if (!empty($baseObjects)) {
+ $viewStore->storeUserArticles($pubkey, $baseObjects);
+ }
+ } catch (\Exception $e) {
+ // Log but don't fail the request
+ error_log('Failed to cache user articles view: ' . $e->getMessage());
+ }
+ }
+ }
// Get latest createdAt for dispatching fetch message
if (!empty($articles)) {
- $latest = $articles[0]->getCreatedAt()->getTimestamp();
+ // Handle both Article entities and cached arrays
+ if (is_array($articles[0])) {
+ $latest = isset($articles[0]['article']['publishedAt'])
+ ? strtotime($articles[0]['article']['publishedAt'])
+ : time();
+ } else {
+ $latest = $articles[0]->getCreatedAt()->getTimestamp();
+ }
// Dispatch async message to fetch new articles since latest + 1
$messageBus->dispatch(new FetchAuthorArticlesMessage($pubkey, $latest + 1));
} else {
@@ -266,6 +307,7 @@ class AuthorController extends AbstractController
'pubkey' => $pubkey,
'articles' => $articles,
'is_author_profile' => true,
+ 'from_cache' => $fromCache,
]);
}
diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php
index 9d93b49..82609ae 100644
--- a/src/Controller/DefaultController.php
+++ b/src/Controller/DefaultController.php
@@ -9,6 +9,7 @@ use App\Entity\Event;
use App\Enum\KindsEnum;
use App\Service\NostrClient;
use App\Service\RedisCacheService;
+use App\Service\RedisViewStore;
use App\Util\CommonMark\Converter;
use App\Util\ForumTopics;
use App\Util\NostrKeyUtil;
@@ -32,7 +33,6 @@ use Psr\Log\LoggerInterface;
class DefaultController extends AbstractController
{
-
/**
* @throws Exception
*/
@@ -58,67 +58,89 @@ class DefaultController extends AbstractController
public function discover(
FinderInterface $finder,
RedisCacheService $redisCacheService,
+ RedisViewStore $viewStore,
CacheItemPoolInterface $articlesCache
): Response
{
- $env = $this->getParameter('kernel.environment');
- // Reuse previous latest list cache key to show same set as old 'latest'
- $cacheKey = 'latest_articles_list_' . $env;
- $cacheItem = $articlesCache->getItem($cacheKey);
-
- if (!$cacheItem->isHit()) {
- // Fallback: run query now if command hasn't populated cache yet
- set_time_limit(300);
- ini_set('max_execution_time', '300');
-
- $key = new Key();
- $excludedPubkeys = [
- $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'),
- $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'),
- $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'),
- $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'),
- $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'),
- $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'),
- $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'),
- $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'),
- ];
-
- $boolQuery = new BoolQuery();
- $boolQuery->addMustNot(new Query\Terms('pubkey', $excludedPubkeys));
- $query = new Query($boolQuery);
- $query->setSize(50);
- $query->setSort(['createdAt' => ['order' => 'desc']]);
- $collapseSlug = new Collapse();
- $collapseSlug->setFieldname('slug');
- $query->setCollapse($collapseSlug);
- $articles = $finder->find($query);
- $cacheItem->set($articles);
- $cacheItem->expiresAfter(3600); // 1 hour to match command cache duration
- $articlesCache->save($cacheItem);
- }
+ // Fast path: Try to get from Redis views first (single GET)
+ $cachedView = $viewStore->fetchLatestArticles();
+ $fromCache = false;
+
+ if ($cachedView !== null) {
+ // Redis view data already matches template expectations!
+ // Just extract articles and profiles - NO MAPPING NEEDED
+ $articles = [];
+ $authorsMetadata = [];
+
+ foreach ($cachedView as $baseObject) {
+ if (isset($baseObject['article'])) {
+ $articles[] = (object) $baseObject['article']; // Cast to object for template
+ }
+ if (isset($baseObject['profiles'])) {
+ foreach ($baseObject['profiles'] as $pubkey => $profile) {
+ $authorsMetadata[$pubkey] = (object) $profile; // Cast to object for template
+ }
+ }
+ }
+ $fromCache = true;
+ } else {
+ // Fallback path: Use old cache system if Redis view not available
+ $env = $this->getParameter('kernel.environment');
+ $cacheKey = 'latest_articles_list_' . $env;
+ $cacheItem = $articlesCache->getItem($cacheKey);
+
+ if (!$cacheItem->isHit()) {
+ // Fallback: run query now if command hasn't populated cache yet
+ set_time_limit(300);
+ ini_set('max_execution_time', '300');
- $articles = $cacheItem->get();
+ $key = new Key();
+ $excludedPubkeys = [
+ $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'),
+ $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'),
+ $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'),
+ $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'),
+ $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'),
+ $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'),
+ $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'),
+ $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'),
+ ];
+
+ $boolQuery = new BoolQuery();
+ $boolQuery->addMustNot(new Query\Terms('pubkey', $excludedPubkeys));
+ $query = new Query($boolQuery);
+ $query->setSize(50);
+ $query->setSort(['createdAt' => ['order' => 'desc']]);
+ $collapseSlug = new Collapse();
+ $collapseSlug->setFieldname('slug');
+ $query->setCollapse($collapseSlug);
+ $articles = $finder->find($query);
+ $cacheItem->set($articles);
+ $cacheItem->expiresAfter(3600);
+ $articlesCache->save($cacheItem);
+ } else {
+ $articles = $cacheItem->get();
+ }
- // Fetch author metadata - this is now much faster because
- // metadata is pre-cached by the CacheLatestArticlesCommand
- $authorPubkeys = [];
- foreach ($articles as $article) {
- if ($article instanceof \App\Entity\Article) {
- $pubkey = $article->getPubkey();
- if ($pubkey && NostrKeyUtil::isHexPubkey($pubkey)) {
- $authorPubkeys[] = $pubkey;
- }
- } elseif (is_object($article)) {
- // Elastica result object fallback
- if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) {
- $authorPubkeys[] = $article->pubkey;
- } elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) {
- $authorPubkeys[] = NostrKeyUtil::npubToHex($article->npub);
+ // Fetch author metadata for fallback path
+ $authorPubkeys = [];
+ foreach ($articles as $article) {
+ if ($article instanceof \App\Entity\Article) {
+ $pubkey = $article->getPubkey();
+ if ($pubkey && NostrKeyUtil::isHexPubkey($pubkey)) {
+ $authorPubkeys[] = $pubkey;
+ }
+ } elseif (is_object($article)) {
+ if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) {
+ $authorPubkeys[] = $article->pubkey;
+ } elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) {
+ $authorPubkeys[] = NostrKeyUtil::npubToHex($article->npub);
+ }
}
}
+ $authorPubkeys = array_unique($authorPubkeys);
+ $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys);
}
- $authorPubkeys = array_unique($authorPubkeys);
- $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys);
// Build main topics key => display name map from ForumTopics constant
$mainTopicsMap = [];
@@ -131,6 +153,7 @@ class DefaultController extends AbstractController
'articles' => $articles,
'authorsMetadata' => $authorsMetadata,
'mainTopicsMap' => $mainTopicsMap,
+ 'from_redis_view' => $fromCache,
]);
}
@@ -140,52 +163,85 @@ class DefaultController extends AbstractController
#[Route('/latest-articles', name: 'latest_articles')]
public function latestArticles(
RedisCacheService $redisCacheService,
- NostrClient $nostrClient
+ NostrClient $nostrClient,
+ RedisViewStore $viewStore
): Response
{
- set_time_limit(300); // 5 minutes
- ini_set('max_execution_time', '300');
-
- // Direct feed: always fetch fresh from relay, no caching
+ // Define excluded pubkeys (needed for template)
$key = new Key();
$excludedPubkeys = [
$key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot)
$key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot)
$key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot)
$key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot)
- $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying)
- $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed bot
+ $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz
+ $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace
$key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW
$key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs
];
- // Fetch raw latest articles (limit 50) directly from relay
- $articles = $nostrClient->getLatestLongFormArticles(50);
+ // Fast path: Try Redis cache first (single GET - super fast!)
+ $cachedView = $viewStore->fetchLatestArticles();
+
+ if ($cachedView !== null) {
+ // Use cached articles - already filtered and with metadata
+ $articles = [];
+ $authorsMetadata = [];
- // Filter out excluded pubkeys
- $articles = array_filter($articles, function($article) use ($excludedPubkeys) {
- if (method_exists($article, 'getPubkey')) {
- return !in_array($article->getPubkey(), $excludedPubkeys, true);
+ foreach ($cachedView as $baseObject) {
+ if (isset($baseObject['article'])) {
+ $articles[] = (object) $baseObject['article'];
+ }
+ if (isset($baseObject['profiles'])) {
+ foreach ($baseObject['profiles'] as $pubkey => $profile) {
+ $authorsMetadata[$pubkey] = (object) $profile;
+ }
+ }
}
- return true;
- });
- // Collect author pubkeys for metadata
- $authorPubkeys = [];
- foreach ($articles as $article) {
- if (method_exists($article, 'getPubkey')) {
- $authorPubkeys[] = $article->getPubkey();
- } elseif (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) {
- $authorPubkeys[] = $article->pubkey;
+ $fromCache = true;
+ } else {
+ // Cache miss: Fetch fresh from relay (slower but ensures we have data)
+ set_time_limit(300);
+ ini_set('max_execution_time', '300');
+
+
+ try {
+ // Fetch fresh from relay
+ $articles = $nostrClient->getLatestLongFormArticles(50);
+
+ // Filter out excluded pubkeys
+ $articles = array_filter($articles, function($article) use ($excludedPubkeys) {
+ if (method_exists($article, 'getPubkey')) {
+ return !in_array($article->getPubkey(), $excludedPubkeys, true);
+ }
+ return true;
+ });
+
+ // Collect author pubkeys for metadata
+ $authorPubkeys = [];
+ foreach ($articles as $article) {
+ if (method_exists($article, 'getPubkey')) {
+ $authorPubkeys[] = $article->getPubkey();
+ } elseif (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) {
+ $authorPubkeys[] = $article->pubkey;
+ }
+ }
+ $authorPubkeys = array_unique($authorPubkeys);
+ $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys);
+
+ $fromCache = false;
+ } catch (\Exception $e) {
+ // Relay fetch failed and no cache available
+ throw $e;
}
}
- $authorPubkeys = array_unique($authorPubkeys);
- $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys);
return $this->render('pages/latest-articles.html.twig', [
'articles' => $articles,
'newsBots' => array_slice($excludedPubkeys, 0, 4),
- 'authorsMetadata' => $authorsMetadata
+ 'authorsMetadata' => $authorsMetadata,
+ 'from_redis_view' => $fromCache ?? false,
]);
}
diff --git a/src/Entity/Article.php b/src/Entity/Article.php
index 2feaf55..972f787 100644
--- a/src/Entity/Article.php
+++ b/src/Entity/Article.php
@@ -36,6 +36,10 @@ class Article
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $content = null;
+
+ #[ORM\Column(type: Types::TEXT, nullable: true)]
+ private ?string $processedHtml = null;
+
#[ORM\Column(nullable: true, enumType: KindsEnum::class)]
private ?KindsEnum $kind = null;
@@ -130,6 +134,18 @@ class Article
return $this;
}
+ public function getProcessedHtml(): ?string
+ {
+ return $this->processedHtml;
+ }
+
+ public function setProcessedHtml(?string $processedHtml): static
+ {
+ $this->processedHtml = $processedHtml;
+
+ return $this;
+ }
+
public function getKind(): ?KindsEnum
{
return $this->kind;
diff --git a/src/ReadModel/RedisView/RedisArticleView.php b/src/ReadModel/RedisView/RedisArticleView.php
new file mode 100644
index 0000000..da90393
--- /dev/null
+++ b/src/ReadModel/RedisView/RedisArticleView.php
@@ -0,0 +1,26 @@
+
+ */
+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 $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 = [],
+ ) {}
+}
+
diff --git a/src/ReadModel/RedisView/RedisHighlightView.php b/src/ReadModel/RedisView/RedisHighlightView.php
new file mode 100644
index 0000000..27732df
--- /dev/null
+++ b/src/ReadModel/RedisView/RedisHighlightView.php
@@ -0,0 +1,20 @@
+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'] ?? [],
+ );
+ }
+}
+
diff --git a/src/Repository/HighlightRepository.php b/src/Repository/HighlightRepository.php
index 63069b8..be13a6a 100644
--- a/src/Repository/HighlightRepository.php
+++ b/src/Repository/HighlightRepository.php
@@ -94,5 +94,74 @@ class HighlightRepository extends ServiceEntityRepository
->getQuery()
->execute();
}
+
+ /**
+ * Get latest highlights across all articles
+ * @param int $limit Maximum number of highlights to return
+ * @return array
+ */
+ public function findLatest(int $limit = 50): array
+ {
+ return $this->createQueryBuilder('h')
+ ->orderBy('h.createdAt', 'DESC')
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult();
+ }
+
+ /**
+ * Get latest highlights with their corresponding articles
+ * Uses a join or separate query to efficiently fetch both
+ * @param int $limit Maximum number of highlights to return
+ * @return array
+ */
+ public function findLatestWithArticles(int $limit = 50): array
+ {
+ // First get the highlights
+ $highlights = $this->findLatest($limit);
+
+ // Extract article coordinates and fetch corresponding articles
+ $coordinates = array_unique(array_map(
+ fn(Highlight $h) => $h->getArticleCoordinate(),
+ $highlights
+ ));
+
+ // Query articles by their coordinates
+ $articles = [];
+ if (!empty($coordinates)) {
+ $em = $this->getEntityManager();
+ $articleRepo = $em->getRepository(\App\Entity\Article::class);
+
+ // Build article coordinate map (kind:pubkey:identifier format)
+ foreach ($coordinates as $coordinate) {
+ // Parse coordinate: 30023:pubkey:identifier
+ $parts = explode(':', $coordinate, 3);
+ if (count($parts) === 3) {
+ [, $pubkey, $identifier] = $parts;
+
+ // Find article by pubkey and slug (identifier)
+ $article = $articleRepo->findOneBy([
+ 'pubkey' => $pubkey,
+ 'slug' => $identifier,
+ ]);
+
+ if ($article) {
+ $articles[$coordinate] = $article;
+ }
+ }
+ }
+ }
+
+ // Combine highlights with their articles
+ $result = [];
+ foreach ($highlights as $highlight) {
+ $result[] = [
+ 'highlight' => $highlight,
+ 'article' => $articles[$highlight->getArticleCoordinate()] ?? null,
+ ];
+ }
+
+ return $result;
+ }
}
diff --git a/src/Service/ArticleEventProjector.php b/src/Service/ArticleEventProjector.php
new file mode 100644
index 0000000..9cb8f6f
--- /dev/null
+++ b/src/Service/ArticleEventProjector.php
@@ -0,0 +1,113 @@
+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;
+ }
+ }
+}
+
diff --git a/src/Service/NostrRelayPool.php b/src/Service/NostrRelayPool.php
index de4559a..c9aedd5 100644
--- a/src/Service/NostrRelayPool.php
+++ b/src/Service/NostrRelayPool.php
@@ -3,8 +3,12 @@
namespace App\Service;
use Psr\Log\LoggerInterface;
+use swentel\nostr\Filter\Filter;
+use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\RelayResponse\RelayResponse;
+use swentel\nostr\Subscription\Subscription;
+use Symfony\Component\HttpClient\Exception\TimeoutException;
use WebSocket\Exception\Exception;
/**
@@ -224,7 +228,7 @@ class NostrRelayPool
continue;
}
- $relayResponse = \swentel\nostr\RelayResponse\RelayResponse::create($decoded);
+ $relayResponse = RelayResponse::create($decoded);
$relayResponses[] = $relayResponse;
// Check for EOSE (End of Stored Events) or CLOSED
@@ -405,4 +409,221 @@ class NostrRelayPool
return $relayUrls;
}
+
+ /**
+ * Subscribe to local relay for article events with long-lived connection
+ * This method blocks indefinitely and calls the callback for each received article event
+ *
+ * @param callable $onArticleEvent Callback function that receives (object $event, string $relayUrl)
+ * @throws \Exception|\Throwable If local relay is not configured or connection fails
+ */
+ public function subscribeLocalArticles(callable $onArticleEvent): void
+ {
+ if (!$this->nostrDefaultRelay) {
+ throw new \Exception('Local relay not configured. Set NOSTR_DEFAULT_RELAY environment variable.');
+ }
+
+ $relayUrl = $this->normalizeRelayUrl($this->nostrDefaultRelay);
+
+ $this->logger->info('Starting long-lived subscription to local relay for articles', [
+ 'relay' => $relayUrl,
+ 'kind' => 30023
+ ]);
+
+ // Get relay connection
+ $relay = $this->getRelay($relayUrl);
+
+ // Ensure relay is connected
+ if (!$relay->isConnected()) {
+ $relay->connect();
+ }
+
+ $client = $relay->getClient();
+
+ // Use reasonable timeout like TweakedRequest does (15 seconds per receive call)
+ // This allows for proper WebSocket handshake and message handling
+ $client->setTimeout(15);
+
+ // Create subscription for article events (kind 30023)
+ $subscription = new Subscription();
+ $subscriptionId = $subscription->setId();
+
+ $filter = new Filter();
+ $filter->setKinds([30023]); // Longform article events only
+
+ $requestMessage = new RequestMessage($subscriptionId, [$filter]);
+ $payload = $requestMessage->generate();
+
+ $this->logger->info('Sending REQ to local relay', [
+ 'relay' => $relayUrl,
+ 'subscription_id' => $subscriptionId,
+ 'filter' => ['kinds' => [30023]]
+ ]);
+
+ // Send the subscription request
+ $client->text($payload);
+
+ $this->logger->info('Entering infinite receive loop for article events...');
+
+ // Track if we've received EOSE
+ $eoseReceived = false;
+
+ // Infinite loop: receive and process messages
+ while (true) {
+ try {
+ $resp = $client->receive();
+
+ // Handle PING/PONG for keepalive (following TweakedRequest pattern)
+ if ($resp instanceof \WebSocket\Message\Ping) {
+ $client->text((new \WebSocket\Message\Pong())->getPayload());
+ $this->logger->debug('Received PING, sent PONG');
+ continue;
+ }
+
+ // Only process text messages
+ if (!($resp instanceof \WebSocket\Message\Text)) {
+ continue;
+ }
+
+ $content = $resp->getContent();
+ $decoded = json_decode($content); // Decode as object for RelayResponse compatibility
+
+ if (!$decoded) {
+ $this->logger->debug('Failed to decode message from relay', [
+ 'relay' => $relayUrl,
+ 'content_preview' => substr($content, 0, 100)
+ ]);
+ continue;
+ }
+
+ // Parse relay response
+ $relayResponse = RelayResponse::create($decoded);
+
+ // Handle different response types
+ switch ($relayResponse->type) {
+ case 'EVENT':
+ // This is an article event - process it
+ if ($relayResponse instanceof \swentel\nostr\RelayResponse\RelayResponseEvent) {
+ $event = $relayResponse->event;
+
+ $this->logger->info('Received article event from relay', [
+ 'relay' => $relayUrl,
+ 'event_id' => $event->id ?? 'unknown',
+ 'kind' => $event->kind ?? 'unknown',
+ 'pubkey' => substr($event->pubkey ?? 'unknown', 0, 16) . '...'
+ ]);
+
+ // Call the callback with the event
+ try {
+ $onArticleEvent($event, $relayUrl);
+ } catch (\Throwable $e) {
+ // Log callback errors but don't break the loop
+ $this->logger->error('Error in article event callback', [
+ 'event_id' => $event->id ?? 'unknown',
+ 'error' => $e->getMessage(),
+ 'exception' => get_class($e)
+ ]);
+ }
+ }
+ break;
+
+ case 'EOSE':
+ // End of stored events - all historical events received
+ // Unlike TweakedRequest, we DON'T close the subscription or disconnect
+ // We want to keep listening for new live events
+ $eoseReceived = true;
+ $this->logger->info('Received EOSE - all stored events processed, now listening for new events', [
+ 'relay' => $relayUrl,
+ 'subscription_id' => $subscriptionId
+ ]);
+ // Continue the loop - this is the key difference from short-lived requests
+ break;
+
+ case 'CLOSED':
+ // Subscription closed by relay
+ // Decode as array to access message by index
+ $decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true);
+ $this->logger->warning('Relay closed subscription', [
+ 'relay' => $relayUrl,
+ 'subscription_id' => $subscriptionId,
+ 'message' => $decodedArray[2] ?? 'no message'
+ ]);
+ throw new \Exception('Subscription closed by relay: ' . ($decodedArray[2] ?? 'no message'));
+
+ case 'NOTICE':
+ // Notice from relay - check if it's an error
+ $decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true);
+ $message = $decodedArray[1] ?? 'no message';
+ if (str_starts_with($message, 'ERROR:')) {
+ $this->logger->error('Received ERROR NOTICE from relay', [
+ 'relay' => $relayUrl,
+ 'message' => $message
+ ]);
+ throw new \Exception('Relay error: ' . $message);
+ }
+ $this->logger->info('Received NOTICE from relay', [
+ 'relay' => $relayUrl,
+ 'message' => $message
+ ]);
+ break;
+
+ case 'OK':
+ // Command result (usually for EVENT, not REQ)
+ $this->logger->debug('Received OK from relay', [
+ 'relay' => $relayUrl,
+ 'message' => json_encode($decoded)
+ ]);
+ break;
+
+ case 'AUTH':
+ // NIP-42 auth challenge - log but don't handle for now
+ // Most relays won't require auth for reading public events
+ $decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true);
+ $this->logger->info('Received AUTH challenge from relay (ignoring for read-only subscription)', [
+ 'relay' => $relayUrl,
+ 'challenge' => $decodedArray[1] ?? 'no challenge'
+ ]);
+ break;
+
+ default:
+ $this->logger->debug('Received unknown message type from relay', [
+ 'relay' => $relayUrl,
+ 'type' => $relayResponse->type ?? 'unknown',
+ 'content' => json_encode($decoded)
+ ]);
+ break;
+ }
+
+ } catch (TimeoutException $e) {
+ // Timeout is expected when no new events arrive
+ // Just continue the loop to keep listening
+ if ($eoseReceived) {
+ $this->logger->debug('WebSocket timeout (normal - waiting for new events)', [
+ 'relay' => $relayUrl
+ ]);
+ }
+ continue;
+ } catch (Exception $e) {
+ // WebSocket errors - log and rethrow to allow Docker restart
+ $this->logger->error('WebSocket error in subscription loop', [
+ 'relay' => $relayUrl,
+ 'error' => $e->getMessage(),
+ 'exception' => get_class($e)
+ ]);
+ throw $e;
+ } catch (\Throwable $e) {
+ // Unexpected errors - log and rethrow
+ $this->logger->error('Unexpected error in subscription loop', [
+ 'relay' => $relayUrl,
+ 'error' => $e->getMessage(),
+ 'exception' => get_class($e)
+ ]);
+ throw $e;
+ }
+ }
+
+ // This line should never be reached (infinite loop above)
+ // But if it is, log it
+ $this->logger->warning('Subscription loop exited unexpectedly');
+ }
}
diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php
index 153ac66..efbf093 100644
--- a/src/Service/RedisCacheService.php
+++ b/src/Service/RedisCacheService.php
@@ -15,15 +15,25 @@ use swentel\nostr\Key\Key;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Cache\ItemInterface;
-readonly class RedisCacheService
+class RedisCacheService
{
+ private ?RedisViewStore $viewStore = null;
+
public function __construct(
- private NostrClient $nostrClient,
- private CacheItemPoolInterface $npubCache,
- private EntityManagerInterface $entityManager,
- private LoggerInterface $logger
+ private readonly NostrClient $nostrClient,
+ private readonly CacheItemPoolInterface $npubCache,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly LoggerInterface $logger
) {}
+ /**
+ * Inject RedisViewStore (using setter to avoid circular dependency)
+ */
+ public function setViewStore(RedisViewStore $viewStore): void
+ {
+ $this->viewStore = $viewStore;
+ }
+
/**
* Generate the cache key for user metadata (hex pubkey only).
*/
@@ -487,5 +497,35 @@ readonly class RedisCacheService
}
}
+ /**
+ * Invalidate Redis views that contain this profile
+ * Called when profile metadata (kind 0) is updated
+ *
+ * @param string $pubkey Hex pubkey whose profile was updated
+ */
+ public function invalidateProfileViews(string $pubkey): void
+ {
+ if ($this->viewStore === null) {
+ return; // ViewStore not injected, skip invalidation
+ }
+
+ try {
+ // Invalidate user's articles view
+ $this->viewStore->invalidateUserArticles($pubkey);
+
+ // For now, we invalidate all latest views since we don't track which profiles are in them
+ // In future, this could be more selective
+ $this->viewStore->invalidateAll();
+
+ $this->logger->debug('Invalidated Redis views for profile update', [
+ 'pubkey' => $pubkey,
+ ]);
+ } catch (\Exception $e) {
+ $this->logger->error('Failed to invalidate Redis views', [
+ 'pubkey' => $pubkey,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
}
diff --git a/src/Service/RedisViewStore.php b/src/Service/RedisViewStore.php
new file mode 100644
index 0000000..5952617
--- /dev/null
+++ b/src/Service/RedisViewStore.php
@@ -0,0 +1,234 @@
+ $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 $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 $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 $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
+ }
+}
+