staleAfterSeconds`; the constructor * validates this at construction time so a misconfigured * policy fails loudly rather than silently classifying every * stale-grade record as expired. * * Special cases: * * - A `broken` `syncState` short-circuits the freshness check * and is reported as {@see SyncState::broken()} regardless of * timestamps (§7.2 — broken means "missing minimum required * fields", which is structural, not temporal). * * - An `archived` `lifecycleState` short-circuits the freshness * check and is reported as {@see SyncState::archived()} * (§7.2 — archived snapshots are out of the freshness rotation). * * - `lastSyncedAt === null` (never successfully synced) is * treated as `expired`-grade per §7.2's "再同期が強く推奨される * 状態" intent: a snapshot that has never produced a known-good * fetch should be at least as urgent as one that has aged past * `expiredAfterSeconds`. This documented choice keeps the * never-synced cohort visible to the high-priority sweep * without inventing a separate state token. * * Phase 9 MVP scope: * * - This class consumes the global TTL boundaries supplied at * construction time. Per-provider overrides described in §9.3 * are out of scope for slice 9d; a future slice can introduce * a provider-aware variant or compose multiple policies. * * @package Affilun\Application\Sync */ declare(strict_types=1); namespace Affilun\Application\Sync; use Affilun\Domain\Clock; use Affilun\Domain\Sync\SyncPriority; use Affilun\Domain\Sync\SyncState; use Affilun\Infrastructure\Persistence\ProductRecord; use InvalidArgumentException; /** * Decide freshness state and sync priority for a stored * {@see ProductRecord}. See the file-level docblock for the * §7 / §13 boundary semantics and the documented treatment of * `broken` / `archived` / never-synced records. */ final class SyncPolicy { /** * Inclusive upper bound (in seconds) for the `fresh` band. */ private int $staleAfterSeconds; /** * Inclusive upper bound (in seconds) for the `stale` band; * strictly greater than {@see self::$staleAfterSeconds}. */ private int $expiredAfterSeconds; /** * Clock used to resolve "now" for freshness arithmetic. */ private Clock $clock; /** * Construct the policy with explicit boundaries. * * @param int $staleAfterSeconds Inclusive upper bound for * the `fresh` band (must be * > 0). * @param int $expiredAfterSeconds Inclusive upper bound for * the `stale` band (must be * > `$staleAfterSeconds`). * @param Clock $clock Clock that resolves "now". * * @throws InvalidArgumentException When either threshold is * non-positive, or when the * ordering rule * `expiredAfter > staleAfter` * is violated. */ public function __construct( int $staleAfterSeconds, int $expiredAfterSeconds, Clock $clock ) { if ( $staleAfterSeconds <= 0 ) { $message = sprintf( 'SyncPolicy: staleAfterSeconds must be > 0, got %d.', $staleAfterSeconds ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $expiredAfterSeconds <= 0 ) { $message = sprintf( 'SyncPolicy: expiredAfterSeconds must be > 0, got %d.', $expiredAfterSeconds ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $expiredAfterSeconds <= $staleAfterSeconds ) { $message = sprintf( 'SyncPolicy: expiredAfterSeconds (%d) must be > staleAfterSeconds (%d).', $expiredAfterSeconds, $staleAfterSeconds ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->staleAfterSeconds = $staleAfterSeconds; $this->expiredAfterSeconds = $expiredAfterSeconds; $this->clock = $clock; } /** * Inclusive upper bound (in seconds) for the `fresh` band. */ public function staleAfterSeconds(): int { return $this->staleAfterSeconds; } /** * Inclusive upper bound (in seconds) for the `stale` band. */ public function expiredAfterSeconds(): int { return $this->expiredAfterSeconds; } /** * Decide the freshness state of the given record. * * Decision order: * * 1. Structural break (`syncState === 'broken'`) → broken. * 2. Lifecycle archive (`lifecycleState === 'archived'`) → * archived. * 3. Never-synced (`lastSyncedAt === null`) → expired (per * the file-level docblock). * 4. Otherwise compute `age = now - lastSyncedAt` and bucket * it by `staleAfterSeconds` / `expiredAfterSeconds`. * * @param ProductRecord $record Record to classify. */ public function evaluateState( ProductRecord $record ): SyncState { if ( $record->syncState() === SyncState::BROKEN ) { return SyncState::broken(); } if ( $record->lifecycleState() === 'archived' ) { return SyncState::archived(); } $lastSyncedAt = $record->lastSyncedAt(); if ( $lastSyncedAt === null ) { return SyncState::expired(); } $now = $this->clock->now(); $age = $now->getTimestamp() - $lastSyncedAt->getTimestamp(); // Defensive: a future-dated lastSyncedAt is treated as // fresh rather than as an arithmetic anomaly. if ( $age <= 0 ) { return SyncState::fresh(); } if ( $age <= $this->staleAfterSeconds ) { return SyncState::fresh(); } if ( $age <= $this->expiredAfterSeconds ) { return SyncState::stale(); } return SyncState::expired(); } /** * Whether the given record warrants a resync. * * Equivalent to `evaluateState($record)->requiresResync()`. * * @param ProductRecord $record Record under consideration. */ public function requiresResync( ProductRecord $record ): bool { return $this->evaluateState( $record )->requiresResync(); } /** * Decide the sync-queue priority for the given record. * * Mapping (§13.2): * * - archived → low (out of rotation; low-priority cleanup * only). * - broken → urgent (recovery path; structural failure * should be addressed promptly). * - expired → high (past the freshness requirement; should * be refreshed before normal display). * - stale → normal (eligible for routine background * resync). * - fresh → low (already current). * * @param ProductRecord $record Record under consideration. */ public function priorityFor( ProductRecord $record ): SyncPriority { $state = $this->evaluateState( $record ); if ( $state->isArchived() ) { return SyncPriority::low(); } if ( $state->isBroken() ) { return SyncPriority::urgent(); } if ( $state->isExpired() ) { return SyncPriority::high(); } if ( $state->isStale() ) { return SyncPriority::normal(); } return SyncPriority::low(); } }