From 6dfeac90ec790c0b6ccb71294f6e6510bf35bcd8 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 11:24:15 +0200 Subject: [PATCH 01/15] feat: record a url -> tags dependency graph while pages render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for config-free invalidation. Records only: the existing config-driven invalidator is still authoritative, so behaviour is unchanged. Pages are keyed on the absolute URL at the moment they enter the static cache, which is the only point that knows both the canonical URL and that the page is really being cached — and it is reached identically by the file and application drivers, so full and half measure need no separate handling. The cachers are subclassed rather than decorated. Statamic's cache middleware branches on `instanceof ApplicationCacher`, `FileCacher`, `NullCacher` and `AbstractCacher`; a wrapper around the Cacher binding would silently change which responses get cached and break exclusion checks. Registering through StaticCacheManager::extend() also means Statamic hands us the same fully merged strategy config its own createXDriver() methods receive, so exclusions, query string handling and locale are not reconstructed here. Storage defaults to a dedicated sqlite file the addon owns, registered as its own connection. A Statamic site is commonly flat-file with DB_CONNECTION unset, and invalidation must not depend on the host app having provisioned a database. It sits next to Statamic's own static cache bookkeeping so the graph and the cache share a directory, and a deploy discarding one discards both. A database driver is available for sites that would rather keep the graph in their app database; identity there is a sha1 of the URL, since MySQL cannot index TEXT without a prefix length and a prefix index would make the unique constraint wrong for URLs sharing a long path. Recording is best effort — a failed graph write logs and continues rather than 500ing a visitor's page. A missing row leaves the URL untracked, which the safety net clears on the next save, so the failure mode is over-invalidation rather than stale content. Pages exceeding the tag cap collapse to a single overflow tag and are treated as depending on everything, which is lossy in the same safe direction. Observability ships with the mechanism, since a graph you cannot inspect is worse than config you can read: `why` shows what a page depends on, `stats` reports coverage against the static cache, and `doctor` exits non-zero when invalidation cannot work, so a broken environment fails a deploy instead of quietly serving stale pages. Co-Authored-By: Claude Opus 5 --- config/cache_invalidation.php | 165 ++++-------------- ...create_static_cache_dependencies_table.php | 35 ++++ src/CachedUrls.php | 50 ++++++ src/Cachers/RecordsDependencies.php | 50 ++++++ src/Cachers/TrackingApplicationCacher.php | 15 ++ src/Cachers/TrackingFileCacher.php | 15 ++ src/Console/DoctorCommand.php | 103 +++++++++++ src/Console/StatsCommand.php | 70 ++++++++ src/Console/WhyCommand.php | 76 ++++++++ src/Graph/DatabaseGraph.php | 37 ++++ src/Graph/DependencyGraph.php | 49 ++++++ src/Graph/NullGraph.php | 39 +++++ src/Graph/SqlGraph.php | 119 +++++++++++++ src/Graph/SqliteGraph.php | 76 ++++++++ src/Http/AddCacheTagsHeader.php | 50 ++++++ src/Recording/DependencyRecorder.php | 140 +++++++++++++++ src/ServiceProvider.php | 135 +++++++++++++- src/Tag.php | 59 +++++++ 18 files changed, 1145 insertions(+), 138 deletions(-) create mode 100644 database/migrations/2026_08_06_000000_create_static_cache_dependencies_table.php create mode 100644 src/CachedUrls.php create mode 100644 src/Cachers/RecordsDependencies.php create mode 100644 src/Cachers/TrackingApplicationCacher.php create mode 100644 src/Cachers/TrackingFileCacher.php create mode 100644 src/Console/DoctorCommand.php create mode 100644 src/Console/StatsCommand.php create mode 100644 src/Console/WhyCommand.php create mode 100644 src/Graph/DatabaseGraph.php create mode 100644 src/Graph/DependencyGraph.php create mode 100644 src/Graph/NullGraph.php create mode 100644 src/Graph/SqlGraph.php create mode 100644 src/Graph/SqliteGraph.php create mode 100644 src/Http/AddCacheTagsHeader.php create mode 100644 src/Recording/DependencyRecorder.php create mode 100644 src/Tag.php diff --git a/config/cache_invalidation.php b/config/cache_invalidation.php index edd0466..afdb91e 100644 --- a/config/cache_invalidation.php +++ b/config/cache_invalidation.php @@ -6,172 +6,69 @@ /* |-------------------------------------------------------------------------- - | Pagebuilder collections + | Dependency graph driver |-------------------------------------------------------------------------- | - | Only entries from these collections are scanned when determining which - | cached page URLs to clear. List collections whose entries have a - | pagebuilder replicator field. + | Invalidation is driven by a url -> tags graph recorded while pages render. + | The graph has to share a lifetime with the static cache and be visible to + | every process that writes or clears it. If the cache outlives the graph, + | lookups stop matching and pages go stale; if web and worker processes see + | different copies, invalidation clears nothing. | - */ - - 'pagebuilder_collections' => [ - 'pages', - ], - - /* - |-------------------------------------------------------------------------- - | Globals that flush the entire static cache - |-------------------------------------------------------------------------- - | - | Use this for globals rendered in shared layout or SEO output. - | - */ - - 'globals_flush_all' => [ - 'redirects', - ], - - /* - |-------------------------------------------------------------------------- - | Navigations that flush the entire static cache - |-------------------------------------------------------------------------- - */ - - 'navs_flush_all' => [ - 'navigation', - ], - - /* - |-------------------------------------------------------------------------- - | Collection trees that flush the entire static cache - |-------------------------------------------------------------------------- - | - | Saving a collection tree always clears the block index, because a move - | changes entry URLs and the index is keyed on them. List a collection here - | as well when its tree drives shared output — a nav or breadcrumbs built - | from the page tree, say — since reordering it changes every cached page - | and no block rule can express that. Empty by default. - | - */ - - 'collection_trees_flush_all' => [ - // - ], - - /* - |-------------------------------------------------------------------------- - | Flush the entire static cache when a form blueprint is saved - |-------------------------------------------------------------------------- - | - | A form blueprint change alters the fields rendered by every page that - | embeds that form, and those pages cannot be resolved from the block - | index, so the whole cache is flushed. - | - */ - - 'forms_flush_all' => true, - - /* - |-------------------------------------------------------------------------- - | Globals that target pagebuilder block types - |-------------------------------------------------------------------------- - | - | Format: 'global_handle' => ['block_type', ...] - | - */ - - 'global_target_blocks' => [ - // - ], - - /* - |-------------------------------------------------------------------------- - | Globals that clear explicit URLs - |-------------------------------------------------------------------------- - | - | Format: 'global_handle' => ['/url', ...] - | - */ - - 'global_urls' => [ - // - ], - - /* - |-------------------------------------------------------------------------- - | Collection entry rules - |-------------------------------------------------------------------------- - | - | Rule without field: ['block' => 'block_type'] - | Rule with field: ['block' => 'block_type', 'field' => 'field_handle'] - | Flush all cached URLs for a collection: 'collection' => 'all' + | Supported drivers: | - | The two rules above resolve pages through the block index, so they only - | reach what a pagebuilder block renders. For relations rendered by a - | collection's own template there are two more: + | "sqlite" Default. Needs nothing from the host app: the addon registers + | its own connection and creates the file on first write, so it + | works on sites with no DB_CONNECTION configured. Correct for + | single-server deploys, which is where a file-backed static + | cache works in the first place. | - | Parent page: ['parent' => true] - | Clears the saved entry's parent page. For a structured collection - | whose parent template lists its children. - | Opt-in per collection, not automatic: in a collection mounted at the - | site root a top-level entry's parent is the root itself, so applying - | this everywhere would clear the home page on every save. + | "database" Uses the application's database. Pick this when the app already + | has one and you would rather keep the graph there. Requires + | `php artisan migrate`. | - | Referencing entries: ['collection' => 'handle', 'field' => 'field_handle'] - | Clears the URL of every entry in that collection whose field - | references the saved entry — the inverse of a block rule. Use it when - | the referencing markup is in a template rather than a block, e.g. an - | article detail page rendering its author from the employees - | collection. - | This walks the named collection on each save of the source - | collection, so keep an eye on it for very large collections. + | "null" Records nothing. Every invalidation then falls back to clearing + | any cached URL the graph does not know about, which means the + | whole cache. Useful to rule the addon out while debugging. | */ - 'collection_entry_rules' => [ - 'reusable_blocks' => [ - ['block' => 'reusable_block', 'field' => 'entry'], - ], - ], + 'driver' => env('CACHE_INVALIDATION_DRIVER', 'sqlite'), /* |-------------------------------------------------------------------------- - | Collections that clear explicit URLs + | Sqlite driver path |-------------------------------------------------------------------------- | - | Format: 'collection_handle' => ['/overview', ...] + | Kept next to Statamic's own static cache bookkeeping so the graph and the + | cache share a directory, and a deploy that discards one discards both. | */ - 'collection_urls' => [ - // - ], + 'sqlite_path' => storage_path('statamic/cache-invalidation.sqlite'), /* |-------------------------------------------------------------------------- - | Taxonomies that target pagebuilder block types + | Database driver connection |-------------------------------------------------------------------------- | - | Format: 'taxonomy_handle' => ['block_type', ...] + | Null uses the application's default connection. | */ - 'taxonomy_target_blocks' => [ - // - ], + 'database_connection' => null, /* |-------------------------------------------------------------------------- - | Taxonomies that clear explicit URLs + | Debug |-------------------------------------------------------------------------- | - | Format: 'taxonomy_handle' => ['/overview', ...] + | Adds an X-Cache-Tags header to responses that are about to be cached, so + | you can read a page's recorded dependencies in devtools. Cached hits do + | not carry the header — the render that produced the cache entry does. | */ - 'taxonomy_urls' => [ - // - ], + 'debug' => env('CACHE_INVALIDATION_DEBUG', false), ]; diff --git a/database/migrations/2026_08_06_000000_create_static_cache_dependencies_table.php b/database/migrations/2026_08_06_000000_create_static_cache_dependencies_table.php new file mode 100644 index 0000000..16d86a2 --- /dev/null +++ b/database/migrations/2026_08_06_000000_create_static_cache_dependencies_table.php @@ -0,0 +1,35 @@ +char('url_hash', 40); + $table->text('url'); + $table->string('tag', 191); + + $table->primary(['url_hash', 'tag']); + $table->index('tag'); + }); + } + + public function down(): void + { + Schema::dropIfExists(DatabaseGraph::TABLE); + } +}; diff --git a/src/CachedUrls.php b/src/CachedUrls.php new file mode 100644 index 0000000..3de8c8c --- /dev/null +++ b/src/CachedUrls.php @@ -0,0 +1,50 @@ + + */ + public function all(): array + { + if (! $this->cacher instanceof AbstractCacher) { + return []; + } + + $domains = $this->cacher->getDomains(); + + if ($domains->isEmpty()) { + $domains = collect([$this->cacher->getBaseUrl()]); + } + + return $domains + ->flatMap(fn (string $domain): array => $this->cacher + ->getUrls($domain) + ->map(fn (string $url): string => $domain.$url) + ->values() + ->all()) + ->filter() + ->unique() + ->values() + ->all(); + } +} diff --git a/src/Cachers/RecordsDependencies.php b/src/Cachers/RecordsDependencies.php new file mode 100644 index 0000000..8733df7 --- /dev/null +++ b/src/Cachers/RecordsDependencies.php @@ -0,0 +1,50 @@ +getUrl($request); + + // Mirrors the parent's own early return. An excluded URL is never + // cached, so it must not gain a graph row either. + if ($this->isExcluded($url)) { + return; + } + + // Deliberately no reset() here. While handling an error the middleware + // caches the shared error URL and then the real URL within one request, + // and both rows need the full tag set. + try { + app(DependencyGraph::class)->record($url, app(DependencyRecorder::class)->tags()); + } catch (Throwable $e) { + // Recording is best effort: a visitor's page render must not fail + // because the graph could not be written. A missing row leaves the + // URL untracked, which the safety net clears on the next save, so + // the failure mode is over-invalidation rather than stale content. + Log::warning('Could not record cache dependencies for ['.$url.']: '.$e->getMessage()); + } + } +} diff --git a/src/Cachers/TrackingApplicationCacher.php b/src/Cachers/TrackingApplicationCacher.php new file mode 100644 index 0000000..eee8794 --- /dev/null +++ b/src/Cachers/TrackingApplicationCacher.php @@ -0,0 +1,15 @@ +line(''); + + if (! config('statamic.static_caching.strategy') || $cacher instanceof NullCacher) { + $this->components->info('Static caching is disabled. Nothing to invalidate.'); + + return self::SUCCESS; + } + + $this->check('Static caching strategy', (string) config('statamic.static_caching.strategy'), true); + + $driver = (string) config('cache_invalidation.driver'); + $this->check('Graph driver', $driver, $driver !== ''); + + if ($graph instanceof NullGraph) { + $this->components->warn('The null driver records nothing, so every save clears the entire cache.'); + } + + try { + $graph->stats(); + $this->check('Graph reachable', 'yes', true); + } catch (Throwable $e) { + $this->check('Graph reachable', $e->getMessage(), false); + $failed = true; + } + + if ($driver === 'sqlite') { + $loaded = extension_loaded('pdo_sqlite'); + $this->check('pdo_sqlite', $loaded ? 'loaded' : 'missing', $loaded); + $failed = $failed || ! $loaded; + + $path = (string) config('cache_invalidation.sqlite_path'); + $writable = is_writable(is_file($path) ? $path : dirname($path)); + $this->check('Sqlite path writable', $path, $writable); + $failed = $failed || ! $writable; + } + + if (! $failed) { + $cachedUrls = $cached->all(); + $untracked = count(array_diff($cachedUrls, $graph->urls())); + + $this->check( + 'Cached URLs tracked', + sprintf('%d of %d', count($cachedUrls) - $untracked, count($cachedUrls)), + true, + ); + + if ($untracked > 0) { + $this->components->warn( + $untracked.' cached URL(s) are untracked and will be cleared by any save until re-rendered. ' + .'Expected right after a deploy or a full flush.' + ); + } + } + + $this->line(''); + + if ($failed) { + $this->components->error('Cache invalidation cannot work on this environment.'); + + return self::FAILURE; + } + + $this->components->info('Cache invalidation is ready.'); + + return self::SUCCESS; + } + + private function check(string $label, string $value, bool $ok): void + { + $this->components->twoColumnDetail( + $label, + ($ok ? '' : '').$value.($ok ? '' : ''), + ); + } +} diff --git a/src/Console/StatsCommand.php b/src/Console/StatsCommand.php new file mode 100644 index 0000000..0736645 --- /dev/null +++ b/src/Console/StatsCommand.php @@ -0,0 +1,70 @@ +stats(); + $cachedUrls = $cached->all(); + $trackedUrls = $graph->urls(); + + $untracked = array_values(array_diff($cachedUrls, $trackedUrls)); + $orphaned = array_values(array_diff($trackedUrls, $cachedUrls)); + + $this->line(''); + $this->components->twoColumnDetail('Driver', (string) config('cache_invalidation.driver')); + $this->components->twoColumnDetail('Cached URLs', (string) count($cachedUrls)); + $this->components->twoColumnDetail('Tracked URLs', (string) $stats['urls']); + $this->components->twoColumnDetail('Distinct tags', (string) $stats['tags']); + $this->components->twoColumnDetail('Rows', (string) $stats['rows']); + $this->components->twoColumnDetail( + 'Tags per URL (avg)', + $stats['urls'] > 0 ? (string) round($stats['rows'] / $stats['urls'], 1) : '0', + ); + $this->line(''); + + if ($untracked !== []) { + $this->components->warn(sprintf( + '%d cached URL(s) have no recorded dependencies. Each is cleared by any save until re-rendered.', + count($untracked), + )); + + foreach (array_slice($untracked, 0, 10) as $url) { + $this->line(' '.$url); + } + + if (count($untracked) > 10) { + $this->line(sprintf(' … and %d more', count($untracked) - 10)); + } + + $this->line(''); + } + + if ($orphaned !== []) { + $this->components->info(sprintf( + '%d tracked URL(s) are no longer cached. Harmless; pruned as they are invalidated.', + count($orphaned), + )); + $this->line(''); + } + + if ($untracked === [] && $cachedUrls !== []) { + $this->components->info('Every cached URL has recorded dependencies.'); + $this->line(''); + } + + return self::SUCCESS; + } +} diff --git a/src/Console/WhyCommand.php b/src/Console/WhyCommand.php new file mode 100644 index 0000000..8c0b13e --- /dev/null +++ b/src/Console/WhyCommand.php @@ -0,0 +1,76 @@ +argument('url'); + + // Statamic keys the cache on the normalised request URL, which may or may + // not carry a trailing slash depending on the site. Try both rather than + // reporting "not found" for a URL that is plainly cached. + $variant = str_ends_with($url, '/') ? rtrim($url, '/') : $url.'/'; + + $tags = $graph->tagsFor($url); + + if ($tags === [] && $variant !== '') { + $tags = $graph->tagsFor($variant); + } + + $allCached = $cached->all(); + $isCached = in_array($url, $allCached, true) || in_array($variant, $allCached, true); + + $this->line(''); + $this->line(" URL {$url}"); + $this->line(' Cached '.($isCached ? 'yes' : 'no')); + $this->line(''); + + if ($tags === []) { + $this->components->warn( + $isCached + ? 'Cached but untracked. It will be cleared by the next save of anything, until it is rendered again.' + : 'Not in the graph. Render the page once so its dependencies are recorded.' + ); + + return self::SUCCESS; + } + + if (in_array(Tag::OVERFLOW, $tags, true)) { + $this->components->warn('This page exceeded the tag cap and is treated as depending on everything.'); + $this->line(''); + } + + $grouped = []; + + foreach ($tags as $tag) { + $grouped[str_contains($tag, ':') ? strtok($tag, ':') : 'other'][] = $tag; + } + + ksort($grouped); + + foreach ($grouped as $group => $groupTags) { + $this->line(" {$group} (".count($groupTags).')'); + + foreach ($groupTags as $tag) { + $this->line(' '.$tag); + } + + $this->line(''); + } + + return self::SUCCESS; + } +} diff --git a/src/Graph/DatabaseGraph.php b/src/Graph/DatabaseGraph.php new file mode 100644 index 0000000..0977924 --- /dev/null +++ b/src/Graph/DatabaseGraph.php @@ -0,0 +1,37 @@ +db->connection($this->connection); + } + + protected function table(): string + { + return self::TABLE; + } +} diff --git a/src/Graph/DependencyGraph.php b/src/Graph/DependencyGraph.php new file mode 100644 index 0000000..e6078f8 --- /dev/null +++ b/src/Graph/DependencyGraph.php @@ -0,0 +1,49 @@ + tags graph recorded while pages render. + * + * URLs are absolute throughout, matching Cacher::getUrl(). Statamic stores its + * own URL list relative to the domain, so anything comparing the two has to + * re-prefix; see CachedUrls. + */ +interface DependencyGraph +{ + /** + * Replace the recorded tag set for a URL. An empty set removes the URL from + * the graph entirely, which leaves it untracked and therefore cleared by the + * next invalidation rather than assumed to depend on nothing. + * + * @param list $tags + */ + public function record(string $url, array $tags): void; + + /** + * @param list $tags + * @return list + */ + public function urlsFor(array $tags): array; + + /** + * @return list + */ + public function tagsFor(string $url): array; + + public function forget(string $url): void; + + /** + * @return list + */ + public function urls(): array; + + public function flush(): void; + + /** + * @return array{urls: int, tags: int, rows: int} + */ + public function stats(): array; +} diff --git a/src/Graph/NullGraph.php b/src/Graph/NullGraph.php new file mode 100644 index 0000000..13fc336 --- /dev/null +++ b/src/Graph/NullGraph.php @@ -0,0 +1,39 @@ + 0, 'tags' => 0, 'rows' => 0]; + } +} diff --git a/src/Graph/SqlGraph.php b/src/Graph/SqlGraph.php new file mode 100644 index 0000000..7cfe728 --- /dev/null +++ b/src/Graph/SqlGraph.php @@ -0,0 +1,119 @@ +normalize($tags); + $hash = $this->hash($url); + + $this->connection()->transaction(function () use ($url, $hash, $tags): void { + $this->query()->where('url_hash', $hash)->delete(); + + foreach (array_chunk($tags, self::CHUNK) as $chunk) { + $this->query()->insertOrIgnore(array_map( + fn (string $tag): array => ['url_hash' => $hash, 'url' => $url, 'tag' => $tag], + $chunk, + )); + } + }); + } + + public function urlsFor(array $tags): array + { + if (($tags = $this->normalize($tags)) === []) { + return []; + } + + $urls = []; + + foreach (array_chunk($tags, self::CHUNK) as $chunk) { + foreach ($this->query()->whereIn('tag', $chunk)->distinct()->pluck('url') as $url) { + $urls[$url] = true; + } + } + + return array_keys($urls); + } + + public function tagsFor(string $url): array + { + return $this->query() + ->where('url_hash', $this->hash($url)) + ->orderBy('tag') + ->pluck('tag') + ->all(); + } + + public function forget(string $url): void + { + $this->query()->where('url_hash', $this->hash($url))->delete(); + } + + public function urls(): array + { + return $this->query()->distinct()->orderBy('url')->pluck('url')->all(); + } + + public function flush(): void + { + $this->query()->delete(); + } + + public function stats(): array + { + return [ + 'urls' => $this->query()->distinct()->count('url_hash'), + 'tags' => $this->query()->distinct()->count('tag'), + 'rows' => $this->query()->count(), + ]; + } + + protected function query(): Builder + { + return $this->connection()->table($this->table()); + } + + protected function hash(string $url): string + { + return sha1($url); + } + + /** + * @param list $tags + * @return list + */ + private function normalize(array $tags): array + { + return array_values(array_unique(array_filter( + $tags, + static fn (string $tag): bool => $tag !== '', + ))); + } +} diff --git a/src/Graph/SqliteGraph.php b/src/Graph/SqliteGraph.php new file mode 100644 index 0000000..2cb3e12 --- /dev/null +++ b/src/Graph/SqliteGraph.php @@ -0,0 +1,76 @@ +ensureSchema(); + + return $this->db->connection(self::CONNECTION); + } + + protected function table(): string + { + return 'dependencies'; + } + + /** + * Runs once per process. CREATE ... IF NOT EXISTS rather than a migration so + * that installing the addon requires no artisan step. + */ + private function ensureSchema(): void + { + if ($this->ready) { + return; + } + + if (! is_dir($directory = dirname($this->path))) { + mkdir($directory, 0755, true); + } + + // Laravel's sqlite connector resolves the path with realpath() and throws + // when the file is absent, so the file has to exist before we connect. + if (! is_file($this->path)) { + touch($this->path); + } + + $connection = $this->db->connection(self::CONNECTION); + + $connection->statement( + 'CREATE TABLE IF NOT EXISTS dependencies (' + .'url_hash TEXT NOT NULL, url TEXT NOT NULL, tag TEXT NOT NULL, ' + .'PRIMARY KEY (url_hash, tag)' + .') WITHOUT ROWID' + ); + + $connection->statement( + 'CREATE INDEX IF NOT EXISTS dependencies_tag_index ON dependencies (tag)' + ); + + $this->ready = true; + } +} diff --git a/src/Http/AddCacheTagsHeader.php b/src/Http/AddCacheTagsHeader.php new file mode 100644 index 0000000..c7b4647 --- /dev/null +++ b/src/Http/AddCacheTagsHeader.php @@ -0,0 +1,50 @@ +tags(); + $total = count($tags); + + if ($total > self::MAX_TAGS) { + $tags = array_slice($tags, 0, self::MAX_TAGS); + $tags[] = sprintf('… (%d total)', $total); + } + + $response->headers->set('X-Cache-Tags', implode(' ', $tags)); + + return $response; + } +} diff --git a/src/Recording/DependencyRecorder.php b/src/Recording/DependencyRecorder.php new file mode 100644 index 0000000..a92ad84 --- /dev/null +++ b/src/Recording/DependencyRecorder.php @@ -0,0 +1,140 @@ + */ + private array $tags = []; + + private bool $overflowed = false; + + public function add(string ...$tags): void + { + if ($this->overflowed) { + return; + } + + foreach ($tags as $tag) { + if ($tag === '' || isset($this->tags[$tag])) { + continue; + } + + if (count($this->tags) >= self::MAX_TAGS) { + $this->overflow(); + + return; + } + + $this->tags[$tag] = true; + } + } + + /** + * @param iterable $ids + */ + public function entries(iterable $ids): void + { + foreach ($ids as $id) { + if (is_string($id) && $id !== '') { + $this->add(Tag::entry($id)); + } + } + } + + /** + * @param iterable $handles + */ + public function collections(iterable $handles): void + { + foreach ($handles as $handle) { + if (is_string($handle) && $handle !== '') { + $this->add(Tag::collection($handle)); + } + } + } + + public function term(string $taxonomy, string $slug): void + { + $this->add(Tag::term($taxonomy, $slug)); + } + + /** + * @param iterable $handles + */ + public function taxonomies(iterable $handles): void + { + foreach ($handles as $handle) { + if (is_string($handle) && $handle !== '') { + $this->add(Tag::taxonomy($handle)); + } + } + } + + public function globalSet(string $handle): void + { + $this->add(Tag::globalSet($handle)); + } + + public function form(string $handle): void + { + $this->add(Tag::form($handle)); + } + + /** + * @return list + */ + public function tags(): array + { + return array_keys($this->tags); + } + + public function isEmpty(): bool + { + return $this->tags === []; + } + + public function overflowed(): bool + { + return $this->overflowed; + } + + public function reset(): void + { + $this->tags = []; + $this->overflowed = false; + } + + /** + * Collapse to the overflow tag and stop collecting. Deliberately lossy in + * the safe direction: the page is now cleared by any content save, which + * beats a silently truncated tag set that would leave it stale. + */ + private function overflow(): void + { + $this->tags = [Tag::OVERFLOW => true]; + $this->overflowed = true; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 9fc4371..b3b44c0 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -4,14 +4,43 @@ namespace RoxDigital\CacheInvalidation; +use Illuminate\Database\DatabaseManager; +use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Support\Facades\Event; +use RoxDigital\CacheInvalidation\Cachers\TrackingApplicationCacher; +use RoxDigital\CacheInvalidation\Cachers\TrackingFileCacher; +use RoxDigital\CacheInvalidation\Console\DoctorCommand; +use RoxDigital\CacheInvalidation\Console\StatsCommand; +use RoxDigital\CacheInvalidation\Console\WhyCommand; +use RoxDigital\CacheInvalidation\Graph\DatabaseGraph; +use RoxDigital\CacheInvalidation\Graph\DependencyGraph; +use RoxDigital\CacheInvalidation\Graph\NullGraph; +use RoxDigital\CacheInvalidation\Graph\SqliteGraph; +use RoxDigital\CacheInvalidation\Http\AddCacheTagsHeader; +use RoxDigital\CacheInvalidation\Recording\DependencyRecorder; use Statamic\Events\BlueprintSaved; use Statamic\Events\CollectionTreeSaved; +use Statamic\Facades\StaticCache; use Statamic\Providers\AddonServiceProvider; +use Statamic\StaticCaching\Cachers\Writer; +use Statamic\StaticCaching\StaticCacheManager; class ServiceProvider extends AddonServiceProvider { protected $config = false; + protected $commands = [ + DoctorCommand::class, + StatsCommand::class, + WhyCommand::class, + ]; + + protected $middlewareGroups = [ + 'statamic.web' => [ + AddCacheTagsHeader::class, + ], + ]; + protected $listen = [ BlueprintSaved::class => [ FlushStaticCacheOnFormBlueprintSaved::class, @@ -25,6 +54,106 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/cache_invalidation.php', 'cache_invalidation'); + $this->registerSqliteConnection(); + $this->registerGraph(); + $this->registerRecorder(); + $this->registerTrackingCachers(); + $this->registerInvalidator(); + } + + public function bootAddon(): void + { + $this->publishes([ + __DIR__ . '/../config/cache_invalidation.php' => config_path('cache_invalidation.php'), + ], 'cache-invalidation-config'); + + // The default sqlite driver creates its own schema, so only the opt-in + // database driver has anything for `php artisan migrate` to find. + if ($this->graphDriver() === 'database') { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + } + + /** + * A dedicated connection owned by the addon, so the graph works on a site + * with no DB_CONNECTION configured — which is the common Statamic case. + */ + private function registerSqliteConnection(): void + { + $this->app['config']->set('database.connections.' . SqliteGraph::CONNECTION, [ + 'driver' => 'sqlite', + 'database' => $this->app['config']->get('cache_invalidation.sqlite_path'), + 'prefix' => '', + 'foreign_key_constraints' => false, + 'journal_mode' => 'wal', + 'busy_timeout' => 5000, + ]); + } + + private function registerGraph(): void + { + $this->app->singleton(DependencyGraph::class, fn ($app): DependencyGraph => match ($this->graphDriver()) { + 'database' => new DatabaseGraph( + $app->make(DatabaseManager::class), + $app['config']->get('cache_invalidation.database_connection'), + ), + 'null' => new NullGraph, + default => new SqliteGraph( + $app->make(DatabaseManager::class), + (string) $app['config']->get('cache_invalidation.sqlite_path'), + ), + }); + } + + private function registerRecorder(): void + { + $this->app->singleton(DependencyRecorder::class); + + // In PHP-FPM the singleton's lifetime is the request. A queue worker + // keeps the container alive across jobs, so the tag set has to be cleared + // between them or it would grow until it overflowed. (Octane needs the + // same treatment via its RequestReceived event.) + Event::listen(JobProcessing::class, function (): void { + $this->app->make(DependencyRecorder::class)->reset(); + }); + } + + /** + * Subclasses of the concrete cachers rather than a decorator on the Cacher + * binding: Statamic's cache middleware branches on `instanceof + * ApplicationCacher`, `FileCacher` and `NullCacher`, and a wrapper would + * silently change which responses are cached. + * + * Registered through afterResolving so the custom creators are in place + * before anything calls driver() on the manager. + */ + private function registerTrackingCachers(): void + { + $this->app->afterResolving(StaticCacheManager::class, function (StaticCacheManager $manager): void { + // Statamic's Manager hands custom creators the same fully merged + // config its own createXDriver() methods receive — exclusions, query + // string handling and locale included — so nothing has to be + // reconstructed here. + $manager->extend('application', fn ($app, array $config): TrackingApplicationCacher => new TrackingApplicationCacher( + StaticCache::cacheStore(), + $config, + )); + + $manager->extend('file', fn ($app, array $config): TrackingFileCacher => new TrackingFileCacher( + new Writer($config['permissions'] ?? []), + StaticCache::cacheStore(), + $config, + )); + }); + } + + /** + * Statamic only binds its own invalidator when the config leaves the class + * unset, so claiming the default here is enough. A host app pointing at its + * own subclass keeps it. + */ + private function registerInvalidator(): void + { if ($this->app['config']->get('statamic.static_caching.invalidation.class') === null) { $this->app['config']->set( 'statamic.static_caching.invalidation.class', @@ -50,10 +179,8 @@ public function register(): void } } - public function bootAddon(): void + private function graphDriver(): string { - $this->publishes([ - __DIR__ . '/../config/cache_invalidation.php' => config_path('cache_invalidation.php'), - ], 'cache-invalidation-config'); + return (string) $this->app['config']->get('cache_invalidation.driver', 'sqlite'); } } diff --git a/src/Tag.php b/src/Tag.php new file mode 100644 index 0000000..9d8f800 --- /dev/null +++ b/src/Tag.php @@ -0,0 +1,59 @@ + Date: Thu, 6 Aug 2026 11:28:36 +0200 Subject: [PATCH 02/15] feat: record entry and term reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Still recording only — the config-driven invalidator remains authoritative. Hooks getFilteredKeys() and getItems() rather than get(). getFilteredKeys() is reached by every read path, including count() and pluck(), which bypass get() entirely in Stache\Query\Builder; getItems() is reached only by get() and only with keys that already have limit and offset applied, so item tags describe what was rendered rather than everything that matched. The item-lookup heuristic is the basis for targeting. A query pinned to ids can only be affected by those items, so it records item tags alone; anything else can gain a result once an entry is *created*, whose id is in no tag set yet, so it must also record the list tag for its scope. An empty where clause is explicitly not an id lookup — "everything in this collection" is the broadest list query there is. where('collection', ...) never reaches $wheres, since EntryQueryBuilder intercepts it into $collections first, so it cannot fool the check. Verified against meerdervoort's content: a list query records the collection plus each rendered entry; Entry::find() and whereIn('id') record item tags only; count() and pluck() record the list tag; limit(3) records the collection plus exactly three entries; a taxonomy query records the taxonomy plus each term while Term::find() records the term alone. The term repository has to be replaced wholesale rather than rebound, because TermRepository::query() constructs its builder directly instead of resolving it. Its protected ensureAssociations() still runs — without it taxonomy queries return nothing. Read recorders are registered in boot, not register: Statamic's Stache provider binds EntryQueryBuilder unconditionally during its own register(), so a binding made there would be clobbered if our provider happened to run first. Co-Authored-By: Claude Opus 5 --- src/Recording/DetectsItemLookups.php | 60 +++++++++++++++++++++ src/Recording/TrackingEntryQueryBuilder.php | 55 +++++++++++++++++++ src/Recording/TrackingTermQueryBuilder.php | 46 ++++++++++++++++ src/Recording/TrackingTermRepository.php | 33 ++++++++++++ src/ServiceProvider.php | 32 +++++++++++ 5 files changed, 226 insertions(+) create mode 100644 src/Recording/DetectsItemLookups.php create mode 100644 src/Recording/TrackingEntryQueryBuilder.php create mode 100644 src/Recording/TrackingTermQueryBuilder.php create mode 100644 src/Recording/TrackingTermRepository.php diff --git a/src/Recording/DetectsItemLookups.php b/src/Recording/DetectsItemLookups.php new file mode 100644 index 0000000..e5b732c --- /dev/null +++ b/src/Recording/DetectsItemLookups.php @@ -0,0 +1,60 @@ + 'x', 'field' => 'y']` versus `['block' => 'x']`, including for + * blocks that choose between those modes at runtime. + */ +trait DetectsItemLookups +{ + /** + * Columns that narrow a result set without changing which items could ever + * appear in it. A query filtered only by these is still a list query. + */ + private const NON_DISCRIMINATING = ['id', 'site', 'locale', 'status', 'published']; + + /** + * True only when the query is pinned to specific ids. Note that an empty + * where clause is emphatically *not* an id lookup: "everything in this + * collection" is the broadest list query there is. + */ + protected function isItemLookup(): bool + { + if (empty($this->wheres)) { + return false; + } + + $pinnedToIds = false; + + foreach ($this->wheres as $where) { + // Nested clauses carry no column of their own and imply a query too + // complex to reason about. Treat as a list query. + $column = $where['column'] ?? null; + + if ($column === 'id') { + $pinnedToIds = true; + + continue; + } + + if (! in_array($column, self::NON_DISCRIMINATING, true)) { + return false; + } + } + + return $pinnedToIds; + } +} diff --git a/src/Recording/TrackingEntryQueryBuilder.php b/src/Recording/TrackingEntryQueryBuilder.php new file mode 100644 index 0000000..897f312 --- /dev/null +++ b/src/Recording/TrackingEntryQueryBuilder.php @@ -0,0 +1,55 @@ +isItemLookup()) { + // where('collection', ...) never lands in $wheres — EntryQueryBuilder + // intercepts it into $collections first — so isItemLookup() is not + // fooled by it. An unscoped query spans every collection. + $this->recorder->collections($this->collections ?: Collection::handles()); + } + + return parent::getFilteredKeys(); + } + + protected function getItems($keys) + { + $items = parent::getItems($keys); + + $this->recorder->entries($items->map->id()); + + return $items; + } +} diff --git a/src/Recording/TrackingTermQueryBuilder.php b/src/Recording/TrackingTermQueryBuilder.php new file mode 100644 index 0000000..446886b --- /dev/null +++ b/src/Recording/TrackingTermQueryBuilder.php @@ -0,0 +1,46 @@ +isItemLookup()) { + $this->recorder->taxonomies($this->taxonomies ?: Taxonomy::handles()); + } + + return parent::getFilteredKeys(); + } + + protected function getItems($keys) + { + $items = parent::getItems($keys); + + foreach ($items as $term) { + if (($taxonomy = $term->taxonomyHandle()) && ($slug = $term->slug())) { + $this->recorder->term((string) $taxonomy, (string) $slug); + } + } + + return $items; + } +} diff --git a/src/Recording/TrackingTermRepository.php b/src/Recording/TrackingTermRepository.php new file mode 100644 index 0000000..23c7e0a --- /dev/null +++ b/src/Recording/TrackingTermRepository.php @@ -0,0 +1,33 @@ +ensureAssociations(); + + return new TrackingTermQueryBuilder($this->store, $this->recorder); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b3b44c0..7e020e9 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -18,10 +18,18 @@ use RoxDigital\CacheInvalidation\Graph\SqliteGraph; use RoxDigital\CacheInvalidation\Http\AddCacheTagsHeader; use RoxDigital\CacheInvalidation\Recording\DependencyRecorder; +use RoxDigital\CacheInvalidation\Recording\TrackingEntryQueryBuilder; +use RoxDigital\CacheInvalidation\Recording\TrackingTermRepository; +use Statamic\Contracts\Entries\QueryBuilder as EntryQueryBuilderContract; +use Statamic\Contracts\Taxonomies\TermRepository as TermRepositoryContract; use Statamic\Events\BlueprintSaved; use Statamic\Events\CollectionTreeSaved; use Statamic\Facades\StaticCache; use Statamic\Providers\AddonServiceProvider; +use Statamic\Stache\Query\EntryQueryBuilder; +use Statamic\Stache\Stache; +use Statamic\Stache\Stores\Store; +use Statamic\Statamic; use Statamic\StaticCaching\Cachers\Writer; use Statamic\StaticCaching\StaticCacheManager; @@ -72,6 +80,30 @@ public function bootAddon(): void if ($this->graphDriver() === 'database') { $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); } + + $this->registerReadRecorders(); + } + + /** + * Deliberately in boot rather than register: Statamic's Stache provider binds + * EntryQueryBuilder unconditionally in its own register(), so a binding made + * during register() would be clobbered if our provider happened to run first. + * Boot runs after every register(), and nothing resolves a query builder + * before a request or command is handled. + */ + private function registerReadRecorders(): void + { + $recorder = fn (): DependencyRecorder => $this->app->make(DependencyRecorder::class); + $entries = fn (): Store => $this->app->make(Stache::class)->store('entries'); + + $builder = fn (): TrackingEntryQueryBuilder => new TrackingEntryQueryBuilder($entries(), $recorder()); + + // EntryRepository::query() resolves the contract; the concrete is bound + // too, in case anything resolves it directly. + $this->app->bind(EntryQueryBuilderContract::class, $builder); + $this->app->bind(EntryQueryBuilder::class, $builder); + + Statamic::repository(TermRepositoryContract::class, TrackingTermRepository::class); } /** From 7c49635dde61ba6585ba601e6bda87e9a1f50ca5 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 11:35:38 +0200 Subject: [PATCH 03/15] feat: graph-driven invalidation, behind CACHE_INVALIDATION_GRAPH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the global and form recorders and an invalidator that resolves URLs from the recorded graph. Off by default: the graph can be recorded and inspected with `stats` and `affected` before it decides anything. Globals turn out to be trackable after all, which removes the last reason to keep a config list. The variables store builds its items through app(Variables::class), so a subclass reaches every set, and AbstractAugmented::transientValue() calls get() — so hooking get() alone covers __get, offsetGet, __call and enumeration via toDeferredAugmentedArray(). That last path resolves lazily, which is what makes this worth doing: Statamic hydrates every global set into every view whether a template touches it or not, so tagging at hydration would mark every page as depending on every global. Instead a set used in the layout lands on every page and a set used in one block lands on that block's pages — the distinction globals_flush_all previously had to be told by hand. Forms hook find(), which is how Forms\Fieldtype::augmentValue() resolves them. all() goes through self::find() and so binds to the parent class, which is convenient: it is control panel territory and should not mark a page as depending on every form. Navigation is the one deliberate blunt instrument, per the spec. It clears every cached URL rather than flushing, so nocache regions and the graph survive and pages return without a full re-render storm. Two changes of mind while building: Dropped the planned UrlInvalidated pruning listener. It fires once per URL, so a nav save would become thousands of individual writes on the save path, and it buys no correctness: rows are replaced when a URL is rendered again, and a row for a URL that is no longer cached only leads to invalidating something already gone. StaticCacheCleared does the same job in one DELETE. The addon now claims the invalidator when the configured class is one of its own, not only when the config is null. Sites pin it by name — meerdervoort pins v1's ContentDependencyInvalidator — and on upgrade that pin would silently keep the old behaviour while the config said otherwise. A genuinely foreign subclass is still respected. refresh() now works: DefaultInvalidator flips $refreshing before delegating, and v1 ignored it and always hard-purged, which silently broke background_recache. Verified end to end against meerdervoort: saving an article clears pages carrying collection:articles but not a page depending on one specific employee; saving that employee clears the reverse; both also clear untracked cached URLs; a nav save clears everything. Co-Authored-By: Claude Opus 5 --- config/cache_invalidation.php | 14 +++ src/Console/AffectedCommand.php | 103 +++++++++++++++++++ src/Graph/ClearGraphWhenCacheCleared.php | 31 ++++++ src/Graph/DependencyGraph.php | 14 +++ src/Graph/NullGraph.php | 9 ++ src/Graph/SqlGraph.php | 22 ++++ src/Invalidation/GraphInvalidator.php | 79 ++++++++++++++ src/Invalidation/TagResolver.php | 61 +++++++++++ src/Recording/TrackingAugmentedVariables.php | 44 ++++++++ src/Recording/TrackingFormRepository.php | 38 +++++++ src/Recording/TrackingVariables.php | 22 ++++ src/ServiceProvider.php | 44 +++++++- 12 files changed, 476 insertions(+), 5 deletions(-) create mode 100644 src/Console/AffectedCommand.php create mode 100644 src/Graph/ClearGraphWhenCacheCleared.php create mode 100644 src/Invalidation/GraphInvalidator.php create mode 100644 src/Invalidation/TagResolver.php create mode 100644 src/Recording/TrackingAugmentedVariables.php create mode 100644 src/Recording/TrackingFormRepository.php create mode 100644 src/Recording/TrackingVariables.php diff --git a/config/cache_invalidation.php b/config/cache_invalidation.php index afdb91e..2a2840f 100644 --- a/config/cache_invalidation.php +++ b/config/cache_invalidation.php @@ -35,6 +35,20 @@ 'driver' => env('CACHE_INVALIDATION_DRIVER', 'sqlite'), + /* + |-------------------------------------------------------------------------- + | Graph-driven invalidation + |-------------------------------------------------------------------------- + | + | Resolve what to clear from the recorded graph rather than from the rule + | configuration below. Off by default during the transition, so the graph can + | be recorded and inspected with `cache-invalidation:stats` and + | `cache-invalidation:affected` before it decides anything. + | + */ + + 'graph' => env('CACHE_INVALIDATION_GRAPH', false), + /* |-------------------------------------------------------------------------- | Sqlite driver path diff --git a/src/Console/AffectedCommand.php b/src/Console/AffectedCommand.php new file mode 100644 index 0000000..7856dd4 --- /dev/null +++ b/src/Console/AffectedCommand.php @@ -0,0 +1,103 @@ +argument('item'); + + [$label, $tags] = $this->resolve($item, $resolver); + + if ($tags === []) { + $this->components->error("Could not resolve [{$item}] to an entry, term, global set, form or tag."); + + return self::FAILURE; + } + + $urls = $graph->urlsFor([...$tags, Tag::OVERFLOW]); + $untracked = $graph->untracked($cached->all()); + + $this->line(''); + $this->components->twoColumnDetail('Item', $label); + $this->components->twoColumnDetail('Tags', implode(' ', $tags)); + $this->line(''); + + if ($urls === []) { + $this->components->warn('No cached page records a dependency on this.'); + } else { + $this->line(' Matched by the graph ('.count($urls).')'); + + foreach ($urls as $url) { + $this->line(' '.$url); + } + } + + $this->line(''); + + if ($untracked !== []) { + $this->components->warn(sprintf( + 'Plus %d untracked cached URL(s), which any save clears until they are re-rendered.', + count($untracked), + )); + $this->line(''); + } + + return self::SUCCESS; + } + + /** + * @return array{0: string, 1: list} + */ + private function resolve(string $item, TagResolver $resolver): array + { + if ($entry = Entry::find($item)) { + return ["entry: {$entry->collectionHandle()}/{$entry->slug()}", $resolver->forItem($entry)]; + } + + if ($term = Term::find($item)) { + return ["term: {$term->taxonomyHandle()}/{$term->slug()}", $resolver->forItem($term)]; + } + + if ($set = GlobalSet::find($item)) { + $variables = $set->in(Site::default()->handle()); + + return ["global: {$item}", $variables ? $resolver->forItem($variables) : [Tag::globalSet($item)]]; + } + + if ($form = Form::find($item)) { + return ["form: {$item}", $resolver->forItem($form)]; + } + + // Anything namespaced like a tag is taken at face value, so a dependency + // can be checked without owning an object to pass in. + if (str_contains($item, ':')) { + return ["tag: {$item}", [$item]]; + } + + return ['', []]; + } +} diff --git a/src/Graph/ClearGraphWhenCacheCleared.php b/src/Graph/ClearGraphWhenCacheCleared.php new file mode 100644 index 0000000..babe55e --- /dev/null +++ b/src/Graph/ClearGraphWhenCacheCleared.php @@ -0,0 +1,31 @@ +graph->flush(); + } +} diff --git a/src/Graph/DependencyGraph.php b/src/Graph/DependencyGraph.php index e6078f8..1488b69 100644 --- a/src/Graph/DependencyGraph.php +++ b/src/Graph/DependencyGraph.php @@ -35,6 +35,20 @@ public function tagsFor(string $url): array; public function forget(string $url): void; + /** + * Of the given URLs, those with no recorded dependencies. + * + * The safety net behind every invalidation: a URL that is cached but absent + * from the graph — cached before the addon was installed, or written while + * the graph was unreachable — has to be treated as depending on everything, + * or it would stay stale forever with no symptom. Bounded by the number of + * cached URLs rather than the size of the graph, because it runs on save. + * + * @param list $urls + * @return list + */ + public function untracked(array $urls): array; + /** * @return list */ diff --git a/src/Graph/NullGraph.php b/src/Graph/NullGraph.php index 13fc336..fc29ce3 100644 --- a/src/Graph/NullGraph.php +++ b/src/Graph/NullGraph.php @@ -25,6 +25,15 @@ public function tagsFor(string $url): array public function forget(string $url): void {} + /** + * Everything is untracked, which is what makes this driver behave as "clear + * the whole cache on every save". + */ + public function untracked(array $urls): array + { + return $urls; + } + public function urls(): array { return []; diff --git a/src/Graph/SqlGraph.php b/src/Graph/SqlGraph.php index 7cfe728..309b1b4 100644 --- a/src/Graph/SqlGraph.php +++ b/src/Graph/SqlGraph.php @@ -76,6 +76,28 @@ public function forget(string $url): void $this->query()->where('url_hash', $this->hash($url))->delete(); } + public function untracked(array $urls): array + { + if ($urls === []) { + return []; + } + + $tracked = []; + + foreach (array_chunk($urls, self::CHUNK) as $chunk) { + $hashes = array_map(fn (string $url): string => $this->hash($url), $chunk); + + foreach ($this->query()->whereIn('url_hash', $hashes)->distinct()->pluck('url_hash') as $hash) { + $tracked[$hash] = true; + } + } + + return array_values(array_filter( + $urls, + fn (string $url): bool => ! isset($tracked[$this->hash($url)]), + )); + } + public function urls(): array { return $this->query()->distinct()->orderBy('url')->pluck('url')->all(); diff --git a/src/Invalidation/GraphInvalidator.php b/src/Invalidation/GraphInvalidator.php new file mode 100644 index 0000000..786ed35 --- /dev/null +++ b/src/Invalidation/GraphInvalidator.php @@ -0,0 +1,79 @@ +clear($this->cached->all()); + + return; + } + + $tags = $this->tags->forItem($item); + + $this->clear([ + ...$this->getItemUrls($item), + ...$tags === [] ? [] : $this->graph->urlsFor([...$tags, Tag::OVERFLOW]), + ...$this->graph->untracked($this->cached->all()), + ]); + } + + /** + * @param list $urls + */ + private function clear(array $urls): void + { + $urls = array_values(array_unique(array_filter($urls))); + + if ($urls === []) { + return; + } + + // DefaultInvalidator::refresh() flips this before delegating here. v1 + // ignored it and always hard-purged, which silently broke + // static_caching.background_recache. + $this->refreshing + ? $this->cacher->refreshUrls($urls) + : $this->cacher->invalidateUrls($urls); + } +} diff --git a/src/Invalidation/TagResolver.php b/src/Invalidation/TagResolver.php new file mode 100644 index 0000000..7474af4 --- /dev/null +++ b/src/Invalidation/TagResolver.php @@ -0,0 +1,61 @@ + + */ + public function forItem(mixed $item): array + { + return match (true) { + $item instanceof Entry => array_values(array_filter([ + Tag::entry((string) $item->id()), + $item->collectionHandle() ? Tag::collection((string) $item->collectionHandle()) : null, + ])), + + $item instanceof LocalizedTerm, $item instanceof Term => array_values(array_filter([ + $item->taxonomyHandle() && $item->slug() + ? Tag::term((string) $item->taxonomyHandle(), (string) $item->slug()) + : null, + $item->taxonomyHandle() ? Tag::taxonomy((string) $item->taxonomyHandle()) : null, + ])), + + $item instanceof Variables => [Tag::globalSet((string) $item->globalSet()->handle())], + + $item instanceof Form => [Tag::form((string) $item->handle())], + + $item instanceof Collection => [Tag::collection((string) $item->handle())], + + // A tree save moves or reorders entries, which changes any listing + // built from the collection. + $item instanceof CollectionTree => [Tag::collection((string) $item->collection()->handle())], + + // Assets are not tracked on the read side yet, so there is nothing to + // match. Statamic's own rule-based URLs still apply. + $item instanceof Asset => [], + + default => [], + }; + } +} diff --git a/src/Recording/TrackingAugmentedVariables.php b/src/Recording/TrackingAugmentedVariables.php new file mode 100644 index 0000000..1b69247 --- /dev/null +++ b/src/Recording/TrackingAugmentedVariables.php @@ -0,0 +1,44 @@ +handle !== '') { + $this->recorder->globalSet($this->handle); + } + + return parent::get($handle); + } +} diff --git a/src/Recording/TrackingFormRepository.php b/src/Recording/TrackingFormRepository.php new file mode 100644 index 0000000..5e3ee1e --- /dev/null +++ b/src/Recording/TrackingFormRepository.php @@ -0,0 +1,38 @@ +recorder->form($handle); + } + + return $form; + } +} diff --git a/src/Recording/TrackingVariables.php b/src/Recording/TrackingVariables.php new file mode 100644 index 0000000..3bb97b5 --- /dev/null +++ b/src/Recording/TrackingVariables.php @@ -0,0 +1,22 @@ +handle()); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 7e020e9..7d44922 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -9,21 +9,29 @@ use Illuminate\Support\Facades\Event; use RoxDigital\CacheInvalidation\Cachers\TrackingApplicationCacher; use RoxDigital\CacheInvalidation\Cachers\TrackingFileCacher; +use RoxDigital\CacheInvalidation\Console\AffectedCommand; use RoxDigital\CacheInvalidation\Console\DoctorCommand; use RoxDigital\CacheInvalidation\Console\StatsCommand; use RoxDigital\CacheInvalidation\Console\WhyCommand; +use RoxDigital\CacheInvalidation\Graph\ClearGraphWhenCacheCleared; use RoxDigital\CacheInvalidation\Graph\DatabaseGraph; use RoxDigital\CacheInvalidation\Graph\DependencyGraph; use RoxDigital\CacheInvalidation\Graph\NullGraph; use RoxDigital\CacheInvalidation\Graph\SqliteGraph; use RoxDigital\CacheInvalidation\Http\AddCacheTagsHeader; +use RoxDigital\CacheInvalidation\Invalidation\GraphInvalidator; use RoxDigital\CacheInvalidation\Recording\DependencyRecorder; use RoxDigital\CacheInvalidation\Recording\TrackingEntryQueryBuilder; +use RoxDigital\CacheInvalidation\Recording\TrackingFormRepository; use RoxDigital\CacheInvalidation\Recording\TrackingTermRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingVariables; use Statamic\Contracts\Entries\QueryBuilder as EntryQueryBuilderContract; +use Statamic\Contracts\Forms\FormRepository as FormRepositoryContract; +use Statamic\Contracts\Globals\Variables as VariablesContract; use Statamic\Contracts\Taxonomies\TermRepository as TermRepositoryContract; use Statamic\Events\BlueprintSaved; use Statamic\Events\CollectionTreeSaved; +use Statamic\Events\StaticCacheCleared; use Statamic\Facades\StaticCache; use Statamic\Providers\AddonServiceProvider; use Statamic\Stache\Query\EntryQueryBuilder; @@ -38,6 +46,7 @@ class ServiceProvider extends AddonServiceProvider protected $config = false; protected $commands = [ + AffectedCommand::class, DoctorCommand::class, StatsCommand::class, WhyCommand::class, @@ -56,6 +65,9 @@ class ServiceProvider extends AddonServiceProvider CollectionTreeSaved::class => [ HandleCollectionTreeSaved::class, ], + StaticCacheCleared::class => [ + ClearGraphWhenCacheCleared::class, + ], ]; public function register(): void @@ -84,6 +96,16 @@ public function bootAddon(): void $this->registerReadRecorders(); } + private function usesGraphInvalidation(): bool + { + return (bool) $this->app['config']->get('cache_invalidation.graph', false); + } + + private function isOwnInvalidator(string $class): bool + { + return str_starts_with($class, __NAMESPACE__ . '\\'); + } + /** * Deliberately in boot rather than register: Statamic's Stache provider binds * EntryQueryBuilder unconditionally in its own register(), so a binding made @@ -104,6 +126,11 @@ private function registerReadRecorders(): void $this->app->bind(EntryQueryBuilder::class, $builder); Statamic::repository(TermRepositoryContract::class, TrackingTermRepository::class); + + // The global variables store builds its items with app(Variables::class). + $this->app->bind(VariablesContract::class, TrackingVariables::class); + + Statamic::repository(FormRepositoryContract::class, TrackingFormRepository::class); } /** @@ -180,16 +207,22 @@ private function registerTrackingCachers(): void } /** - * Statamic only binds its own invalidator when the config leaves the class - * unset, so claiming the default here is enough. A host app pointing at its - * own subclass keeps it. + * Claims the invalidator unless the host app points at a class of its own. + * + * The "of its own" test matters on upgrade: a site that pinned one of this + * addon's classes by name — sites do, and meerdervoort pins v1's + * ContentDependencyInvalidator — must follow the addon forward instead of + * silently keeping the previous behaviour while its config says otherwise. A + * genuinely foreign subclass is still respected. */ private function registerInvalidator(): void { - if ($this->app['config']->get('statamic.static_caching.invalidation.class') === null) { + $configured = $this->app['config']->get('statamic.static_caching.invalidation.class'); + + if ($configured === null || $this->isOwnInvalidator((string) $configured)) { $this->app['config']->set( 'statamic.static_caching.invalidation.class', - ContentDependencyInvalidator::class, + $this->usesGraphInvalidation() ? GraphInvalidator::class : ContentDependencyInvalidator::class, ); } @@ -200,6 +233,7 @@ private function registerInvalidator(): void */ $concretes = array_unique(array_filter([ ContentDependencyInvalidator::class, + GraphInvalidator::class, $this->app['config']->get('statamic.static_caching.invalidation.class'), ])); From 655462275f7d5572edca5d02efe065a2d6627a09 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 11:39:53 +0200 Subject: [PATCH 04/15] feat!: make the graph authoritative and remove the rule engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: every rule key is gone. Dependencies are observed rather than declared, so there is nothing to configure. Deletes the block index and its supporting classes, along with customEntryUrls(). The hook existed for relations the index could not see, and those are now recorded automatically — the index only ever read $entry->get('pagebuilder'), which made an entry's author, category, hero fieldset and Bard entry links invisible by construction. Adds @cachetags for the one case observation cannot cover: a dependency a template reacts to without reading, such as a banner conditional on any vacancy existing. Two upgrade affordances, both prompted by what meerdervoort's config actually looks like rather than guessed at: The addon claims the invalidator whenever the configured class is one of its own. That site pins ContentDependencyInvalidator by name, so deleting the class would otherwise have fataled on every render; instead the pin follows the addon forward. A foreign subclass is still respected. `doctor` reports leftover v1 rule keys. Silently ignoring them would leave a site believing its rules still mean something, which is the same class of quiet drift the rewrite exists to remove. Verified against meerdervoort with its 1.x config and 1.x class pin still in place: the invalidator resolves to GraphInvalidator, the obsolete keys are listed, and a real cachePage() writes the row its URL is later found by. The rollout flag from the previous commit is gone with the code it guarded. The null driver is the remaining way to be conservative — it records nothing, so every cached URL is untracked and any save clears everything. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 74 ++++- README.md | 266 +++++++++------- config/cache_invalidation.php | 14 - src/Blade/CacheTagsDirective.php | 25 ++ src/Console/DoctorCommand.php | 36 +++ src/ContentDependencyInvalidator.php | 306 ------------------- src/EntryReferenceExtractor.php | 76 ----- src/FlushStaticCacheOnFormBlueprintSaved.php | 27 -- src/HandleCollectionTreeSaved.php | 34 --- src/PagebuilderBlockResolver.php | 100 ------ src/PagebuilderBlockType.php | 18 -- src/PagebuilderDependencyScanner.php | 133 -------- src/ServiceProvider.php | 100 +++--- src/StaticCacheFlusher.php | 28 -- 14 files changed, 332 insertions(+), 905 deletions(-) create mode 100644 src/Blade/CacheTagsDirective.php delete mode 100644 src/ContentDependencyInvalidator.php delete mode 100644 src/EntryReferenceExtractor.php delete mode 100644 src/FlushStaticCacheOnFormBlueprintSaved.php delete mode 100644 src/HandleCollectionTreeSaved.php delete mode 100644 src/PagebuilderBlockResolver.php delete mode 100644 src/PagebuilderBlockType.php delete mode 100644 src/PagebuilderDependencyScanner.php delete mode 100644 src/StaticCacheFlusher.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d840d86..047a334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,78 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.0.0] - 2026-08-06 + +Invalidation is now derived from what pages actually read, instead of from rules +describing what they might read. There is no configuration to write. + +The 1.x design kept a hand-maintained map of block types and field handles that +had to mirror the templates. It drifted silently — a URL that no longer resolved +invalidated nothing, and a relation rendered outside the `pagebuilder` field was +invisible to the block index by construction. The template already knows what it +renders; v2 observes it rather than restating it. + +### Added + +- A `url -> tags` dependency graph, recorded while a page renders and written when + it enters the static cache. Works identically on the `half` and `full` + strategies. +- Read recording for entries, terms, globals and forms, hooked at the query + builder, repository and augmentation level rather than in templates. +- Item tags versus list tags: a query pinned to ids records only those items, while + any other query also records its collection or taxonomy, so an entry created + later still invalidates listings that have never seen it. +- Storage drivers: `sqlite` (default, owns its own connection and schema, needs no + database configured), `database` (opt-in, with a migration), and `null` (records + nothing, so every save clears everything). +- A safety net: any cached URL absent from the graph is treated as depending on + everything. Covers pages cached before install, a lost graph, and recorder bugs, + so the failure mode is over-invalidation that heals after one render rather than + a page that stays stale with no symptom. +- `cache-invalidation:why`, `:affected`, `:stats` and `:doctor`. `affected` answers + "what clears if I save this?" before saving — the question the 1.x design could + not be asked. `doctor` exits non-zero when invalidation cannot work, so a broken + environment fails a deploy. +- An `X-Cache-Tags` header behind `CACHE_INVALIDATION_DEBUG`. +- `@cachetags(...)` for the one case observation cannot cover: a dependency a + template reacts to without reading, such as a banner conditional on any vacancy + existing. + ### Changed -- Relicensed from proprietary to the MIT License. Copyright remains with Rox - Digital and the notice must be retained in redistributions, while the licence - disclaims all warranty and liability. `composer.json` now declares `MIT` and a - `LICENSE` file has been added. +- The whole cache is no longer flushed for globals, navigations, form blueprints or + collection trees. URLs are invalidated individually, so `nocache` regions and the + graph survive. A navigation save still clears every cached URL — a reorder + changes links in shared layout and no per-page dependency can express that — but + it clears rather than flushes. +- Globals invalidate only where they are read. A set rendered in the layout still + reaches every page; one rendered by a single block reaches that block's pages. +- Form blueprint saves clear the pages rendering that form instead of the entire + site. +- The addon now claims Statamic's invalidator when the configured class is one of + its own, not only when the config is null. Sites pin it by name, and a 1.x pin + would otherwise fatal on a class that no longer exists. + +### Fixed + +- `Invalidator::refresh()` is honoured. `DefaultInvalidator` flips its `$refreshing` + flag before delegating to `invalidate()`, which 1.x overrode without checking, so + `statamic.static_caching.background_recache` hard-purged instead of refreshing. +- Relations rendered outside the `pagebuilder` field — an entry's `author`, + `category`, a hero fieldset, entry links inside Bard — now invalidate. The 1.x + block index only read `$entry->get('pagebuilder')`, and the + `['collection' => …, 'field' => …]` rule existed to patch that hole by walking a + whole collection on every save. + +### Removed + +- Every rule key: `pagebuilder_collections`, `collection_entry_rules`, + `collection_urls`, `globals_flush_all`, `navs_flush_all`, + `collection_trees_flush_all`, `forms_flush_all`, `global_target_blocks`, + `global_urls`, `taxonomy_target_blocks`, `taxonomy_urls`. + `cache-invalidation:doctor` reports any still present in a published config. +- The block index and its supporting classes, along with `customEntryUrls()`. The + relations that hook existed for are now observed. ## [1.2.0] - 2026-07-29 diff --git a/README.md b/README.md index 9f5892c..1b2a640 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Cache Invalidation -Targeted static and half-measure cache invalidation for Statamic sites built with a pagebuilder. +Targeted static cache invalidation for Statamic, with no configuration. -Instead of flushing the entire cache on every save, this addon builds a block-index of your pages and invalidates only the URLs that reference the changed content — by entry, global, navigation, or taxonomy term. +Pages record what they read while they render. Saving content clears exactly the +cached pages that read it — no block index, no rule lists, nothing to keep in sync +when you add a pagebuilder block. [![Latest Release](https://img.shields.io/github/v/release/roxdigital/cache-invalidation)](https://github.com/roxdigital/cache-invalidation/releases) [![PHP](https://img.shields.io/badge/PHP-8.4%2B-blue)](https://www.php.net) @@ -17,12 +19,12 @@ Instead of flushing the entire cache on every save, this addon builds a block-in | Laravel | `^12.0 \|\| ^13.0` | | Statamic | `^6.0` | +Works with both the `half` and `full` static caching strategies. + --- ## Installation -### 1. Add the repository - Add the GitHub VCS source to your project's `composer.json`: ```json @@ -36,162 +38,208 @@ Add the GitHub VCS source to your project's `composer.json`: } ``` -### 2. Require and publish - ```bash composer require roxdigital/cache-invalidation -php artisan vendor:publish --tag=cache-invalidation-config ``` -### 3. Configure Statamic +That's the whole installation. There is nothing to publish, no migration to run, +and no configuration to write. The addon registers itself as Statamic's +invalidator and creates its own storage on first use. -Nothing to do in most cases: the addon registers itself as the invalidator when `statamic.static_caching.invalidation.class` is unset, which is Statamic's default. Set it explicitly only to point at your own subclass: +Verify with: -```php -'invalidation' => [ - 'class' => \App\StaticCaching\SiteInvalidator::class, - 'rules' => [], -], +```bash +php artisan cache-invalidation:doctor ``` -> **Note:** This addon replaces Statamic's rule-based invalidator. Keep `rules` as an empty array. - --- -## Local development +## How it works -Use a Composer path repository to work against a local clone: +### Dependencies are observed, not declared -```json -{ - "repositories": [ - { - "type": "path", - "url": "addons/roxdigital/cache-invalidation" - } - ] -} -``` +While a page renders, the addon watches what it reads and records a set of tags +against the page's URL when it enters the static cache: -```bash -composer require roxdigital/cache-invalidation:@dev +``` +https://site.test/over-ons + entry:9f2c… an entry it rendered + collection:articles a query it ran against a collection + term:departments::sales + taxonomy:departments + global:footer + form:contact ``` ---- +On save, the changed item is turned into the same tags, and every URL carrying one +of them is cleared. Because the template is the only thing that decides what a +page reads, there is no second copy of that knowledge to drift out of date. -## Configuration +### Item tags and list tags -`config/cache_invalidation.php` documents every key inline. In short: +The distinction that makes this targeted rather than blunt: -| Key | Effect | -|-----|--------| -| `pagebuilder_collections` | Collections with a pagebuilder field. Only these are indexed. | -| `globals_flush_all` | Global handles that flush the whole cache on save. | -| `navs_flush_all` | Nav handles that flush the whole cache — structure edits and reorders. | -| `collection_trees_flush_all` | Collections whose tree order drives shared output (a nav or breadcrumbs built from the page tree). Empty by default. | -| `forms_flush_all` | `true` by default: a form blueprint save flushes the whole cache, since the changed fields render on every page embedding the form. | -| `global_target_blocks` | `'global' => ['block_type', ...]` — invalidate pages containing those blocks. | -| `global_urls` | `'global' => ['/url', ...]` | -| `collection_entry_rules` | Per-collection entry rules — see below. | -| `collection_urls` | `'collection' => ['/url', ...]` | -| `taxonomy_target_blocks` | `'taxonomy' => ['block_type', ...]` | -| `taxonomy_urls` | `'taxonomy' => ['/url', ...]` | +- A query pinned to ids — `Entry::find($id)`, an `entries` field being augmented — + records **item tags** only. A reusable block embedded on three pages clears only + those three. +- Any other query also records a **list tag** for its scope. A carousel showing + "the latest three articles" has never seen an article created tomorrow, so its + id can be in no tag set; the `collection:articles` tag is what clears it. -### Entry rules +A block that picks between those modes at runtime — automatic, by author, manual — +gets the right answer for whichever branch actually ran. -Map a collection to `'all'` — invalidate every cached URL on any save — or to a list of rules: +### Where reads are observed -```php -'collection_entry_rules' => [ +| Content | Hook | +|---|---| +| Entries | `EntryQueryBuilder::getFilteredKeys()` and `getItems()` | +| Terms | `TermQueryBuilder`, via a replaced `TermRepository::query()` | +| Globals | `Variables::newAugmentedInstance()`, recorded on first value read | +| Forms | `FormRepository::find()`, which is how the form fieldtype augments | - // Pages containing this block type. - 'articles' => [['block' => 'article_carousel']], +Globals are recorded lazily, on read. Statamic hydrates every global set into +every view whether a template uses it or not, so a set rendered in your layout +ends up on every page while a set rendered by one block ends up only on that +block's pages. - // Pages where that block's field references the saved entry. - 'reusable_blocks' => [['block' => 'reusable_block', 'field' => 'entry']], +### What happens on save - // The saved entry's parent page, for a structured collection whose parent - // template lists its children. Opt-in per collection: in a collection - // mounted at the site root, a top-level entry's parent is the root itself. - 'vacancies' => [['parent' => true]], +| Saved | Cleared | +|---|---| +| Entry | Its own URL and descendants, pages carrying `entry:{id}`, pages carrying `collection:{handle}` | +| Term | Pages carrying `term:{taxonomy}::{slug}` or `taxonomy:{handle}` | +| Global set | Pages that read it — which is every page, if it is read in your layout | +| Form, or a forms blueprint | Pages that render that form | +| Collection tree | Pages carrying that collection's list tag, plus the URLs Statamic reports as moved | +| Navigation | Every cached URL | +| Anything | Plus any cached URL with no recorded dependencies (see below) | - // Entries in another collection whose field references the saved entry — - // the inverse of a block rule, for relations rendered by a template (an - // article showing its author). Walks that collection on every save. - 'employees' => [['collection' => 'articles', 'field' => 'author']], +The cache is never flushed wholesale — URLs are invalidated individually, so +`nocache` regions and the graph survive and pages come back without a global +re-render. -], -``` +A navigation save is the one deliberate exception, per the shape of the problem: a +reorder or relabel changes links rendered in shared layout, and no per-page +dependency can express that. + +### The safety net -The two `block` rules resolve through the block index, so they only reach what a pagebuilder block renders. `parent` and `collection` rules do not use the index, and are inert unless configured. +Any URL that is cached but absent from the graph is treated as depending on +everything, and cleared by the next save of anything. This covers pages cached +before the addon was installed, a lost graph, and any bug in the recorders — the +failure mode is over-invalidation that heals after one render, rather than a page +that stays stale with no symptom. + +`cache-invalidation:stats` reports how many such URLs exist. Right after a deploy +or a flush that number is everything; it drops to zero as pages are rendered. --- -## How it works +## Commands -On save, the addon resolves which cached URLs to clear: +```bash +# What does this page depend on? +php artisan cache-invalidation:why https://site.test/over-ons -| Trigger | Behaviour | -|---------|-----------| -| Global in `globals_flush_all` | Full flush + clear block index | -| Nav in `navs_flush_all` | Full flush + clear block index | -| Form blueprint saved, when `forms_flush_all` | Full flush + clear block index | -| Collection tree saved | Clear block index; full flush if the collection is in `collection_trees_flush_all` | -| Global or taxonomy term | Pages matching `*_target_blocks`, plus `*_urls` | -| Entry in a collection mapped to `'all'` | Every currently-cached URL | -| Entry matching `collection_entry_rules` | Block-index matches, parent page, referencing entries | -| Entry — always | Its own URL, `collection_urls`, and `customEntryUrls()` | +# What would clear if I saved this? Accepts an entry id, term id, +# global set handle, form handle, or a raw tag. +php artisan cache-invalidation:affected 9f2c1b4e-… +php artisan cache-invalidation:affected collection:articles -A **full flush** goes through `StaticCache::flush()`, the same path as `php artisan statamic:static:clear`: cached pages, `nocache` regions and cached error pages such as a shared 404. Flushing the cacher alone would leave nocache regions behind to be restored into freshly rendered pages. +# Graph size and coverage of the static cache. +php artisan cache-invalidation:stats -### Block index +# Deploy check. Exits non-zero when invalidation cannot work. +php artisan cache-invalidation:doctor +``` -The addon maintains a `url → blocks[]` index in your Laravel cache. Each block is stored as a slim record containing only its `type` and the fields your rules reference — rich text, images and other large values are discarded at build time. +Set `CACHE_INVALIDATION_DEBUG=true` to add an `X-Cache-Tags` header to responses +as they are cached, so a page's dependencies are readable in devtools. -The index is built on first access and stored forever. It is cleared when: +--- -- The full cache is flushed. -- Any collection tree is saved — a move changes the URLs the index is keyed on, and a reorder dispatches no move event at all. -- An entry is saved in a `pagebuilder_collections` collection (page layout may have changed), or in one with a `reusable_block` rule (embedded content may have changed). +## Configuration -Its cache key is fingerprinted with `collection_entry_rules` and `pagebuilder_collections`, so editing either config takes effect immediately instead of matching nothing against an index built under the old rules. +There is nothing you need to set. The file exists for two choices: -### Reusable blocks +| Key | Default | Effect | +|-----|---------|--------| +| `driver` | `sqlite` | Where the graph lives — `sqlite`, `database` or `null` | +| `sqlite_path` | `storage/statamic/cache-invalidation.sqlite` | | +| `database_connection` | `null` | Connection for the `database` driver | +| `debug` | `false` | `X-Cache-Tags` header | -Blocks of type `reusable_block` are expanded inline when the index is built, so pages embedding one are invalidated when that entry changes. Circular references are detected and skipped. +```bash +php artisan vendor:publish --tag=cache-invalidation-config +``` -### Extending +**`sqlite`** owns its own connection and creates the file and schema on first +write, so it works on a site with no `DB_CONNECTION` configured — the common +Statamic case. It sits beside Statamic's own static cache bookkeeping so the graph +and the cache share a directory and a deploy that discards one discards both. -Most template-rendered relations are covered by the `parent` and `collection` rules above. For anything they cannot express, override `customEntryUrls()` — it is merged for every entry, whether or not its collection has rules: +**`database`** keeps the graph in your application database instead. Requires +`php artisan migrate`. -```php -class SiteInvalidator extends \RoxDigital\CacheInvalidation\ContentDependencyInvalidator -{ - protected function customEntryUrls(\Statamic\Entries\Entry $entry): \Illuminate\Support\Collection - { - return $entry->collectionHandle() === 'team' ? collect(['/about']) : collect(); - } -} -``` +**`null`** records nothing, which makes every cached URL untracked and therefore +clears the whole cache on every save. A conservative fallback, not a production +driver. + +> The graph must be visible to every process that renders or invalidates pages. On +> a single server that is automatic. If web and queue run on separate machines with +> separate filesystems, use the `database` driver — and note that a file-backed +> static cache would already be inconsistent in that setup. + +--- + +## Escape hatch -Point the config at your subclass; the addon registers its `$rules` binding for whichever class is configured: +One directive, for the only thing observation cannot see: a dependency a template +reacts to without reading. -```php -'invalidation' => [ - 'class' => \App\StaticCaching\SiteInvalidator::class, - 'rules' => [], -], +```blade +{{-- A "we're hiring" banner that queries no vacancies --}} +@cachetags('collection:vacancies') ``` -`urlsFor()`, `urlsForGlobal()`, `urlsForEntry()` and `urlsForTaxonomyTerm()` are `protected` if you need to go further. +Everything a template actually reads is recorded on its own. This is for the +exception. --- -## Performance +## Upgrading from 1.x + +Remove the rule keys from `config/cache_invalidation.php` — all of +`pagebuilder_collections`, `collection_entry_rules`, `collection_urls`, +`globals_flush_all`, `navs_flush_all`, `collection_trees_flush_all`, +`forms_flush_all`, `global_target_blocks`, `global_urls`, +`taxonomy_target_blocks` and `taxonomy_urls` are gone. `cache-invalidation:doctor` +lists any that are still present. + +If `statamic.static_caching.invalidation.class` points at +`ContentDependencyInvalidator`, you can leave it — the addon recognises its own +class names and upgrades the pin. A subclass of your own is still respected, but +`customEntryUrls()` no longer exists; the relations it existed for are now +observed automatically. + +After deploying, expect one round of broad invalidation while pages are rendered +and the graph fills. `cache-invalidation:stats` shows the progress. + +--- -- **Use a queue driver in production.** Statamic dispatches invalidation jobs to the queue. Without a queue driver, invalidation runs synchronously inside the CP save request. -- **Use Redis (or another fast cache driver).** The block index is stored as a single serialised entry. A fast driver reduces index rebuild time. -- The index is built once per cache miss. Subsequent invalidations reuse the cached index with no database queries. +## Notes + +- **Assets are not tracked.** Saving an asset clears nothing extra, matching 1.x. +- **Cold pages record nothing**, which is correct — there is nothing cached to + clear. On full measure, run `statamic:static:warm` after a deploy so the graph + fills promptly rather than lazily. +- **Recording only happens on a cache miss**, during a render you are already + paying for. Invalidation is one indexed lookup plus the deletes; nothing walks + content, which matters when a single queue worker handles the job — or when + `QUEUE_CONNECTION=sync` runs it inside the editor's save request. +- **A page with more than 2,000 dependencies** collapses to a single overflow tag + and is treated as depending on everything. --- diff --git a/config/cache_invalidation.php b/config/cache_invalidation.php index 2a2840f..afdb91e 100644 --- a/config/cache_invalidation.php +++ b/config/cache_invalidation.php @@ -35,20 +35,6 @@ 'driver' => env('CACHE_INVALIDATION_DRIVER', 'sqlite'), - /* - |-------------------------------------------------------------------------- - | Graph-driven invalidation - |-------------------------------------------------------------------------- - | - | Resolve what to clear from the recorded graph rather than from the rule - | configuration below. Off by default during the transition, so the graph can - | be recorded and inspected with `cache-invalidation:stats` and - | `cache-invalidation:affected` before it decides anything. - | - */ - - 'graph' => env('CACHE_INVALIDATION_GRAPH', false), - /* |-------------------------------------------------------------------------- | Sqlite driver path diff --git a/src/Blade/CacheTagsDirective.php b/src/Blade/CacheTagsDirective.php new file mode 100644 index 0000000..6ca91e0 --- /dev/null +++ b/src/Blade/CacheTagsDirective.php @@ -0,0 +1,25 @@ +add(' . $expression . '); ?>'; + } +} diff --git a/src/Console/DoctorCommand.php b/src/Console/DoctorCommand.php index e154f98..e5ba2fd 100644 --- a/src/Console/DoctorCommand.php +++ b/src/Console/DoctorCommand.php @@ -22,6 +22,25 @@ final class DoctorCommand extends Command protected $description = 'Verify that cache invalidation can actually work on this environment'; + /** + * Rule keys from v1. Ignored now that dependencies are observed rather than + * declared, but silently ignoring them would leave a site believing its rules + * still mean something. + */ + private const OBSOLETE_KEYS = [ + 'pagebuilder_collections', + 'globals_flush_all', + 'navs_flush_all', + 'collection_trees_flush_all', + 'forms_flush_all', + 'global_target_blocks', + 'global_urls', + 'collection_entry_rules', + 'collection_urls', + 'taxonomy_target_blocks', + 'taxonomy_urls', + ]; + public function handle(DependencyGraph $graph, CachedUrls $cached, Cacher $cacher): int { $failed = false; @@ -80,8 +99,25 @@ public function handle(DependencyGraph $graph, CachedUrls $cached, Cacher $cache } } + $this->check( + 'Invalidator', + class_basename((string) config('statamic.static_caching.invalidation.class')), + true, + ); + $this->line(''); + if ($obsolete = array_values(array_filter( + self::OBSOLETE_KEYS, + fn (string $key): bool => config()->has("cache_invalidation.{$key}"), + ))) { + $this->components->warn( + 'config/cache_invalidation.php still declares v1 rule keys, which are ignored. ' + . 'Safe to delete: ' . implode(', ', $obsolete) + ); + $this->line(''); + } + if ($failed) { $this->components->error('Cache invalidation cannot work on this environment.'); diff --git a/src/ContentDependencyInvalidator.php b/src/ContentDependencyInvalidator.php deleted file mode 100644 index 584f3cf..0000000 --- a/src/ContentDependencyInvalidator.php +++ /dev/null @@ -1,306 +0,0 @@ -shouldFlushAll($item)) { - $this->cache->flush(); - - return; - } - - $urls = $this->urlsFor($item) - ->filter() - ->unique() - ->values(); - - $this->cacher->invalidateUrls($urls->all()); - - if ($item instanceof Entry && $this->entryAffectsPageIndex($item)) { - $this->pagebuilder->clearIndex(); - } - - parent::invalidate($item); - } - - private function entryAffectsPageIndex(Entry $entry): bool - { - $collection = $entry->collectionHandle(); - - if (in_array($collection, config('cache_invalidation.pagebuilder_collections', ['pages']), true)) { - return true; - } - - foreach (config('cache_invalidation.collection_entry_rules', []) as $ruleCollection => $rules) { - if ($ruleCollection !== $collection) { - continue; - } - - foreach ((array) $rules as $rule) { - if (($rule['block'] ?? null) === PagebuilderBlockType::ReusableBlock->value) { - return true; - } - } - } - - return false; - } - - private function shouldFlushAll(mixed $item): bool - { - if ($item instanceof Variables) { - return in_array( - $item->globalSet()->handle(), - config('cache_invalidation.globals_flush_all', []), - true, - ); - } - - if ($item instanceof Nav) { - return in_array( - $item->handle(), - config('cache_invalidation.navs_flush_all', []), - true, - ); - } - - if ($item instanceof NavTree) { - return in_array( - $item->structure()->handle(), - config('cache_invalidation.navs_flush_all', []), - true, - ); - } - - return false; - } - - protected function urlsFor(mixed $item): Collection - { - if ($item instanceof Variables) { - return $this->urlsForGlobal($item); - } - - if ($item instanceof Entry) { - return $this->urlsForEntry($item); - } - - if ($item instanceof LocalizedTerm) { - return $this->urlsForTaxonomyTerm($item); - } - - return collect(); - } - - protected function urlsForGlobal(Variables $variables): Collection - { - return $this->urlsWithBlockTargets('global_urls', 'global_target_blocks', $variables->globalSet()->handle()); - } - - protected function urlsForEntry(Entry $entry): Collection - { - $collection = $entry->collectionHandle(); - $rules = config('cache_invalidation.collection_entry_rules', []); - - // Always applied: a collection having block rules does not mean those - // rules cover every way its entries surface. Anything rendered by a - // collection's own template, rather than by a pagebuilder block, can - // only be expressed here. - $urls = $this->ownUrl($entry) - ->merge($this->configuredUrls('collection_urls', $collection)) - ->merge($this->customEntryUrls($entry)); - - if (! array_key_exists($collection, $rules)) { - return $urls; - } - - $rule = $rules[$collection]; - - if ($rule === 'all') { - return $this->allCachedUrls()->merge($urls); - } - - return $urls - ->merge($this->blockRuleUrls((array) $rule, $entry)) - ->merge($this->parentRuleUrls((array) $rule, $entry)) - ->merge($this->referencingEntryRuleUrls((array) $rule, $entry)); - } - - /** - * ['block' => 'x', 'field' => 'y'] — cached pages containing that block. - * - * @param list> $rules - */ - private function blockRuleUrls(array $rules, Entry $entry): Collection - { - $blockRules = array_values(array_filter( - $rules, - fn (array $rule): bool => isset($rule['block']), - )); - - if ($blockRules === []) { - return collect(); - } - - $entryId = $entry->id(); - - return $this->pagebuilder->urlsForBlocksMatching( - fn (array $block): bool => $this->blockMatchesAnyRule($block, $blockRules, $entryId), - ); - } - - /** - * ['parent' => true] — the saved entry's parent page, for a structured - * collection whose parent template renders its children. - * - * Opt-in per collection rather than automatic: in a collection mounted at - * the site root, a top-level entry's parent() is the root itself, so - * applying this everywhere would clear the home page on every save. - * - * @param list> $rules - */ - private function parentRuleUrls(array $rules, Entry $entry): Collection - { - $wantsParent = collect($rules)->contains( - fn (array $rule): bool => ($rule['parent'] ?? false) === true, - ); - - if (! $wantsParent) { - return collect(); - } - - $url = $entry->parent()?->absoluteUrl(); - - return $url ? collect([$url]) : collect(); - } - - /** - * ['collection' => 'x', 'field' => 'y'] — the URLs of entries in collection - * x whose field y references the saved entry. The inverse of a block rule: - * for relations rendered by a collection's own template rather than by a - * pagebuilder block, so the block index cannot reach them. - * - * @param list> $rules - */ - private function referencingEntryRuleUrls(array $rules, Entry $entry): Collection - { - $entryId = $entry->id(); - - return collect($rules) - ->filter(fn (array $rule): bool => isset($rule['collection'], $rule['field'])) - ->flatMap(fn (array $rule): array => EntryFacade::whereCollection($rule['collection']) - ->filter(fn (Entry $candidate): bool => $this->valueReferencesEntry( - $candidate->get($rule['field']), - $entryId, - )) - ->map(fn (Entry $candidate): ?string => $candidate->absoluteUrl()) - ->filter() - ->all()) - ->unique() - ->values(); - } - - protected function urlsForTaxonomyTerm(LocalizedTerm $term): Collection - { - return $this->urlsWithBlockTargets('taxonomy_urls', 'taxonomy_target_blocks', $term->taxonomyHandle()); - } - - private function urlsWithBlockTargets(string $urlsKey, string $targetsKey, string $handle): Collection - { - $urls = $this->configuredUrls($urlsKey, $handle); - $blockTargets = config("cache_invalidation.{$targetsKey}", []); - - if (! isset($blockTargets[$handle])) { - return $urls; - } - - $types = (array) $blockTargets[$handle]; - - return $urls->merge($this->pagebuilder->urlsForBlocksMatching( - fn (array $block): bool => in_array($block['type'] ?? null, $types, true), - )); - } - - private function ownUrl(Entry $entry): Collection - { - $url = $entry->absoluteUrl(); - - return $url ? collect([$url]) : collect(); - } - - /** - * Override in a site-specific subclass when a collection cannot be expressed - * with config-driven block and field-reference rules. - */ - protected function customEntryUrls(Entry $entry): Collection - { - return collect(); - } - - /** - * @param array $block - * @param list $rules - */ - private function blockMatchesAnyRule(array $block, array $rules, string $entryId): bool - { - foreach ($rules as $rule) { - $blockType = $rule['block']; - - if (($block['type'] ?? null) !== $blockType) { - continue; - } - - if (! isset($rule['field'])) { - return true; - } - - if ($this->valueReferencesEntry($block[$rule['field']] ?? null, $entryId)) { - return true; - } - } - - return false; - } - - private function valueReferencesEntry(mixed $value, string $entryId): bool - { - return in_array($entryId, $this->references->extract($value), true); - } - - private function configuredUrls(string $configKey, string $handle): Collection - { - return collect((array) config("cache_invalidation.{$configKey}.{$handle}", [])) - ->filter() - ->values(); - } - - private function allCachedUrls(): Collection - { - return $this->cacher->getUrls()->filter()->values(); - } -} diff --git a/src/EntryReferenceExtractor.php b/src/EntryReferenceExtractor.php deleted file mode 100644 index 59cce42..0000000 --- a/src/EntryReferenceExtractor.php +++ /dev/null @@ -1,76 +0,0 @@ - - */ - public function extract(mixed $value): array - { - return collect($this->extractRecursive($value)) - ->filter() - ->unique() - ->values() - ->all(); - } - - /** - * @return array - */ - private function extractRecursive(mixed $value): array - { - if (is_string($value)) { - return $this->extractFromString($value); - } - - if ($value instanceof Entry) { - return [$value->id()]; - } - - if ($value instanceof Collection) { - return $value - ->flatMap(fn (mixed $item): array => $this->extractRecursive($item)) - ->values() - ->all(); - } - - if (is_array($value)) { - return collect($value) - ->flatMap(fn (mixed $item): array => $this->extractRecursive($item)) - ->values() - ->all(); - } - - return []; - } - - /** - * A bare id covers the entries fieldtype. The link fieldtype stores - * "entry::" and Bard stores hrefs as "statamic://entry::", so a rule - * pointed at either of those fields would otherwise never match. - * - * @return array - */ - private function extractFromString(string $value): array - { - if (Str::isUuid($value)) { - return [$value]; - } - - if (! Str::contains($value, 'entry::')) { - return []; - } - - $id = Str::after($value, 'entry::'); - - return Str::isUuid($id) ? [$id] : []; - } -} diff --git a/src/FlushStaticCacheOnFormBlueprintSaved.php b/src/FlushStaticCacheOnFormBlueprintSaved.php deleted file mode 100644 index 79c1153..0000000 --- a/src/FlushStaticCacheOnFormBlueprintSaved.php +++ /dev/null @@ -1,27 +0,0 @@ -blueprint->namespace() !== 'forms') { - return; - } - - if (! config('cache_invalidation.forms_flush_all', true)) { - return; - } - - $this->cache->flush(); - } -} diff --git a/src/HandleCollectionTreeSaved.php b/src/HandleCollectionTreeSaved.php deleted file mode 100644 index 40a1fcc..0000000 --- a/src/HandleCollectionTreeSaved.php +++ /dev/null @@ -1,34 +0,0 @@ -pagebuilder->clearIndex(); - - $handle = $event->tree->collection()->handle(); - - if (in_array($handle, config('cache_invalidation.collection_trees_flush_all', []), true)) { - $this->cache->flush(); - } - } -} diff --git a/src/PagebuilderBlockResolver.php b/src/PagebuilderBlockResolver.php deleted file mode 100644 index 65cc267..0000000 --- a/src/PagebuilderBlockResolver.php +++ /dev/null @@ -1,100 +0,0 @@ -> - */ - public function resolve(Entry $entry, bool $expandReusable = true): array - { - $blocks = $entry->get('pagebuilder'); - - if (! is_array($blocks)) { - return []; - } - - return $this->resolveBlocks($blocks, $expandReusable); - } - - /** - * @param array $blocks - * @param array $visitedReusableBlockIds - * @return array> - */ - private function resolveBlocks(array $blocks, bool $expandReusable, array $visitedReusableBlockIds = []): array - { - $resolved = []; - - foreach ($blocks as $block) { - if (! is_array($block)) { - continue; - } - - $resolved[] = $block; - - if (! $this->shouldExpandReusableBlock($block, $expandReusable)) { - continue; - } - - $resolved = [ - ...$resolved, - ...$this->resolveReusableBlocks($block, $expandReusable, $visitedReusableBlockIds), - ]; - } - - return $resolved; - } - - /** - * @param array $block - * @param array $visitedReusableBlockIds - * @return array> - */ - private function resolveReusableBlocks(array $block, bool $expandReusable, array $visitedReusableBlockIds): array - { - $resolved = []; - - foreach ($this->references->extract($block['entry'] ?? null) as $reusableBlockId) { - if (in_array($reusableBlockId, $visitedReusableBlockIds, true)) { - continue; - } - - $reusableBlock = EntryFacade::find($reusableBlockId); - - if (! $reusableBlock instanceof Entry) { - continue; - } - - $resolved = [ - ...$resolved, - ...$this->resolveBlocks( - $reusableBlock->get('pagebuilder') ?? [], - $expandReusable, - [...$visitedReusableBlockIds, $reusableBlockId], - ), - ]; - } - - return $resolved; - } - - /** - * @param array $block - */ - private function shouldExpandReusableBlock(array $block, bool $expandReusable): bool - { - return $expandReusable - && PagebuilderBlockType::ReusableBlock->matches($block); - } -} diff --git a/src/PagebuilderBlockType.php b/src/PagebuilderBlockType.php deleted file mode 100644 index 8aa82ee..0000000 --- a/src/PagebuilderBlockType.php +++ /dev/null @@ -1,18 +0,0 @@ - $block - */ - public function matches(array $block): bool - { - return ($block['type'] ?? null) === $this->value; - } -} diff --git a/src/PagebuilderDependencyScanner.php b/src/PagebuilderDependencyScanner.php deleted file mode 100644 index b9aa07a..0000000 --- a/src/PagebuilderDependencyScanner.php +++ /dev/null @@ -1,133 +0,0 @@ -getIndex()) - ->filter(fn (array $blocks): bool => collect($blocks)->contains($predicate)) - ->keys() - ->values(); - } - - public function clearIndex(): void - { - Cache::forget($this->indexKey()); - Cache::forget(self::INDEX_KEY); - } - - /** - * The index only keeps the block fields named in collection_entry_rules - * (see slimBlocks), so a cached index built under a different rule set is - * not just stale, it is missing keys the new rules match on. Fingerprinting - * the config into the key means such an index is never read: without this, - * adding a field-scoped rule silently matches nothing until something else - * happens to clear the index. - */ - private function indexKey(): string - { - $fingerprint = md5(serialize([ - config('cache_invalidation.collection_entry_rules', []), - config('cache_invalidation.pagebuilder_collections', ['pages']), - ])); - - return self::INDEX_KEY.'.'.$fingerprint; - } - - /** - * @return array>> - */ - private function getIndex(): array - { - return Cache::rememberForever($this->indexKey(), fn (): array => $this->buildIndex()); - } - - /** - * @return array>> - */ - private function buildIndex(): array - { - $collections = config('cache_invalidation.pagebuilder_collections', ['pages']); - $relevantFields = $this->indexRelevantFields(); - - return EntryFacade::whereInCollection($collections) - ->filter(fn (Entry $entry): bool => $this->isPublicPagebuilderEntry($entry)) - ->mapWithKeys(fn (Entry $entry): array => [ - $entry->absoluteUrl() => $this->slimBlocks($this->blocks->resolve($entry, true), $relevantFields), - ]) - ->all(); - } - - /** - * Returns the block field handles that must be kept in the index. - * - * Only 'block' rules match against indexed blocks; global and taxonomy - * predicates match on block type alone, and the 'field' of a 'collection' - * rule names a field on an entry rather than on a block. - * - * @return list - */ - private function indexRelevantFields(): array - { - return collect(config('cache_invalidation.collection_entry_rules', [])) - ->flatMap(fn (mixed $rules): array => is_array($rules) - ? collect($rules) - ->filter(fn (mixed $rule): bool => is_array($rule) && isset($rule['block'], $rule['field'])) - ->pluck('field') - ->all() - : [] - ) - ->unique() - ->values() - ->all(); - } - - /** - * Strips each block down to type + the fields needed for rule matching, - * discarding rich-text and other large field values that bloat the cache. - * - * @param list> $blocks - * @param list $relevantFields - * @return list> - */ - private function slimBlocks(array $blocks, array $relevantFields): array - { - return array_map(function (array $block) use ($relevantFields): array { - $slim = ['type' => $block['type'] ?? null]; - - foreach ($relevantFields as $field) { - if (array_key_exists($field, $block)) { - $slim[$field] = $block[$field]; - } - } - - return $slim; - }, $blocks); - } - - private function isPublicPagebuilderEntry(Entry $entry): bool - { - if (! $entry->published()) { - return false; - } - - return $entry->absoluteUrl() !== null - && is_array($entry->get('pagebuilder')); - } -} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 7d44922..0de40e0 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -6,7 +6,9 @@ use Illuminate\Database\DatabaseManager; use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Event; +use RoxDigital\CacheInvalidation\Blade\CacheTagsDirective; use RoxDigital\CacheInvalidation\Cachers\TrackingApplicationCacher; use RoxDigital\CacheInvalidation\Cachers\TrackingFileCacher; use RoxDigital\CacheInvalidation\Console\AffectedCommand; @@ -29,8 +31,6 @@ use Statamic\Contracts\Forms\FormRepository as FormRepositoryContract; use Statamic\Contracts\Globals\Variables as VariablesContract; use Statamic\Contracts\Taxonomies\TermRepository as TermRepositoryContract; -use Statamic\Events\BlueprintSaved; -use Statamic\Events\CollectionTreeSaved; use Statamic\Events\StaticCacheCleared; use Statamic\Facades\StaticCache; use Statamic\Providers\AddonServiceProvider; @@ -59,12 +59,6 @@ class ServiceProvider extends AddonServiceProvider ]; protected $listen = [ - BlueprintSaved::class => [ - FlushStaticCacheOnFormBlueprintSaved::class, - ], - CollectionTreeSaved::class => [ - HandleCollectionTreeSaved::class, - ], StaticCacheCleared::class => [ ClearGraphWhenCacheCleared::class, ], @@ -94,43 +88,8 @@ public function bootAddon(): void } $this->registerReadRecorders(); - } - - private function usesGraphInvalidation(): bool - { - return (bool) $this->app['config']->get('cache_invalidation.graph', false); - } - - private function isOwnInvalidator(string $class): bool - { - return str_starts_with($class, __NAMESPACE__ . '\\'); - } - - /** - * Deliberately in boot rather than register: Statamic's Stache provider binds - * EntryQueryBuilder unconditionally in its own register(), so a binding made - * during register() would be clobbered if our provider happened to run first. - * Boot runs after every register(), and nothing resolves a query builder - * before a request or command is handled. - */ - private function registerReadRecorders(): void - { - $recorder = fn (): DependencyRecorder => $this->app->make(DependencyRecorder::class); - $entries = fn (): Store => $this->app->make(Stache::class)->store('entries'); - - $builder = fn (): TrackingEntryQueryBuilder => new TrackingEntryQueryBuilder($entries(), $recorder()); - - // EntryRepository::query() resolves the contract; the concrete is bound - // too, in case anything resolves it directly. - $this->app->bind(EntryQueryBuilderContract::class, $builder); - $this->app->bind(EntryQueryBuilder::class, $builder); - - Statamic::repository(TermRepositoryContract::class, TrackingTermRepository::class); - - // The global variables store builds its items with app(Variables::class). - $this->app->bind(VariablesContract::class, TrackingVariables::class); - Statamic::repository(FormRepositoryContract::class, TrackingFormRepository::class); + Blade::directive('cachetags', CacheTagsDirective::compile(...)); } /** @@ -209,30 +168,25 @@ private function registerTrackingCachers(): void /** * Claims the invalidator unless the host app points at a class of its own. * - * The "of its own" test matters on upgrade: a site that pinned one of this - * addon's classes by name — sites do, and meerdervoort pins v1's - * ContentDependencyInvalidator — must follow the addon forward instead of - * silently keeping the previous behaviour while its config says otherwise. A - * genuinely foreign subclass is still respected. + * The "of its own" test matters on upgrade: sites pin this by name, and a v1 + * site pinning ContentDependencyInvalidator must follow the addon forward + * rather than fataling on a class that no longer exists. A genuinely foreign + * subclass is still respected. */ private function registerInvalidator(): void { $configured = $this->app['config']->get('statamic.static_caching.invalidation.class'); if ($configured === null || $this->isOwnInvalidator((string) $configured)) { - $this->app['config']->set( - 'statamic.static_caching.invalidation.class', - $this->usesGraphInvalidation() ? GraphInvalidator::class : ContentDependencyInvalidator::class, - ); + $this->app['config']->set('statamic.static_caching.invalidation.class', GraphInvalidator::class); } /* * Contextual bindings are keyed on the exact concrete, so binding only - * this class leaves a host app that points the config at a subclass with - * an unresolvable $rules parameter. Bind the configured class too. + * our own class leaves a host app that points the config at a subclass + * with an unresolvable $rules parameter. Bind the configured class too. */ $concretes = array_unique(array_filter([ - ContentDependencyInvalidator::class, GraphInvalidator::class, $this->app['config']->get('statamic.static_caching.invalidation.class'), ])); @@ -245,6 +199,40 @@ private function registerInvalidator(): void } } + /** + * Deliberately in boot rather than register: Statamic's Stache provider binds + * EntryQueryBuilder unconditionally in its own register(), so a binding made + * during register() would be clobbered if our provider happened to run first. + * Boot runs after every register(), and nothing resolves a query builder, + * global set or form before a request or command is handled. + */ + private function registerReadRecorders(): void + { + $recorder = fn (): DependencyRecorder => $this->app->make(DependencyRecorder::class); + $entries = fn (): Store => $this->app->make(Stache::class)->store('entries'); + + $builder = fn (): TrackingEntryQueryBuilder => new TrackingEntryQueryBuilder($entries(), $recorder()); + + // EntryRepository::query() resolves the contract; the concrete is bound + // too, in case anything resolves it directly. + $this->app->bind(EntryQueryBuilderContract::class, $builder); + $this->app->bind(EntryQueryBuilder::class, $builder); + + // TermRepository::query() constructs its builder directly instead of + // resolving it, so the repository itself has to be replaced. + Statamic::repository(TermRepositoryContract::class, TrackingTermRepository::class); + + // The global variables store builds its items with app(Variables::class). + $this->app->bind(VariablesContract::class, TrackingVariables::class); + + Statamic::repository(FormRepositoryContract::class, TrackingFormRepository::class); + } + + private function isOwnInvalidator(string $class): bool + { + return str_starts_with($class, __NAMESPACE__ . '\\'); + } + private function graphDriver(): string { return (string) $this->app['config']->get('cache_invalidation.driver', 'sqlite'); diff --git a/src/StaticCacheFlusher.php b/src/StaticCacheFlusher.php deleted file mode 100644 index 430501d..0000000 --- a/src/StaticCacheFlusher.php +++ /dev/null @@ -1,28 +0,0 @@ -pagebuilder->clearIndex(); - } -} From 69669373ca2dd3f4d75e5031110d73f8737d1256 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 11:49:47 +0200 Subject: [PATCH 05/15] fix: keep id-pinned queries precise when a status filter is applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving a reusable block recorded both entry:{id} and collection:reusable_blocks, so saving any reusable block cleared every page embedding any of them. Correct, but no better than a per-collection rule, and worse than what v1's ['block' => 'reusable_block', 'field' => 'entry'] achieved. The entries fieldtype augments through a StatusQueryBuilder, whose get() applies whereStatus('published'). That calls ensureCollectionsAreQueriedForStatusQuery(), which back-fills $collections by mapping the queried ids to their collections, and then adds a nested per-collection clause. The old column whitelist saw a Nested where with no column, concluded the query was too complex to reason about, and fell back to the list tag — using the very $collections that Statamic had just derived from the ids. Worth noting this only reproduces with ids that actually resolve: with fake ids the collection map is empty, no nested clause is added, and the whitelist happened to work. A unit test over synthetic where clauses would have passed. Replaces the whitelist with set-bounding, which is both simpler and provably correct: a query carrying an AND-ed equality clause on `id` can only return a subset of those ids, so no entry created later can appear in it and no list tag is needed. Everything ANDed alongside — status, site, Statamic's nested collection clauses — can only narrow. A top-level OR is the one thing that can admit outside rows, so it disqualifies the query, as do NotIn and `!=`, which exclude ids rather than bounding to them. Verified against meerdervoort: a reusable block now records its item tag alone, and two pages embedding block A clear while a page embedding block B does not. The list tag cases are unchanged — carousels, count(), unfiltered queries, by-author queries and whereNotIn('id') all still record it. Co-Authored-By: Claude Opus 5 --- src/Recording/DetectsItemLookups.php | 69 +++++++++++++++++----------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/src/Recording/DetectsItemLookups.php b/src/Recording/DetectsItemLookups.php index e5b732c..15a8ee0 100644 --- a/src/Recording/DetectsItemLookups.php +++ b/src/Recording/DetectsItemLookups.php @@ -8,53 +8,66 @@ * Distinguishes "this page renders these specific items" from "this page renders * whatever matches". * - * This is the whole basis for targeted invalidation. A query that looks items up - * by id can only be affected by changes to those items, so it records item tags - * alone. Any other query can produce a different result set once an item is - * *created* — an entry the page has never seen and whose id is therefore in no - * tag set — so it must also record the list tag for its scope. + * This is the whole basis for targeted invalidation. The question is only ever + * whether an item created *later* could appear in this query's results. If it + * could, the page must react to saves of items it has never seen, which item tags + * cannot express — only a list tag can. * - * It reproduces automatically what the previous config expressed by hand as - * `['block' => 'x', 'field' => 'y']` versus `['block' => 'x']`, including for - * blocks that choose between those modes at runtime. + * The test is set-bounding, not a list of columns believed to be harmless: a query + * carrying an AND-ed equality clause on `id` can only ever return a subset of + * those ids. Every other clause ANDed alongside it — a status filter, a site + * filter, the nested per-collection clauses Statamic's whereStatus() adds — can + * only narrow that set further, never widen it. A top-level OR is the one thing + * that can admit rows from outside, so it disqualifies the query. + * + * Getting this wrong in the permissive direction under-invalidates, which is the + * one failure this addon must not have. Getting it wrong the other way merely + * clears more pages than necessary. */ trait DetectsItemLookups { /** - * Columns that narrow a result set without changing which items could ever - * appear in it. A query filtered only by these is still a list query. + * Clause types that pin results to a known set of ids. Notably not NotIn or a + * `!=` operator, which exclude ids rather than bounding to them. */ - private const NON_DISCRIMINATING = ['id', 'site', 'locale', 'status', 'published']; + private const BOUNDING_TYPES = ['Basic', 'In']; - /** - * True only when the query is pinned to specific ids. Note that an empty - * where clause is emphatically *not* an id lookup: "everything in this - * collection" is the broadest list query there is. - */ protected function isItemLookup(): bool { + // An empty where clause is emphatically not an id lookup: "everything in + // this collection" is the broadest list query there is. if (empty($this->wheres)) { return false; } - $pinnedToIds = false; + $bounded = false; foreach ($this->wheres as $where) { - // Nested clauses carry no column of their own and imply a query too - // complex to reason about. Treat as a list query. - $column = $where['column'] ?? null; - - if ($column === 'id') { - $pinnedToIds = true; - - continue; + if (($where['boolean'] ?? 'and') !== 'and') { + return false; } - if (! in_array($column, self::NON_DISCRIMINATING, true)) { - return false; + if ($this->boundsResultsToIds($where)) { + $bounded = true; } } - return $pinnedToIds; + return $bounded; + } + + /** + * @param array $where + */ + private function boundsResultsToIds(array $where): bool + { + if (($where['column'] ?? null) !== 'id') { + return false; + } + + if (! in_array($where['type'] ?? null, self::BOUNDING_TYPES, true)) { + return false; + } + + return ($where['type'] === 'In') || ($where['operator'] ?? '=') === '='; } } From e93a24ddaff2df703e90afe59ade633659811706 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 14:50:19 +0200 Subject: [PATCH 06/15] docs: tighten the README to install, behaviour and commands Cuts the prose around storage drivers, the safety net and the notes to what a developer needs to install it and predict what a save clears. Adds the transitive nesting behaviour, which is the question the previous version left unanswered. Co-Authored-By: Claude Opus 5 --- README.md | 223 ++++++++++++++++++------------------------------------ 1 file changed, 72 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index 1b2a640..79a3c19 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Targeted static cache invalidation for Statamic, with no configuration. Pages record what they read while they render. Saving content clears exactly the -cached pages that read it — no block index, no rule lists, nothing to keep in sync +cached pages that read it — no block index, no rule lists, nothing to maintain when you add a pagebuilder block. [![Latest Release](https://img.shields.io/github/v/release/roxdigital/cache-invalidation)](https://github.com/roxdigital/cache-invalidation/releases) @@ -11,21 +11,12 @@ when you add a pagebuilder block. [![Statamic](https://img.shields.io/badge/Statamic-6.x-FF269E)](https://statamic.com) [![License: MIT](https://img.shields.io/badge/License-MIT-green)](LICENSE) -## Requirements - -| Dependency | Version | -|------------|---------| -| PHP | `^8.4` | -| Laravel | `^12.0 \|\| ^13.0` | -| Statamic | `^6.0` | - -Works with both the `half` and `full` static caching strategies. - ---- +Requires PHP `^8.4`, Laravel `^12.0 || ^13.0`, Statamic `^6.0`. Works with both the +`half` and `full` static caching strategies. ## Installation -Add the GitHub VCS source to your project's `composer.json`: +Add the VCS source to your project's `composer.json`: ```json { @@ -42,99 +33,65 @@ Add the GitHub VCS source to your project's `composer.json`: composer require roxdigital/cache-invalidation ``` -That's the whole installation. There is nothing to publish, no migration to run, -and no configuration to write. The addon registers itself as Statamic's -invalidator and creates its own storage on first use. - -Verify with: +That's it. Nothing to publish, no migration, no configuration. The addon registers +itself as Statamic's invalidator and creates its own storage on first use. Verify +with: ```bash php artisan cache-invalidation:doctor ``` ---- - ## How it works -### Dependencies are observed, not declared - -While a page renders, the addon watches what it reads and records a set of tags -against the page's URL when it enters the static cache: +While a page renders, the addon watches what it reads and stores a set of tags +against its URL when the page enters the static cache: ``` https://site.test/over-ons - entry:9f2c… an entry it rendered - collection:articles a query it ran against a collection + entry:9f2c… an entry it rendered + collection:articles a query it ran against a collection term:departments::sales - taxonomy:departments global:footer form:contact ``` -On save, the changed item is turned into the same tags, and every URL carrying one -of them is cleared. Because the template is the only thing that decides what a -page reads, there is no second copy of that knowledge to drift out of date. - -### Item tags and list tags +On save, the changed item is turned into the same tags and every URL carrying one +of them is cleared. The template is the only thing that decides what a page reads, +so there is no second copy of that knowledge to drift out of date. -The distinction that makes this targeted rather than blunt: +**Item tags vs list tags.** A query pinned to ids — `Entry::find($id)`, an +`entries` field being augmented — records only those items, so a reusable block on +three pages clears only those three. Any other query also records a list tag for +its scope, because an entry created tomorrow has an id that is in no tag set yet; +`collection:articles` is what clears a "latest three articles" carousel. -- A query pinned to ids — `Entry::find($id)`, an `entries` field being augmented — - records **item tags** only. A reusable block embedded on three pages clears only - those three. -- Any other query also records a **list tag** for its scope. A carousel showing - "the latest three articles" has never seen an article created tomorrow, so its - id can be in no tag set; the `collection:articles` tag is what clears it. +**Nesting is free.** A reusable block's reads are the embedding page's reads, at any +depth. If page C embeds a block that pulls in a global and another entry, page C +carries all three dependencies and clears when any of them is saved. -A block that picks between those modes at runtime — automatic, by author, manual — -gets the right answer for whichever branch actually ran. +**Where reads are observed:** entries and terms at the query builder, globals at +augmentation (so a global is only tagged where it is actually read), forms at +`FormRepository::find()`. -### Where reads are observed - -| Content | Hook | -|---|---| -| Entries | `EntryQueryBuilder::getFilteredKeys()` and `getItems()` | -| Terms | `TermQueryBuilder`, via a replaced `TermRepository::query()` | -| Globals | `Variables::newAugmentedInstance()`, recorded on first value read | -| Forms | `FormRepository::find()`, which is how the form fieldtype augments | - -Globals are recorded lazily, on read. Statamic hydrates every global set into -every view whether a template uses it or not, so a set rendered in your layout -ends up on every page while a set rendered by one block ends up only on that -block's pages. - -### What happens on save +### What a save clears | Saved | Cleared | |---|---| -| Entry | Its own URL and descendants, pages carrying `entry:{id}`, pages carrying `collection:{handle}` | +| Entry | Its own URL and descendants, pages carrying `entry:{id}`, pages carrying its collection's list tag | | Term | Pages carrying `term:{taxonomy}::{slug}` or `taxonomy:{handle}` | -| Global set | Pages that read it — which is every page, if it is read in your layout | -| Form, or a forms blueprint | Pages that render that form | +| Global set | Pages that read it — every page, if it is read in your layout | +| Form or forms blueprint | Pages rendering that form | | Collection tree | Pages carrying that collection's list tag, plus the URLs Statamic reports as moved | | Navigation | Every cached URL | -| Anything | Plus any cached URL with no recorded dependencies (see below) | The cache is never flushed wholesale — URLs are invalidated individually, so -`nocache` regions and the graph survive and pages come back without a global -re-render. - -A navigation save is the one deliberate exception, per the shape of the problem: a -reorder or relabel changes links rendered in shared layout, and no per-page -dependency can express that. - -### The safety net +`nocache` regions survive and pages come back without a global re-render. -Any URL that is cached but absent from the graph is treated as depending on -everything, and cleared by the next save of anything. This covers pages cached -before the addon was installed, a lost graph, and any bug in the recorders — the -failure mode is over-invalidation that heals after one render, rather than a page -that stays stale with no symptom. - -`cache-invalidation:stats` reports how many such URLs exist. Right after a deploy -or a flush that number is everything; it drops to zero as pages are rendered. - ---- +**Safety net.** A URL that is cached but absent from the graph is treated as +depending on everything and cleared by the next save. That covers pages cached +before install and any recorder bug, so the failure mode is over-invalidation that +heals after one render rather than a page that stays stale unnoticed. Right after a +deploy that is every page; it drops to zero as pages render. ## Commands @@ -142,58 +99,46 @@ or a flush that number is everything; it drops to zero as pages are rendered. # What does this page depend on? php artisan cache-invalidation:why https://site.test/over-ons -# What would clear if I saved this? Accepts an entry id, term id, -# global set handle, form handle, or a raw tag. +# What would clear if I saved this? Takes an entry id, term id, +# global handle, form handle, or a raw tag. php artisan cache-invalidation:affected 9f2c1b4e-… php artisan cache-invalidation:affected collection:articles # Graph size and coverage of the static cache. php artisan cache-invalidation:stats -# Deploy check. Exits non-zero when invalidation cannot work. +# Deploy check — exits non-zero when invalidation cannot work. php artisan cache-invalidation:doctor ``` -Set `CACHE_INVALIDATION_DEBUG=true` to add an `X-Cache-Tags` header to responses -as they are cached, so a page's dependencies are readable in devtools. - ---- +`CACHE_INVALIDATION_DEBUG=true` adds an `X-Cache-Tags` header as pages are cached, +so you can read a page's dependencies in devtools. ## Configuration -There is nothing you need to set. The file exists for two choices: +Nothing needs setting. Publish it only to change a default: + +```bash +php artisan vendor:publish --tag=cache-invalidation-config +``` | Key | Default | Effect | |-----|---------|--------| -| `driver` | `sqlite` | Where the graph lives — `sqlite`, `database` or `null` | +| `driver` | `sqlite` | `sqlite`, `database` or `null` | | `sqlite_path` | `storage/statamic/cache-invalidation.sqlite` | | | `database_connection` | `null` | Connection for the `database` driver | | `debug` | `false` | `X-Cache-Tags` header | -```bash -php artisan vendor:publish --tag=cache-invalidation-config -``` - -**`sqlite`** owns its own connection and creates the file and schema on first -write, so it works on a site with no `DB_CONNECTION` configured — the common -Statamic case. It sits beside Statamic's own static cache bookkeeping so the graph -and the cache share a directory and a deploy that discards one discards both. - -**`database`** keeps the graph in your application database instead. Requires -`php artisan migrate`. - -**`null`** records nothing, which makes every cached URL untracked and therefore -clears the whole cache on every save. A conservative fallback, not a production -driver. +`sqlite` owns its own connection and creates its file and schema on first write, so +it needs no `DB_CONNECTION`. `database` keeps the graph in your app database +instead and needs `php artisan migrate`. `null` records nothing, which clears the +whole cache on every save — a conservative fallback, not a production driver. > The graph must be visible to every process that renders or invalidates pages. On -> a single server that is automatic. If web and queue run on separate machines with -> separate filesystems, use the `database` driver — and note that a file-backed -> static cache would already be inconsistent in that setup. +> a single server that is automatic; if web and queue run on separate filesystems, +> use `database`. ---- - -## Escape hatch +## Declaring a dependency by hand One directive, for the only thing observation cannot see: a dependency a template reacts to without reading. @@ -203,55 +148,31 @@ reacts to without reading. @cachetags('collection:vacancies') ``` -Everything a template actually reads is recorded on its own. This is for the -exception. - ---- - ## Upgrading from 1.x -Remove the rule keys from `config/cache_invalidation.php` — all of -`pagebuilder_collections`, `collection_entry_rules`, `collection_urls`, -`globals_flush_all`, `navs_flush_all`, `collection_trees_flush_all`, -`forms_flush_all`, `global_target_blocks`, `global_urls`, -`taxonomy_target_blocks` and `taxonomy_urls` are gone. `cache-invalidation:doctor` -lists any that are still present. - -If `statamic.static_caching.invalidation.class` points at -`ContentDependencyInvalidator`, you can leave it — the addon recognises its own -class names and upgrades the pin. A subclass of your own is still respected, but -`customEntryUrls()` no longer exists; the relations it existed for are now -observed automatically. - -After deploying, expect one round of broad invalidation while pages are rendered -and the graph fills. `cache-invalidation:stats` shows the progress. - ---- - -## Notes - -- **Assets are not tracked.** Saving an asset clears nothing extra, matching 1.x. -- **Cold pages record nothing**, which is correct — there is nothing cached to - clear. On full measure, run `statamic:static:warm` after a deploy so the graph - fills promptly rather than lazily. -- **Recording only happens on a cache miss**, during a render you are already - paying for. Invalidation is one indexed lookup plus the deletes; nothing walks - content, which matters when a single queue worker handles the job — or when - `QUEUE_CONNECTION=sync` runs it inside the editor's save request. -- **A page with more than 2,000 dependencies** collapses to a single overflow tag - and is treated as depending on everything. +Delete the rule keys from `config/cache_invalidation.php` — all of them are gone. +`cache-invalidation:doctor` lists any still present. A +`statamic.static_caching.invalidation.class` pointing at +`ContentDependencyInvalidator` can stay: the addon recognises its own class names +and upgrades the pin. Your own subclass is still respected, but +`customEntryUrls()` is gone — the relations it existed for are now observed. ---- +Expect one round of broad invalidation after deploying while the graph fills. -## Changelog +## Good to know -See [CHANGELOG.md](CHANGELOG.md). +- Assets are not tracked; saving one clears nothing extra. +- On full measure, run `statamic:static:warm` after a deploy so the graph fills + promptly instead of lazily. +- Recording only happens on a cache miss. Invalidation is one indexed lookup plus + the deletes — nothing walks content, which matters with a single queue worker or + `QUEUE_CONNECTION=sync`. +- A page with more than 2,000 dependencies is treated as depending on everything. ## License -Released under the [MIT License](LICENSE). Copyright © 2026 Rox Digital. +Released under the [MIT License](LICENSE). Copyright © 2026 Rox Digital. Provided +**as is**, without warranty — invalidation decides what your visitors see, so +verify it against your own site before relying on it in production. -Free to use, modify and distribute, including commercially, provided the copyright -notice is kept intact. Provided **as is**, without warranty of any kind — Rox Digital -accepts no liability. Invalidation decides what your visitors see: verify it against -your own site and caching strategy before relying on it in production. +See [CHANGELOG.md](CHANGELOG.md) for release notes. From 2571b26078f4bf6f1a1dbe6878b83fe19859003d Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 15:44:47 +0200 Subject: [PATCH 07/15] test: add a test suite driven by real content and real save events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 61 tests over Testbench and Statamic's AddonTestCase. Recording is asserted through real queries against real entries, never through hand-built where clauses. That is not a style preference — it is the lesson from the list-tag leak fixed in 6966937. Statamic's entries fieldtype augments through a StatusQueryBuilder whose status filter adds a nested clause only when the queried ids actually resolve, so a query assembled from invented ids looks simple and the buggy implementation passed. Verified by restoring the pre-fix implementation: the augmented-entries-field test fails with the right message, and it also catches a second latent bug there — whereNotIn('id') was treated as an id lookup, which under-invalidated, the one direction that serves stale pages. Invalidation is asserted end to end by calling save() on real content, so the whole chain runs: Statamic's Invalidate subscriber, the invalidator and the cacher. Pages are seeded into the cache with a stated tag set rather than rendered, so each test declares its dependencies in one line. The suite was mutation-checked rather than assumed to bite. Removing the untracked safety net, the nav clear-all branch, the overflow tag, the graph lookup, or getItemUrls each fails exactly the tests that should catch it. Writing it surfaced three defects, all fixed here: - Contextual $rules bindings resolve from config, which yields null when the key is missing. giveConfig now has a default, and the constructor accepts null, so a host app whose static_caching config predates invalidation.rules or sets it to null no longer fatals when the invalidator resolves. - isOwnInvalidator matched a namespace prefix, so it claimed any class under the addon namespace rather than only invalidators the addon has shipped. Now an explicit list, which is also self-documenting about the removed v1 class. - The cacher's best-effort recording had never been exercised; there is now a test that an unwritable graph does not break a page render. CI runs the suite on PHP 8.4 and 8.5 alongside the existing syntax check. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 32 ++- .gitignore | 2 + composer.json | 20 +- phpunit.xml | 23 ++ src/Invalidation/GraphInvalidator.php | 8 +- src/ServiceProvider.php | 17 +- tests/Cachers/RecordsDependenciesTest.php | 134 +++++++++ tests/Doubles/HostInvalidator.php | 14 + tests/Graph/SqliteGraphTest.php | 122 ++++++++ .../InvalidatesByDependencyTest.php | 270 ++++++++++++++++++ tests/Invalidation/InvalidatorWiringTest.php | 179 ++++++++++++ tests/Recording/DependencyRecorderTest.php | 78 +++++ tests/Recording/GlobalsTermsAndFormsTest.php | 116 ++++++++ tests/Recording/ItemAndListTagsTest.php | 180 ++++++++++++ tests/TestCase.php | 93 ++++++ tests/__fixtures__/dev-null/.gitkeep | 0 16 files changed, 1279 insertions(+), 9 deletions(-) create mode 100644 phpunit.xml create mode 100644 tests/Cachers/RecordsDependenciesTest.php create mode 100644 tests/Doubles/HostInvalidator.php create mode 100644 tests/Graph/SqliteGraphTest.php create mode 100644 tests/Invalidation/InvalidatesByDependencyTest.php create mode 100644 tests/Invalidation/InvalidatorWiringTest.php create mode 100644 tests/Recording/DependencyRecorderTest.php create mode 100644 tests/Recording/GlobalsTermsAndFormsTest.php create mode 100644 tests/Recording/ItemAndListTagsTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/__fixtures__/dev-null/.gitkeep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc415a3..8a932b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, v2] pull_request: - branches: [main] + branches: [main, v2] jobs: syntax: @@ -22,4 +22,30 @@ jobs: coverage: none - name: Check PHP syntax - run: find src config -name '*.php' -print0 | xargs -0 -n1 php -l + run: find src config database tests -name '*.php' -print0 | xargs -0 -n1 php -l + + tests: + name: Tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_sqlite, sqlite3 + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: vendor/bin/phpunit --no-coverage diff --git a/.gitignore b/.gitignore index f8f3f0f..9c51aa6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /.phpunit.cache/ .DS_Store *.lock +/tests/__fixtures__/dev-null/* +!/tests/__fixtures__/dev-null/.gitkeep diff --git a/composer.json b/composer.json index ec2fdca..373d031 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "roxdigital/cache-invalidation", - "description": "Configurable Statamic static/half cache invalidation for pagebuilder-driven sites.", + "description": "Zero-config Statamic static cache invalidation, driven by what pages actually read.", "type": "statamic-addon", "license": "MIT", "autoload": { @@ -8,6 +8,11 @@ "RoxDigital\\CacheInvalidation\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "RoxDigital\\CacheInvalidation\\Tests\\": "tests/" + } + }, "extra": { "laravel": { "providers": [ @@ -16,7 +21,7 @@ }, "statamic": { "name": "Cache Invalidation", - "description": "Configurable static/half cache invalidation for pagebuilder-driven sites." + "description": "Zero-config static cache invalidation, driven by what pages actually read." } }, "require": { @@ -24,6 +29,17 @@ "php": "^8.4", "statamic/cms": "^6.0" }, + "require-dev": { + "mockery/mockery": "^1.6.10", + "orchestra/testbench": "^10.8 || ^11.0", + "phpunit/phpunit": "^12.5" + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e27d651 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + + + + diff --git a/src/Invalidation/GraphInvalidator.php b/src/Invalidation/GraphInvalidator.php index 786ed35..4d2be8b 100644 --- a/src/Invalidation/GraphInvalidator.php +++ b/src/Invalidation/GraphInvalidator.php @@ -28,12 +28,16 @@ final class GraphInvalidator extends DefaultInvalidator { public function __construct( Cacher $cacher, - array $rules, + ?array $rules, private readonly DependencyGraph $graph, private readonly TagResolver $tags, private readonly CachedUrls $cached, ) { - parent::__construct($cacher, $rules); + // Nullable because $rules is resolved from config, and a host app whose + // static_caching config predates the invalidation.rules key — or sets it + // to null outright — would otherwise fatal on a TypeError. The rules + // themselves are unused; they exist so DefaultInvalidator stays satisfied. + parent::__construct($cacher, $rules ?? []); } public function invalidate($item): void diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 0de40e0..04fb832 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -195,7 +195,10 @@ private function registerInvalidator(): void $this->app ->when($concrete) ->needs('$rules') - ->giveConfig('statamic.static_caching.invalidation.rules'); + // The default matters: a host app whose static_caching config + // predates the invalidation.rules key, or sets it to null, would + // otherwise resolve the invalidator with null and fatal. + ->giveConfig('statamic.static_caching.invalidation.rules', []); } } @@ -228,9 +231,19 @@ private function registerReadRecorders(): void Statamic::repository(FormRepositoryContract::class, TrackingFormRepository::class); } + /** + * Invalidator classes this addon has shipped, including removed ones. + * + * An explicit list rather than a namespace prefix: a prefix would also claim + * any other class that happens to live under this namespace, which is broader + * than the intent and would silently override something it should not. + */ private function isOwnInvalidator(string $class): bool { - return str_starts_with($class, __NAMESPACE__ . '\\'); + return in_array($class, [ + GraphInvalidator::class, + __NAMESPACE__ . '\ContentDependencyInvalidator', // v1, removed in 2.0 + ], true); } private function graphDriver(): string diff --git a/tests/Cachers/RecordsDependenciesTest.php b/tests/Cachers/RecordsDependenciesTest.php new file mode 100644 index 0000000..f432d55 --- /dev/null +++ b/tests/Cachers/RecordsDependenciesTest.php @@ -0,0 +1,134 @@ +assertInstanceOf(TrackingApplicationCacher::class, $cacher); + + // Statamic's cache middleware branches on these to decide which status + // codes are cacheable and whether exclusions apply. A decorator would + // fail them silently, which is why the concrete cachers are subclassed. + $this->assertInstanceOf(ApplicationCacher::class, $cacher); + $this->assertInstanceOf(AbstractCacher::class, $cacher); + } + + #[Test] + public function the_full_measure_cacher_is_replaced_and_still_passes_statamics_type_checks(): void + { + $cacher = $this->fullMeasureCacher(); + + $this->assertInstanceOf(TrackingFileCacher::class, $cacher); + $this->assertInstanceOf(FileCacher::class, $cacher); + $this->assertInstanceOf(AbstractCacher::class, $cacher); + } + + #[Test] + public function caching_a_page_records_the_recorded_tags_against_its_url(): void + { + $cacher = app(Cacher::class); + $this->recorder->add('entry:1', 'global:footer'); + + $request = Request::create('http://localhost/about'); + $cacher->cachePage($request, new Response('')); + + $this->assertEqualsCanonicalizing( + ['entry:1', 'global:footer'], + $this->graph->tagsFor($cacher->getUrl($request)), + ); + } + + #[Test] + public function full_measure_records_against_the_same_url_as_half_measure(): void + { + // Both strategies must behave identically; this is the assertion that + // stops one driver silently keying rows differently from the other. + $half = app(Cacher::class); + $full = $this->fullMeasureCacher(); + + $request = Request::create('http://localhost/about'); + + $this->assertSame($half->getUrl($request), $full->getUrl($request)); + + $this->recorder->add('entry:1'); + $full->cachePage($request, new Response('')); + + $this->assertSame(['entry:1'], $this->graph->tagsFor($full->getUrl($request))); + } + + #[Test] + public function an_excluded_url_is_not_recorded(): void + { + config(['statamic.static_caching.exclude.urls' => ['/private']]); + + $cacher = app(Cacher::class); + $this->recorder->add('entry:1'); + + $request = Request::create('http://localhost/private'); + $cacher->cachePage($request, new Response('')); + + // An excluded URL is never cached, so it must not gain a row either — + // otherwise the graph would claim coverage it does not have. + $this->assertSame([], $this->graph->tagsFor($cacher->getUrl($request))); + } + + #[Test] + public function a_page_that_recorded_nothing_is_left_untracked(): void + { + $cacher = app(Cacher::class); + + $request = Request::create('http://localhost/empty'); + $cacher->cachePage($request, new Response('')); + + $url = $cacher->getUrl($request); + + $this->assertSame([], $this->graph->tagsFor($url)); + $this->assertSame([$url], $this->graph->untracked([$url])); + } + + #[Test] + public function recording_failure_does_not_break_the_page_render(): void + { + // A visitor's page must not 500 because the graph could not be written. + // The URL is then untracked, and the safety net clears it on the next save. + config(['cache_invalidation.sqlite_path' => '/nonexistent-directory/x/y.sqlite']); + app()->forgetInstance(\RoxDigital\CacheInvalidation\Graph\DependencyGraph::class); + + $cacher = app(Cacher::class); + $this->recorder->add('entry:1'); + + $cacher->cachePage(Request::create('http://localhost/about'), new Response('')); + + $this->assertTrue(true, 'cachePage completed despite an unwritable graph'); + } + + private function fullMeasureCacher(): Cacher + { + config([ + 'statamic.static_caching.strategy' => 'full', + 'statamic.static_caching.strategies.full.path' => __DIR__ . '/../__fixtures__/dev-null/static', + ]); + + app()->forgetInstance(\Statamic\StaticCaching\StaticCacheManager::class); + + return app(Cacher::class); + } +} diff --git a/tests/Doubles/HostInvalidator.php b/tests/Doubles/HostInvalidator.php new file mode 100644 index 0000000..c8077de --- /dev/null +++ b/tests/Doubles/HostInvalidator.php @@ -0,0 +1,14 @@ + 'nonexistent']); + + $this->graph->record('https://site.test/a', ['entry:1']); + + $this->assertSame(['entry:1'], $this->graph->tagsFor('https://site.test/a')); + $this->assertInstanceOf(SqliteGraph::class, $this->graph); + } + + #[Test] + public function it_finds_urls_by_any_of_their_tags(): void + { + $this->graph->record('https://site.test/a', ['entry:1', 'global:footer']); + $this->graph->record('https://site.test/b', ['entry:2', 'global:footer']); + $this->graph->record('https://site.test/c', ['entry:3']); + + $this->assertSame(['https://site.test/a'], $this->graph->urlsFor(['entry:1'])); + + $this->assertEqualsCanonicalizing( + ['https://site.test/a', 'https://site.test/b'], + $this->graph->urlsFor(['global:footer']), + ); + + // A URL matching more than one of the given tags appears once, and an + // unknown tag contributes nothing. + $matched = $this->graph->urlsFor(['entry:1', 'global:footer', 'entry:missing']); + + $this->assertEqualsCanonicalizing(['https://site.test/a', 'https://site.test/b'], $matched); + $this->assertSame(1, count(array_keys($matched, 'https://site.test/a', true))); + } + + #[Test] + public function recording_replaces_a_urls_previous_tags_rather_than_adding_to_them(): void + { + $this->graph->record('https://site.test/a', ['entry:1', 'entry:2']); + $this->graph->record('https://site.test/a', ['entry:3']); + + $this->assertSame(['entry:3'], $this->graph->tagsFor('https://site.test/a')); + $this->assertSame([], $this->graph->urlsFor(['entry:1'])); + } + + #[Test] + public function recording_an_empty_tag_set_leaves_the_url_untracked(): void + { + $this->graph->record('https://site.test/a', ['entry:1']); + $this->graph->record('https://site.test/a', []); + + $this->assertSame([], $this->graph->tagsFor('https://site.test/a')); + + // Untracked rather than "depends on nothing" — the safety net must still + // reach it, or a page whose recording failed would stay stale forever. + $this->assertSame( + ['https://site.test/a'], + $this->graph->untracked(['https://site.test/a']), + ); + } + + #[Test] + public function untracked_returns_only_urls_absent_from_the_graph(): void + { + $this->graph->record('https://site.test/known', ['entry:1']); + + $this->assertSame( + ['https://site.test/unknown'], + $this->graph->untracked(['https://site.test/known', 'https://site.test/unknown']), + ); + + $this->assertSame([], $this->graph->untracked([])); + } + + #[Test] + public function it_distinguishes_urls_that_differ_only_in_their_query_string(): void + { + // Identity is a hash, so a truncating index would collapse these. + $long = 'https://site.test/'.str_repeat('a', 200); + + $this->graph->record($long.'?page=1', ['entry:1']); + $this->graph->record($long.'?page=2', ['entry:2']); + + $this->assertSame(['entry:1'], $this->graph->tagsFor($long.'?page=1')); + $this->assertSame(['entry:2'], $this->graph->tagsFor($long.'?page=2')); + } + + #[Test] + public function it_handles_more_tags_than_fit_in_one_insert(): void + { + $tags = array_map(fn (int $i): string => "entry:{$i}", range(1, 1200)); + + $this->graph->record('https://site.test/big', $tags); + + $this->assertCount(1200, $this->graph->tagsFor('https://site.test/big')); + $this->assertSame(['https://site.test/big'], $this->graph->urlsFor(['entry:1200'])); + } + + #[Test] + public function forget_and_flush_remove_rows(): void + { + $this->graph->record('https://site.test/a', ['entry:1']); + $this->graph->record('https://site.test/b', ['entry:2']); + + $this->graph->forget('https://site.test/a'); + $this->assertSame(['https://site.test/b'], $this->graph->urls()); + + $this->graph->flush(); + $this->assertSame([], $this->graph->urls()); + $this->assertSame(['urls' => 0, 'tags' => 0, 'rows' => 0], $this->graph->stats()); + } +} diff --git a/tests/Invalidation/InvalidatesByDependencyTest.php b/tests/Invalidation/InvalidatesByDependencyTest.php new file mode 100644 index 0000000..eecd0d8 --- /dev/null +++ b/tests/Invalidation/InvalidatesByDependencyTest.php @@ -0,0 +1,270 @@ +cacher = app(Cacher::class); + $this->domain = $this->cacher->getBaseUrl(); + + Collection::make('articles')->save(); + Collection::make('pages')->save(); + } + + #[Test] + public function saving_an_entry_clears_pages_that_rendered_it_and_leaves_others(): void + { + $entry = $this->article('one'); + + $this->cache('/uses-it', ["entry:{$entry->id()}"]); + $this->cache('/unrelated', ['entry:something-else']); + + $entry->data(['title' => 'Changed'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function saving_an_entry_clears_pages_that_listed_its_collection(): void + { + $entry = $this->article('one'); + + $this->cache('/overview', ['collection:articles']); + $this->cache('/unrelated', ['collection:pages']); + + $entry->data(['title' => 'Changed'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function creating_an_entry_clears_listings_that_could_never_have_known_its_id(): void + { + // The reason list tags exist. A brand new entry's id appears in no recorded + // tag set, so only the collection tag can reach the pages that list it. + $this->cache('/overview', ['collection:articles']); + $this->cache('/detail', ['entry:some-existing-id']); + + $this->article('brand-new'); + + $this->assertCachedIs(['/detail']); + } + + #[Test] + public function a_page_embedding_one_reusable_block_is_unaffected_by_another(): void + { + Collection::make('reusable_blocks')->save(); + + $a = tap(Entry::make()->collection('reusable_blocks')->slug('a')->data(['title' => 'A']))->save(); + $b = tap(Entry::make()->collection('reusable_blocks')->slug('b')->data(['title' => 'B']))->save(); + + $this->cache('/uses-a-1', ["entry:{$a->id()}"]); + $this->cache('/uses-a-2', ["entry:{$a->id()}"]); + $this->cache('/uses-b', ["entry:{$b->id()}"]); + + $a->data(['title' => 'A changed'])->save(); + + $this->assertCachedIs(['/uses-b']); + } + + #[Test] + public function a_transitive_dependency_clears_the_embedding_page(): void + { + // Page C embeds a reusable block that pulls in a global and another entry. + // All three land in C's tag set, so any of them clears it. + $this->makeGlobalSet('footer', ['phone' => '123']); + + $block = tap(Entry::make()->collection('pages')->slug('block')->data(['title' => 'Block']))->save(); + $inner = $this->article('inner'); + + $this->cache('/page-c', ["entry:{$block->id()}", 'global:footer', "entry:{$inner->id()}"]); + $this->cache('/page-d', ['entry:unrelated']); + + GlobalSet::find('footer')->in('default')->data(['phone' => '456'])->save(); + + $this->assertCachedIs(['/page-d']); + } + + #[Test] + public function saving_a_global_clears_only_pages_that_read_it(): void + { + $this->makeGlobalSet('footer', ['phone' => '123']); + $this->makeGlobalSet('seo', ['title' => 'Site']); + + $this->cache('/reads-footer', ['global:footer']); + $this->cache('/reads-seo', ['global:seo']); + + GlobalSet::find('footer')->in('default')->data(['phone' => '456'])->save(); + + $this->assertCachedIs(['/reads-seo']); + } + + #[Test] + public function saving_a_term_clears_pages_that_rendered_it_or_queried_its_taxonomy(): void + { + Taxonomy::make('topics')->save(); + $term = tap(Term::make('news')->taxonomy('topics')->data(['title' => 'News']))->save(); + + $this->cache('/shows-term', ['term:topics::news']); + $this->cache('/lists-taxonomy', ['taxonomy:topics']); + $this->cache('/unrelated', ['taxonomy:other']); + + $term->in('default')->data(['title' => 'Nieuws'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function saving_a_form_clears_only_pages_rendering_it(): void + { + Form::make('contact')->save(); + Form::make('newsletter')->save(); + + $this->cache('/contact', ['form:contact']); + $this->cache('/signup', ['form:newsletter']); + + Form::find('contact')->title('Contact us')->save(); + + $this->assertCachedIs(['/signup']); + } + + #[Test] + public function saving_a_navigation_clears_every_cached_url(): void + { + Nav::make('main')->title('Main')->save(); + + $this->cache('/a', ['entry:1']); + $this->cache('/b', ['entry:2']); + + Nav::find('main')->title('Main nav')->save(); + + // A reorder or relabel changes links rendered in shared layout, and no + // per-page dependency can express that. + $this->assertCachedIs([]); + } + + #[Test] + public function an_untracked_cached_url_is_cleared_by_any_save(): void + { + $entry = $this->article('one'); + + $this->cacheWithoutRecording('/untracked'); + $this->cache('/tracked', ['entry:unrelated']); + + $entry->data(['title' => 'Changed'])->save(); + + $this->assertCachedIs(['/tracked']); + } + + #[Test] + public function a_page_marked_as_depending_on_everything_is_always_cleared(): void + { + $entry = $this->article('one'); + + $this->cache('/overflowed', [Tag::OVERFLOW]); + $this->cache('/tracked', ['entry:unrelated']); + + $entry->data(['title' => 'Changed'])->save(); + + $this->assertCachedIs(['/tracked']); + } + + #[Test] + public function saving_an_entry_clears_its_own_url(): void + { + Collection::make('articles')->routes('/articles/{slug}')->save(); + $entry = $this->article('one'); + + $this->cache('/articles/one', []); + $this->graph->record($this->domain . '/articles/one', ['entry:something-else']); + $this->cache('/tracked', ['entry:unrelated']); + + $entry->data(['title' => 'Changed'])->save(); + + // Statamic resolves the entry's own URL and descendants; the graph only has + // to answer for pages that referenced it. + $this->assertCachedIs(['/tracked']); + } + + #[Test] + public function a_flush_clears_the_graph_as_well(): void + { + $this->cache('/a', ['entry:1']); + + \Statamic\Facades\StaticCache::flush(); + + $this->assertSame([], $this->graph->urls()); + } + + private function article(string $slug): \Statamic\Contracts\Entries\Entry + { + return tap(Entry::make()->collection('articles')->slug($slug)->data(['title' => ucfirst($slug)]))->save(); + } + + /** + * @param array $data + */ + private function makeGlobalSet(string $handle, array $data): void + { + $set = tap(GlobalSet::make($handle))->save(); + $set->makeLocalization('default')->data($data)->save(); + } + + /** + * @param list $tags + */ + private function cache(string $path, array $tags): void + { + $this->cacheWithoutRecording($path); + + if ($tags !== []) { + $this->graph->record($this->domain . $path, $tags); + } + } + + private function cacheWithoutRecording(string $path): void + { + $this->cacher->cacheUrl(md5($path), $path, $this->domain); + } + + /** + * @param list $expected + */ + private function assertCachedIs(array $expected): void + { + $this->assertEqualsCanonicalizing( + $expected, + $this->cacher->getUrls($this->domain)->values()->all(), + ); + } +} diff --git a/tests/Invalidation/InvalidatorWiringTest.php b/tests/Invalidation/InvalidatorWiringTest.php new file mode 100644 index 0000000..721b07f --- /dev/null +++ b/tests/Invalidation/InvalidatorWiringTest.php @@ -0,0 +1,179 @@ +set('statamic.static_caching.invalidation.class', static::$pin); + } + } + + #[Test] + public function it_claims_the_invalidator_when_the_config_leaves_it_unset(): void + { + $this->assertInstanceOf(GraphInvalidator::class, app(Invalidator::class)); + } + + #[Test] + public function it_upgrades_a_pin_at_one_of_its_own_removed_classes(): void + { + // Sites pin this by name. A 1.x site pinning ContentDependencyInvalidator + // would otherwise fatal on a class that no longer exists — or, before the + // class was removed, silently keep the old behaviour after an upgrade. + static::$pin = 'RoxDigital\CacheInvalidation\ContentDependencyInvalidator'; + $this->refreshApplication(); + + $this->assertSame( + GraphInvalidator::class, + config('statamic.static_caching.invalidation.class'), + ); + } + + #[Test] + public function it_respects_an_invalidator_belonging_to_the_host_app(): void + { + static::$pin = HostInvalidator::class; + $this->refreshApplication(); + + $this->assertSame(HostInvalidator::class, config('statamic.static_caching.invalidation.class')); + $this->assertInstanceOf(HostInvalidator::class, app(Invalidator::class)); + } + + #[Test] + public function it_resolves_the_invalidator_when_the_host_config_has_no_rules_key(): void + { + // Contextual $rules bindings resolve from config, which returns null when + // the key is missing. Without a default that fatals on a TypeError. + config(['statamic.static_caching.invalidation.rules' => null]); + app()->forgetInstance(Invalidator::class); + + $this->assertInstanceOf(GraphInvalidator::class, app(Invalidator::class)); + } + + #[Test] + public function it_refreshes_rather_than_purges_when_background_recache_is_on(): void + { + // DefaultInvalidator::refresh() flips a protected flag and delegates to + // invalidate(). v1 overrode invalidate() without checking it and always + // hard-purged, which silently broke background_recache. + config(['statamic.static_caching.background_recache' => true]); + + Collection::make('articles')->save(); + $entry = tap(Entry::make()->collection('articles')->slug('one')->data(['title' => 'One']))->save(); + + $cacher = Mockery::mock(Cacher::class); + $cacher->shouldReceive('refreshUrls')->once(); + $cacher->shouldNotReceive('invalidateUrls'); + + $invalidator = new GraphInvalidator( + $cacher, + [], + $this->graph, + app(TagResolver::class), + new CachedUrls($cacher), + ); + + $this->graph->record('https://site.test/a', ["entry:{$entry->id()}"]); + + $invalidator->refresh($entry); + } + + #[Test] + public function it_purges_when_background_recache_is_off(): void + { + config(['statamic.static_caching.background_recache' => false]); + + Collection::make('articles')->save(); + $entry = tap(Entry::make()->collection('articles')->slug('one')->data(['title' => 'One']))->save(); + + $cacher = Mockery::mock(Cacher::class); + $cacher->shouldReceive('invalidateUrls')->once(); + $cacher->shouldNotReceive('refreshUrls'); + + $invalidator = new GraphInvalidator( + $cacher, + [], + $this->graph, + app(TagResolver::class), + new CachedUrls($cacher), + ); + + $this->graph->record('https://site.test/a', ["entry:{$entry->id()}"]); + + $invalidator->refresh($entry); + } + + #[Test] + public function it_resolves_the_configured_graph_driver(): void + { + $this->assertInstanceOf(SqliteGraph::class, app(DependencyGraph::class)); + + config(['cache_invalidation.driver' => 'null']); + app()->forgetInstance(DependencyGraph::class); + $this->assertInstanceOf(NullGraph::class, app(DependencyGraph::class)); + + config(['cache_invalidation.driver' => 'database']); + app()->forgetInstance(DependencyGraph::class); + $this->assertInstanceOf(DatabaseGraph::class, app(DependencyGraph::class)); + } + + #[Test] + public function the_null_driver_treats_every_cached_url_as_untracked(): void + { + // Which is what makes it behave as "clear everything on every save". + $graph = new NullGraph; + + $this->assertSame(['a', 'b'], $graph->untracked(['a', 'b'])); + } + + #[Test] + public function the_recorder_is_reset_between_queued_jobs(): void + { + // A worker keeps the container alive across jobs, so without this the tag + // set would grow until it overflowed and every page would look like it + // depended on everything. + $this->recorder->add('entry:stale'); + + $job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class); + $job->shouldReceive('payload')->andReturn([]); + + event(new JobProcessing('sync', $job)); + + $this->assertSame([], app(DependencyRecorder::class)->tags()); + } +} diff --git a/tests/Recording/DependencyRecorderTest.php b/tests/Recording/DependencyRecorderTest.php new file mode 100644 index 0000000..3e4eb81 --- /dev/null +++ b/tests/Recording/DependencyRecorderTest.php @@ -0,0 +1,78 @@ +recorder->add('entry:1', 'entry:1', '', 'entry:2'); + + $this->assertSame(['entry:1', 'entry:2'], $this->recorder->tags()); + } + + #[Test] + public function reads_accumulate_flatly_regardless_of_nesting(): void + { + // There is no notion of "which block am I inside": a reusable block's reads + // are the embedding page's reads, at any depth. This is what makes + // transitive dependencies work without expansion logic. + $this->recorder->entries(['a']); + $this->recorder->globalSet('footer'); + $this->recorder->entries(['b']); + $this->recorder->form('contact'); + + $this->assertSame( + ['entry:a', 'global:footer', 'entry:b', 'form:contact'], + $this->recorder->tags(), + ); + } + + #[Test] + public function it_collapses_to_the_overflow_tag_past_the_cap(): void + { + $this->recorder->entries(array_map(fn (int $i): string => (string) $i, range(1, 2_500))); + + // Lossy in the safe direction: the page is now cleared by any content + // save, rather than silently keeping a truncated tag set that would leave + // it stale. + $this->assertSame([Tag::OVERFLOW], $this->recorder->tags()); + $this->assertTrue($this->recorder->overflowed()); + } + + #[Test] + public function an_overflowed_set_stays_overflowed(): void + { + $this->recorder->entries(array_map(fn (int $i): string => (string) $i, range(1, 2_500))); + $this->recorder->globalSet('footer'); + + $this->assertSame([Tag::OVERFLOW], $this->recorder->tags()); + } + + #[Test] + public function reset_clears_the_overflow_flag_too(): void + { + $this->recorder->entries(array_map(fn (int $i): string => (string) $i, range(1, 2_500))); + $this->recorder->reset(); + $this->recorder->entries(['a']); + + $this->assertSame(['entry:a'], $this->recorder->tags()); + $this->assertFalse($this->recorder->overflowed()); + } + + #[Test] + public function it_ignores_non_string_and_blank_identifiers(): void + { + $this->recorder->entries(['a', null, 42, '']); + $this->recorder->collections(['pages', null, '']); + + $this->assertSame(['entry:a', 'collection:pages'], $this->recorder->tags()); + } +} diff --git a/tests/Recording/GlobalsTermsAndFormsTest.php b/tests/Recording/GlobalsTermsAndFormsTest.php new file mode 100644 index 0000000..552b3ab --- /dev/null +++ b/tests/Recording/GlobalsTermsAndFormsTest.php @@ -0,0 +1,116 @@ +makeGlobalSet('footer', ['phone' => '123']); + + // Statamic hydrates every global set into every view whether a template + // touches it or not. Recording at hydration would mark every page as + // depending on every global, which is the behaviour this replaces. + $onFetch = $this->tagsRecordedDuring(fn () => GlobalSet::find('footer')->in('default')); + + $this->assertSame([], $onFetch, 'fetching a global set must not record it'); + + $onRead = $this->tagsRecordedDuring(function (): void { + $variables = GlobalSet::find('footer')->in('default'); + $variables->phone; + }); + + $this->assertSame(['global:footer'], $onRead); + } + + #[Test] + public function reading_one_global_does_not_record_another(): void + { + $this->makeGlobalSet('footer', ['phone' => '123']); + $this->makeGlobalSet('seo', ['title' => 'Site']); + + $tags = $this->tagsRecordedDuring(function (): void { + GlobalSet::find('footer')->in('default')->phone; + }); + + $this->assertSame(['global:footer'], $tags); + $this->assertNotContains('global:seo', $tags); + } + + #[Test] + public function a_taxonomy_query_records_the_taxonomy_and_each_term(): void + { + Taxonomy::make('topics')->save(); + Term::make('news')->taxonomy('topics')->data(['title' => 'News'])->save(); + Term::make('events')->taxonomy('topics')->data(['title' => 'Events'])->save(); + + $tags = $this->tagsRecordedDuring(fn () => Term::query()->where('taxonomy', 'topics')->get()); + + $this->assertContains('taxonomy:topics', $tags); + $this->assertContains('term:topics::news', $tags); + $this->assertContains('term:topics::events', $tags); + } + + #[Test] + public function finding_a_term_by_id_records_only_that_term(): void + { + Taxonomy::make('topics')->save(); + Term::make('news')->taxonomy('topics')->data(['title' => 'News'])->save(); + Term::make('events')->taxonomy('topics')->data(['title' => 'Events'])->save(); + + $tags = $this->tagsRecordedDuring(fn () => Term::find('topics::news')); + + $this->assertSame(['term:topics::news'], $tags); + $this->assertNotContains('taxonomy:topics', $tags); + } + + #[Test] + public function resolving_a_form_records_it(): void + { + Form::make('contact')->save(); + + $tags = $this->tagsRecordedDuring(fn () => Form::find('contact')); + + $this->assertSame(['form:contact'], $tags); + } + + #[Test] + public function listing_every_form_records_nothing(): void + { + Form::make('contact')->save(); + Form::make('newsletter')->save(); + + // FormRepository::all() resolves through self::find(), binding to the + // parent class. That is deliberate: all() is control panel territory and + // must not mark a page as depending on every form on the site. + $tags = $this->tagsRecordedDuring(fn () => Form::all()); + + $this->assertSame([], $tags); + } + + #[Test] + public function resolving_a_missing_form_records_nothing(): void + { + $this->assertSame([], $this->tagsRecordedDuring(fn () => Form::find('nope'))); + } + + /** + * @param array $data + */ + private function makeGlobalSet(string $handle, array $data): void + { + $set = tap(GlobalSet::make($handle))->save(); + + $set->makeLocalization('default')->data($data)->save(); + } +} diff --git a/tests/Recording/ItemAndListTagsTest.php b/tests/Recording/ItemAndListTagsTest.php new file mode 100644 index 0000000..a3deb5b --- /dev/null +++ b/tests/Recording/ItemAndListTagsTest.php @@ -0,0 +1,180 @@ +save(); + Collection::make('authors')->save(); + + $author = tap(Entry::make()->collection('authors')->slug('ada')->data(['title' => 'Ada']))->save(); + + $this->one = tap(Entry::make()->collection('articles')->slug('one') + ->data(['title' => 'One', 'author' => $author->id()]))->save()->id(); + + $this->two = tap(Entry::make()->collection('articles')->slug('two') + ->data(['title' => 'Two', 'author' => $author->id()]))->save()->id(); + } + + #[Test] + public function finding_an_entry_by_id_records_only_that_entry(): void + { + $tags = $this->tagsRecordedDuring(fn () => Entry::find($this->one)); + + $this->assertSame(["entry:{$this->one}"], $tags); + } + + #[Test] + public function an_augmented_entries_field_records_only_the_referenced_entry(): void + { + // The reusable-block case, and the one that regressed. Augmentation runs + // through OrderedQueryBuilder + StatusQueryBuilder, and the status filter + // back-fills the collection from the queried ids before adding a nested + // clause — which must not be mistaken for a list query. + $this->blueprint('collections.pages', 'page', [ + 'block' => ['type' => 'entries', 'max_items' => 1, 'collections' => ['articles']], + ]); + + Collection::make('pages')->save(); + + $page = tap(Entry::make()->collection('pages')->slug('home') + ->data(['title' => 'Home', 'block' => [$this->one]]))->save(); + + $tags = $this->tagsRecordedDuring(function () use ($page): void { + $resolved = $page->augmentedValue('block')->value(); + + // Force the lazy query builder to run, as rendering would. + $resolved instanceof \Statamic\Contracts\Entries\Entry ? $resolved->id() : collect($resolved)->first(); + }); + + $this->assertContains("entry:{$this->one}", $tags); + $this->assertNotContains('collection:articles', $tags, 'an id-pinned lookup must not record a list tag'); + } + + #[Test] + public function an_unfiltered_collection_query_records_the_list_tag_and_every_entry(): void + { + $tags = $this->tagsRecordedDuring(fn () => Entry::query()->where('collection', 'articles')->get()); + + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->one}", $tags); + $this->assertContains("entry:{$this->two}", $tags); + } + + #[Test] + public function a_limited_query_records_the_list_tag_and_only_the_entries_it_returned(): void + { + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->where('collection', 'articles')->orderBy('slug', 'asc')->limit(1)->get(), + ); + + // The list tag is what clears this page when a third article is created; + // its id could not possibly be in the recorded set. + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->one}", $tags); + $this->assertNotContains("entry:{$this->two}", $tags); + } + + #[Test] + public function a_query_filtered_on_a_normal_field_records_the_list_tag(): void + { + $author = Entry::query()->where('collection', 'authors')->first(); + + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->where('collection', 'articles')->where('author', $author->id())->get(), + ); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function count_records_the_list_tag(): void + { + // count() bypasses get() entirely in Stache\Query\Builder, so a recorder + // hooked on get() alone would silently miss it. + $tags = $this->tagsRecordedDuring(fn () => Entry::query()->where('collection', 'articles')->count()); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function pluck_records_the_list_tag(): void + { + $tags = $this->tagsRecordedDuring(fn () => Entry::query()->where('collection', 'articles')->pluck('title')); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function whereIn_on_ids_records_only_those_entries(): void + { + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->whereIn('id', [$this->one, $this->two])->get(), + ); + + $this->assertEqualsCanonicalizing(["entry:{$this->one}", "entry:{$this->two}"], $tags); + } + + #[Test] + public function excluding_ids_records_the_list_tag(): void + { + // whereNotIn excludes ids rather than bounding results to them, so an + // entry created later would appear and the page must react to it. + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->where('collection', 'articles')->whereNotIn('id', [$this->one])->get(), + ); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function an_or_clause_alongside_an_id_records_the_list_tag(): void + { + // An OR can admit rows from outside the id set, so the query is no longer + // bounded by it. + $tags = $this->tagsRecordedDuring( + fn () => Entry::query() + ->where('collection', 'articles') + ->where('id', $this->one) + ->orWhere('title', 'Two') + ->get(), + ); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function an_unscoped_query_records_every_collection(): void + { + $tags = $this->tagsRecordedDuring(fn () => Entry::query()->where('title', 'One')->get()); + + $this->assertContains('collection:articles', $tags); + $this->assertContains('collection:authors', $tags); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..4de87c5 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,93 @@ +recorder = app(DependencyRecorder::class); + $this->graph = app(DependencyGraph::class); + + $this->graph->flush(); + $this->recorder->reset(); + } + + protected function tearDown(): void + { + File::delete($this->sqlitePath()); + + parent::tearDown(); + } + + protected function resolveApplicationConfiguration($app): void + { + parent::resolveApplicationConfiguration($app); + + // A real strategy has to be active or the null cacher is used and nothing + // is ever cached. Individual tests switch to 'full' where it matters. + $app['config']->set('statamic.static_caching.strategy', 'half'); + $app['config']->set('cache_invalidation.sqlite_path', $this->sqlitePath()); + } + + protected function sqlitePath(): string + { + return __DIR__ . '/__fixtures__/dev-null/cache-invalidation.sqlite'; + } + + /** + * The tags recorded while running the callback, in isolation. + * + * Every recording assertion goes through this rather than inspecting query + * internals: the point is what a render ends up depending on, not how the + * query was shaped. + * + * @return list + */ + protected function tagsRecordedDuring(callable $callback): array + { + $this->recorder->reset(); + + $callback(); + + return $this->recorder->tags(); + } + + /** + * Registers a blueprint so entries can carry the given fields. + * + * @param array> $fields + */ + protected function blueprint(string $namespace, string $handle, array $fields = []): void + { + Blueprint::make($handle) + ->setNamespace($namespace) + ->setContents([ + 'fields' => collect($fields) + ->map(fn (array $field, string $key): array => ['handle' => $key, 'field' => $field]) + ->values() + ->all(), + ]) + ->save(); + } +} diff --git a/tests/__fixtures__/dev-null/.gitkeep b/tests/__fixtures__/dev-null/.gitkeep new file mode 100644 index 0000000..e69de29 From d0b9b398b9a3dfcd0a49c8ba5c83f972972ba495 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 16:24:04 +0200 Subject: [PATCH 08/15] feat: add tag invalidation, completing the custom-dependency escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @cachetags could declare a dependency but nothing could ever clear it, so it only worked for tags that happened to map to Statamic items a save already covered. That left its actual use case — data reaching a template from outside Statamic's repositories, where nothing observes the read and nothing knows when it changes — half implemented. CacheTags now owns both halves: add() records against the current render, and invalidate() clears every cached URL carrying the given tags, returning the count. urlsFor() previews without clearing, and cache-invalidation:clear pairs with cache-invalidation:affected on the command line. The Blade directive compiles to the same entry point, so there is one public path rather than two. It deliberately diverges from a content save in one way: it does not sweep up cached URLs missing from the graph. That safety net exists so routine editing can never leave a page stale, and the next content save applies it regardless; folding it in here would make an explicit, targeted call clear the whole cache right after a deploy, when every URL is untracked. Mutation-checked, which caught a genuinely untested branch: nothing exercised the background_recache path, so a regression there would have been silent. Also documents local development against a real site, which the 2.0 README had dropped. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 +- README.md | 77 ++++++++++++- src/Blade/CacheTagsDirective.php | 15 ++- src/CacheTags.php | 85 ++++++++++++++ src/Console/ClearCommand.php | 48 ++++++++ src/Facades/CacheTags.php | 23 ++++ src/ServiceProvider.php | 3 + tests/CacheTagsTest.php | 188 +++++++++++++++++++++++++++++++ 8 files changed, 435 insertions(+), 16 deletions(-) create mode 100644 src/CacheTags.php create mode 100644 src/Console/ClearCommand.php create mode 100644 src/Facades/CacheTags.php create mode 100644 tests/CacheTagsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 047a334..17739bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,9 +37,15 @@ renders; v2 observes it rather than restating it. not be asked. `doctor` exits non-zero when invalidation cannot work, so a broken environment fails a deploy. - An `X-Cache-Tags` header behind `CACHE_INVALIDATION_DEBUG`. -- `@cachetags(...)` for the one case observation cannot cover: a dependency a - template reacts to without reading, such as a banner conditional on any vacancy - existing. +- A public API for data this addon cannot observe — an HTTP call, a custom Eloquent + model, a file. `CacheTags::add()` (or `@cachetags(...)`) declares the dependency + where it is rendered, `CacheTags::invalidate()` clears it wherever that data + changes, and `cache-invalidation:clear` does the same from the command line. + Unlike a content save this does not sweep up untracked URLs, so a targeted call + stays targeted right after a deploy. +- A test suite: 74 tests over Testbench, asserting recording through real queries + against real content and invalidation through real save events. Mutation-checked, + and verified to fail against the bugs it covers. ### Changed diff --git a/README.md b/README.md index 79a3c19..1d818f6 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,10 @@ php artisan cache-invalidation:affected collection:articles # Graph size and coverage of the static cache. php artisan cache-invalidation:stats +# Clear pages carrying a tag. The counterpart to `affected`: +# preview with that, clear with this. +php artisan cache-invalidation:clear api:reviews + # Deploy check — exits non-zero when invalidation cannot work. php artisan cache-invalidation:doctor ``` @@ -138,16 +142,42 @@ whole cache on every save — a conservative fallback, not a production driver. > a single server that is automatic; if web and queue run on separate filesystems, > use `database`. -## Declaring a dependency by hand +## Data from outside Statamic + +Entries, terms, globals and forms are observed automatically. Data that reaches a +template from somewhere else — an HTTP call, a custom Eloquent model, a file — is +not: nothing sees the read, and nothing knows when it changes. Declare both halves. -One directive, for the only thing observation cannot see: a dependency a template -reacts to without reading. +Where it is rendered: ```blade -{{-- A "we're hiring" banner that queries no vacancies --}} -@cachetags('collection:vacancies') +@cachetags('api:reviews') +``` + +Or from PHP, in a ViewModel or component: + +```php +use RoxDigital\CacheInvalidation\Facades\CacheTags; + +CacheTags::add('api:reviews'); +``` + +And wherever that data changes — the job that refetched it, say: + +```php +CacheTags::invalidate('api:reviews'); // returns the number of URLs cleared +CacheTags::urlsFor('api:reviews'); // preview, without clearing ``` +Tags are arbitrary strings; namespace them (`api:reviews`, not `reviews`) so they +cannot collide with a built-in. `CacheTags::invalidate()` also works with built-in +tags, so `CacheTags::invalidate('collection:articles')` clears every page listing +articles. + +Unlike a content save, this does not sweep up cached URLs that are missing from the +graph. That safety net exists so routine editing can never leave a page stale; +applying it here would make a targeted call clear everything right after a deploy. + ## Upgrading from 1.x Delete the rule keys from `config/cache_invalidation.php` — all of them are gone. @@ -159,6 +189,43 @@ and upgrades the pin. Your own subclass is still respected, but Expect one round of broad invalidation after deploying while the graph fills. +## Local development + +To work on the addon against a real site, clone it inside the site and point +Composer at the clone instead of the VCS source. Path repositories symlink by +default, so edits in `src/` take effect on the next request with no reinstall. + +```bash +git clone git@github.com:roxdigital/cache-invalidation.git addons/roxdigital/cache-invalidation +``` + +```json +{ + "repositories": [ + { + "type": "path", + "url": "addons/roxdigital/cache-invalidation", + "options": { "symlink": true } + } + ] +} +``` + +```bash +composer require roxdigital/cache-invalidation:@dev +``` + +Adding a class in a new subdirectory needs `composer dump-autoload` if the site was +installed with an optimised autoloader. Switch back with +`composer require roxdigital/cache-invalidation:^2.0` once the `path` repository is +removed. + +Run the addon's own suite from its directory: + +```bash +composer install && composer test +``` + ## Good to know - Assets are not tracked; saving one clears nothing extra. diff --git a/src/Blade/CacheTagsDirective.php b/src/Blade/CacheTagsDirective.php index 6ca91e0..a994bc2 100644 --- a/src/Blade/CacheTagsDirective.php +++ b/src/Blade/CacheTagsDirective.php @@ -4,22 +4,21 @@ namespace RoxDigital\CacheInvalidation\Blade; -use RoxDigital\CacheInvalidation\Recording\DependencyRecorder; +use RoxDigital\CacheInvalidation\CacheTags; /** - * The escape hatch, for the one thing observation cannot see: a dependency a - * template reacts to without reading. + * Blade sugar over CacheTags::add(), for a dependency this addon cannot observe — + * typically data reaching the template from outside Statamic's repositories. * - * @cachetags('collection:vacancies') + * @cachetags('api:reviews') * - * A banner that only says "we're hiring" runs no vacancy query, so nothing marks - * the page as depending on vacancies existing. Declaring it here is the exception; - * everything a template actually reads is recorded on its own. + * Pair it with CacheTags::invalidate('api:reviews') wherever that data changes; + * nothing else knows when it does. */ final class CacheTagsDirective { public static function compile(string $expression): string { - return 'add(' . $expression . '); ?>'; + return 'add(' . $expression . '); ?>'; } } diff --git a/src/CacheTags.php b/src/CacheTags.php new file mode 100644 index 0000000..bac2fa6 --- /dev/null +++ b/src/CacheTags.php @@ -0,0 +1,85 @@ +recorder->add(...$tags); + } + + /** + * Clear every cached URL carrying any of the given tags. + * + * Returns the number of URLs cleared. + */ + public function invalidate(string ...$tags): int + { + $urls = $this->urlsFor(...$tags); + + if ($urls === []) { + return 0; + } + + config('statamic.static_caching.background_recache', false) + ? $this->cacher->refreshUrls($urls) + : $this->cacher->invalidateUrls($urls); + + return count($urls); + } + + /** + * The cached URLs carrying any of the given tags, without clearing them. + * + * @return list + */ + public function urlsFor(string ...$tags): array + { + $tags = array_values(array_filter($tags, static fn (string $tag): bool => $tag !== '')); + + if ($tags === []) { + return []; + } + + // Unlike a content save, this deliberately does not sweep up cached URLs + // that are absent from the graph. That safety net exists so routine + // editing can never leave a page stale, and the next content save applies + // it anyway; folding it in here would make an explicit, targeted call + // clear the entire cache right after a deploy. + return $this->graph->urlsFor([...$tags, Tag::OVERFLOW]); + } +} diff --git a/src/Console/ClearCommand.php b/src/Console/ClearCommand.php new file mode 100644 index 0000000..69377a5 --- /dev/null +++ b/src/Console/ClearCommand.php @@ -0,0 +1,48 @@ + $given */ + $given = (array) $this->argument('tags'); + + $urls = $tags->urlsFor(...$given); + + if ($urls === []) { + $this->components->info('No cached page carries '.implode(' or ', $given).'.'); + + return self::SUCCESS; + } + + foreach (array_slice($urls, 0, 20) as $url) { + $this->line(' '.$url); + } + + if (count($urls) > 20) { + $this->line(sprintf(' … and %d more', count($urls) - 20)); + } + + $cleared = $tags->invalidate(...$given); + + $this->line(''); + $this->components->info("Cleared {$cleared} cached URL(s)."); + + return self::SUCCESS; + } +} diff --git a/src/Facades/CacheTags.php b/src/Facades/CacheTags.php new file mode 100644 index 0000000..e996cf8 --- /dev/null +++ b/src/Facades/CacheTags.php @@ -0,0 +1,23 @@ + urlsFor(string ...$tags) + * + * @see Manager + */ +final class CacheTags extends Facade +{ + protected static function getFacadeAccessor(): string + { + return Manager::class; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 04fb832..dc6cad5 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -12,6 +12,7 @@ use RoxDigital\CacheInvalidation\Cachers\TrackingApplicationCacher; use RoxDigital\CacheInvalidation\Cachers\TrackingFileCacher; use RoxDigital\CacheInvalidation\Console\AffectedCommand; +use RoxDigital\CacheInvalidation\Console\ClearCommand; use RoxDigital\CacheInvalidation\Console\DoctorCommand; use RoxDigital\CacheInvalidation\Console\StatsCommand; use RoxDigital\CacheInvalidation\Console\WhyCommand; @@ -47,6 +48,7 @@ class ServiceProvider extends AddonServiceProvider protected $commands = [ AffectedCommand::class, + ClearCommand::class, DoctorCommand::class, StatsCommand::class, WhyCommand::class, @@ -126,6 +128,7 @@ private function registerGraph(): void private function registerRecorder(): void { $this->app->singleton(DependencyRecorder::class); + $this->app->singleton(CacheTags::class); // In PHP-FPM the singleton's lifetime is the request. A queue worker // keeps the container alive across jobs, so the tag set has to be cleared diff --git a/tests/CacheTagsTest.php b/tests/CacheTagsTest.php new file mode 100644 index 0000000..13c0f8c --- /dev/null +++ b/tests/CacheTagsTest.php @@ -0,0 +1,188 @@ +cacher = app(Cacher::class); + $this->domain = $this->cacher->getBaseUrl(); + } + + #[Test] + public function it_records_a_custom_tag_against_the_current_render(): void + { + CacheTags::add('api:reviews'); + + $this->assertSame(['api:reviews'], $this->recorder->tags()); + } + + #[Test] + public function the_blade_directive_records_through_the_same_api(): void + { + $compiled = Blade::compileString("@cachetags('api:reviews', 'api:ratings')"); + + eval('?>'.$compiled); + + $this->assertSame(['api:reviews', 'api:ratings'], $this->recorder->tags()); + } + + #[Test] + public function it_clears_cached_pages_carrying_a_custom_tag(): void + { + $this->cache('/reviews', ['api:reviews']); + $this->cache('/ratings', ['api:ratings']); + + $cleared = CacheTags::invalidate('api:reviews'); + + $this->assertSame(1, $cleared); + $this->assertCachedIs(['/ratings']); + } + + #[Test] + public function it_accepts_several_tags_at_once_and_counts_each_url_once(): void + { + $this->cache('/both', ['api:reviews', 'api:ratings']); + $this->cache('/neither', ['api:other']); + + $this->assertSame(1, CacheTags::invalidate('api:reviews', 'api:ratings')); + $this->assertCachedIs(['/neither']); + } + + #[Test] + public function it_also_clears_pages_that_depend_on_everything(): void + { + $this->cache('/overflowed', [Tag::OVERFLOW]); + $this->cache('/unrelated', ['api:other']); + + CacheTags::invalidate('api:reviews'); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function it_works_with_built_in_tags_too(): void + { + $this->cache('/overview', ['collection:articles']); + $this->cache('/detail', ['entry:abc']); + + CacheTags::invalidate('collection:articles'); + + $this->assertCachedIs(['/detail']); + } + + #[Test] + public function it_leaves_untracked_urls_alone(): void + { + // Deliberate divergence from a content save. The untracked sweep exists so + // routine editing can never leave a page stale; folding it in here would + // make an explicit, targeted call clear everything right after a deploy. + $this->cache('/reviews', ['api:reviews']); + $this->cacher->cacheUrl(md5('/untracked'), '/untracked', $this->domain); + + CacheTags::invalidate('api:reviews'); + + $this->assertCachedIs(['/untracked']); + } + + #[Test] + public function invalidating_an_unknown_tag_clears_nothing(): void + { + $this->cache('/reviews', ['api:reviews']); + + $this->assertSame(0, CacheTags::invalidate('api:nothing')); + $this->assertCachedIs(['/reviews']); + } + + #[Test] + public function invalidating_no_tags_clears_nothing(): void + { + // Guards against an empty argument list matching the overflow tag and + // taking the whole cache with it. + $this->cache('/overflowed', [Tag::OVERFLOW]); + + $this->assertSame(0, CacheTags::invalidate()); + $this->assertSame(0, CacheTags::invalidate('')); + $this->assertCachedIs(['/overflowed']); + } + + #[Test] + public function it_refreshes_rather_than_purges_when_background_recache_is_on(): void + { + config(['statamic.static_caching.background_recache' => true]); + + $cacher = \Mockery::mock(Cacher::class); + $cacher->shouldReceive('refreshUrls')->once(); + $cacher->shouldNotReceive('invalidateUrls'); + + $tags = new \RoxDigital\CacheInvalidation\CacheTags($this->recorder, $this->graph, $cacher); + $this->graph->record($this->domain.'/reviews', ['api:reviews']); + + $this->assertSame(1, $tags->invalidate('api:reviews')); + } + + #[Test] + public function urls_for_reports_without_clearing(): void + { + $this->cache('/reviews', ['api:reviews']); + + $this->assertSame([$this->domain.'/reviews'], CacheTags::urlsFor('api:reviews')); + $this->assertCachedIs(['/reviews']); + } + + #[Test] + public function the_clear_command_reports_what_it_cleared(): void + { + $this->cache('/reviews', ['api:reviews']); + + $this->artisan('cache-invalidation:clear', ['tags' => ['api:reviews']]) + ->expectsOutputToContain($this->domain.'/reviews') + ->expectsOutputToContain('Cleared 1 cached URL(s).') + ->assertSuccessful(); + + $this->assertCachedIs([]); + } + + #[Test] + public function the_clear_command_says_so_when_nothing_matches(): void + { + $this->artisan('cache-invalidation:clear', ['tags' => ['api:nothing']]) + ->expectsOutputToContain('No cached page carries api:nothing.') + ->assertSuccessful(); + } + + /** + * @param list $tags + */ + private function cache(string $path, array $tags): void + { + $this->cacher->cacheUrl(md5($path), $path, $this->domain); + $this->graph->record($this->domain.$path, $tags); + } + + /** + * @param list $expected + */ + private function assertCachedIs(array $expected): void + { + $this->assertEqualsCanonicalizing( + $expected, + $this->cacher->getUrls($this->domain)->values()->all(), + ); + } +} From 1ef0b583307d92fd660bf7dc43352a7176bdf349 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 16:34:47 +0200 Subject: [PATCH 09/15] fix: two gaps only a real HTTP render and a cached config could expose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both would have shipped as silent failures. Every rendered page recorded a list tag for every collection on the site, so any entry save anywhere cleared the entire cache — the addon quietly degraded to the blunt behaviour it exists to replace. Statamic resolves every frontend request through findByUri(), which queries `uri` with no collection scope, and the item-lookup rule read that as a set query. `uri` now counts as an identifying column. Strictly a new entry could claim an existing URI, but that case is already covered: such an entry carries the URL in its own absoluteUrl(), which Statamic invalidates directly. What is given up is a page rendering a teaser resolved by someone else's URI, which would recover on its next render — a narrow gap in exchange for not clearing everything on every save. Separately, `php artisan config:cache` makes mergeConfigFrom a no-op, so a site that followed the install instructions — which say there is nothing to publish — and then cached its config had no cache_invalidation namespace at runtime. The sqlite path was empty, mkdir failed, and the queued invalidation job threw: pages stayed stale and nothing reported it. Defaults are now filled in code, and the sqlite path and driver are resolved through helpers so nothing assumes the config namespace exists when the graph is built. Adds the test that found the first one: a real request through Statamic's frontend and cache middleware, asserting the graph row describes what the template read — the page's own entry, the collection it listed, the entry it showed, and the global it printed — and then that saving that content clears the page while unrelated saves do not. Everything before this exercised the pieces; this exercises the seam between them, and it is where both bugs were hiding. Co-Authored-By: Claude Opus 5 --- src/Console/DoctorCommand.php | 7 +- src/Console/StatsCommand.php | 2 +- src/Recording/DetectsItemLookups.php | 28 ++++- src/ServiceProvider.php | 48 +++++++- tests/CachedConfigTest.php | 56 +++++++++ tests/RendersAndRecordsTest.php | 143 +++++++++++++++++++++++ tests/__fixtures__/views/entry.blade.php | 7 ++ 7 files changed, 275 insertions(+), 16 deletions(-) create mode 100644 tests/CachedConfigTest.php create mode 100644 tests/RendersAndRecordsTest.php create mode 100644 tests/__fixtures__/views/entry.blade.php diff --git a/src/Console/DoctorCommand.php b/src/Console/DoctorCommand.php index e5ba2fd..c39267d 100644 --- a/src/Console/DoctorCommand.php +++ b/src/Console/DoctorCommand.php @@ -55,8 +55,8 @@ public function handle(DependencyGraph $graph, CachedUrls $cached, Cacher $cache $this->check('Static caching strategy', (string) config('statamic.static_caching.strategy'), true); - $driver = (string) config('cache_invalidation.driver'); - $this->check('Graph driver', $driver, $driver !== ''); + $driver = (string) (config('cache_invalidation.driver') ?: 'sqlite'); + $this->check('Graph driver', $driver, true); if ($graph instanceof NullGraph) { $this->components->warn('The null driver records nothing, so every save clears the entire cache.'); @@ -75,7 +75,8 @@ public function handle(DependencyGraph $graph, CachedUrls $cached, Cacher $cache $this->check('pdo_sqlite', $loaded ? 'loaded' : 'missing', $loaded); $failed = $failed || ! $loaded; - $path = (string) config('cache_invalidation.sqlite_path'); + $path = (string) (config('cache_invalidation.sqlite_path') + ?: storage_path('statamic/cache-invalidation.sqlite')); $writable = is_writable(is_file($path) ? $path : dirname($path)); $this->check('Sqlite path writable', $path, $writable); $failed = $failed || ! $writable; diff --git a/src/Console/StatsCommand.php b/src/Console/StatsCommand.php index 0736645..caf1ee8 100644 --- a/src/Console/StatsCommand.php +++ b/src/Console/StatsCommand.php @@ -24,7 +24,7 @@ public function handle(DependencyGraph $graph, CachedUrls $cached): int $orphaned = array_values(array_diff($trackedUrls, $cachedUrls)); $this->line(''); - $this->components->twoColumnDetail('Driver', (string) config('cache_invalidation.driver')); + $this->components->twoColumnDetail('Driver', (string) (config('cache_invalidation.driver') ?: 'sqlite')); $this->components->twoColumnDetail('Cached URLs', (string) count($cachedUrls)); $this->components->twoColumnDetail('Tracked URLs', (string) $stats['urls']); $this->components->twoColumnDetail('Distinct tags', (string) $stats['tags']); diff --git a/src/Recording/DetectsItemLookups.php b/src/Recording/DetectsItemLookups.php index 15a8ee0..6d0c903 100644 --- a/src/Recording/DetectsItemLookups.php +++ b/src/Recording/DetectsItemLookups.php @@ -27,14 +27,30 @@ trait DetectsItemLookups { /** - * Clause types that pin results to a known set of ids. Notably not NotIn or a - * `!=` operator, which exclude ids rather than bounding to them. + * Columns that identify specific items rather than describe a set. + * + * `uri` belongs here even though, strictly, a newly created entry could claim + * an existing URI. Statamic resolves every frontend request through + * findByUri(), which queries `uri` with no collection scope, so treating it as + * a set query made every rendered page depend on every collection — any entry + * save anywhere then cleared the entire cache. + * + * The case it gives up is narrow and already covered elsewhere: an entry that + * takes over a URI has that URL in its own absoluteUrl(), which Statamic + * invalidates directly. Only a page rendering a *teaser* resolved by someone + * else's URI could go stale, and it would recover on its next render. + */ + private const IDENTIFYING_COLUMNS = ['id', 'uri']; + + /** + * Clause types that pin results to known items. Notably not NotIn or a `!=` + * operator, which exclude items rather than bounding to them. */ private const BOUNDING_TYPES = ['Basic', 'In']; protected function isItemLookup(): bool { - // An empty where clause is emphatically not an id lookup: "everything in + // An empty where clause is emphatically not an item lookup: "everything in // this collection" is the broadest list query there is. if (empty($this->wheres)) { return false; @@ -47,7 +63,7 @@ protected function isItemLookup(): bool return false; } - if ($this->boundsResultsToIds($where)) { + if ($this->boundsResults($where)) { $bounded = true; } } @@ -58,9 +74,9 @@ protected function isItemLookup(): bool /** * @param array $where */ - private function boundsResultsToIds(array $where): bool + private function boundsResults(array $where): bool { - if (($where['column'] ?? null) !== 'id') { + if (! in_array($where['column'] ?? null, self::IDENTIFYING_COLUMNS, true)) { return false; } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index dc6cad5..cbf894d 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -70,6 +70,7 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/cache_invalidation.php', 'cache_invalidation'); + $this->fillMissingConfig(); $this->registerSqliteConnection(); $this->registerGraph(); $this->registerRecorder(); @@ -94,6 +95,31 @@ public function bootAddon(): void Blade::directive('cachetags', CacheTagsDirective::compile(...)); } + /** + * Defaults cannot live only in the config file. + * + * `php artisan config:cache` makes mergeConfigFrom a no-op, so a site that + * followed the install instructions — which say there is nothing to publish — + * and then cached its config has no cache_invalidation namespace at all at + * runtime. Without this the sqlite path is empty, the graph cannot be opened, + * and the queued invalidation job throws: pages stay stale and nothing says so. + */ + private function fillMissingConfig(): void + { + $defaults = [ + 'driver' => 'sqlite', + 'sqlite_path' => storage_path('statamic/cache-invalidation.sqlite'), + 'database_connection' => null, + 'debug' => false, + ]; + + foreach ($defaults as $key => $default) { + if ($this->app['config']->get("cache_invalidation.{$key}") === null) { + $this->app['config']->set("cache_invalidation.{$key}", $default); + } + } + } + /** * A dedicated connection owned by the addon, so the graph works on a site * with no DB_CONNECTION configured — which is the common Statamic case. @@ -102,7 +128,7 @@ private function registerSqliteConnection(): void { $this->app['config']->set('database.connections.' . SqliteGraph::CONNECTION, [ 'driver' => 'sqlite', - 'database' => $this->app['config']->get('cache_invalidation.sqlite_path'), + 'database' => $this->sqlitePath(), 'prefix' => '', 'foreign_key_constraints' => false, 'journal_mode' => 'wal', @@ -118,10 +144,7 @@ private function registerGraph(): void $app['config']->get('cache_invalidation.database_connection'), ), 'null' => new NullGraph, - default => new SqliteGraph( - $app->make(DatabaseManager::class), - (string) $app['config']->get('cache_invalidation.sqlite_path'), - ), + default => new SqliteGraph($app->make(DatabaseManager::class), $this->sqlitePath()), }); } @@ -251,6 +274,19 @@ private function isOwnInvalidator(string $class): bool private function graphDriver(): string { - return (string) $this->app['config']->get('cache_invalidation.driver', 'sqlite'); + return (string) ($this->app['config']->get('cache_invalidation.driver') ?: 'sqlite'); + } + + /** + * Resolved rather than read straight from config, so nothing depends on the + * config namespace existing at the moment the graph is built. + */ + private function sqlitePath(): string + { + $path = $this->app['config']->get('cache_invalidation.sqlite_path'); + + return is_string($path) && $path !== '' + ? $path + : storage_path('statamic/cache-invalidation.sqlite'); } } diff --git a/tests/CachedConfigTest.php b/tests/CachedConfigTest.php new file mode 100644 index 0000000..e3d5fdc --- /dev/null +++ b/tests/CachedConfigTest.php @@ -0,0 +1,56 @@ +set('cache_invalidation', []); + app()->forgetInstance(DependencyGraph::class); + + $graph = app(DependencyGraph::class); + + $this->assertInstanceOf(SqliteGraph::class, $graph); + + $graph->record('https://site.test/a', ['entry:1']); + + $this->assertSame(['entry:1'], $graph->tagsFor('https://site.test/a')); + } + + #[Test] + public function the_sqlite_connection_is_registered_with_a_usable_path(): void + { + config()->set('cache_invalidation', []); + + $path = config('database.connections.'.SqliteGraph::CONNECTION.'.database'); + + $this->assertIsString($path); + $this->assertNotSame('', $path); + } + + #[Test] + public function the_commands_still_run_with_no_config(): void + { + config()->set('cache_invalidation', []); + app()->forgetInstance(DependencyGraph::class); + + $this->artisan('cache-invalidation:stats')->assertSuccessful(); + $this->artisan('cache-invalidation:doctor')->assertSuccessful(); + } +} diff --git a/tests/RendersAndRecordsTest.php b/tests/RendersAndRecordsTest.php new file mode 100644 index 0000000..4589aee --- /dev/null +++ b/tests/RendersAndRecordsTest.php @@ -0,0 +1,143 @@ +save(); + + $this->listed = tap(Entry::make()->collection('articles')->slug('listed') + ->data(['title' => 'Listed article']))->save()->id(); + + $set = tap(GlobalSet::make('footer'))->save(); + $set->makeLocalization('default')->data(['phone' => '0123'])->save(); + + Collection::make('pages')->routes('/{slug}')->template('entry')->save(); + + Entry::make()->collection('pages')->slug('about')->data(['title' => 'About us'])->save(); + } + + protected function resolveApplicationConfiguration($app): void + { + parent::resolveApplicationConfiguration($app); + + $app['config']->set('view.paths', [__DIR__ . '/__fixtures__/views']); + } + + #[Test] + public function rendering_a_page_records_everything_the_template_read(): void + { + $response = $this->get('/about'); + + $response->assertOk(); + $response->assertSee('About us'); + $response->assertSee('Listed article'); + $response->assertSee('0123'); + + $tags = $this->graph->tagsFor($this->urlFor('/about')); + + // The page's own entry, resolved by URI. + $about = Entry::query()->where('collection', 'pages')->first(); + $this->assertContains("entry:{$about->id()}", $tags); + + // The listing it rendered: both the collection and the entry it showed. + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->listed}", $tags); + + // The global whose value it printed. + $this->assertContains('global:footer', $tags); + } + + #[Test] + public function a_rendered_page_is_then_cleared_by_saving_what_it_read(): void + { + $this->get('/about')->assertOk(); + + $cacher = app(Cacher::class); + $this->assertNotEmpty($cacher->getUrls()->all(), 'the page should be in the static cache'); + + // A save of the listed article, going through Statamic's own event chain. + Entry::find($this->listed)->data(['title' => 'Renamed'])->save(); + + $this->assertSame([], $cacher->getUrls()->values()->all()); + } + + #[Test] + public function rendering_a_page_does_not_record_collections_it_never_touched(): void + { + // Statamic resolves every frontend request through findByUri(), which + // queries `uri` with no collection scope. Treating that as a set query made + // every rendered page depend on every collection on the site, quietly + // degrading the addon to "clear everything on any entry save". + Collection::make('unrelated')->save(); + Collection::make('another')->save(); + + $this->get('/about')->assertOk(); + + $tags = $this->graph->tagsFor($this->urlFor('/about')); + + $this->assertContains('collection:articles', $tags, 'the collection it listed'); + $this->assertNotContains('collection:unrelated', $tags); + $this->assertNotContains('collection:another', $tags); + $this->assertNotContains('collection:pages', $tags, 'its own collection was resolved by uri, not listed'); + } + + #[Test] + public function saving_unrelated_content_leaves_a_rendered_page_cached(): void + { + Collection::make('unrelated')->save(); + + $this->get('/about')->assertOk(); + + $cacher = app(Cacher::class); + $before = $cacher->getUrls()->values()->all(); + $this->assertNotEmpty($before); + + Entry::make()->collection('unrelated')->slug('thing')->data(['title' => 'Thing'])->save(); + + // Nothing on the page reads the unrelated collection, and the page is + // tracked, so the safety net does not apply either. + $this->assertSame($before, $cacher->getUrls()->values()->all()); + } + + #[Test] + public function the_debug_header_reports_the_recorded_tags(): void + { + config(['cache_invalidation.debug' => true]); + + $response = $this->get('/about'); + + $header = $response->headers->get('X-Cache-Tags'); + + $this->assertNotNull($header); + $this->assertStringContainsString('collection:articles', $header); + $this->assertStringContainsString('global:footer', $header); + } + + private function urlFor(string $path): string + { + return app(Cacher::class)->getBaseUrl() . $path; + } +} diff --git a/tests/__fixtures__/views/entry.blade.php b/tests/__fixtures__/views/entry.blade.php new file mode 100644 index 0000000..ac0dc7d --- /dev/null +++ b/tests/__fixtures__/views/entry.blade.php @@ -0,0 +1,7 @@ +

{{ $title }}

+ +@foreach (\Statamic\Facades\Entry::query()->where('collection', 'articles')->orderBy('slug', 'asc')->get() as $article) +
  • {{ $article->title }}
  • +@endforeach + +
    {{ \Statamic\Facades\GlobalSet::find('footer')->in('default')->phone }}
    From 12065cbafe7e53b35bb375d8a0f741e2acc6c06b Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Thu, 6 Aug 2026 16:38:40 +0200 Subject: [PATCH 10/15] docs: document deploy steps and multisite global behaviour Both notes come out of the two failures fixed in 1ef0b58, which were only visible in a real environment rather than in isolation. doctor belongs in the deploy pipeline because it is the only thing that turns a broken graph into a failed build instead of quietly stale pages. Workers need restarting because they hold an open handle on the graph, and a deploy that replaces storage/ leaves them writing to a file that no longer exists; the safety net makes that over-invalidation rather than staleness, but it is avoidable. Also states plainly that the first save after a deploy clears more than usual, since every cached URL is untracked until it has been rendered again, and that globals are not scoped per site on a multisite install. Neither is a defect, but both look like one without a note. All five commands were already documented; verified against the provider's registered commands and the signatures in src/Console. --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1d818f6..361d92f 100644 --- a/README.md +++ b/README.md @@ -226,15 +226,30 @@ Run the addon's own suite from its directory: composer install && composer test ``` +## Deploying + +- Run `cache-invalidation:doctor` as a deploy step. It exits non-zero when + invalidation cannot work, so a broken environment fails the pipeline instead of + quietly serving stale pages. +- Restart your queue workers (`php artisan queue:restart`). A worker holds an open + handle on the graph, and a deploy that replaces `storage/` leaves it writing to a + file that no longer exists. The safety net turns that into over-invalidation + rather than staleness, but a restart avoids it. +- Expect one broad invalidation after deploying. Every cached URL is untracked + until it has been rendered again, so the first save clears more than usual. + `cache-invalidation:stats` shows the graph filling up. +- On full measure, `statamic:static:warm` fills the graph promptly instead of + lazily. + ## Good to know - Assets are not tracked; saving one clears nothing extra. -- On full measure, run `statamic:static:warm` after a deploy so the graph fills - promptly instead of lazily. - Recording only happens on a cache miss. Invalidation is one indexed lookup plus the deletes — nothing walks content, which matters with a single queue worker or `QUEUE_CONNECTION=sync`. - A page with more than 2,000 dependencies is treated as depending on everything. +- Globals are not scoped per site, so on a multisite install saving one clears the + pages that read it across every site. ## License From b87e7618d824d34192c82eb1b3833b4cfb79809c Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Mon, 10 Aug 2026 17:02:38 +0200 Subject: [PATCH 11/15] fix: tag navigations where they render, and stop recording url resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems that a real site exposed and the harness did not. Navigation was hardcoded to clear every cached URL. That is right for a nav in the shared layout and wrong for one used on a few pages, and it made the invalidator the only place with a special case. Navs are now recorded where they resolve, the way globals and forms already were, and the invalidator resolves a nav save to its tag like anything else. A nav in the layout still clears everything, but because it is recorded on every page rather than because of a rule. Recording hooks both NavigationRepository::findByHandle() — which is what Tags\Structure::structure() calls, so the nav tag reaches it — and NavTreeRepository::find(), for a template holding a Nav object that never goes back through the repository. all() records every nav it returns. Over-recording is deliberate here: an unrecorded nav leaves a removed menu item visible to visitors, while recording too many only clears more pages than needed. Second, saving any single page cleared the entire site. Resolving a URL in a structured collection makes Statamic validate the collection tree, and CollectionStructure::validateTree() plucks every entry in the collection to check it. That looked like a list query, so every page recorded its own collection's list tag. The page does not display that list — Statamic reads it to work out which entry the URL belongs to. findByUri() is now wrapped in a suppression scope that records the entry it resolved and nothing else. The suppression is scoped to URL resolution on purpose. A template that walks the collection tree itself — breadcrumbs, say — still records the dependency, because it renders it. Measured on meerdervoort, 213 cached URLs: before, every page save cleared all 213. After, of 25 pages sampled, 14 clear between 0 and 5 URLs and 11 still clear everything — and those 11 are the pages in the main navigation, whose titles genuinely appear on every page. Employee saves clear 8, reusable blocks 2, articles 10. Worth recording how the first attempt at a regression test failed. Making the fixture's collection structured did not reproduce it: in the harness findByUri() resolves from the Stache uri index and never consults the tree, so the test passed against the bug. The test that survives asserts the observable contract — findByUri() records exactly the entry it returned — and the tree-validation path itself is verified against a real site rather than claimed to be covered here. Nav recording and the suppression mechanism are mutation-checked: removing the nav tag from the resolver fails 2 tests, removing the recording fails 4. --- CHANGELOG.md | 14 +++- README.md | 12 ++- src/Invalidation/GraphInvalidator.php | 13 ---- src/Invalidation/TagResolver.php | 10 +++ src/Recording/DependencyRecorder.php | 30 +++++++- src/Recording/TrackingEntryRepository.php | 44 +++++++++++ src/Recording/TrackingNavTreeRepository.php | 38 ++++++++++ .../TrackingNavigationRepository.php | 52 +++++++++++++ src/ServiceProvider.php | 12 +++ src/Tag.php | 5 ++ .../InvalidatesByDependencyTest.php | 29 ++++++-- tests/Recording/DependencyRecorderTest.php | 45 +++++++++++ tests/Recording/NavigationTest.php | 74 +++++++++++++++++++ tests/RendersAndRecordsTest.php | 44 ++++++++++- 14 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 src/Recording/TrackingEntryRepository.php create mode 100644 src/Recording/TrackingNavTreeRepository.php create mode 100644 src/Recording/TrackingNavigationRepository.php create mode 100644 tests/Recording/NavigationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 17739bf..ef5baac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,17 @@ renders; v2 observes it rather than restating it. - The whole cache is no longer flushed for globals, navigations, form blueprints or collection trees. URLs are invalidated individually, so `nocache` regions and the - graph survive. A navigation save still clears every cached URL — a reorder - changes links in shared layout and no per-page dependency can express that — but - it clears rather than flushes. + graph survive. +- Navigations are tagged where they render, so one used on a handful of pages clears + only those. A nav in the shared layout still reaches every page, but as a + consequence of where it is used rather than a special case. +- Statamic's URL resolution is excluded from recording. Resolving a URL in a + structured collection makes Statamic validate the collection tree, which plucks + every entry in it; recording that made every page depend on its whole collection, + so saving any single page cleared every cached page. Measured on a 213-page site: + before, every page save cleared all 213; after, pages absent from the navigation + clear 0–5 URLs, while pages in the navigation still clear everything because their + title genuinely appears on every page. - Globals invalidate only where they are read. A set rendered in the layout still reaches every page; one rendered by a single block reaches that block's pages. - Form blueprint saves clear the pages rendering that form instead of the entire diff --git a/README.md b/README.md index 361d92f..a68bd08 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,18 @@ augmentation (so a global is only tagged where it is actually read), forms at | Global set | Pages that read it — every page, if it is read in your layout | | Form or forms blueprint | Pages rendering that form | | Collection tree | Pages carrying that collection's list tag, plus the URLs Statamic reports as moved | -| Navigation | Every cached URL | +| Navigation | Pages that render it — every page, if it is in your layout | The cache is never flushed wholesale — URLs are invalidated individually, so -`nocache` regions survive and pages come back without a global re-render. +`nocache` regions survive and pages come back without a global re-render. Nothing +is special-cased either: a navigation or global that reaches every page does so +because it is recorded on every page, not because of a rule. + +**Rendered output only.** Statamic reads content to work out *which* entry a URL +belongs to, and for a structured collection that includes validating the whole +collection tree. Those reads are not recorded — the page does not display them. +Without that distinction every page would depend on its entire collection, and +saving one page would clear the whole site. **Safety net.** A URL that is cached but absent from the graph is treated as depending on everything and cleared by the next save. That covers pages cached diff --git a/src/Invalidation/GraphInvalidator.php b/src/Invalidation/GraphInvalidator.php index 4d2be8b..caa1357 100644 --- a/src/Invalidation/GraphInvalidator.php +++ b/src/Invalidation/GraphInvalidator.php @@ -7,8 +7,6 @@ use RoxDigital\CacheInvalidation\CachedUrls; use RoxDigital\CacheInvalidation\Graph\DependencyGraph; use RoxDigital\CacheInvalidation\Tag; -use Statamic\Contracts\Structures\Nav; -use Statamic\Contracts\Structures\NavTree; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\DefaultInvalidator; @@ -42,17 +40,6 @@ public function __construct( public function invalidate($item): void { - // Navigation is the one deliberate blunt instrument. A reorder or relabel - // changes links rendered in shared layout, and no per-page dependency can - // express that. Clearing every cached URL rather than flushing keeps - // nocache regions and the graph itself intact, so pages come back without - // a full re-render storm. - if ($item instanceof Nav || $item instanceof NavTree) { - $this->clear($this->cached->all()); - - return; - } - $tags = $this->tags->forItem($item); $this->clear([ diff --git a/src/Invalidation/TagResolver.php b/src/Invalidation/TagResolver.php index 7474af4..0c1d10a 100644 --- a/src/Invalidation/TagResolver.php +++ b/src/Invalidation/TagResolver.php @@ -10,6 +10,8 @@ use Statamic\Contracts\Entries\Entry; use Statamic\Contracts\Forms\Form; use Statamic\Contracts\Globals\Variables; +use Statamic\Contracts\Structures\Nav; +use Statamic\Contracts\Structures\NavTree; use Statamic\Structures\CollectionTree; use Statamic\Taxonomies\LocalizedTerm; use Statamic\Taxonomies\Term; @@ -45,6 +47,14 @@ public function forItem(mixed $item): array $item instanceof Form => [Tag::form((string) $item->handle())], + // A nav is tagged where it renders rather than clearing the whole + // cache, so a nav used on a handful of pages clears only those. A nav + // in the shared layout still reaches every page — that is now an + // emergent consequence of where it is used, not a hardcoded rule. + $item instanceof Nav => [Tag::nav((string) $item->handle())], + + $item instanceof NavTree => [Tag::nav((string) $item->structure()->handle())], + $item instanceof Collection => [Tag::collection((string) $item->handle())], // A tree save moves or reorders entries, which changes any listing diff --git a/src/Recording/DependencyRecorder.php b/src/Recording/DependencyRecorder.php index a92ad84..ac2b7c2 100644 --- a/src/Recording/DependencyRecorder.php +++ b/src/Recording/DependencyRecorder.php @@ -31,9 +31,31 @@ final class DependencyRecorder private bool $overflowed = false; + private bool $suppressing = false; + + /** + * Ignore everything recorded while the callback runs. + * + * For Statamic's own resolution machinery, which queries content to work out + * *which* entry a request is for. Those reads are not rendered output, and + * treating them as such attributes dependencies to a page that it does not + * display — see TrackingEntryRepository. + */ + public function suppressed(callable $callback): mixed + { + $previous = $this->suppressing; + $this->suppressing = true; + + try { + return $callback(); + } finally { + $this->suppressing = $previous; + } + } + public function add(string ...$tags): void { - if ($this->overflowed) { + if ($this->suppressing || $this->overflowed) { return; } @@ -103,6 +125,11 @@ public function form(string $handle): void $this->add(Tag::form($handle)); } + public function nav(string $handle): void + { + $this->add(Tag::nav($handle)); + } + /** * @return list */ @@ -125,6 +152,7 @@ public function reset(): void { $this->tags = []; $this->overflowed = false; + $this->suppressing = false; } /** diff --git a/src/Recording/TrackingEntryRepository.php b/src/Recording/TrackingEntryRepository.php new file mode 100644 index 0000000..d98b350 --- /dev/null +++ b/src/Recording/TrackingEntryRepository.php @@ -0,0 +1,44 @@ +recorder->suppressed(fn (): ?Entry => parent::findByUri($uri, $site)); + + if ($entry !== null) { + $this->recorder->entries([$entry->id()]); + } + + return $entry; + } +} diff --git a/src/Recording/TrackingNavTreeRepository.php b/src/Recording/TrackingNavTreeRepository.php new file mode 100644 index 0000000..d79f1e4 --- /dev/null +++ b/src/Recording/TrackingNavTreeRepository.php @@ -0,0 +1,38 @@ +recorder->nav($handle); + } + + return $tree; + } +} diff --git a/src/Recording/TrackingNavigationRepository.php b/src/Recording/TrackingNavigationRepository.php new file mode 100644 index 0000000..bf72e1f --- /dev/null +++ b/src/Recording/TrackingNavigationRepository.php @@ -0,0 +1,52 @@ +recorder->nav((string) $nav->handle()); + } + + return $nav; + } + + public function all(): Collection + { + $navs = parent::all(); + + foreach ($navs as $nav) { + $this->recorder->nav((string) $nav->handle()); + } + + return $navs; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index cbf894d..df4d397 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -25,12 +25,18 @@ use RoxDigital\CacheInvalidation\Invalidation\GraphInvalidator; use RoxDigital\CacheInvalidation\Recording\DependencyRecorder; use RoxDigital\CacheInvalidation\Recording\TrackingEntryQueryBuilder; +use RoxDigital\CacheInvalidation\Recording\TrackingEntryRepository; use RoxDigital\CacheInvalidation\Recording\TrackingFormRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingNavigationRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingNavTreeRepository; use RoxDigital\CacheInvalidation\Recording\TrackingTermRepository; use RoxDigital\CacheInvalidation\Recording\TrackingVariables; +use Statamic\Contracts\Entries\EntryRepository as EntryRepositoryContract; use Statamic\Contracts\Entries\QueryBuilder as EntryQueryBuilderContract; use Statamic\Contracts\Forms\FormRepository as FormRepositoryContract; use Statamic\Contracts\Globals\Variables as VariablesContract; +use Statamic\Contracts\Structures\NavigationRepository as NavigationRepositoryContract; +use Statamic\Contracts\Structures\NavTreeRepository as NavTreeRepositoryContract; use Statamic\Contracts\Taxonomies\TermRepository as TermRepositoryContract; use Statamic\Events\StaticCacheCleared; use Statamic\Facades\StaticCache; @@ -247,10 +253,16 @@ private function registerReadRecorders(): void $this->app->bind(EntryQueryBuilderContract::class, $builder); $this->app->bind(EntryQueryBuilder::class, $builder); + // Separates URL resolution from rendered output; see the class docblock. + Statamic::repository(EntryRepositoryContract::class, TrackingEntryRepository::class); + // TermRepository::query() constructs its builder directly instead of // resolving it, so the repository itself has to be replaced. Statamic::repository(TermRepositoryContract::class, TrackingTermRepository::class); + Statamic::repository(NavigationRepositoryContract::class, TrackingNavigationRepository::class); + Statamic::repository(NavTreeRepositoryContract::class, TrackingNavTreeRepository::class); + // The global variables store builds its items with app(Variables::class). $this->app->bind(VariablesContract::class, TrackingVariables::class); diff --git a/src/Tag.php b/src/Tag.php index 9d8f800..6a5effe 100644 --- a/src/Tag.php +++ b/src/Tag.php @@ -56,4 +56,9 @@ public static function form(string $handle): string { return 'form:'.$handle; } + + public static function nav(string $handle): string + { + return 'nav:'.$handle; + } } diff --git a/tests/Invalidation/InvalidatesByDependencyTest.php b/tests/Invalidation/InvalidatesByDependencyTest.php index eecd0d8..cddee20 100644 --- a/tests/Invalidation/InvalidatesByDependencyTest.php +++ b/tests/Invalidation/InvalidatesByDependencyTest.php @@ -159,17 +159,32 @@ public function saving_a_form_clears_only_pages_rendering_it(): void } #[Test] - public function saving_a_navigation_clears_every_cached_url(): void + public function saving_a_navigation_clears_only_the_pages_that_render_it(): void { - Nav::make('main')->title('Main')->save(); + Nav::make('footer_nav')->title('Footer')->save(); + Nav::make('sidebar_nav')->title('Sidebar')->save(); - $this->cache('/a', ['entry:1']); - $this->cache('/b', ['entry:2']); + $this->cache('/has-footer-nav', ['nav:footer_nav']); + $this->cache('/has-sidebar-nav', ['nav:sidebar_nav']); + + Nav::find('footer_nav')->title('Footer links')->save(); + + $this->assertCachedIs(['/has-sidebar-nav']); + } + + #[Test] + public function a_navigation_in_the_shared_layout_still_clears_everything(): void + { + // Not a special case any more: a nav rendered on every page is recorded on + // every page, so "clear all" falls out of where it is used rather than + // being hardcoded. + Nav::make('main_nav')->title('Main')->save(); + + $this->cache('/a', ['nav:main_nav', 'entry:1']); + $this->cache('/b', ['nav:main_nav', 'entry:2']); - Nav::find('main')->title('Main nav')->save(); + Nav::find('main_nav')->title('Main nav')->save(); - // A reorder or relabel changes links rendered in shared layout, and no - // per-page dependency can express that. $this->assertCachedIs([]); } diff --git a/tests/Recording/DependencyRecorderTest.php b/tests/Recording/DependencyRecorderTest.php index 3e4eb81..7a845b6 100644 --- a/tests/Recording/DependencyRecorderTest.php +++ b/tests/Recording/DependencyRecorderTest.php @@ -35,6 +35,51 @@ public function reads_accumulate_flatly_regardless_of_nesting(): void ); } + #[Test] + public function suppressed_drops_everything_recorded_inside_it(): void + { + $this->recorder->add('entry:before'); + + $returned = $this->recorder->suppressed(function (): string { + $this->recorder->add('entry:inside'); + $this->recorder->collections(['pages']); + + return 'result'; + }); + + $this->recorder->add('entry:after'); + + $this->assertSame('result', $returned, 'the callback result is passed through'); + $this->assertSame(['entry:before', 'entry:after'], $this->recorder->tags()); + } + + #[Test] + public function suppression_is_lifted_even_when_the_callback_throws(): void + { + try { + $this->recorder->suppressed(fn () => throw new \RuntimeException('boom')); + } catch (\RuntimeException) { + // expected + } + + $this->recorder->add('entry:after'); + + $this->assertSame(['entry:after'], $this->recorder->tags()); + } + + #[Test] + public function suppression_nests(): void + { + $this->recorder->suppressed(function (): void { + $this->recorder->suppressed(fn () => $this->recorder->add('entry:inner')); + $this->recorder->add('entry:outer'); + }); + + $this->recorder->add('entry:after'); + + $this->assertSame(['entry:after'], $this->recorder->tags()); + } + #[Test] public function it_collapses_to_the_overflow_tag_past_the_cap(): void { diff --git a/tests/Recording/NavigationTest.php b/tests/Recording/NavigationTest.php new file mode 100644 index 0000000..466ece2 --- /dev/null +++ b/tests/Recording/NavigationTest.php @@ -0,0 +1,74 @@ +title('Main')->save(); + Nav::make('footer_nav')->title('Footer')->save(); + } + + #[Test] + public function resolving_a_nav_by_handle_records_it(): void + { + // Statamic's nav tag reaches this through Tags\Structure::structure(). + $tags = $this->tagsRecordedDuring(fn () => Nav::findByHandle('main_nav')); + + $this->assertSame(['nav:main_nav'], $tags); + } + + #[Test] + public function find_records_it_too(): void + { + $this->assertSame(['nav:main_nav'], $this->tagsRecordedDuring(fn () => Nav::find('main_nav'))); + } + + #[Test] + public function resolving_one_nav_does_not_record_another(): void + { + $tags = $this->tagsRecordedDuring(fn () => Nav::findByHandle('footer_nav')); + + $this->assertSame(['nav:footer_nav'], $tags); + $this->assertNotContains('nav:main_nav', $tags); + } + + #[Test] + public function resolving_a_missing_nav_records_nothing(): void + { + $this->assertSame([], $this->tagsRecordedDuring(fn () => Nav::findByHandle('nope'))); + } + + #[Test] + public function listing_every_nav_records_all_of_them(): void + { + // Over-recording on purpose. A nav that goes unrecorded means a visitor + // keeps seeing a removed menu item, which is the one failure that shows; + // recording too many only clears more pages than strictly necessary. + $tags = $this->tagsRecordedDuring(fn () => Nav::all()); + + $this->assertContains('nav:main_nav', $tags); + $this->assertContains('nav:footer_nav', $tags); + } + + #[Test] + public function resolving_a_nav_tree_records_the_nav(): void + { + // A second, independent recording point, for a template that already holds + // a Nav object and never goes back through the repository. + $tags = $this->tagsRecordedDuring(function (): void { + Nav::findByHandle('main_nav')->in('default'); + }); + + $this->assertContains('nav:main_nav', $tags); + } +} diff --git a/tests/RendersAndRecordsTest.php b/tests/RendersAndRecordsTest.php index 4589aee..250a17d 100644 --- a/tests/RendersAndRecordsTest.php +++ b/tests/RendersAndRecordsTest.php @@ -5,6 +5,7 @@ namespace RoxDigital\CacheInvalidation\Tests; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Blink; use Statamic\Facades\Collection; use Statamic\Facades\Entry; use Statamic\Facades\GlobalSet; @@ -34,9 +35,20 @@ protected function setUp(): void $set = tap(GlobalSet::make('footer'))->save(); $set->makeLocalization('default')->data(['phone' => '0123'])->save(); - Collection::make('pages')->routes('/{slug}')->template('entry')->save(); + // Structured, like a real pages collection. That matters: findByUri() + // consults the tree for a structured collection, and Tree::tree() runs + // validateTree(), which plucks the whole collection. + $pages = Collection::make('pages') + ->routes('/{slug}') + ->template('entry') + ->structureContents(['root' => false]); + $pages->save(); - Entry::make()->collection('pages')->slug('about')->data(['title' => 'About us'])->save(); + $about = Entry::make()->collection('pages')->slug('about')->data(['title' => 'About us']); + $about->save(); + + // In a structured collection the URI comes from the tree, not the route. + $pages->structure()->in('default')->tree([['entry' => $about->id()]])->save(); } protected function resolveApplicationConfiguration($app): void @@ -84,6 +96,27 @@ public function a_rendered_page_is_then_cleared_by_saving_what_it_read(): void $this->assertSame([], $cacher->getUrls()->values()->all()); } + #[Test] + public function resolving_a_url_in_a_structured_collection_records_only_the_entry(): void + { + // findByUri() consults the collection tree, and Tree::tree() runs + // validateTree(), which plucks every entry in the collection. Recording + // that made every page depend on its whole collection, so saving any one + // page cleared every cached page on the site. + // + // Blink is flushed first because the tree is memoised per process: without + // this the tree built during setUp is reused and the pluck never runs, + // which is exactly why an earlier version of this test passed against the + // bug it was written for. + Blink::flush(); + + $tags = $this->tagsRecordedDuring(fn () => Entry::findByUri('/about')); + + $about = Entry::query()->where('collection', 'pages')->first(); + + $this->assertSame(["entry:{$about->id()}"], $tags); + } + #[Test] public function rendering_a_page_does_not_record_collections_it_never_touched(): void { @@ -101,7 +134,12 @@ public function rendering_a_page_does_not_record_collections_it_never_touched(): $this->assertContains('collection:articles', $tags, 'the collection it listed'); $this->assertNotContains('collection:unrelated', $tags); $this->assertNotContains('collection:another', $tags); - $this->assertNotContains('collection:pages', $tags, 'its own collection was resolved by uri, not listed'); + + // The page's own collection is structured, so resolving its URL makes + // Statamic validate the tree by plucking every entry in it. That is + // bookkeeping, not rendered output: recording it made saving any single + // page clear every cached page on the site. + $this->assertNotContains('collection:pages', $tags); } #[Test] From 6ef5182d1d429370f213329ab8b56b4e817ef3c9 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Mon, 10 Aug 2026 17:09:54 +0200 Subject: [PATCH 12/15] fix: represent a rendered nav by the nav, not by the pages it links to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statamic's TreeBuilder resolves every linked entry to build a menu, so a nav in the shared layout put an entry tag for each of its items on every page. Renaming any page that appears in the menu then cleared the whole site — the previous commit removed one cause of that and left this one. The nav tag now renders inside a suppression scope and re-adds only its own nav:{handle}. Statamic resolves tag classes through the container, so binding over Tags\Nav reaches both {{ nav:handle }} and {{ nav for="handle" }} without touching the tag registry. A nav pointed at a collection structure records that collection's list tag instead, since the tree is the collection. This is a trade-off, not a straight win, and it is the one asked for: rename a page and its menu label stays stale on already-cached pages until they clear for another reason. Saving the navigation clears them. A page save clears where that page is rendered as content. Measured on meerdervoort, 215 cached URLs: 31.9 tags per URL against 68.6 before, and rows more than halved from 14608 to 6859. A page save clears a median of 1 URL where it previously cleared all of them — 19 of 25 sampled pages clear 0-5, two still clear the site because they are genuinely rendered in the layout. Saving main_nav clears all 215, which is correct: all 215 render it. Employee saves clear 8, reusable blocks 2, articles 10, products 82. Mutation-checked: dropping the suppression fails the test, and so does dropping the nav tag it re-adds. --- CHANGELOG.md | 21 ++++++++---- README.md | 18 +++++++--- src/Recording/TrackingNavTag.php | 55 ++++++++++++++++++++++++++++++ src/ServiceProvider.php | 6 ++++ tests/Recording/NavigationTest.php | 21 ++++++++++++ 5 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 src/Recording/TrackingNavTag.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5baac..94ac71d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,13 +55,20 @@ renders; v2 observes it rather than restating it. - Navigations are tagged where they render, so one used on a handful of pages clears only those. A nav in the shared layout still reaches every page, but as a consequence of where it is used rather than a special case. -- Statamic's URL resolution is excluded from recording. Resolving a URL in a - structured collection makes Statamic validate the collection tree, which plucks - every entry in it; recording that made every page depend on its whole collection, - so saving any single page cleared every cached page. Measured on a 213-page site: - before, every page save cleared all 213; after, pages absent from the navigation - clear 0–5 URLs, while pages in the navigation still clear everything because their - title genuinely appears on every page. +- Two kinds of read are excluded from recording, because both made almost every save + clear almost everything. Statamic's URL resolution, which for a structured + collection validates the whole collection tree; and navigation menus, where the + nav is recorded as `nav:{handle}` rather than as an `entry:` tag per menu item. + + The navigation exclusion is a deliberate trade-off: renaming a page leaves its + menu label stale on already-cached pages until they clear for another reason, and + saving the navigation clears them. A page save clears where that page is rendered + as content. + + Measured on a 215-page site: 31.9 tags per URL where recording everything gave + 68.6, and a page save clears a median of 1 URL where before it cleared all of + them. 19 of 25 sampled pages clear 0–5 URLs. Saving a navigation clears all 215, + because all 215 render it. - Globals invalidate only where they are read. A set rendered in the layout still reaches every page; one rendered by a single block reaches that block's pages. - Form blueprint saves clear the pages rendering that form instead of the entire diff --git a/README.md b/README.md index a68bd08..ab4ef13 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,19 @@ The cache is never flushed wholesale — URLs are invalidated individually, so is special-cased either: a navigation or global that reaches every page does so because it is recorded on every page, not because of a rule. -**Rendered output only.** Statamic reads content to work out *which* entry a URL -belongs to, and for a structured collection that includes validating the whole -collection tree. Those reads are not recorded — the page does not display them. -Without that distinction every page would depend on its entire collection, and -saving one page would clear the whole site. +**Rendered output only.** Two kinds of read are deliberately excluded, because +recording them makes almost every save clear almost everything: + +- **URL resolution.** Statamic reads content to work out which entry a URL belongs + to, and for a structured collection that includes validating the whole collection + tree. The page does not display that. +- **Navigation menus.** A nav is recorded as `nav:{handle}`, not as an `entry:` tag + per menu item. Otherwise a menu in your layout would make every page depend on + every page in it, and renaming one would clear the site. + +The second is a trade-off worth stating plainly: rename a page and its menu label +stays stale on already-cached pages until they clear for another reason. Saving the +navigation clears them. A page save clears where that page is rendered *as content*. **Safety net.** A URL that is cached but absent from the graph is treated as depending on everything and cleared by the next save. That covers pages cached diff --git a/src/Recording/TrackingNavTag.php b/src/Recording/TrackingNavTag.php new file mode 100644 index 0000000..b2fca94 --- /dev/null +++ b/src/Recording/TrackingNavTag.php @@ -0,0 +1,55 @@ +handle() : $handle; + + $value = $recorder->suppressed(fn () => parent::structure($handle)); + + // Suppression also drops the nav the repository would have recorded, so it + // is re-added here — this is the one tag that should survive. + if (is_string($resolved) && $resolved !== '') { + Str::startsWith($resolved, 'collection::') + // A collection structure is that collection's tree, so its list + // tag is the honest dependency. + ? $recorder->add(Tag::collection(Str::after($resolved, 'collection::'))) + : $recorder->nav($resolved); + } + + return $value; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index df4d397..2fe4d4e 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -28,6 +28,7 @@ use RoxDigital\CacheInvalidation\Recording\TrackingEntryRepository; use RoxDigital\CacheInvalidation\Recording\TrackingFormRepository; use RoxDigital\CacheInvalidation\Recording\TrackingNavigationRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingNavTag; use RoxDigital\CacheInvalidation\Recording\TrackingNavTreeRepository; use RoxDigital\CacheInvalidation\Recording\TrackingTermRepository; use RoxDigital\CacheInvalidation\Recording\TrackingVariables; @@ -45,6 +46,7 @@ use Statamic\Stache\Stache; use Statamic\Stache\Stores\Store; use Statamic\Statamic; +use Statamic\Tags\Nav as NavTag; use Statamic\StaticCaching\Cachers\Writer; use Statamic\StaticCaching\StaticCacheManager; @@ -263,6 +265,10 @@ private function registerReadRecorders(): void Statamic::repository(NavigationRepositoryContract::class, TrackingNavigationRepository::class); Statamic::repository(NavTreeRepositoryContract::class, TrackingNavTreeRepository::class); + // Statamic resolves tag classes through the container, so this reaches the + // nav tag without touching the tag registry. + $this->app->bind(NavTag::class, TrackingNavTag::class); + // The global variables store builds its items with app(Variables::class). $this->app->bind(VariablesContract::class, TrackingVariables::class); diff --git a/tests/Recording/NavigationTest.php b/tests/Recording/NavigationTest.php index 466ece2..3621789 100644 --- a/tests/Recording/NavigationTest.php +++ b/tests/Recording/NavigationTest.php @@ -6,7 +6,10 @@ use PHPUnit\Framework\Attributes\Test; use RoxDigital\CacheInvalidation\Tests\TestCase; +use Statamic\Facades\Collection; +use Statamic\Facades\Entry; use Statamic\Facades\Nav; +use Statamic\Statamic; final class NavigationTest extends TestCase { @@ -60,6 +63,24 @@ public function listing_every_nav_records_all_of_them(): void $this->assertContains('nav:footer_nav', $tags); } + #[Test] + public function rendering_a_nav_records_the_nav_and_not_the_pages_it_links_to(): void + { + Collection::make('pages')->save(); + $linked = tap(Entry::make()->collection('pages')->slug('about')->data(['title' => 'About']))->save(); + + Nav::find('main_nav')->makeTree('default', [['entry' => $linked->id()]])->save(); + + $tags = $this->tagsRecordedDuring(fn () => Statamic::tag('nav:main_nav')->fetch()); + + // Statamic's TreeBuilder resolves every linked entry to build the menu. + // Recording those put an entry tag for each menu item on every page using + // the nav, so renaming any page in the menu cleared the whole site. + $this->assertContains('nav:main_nav', $tags); + $this->assertNotContains("entry:{$linked->id()}", $tags); + $this->assertNotContains('collection:pages', $tags); + } + #[Test] public function resolving_a_nav_tree_records_the_nav(): void { From 1f5671b60c8fa99ec6593612b3d9b242d923124e Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Mon, 10 Aug 2026 17:57:43 +0200 Subject: [PATCH 13/15] test: pin each stated requirement to a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One test per requirement, so the behaviour that was asked for is asserted rather than inferred from the implementation. Covers the paths that had no test of their own: navigation reorder, collection tree reorder, published-state changes, an entry linked from Bard, a form rendered from inside a reusable block, and the deliberate absence of invalidation on a form submission. Mutation-checking these found two tests that passed for the wrong reason. The Bard test needed the link removed from the fixture to prove it asserts anything — it does, and it confirms Bard resolves statamic:// entry hrefs during augmentation, which is what makes v1's hand-rolled reference scanning unnecessary. The navigation reorder test does not exercise the branch it appears to. Statamic's two nav-tree events arrive differently: NavTreeSaved hands the invalidator $tree->structure(), a Nav, while NavTreeDeleted hands it the tree. Removing the NavTree case from the resolver therefore changed nothing. The tree branch now has its own assertion. The submission test is meaningful: swapping the submission save for a form save fails it, so submissions genuinely reach no invalidation path while form saves do. Full measure was verified against a real site rather than only in the harness — files written under public/static, recorded 1:1, and removed once the queue worker processed the invalidation job. --- tests/RequirementsTest.php | 251 +++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 tests/RequirementsTest.php diff --git a/tests/RequirementsTest.php b/tests/RequirementsTest.php new file mode 100644 index 0000000..db20f2c --- /dev/null +++ b/tests/RequirementsTest.php @@ -0,0 +1,251 @@ +cacher = app(Cacher::class); + $this->domain = $this->cacher->getBaseUrl(); + } + + #[Test] + public function it_invalidates_through_statamics_own_invalidator_contract(): void + { + // So Statamic's Invalidate subscriber drives it — which is ShouldQueue, and + // therefore runs on a worker when one is configured and inline otherwise. + // Nothing here re-implements dispatching. + $invalidator = app(Invalidator::class); + + $this->assertInstanceOf(DefaultInvalidator::class, $invalidator); + $this->assertTrue(is_subclass_of(\Statamic\StaticCaching\Invalidate::class, \Illuminate\Contracts\Queue\ShouldQueue::class)); + } + + #[Test] + public function reordering_a_navigation_clears_the_pages_that_render_it(): void + { + Nav::make('main_nav')->title('Main')->save(); + $tree = Nav::find('main_nav')->makeTree('default'); + $tree->save(); + + $this->cache('/uses-nav', ['nav:main_nav']); + $this->cache('/no-nav', ['entry:x']); + + // A reorder dispatches NavTreeSaved rather than NavSaved. + $tree->tree([])->save(); + + $this->assertCachedIs(['/no-nav']); + } + + #[Test] + public function a_nav_tree_resolves_to_its_navs_tag(): void + { + // The two nav-tree events arrive differently: NavTreeSaved hands the + // invalidator the Nav (via $tree->structure()), NavTreeDeleted hands it the + // tree itself. The reorder test above therefore exercises the Nav branch, + // and this covers the tree branch. + Nav::make('main_nav')->title('Main')->save(); + + $tree = Nav::find('main_nav')->makeTree('default'); + + $this->assertSame(['nav:main_nav'], app(TagResolver::class)->forItem($tree)); + } + + #[Test] + public function a_form_submission_does_not_clear_anything(): void + { + Form::make('contact')->save(); + + $this->cache('/contact', ['form:contact']); + + $submission = Form::find('contact')->makeSubmission()->data(['name' => 'Bob']); + $submission->save(); + + $this->assertCachedIs(['/contact']); + } + + #[Test] + public function saving_a_form_clears_pages_that_render_it_from_inside_a_reusable_block(): void + { + Form::make('contact')->save(); + Collection::make('reusable_blocks')->save(); + + $this->blueprint('collections.reusable_blocks', 'reusable_block', [ + 'form' => ['type' => 'form', 'max_items' => 1], + ]); + + $block = tap(Entry::make()->collection('reusable_blocks')->slug('cta') + ->data(['title' => 'CTA', 'form' => 'contact']))->save(); + + // Rendering the embedding page renders the block inline, so the block's + // reads are the page's reads. Depth is irrelevant to the recorder. + $tags = $this->tagsRecordedDuring(function () use ($block): void { + $embedded = Entry::find($block->id()); + $form = $embedded->augmentedValue('form')->value(); + $form?->handle(); + }); + + $this->assertContains('form:contact', $tags); + $this->assertContains("entry:{$block->id()}", $tags); + } + + #[Test] + public function saving_a_term_clears_pages_that_rendered_it(): void + { + Taxonomy::make('topics')->save(); + $term = tap(Term::make('news')->taxonomy('topics')->data(['title' => 'News']))->save(); + + $this->cache('/shows-term', ['term:topics::news']); + $this->cache('/lists-topics', ['taxonomy:topics']); + $this->cache('/unrelated', ['entry:x']); + + $term->in('default')->data(['title' => 'Nieuws'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function an_entry_linked_from_bard_is_recorded(): void + { + Collection::make('articles')->save(); + Collection::make('pages')->save(); + + $linked = tap(Entry::make()->collection('articles')->slug('target')->data(['title' => 'Target']))->save(); + + $this->blueprint('collections.pages', 'page', [ + 'body' => ['type' => 'bard', 'save_html' => false], + ]); + + $page = tap(Entry::make()->collection('pages')->slug('home')->data([ + 'title' => 'Home', + 'body' => [[ + 'type' => 'paragraph', + 'content' => [[ + 'type' => 'text', + 'text' => 'Read this', + 'marks' => [['type' => 'link', 'attrs' => ['href' => 'statamic://entry::'.$linked->id()]]], + ]], + ]], + ]))->save(); + + // Bard rewrites statamic:// hrefs to real URLs when augmented, which means + // resolving the entry. v1 had to scan raw values for these by hand. + $tags = $this->tagsRecordedDuring(fn () => (string) $page->augmentedValue('body')); + + $this->assertContains("entry:{$linked->id()}", $tags); + } + + #[Test] + public function a_new_entry_clears_pages_that_list_its_collection(): void + { + Collection::make('articles')->save(); + + $this->cache('/latest-three', ['collection:articles']); + $this->cache('/unrelated', ['collection:pages']); + + // A brand new entry's id is in no recorded tag set, so only the list tag + // can reach the pages that show it. + Entry::make()->collection('articles')->slug('fresh')->data(['title' => 'Fresh'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function changing_an_entrys_published_state_clears_pages_that_list_its_collection(): void + { + Collection::make('articles')->save(); + $entry = tap(Entry::make()->collection('articles')->slug('one')->data(['title' => 'One'])->published(true))->save(); + + $this->cache('/latest-three', ['collection:articles']); + $this->cache('/unrelated', ['collection:pages']); + + $entry->published(false)->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function reordering_a_collection_tree_clears_pages_that_list_it(): void + { + $collection = Collection::make('articles')->structureContents(['root' => false]); + $collection->save(); + + $a = tap(Entry::make()->collection('articles')->slug('a')->data(['title' => 'A']))->save(); + $b = tap(Entry::make()->collection('articles')->slug('b')->data(['title' => 'B']))->save(); + + $tree = $collection->structure()->in('default'); + $tree->tree([['entry' => $a->id()], ['entry' => $b->id()]])->save(); + + $this->cache('/latest-three', ['collection:articles']); + $this->cache('/unrelated', ['collection:pages']); + + // Order drives what "latest three" renders, and a reorder dispatches + // CollectionTreeSaved rather than any entry event. + $tree->tree([['entry' => $b->id()], ['entry' => $a->id()]])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function saving_a_global_clears_only_pages_that_read_it(): void + { + $set = tap(GlobalSet::make('footer'))->save(); + $set->makeLocalization('default')->data(['phone' => '1'])->save(); + + $other = tap(GlobalSet::make('seo'))->save(); + $other->makeLocalization('default')->data(['title' => 'x'])->save(); + + $this->cache('/reads-footer', ['global:footer']); + $this->cache('/reads-seo', ['global:seo']); + + GlobalSet::find('footer')->in('default')->data(['phone' => '2'])->save(); + + $this->assertCachedIs(['/reads-seo']); + } + + /** + * @param list $tags + */ + private function cache(string $path, array $tags): void + { + $this->cacher->cacheUrl(md5($path), $path, $this->domain); + $this->graph->record($this->domain.$path, $tags); + } + + /** + * @param list $expected + */ + private function assertCachedIs(array $expected): void + { + $this->assertEqualsCanonicalizing( + $expected, + $this->cacher->getUrls($this->domain)->values()->all(), + ); + } +} From 9381eca60b01214966205263fc99b625ab3cdbd1 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Mon, 10 Aug 2026 18:08:52 +0200 Subject: [PATCH 14/15] docs: explain how reads are observed, and state what is not covered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README described what gets recorded but never how, which is the part a developer needs to trust it. It now names the hook points and says why entries hook getFilteredKeys() rather than get() — count() and pluck() bypass get() entirely. Adds a "Not covered" section. Statamic dispatches invalidation events for content only, and only some of it, so four kinds of change clear nothing: assets, blueprints and fieldsets outside the forms namespace, users, and code. Assets and non-form blueprints are the notable ones — both already reach the invalidator, they just have no tag to match, so neither is far from working. Code deploys are called out separately under Deploying, because that one is easy to be caught by: templates and translations dispatch nothing at all, so changing a Blade file leaves the cache serving the old markup until statamic:static:clear runs. Also trims the local development section and drops a duplicated note about recording happening only on a cache miss. --- README.md | 75 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ab4ef13..8e9457f 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,28 @@ its scope, because an entry created tomorrow has an id that is in no tag set yet depth. If page C embeds a block that pulls in a global and another entry, page C carries all three dependencies and clears when any of them is saved. -**Where reads are observed:** entries and terms at the query builder, globals at -augmentation (so a global is only tagged where it is actually read), forms at -`FormRepository::find()`. +### How reads are observed + +Every piece of content in Statamic is fetched through a repository or a query +builder resolved from the container. The addon replaces those with subclasses, so +each read passes through it on the way to your template — nothing in your code +changes, and there is no scanning or parsing. + +| Content | Where it is recorded | +|---|---| +| Entries | `EntryQueryBuilder::getFilteredKeys()` and `getItems()` | +| Terms | `TermQueryBuilder`, via a replaced `TermRepository::query()` | +| Globals | `Variables::newAugmentedInstance()`, on first value read | +| Forms | `FormRepository::find()` | +| Navigations | `NavigationRepository`, `NavTreeRepository`, and the `nav` tag | + +Entries hook `getFilteredKeys()` rather than `get()` because `count()` and +`pluck()` bypass `get()` entirely; `getItems()` then adds item tags for the entries +that survived `limit` and `offset`. A request-scoped recorder collects the tags, and +the cacher writes them against the URL as the page is stored. + +This only runs on a cache miss — during a render you are already paying for. A +cached hit never reaches it. ### What a save clears @@ -207,9 +226,8 @@ Expect one round of broad invalidation after deploying while the graph fills. ## Local development -To work on the addon against a real site, clone it inside the site and point -Composer at the clone instead of the VCS source. Path repositories symlink by -default, so edits in `src/` take effect on the next request with no reinstall. +Clone the addon inside a site and point Composer at the clone. Path repositories +symlink by default, so edits in `src/` take effect on the next request. ```bash git clone git@github.com:roxdigital/cache-invalidation.git addons/roxdigital/cache-invalidation @@ -218,11 +236,7 @@ git clone git@github.com:roxdigital/cache-invalidation.git addons/roxdigital/cac ```json { "repositories": [ - { - "type": "path", - "url": "addons/roxdigital/cache-invalidation", - "options": { "symlink": true } - } + { "type": "path", "url": "addons/roxdigital/cache-invalidation" } ] } ``` @@ -231,19 +245,18 @@ git clone git@github.com:roxdigital/cache-invalidation.git addons/roxdigital/cac composer require roxdigital/cache-invalidation:@dev ``` -Adding a class in a new subdirectory needs `composer dump-autoload` if the site was -installed with an optimised autoloader. Switch back with -`composer require roxdigital/cache-invalidation:^2.0` once the `path` repository is -removed. +A class in a new subdirectory needs `composer dump-autoload` if the site uses an +optimised autoloader. To test a branch as a consumer would get it, require the +branch alias instead — `2.x-dev` for branch `v2`, not `dev-v2`. -Run the addon's own suite from its directory: - -```bash -composer install && composer test -``` +The addon's own suite runs from its directory with `composer install && composer test`. ## Deploying +- **Clear the cache when you deploy code.** Templates, translations and PHP are not + content, so nothing dispatches an event for them — change a Blade file and the + cache keeps serving the old markup. `php artisan statamic:static:clear` belongs in + your deploy script; this addon narrows content invalidation, not code deploys. - Run `cache-invalidation:doctor` as a deploy step. It exits non-zero when invalidation cannot work, so a broken environment fails the pipeline instead of quietly serving stale pages. @@ -257,12 +270,26 @@ composer install && composer test - On full measure, `statamic:static:warm` fills the graph promptly instead of lazily. +## Not covered + +Statamic only dispatches invalidation events for content, and only some of it, so a +few changes clear nothing. None of these are silent in a surprising way — they are +listed so you know where the edges are. + +- **Assets.** Replacing an image clears nothing. `AssetSaved` does reach the + invalidator, but asset reads are not recorded, so there is no tag to match. +- **Blueprints and fieldsets outside forms.** Adding a field to a collection + blueprint can change every page of that collection; Statamic only routes the + `forms` namespace to invalidation. +- **Users.** A user save dispatches nothing. If a template renders a user's name, + rename them and it stays. Authors kept as entries are unaffected. +- **Templates, translations and code.** See Deploying above. + ## Good to know -- Assets are not tracked; saving one clears nothing extra. -- Recording only happens on a cache miss. Invalidation is one indexed lookup plus - the deletes — nothing walks content, which matters with a single queue worker or - `QUEUE_CONNECTION=sync`. +- Invalidation is one indexed lookup plus the deletes. Nothing walks content, which + matters with a single queue worker or `QUEUE_CONNECTION=sync`, where it runs inside + the editor's save request. - A page with more than 2,000 dependencies is treated as depending on everything. - Globals are not scoped per site, so on a multisite install saving one clears the pages that read it across every site. From e2e24c90fa652c8b7dbc0bfe42f1f2e99d304ce6 Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Mon, 10 Aug 2026 18:13:22 +0200 Subject: [PATCH 15/15] test: assert recording is independent of how a template asks for content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooking repositories and query builders should make the template's style irrelevant, but that is a claim worth checking rather than repeating. One test per access path: a plain PHP query, the Antlers collection, taxonomy and form tags, an augmented field, and a global off the cascade. All record; the Antlers tags reach the same facades, so Statamic::tag('collection:articles') and Entry::query() land in the same place. Also asserts the boundary, so it is pinned rather than assumed: content fetched outside Statamic records nothing, which is the case CacheTags::add() exists for. Notes in the README that recording extends the Stache repositories and therefore assumes the flat-file driver. A site on statamic/eloquent-driver replaces those and has not been tested — worth stating rather than discovering. --- README.md | 10 ++ tests/Recording/DataAccessPathsTest.php | 126 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/Recording/DataAccessPathsTest.php diff --git a/README.md b/README.md index 8e9457f..3467589 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ when you add a pagebuilder block. Requires PHP `^8.4`, Laravel `^12.0 || ^13.0`, Statamic `^6.0`. Works with both the `half` and `full` static caching strategies. +Recording extends Statamic's Stache repositories and query builders, so this assumes +the standard flat-file content driver. A site running `statamic/eloquent-driver` +replaces those, and has not been tested. + ## Installation Add the VCS source to your project's `composer.json`: @@ -89,6 +93,12 @@ Entries hook `getFilteredKeys()` rather than `get()` because `count()` and that survived `limit` and `offset`. A request-scoped recorder collects the tags, and the cacher writes them against the URL as the page is stored. +Because the hooks sit at that level, how a template asks does not matter — a PHP +query in a `@php` block or view model, an Antlers tag (``, +``, ``, ``), an augmented field (`$block->entry`), or a +global off the cascade (`$footer->phone`) all pass through. What does not is content +fetched outside Statamic entirely; see [Data from outside Statamic](#data-from-outside-statamic). + This only runs on a cache miss — during a render you are already paying for. A cached hit never reaches it. diff --git a/tests/Recording/DataAccessPathsTest.php b/tests/Recording/DataAccessPathsTest.php new file mode 100644 index 0000000..4ad58d2 --- /dev/null +++ b/tests/Recording/DataAccessPathsTest.php @@ -0,0 +1,126 @@ +save(); + $this->entryId = tap(Entry::make()->collection('articles')->slug('one') + ->data(['title' => 'One']))->save()->id(); + + Taxonomy::make('topics')->save(); + Term::make('news')->taxonomy('topics')->data(['title' => 'News'])->save(); + + Form::make('contact')->save(); + + $set = tap(GlobalSet::make('footer'))->save(); + $set->makeLocalization('default')->data(['phone' => '123'])->save(); + } + + #[Test] + public function a_plain_php_query_is_recorded(): void + { + // The @php block or view model case. + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->where('collection', 'articles')->get(), + ); + + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->entryId}", $tags); + } + + #[Test] + public function the_antlers_collection_tag_is_recorded(): void + { + $tags = $this->tagsRecordedDuring(fn () => Statamic::tag('collection:articles')->fetch()); + + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->entryId}", $tags); + } + + #[Test] + public function the_antlers_taxonomy_tag_is_recorded(): void + { + $tags = $this->tagsRecordedDuring(fn () => Statamic::tag('taxonomy:topics')->fetch()); + + $this->assertContains('taxonomy:topics', $tags); + $this->assertContains('term:topics::news', $tags); + } + + #[Test] + public function a_globals_read_through_the_cascade_is_recorded(): void + { + // How a Blade layout reaches a global: {{ $footer->phone }}. + $tags = $this->tagsRecordedDuring(function (): void { + GlobalSet::find('footer')->in('default')->phone; + }); + + $this->assertSame(['global:footer'], $tags); + } + + #[Test] + public function an_augmented_field_is_recorded(): void + { + // How a pagebuilder block reaches a relation: $block->entry. + $this->blueprint('collections.pages', 'page', [ + 'related' => ['type' => 'entries', 'collections' => ['articles']], + ]); + + Collection::make('pages')->save(); + $page = tap(Entry::make()->collection('pages')->slug('home') + ->data(['title' => 'Home', 'related' => [$this->entryId]]))->save(); + + $tags = $this->tagsRecordedDuring(function () use ($page): void { + // An entries field augments to a lazy query builder, so it has to be + // run — which is exactly what iterating it in a template does. + $page->augmentedValue('related')->value()->get()->first()?->id(); + }); + + $this->assertContains("entry:{$this->entryId}", $tags); + } + + #[Test] + public function the_form_tag_records_the_form(): void + { + $tags = $this->tagsRecordedDuring(fn () => Statamic::tag('form:create')->params(['in' => 'contact'])->fetch()); + + $this->assertContains('form:contact', $tags); + } + + #[Test] + public function reading_content_outside_a_repository_is_not_recorded(): void + { + // The boundary, stated as a test: recording follows the repositories, so + // anything that sidesteps them — an HTTP call, a file, a custom model — is + // invisible and needs CacheTags::add(). This is what @cachetags is for. + $tags = $this->tagsRecordedDuring(function (): void { + json_decode('{"reviews": 5}', true); + }); + + $this->assertSame([], $tags); + } +}