:"), not autoloaded. */ public const RUN_TOKEN = Flavor::OPTION_PREFIX . 'run_token'; /** * Option: the id of a key that has been minted but whose connection is not saved yet. */ const PENDING_KEY = Flavor::OPTION_PREFIX . 'pending_key_id'; /** * Option: a number that is part of every cached enriched-list key; bumping it throws all cached lists away at once. */ const ENRICHED_GENERATION = Flavor::OPTION_PREFIX . 'enriched_generation'; /** * Transient (not an option): the status panel's numbers, kept for StatsApi::TTL seconds. */ const STATS_TRANSIENT = Flavor::OPTION_PREFIX . 'stats'; /** * Transient: the product push's run lock (ProductSyncLock), 60 seconds, refreshed after every push. */ public const PRODUCT_SYNC_LOCK = Flavor::OPTION_PREFIX . 'product_sync_lock'; /** * Transient: set for StatsApi::FAILURE_TTL seconds after a failed stats call, so the page stops waiting on a backend that is down. */ const STATS_FAILED_TRANSIENT = Flavor::OPTION_PREFIX . 'stats_failed'; /** * Transient: set for EnrichedApi::FAILURE_TTL seconds after a failed enriched-list call. */ const ENRICHED_FAILED_TRANSIENT = Flavor::OPTION_PREFIX . 'enriched_failed'; /** * Transient: the same for a list asked with a field filter, which is slow, so its failure never blanks the plain list. */ const ENRICHED_FIELD_FAILED_TRANSIENT = Flavor::OPTION_PREFIX . 'enriched_field_failed'; /** * Prefix of the three lane options (sync_lane_0..2), one per running product action. */ const SYNC_LANE = Flavor::OPTION_PREFIX . 'sync_lane_'; const ALL = array( self::CONNECTION, self::SECRET, self::SETTINGS, self::WEBHOOK_IDS, self::LAST_ERROR, self::CONNECT_LOCK, self::SYNC_CURSOR, self::SYNC_DELETED, self::VERSION, self::SYNC_LOCK, self::SYNC_STATE, self::SYNC_QUEUE, self::SYNC_SIGNED, self::ENRICHED_GENERATION, self::PENDING_KEY, self::SYNC_LANE . '0', self::SYNC_LANE . '1', self::SYNC_LANE . '2', self::RUN_TOKEN ); const INJECTION_KEYS = array( 'json_ld', 'og_title', 'og_description', 'og_image', 'canonical' ); const LOCK_STALE_AFTER = 60; const PIXEL_ID_PATTERN = '/^[A-Za-z0-9]{12}$/'; /** * Reads the stored option each call, so a second call can see a connection made in between. * * @phpstan-impure */ public static function is_connected(): bool { return '' !== self::tenant_id(); } public static function tenant_id(): string { $connection = self::connection(); return isset( $connection['tenant_id'] ) && is_string( $connection['tenant_id'] ) ? $connection['tenant_id'] : ''; } public static function key_id(): int { $connection = self::connection(); return isset( $connection['key_id'] ) ? (int) $connection['key_id'] : 0; } public static function connected_at(): int { $connection = self::connection(); return isset( $connection['connected_at'] ) ? (int) $connection['connected_at'] : 0; } public static function save_connection( string $tenant_id, int $key_id, string $webhook_secret, string $pixel_id = '' ): void { update_option( self::CONNECTION, array( 'tenant_id' => $tenant_id, 'key_id' => $key_id, 'connected_at' => time(), 'pixel_id' => $pixel_id, ), true ); update_option( self::SECRET, $webhook_secret, false ); } public static function pending_key_id(): int { return (int) get_option( self::PENDING_KEY, 0 ); } public static function save_pending_key_id( int $key_id ): void { update_option( self::PENDING_KEY, $key_id, false ); } public static function clear_pending_key_id(): void { delete_option( self::PENDING_KEY ); } /** * The store's public pixel id from the backend, or '' when it has none (or a malformed one). */ public static function pixel_id(): string { $connection = self::connection(); $pixel_id = isset( $connection['pixel_id'] ) && is_string( $connection['pixel_id'] ) ? $connection['pixel_id'] : ''; return 1 === preg_match( self::PIXEL_ID_PATTERN, $pixel_id ) ? $pixel_id : ''; } /** * Replaces the pixel id (the backend rotates it through PUT /cortex/v1/config). No-op when not connected. */ public static function set_pixel_id( string $pixel_id ): void { $connection = self::connection(); if ( array() === $connection ) { return; } $connection['pixel_id'] = $pixel_id; update_option( self::CONNECTION, $connection, true ); } /** * Forgets the connection and its sync progress, so the next connection starts with a full sync. Settings * stay; a new connection resets them. */ public static function clear_connection(): void { delete_option( self::CONNECTION ); delete_option( self::SECRET ); delete_option( self::WEBHOOK_IDS ); delete_option( self::SYNC_CURSOR ); delete_option( self::SYNC_DELETED ); delete_option( self::SYNC_SIGNED ); delete_option( self::RUN_TOKEN ); delete_transient( self::STATS_TRANSIENT ); delete_transient( self::PRODUCT_SYNC_LOCK ); delete_transient( self::STATS_FAILED_TRANSIENT ); delete_transient( self::ENRICHED_FAILED_TRANSIENT ); delete_transient( self::ENRICHED_FIELD_FAILED_TRANSIENT ); // Lists cached for the old connection must not be shown for the next one. update_option( self::ENRICHED_GENERATION, (int) get_option( self::ENRICHED_GENERATION, 0 ) + 1, false ); } public static function webhook_secret(): string { $secret = get_option( self::SECRET, '' ); return is_string( $secret ) ? $secret : ''; } /** * @return array{pixel_enabled: bool, injection: array} */ public static function settings(): array { $raw = get_option( self::SETTINGS, array() ); $raw = is_array( $raw ) ? $raw : array(); $injection = array(); foreach ( self::INJECTION_KEYS as $key ) { $injection[ $key ] = isset( $raw['injection'][ $key ] ) && true === $raw['injection'][ $key ]; } return array( 'pixel_enabled' => isset( $raw['pixel_enabled'] ) && true === $raw['pixel_enabled'], 'injection' => $injection, ); } /** * @param array $patch Validated partial: pixel_enabled (bool) and/or injection (flag => bool). * @return array{pixel_enabled: bool, injection: array} */ public static function update_settings( array $patch ): array { $settings = self::settings(); if ( array_key_exists( 'pixel_enabled', $patch ) ) { $settings['pixel_enabled'] = (bool) $patch['pixel_enabled']; } if ( isset( $patch['injection'] ) && is_array( $patch['injection'] ) ) { foreach ( $patch['injection'] as $key => $on ) { if ( in_array( $key, self::INJECTION_KEYS, true ) ) { $settings['injection'][ $key ] = (bool) $on; } } } update_option( self::SETTINGS, $settings, true ); return $settings; } /** * Pixel on; injection off until the Visibility scanner says otherwise (fail closed). */ public static function reset_settings_for_new_connection(): void { update_option( self::SETTINGS, array( 'pixel_enabled' => true, 'injection' => array_fill_keys( self::INJECTION_KEYS, false ), ), true ); } /** * @return array Topic => webhook id. */ public static function webhook_ids(): array { $ids = get_option( self::WEBHOOK_IDS, array() ); return is_array( $ids ) ? array_map( 'intval', $ids ) : array(); } /** * @param array $ids Topic => webhook id. */ public static function save_webhook_ids( array $ids ): void { update_option( self::WEBHOOK_IDS, $ids, false ); } /** * The last product the backend accepted, or null until the first sync request is accepted. * * @return array{modified: string, id: int}|null */ public static function sync_cursor(): ?array { $cursor = get_option( self::SYNC_CURSOR, null ); if ( ! is_array( $cursor ) || ! isset( $cursor['modified'], $cursor['id'] ) || ! is_string( $cursor['modified'] ) ) { return null; } return array( 'modified' => $cursor['modified'], 'id' => (int) $cursor['id'], ); } /** * @param string $modified The product's post_modified_gmt. * @param int $id The product id. */ public static function save_sync_cursor( string $modified, int $id ): void { update_option( self::SYNC_CURSOR, array( 'modified' => $modified, 'id' => $id, ), false ); } /** * Ids of products deleted for good that the backend has not accepted yet. * * @return array */ public static function deleted_product_ids(): array { $ids = get_option( self::SYNC_DELETED, array() ); return is_array( $ids ) ? array_values( array_map( 'intval', $ids ) ) : array(); } public static function add_deleted_product_id( int $id ): void { $ids = self::deleted_product_ids(); if ( ! in_array( $id, $ids, true ) ) { $ids[] = $id; update_option( self::SYNC_DELETED, $ids, false ); } } /** * @param array $sent Ids the backend accepted. */ public static function forget_deleted_product_ids( array $sent ): void { if ( array() !== $sent ) { update_option( self::SYNC_DELETED, array_values( array_diff( self::deleted_product_ids(), $sent ) ), false ); } } /** * Stores the token the next chained run must present, replacing any earlier one. * * @param string $token The token (no ':'). * @param int $expires_at Unix time after which it is refused. */ public static function save_run_token( string $token, int $expires_at ): void { update_option( self::RUN_TOKEN, $token . ':' . $expires_at, false ); } /** * Whether $presented is the stored, unexpired run token. A match removes the row in one compare-and-delete, so * of two requests presenting it at once exactly one wins. An expired row is removed too. */ public static function consume_run_token( string $presented ): bool { $raw = self::raw( self::RUN_TOKEN ); if ( null === $raw || '' === $presented ) { return false; } $separator = strrpos( $raw, ':' ); if ( false === $separator ) { return false; } $stored = substr( $raw, 0, $separator ); $expires_at = (int) substr( $raw, $separator + 1 ); if ( time() > $expires_at ) { self::compare_and_delete( self::RUN_TOKEN, $raw ); return false; } if ( '' === $stored || ! hash_equals( $stored, $presented ) ) { return false; } return self::compare_and_delete( self::RUN_TOKEN, $raw ); } /** * @return array{code: string, message: string}|null */ public static function last_error(): ?array { $error = get_option( self::LAST_ERROR, null ); if ( ! is_array( $error ) || ! isset( $error['code'], $error['message'] ) ) { return null; } return array( 'code' => (string) $error['code'], 'message' => (string) $error['message'], ); } public static function set_last_error( string $code, string $message ): void { update_option( self::LAST_ERROR, array( 'code' => $code, 'message' => $message, ), false ); } public static function clear_last_error(): void { delete_option( self::LAST_ERROR ); } /** * A mutex so a double-click or a second tab cannot mint two keys. * * add_option() first SELECTs and then INSERTs/UPDATEs, so two concurrent requests can both "win" it; the * same is true of a get-then-update stale takeover. This instead follows WP_Upgrader::create_lock(): the * claim is a single atomic `INSERT IGNORE`, which the database guarantees only one concurrent request can * turn into a new row, and a stale lock is only taken over via a `DELETE … WHERE option_value = $held`, * which only removes the row when it still holds the exact value we read — a racer whose DELETE affects 0 * rows has lost the takeover and must not retry the insert. */ public static function acquire_connect_lock(): bool { $now = (string) time(); if ( self::claim( self::CONNECT_LOCK, $now ) ) { return true; } $held = self::raw( self::CONNECT_LOCK ); if ( null === $held || ( (int) $now - (int) $held ) <= self::LOCK_STALE_AFTER ) { return false; } if ( ! self::compare_and_delete( self::CONNECT_LOCK, $held ) ) { return false; // Someone else already took it over, released it, or renewed it under us. } return self::claim( self::CONNECT_LOCK, $now ); } public static function release_connect_lock(): void { delete_option( self::CONNECT_LOCK ); } /** * Atomically creates option $name holding $value, iff it does not exist yet. * * add_option() first SELECTs and then INSERTs, so two concurrent requests can both "win" it. This follows * WP_Upgrader::create_lock() instead: one `INSERT IGNORE`, which the database lets only one request turn into a * new row. Used for the connect lock and the sync lock. */ public static function claim( string $name, string $value ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- INSERT IGNORE is the only atomic way to claim the row; add_option()/update_option() would race. Cache is repaired below on success. $inserted = (bool) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $name, $value ) ); if ( $inserted ) { self::forget_cached( $name ); } return $inserted; } /** * The option's stored value straight from the database (never a cached copy), or null when there is none. */ public static function raw( string $name ): ?string { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- lock checks must see the row as it is now, not an object-cache copy. $value = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) ); return null === $value ? null : (string) $value; } /** * get_option() after dropping any cached copy: for options that other processes (Action Scheduler runners) * write while this request runs. The alloptions cache is dropped too, so an autoloaded option is not served * stale; the sync options are still written non-autoloaded, so reading them never loads the whole autoload set. * * @return mixed The stored value, or null when there is none. */ public static function fresh( string $name ) { self::forget_cached( $name ); return get_option( $name, null ); } /** * Replaces the value only while the row still holds $old: one atomic UPDATE, so of two racers exactly one wins. */ public static function compare_and_swap( string $name, string $old, string $replacement ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- atomic compare-and-swap; update_option() cannot condition on the current value. $updated = $wpdb->update( $wpdb->options, array( 'option_value' => $replacement ), array( 'option_name' => $name, 'option_value' => $old, ), array( '%s' ), array( '%s', '%s' ) ); self::forget_cached( $name ); return 1 === $updated; } /** * Deletes the row only while it still holds $value. */ public static function compare_and_delete( string $name, string $value ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- atomic compare-and-delete: only removes the row when it still holds the exact value we read. $deleted = $wpdb->delete( $wpdb->options, array( 'option_name' => $name, 'option_value' => $value, ), array( '%s', '%s' ) ); self::forget_cached( $name ); return 1 === $deleted; } /** * The row was read or written straight through $wpdb, so get_option()/delete_option() must forget their copy. */ private static function forget_cached( string $name ): void { wp_cache_delete( $name, 'options' ); // get_option() reads an autoloaded option from this bundle first. wp_cache_delete( 'alloptions', 'options' ); $notoptions = wp_cache_get( 'notoptions', 'options' ); if ( is_array( $notoptions ) && isset( $notoptions[ $name ] ) ) { unset( $notoptions[ $name ] ); wp_cache_set( 'notoptions', $notoptions, 'options' ); } } /** * @return array */ private static function connection(): array { $connection = get_option( self::CONNECTION, array() ); return is_array( $connection ) ? $connection : array(); } }