• File: PushTokensDataStore.php
  • Full Path: /home/slvgwrnz/public_html/wp-content/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
  • Date Modified: 10/26/2026 10:38 AM
  • File size: 29.73 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php
/**
 * PushTokensDataStore class file.
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Exception;
use Throwable;
use WC_Data_Exception;
use WP_Http;
use WP_Query;

/**
 * Data store class for push tokens.
 *
 * @since 10.5.0
 */
class PushTokensDataStore {
	/**
	 * In-memory cache for get_tokens_for_roles() results, keyed by the
	 * comma-joined role list (with optional pagination suffix). Avoids
	 * repeated DB queries within the same PHP request.
	 *
	 * @var array<string, PushToken[]|array{tokens: PushToken[], total: int, total_pages: int}>
	 */
	private array $tokens_by_roles_cache = array();

	/**
	 * Memoized count_tokens() result for this request. The step log puts it on
	 * every notification's recipients line, and one request can carry thousands
	 * of notifications.
	 *
	 * @var int|null
	 */
	private ?int $token_count = null;

	/**
	 * Memoized has_tokens() result. Null until the first lookup, and reset by create() so a stale false cannot drop a notification.
	 *
	 * @var bool|null
	 */
	private ?bool $has_tokens = null;

	/**
	 * Buffered last-send stamps awaiting a write, as token post ID => GMT
	 * datetime. Holds the most recent time recorded for each token this
	 * request. Flushed by {@see self::flush_last_sent_at()} on shutdown.
	 *
	 * @var array<int, string>
	 */
	private array $pending_last_sent_at = array();

	/**
	 * Whether the shutdown flush for `$pending_last_sent_at` has been registered.
	 *
	 * @var bool
	 */
	private bool $last_sent_at_flush_registered = false;

	/**
	 * How many tokens to write per statement when flushing last-send stamps.
	 */
	const LAST_SENT_AT_CHUNK_SIZE = 100;

	/**
	 * Meta key holding the GMT datetime of the last successful send to WPCOM.
	 *
	 * Deliberately absent from `build_meta_array_from_token()`: it is written
	 * only by `record_last_sent_at()`, so an unrelated token update (e.g. the app
	 * re-registering with a new locale) cannot clobber it.
	 */
	const LAST_SENT_AT_META_KEY = 'last_sent_at_gmt';

	const SUPPORTED_META = array(
		'origin',
		'device_uuid',
		'token',
		'platform',
		'device_locale',
		'metadata',
		self::LAST_SENT_AT_META_KEY,
	);

	/**
	 * Creates a post representing the push token.
	 *
	 * @since 10.5.0
	 * @param array $data Token data with keys: user_id, token, platform, device_uuid (optional), origin.
	 * @throws PushTokenInvalidDataException If the token data is invalid.
	 * @throws WC_Data_Exception If the token creation fails.
	 * @return PushToken The created push token with ID set.
	 */
	public function create( array $data ): PushToken {
		$push_token = new PushToken( $data );

		if ( ! $push_token->can_be_created() ) {
			throw new PushTokenInvalidDataException(
				'Can\'t create push token because the push token data provided is invalid.'
			);
		}

		$id = wp_insert_post(
			array(
				'post_author' => (int) $push_token->get_user_id(),
				'post_type'   => PushToken::POST_TYPE,
				'post_status' => 'private',
				'meta_input'  => $this->build_meta_array_from_token( $push_token ),
			),
			true
		);

		if ( is_wp_error( $id ) ) {
			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new WC_Data_Exception(
				(string) $id->get_error_code(),
				$id->get_error_message(),
				WP_Http::INTERNAL_SERVER_ERROR
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}

		$push_token->set_id( $id );

		$this->has_tokens  = null;
		$this->token_count = null;

		return $push_token;
	}

	/**
	 * Gets post representing a push token.
	 *
	 * @since 10.5.0
	 * @param int $id The push token ID.
	 * @throws PushTokenInvalidDataException If the ID is invalid.
	 * @throws PushTokenNotFoundException If the token can't be found.
	 * @return PushToken The populated push token.
	 */
	public function read( int $id ): PushToken {
		$push_token = new PushToken( array( 'id' => $id ) );
		$post       = get_post( $push_token->get_id() );

		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
			throw new PushTokenNotFoundException();
		}

		$meta = $this->build_meta_array_from_database( (int) $push_token->get_id() );

		if (
			empty( $meta['token'] )
			|| empty( $meta['platform'] )
			|| empty( $meta['origin'] )
			|| (
				empty( $meta['device_uuid'] )
				&& PushToken::PLATFORM_BROWSER !== $meta['platform']
			)
		) {
			throw new PushTokenInvalidDataException(
				'Can\'t read push token because the push token record is malformed.'
			);
		}

		$push_token->set_user_id( (int) $post->post_author );
		$push_token->set_token( $meta['token'] );
		$push_token->set_device_uuid( $meta['device_uuid'] ?? null );
		$push_token->set_platform( $meta['platform'] );
		$push_token->set_origin( $meta['origin'] );

		/**
		 * These meta items were added after the ability to store tokens, so may
		 * not be available for older tokens. Use sensible defaults.
		 */
		$push_token->set_device_locale( $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE );
		$push_token->set_metadata( $meta['metadata'] ?? array() );
		$push_token->set_last_sent_at_gmt( $meta[ self::LAST_SENT_AT_META_KEY ] ?? null );

		/**
		 * Both timestamps come from the post record rather than meta, because
		 * WordPress already maintains them. See {@see PushToken::$last_confirmed_at_gmt}
		 * for what `post_modified_gmt` means for a push token.
		 */
		$push_token->set_created_at_gmt( $post->post_date_gmt );
		$push_token->set_last_confirmed_at_gmt( $post->post_modified_gmt );

		return $push_token;
	}

	/**
	 * Updates a post representing the push token.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token The push token to update.
	 * @throws PushTokenInvalidDataException If the token can't be updated.
	 * @throws WC_Data_Exception If the token update fails.
	 * @return bool True on success.
	 */
	public function update( PushToken $push_token ): bool {
		if ( ! $push_token->can_be_updated() ) {
			throw new PushTokenInvalidDataException(
				'Can\'t update push token because the push token data provided is invalid.'
			);
		}

		$result = wp_update_post(
			array(
				'ID'          => (int) $push_token->get_id(),
				'post_author' => (int) $push_token->get_user_id(),
				'post_type'   => PushToken::POST_TYPE,
				'post_status' => 'private',
				'meta_input'  => $this->build_meta_array_from_token( $push_token ),
			),
			true
		);

		if ( is_wp_error( $result ) ) {
			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new WC_Data_Exception(
				(string) $result->get_error_code(),
				$result->get_error_message(),
				WP_Http::INTERNAL_SERVER_ERROR
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}

		if ( null === $push_token->get_device_uuid() ) {
			delete_post_meta( (int) $push_token->get_id(), 'device_uuid' );
		}

		return true;
	}

	/**
	 * Deletes a push token.
	 *
	 * @since 10.5.0
	 * @param int $id The push token ID.
	 * @throws PushTokenNotFoundException If the token can't be found.
	 * @return bool True on success.
	 */
	public function delete( int $id ): bool {
		$post = get_post( $id );

		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
			throw new PushTokenNotFoundException();
		}

		return (bool) wp_delete_post( (int) $id, true );
	}

	/**
	 * Deletes every push token belonging to a user.
	 *
	 * A non-positive ID is refused rather than queried, so a caller that loses
	 * the user ID cannot match the rows of every author-less token at once.
	 *
	 * @since 11.2.0
	 * @param int $user_id The user whose tokens should be deleted.
	 * @return int The number of tokens deleted.
	 */
	public function delete_for_user( int $user_id ): int {
		if ( $user_id < 1 ) {
			return 0;
		}

		global $wpdb;

		// Direct query so pre_get_posts filters cannot hide a token, and any status is deleted.
		$post_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_author = %d",
				PushToken::POST_TYPE,
				$user_id
			)
		);

		if ( empty( $post_ids ) ) {
			return 0;
		}

		$deleted = 0;

		foreach ( $post_ids as $post_id ) {
			if ( wp_delete_post( (int) $post_id, true ) ) {
				++$deleted;
			}
		}

		// Anything read earlier in this request now includes deleted tokens.
		$this->tokens_by_roles_cache = array();
		$this->token_count           = null;

		return $deleted;
	}

	/**
	 * Find tokens for this user and platform that match either the token
	 * or device UUID. We check the token value to avoid creating a duplicate.
	 * We check the device UUID value because only one token should be issued
	 * per device, therefore if we already have one then we can update it to
	 * avoid creating a duplicate.
	 *
	 * @since 10.5.0
	 * @param array $data Token data with keys: user_id, platform, origin, token (optional), device_uuid (optional).
	 * @return null|PushToken
	 * @throws PushTokenInvalidDataException If push token is missing data.
	 */
	public function get_by_token_or_device_id( array $data ): ?PushToken {
		$user_id     = $data['user_id'] ?? null;
		$platform    = $data['platform'] ?? null;
		$origin      = $data['origin'] ?? null;
		$token       = $data['token'] ?? null;
		$device_uuid = $data['device_uuid'] ?? null;

		if (
			! $user_id
			|| ! $platform
			|| ! $origin
			|| (
				/**
				 * Platforms iOS and Android require token OR device UUID.
				 */
				PushToken::PLATFORM_BROWSER !== $platform
				&& ! $token
				&& ! $device_uuid
			)
			|| (
				/**
				 * Browsers don't have device UUIDs, so require token.
				 */
				PushToken::PLATFORM_BROWSER === $platform
				&& ! $token
			)
		) {
			throw new PushTokenInvalidDataException(
				'Can\'t retrieve push token because the push token data provided is invalid.'
			);
		}

		$query = new WP_Query(
			array(
				'post_type'      => PushToken::POST_TYPE,
				'post_status'    => 'private',
				'author'         => $user_id,
				'posts_per_page' => -1,
				'orderby'        => 'ID',
				'order'          => 'DESC',
				'fields'         => 'ids',
			)
		);

		/**
		 * Typehint for PHPStan, specifies these are IDs and not instances of
		 * WP_Post.
		 *
		 * @var int[] $post_ids
		 */
		$post_ids = $query->posts;

		if ( empty( $post_ids ) ) {
			return null;
		}

		update_meta_cache( 'post', $post_ids );

		foreach ( $post_ids as $post_id ) {
			try {
				$meta = $this->build_meta_array_from_database( $post_id );
			} catch ( Exception $e ) {
				wc_get_logger()->warning(
					'Failed to load meta for push token.',
					array(
						'source'   => PushNotifications::FEATURE_NAME,
						'token_id' => $post_id,
						'error'    => $e->getMessage(),
					)
				);

				continue;
			}

			if (
				$meta['platform'] === $platform
				&& $meta['origin'] === $origin
				&& (
					( $token && $token === $meta['token'] )
					|| ( $device_uuid && $device_uuid === $meta['device_uuid'] )
				)
			) {
				return new PushToken(
					array(
						'id'               => $post_id,
						'user_id'          => $user_id,
						'token'            => $meta['token'],
						'device_uuid'      => $meta['device_uuid'] ?? null,
						'platform'         => $meta['platform'],
						'origin'           => $meta['origin'],
						/**
						 * These meta items were added after the ability to store
						 * tokens, so may not be available for older tokens. Use
						 * sensible defaults.
						 */
						'device_locale'    => $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE,
						'metadata'         => $meta['metadata'] ?? array(),
						'last_sent_at_gmt' => $meta[ self::LAST_SENT_AT_META_KEY ] ?? null,
					)
				);
			}
		}

		return null;
	}

	/**
	 * Determines whether any push token exists, ignoring roles and preferences so callers can bail out before the cost of get_tokens_for_roles().
	 *
	 * @since 11.2.0
	 * @return bool True if at least one push token exists.
	 */
	public function has_tokens(): bool {
		if ( null !== $this->has_tokens ) {
			return $this->has_tokens;
		}

		global $wpdb;

		// Exactly this SQL to leverage the wp_posts type_status_author index.
		$this->has_tokens = (bool) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT 1 FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private' LIMIT 1",
				PushToken::POST_TYPE
			)
		);

		return $this->has_tokens;
	}

	/**
	 * Counts every push token on the store, whatever the owner's role.
	 *
	 * Registration requires one of the roles that receive push notifications,
	 * so no token starts out ineligible. A user can lose the role afterwards
	 * though, and their token stays, which is the difference between this count
	 * and what get_tokens_for_roles() returns.
	 *
	 * @since 11.3.0
	 * @return int
	 */
	public function count_tokens(): int {
		if ( null !== $this->token_count ) {
			return $this->token_count;
		}

		global $wpdb;

		$this->token_count = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private'",
				PushToken::POST_TYPE
			)
		);

		return $this->token_count;
	}

	/**
	 * Returns the IDs of the push tokens a user owns, most recently registered
	 * first.
	 *
	 * Capped, because the step log read tests every line against every ID
	 * returned. Re-registering a device updates its post, so ordering by
	 * modification time keeps the devices still in use and drops the ones
	 * abandoned longest ago.
	 *
	 * @since 11.3.0
	 * @param int $user_id The user.
	 * @param int $limit   The most IDs to return.
	 * @return int[]
	 */
	public function get_token_ids_for_user( int $user_id, int $limit = 100 ): array {
		global $wpdb;

		return array_map(
			'intval',
			$wpdb->get_col(
				$wpdb->prepare(
					"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private' AND post_author = %d ORDER BY post_modified DESC LIMIT %d",
					PushToken::POST_TYPE,
					$user_id,
					$limit
				)
			)
		);
	}

	/**
	 * Returns push tokens belonging to users with the given roles.
	 *
	 * When called without pagination parameters, returns all tokens as a
	 * flat array (cached per-request), most recently registered first. When
	 * $page and $per_page are provided, returns a paginated result with total
	 * counts, ordered by ID so a re-registration cannot move a token between
	 * pages.
	 *
	 * The eligible-user lookup is restricted to users that actually own
	 * push tokens, so the role check runs against a handful of IDs instead
	 * of scanning every user's capabilities meta, which does not scale on
	 * sites with very large user tables.
	 *
	 * @param string[] $roles    The roles to query tokens for.
	 * @param int|null $page     Optional page number (1-based).
	 * @param int|null $per_page Optional number of tokens per page.
	 * @param array    $filters  Optional exact-match filters: `user_id` (int) and `device_uuid` (string).
	 * @phpstan-param array{user_id?: int|null, device_uuid?: string|null} $filters
	 * @return PushToken[]|array{tokens: PushToken[], total: int, total_pages: int}
	 *
	 * @since 10.7.0
	 */
	public function get_tokens_for_roles( array $roles, ?int $page = null, ?int $per_page = null, array $filters = array() ) {
		$paginate    = null !== $page && null !== $per_page;
		$user_id     = empty( $filters['user_id'] ) ? null : (int) $filters['user_id'];
		$device_uuid = empty( $filters['device_uuid'] ) ? null : (string) $filters['device_uuid'];
		$cache_key   = implode( ',', $roles ) . ":$page:$per_page:$user_id:$device_uuid";

		$empty_result = $paginate
			? array(
				'tokens'      => array(),
				'total'       => 0,
				'total_pages' => 0,
			)
			: array();

		if ( empty( $roles ) ) {
			return $empty_result;
		}

		if ( isset( $this->tokens_by_roles_cache[ $cache_key ] ) ) {
			return $this->tokens_by_roles_cache[ $cache_key ];
		}

		global $wpdb;

		// Exactly this SQL to leverage the wp_posts type_status_author index; low token cardinality keeps it fast at any store size.
		$sql  = "SELECT DISTINCT post_author FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private'";
		$args = array( PushToken::POST_TYPE );

		if ( null !== $user_id ) {
			$sql   .= ' AND post_author = %d';
			$args[] = $user_id;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built from literals above and every value goes through a placeholder.
		$users_with_tokens = $wpdb->get_col( $wpdb->prepare( $sql, ...$args ) );

		// An empty include must short-circuit: WP_User_Query would ignore it and scan all users by role.
		$user_ids = empty( $users_with_tokens ) ? array() : get_users(
			array(
				'role__in' => $roles,
				'fields'   => 'ID',
				'include'  => $users_with_tokens,
			)
		);

		if ( empty( $user_ids ) ) {
			$this->tokens_by_roles_cache[ $cache_key ] = $empty_result;
			return $this->tokens_by_roles_cache[ $cache_key ];
		}

		$query_args = array(
			'post_type'      => PushToken::POST_TYPE,
			'post_status'    => 'private',
			'author__in'     => $user_ids,
			'posts_per_page' => $paginate ? $per_page : -1,
			'fields'         => 'ids',
		);

		if ( $paginate ) {
			$query_args['paged']   = $page;
			$query_args['orderby'] = 'ID';
			$query_args['order']   = 'ASC';
		}

		if ( null !== $device_uuid ) {
			// Bounded by author__in, so the meta join only sees this store's own tokens.
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			$query_args['meta_query'] = array(
				array(
					'key'   => 'device_uuid',
					'value' => $device_uuid,
				),
			);
		}

		$query = new WP_Query( $query_args );

		/**
		 * Typehint for PHPStan, specifies these are IDs and not instances of
		 * WP_Post.
		 *
		 * @var int[] $post_ids
		 */
		$post_ids = $query->posts;

		if ( empty( $post_ids ) ) {
			$result = $empty_result;

			// WP_Query skips counting when a page comes back empty, so a page past the end would otherwise report no matches.
			if ( $paginate && $page > 1 ) {
				$count_query = new WP_Query(
					array_merge(
						$query_args,
						array(
							'paged'          => 1,
							'posts_per_page' => 1,
						)
					)
				);

				$result['total']       = (int) $count_query->found_posts;
				$result['total_pages'] = (int) ceil( $result['total'] / max( 1, $per_page ) );
			}

			$this->tokens_by_roles_cache[ $cache_key ] = $result;
			return $result;
		}

		_prime_post_caches( $post_ids, false, true );

		$tokens = array();

		foreach ( $post_ids as $post_id ) {
			try {
				$tokens[] = $this->read( (int) $post_id );
			} catch ( WC_Data_Exception $e ) {
				wc_get_logger()->warning(
					'Skipping malformed push token during role-based query.',
					array(
						'source'   => PushNotifications::FEATURE_NAME,
						'token_id' => $post_id,
						'error'    => $e->getMessage(),
					)
				);
			}
		}

		if ( ! $paginate ) {
			/**
			 * Sorted on the GMT date because WP_Query can only order by the local
			 * `post_modified`, which runs backwards across a daylight saving change.
			 */
			usort(
				$tokens,
				fn ( PushToken $a, PushToken $b ) => array( $b->get_last_confirmed_at_gmt(), $b->get_id() ) <=> array( $a->get_last_confirmed_at_gmt(), $a->get_id() )
			);
		}

		$result = $paginate
			? array(
				'tokens'      => $tokens,
				'total'       => (int) $query->found_posts,
				'total_pages' => (int) $query->max_num_pages,
			)
			: $tokens;

		$this->tokens_by_roles_cache[ $cache_key ] = $result;
		return $result;
	}

	/**
	 * Records that the given tokens were successfully sent to WPCOM.
	 *
	 * Buffers the stamps and writes them once on shutdown rather than per call.
	 * A single request often processes several notifications — the loopback
	 * receives every notification a store event produced, and a bulk order
	 * update can produce dozens — and each one would otherwise repeat the same
	 * write against the same handful of tokens. Buffering makes the cost a
	 * function of the request rather than of the notification count, while each
	 * token still keeps the exact time of its own most recent send.
	 *
	 * @since 11.2.0
	 *
	 * @param PushToken[] $push_tokens The tokens WPCOM accepted.
	 * @return void
	 */
	public function record_last_sent_at( array $push_tokens ): void {
		$timestamp = gmdate( 'Y-m-d H:i:s' );

		foreach ( $push_tokens as $push_token ) {
			$id = $push_token->get_id();

			if ( $id ) {
				$this->pending_last_sent_at[ $id ] = $timestamp;
			}
		}

		if ( empty( $this->pending_last_sent_at ) || $this->last_sent_at_flush_registered ) {
			return;
		}

		add_action( 'shutdown', array( $this, 'flush_last_sent_at' ) );

		// The safety net and retry jobs run under an Action Scheduler queue
		// runner, which is routinely killed on a time limit. Shutdown functions
		// do not run on a kill, so flush after each action as well.
		add_action( 'action_scheduler_after_execute', array( $this, 'flush_last_sent_at' ) );

		$this->last_sent_at_flush_registered = true;
	}

	/**
	 * Writes the buffered last-send stamps.
	 *
	 * Runs on shutdown and after each Action Scheduler action, and is safe to
	 * call directly to force the write early.
	 *
	 * Failure is swallowed: not knowing when a token was last used is a
	 * diagnostic gap, and must never turn a delivered notification into a
	 * failed one.
	 *
	 * @since 11.2.0
	 *
	 * @return void
	 */
	public function flush_last_sent_at(): void {
		// Lets a later `record_last_sent_at()` re-assert the Action Scheduler
		// hook, which fires once per action. It cannot re-arm `shutdown`:
		// WP_Hook iterates a copy of its callbacks, so a stamp recorded during
		// shutdown after this ran is dropped. No path does that today.
		$this->last_sent_at_flush_registered = false;

		if ( empty( $this->pending_last_sent_at ) ) {
			return;
		}

		$pending                    = $this->pending_last_sent_at;
		$this->pending_last_sent_at = array();

		// Chunked so neither the `IN` list nor the number of placeholders handed
		// to `$wpdb->prepare()` grows with the number of registered devices. A
		// large `IN` list can also push the optimizer off the `post_id` index.
		foreach ( array_chunk( $pending, self::LAST_SENT_AT_CHUNK_SIZE, true ) as $chunk ) {
			try {
				$this->write_last_sent_at_chunk( $chunk );
			} catch ( Throwable $e ) {
				// Throwable, not Exception. `action_scheduler_after_execute`
				// fires between the action running and `mark_complete()`, inside
				// the runner's own Throwable catch, so an Error escaping here
				// would record a delivered notification's action as failed.
				$this->warn_last_sent_at_not_recorded( array_keys( $chunk ), $e->getMessage() );
			}
		}
	}

	/**
	 * Writes one chunk of buffered last-send stamps.
	 *
	 * Existing rows are updated in place rather than deleted and reinserted.
	 * This is the busiest write path in the feature, and a delete/insert cycle
	 * on the same rows consumes `meta_id` values permanently and fragments the
	 * primary key, for no benefit — `wp_postmeta` has no unique key on
	 * `(post_id, meta_key)`, so the rows to update have to be identified first
	 * either way.
	 *
	 * @param array<int, string> $chunk Map of token post ID to GMT datetime.
	 * @return void
	 */
	private function write_last_sent_at_chunk( array $chunk ): void {
		global $wpdb;

		$post_ids = array_keys( $chunk );

		/**
		 * The statements below interpolate a placeholder list whose length
		 * depends on the number of tokens. The interpolated strings are built
		 * from literals only — never from token data — and every value still
		 * travels through `$wpdb->prepare()`, which is why the sniffs are
		 * suppressed rather than the queries being restructured.
		 */
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$select = $wpdb->prepare(
			sprintf(
				"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %%s AND post_id IN ( %s )",
				implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) )
			),
			array_merge( array( self::LAST_SENT_AT_META_KEY ), $post_ids )
		);

		// Checked before the read, not after: a null here would leave
		// `last_result` holding whatever ran previously.
		if ( ! is_string( $select ) || '' === $select ) {
			$this->warn_last_sent_at_not_recorded( $post_ids, 'Could not build the query to read existing stamps, skipping chunk.' );

			return;
		}

		// Run the statement rather than using `get_col()`, whose empty array
		// means both "nothing matched" and "the read failed". Taking a failed
		// read as "no rows exist" would insert duplicates that `wp_postmeta`
		// has no unique key to prevent. `last_error` alone cannot be trusted
		// either: `wpdb::query()` returns false before it clears the previous
		// error when the `query` filter empties the statement.
		$rows = $wpdb->query( $select );

		if ( false === $rows ) {
			$this->warn_last_sent_at_not_recorded(
				$post_ids,
				sprintf(
					'Could not read existing stamps, skipping chunk. %s',
					'' !== $wpdb->last_error ? $wpdb->last_error : 'The query did not run.'
				)
			);

			return;
		}

		$existing = array_map( 'intval', wp_list_pluck( (array) $wpdb->last_result, 'post_id' ) );

		// Tokens sent at the same moment share an UPDATE, so this is usually
		// one query, without giving a token another's send time when a request
		// spans a second boundary.
		$by_timestamp = array();

		foreach ( $chunk as $post_id => $timestamp ) {
			$by_timestamp[ $timestamp ][] = $post_id;
		}

		foreach ( $by_timestamp as $timestamp => $ids ) {
			$update_ids = array_values( array_intersect( $ids, $existing ) );
			$insert_ids = array_values( array_diff( $ids, $existing ) );

			if ( ! empty( $update_ids ) ) {
				// Advance-only. Each request captures its timestamp when it
				// dispatches and writes it on shutdown, so a slow request can
				// reach this after a later one has already written a newer
				// value. `Y-m-d H:i:s` compares lexicographically in date
				// order, and a zero-row match returns 0 rather than false.
				$update = $wpdb->prepare(
					sprintf(
						"UPDATE {$wpdb->postmeta} SET meta_value = %%s WHERE meta_key = %%s AND meta_value < %%s AND post_id IN ( %s )",
						implode( ', ', array_fill( 0, count( $update_ids ), '%d' ) )
					),
					array_merge( array( $timestamp, self::LAST_SENT_AT_META_KEY, $timestamp ), $update_ids )
				);

				if ( ! is_string( $update ) || '' === $update ) {
					$this->warn_last_sent_at_not_recorded( $update_ids, 'Could not build the update statement.' );
				} elseif ( false === $wpdb->query( $update ) ) {
					$this->warn_last_sent_at_not_recorded( $update_ids, $wpdb->last_error );
				}
			}

			if ( ! empty( $insert_ids ) ) {
				$insert_args = array();

				foreach ( $insert_ids as $post_id ) {
					$insert_args[] = $post_id;
					$insert_args[] = self::LAST_SENT_AT_META_KEY;
					$insert_args[] = $timestamp;
				}

				$insert = $wpdb->prepare(
					sprintf(
						"INSERT INTO {$wpdb->postmeta} ( post_id, meta_key, meta_value ) VALUES %s",
						implode( ', ', array_fill( 0, count( $insert_ids ), '( %d, %s, %s )' ) )
					),
					$insert_args
				);

				if ( ! is_string( $insert ) || '' === $insert ) {
					$this->warn_last_sent_at_not_recorded( $insert_ids, 'Could not build the insert statement.' );
				} elseif ( false === $wpdb->query( $insert ) ) {
					$this->warn_last_sent_at_not_recorded( $insert_ids, $wpdb->last_error );
				}
			}
		}
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		// The rows were written behind the meta API's back, so the cached meta
		// for these posts is now stale and must be dropped.
		wp_cache_delete_multiple( $post_ids, 'post_meta' );
	}

	/**
	 * Logs that last-send stamps could not be recorded.
	 *
	 * @param int[]  $post_ids The token post IDs affected.
	 * @param string $error    What went wrong, and what was skipped as a result.
	 * @return void
	 */
	private function warn_last_sent_at_not_recorded( array $post_ids, string $error ): void {
		wc_get_logger()->warning(
			'Could not record last sent time for push tokens.',
			array(
				'source'      => PushNotifications::FEATURE_NAME,
				'token_count' => count( $post_ids ),
				'error'       => $error,
			)
		);
	}

	/**
	 * Returns an associative array of post meta as key => value pairs for the
	 * keys defined in SUPPORTED_META; missing keys return null. Use
	 * `update_meta_cache` with `get_post_meta` to allow reading the meta as
	 * single values which automatically unserialize when requires,
	 * rather than nested arrays that don't.
	 *
	 * @since 10.5.0
	 * @param int $id The push token ID.
	 * @return array
	 */
	private function build_meta_array_from_database( int $id ): array {
		$meta_by_key = array_fill_keys( static::SUPPORTED_META, null );

		foreach ( static::SUPPORTED_META as $key ) {
			$meta = get_post_meta( $id, $key, true );

			if ( '' !== $meta ) {
				$meta_by_key[ $key ] = $meta;
			}
		}

		return $meta_by_key;
	}

	/**
	 * Returns an associative array of post meta as key => value pairs, built
	 * using push token properties.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token An instance of PushToken.
	 * @return array
	 */
	private function build_meta_array_from_token( PushToken $push_token ) {
		return array_filter(
			array(
				'platform'      => $push_token->get_platform(),
				'token'         => $push_token->get_token(),
				'device_uuid'   => $push_token->get_device_uuid(),
				'origin'        => $push_token->get_origin(),
				'device_locale' => $push_token->get_device_locale(),
				'metadata'      => $push_token->get_metadata(),
			),
			fn ( $value ) => null !== $value && '' !== $value
		);
	}
}