diff --git a/composer.json b/composer.json index 2b84be5..730bbf5 100644 --- a/composer.json +++ b/composer.json @@ -6,15 +6,15 @@ "require": { "php": "^8.4", "filament/filament": "^5.0", - "illuminate/contracts": "^12.0", - "illuminate/database": "^12.0", - "illuminate/support": "^12.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", "spatie/laravel-activitylog": "^5.0", "spatie/laravel-package-tools": "^1.16" }, "require-dev": { "laravel/pint": "^1.0", - "orchestra/testbench": "^10.0", + "orchestra/testbench": "^10.0|^11.0", "pestphp/pest": "^4.0", "pestphp/pest-plugin-laravel": "^4.0", "pestphp/pest-plugin-livewire": "^4.0" diff --git a/config/activity-log.php b/config/activity-log.php index 8a1bf52..e4e3e00 100644 --- a/config/activity-log.php +++ b/config/activity-log.php @@ -14,8 +14,6 @@ 'custom' => 30, ], - 'date_groups' => ['today', 'yesterday', 'this_week', 'last_week', 'this_month', 'older'], - 'renderers' => [ // 'email_sent' => \App\Timeline\Renderers\EmailSentRenderer::class, ], diff --git a/docs/content/1.getting-started/1.installation.md b/docs/content/1.getting-started/1.installation.md index 165e4fe..ae76207 100644 --- a/docs/content/1.getting-started/1.installation.md +++ b/docs/content/1.getting-started/1.installation.md @@ -79,7 +79,7 @@ Without this line, you may see unstyled or partially-styled timeline entries in ## Register the panel plugin (optional) -Only needed when you want to register custom renderers; auto-discovery covers everything else. Use the **Filament-namespaced plugin** (not the orphan root `Relaticle\ActivityLog\ActivityLogPlugin` — see [issue #13](https://github.com/relaticle/activity-log/issues/13)): +Only needed when you want to register custom renderers; auto-discovery covers everything else. ```php use Relaticle\ActivityLog\Filament\ActivityLogPlugin; diff --git a/docs/content/3.essentials/4.customization.md b/docs/content/3.essentials/4.customization.md index df3913d..3c9d207 100644 --- a/docs/content/3.essentials/4.customization.md +++ b/docs/content/3.essentials/4.customization.md @@ -61,7 +61,7 @@ Three places to register renderers. They write to the same registry — pick by ### Plugin -Preferred for **panel-scoped overrides**. Use `Relaticle\ActivityLog\Filament\ActivityLogPlugin` (the Filament-namespaced one) and pass renderers directly to the plugin. +Preferred for **panel-scoped overrides**. Use `Relaticle\ActivityLog\Filament\ActivityLogPlugin` and pass renderers directly to the plugin. ```php use Illuminate\Support\HtmlString; @@ -76,10 +76,6 @@ $panel->plugin( ); ``` -::callout{icon="i-lucide-triangle-alert" color="warning"} -**Use the Filament-namespaced plugin.** A stale orphan class `Relaticle\ActivityLog\ActivityLogPlugin` (root namespace, no `Filament\`) still ships in the package — never import that one. Tracked by [issue #13](https://github.com/relaticle/activity-log/issues/13). -:: - ### Facade Useful from a service provider's `boot()` for **global, panel-agnostic overrides** — or for runtime/conditional registration. diff --git a/docs/content/3.essentials/5.caching.md b/docs/content/3.essentials/5.caching.md index b8ed4d6..5b74f39 100644 --- a/docs/content/3.essentials/5.caching.md +++ b/docs/content/3.essentials/5.caching.md @@ -4,7 +4,7 @@ description: Opt-in per-call caching, key composition, and invalidation caveats. navigation: icon: i-lucide-database seo: - description: Per-call caching, cache key composition, and the forgetTimelineCache full-flush limitation in relaticle/activity-log. + description: Per-call caching, cache key composition, and per-subject invalidation in relaticle/activity-log. ogImage: /preview.png --- @@ -48,20 +48,11 @@ Where: Changing any filter — `->ofType(...)`, `->between(...)`, `->sortByDateAsc()` — produces a different key, so re-running the same builder with different chain state does **not** collide on a stale entry. -## Invalidation — known limitation +## Invalidation -::callout{icon="i-lucide-alert-triangle" color="warning"} -**`$record->forgetTimelineCache()` flushes the entire cache store, not just this subject's timeline entries.** +`$record->forgetTimelineCache()` invalidates only this subject's cached timeline pages. It tracks the keys it writes in a per-subject index entry (`{prefix}:{model_class}:{key}:index`) and forgets exactly those keys plus the index — sessions, queue locks, and other application caches in the same store are untouched. -Internally it calls `Cache::store(...)->getStore()->flush()`. If you share the default cache store with sessions, application caches, queue locks, or anything else, calling `forgetTimelineCache()` clears all of them. - -Tracked by [issue #12](https://github.com/relaticle/activity-log/issues/12). The recommended fix is tagged-cache invalidation keyed on the per-subject prefix. - -**Workarounds until the fix lands:** - -- **Use a dedicated cache store.** Set `cache.store` to a Redis database, file path, or memory store dedicated to the timeline. Flushing it then only affects timeline entries — see [Configuration knobs](#configuration-knobs) below. -- **Skip explicit invalidation.** Pick a TTL short enough that staleness is acceptable (e.g. 60 seconds for a high-traffic dashboard) and let entries expire naturally. No `forgetTimelineCache()` call needed. -:: +Alternative: skip explicit invalidation and pick a TTL short enough that staleness is acceptable (e.g. 60 seconds for a high-traffic dashboard) and let entries expire naturally. ## Configuration knobs @@ -69,6 +60,6 @@ Short reference here; the full table lives on [/essentials/configuration#cache]( | Key | Default | Effect | |---|---|---| -| `cache.store` | `null` (default cache) | Which Laravel cache store to use. **Strongly recommended: a dedicated store** (see invalidation caveat above). | +| `cache.store` | `null` (default cache) | Which Laravel cache store to use. | | `cache.ttl_seconds` | `0` | Reserved; not currently consulted by `TimelineCache`. The per-call `->cached($ttl)` is the working knob. | | `cache.key_prefix` | `'activity-log'` | Namespace for cache keys. | diff --git a/docs/content/3.essentials/6.configuration.md b/docs/content/3.essentials/6.configuration.md index 1f13537..93ed3ef 100644 --- a/docs/content/3.essentials/6.configuration.md +++ b/docs/content/3.essentials/6.configuration.md @@ -22,16 +22,16 @@ Every knob the package exposes lives in `config/activity-log.php`. Publish it wi | `source_priorities.related_model` | `int` | `20` | Priority for `RelatedModelSource`. | | `source_priorities.custom` | `int` | `30` | Priority for `CustomEventSource`. | | `renderers` | `array` | `[]` | Event-or-type → renderer map. See [/essentials/customization#registration-channels](/essentials/customization#registration-channels). | -| `cache.store` | `?string` | `null` (default cache) | Laravel cache store name. **Recommended: dedicated store** to avoid `forgetTimelineCache` cross-contamination. See [/essentials/caching](/essentials/caching). | +| `cache.store` | `?string` | `null` (default cache) | Laravel cache store name. See [/essentials/caching](/essentials/caching). | | `cache.ttl_seconds` | `int` | `0` | Reserved; not currently consulted by `TimelineCache`. The per-call `->cached($ttl)` is the working knob. | | `cache.key_prefix` | `string` | `'activity-log'` | Prefix for all cache keys. | ## Removed key: `date_groups` ::callout{icon="i-lucide-alert-triangle" color="warning"} -Earlier docs (and the published config still ships) a `date_groups` key listing 6 bucket labels (`today`, `yesterday`, `this_week`, `last_week`, `this_month`, `older`). +Earlier versions of `config/activity-log.php` shipped a `date_groups` key listing 6 bucket labels (`today`, `yesterday`, `this_week`, `last_week`, `this_month`, `older`). It has been removed. -**The key is dead.** `Grep src/` returns zero references. The actual buckets emitted by `ActivityLogLivewire::bucketFor()` are `this_week`, `last_week`, and `week_of ` (3 buckets, not 6). +**The key was dead.** `Grep src/` returns zero references. The actual buckets emitted by `ActivityLogLivewire::bucketFor()` are `this_week`, `last_week`, and `week_of ` (3 buckets, not 6). Do NOT add `date_groups` to your published config expecting the buckets to change — it has no effect. diff --git a/docs/content/6.troubleshooting/1.index.md b/docs/content/6.troubleshooting/1.index.md index f8a0765..e5256dc 100644 --- a/docs/content/6.troubleshooting/1.index.md +++ b/docs/content/6.troubleshooting/1.index.md @@ -4,7 +4,7 @@ description: Common pitfalls, known limitations, and how to work around them. navigation: icon: i-lucide-life-buoy seo: - description: FAQ and known limitations for relaticle/activity-log — Tailwind, renderers, dedup, type filters, cache invalidation, unsaved subjects. + description: FAQ and known limitations for relaticle/activity-log — Tailwind, renderers, dedup, type filters, unsaved subjects. ogImage: /preview.png --- diff --git a/src/ActivityLogPlugin.php b/src/ActivityLogPlugin.php deleted file mode 100644 index 4f96d02..0000000 --- a/src/ActivityLogPlugin.php +++ /dev/null @@ -1,47 +0,0 @@ - */ - private array $renderers = []; - - public static function make(): static - { - return resolve(self::class); - } - - public function getId(): string - { - return 'activity-log'; - } - - /** - * @param array $renderers - */ - public function renderers(array $renderers): static - { - $this->renderers = $renderers; - - return $this; - } - - public function register(Panel $panel): void {} - - public function boot(Panel $panel): void - { - $registry = resolve(RendererRegistry::class); - - foreach ($this->renderers as $key => $renderer) { - $registry->register($key, $renderer); - } - } -} diff --git a/src/Timeline/Sources/RelatedActivityLogSource.php b/src/Timeline/Sources/RelatedActivityLogSource.php index 3a77ba8..9ecd593 100644 --- a/src/Timeline/Sources/RelatedActivityLogSource.php +++ b/src/Timeline/Sources/RelatedActivityLogSource.php @@ -33,7 +33,7 @@ public function resolve(Model $subject, Window $window): iterable $morphClass = (new $relatedClass)->getMorphClass(); /** @var EloquentCollection $rows */ - $rows = $subject->{$relation}()->get(); + $rows = $subject->{$relation}()->limit($window->cap)->get(); foreach ($rows as $row) { $subjectPairs[] = [$morphClass, (string) $row->getKey()]; diff --git a/src/Timeline/TimelineBuilder.php b/src/Timeline/TimelineBuilder.php index a64f006..392f8d5 100644 --- a/src/Timeline/TimelineBuilder.php +++ b/src/Timeline/TimelineBuilder.php @@ -280,7 +280,8 @@ public function paginate(?int $perPage = null, int $page = 1): LengthAwarePagina $cache = resolve(TimelineCache::class); $key = $cache->keyFor($this->subject, $this->filterHash(), $page, $perPage); - return $cache->store()->remember( + return $cache->remember( + $this->subject, $key, $this->cacheTtl, fn (): LengthAwarePaginator => $this->runPaginate($perPage, $page), diff --git a/src/Timeline/TimelineCache.php b/src/Timeline/TimelineCache.php index b353949..551374d 100644 --- a/src/Timeline/TimelineCache.php +++ b/src/Timeline/TimelineCache.php @@ -4,6 +4,7 @@ namespace Relaticle\ActivityLog\Timeline; +use Closure; use Illuminate\Contracts\Cache\Repository; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; @@ -19,23 +20,73 @@ public function store(): Repository public function keyFor(Model $subject, string $filterHash, int $page, int $perPage): string { - $prefix = (string) config('activity-log.cache.key_prefix', 'activity-log'); - return sprintf( - '%s:%s:%s:%s:p%d:pp%d', - $prefix, - str_replace('\\', '_', $subject::class), - (string) $subject->getKey(), + '%s:%s:p%d:pp%d', + $this->subjectPrefix($subject), $filterHash, $page, $perPage, ); } + /** + * @template TValue + * + * @param Closure(): TValue $callback + * @return TValue + */ + public function remember(Model $subject, string $key, int $ttl, Closure $callback): mixed + { + $this->trackKey($subject, $key); + + return $this->store()->remember($key, $ttl, $callback); + } + public function forget(Model $subject): void { - unset($subject); + $store = $this->store(); + $indexKey = $this->indexKey($subject); + + /** @var array $keys */ + $keys = $store->get($indexKey, []); + + foreach ($keys as $key) { + $store->forget($key); + } + + $store->forget($indexKey); + } + + private function trackKey(Model $subject, string $key): void + { + $store = $this->store(); + $indexKey = $this->indexKey($subject); - $this->store()->getStore()->flush(); + /** @var array $keys */ + $keys = $store->get($indexKey, []); + + if (in_array($key, $keys, true)) { + return; + } + + $keys[] = $key; + $store->forever($indexKey, $keys); + } + + private function indexKey(Model $subject): string + { + return $this->subjectPrefix($subject).':index'; + } + + private function subjectPrefix(Model $subject): string + { + $prefix = (string) config('activity-log.cache.key_prefix', 'activity-log'); + + return sprintf( + '%s:%s:%s', + $prefix, + str_replace('\\', '_', $subject::class), + (string) $subject->getKey(), + ); } } diff --git a/tests/Feature/TimelineCacheTest.php b/tests/Feature/TimelineCacheTest.php index 7a080be..ca7df49 100644 --- a/tests/Feature/TimelineCacheTest.php +++ b/tests/Feature/TimelineCacheTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Carbon\CarbonImmutable; +use Illuminate\Support\Facades\Cache; use Relaticle\ActivityLog\Tests\Fixtures\Models\Email; use Relaticle\ActivityLog\Tests\Fixtures\Models\Person; use Relaticle\ActivityLog\Timeline\Sources\RelatedModelSource; @@ -49,3 +50,47 @@ expect($after->total())->toBe(2); }); + +it('forgetTimelineCache() leaves unrelated cache entries intact', function (): void { + $person = Person::factory()->create(); + Email::factory()->for($person)->create(['sent_at' => CarbonImmutable::now()]); + + TimelineBuilder::make($person) + ->fromRelation('emails', fn (RelatedModelSource $s): RelatedModelSource => $s->event('sent_at', 'email_sent')) + ->cached(ttlSeconds: 60) + ->paginate(perPage: 5); + + Cache::put('unrelated:session:abc', 'keep-me', 300); + + $person->forgetTimelineCache(); + + expect(Cache::get('unrelated:session:abc'))->toBe('keep-me'); +}); + +it('forgetTimelineCache() does not affect other subjects', function (): void { + $alice = Person::factory()->create(); + $bob = Person::factory()->create(); + Email::factory()->for($alice)->create(['sent_at' => CarbonImmutable::now()]); + Email::factory()->for($bob)->create(['sent_at' => CarbonImmutable::now()]); + + TimelineBuilder::make($alice) + ->fromRelation('emails', fn (RelatedModelSource $s): RelatedModelSource => $s->event('sent_at', 'email_sent')) + ->cached(ttlSeconds: 60) + ->paginate(perPage: 5); + + $bobFirst = TimelineBuilder::make($bob) + ->fromRelation('emails', fn (RelatedModelSource $s): RelatedModelSource => $s->event('sent_at', 'email_sent')) + ->cached(ttlSeconds: 60) + ->paginate(perPage: 5); + + Email::factory()->for($bob)->create(['sent_at' => CarbonImmutable::now()]); + + $alice->forgetTimelineCache(); + + $bobAfter = TimelineBuilder::make($bob) + ->fromRelation('emails', fn (RelatedModelSource $s): RelatedModelSource => $s->event('sent_at', 'email_sent')) + ->cached(ttlSeconds: 60) + ->paginate(perPage: 5); + + expect($bobAfter->total())->toBe($bobFirst->total()); +});