= 60). */ private int $freshTtlSeconds; /** * Stale TTL in seconds (>= freshTtlSeconds). */ private int $staleTtlSeconds; /** * Expired TTL in seconds (>= staleTtlSeconds). */ private int $expiredTtlSeconds; /** * Maximum concurrent sync jobs (1..16). */ private int $maxConcurrentJobs; /** * Batch size per sync job (1..200). */ private int $batchSize; /** * Retry policy preset (closed set: * `'none' | 'fast' | 'balanced' | 'patient'`). */ private string $retryPolicyPreset; /** * Construct a sync-settings snapshot with explicit values * for every field. * * @param bool $enabled Master sync toggle. * @param bool $scheduledSyncEnabled Periodic-sync toggle. * @param int $freshTtlSeconds Fresh TTL in seconds. * @param int $staleTtlSeconds Stale TTL in seconds. * @param int $expiredTtlSeconds Expired TTL in seconds. * @param int $maxConcurrentJobs Maximum concurrent jobs. * @param int $batchSize Batch size per job. * @param string $retryPolicyPreset Retry policy preset. * * @throws InvalidArgumentException When any field violates * the per-field rules * summarized in the * file-level docblock. */ public function __construct( bool $enabled, bool $scheduledSyncEnabled, int $freshTtlSeconds, int $staleTtlSeconds, int $expiredTtlSeconds, int $maxConcurrentJobs, int $batchSize, string $retryPolicyPreset ) { self::validateTtl( 'freshTtlSeconds', $freshTtlSeconds ); self::validateTtl( 'staleTtlSeconds', $staleTtlSeconds ); self::validateTtl( 'expiredTtlSeconds', $expiredTtlSeconds ); self::validateTtlOrdering( $freshTtlSeconds, $staleTtlSeconds, $expiredTtlSeconds ); self::validateRange( 'maxConcurrentJobs', $maxConcurrentJobs, 1, 16 ); self::validateRange( 'batchSize', $batchSize, 1, 200 ); $this->enabled = $enabled; $this->scheduledSyncEnabled = $scheduledSyncEnabled; $this->freshTtlSeconds = $freshTtlSeconds; $this->staleTtlSeconds = $staleTtlSeconds; $this->expiredTtlSeconds = $expiredTtlSeconds; $this->maxConcurrentJobs = $maxConcurrentJobs; $this->batchSize = $batchSize; $this->retryPolicyPreset = self::normalizeRetryPolicyPreset( $retryPolicyPreset ); } /** * Construct a sync-settings snapshot with the canonical * §13.7 baseline values: sync enabled, TTLs at 1d / 7d / * 30d, two concurrent jobs, batch size 20, balanced retry. */ public static function defaults(): self { return new self( true, true, 86400, 86400 * 7, 86400 * 30, 2, 20, 'balanced' ); } /** * Master sync toggle. */ public function enabled(): bool { return $this->enabled; } /** * Periodic-sync toggle. */ public function scheduledSyncEnabled(): bool { return $this->scheduledSyncEnabled; } /** * Fresh TTL in seconds. */ public function freshTtlSeconds(): int { return $this->freshTtlSeconds; } /** * Stale TTL in seconds. */ public function staleTtlSeconds(): int { return $this->staleTtlSeconds; } /** * Expired TTL in seconds. */ public function expiredTtlSeconds(): int { return $this->expiredTtlSeconds; } /** * Maximum concurrent sync jobs. */ public function maxConcurrentJobs(): int { return $this->maxConcurrentJobs; } /** * Batch size per sync job. */ public function batchSize(): int { return $this->batchSize; } /** * Retry policy preset. */ public function retryPolicyPreset(): string { return $this->retryPolicyPreset; } /** * Produce a copy with `enabled` replaced. * * @param bool $enabled New toggle. */ public function withEnabled( bool $enabled ): self { $clone = clone $this; $clone->enabled = $enabled; return $clone; } /** * Produce a copy with `scheduledSyncEnabled` replaced. * * @param bool $scheduledSyncEnabled New toggle. */ public function withScheduledSyncEnabled( bool $scheduledSyncEnabled ): self { $clone = clone $this; $clone->scheduledSyncEnabled = $scheduledSyncEnabled; return $clone; } /** * Produce a copy with `freshTtlSeconds` replaced. * * The new value must remain consistent with the existing * `staleTtlSeconds` and `expiredTtlSeconds` ordering; * violations raise {@see InvalidArgumentException}. * * @param int $freshTtlSeconds New fresh TTL. * * @throws InvalidArgumentException When the new value * violates the lower * bound or ordering rule. */ public function withFreshTtlSeconds( int $freshTtlSeconds ): self { self::validateTtl( 'freshTtlSeconds', $freshTtlSeconds ); self::validateTtlOrdering( $freshTtlSeconds, $this->staleTtlSeconds, $this->expiredTtlSeconds ); $clone = clone $this; $clone->freshTtlSeconds = $freshTtlSeconds; return $clone; } /** * Produce a copy with `staleTtlSeconds` replaced. * * @param int $staleTtlSeconds New stale TTL. * * @throws InvalidArgumentException When the new value * violates the lower * bound or ordering rule. */ public function withStaleTtlSeconds( int $staleTtlSeconds ): self { self::validateTtl( 'staleTtlSeconds', $staleTtlSeconds ); self::validateTtlOrdering( $this->freshTtlSeconds, $staleTtlSeconds, $this->expiredTtlSeconds ); $clone = clone $this; $clone->staleTtlSeconds = $staleTtlSeconds; return $clone; } /** * Produce a copy with `expiredTtlSeconds` replaced. * * @param int $expiredTtlSeconds New expired TTL. * * @throws InvalidArgumentException When the new value * violates the lower * bound or ordering rule. */ public function withExpiredTtlSeconds( int $expiredTtlSeconds ): self { self::validateTtl( 'expiredTtlSeconds', $expiredTtlSeconds ); self::validateTtlOrdering( $this->freshTtlSeconds, $this->staleTtlSeconds, $expiredTtlSeconds ); $clone = clone $this; $clone->expiredTtlSeconds = $expiredTtlSeconds; return $clone; } /** * Produce a copy with `maxConcurrentJobs` replaced. * * @param int $maxConcurrentJobs New value. * * @throws InvalidArgumentException When the value is * outside the 1..16 range. */ public function withMaxConcurrentJobs( int $maxConcurrentJobs ): self { self::validateRange( 'maxConcurrentJobs', $maxConcurrentJobs, 1, 16 ); $clone = clone $this; $clone->maxConcurrentJobs = $maxConcurrentJobs; return $clone; } /** * Produce a copy with `batchSize` replaced. * * @param int $batchSize New value. * * @throws InvalidArgumentException When the value is * outside the 1..200 range. */ public function withBatchSize( int $batchSize ): self { self::validateRange( 'batchSize', $batchSize, 1, 200 ); $clone = clone $this; $clone->batchSize = $batchSize; return $clone; } /** * Produce a copy with `retryPolicyPreset` replaced. * * @param string $retryPolicyPreset New preset. * * @throws InvalidArgumentException When the preset is off * the closed set. */ public function withRetryPolicyPreset( string $retryPolicyPreset ): self { $clone = clone $this; $clone->retryPolicyPreset = self::normalizeRetryPolicyPreset( $retryPolicyPreset ); return $clone; } /** * Report whether this snapshot equals the supplied one. * * Equality is structural: every field must match under * strict comparison. * * @param self $other Other snapshot to compare against. */ public function equals( self $other ): bool { return $this->enabled === $other->enabled && $this->scheduledSyncEnabled === $other->scheduledSyncEnabled && $this->freshTtlSeconds === $other->freshTtlSeconds && $this->staleTtlSeconds === $other->staleTtlSeconds && $this->expiredTtlSeconds === $other->expiredTtlSeconds && $this->maxConcurrentJobs === $other->maxConcurrentJobs && $this->batchSize === $other->batchSize && $this->retryPolicyPreset === $other->retryPolicyPreset; } /** * Serialize this snapshot for OptionStore persistence. * * @return array */ public function toArray(): array { return array( 'enabled' => $this->enabled, 'scheduled_sync_enabled' => $this->scheduledSyncEnabled, 'fresh_ttl_seconds' => $this->freshTtlSeconds, 'stale_ttl_seconds' => $this->staleTtlSeconds, 'expired_ttl_seconds' => $this->expiredTtlSeconds, 'max_concurrent_jobs' => $this->maxConcurrentJobs, 'batch_size' => $this->batchSize, 'retry_policy_preset' => $this->retryPolicyPreset, ); } /** * Construct a snapshot from a {@see self::toArray()} payload. * * @param array $data Persisted payload. * * @throws InvalidArgumentException When `$data` is malformed. */ public static function fromArray( array $data ): self { return new self( self::requireBool( $data, 'enabled' ), self::requireBool( $data, 'scheduled_sync_enabled' ), self::requireInt( $data, 'fresh_ttl_seconds' ), self::requireInt( $data, 'stale_ttl_seconds' ), self::requireInt( $data, 'expired_ttl_seconds' ), self::requireInt( $data, 'max_concurrent_jobs' ), self::requireInt( $data, 'batch_size' ), self::requireString( $data, 'retry_policy_preset' ) ); } /** * Validate a single TTL value against the lower bound. * * @param string $field Field name for the error message. * @param int $value Raw value. * * @throws InvalidArgumentException When `$value` is below * the lower bound. */ private static function validateTtl( string $field, int $value ): void { if ( $value < self::MIN_TTL_SECONDS ) { $message = sprintf( 'SyncSettings %s must be >= %d.', $field, self::MIN_TTL_SECONDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Validate the TTL ordering rule * `fresh <= stale <= expired`. * * @param int $fresh Fresh TTL. * @param int $stale Stale TTL. * @param int $expired Expired TTL. * * @throws InvalidArgumentException When the ordering is * violated. */ private static function validateTtlOrdering( int $fresh, int $stale, int $expired ): void { if ( $fresh > $stale ) { $message = 'SyncSettings freshTtlSeconds must be <= staleTtlSeconds.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $stale > $expired ) { $message = 'SyncSettings staleTtlSeconds must be <= expiredTtlSeconds.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Validate an integer field against an inclusive range. * * @param string $field Field name for the error message. * @param int $value Raw value. * @param int $min Inclusive lower bound. * @param int $max Inclusive upper bound. * * @throws InvalidArgumentException When `$value` is outside * the `[$min, $max]` range. */ private static function validateRange( string $field, int $value, int $min, int $max ): void { if ( $value < $min || $value > $max ) { $message = sprintf( 'SyncSettings %s must be in [%d, %d].', $field, $min, $max ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Validate and normalize the `retryPolicyPreset` field at * construction. * * @param string $retryPolicyPreset Raw preset value. * * @throws InvalidArgumentException When off the closed set. */ private static function normalizeRetryPolicyPreset( string $retryPolicyPreset ): string { if ( ! in_array( $retryPolicyPreset, self::RETRY_POLICY_PRESETS, true ) ) { $message = sprintf( 'SyncSettings retryPolicyPreset must be one of %s.', implode( ', ', self::RETRY_POLICY_PRESETS ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $retryPolicyPreset; } /** * Read a required bool field from a {@see self::fromArray()} * payload. * * @param array $data Persisted payload. * @param string $key Field name. * * @throws InvalidArgumentException When the field is missing * or not a bool. */ private static function requireBool( array $data, string $key ): bool { if ( ! array_key_exists( $key, $data ) || ! is_bool( $data[ $key ] ) ) { $message = sprintf( 'SyncSettings payload must carry a bool `%s`.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $data[ $key ]; } /** * Read a required int field from a {@see self::fromArray()} * payload. * * @param array $data Persisted payload. * @param string $key Field name. * * @throws InvalidArgumentException When the field is missing * or not an int. */ private static function requireInt( array $data, string $key ): int { if ( ! array_key_exists( $key, $data ) || ! is_int( $data[ $key ] ) ) { $message = sprintf( 'SyncSettings payload must carry an int `%s`.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $data[ $key ]; } /** * Read a required string field from a {@see self::fromArray()} * payload. * * @param array $data Persisted payload. * @param string $key Field name. * * @throws InvalidArgumentException When the field is missing * or not a string. */ private static function requireString( array $data, string $key ): string { if ( ! array_key_exists( $key, $data ) || ! is_string( $data[ $key ] ) ) { $message = sprintf( 'SyncSettings payload must carry a string `%s`.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $data[ $key ]; } }