Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 0 additions & 2 deletions config/activity-log.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
Expand Down
2 changes: 1 addition & 1 deletion docs/content/1.getting-started/1.installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 1 addition & 5 deletions docs/content/3.essentials/4.customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
19 changes: 5 additions & 14 deletions docs/content/3.essentials/5.caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down Expand Up @@ -48,27 +48,18 @@ 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

Short reference here; the full table lives on [/essentials/configuration#cache](/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. |
6 changes: 3 additions & 3 deletions docs/content/3.essentials/6.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date>` (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 <date>` (3 buckets, not 6).

Do NOT add `date_groups` to your published config expecting the buckets to change — it has no effect.

Expand Down
2 changes: 1 addition & 1 deletion docs/content/6.troubleshooting/1.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
47 changes: 0 additions & 47 deletions src/ActivityLogPlugin.php

This file was deleted.

2 changes: 1 addition & 1 deletion src/Timeline/Sources/RelatedActivityLogSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function resolve(Model $subject, Window $window): iterable
$morphClass = (new $relatedClass)->getMorphClass();

/** @var EloquentCollection<int, Model> $rows */
$rows = $subject->{$relation}()->get();
$rows = $subject->{$relation}()->limit($window->cap)->get();

foreach ($rows as $row) {
$subjectPairs[] = [$morphClass, (string) $row->getKey()];
Expand Down
3 changes: 2 additions & 1 deletion src/Timeline/TimelineBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
67 changes: 59 additions & 8 deletions src/Timeline/TimelineCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<int, string> $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<int, string> $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(),
);
}
}
45 changes: 45 additions & 0 deletions tests/Feature/TimelineCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
});