From 9f1dddc2423ea5e1ba05a530d12ccb95080ca8f9 Mon Sep 17 00:00:00 2001 From: Juri Ehret Date: Sun, 3 May 2026 16:31:14 +0200 Subject: [PATCH] Phase 14e-2: domain-event listeners (file cleanup + cache invalidation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scriptor-side companion to 14e-1 (iManager PSR-14 dispatcher + storage emits). Container now wires a sync dispatcher on top of the in-memory subscriber provider, hands it to SqliteStorage, and subscribes two listeners: Scriptor\Boot\Events\ItemFileCleanupListener - Subscribes to ItemDeleted. - Walks FileRepository::findByItem() while the row is still reachable (Plan §14e contract: dispatcher fires before the SQL DELETE so the FK cascade hasn't dropped the metadata yet) and asks FileStorage to drop each asset. - Also scrubs every x_ thumbnail under /thumbnail/ via FileStorage::absolutePath() + scandir, matching the upload convention (suffix `_` plus a `\d+x\d+_` prefix). Tolerates missing dirs and unreadable siblings. Scriptor\Boot\Events\PageCacheInvalidationListener - Subscribes to ItemCreated, ItemUpdated, ItemDeleted. - Filters by category id (Pages only — Users mutations don't invalidate the rendered-HTML cache). - Calls Cache::clear() on the FilesystemCache. BasicTheme keys cache entries by md5(host + REQUEST_URI), which we can't recreate at event-time, so this is a global flush of the section cache until the cache learns tags / per-page prefixes. ImanagerBootstrap: - Registers SubscriberListenerProvider (typed bind) + ListenerProviderInterface (alias) + EventDispatcherInterface (SyncEventDispatcher composing the provider). - SqliteStorage is now built with the dispatcher. - wireDomainEventListeners() subscribes both listeners through the provider; listener instances are constructed lazily on first fire, and the cache-invalidator caches its watched-category id after the first lookup so it doesn't re-resolve on every event. composer.lock: pulls in psr/event-dispatcher 1.0.0 (transitively through bigins/imanager 14e-1). Manual smoke (PHP built-in server, fresh PNG fixture): POST /editor/pages/edit/ → 302; new item id=11 created POST /editor/api/upload → 200; fileId=5, asset + 300x300 thumbnail on disk Frontend hit → 1 cache file GET /editor/pages/delete/?… → 302 Verifications after delete: items row gone (0) files row gone (0; FK cascade) asset 14e2.png gone (ItemFileCleanupListener) thumbnail/300x300_14e2.png gone (ItemFileCleanupListener) cache files 0 (PageCacheInvalidationListener) --- boot/Events/ItemFileCleanupListener.php | 83 +++++++++++++++++++ boot/Events/PageCacheInvalidationListener.php | 51 ++++++++++++ boot/ImanagerBootstrap.php | 76 ++++++++++++++++- composer.lock | 53 +++++++++++- 4 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 boot/Events/ItemFileCleanupListener.php create mode 100644 boot/Events/PageCacheInvalidationListener.php diff --git a/boot/Events/ItemFileCleanupListener.php b/boot/Events/ItemFileCleanupListener.php new file mode 100644 index 0000000..2a35d17 --- /dev/null +++ b/boot/Events/ItemFileCleanupListener.php @@ -0,0 +1,83 @@ +/thumbnail/x_` (the + * convention shared by Frontend\ImageUrlBuilder and the upload + * endpoint) — we walk the thumbnail directory so this works for any + * thumbnail size, not just the ones we currently generate. + */ +final readonly class ItemFileCleanupListener +{ + public function __construct( + private FileRepository $files, + private FileStorage $storage, + ) {} + + public function __invoke(ItemDeleted $event): void + { + $files = $this->files->findByItem($event->itemId); + if ($files === []) { + return; + } + + foreach ($files as $file) { + // Delete the asset itself. + if ($this->storage->exists($file->path)) { + $this->storage->delete($file->path); + } + // Plus every thumbnail size that was ever generated for it. + $this->purgeThumbnails($file->path, $file->name); + } + } + + /** + * Walks `/thumbnail/` and removes every `x_` entry + * for the given filename. Tolerates a missing directory (no + * thumbnails generated yet) and unreadable / non-image siblings. + */ + private function purgeThumbnails(string $assetPath, string $name): void + { + $thumbDirRel = \dirname($assetPath) . '/thumbnail'; + $thumbDirAbs = $this->storage->absolutePath($thumbDirRel); + if (! is_dir($thumbDirAbs)) { + return; + } + $entries = scandir($thumbDirAbs); + if ($entries === false) { + return; + } + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + // Match `x_` exactly so we never delete a sibling + // file uploaded with a different stem. + if (! preg_match('/^\d+x\d+_/', $entry) || ! str_ends_with($entry, '_' . $name)) { + continue; + } + $thumbRel = $thumbDirRel . '/' . $entry; + if ($this->storage->exists($thumbRel)) { + $this->storage->delete($thumbRel); + } + } + } +} diff --git a/boot/Events/PageCacheInvalidationListener.php b/boot/Events/PageCacheInvalidationListener.php new file mode 100644 index 0000000..8e8fef8 --- /dev/null +++ b/boot/Events/PageCacheInvalidationListener.php @@ -0,0 +1,51 @@ +extractCategoryId($event); + if ($categoryId === null || $categoryId !== $this->watchedCategoryId) { + return; + } + $this->cache->clear(); + } + + private function extractCategoryId(DomainEvent $event): ?int + { + return match (true) { + $event instanceof ItemCreated => $event->item->categoryId, + $event instanceof ItemUpdated => $event->current->categoryId, + $event instanceof ItemDeleted => $event->categoryId, + default => null, + }; + } +} diff --git a/boot/ImanagerBootstrap.php b/boot/ImanagerBootstrap.php index 547362d..b1adf6f 100644 --- a/boot/ImanagerBootstrap.php +++ b/boot/ImanagerBootstrap.php @@ -6,6 +6,8 @@ use Imanager\Bootstrap; use Imanager\Cache\FilesystemCache; +use Imanager\Events\SubscriberListenerProvider; +use Imanager\Events\SyncEventDispatcher; use Imanager\Field\FieldTypeRegistry; use Imanager\Field\Types\ArrayListFieldType; use Imanager\Field\Types\CheckboxFieldType; @@ -41,6 +43,10 @@ use Imanager\Storage\Storage; use Imanager\Validation\Sanitizer; use League\Container\Container; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\ListenerProviderInterface; +use Scriptor\Boot\Events\ItemFileCleanupListener; +use Scriptor\Boot\Events\PageCacheInvalidationListener; /** * Wires the iManager 2.0 service graph for use inside Scriptor. @@ -81,8 +87,22 @@ public static function create(string $scriptorRoot, array $paths = []): Containe $container->addShared(Sanitizer::class, static fn(): Sanitizer => new Sanitizer()); + // 14e: PSR-14 dispatcher + listener provider. The same provider + // instance is reachable via both interfaces so listeners can + // subscribe through the typed bind, while storage and other + // dispatch sites use the dispatcher. + $container->addShared(SubscriberListenerProvider::class, static fn(): SubscriberListenerProvider + => new SubscriberListenerProvider()); + $container->addShared(ListenerProviderInterface::class, static fn(): ListenerProviderInterface + => $container->get(SubscriberListenerProvider::class)); + $container->addShared(EventDispatcherInterface::class, static fn(): EventDispatcherInterface + => new SyncEventDispatcher($container->get(ListenerProviderInterface::class))); + $container->addShared(SqliteStorage::class, static fn(): SqliteStorage - => new SqliteStorage($container->get(\PDO::class))); + => new SqliteStorage( + $container->get(\PDO::class), + $container->get(EventDispatcherInterface::class), + )); $container->addShared(Storage::class, static fn(): Storage => $container->get(SqliteStorage::class)); @@ -114,6 +134,10 @@ public static function create(string $scriptorRoot, array $paths = []): Containe $container->addShared(Csrf::class, static fn(): Csrf => new Csrf($container->get(SessionStore::class), maxTokens: 10)); + // 14e domain-event listeners. Side effects fire in-process from + // SqliteStorage save/delete (synchronous PSR-14 dispatch). + self::wireDomainEventListeners($container); + $container->addShared(FieldTypeRegistry::class, static function () use ($container): FieldTypeRegistry { $registry = new FieldTypeRegistry(); $sanitizer = $container->get(Sanitizer::class); @@ -142,4 +166,54 @@ public static function create(string $scriptorRoot, array $paths = []): Containe private function __construct() { } + + /** + * Subscribes Phase 14e listeners on the container's + * {@see SubscriberListenerProvider}. Listener instantiation is + * deferred until the first matching event fires, so a request + * that doesn't touch any item never even constructs the listeners. + */ + private static function wireDomainEventListeners(Container $container): void + { + /** @var SubscriberListenerProvider $provider */ + $provider = $container->get(SubscriberListenerProvider::class); + + $provider->subscribe( + \Imanager\Domain\Event\ItemDeleted::class, + static function (\Imanager\Domain\Event\ItemDeleted $event) use ($container): void { + /** @var ItemFileCleanupListener $listener */ + static $listener = null; + if ($listener === null) { + $listener = new ItemFileCleanupListener( + $container->get(FileRepository::class), + $container->get(FileStorage::class), + ); + } + $listener($event); + }, + ); + + // Cache-invalidation: only when a Pages-category item mutates. + // Resolve the watched id lazily so the listener factory doesn't + // hit the DB on every request — we resolve once and then keep + // the same listener instance. + $cacheInvalidator = static function (object $event) use ($container): void { + /** @var PageCacheInvalidationListener|null $listener */ + static $listener = null; + if ($listener === null) { + $pages = $container->get(CategoryRepository::class)->findBySlug('pages'); + if ($pages === null || $pages->id === null) { + return; // no Pages category — nothing to invalidate + } + $listener = new PageCacheInvalidationListener( + $container->get(FilesystemCache::class), + $pages->id, + ); + } + $listener($event); + }; + $provider->subscribe(\Imanager\Domain\Event\ItemCreated::class, $cacheInvalidator); + $provider->subscribe(\Imanager\Domain\Event\ItemUpdated::class, $cacheInvalidator); + $provider->subscribe(\Imanager\Domain\Event\ItemDeleted::class, $cacheInvalidator); + } } diff --git a/composer.lock b/composer.lock index 87ea26d..7e4ae36 100644 --- a/composer.lock +++ b/composer.lock @@ -12,7 +12,7 @@ "dist": { "type": "path", "url": "../imanager", - "reference": "995d3bdefd78e620304504c4c84a4828468aae9c" + "reference": "1c28b7e0bd68be277c5e87f8bad61d67a0c10c96" }, "require": { "erusev/parsedown": "^1.7", @@ -26,6 +26,7 @@ "league/container": "^4.2", "nikic/php-parser": "^5.0", "php": "^8.2", + "psr/event-dispatcher": "^1.0", "psr/log": "^3.0", "psr/simple-cache": "^3.0", "symfony/console": "^6.4 || ^7.0" @@ -557,6 +558,56 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/log", "version": "3.0.2",