assertNonEmpty( 'jobType', $jobType ); $this->assertNonEmpty( 'payloadJson', $payloadJson ); $this->assertNonEmpty( 'status', $status ); $this->id = $id; $this->jobType = $jobType; $this->payloadJson = $payloadJson; $this->status = $status; $this->priority = $priority; $this->attempts = $attempts; $this->maxAttempts = $maxAttempts; $this->availableAt = self::normalizeToUtc( $availableAt ) ?? $availableAt; $this->lockedAt = self::normalizeToUtc( $lockedAt ); $this->lockedBy = $lockedBy; $this->finishedAt = self::normalizeToUtc( $finishedAt ); $this->lastErrorCode = $lastErrorCode; $this->lastError = $lastError; $this->createdAt = self::normalizeToUtc( $createdAt ) ?? $createdAt; $this->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt; } /** * Ergonomic named constructor for fresh (unpersisted) records. * * Required columns are taken as positional arguments; everything * else (status, priority, retry budget, scheduling, lock state, * error summary) flows through an overrides array so callers that * just want "enqueue a pending job now" can stay terse. * * Defaults: * * - `status` defaults to {@see self::DEFAULT_STATUS} (`pending`). * - `priority` defaults to {@see self::DEFAULT_PRIORITY} (100). * - `attempts` defaults to 0. * - `maxAttempts` defaults to {@see self::DEFAULT_MAX_ATTEMPTS} (5). * - `availableAt` defaults to `$now` (job is immediately eligible). * - `lockedAt`, `lockedBy`, `finishedAt`, `lastErrorCode`, * `lastError` all default to null. * * @param string $jobType Job type discriminator. * @param string $payloadJson Serialized JSON payload. * @param DateTimeImmutable $now Current UTC timestamp. * @param array $optional Optional column overrides * keyed by property name. */ public static function createNew( string $jobType, string $payloadJson, DateTimeImmutable $now, array $optional = array() ): self { $status = self::DEFAULT_STATUS; if ( array_key_exists( 'status', $optional ) && $optional['status'] !== null ) { $status = (string) $optional['status']; } $priority = self::DEFAULT_PRIORITY; if ( array_key_exists( 'priority', $optional ) && $optional['priority'] !== null ) { $priority = (int) $optional['priority']; } $attempts = 0; if ( array_key_exists( 'attempts', $optional ) && $optional['attempts'] !== null ) { $attempts = (int) $optional['attempts']; } $maxAttempts = self::DEFAULT_MAX_ATTEMPTS; if ( array_key_exists( 'maxAttempts', $optional ) && $optional['maxAttempts'] !== null ) { $maxAttempts = (int) $optional['maxAttempts']; } $availableAt = $now; if ( array_key_exists( 'availableAt', $optional ) && $optional['availableAt'] instanceof DateTimeImmutable ) { $availableAt = $optional['availableAt']; } $lockedAt = null; if ( array_key_exists( 'lockedAt', $optional ) && $optional['lockedAt'] instanceof DateTimeImmutable ) { $lockedAt = $optional['lockedAt']; } $lockedBy = null; if ( array_key_exists( 'lockedBy', $optional ) && $optional['lockedBy'] !== null ) { $lockedBy = (string) $optional['lockedBy']; } $finishedAt = null; if ( array_key_exists( 'finishedAt', $optional ) && $optional['finishedAt'] instanceof DateTimeImmutable ) { $finishedAt = $optional['finishedAt']; } $lastErrorCode = null; if ( array_key_exists( 'lastErrorCode', $optional ) && $optional['lastErrorCode'] !== null ) { $lastErrorCode = (string) $optional['lastErrorCode']; } $lastError = null; if ( array_key_exists( 'lastError', $optional ) && $optional['lastError'] !== null ) { $lastError = (string) $optional['lastError']; } return new self( null, $jobType, $payloadJson, $status, $priority, $attempts, $maxAttempts, $availableAt, $lockedAt, $lockedBy, $finishedAt, $lastErrorCode, $lastError, $now, $now ); } /** * Reconstitute a record from a row-shaped associative array. * * Intended as the single decode seam used by * {@see WpdbSyncJobRepository} when mapping `$wpdb->get_row()` * results back into records. Unknown keys are ignored so callers * can pass raw result rows without having to filter them; missing * non-nullable keys raise {@see InvalidArgumentException}. * * DateTime columns accept either a `DateTimeImmutable` instance * (produced by in-memory repositories) or a `Y-m-d H:i:s` string * (produced by `$wpdb`). Invalid strings raise * {@see InvalidArgumentException}. * * @param array $row Row-shaped associative array. * * @throws InvalidArgumentException When a required key is missing * or a timestamp string cannot * be parsed. */ public static function fromArray( array $row ): self { $requireString = static function ( string $key ) use ( $row ): string { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { $message = sprintf( 'SyncJobRecord::fromArray() missing required key "%s".', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return (string) $row[ $key ]; }; $optionalString = static function ( string $key ) use ( $row ): ?string { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { return null; } return (string) $row[ $key ]; }; $requireInt = static function ( string $key ) use ( $row ): int { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { $message = sprintf( 'SyncJobRecord::fromArray() missing required integer "%s".', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return (int) $row[ $key ]; }; $requireDate = static function ( string $key ) use ( $row ): DateTimeImmutable { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { $message = sprintf( 'SyncJobRecord::fromArray() missing required timestamp "%s".', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return self::parseDateTime( $key, $row[ $key ] ); }; $optionalDate = static function ( string $key ) use ( $row ): ?DateTimeImmutable { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { return null; } return self::parseDateTime( $key, $row[ $key ] ); }; $idRaw = $row['id'] ?? null; $id = $idRaw === null ? null : (int) $idRaw; return new self( $id, $requireString( 'job_type' ), $requireString( 'payload_json' ), $requireString( 'status' ), $requireInt( 'priority' ), $requireInt( 'attempts' ), $requireInt( 'max_attempts' ), $requireDate( 'available_at' ), $optionalDate( 'locked_at' ), $optionalString( 'locked_by' ), $optionalDate( 'finished_at' ), $optionalString( 'last_error_code' ), $optionalString( 'last_error' ), $requireDate( 'created_at' ), $requireDate( 'updated_at' ) ); } /** * Encode the record as a row-shaped associative array keyed by the * canonical snake_case column names from §8.2. * * Used by {@see WpdbSyncJobRepository::save()} to produce the * payload handed to `$wpdb->insert()` / `$wpdb->update()`. * Timestamps are formatted via {@see self::formatDateTime()} so the * array is round-trippable through {@see self::fromArray()}. * * @return array */ public function toArray(): array { return array( 'id' => $this->id, 'job_type' => $this->jobType, 'payload_json' => $this->payloadJson, 'status' => $this->status, 'priority' => $this->priority, 'attempts' => $this->attempts, 'max_attempts' => $this->maxAttempts, 'available_at' => self::formatDateTime( $this->availableAt ), 'locked_at' => self::formatDateTime( $this->lockedAt ), 'locked_by' => $this->lockedBy, 'finished_at' => self::formatDateTime( $this->finishedAt ), 'last_error_code' => $this->lastErrorCode, 'last_error' => $this->lastError, 'created_at' => self::formatDateTime( $this->createdAt ), 'updated_at' => self::formatDateTime( $this->updatedAt ), ); } /** * Produce a copy of this record with the given primary-key id * assigned. Used by the repository's `save()` path immediately * after an insert to surface the database-assigned id to callers. * * @param int $id Newly-assigned primary key. Must be positive. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function withId( int $id ): self { if ( $id <= 0 ) { $message = sprintf( 'SyncJobRecord::withId() requires a positive integer, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $clone = clone $this; $clone->id = $id; return $clone; } /** * Produce a copy of this record with a new `updated_at` timestamp. * * Used by the repository's `save()` path on the update branch so * `updated_at` advances even when no other field changed. * * @param DateTimeImmutable $updatedAt New `updated_at` value; normalized to UTC. */ public function withUpdatedAt( DateTimeImmutable $updatedAt ): self { $clone = clone $this; $clone->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt; return $clone; } /** * Produce a copy of this record transitioned to the §8.5 claimed * (`running`) state for the supplied worker. * * Sets `status='running'`, `lockedAt=$now`, `lockedBy=$workerId`, * and `updatedAt=$now`. The `attempts` counter is intentionally * NOT incremented here — the handler increments it on the * terminal transition (success / failure) so that a worker crash * mid-flight does not burn an attempt the handler never observed. * * Used by repository implementations of * {@see SyncJobRepository::claimNext()} as the canonical * post-claim record shape so both the in-memory and Wpdb-backed * implementations stay behaviorally aligned. * * @param string $workerId Lock-holder identifier; must * be non-empty and at most 64 * bytes (matching the * `locked_by` column width). * @param DateTimeImmutable $now Current UTC instant; stamped * onto `lockedAt` and * `updatedAt` (normalized to * UTC). * * @throws InvalidArgumentException When `$workerId` is empty or * exceeds 64 bytes. */ public function withClaim( string $workerId, DateTimeImmutable $now ): self { $length = strlen( $workerId ); if ( $length === 0 ) { $message = 'SyncJobRecord::withClaim() requires a non-empty workerId.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $length > 64 ) { $message = sprintf( 'SyncJobRecord::withClaim() requires workerId of at most 64 bytes, got %d.', $length ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalizedNow = self::normalizeToUtc( $now ) ?? $now; $clone = clone $this; $clone->status = self::STATUS_RUNNING; $clone->lockedAt = $normalizedNow; $clone->lockedBy = $workerId; $clone->updatedAt = $normalizedNow; return $clone; } /** * Auto-increment primary key, or null for unpersisted records. */ public function id(): ?int { return $this->id; } /** * Report whether this record has been assigned a primary key. */ public function isPersisted(): bool { return $this->id !== null; } /** * Job type discriminator per §8.2 / §8.4. */ public function jobType(): string { return $this->jobType; } /** * Serialized JSON payload per §8.2. */ public function payloadJson(): string { return $this->payloadJson; } /** * Status discriminator per §8.2. */ public function status(): string { return $this->status; } /** * Priority per §8.2 (smaller = higher priority). */ public function priority(): int { return $this->priority; } /** * Executed attempt count per §8.2. */ public function attempts(): int { return $this->attempts; } /** * Retry budget per §8.2. */ public function maxAttempts(): int { return $this->maxAttempts; } /** * Earliest execution time per §8.2 / §8.5. */ public function availableAt(): DateTimeImmutable { return $this->availableAt; } /** * Lock-acquisition timestamp per §8.2, or null when not held. */ public function lockedAt(): ?DateTimeImmutable { return $this->lockedAt; } /** * Lock-holder worker identifier per §8.2, or null when not held. */ public function lockedBy(): ?string { return $this->lockedBy; } /** * Completion timestamp per §8.2, or null while in-flight. */ public function finishedAt(): ?DateTimeImmutable { return $this->finishedAt; } /** * Most recent failure code per §8.2, or null. */ public function lastErrorCode(): ?string { return $this->lastErrorCode; } /** * Most recent failure message per §8.2, or null. */ public function lastError(): ?string { return $this->lastError; } /** * UTC `created_at` timestamp for this record. */ public function createdAt(): DateTimeImmutable { return $this->createdAt; } /** * UTC `updated_at` timestamp for this record. */ public function updatedAt(): DateTimeImmutable { return $this->updatedAt; } /** * Format a {@see DateTimeInterface} as a canonical MySQL `DATETIME` * string in UTC, or return null when the input is null. * * Exposed as a public static helper so the repository implementations * and tests can round-trip timestamps through the same path. * * @param DateTimeInterface|null $value Timestamp to format. */ public static function formatDateTime( ?DateTimeInterface $value ): ?string { if ( $value === null ) { return null; } $utc = $value instanceof DateTimeImmutable ? $value->setTimezone( new DateTimeZone( 'UTC' ) ) : DateTimeImmutable::createFromInterface( $value )->setTimezone( new DateTimeZone( 'UTC' ) ); return $utc->format( self::MYSQL_DATETIME_FORMAT ); } /** * Parse a MySQL `DATETIME` string (or an already-constructed * {@see DateTimeImmutable}) into a UTC `DateTimeImmutable`. * * @param string $key Column name, used only in the * error message to surface which * timestamp failed to parse. * @param mixed $value Value to parse. * * @throws InvalidArgumentException When `$value` is not a * {@see DateTimeImmutable} or a * parseable MySQL `DATETIME` * string. */ private static function parseDateTime( string $key, $value ): DateTimeImmutable { if ( $value instanceof DateTimeImmutable ) { return self::normalizeToUtc( $value ) ?? $value; } if ( is_string( $value ) && $value !== '' ) { $parsed = DateTimeImmutable::createFromFormat( self::MYSQL_DATETIME_FORMAT, $value, new DateTimeZone( 'UTC' ) ); if ( $parsed instanceof DateTimeImmutable ) { return $parsed; } try { return new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) ); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Fall through to the descriptive throw below. } catch ( \Exception $unused ) { // Fall through to the descriptive throw below. unset( $unused ); } } $message = sprintf( 'SyncJobRecord::fromArray() could not parse "%s" as a MySQL DATETIME.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } /** * Normalize a nullable timestamp to UTC so equality checks and * repository round-trips do not depend on the caller's timezone. * * @param DateTimeImmutable|null $value Value to normalize. */ private static function normalizeToUtc( ?DateTimeImmutable $value ): ?DateTimeImmutable { if ( $value === null ) { return null; } return $value->setTimezone( new DateTimeZone( 'UTC' ) ); } /** * Guard against an empty string for a column that the schema * declares as `NOT NULL`. * * @param string $field Column / property name for the error message. * @param string $value Value to validate. * * @throws InvalidArgumentException When `$value` is the empty string. */ private function assertNonEmpty( string $field, string $value ): void { if ( $value === '' ) { $message = sprintf( 'SyncJobRecord requires a non-empty value for "%s".', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } }