## Faceted product caching plan (Statamic 5)

### Goals and constraints
- **Scale**: 100k products, many facets; avoid combinatorial caches.
- **Cache policy**: Items expire after 48h; full rebuild nightly at 03:00; do not refresh on import completion.
- **Compatibility**: Statamic 5 (Laravel 11) compatible, no vendor changes.
- **Isolation**: Keep caching logic in a dedicated service layer, separate command and tag.

### Approach (two-tier cache)
- **Tier 1 – Single-facet indices**: Cache product ID sets for each single facet value.
  - Example keys: `facet:{site}:{field}:{value}` → `[id1,id2,...]`
  - Also cache `facet:{site}:all` → all product IDs, to short-circuit when no filters are selected.
- **Tier 2 – Request-time composition**:
  - Union within the same facet (e.g., brand=Kia OR Toyota), intersection across different facets (brand ∩ fuel_type ∩ …).
  - Optional: intersect with wishlist IDs when `?wishlist=1`.
  - Keep price range and sorting in the DB via the existing `price_between` scope and `sort`, but pass a reduced `id:in` slice.

### Clean architecture mapping
- **Domain layer**: `App\Domain\FacetedIndex` (interfaces + algorithms for building and resolving facets).
- **Data layer**: `App\Services\FacetedIndex` (cache I/O, product scanning), Laravel Cache (Redis preferred).
- **Presentation layer**: Antlers template uses a tag to fetch candidate IDs; controllers remain thin.

### Components
- **Service (builder)**: `App\Services\FacetedIndex\FacetedIndexBuilder`
  - Scans published products for the current site, buckets IDs per facet value, writes keys with TTL 48h.
  - Writes `facet:{site}:all` once per build.
  - Uses a version key `facet:{site}:version` to support atomic rollovers if needed.
- **Service (resolver)**: `App\Services\FacetedIndex\FacetResolver`
  - Fetches single-facet sets from cache for current filters, unions within facet, intersects across facets starting from the smallest set for performance.
  - Intersects with wishlist IDs (fetched/cached per session token) when applicable.
  - Optional short-lived combo cache: `srch:{site}:{hash(filters)}` (5–15 min) to avoid recomputing hot queries.
- **Console command**: `app:build-facet-index`
  - Dedicated command to (re)build the entire facet index. Not coupled to `app:create-filters-cache`.
  - Idempotent; safe to run anytime. Sets TTL 48h on all keys.
- **Tag**: `App\Tags\Facets` (handle: `facets`)
  - Method `ids` returns a comma-separated (or array) of candidate product IDs for the current request.
  - If no filters selected, returns nothing (template omits `id:in`) or returns all IDs via `facet:{site}:all` (discouraged for very large sets).

### Cache key design
- `facet:{site}:all` → `[id,...]`
- `facet:{site}:{field}:{value}` → `[id,...]`
- `facet:{site}:version` → `vN` (optional, for blue/green rebuilds)
- `wishlist:{session}:ids` → `[id,...]` (already in place)
- `srch:{site}:{hash}` → `[id,...]` (short TTL, optional)

TTL: 48 hours for all `facet:*` keys; `srch:*` 5–15 minutes.

### Scheduling (nightly rebuild at 03:00)
Register the command in `app/Console/Kernel.php` to run daily at 03:00. Do not hook into product import completion.

```php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('app:build-facet-index')->dailyAt('03:00')->withoutOverlapping();
}
```

### Template integration (Statamic 5)
Update `products.antlers.html` to pass reduced ID set while preserving existing price scope and sorting.

```antlers
{{ collection:products paginate="15" as="products"
    id:in="{ facets:ids }"
    sort="{ get:sort ? get:sort : 'price:asc' }"
    query_scope="{ get:wishlist ? 'wishlist' : 'price_between' }"
    price_min="{ get:filters.price-min ? get:filters.price-min : 0 }"
    price_max="{ get:filters.price-max ? get:filters.price-max : 999999 }"
}}
```

Notes:
- If no filters are selected, omit `id:in` entirely to avoid sending 100k IDs. The tag should return an empty value in that case.
- You can keep the existing field-level `:in` filters during rollout, then remove them after verifying parity.

### Command outline (builder)
```php
// app/Console/Commands/BuildFacetIndex.php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Services\FacetedIndex\FacetedIndexBuilder;

class BuildFacetIndex extends Command
{
    protected $signature = 'app:build-facet-index';
    protected $description = 'Build cached single-facet indices for product filtering';

    public function handle(FacetedIndexBuilder $builder): int
    {
        $builder->buildForAllSites(ttlSeconds: 48 * 3600);
        $this->info('Facet index built');
        return self::SUCCESS;
    }
}
```

### Service outline (builder and resolver)
```php
// app/Services/FacetedIndex/FacetedIndexBuilder.php
namespace App\Services\FacetedIndex;

class FacetedIndexBuilder
{
    public function buildForAllSites(int $ttlSeconds): void { /* scan entries, bucket IDs, Cache::put(...) */ }
}

// app/Services/FacetedIndex/FacetResolver.php
namespace App\Services\FacetedIndex;

class FacetResolver
{
    public function resolveCandidateIds(array $filters, ?array $wishlistIds = null): array { /* union+intersect */ }
}
```

### Tag outline
```php
// app/Tags/Facets.php
namespace App\Tags;

use Statamic\Tags\Tags;
use App\Services\FacetedIndex\FacetResolver;

class Facets extends Tags
{
    protected FacetResolver $resolver;
    public function __construct(FacetResolver $resolver) { $this->resolver = $resolver; }

    public function ids(): string
    {
        $filters = request()->input('filters', []);
        unset($filters['price-min'], $filters['price-max']);
        $wishlist = request()->boolean('wishlist');
        $wishlistIds = $wishlist ? /* fetch cached wishlist IDs */ [] : null;
        $ids = $this->resolver->resolveCandidateIds($filters, $wishlistIds);
        return implode(',', $ids);
    }
}
```

### Algorithm details
- Normalize values (trim, case-fold) for stable keys.
- Intersection order: start with the smallest set to minimize operations.
- Use sorted arrays and two-pointer intersection for low memory, or hash-sets when sets are small.

### Invalidation and rebuild
- Only the scheduled nightly command rebuilds caches.
- Keys use TTL 48h; the nightly rebuild refreshes them well before expiry.
- Optionally write to `facet:{site}:version:next` then swap a pointer key for atomic cutover.

### Isolation and structure
- Place services under `app/Services/FacetedIndex/` and (optional) interfaces under `app/Domain/FacetedIndex/`.
- Keep the command and tag thin; all logic in the services.
- Unit-test services thoroughly; integration-test the tag and a few template paths.

### Rollout steps
1) Implement builder service and command; run once in staging; verify key counts and sample contents.
2) Implement resolver and tag; A/B compare results with existing field filters.
3) Switch template to `id:in="{ facets:ids }"` behind a feature flag; monitor performance.
4) Remove redundant field-level `:in` filters once parity is confirmed.
5) Enable nightly schedule at 03:00.

### Testing
- Unit tests: builder (bucketing, TTL), resolver (union/intersect correctness, edge cases), wishlist intersection.
- Integration tests: tag returns stable IDs for given inputs; template query returns the same set as baseline.
- Load tests: intersection latency, cache hit ratios, and DB query time before/after.


