Changeset 3795175
- Timestamp:
- 11/10/2026 08:10:12 PM (25 hours ago)
- Location:
- statora-plus
- Files:
-
- 4 edited
- 1 copied
-
tags/0.11.47 (copied) (copied from statora-plus/trunk )
-
tags/0.11.47/readme.txt (modified) (2 diffs)
-
tags/0.11.47/statora-plus.php (modified) (48 diffs)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/statora-plus.php (modified) (48 diffs)
Legend:
- Unmodified
- Added
- Removed
-
statora-plus/tags/0.11.47/readme.txt
r3790156 r3795175 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 0.11.4 67 Stable tag: 0.11.47 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 82 82 == Changelog == 83 83 84 = 0.11.47 = 85 * Harden historical product-view source queries and validate adapter schema/table selections. 86 * Use prepared integer limits and WordPress timezone-aware retry timestamps. 87 * Use WordPress URL parsing for Statify source URLs. 88 84 89 = 0.11.46 = 85 90 * Place narrowly scoped Plugin Check security-rule annotations on the database-call lines that use constructor-generated table identifiers. -
statora-plus/tags/0.11.47/statora-plus.php
r3788367 r3795175 3 3 * Plugin Name: Statora Plus 4 4 * Description: View key WooCommerce product statistics in one clear dashboard. 5 * Version: 0.11.4 65 * Version: 0.11.47 6 6 * Author: Panagiotis Pavlidis 7 7 * Author URI: https://gr.linkedin.com/in/frozenfiregr … … 16 16 if (!defined('ABSPATH')) { exit; } 17 17 18 19 /** 20 * Audited wpdb boundary for Statora SQL. 21 * 22 * All runtime data values must already be passed through statpl_db_prepare() 23 * with placeholders. Table/column identifiers are not SQL values and must be 24 * generated from the WordPress prefix plus fixed plugin/provider suffixes, or 25 * validated against a strict allowlist before reaching this boundary. 26 * 27 * These wrappers keep direct wpdb access in one reviewable location. They do 28 * not add caching: importer transactions, locks, schema checks and live metric 29 * reads require their existing consistency and freshness semantics. 30 */ 31 if (!function_exists('statpl_db_prepare')) { 32 function statpl_db_prepare($query, ...$args) { 33 global $wpdb; 34 if (!is_object($wpdb) || !method_exists($wpdb, 'prepare') || !is_string($query)) { 35 return false; 36 } 37 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Centralized preparation boundary; callers provide fixed SQL structure, validated identifiers and runtime values as placeholders. 38 return call_user_func_array(array($wpdb, 'prepare'), array_merge(array($query), $args)); 39 } 40 } 41 42 if (!function_exists('statpl_db_query')) { 43 function statpl_db_query($query) { 44 global $wpdb; 45 if (!is_object($wpdb) || !method_exists($wpdb, 'query') || !is_string($query)) { 46 return false; 47 } 48 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized execution boundary; query strings are prepared by statpl_db_prepare() or consist solely of fixed DDL/SQL and validated identifiers. 49 return $wpdb->query($query); 50 } 51 } 52 53 if (!function_exists('statpl_db_get_var')) { 54 function statpl_db_get_var($query, ...$args) { 55 global $wpdb; 56 if (!is_object($wpdb) || !method_exists($wpdb, 'get_var') || !is_string($query)) { 57 return null; 58 } 59 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers. 60 return $wpdb->get_var($query, ...$args); 61 } 62 } 63 64 if (!function_exists('statpl_db_get_row')) { 65 function statpl_db_get_row($query, ...$args) { 66 global $wpdb; 67 if (!is_object($wpdb) || !method_exists($wpdb, 'get_row') || !is_string($query)) { 68 return null; 69 } 70 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers. 71 return $wpdb->get_row($query, ...$args); 72 } 73 } 74 75 if (!function_exists('statpl_db_get_results')) { 76 function statpl_db_get_results($query, ...$args) { 77 global $wpdb; 78 if (!is_object($wpdb) || !method_exists($wpdb, 'get_results') || !is_string($query)) { 79 return null; 80 } 81 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers. 82 return $wpdb->get_results($query, ...$args); 83 } 84 } 85 86 if (!function_exists('statpl_db_get_col')) { 87 function statpl_db_get_col($query, ...$args) { 88 global $wpdb; 89 if (!is_object($wpdb) || !method_exists($wpdb, 'get_col') || !is_string($query)) { 90 return null; 91 } 92 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers. 93 return $wpdb->get_col($query, ...$args); 94 } 95 } 96 97 98 if (!function_exists('statpl_db_update')) { 99 function statpl_db_update($table, $data, $where, $format = null, $where_format = null) { 100 global $wpdb; 101 if (!is_object($wpdb) || !method_exists($wpdb, 'update')) { 102 return false; 103 } 104 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and data values use wpdb format handling. 105 return $wpdb->update($table, $data, $where, $format, $where_format); 106 } 107 } 108 109 if (!function_exists('statpl_db_insert')) { 110 function statpl_db_insert($table, $data, $format = null) { 111 global $wpdb; 112 if (!is_object($wpdb) || !method_exists($wpdb, 'insert')) { 113 return false; 114 } 115 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and data values use wpdb format handling. 116 return $wpdb->insert($table, $data, $format); 117 } 118 } 119 120 if (!function_exists('statpl_db_delete')) { 121 function statpl_db_delete($table, $where, $where_format = null) { 122 global $wpdb; 123 if (!is_object($wpdb) || !method_exists($wpdb, 'delete')) { 124 return false; 125 } 126 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and values use wpdb format handling. 127 return $wpdb->delete($table, $where, $where_format); 128 } 129 } 130 131 /** 132 * Contract implemented by source-specific historical product-view adapters. 133 * Adapters are supplied through the `statpl_historical_view_adapters` filter. 134 * 135 * `discover_origins()` must return only historical product-view origins; general 136 * page views, visits and non-product content must never be advertised here. 137 * `get_origin_snapshot()` must return an immutable, non-empty `boundary` descriptor. 138 * Every `get_product_view_batch()` result must include `boundary_applied => true`; 139 * the adapter must enforce that snapshot boundary in its source query, return no 140 * more than the requested limit, and advance `next_checkpoint` when `has_more` is true. 141 * `normalize_record()` may explicitly return `['skip' => true, 'source_record_key' => ..., 'cursor' => ...]` 142 * for a valid source row that is outside product scope. The core validates the key/cursor, 143 * clears any stale per-row failure, advances the batch cursor, and does not create an event 144 * or failure-ledger entry. Invalid product records must still return null and follow retries. 145 * 146 * Optional adapter extension: `get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event)`. 147 * It may return a small, JSON-serializable evidence array describing source-specific 148 * overlap signals. Its output is audit evidence only: it cannot suppress counts or 149 * alter the generic import decision. Automatic suppression remains limited to the 150 * generic exact-match rule requiring a shared fingerprint and identical dimensions. 151 */ 152 if (!interface_exists('STATPL_Historical_View_Adapter')) { 153 interface STATPL_Historical_View_Adapter { 154 public function get_adapter_key(); 155 public function get_source_key(); 156 public function get_display_name(); 157 public function get_adapter_version(); 158 public function discover_origins(); 159 public function get_origin_snapshot($origin_key); 160 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit); 161 public function normalize_record($origin_key, $record); 162 } 163 } 164 18 165 final class STATPL_Core { 19 const VERSION = '0.11.46'; 166 const VERSION = '0.11.47'; 167 const VIEW_IMPORT_SCHEMA_VERSION = '1.0.0'; 168 const VIEW_IMPORT_STATE_SCHEMA_VERSION = '1.1.0'; 169 const VIEW_IMPORT_EVENTS_SCHEMA_VERSION = '1.3.0'; 170 const VIEW_IMPORT_FAILURES_SCHEMA_VERSION = '1.3.0'; 171 const VIEW_SOURCE_SCHEMA_VERSION = '1.3.4'; 172 const HISTORY_REPORT_SCHEMA_VERSION = '1.0.0'; 20 173 const NONCE_ACTION = 'statpl_record_view'; 21 174 private static $instance; … … 24 177 private $order_events_table; 25 178 private $order_items_table; 26 27 public static function instance() { 179 private $view_imports_table; 180 private $view_import_failures_table; 181 182 private $view_import_state_table; 183 private $view_import_events_table; 184 private $view_sources_table; 185 private $view_source_origins_table; 186 private $view_source_state_table; 187 private $view_source_events_table; 188 private $view_source_failures_table; 189 private $view_source_reconciliation_table; 190 private $view_source_reconciliation_history_table; 191 private $historical_import_report_table; 192 private $historical_view_adapters = []; 193 public static function instance() { 28 194 if (!self::$instance) self::$instance = new self(); 29 195 return self::$instance; … … 35 201 $this->order_events_table = $wpdb->prefix . 'statpl_product_metric_events'; 36 202 $this->order_items_table = $wpdb->prefix . 'statpl_order_product_snapshot'; 203 $this->view_imports_table = $wpdb->prefix . 'statpl_view_imports'; 204 $this->view_import_state_table = $wpdb->prefix . 'statpl_view_import_state'; 205 $this->view_import_events_table = $wpdb->prefix . 'statpl_view_import_events'; 206 $this->view_import_failures_table = $wpdb->prefix . 'statpl_view_import_failures'; 207 $this->view_sources_table = $wpdb->prefix . 'statpl_view_sources'; 208 $this->view_source_origins_table = $wpdb->prefix . 'statpl_view_source_origins'; 209 $this->view_source_state_table = $wpdb->prefix . 'statpl_view_source_state'; 210 $this->view_source_events_table = $wpdb->prefix . 'statpl_view_source_events'; 211 $this->view_source_failures_table = $wpdb->prefix . 'statpl_view_source_failures'; 212 $this->view_source_reconciliation_table = $wpdb->prefix . 'statpl_view_source_reconciliation'; 213 $this->view_source_reconciliation_history_table = $wpdb->prefix . 'statpl_view_source_reconciliation_history'; 214 $this->historical_import_report_table = $wpdb->prefix . 'statpl_historical_import_report'; 37 215 register_activation_hook(__FILE__, [$this, 'activate']); 216 register_deactivation_hook(__FILE__, [$this, 'deactivate']); 217 add_action('admin_post_statpl_prepare_deactivation', [$this, 'prepare_deactivation']); 38 218 add_action('plugins_loaded', [$this, 'init']); 39 219 } 220 /** 221 * Intercept the Plugins-screen deactivation action and persist the explicit 222 * administrator choice before WordPress invokes the deactivation hook. 223 */ 224 public function prepare_deactivation() { 225 if (!current_user_can('activate_plugins')) { 226 wp_die(esc_html__('Δεν έχεις δικαίωμα εκτέλεσης αυτής της ενέργειας.', 'statora-plus')); 227 } 228 check_admin_referer('statpl_prepare_deactivation'); 229 230 $choice = isset($_GET['choice']) ? sanitize_key(wp_unslash($_GET['choice'])) : ''; 231 if (!in_array($choice, ['retain', 'delete'], true)) { 232 wp_die(esc_html__('Μη έγκυρη επιλογή διατήρησης δεδομένων.', 'statora-plus')); 233 } 234 235 $redirect = isset($_GET['redirect_to']) ? esc_url_raw(wp_unslash($_GET['redirect_to'])) : ''; 236 $parts = wp_parse_url($redirect); 237 $query = []; 238 if (!is_array($parts) || empty($parts['path']) || basename($parts['path']) !== 'plugins.php') { 239 wp_die(esc_html__('Μη έγκυρος προορισμός απενεργοποίησης.', 'statora-plus')); 240 } 241 if (!empty($parts['query'])) { 242 parse_str($parts['query'], $query); 243 } 244 if (($query['action'] ?? '') !== 'deactivate' || ($query['plugin'] ?? '') !== plugin_basename(__FILE__)) { 245 wp_die(esc_html__('Η ενέργεια δεν αφορά το Statora Plus.', 'statora-plus')); 246 } 247 248 update_option('statpl_deactivation_choice', $choice, false); 249 wp_safe_redirect($redirect); 250 exit; 251 } 252 253 /** Stop Statora-owned workers and either retain or remove Statora-owned data. */ 254 public function deactivate() { 255 global $wpdb; 256 257 $choice = (string) get_option('statpl_deactivation_choice', ''); 258 delete_option('statpl_deactivation_choice'); 259 260 $hooks = [ 261 'statpl_import_batch', 262 'statpl_view_import_batch', 263 'statpl_generic_view_import_batch', 264 'statpl_generic_view_snapshot_retry', 265 'statpl_generic_view_retry_failed_records', 266 'statpl_sync_order', 267 'statpl_reconcile_legacy_aggregate_fingerprints', 268 ]; 269 foreach ($hooks as $hook) { 270 wp_clear_scheduled_hook($hook); 271 if (function_exists('as_unschedule_all_actions')) { 272 as_unschedule_all_actions($hook, null, 'statpl-plus'); 273 } 274 } 275 276 if ($choice === 'retain') { 277 update_option('statpl_retained_deactivation_at', current_time('mysql'), false); 278 update_option('statpl_data_lifecycle_state', 'retained', false); 279 delete_transient('statpl_order_stats_v1'); 280 delete_transient('statpl_global_summary_v2'); 281 return; 282 } 283 284 // A missing/invalid choice must never silently destroy data. 285 if ($choice !== 'delete') { 286 update_option('statpl_deactivation_choice_missing', '1', false); 287 return; 288 } 289 290 $tables = [ 291 $this->views_table, 292 $this->events_table, 293 $this->order_events_table, 294 $this->order_items_table, 295 $this->view_imports_table, 296 $this->view_import_state_table, 297 $this->view_import_events_table, 298 $this->view_import_failures_table, 299 $this->view_sources_table, 300 $this->view_source_origins_table, 301 $this->view_source_state_table, 302 $this->view_source_events_table, 303 $this->view_source_failures_table, 304 $this->view_source_reconciliation_table, 305 $this->view_source_reconciliation_history_table, 306 $this->historical_import_report_table, 307 ]; 308 foreach ($tables as $table) { 309 // Table names are built exclusively from the active WordPress prefix 310 // and fixed Statora suffixes declared in the constructor. Validate the 311 // complete identifier before placing it in a DDL statement; identifiers 312 // cannot be supplied as ordinary prepared-statement value placeholders. 313 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 314 continue; 315 } 316 $quoted_table = '`' . $table . '`'; 317 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL identifiers cannot use value placeholders; the identifier is constrained to ASCII letters, digits and underscores and is quoted as one identifier. 318 statpl_db_query('DROP TABLE IF EXISTS ' . $quoted_table); 319 } 320 321 // Remove only Statora's reserved option/transient namespaces. 322 $option_like = $wpdb->esc_like('statpl_') . '%'; 323 statpl_db_query(statpl_db_prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $option_like)); 324 $transient_like = $wpdb->esc_like('_transient_statpl_') . '%'; 325 $timeout_like = $wpdb->esc_like('_transient_timeout_statpl_') . '%'; 326 statpl_db_query(statpl_db_prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $transient_like, $timeout_like)); 327 if (function_exists('wp_cache_flush_group')) { 328 wp_cache_flush_group('statpl_plus'); 329 } 330 } 331 332 /** Rebuild the historical import window after an explicit retain/reactivate cycle. */ 333 private function resume_retained_history_gap() { 334 global $wpdb; 335 336 $deactivated_at = (string) get_option('statpl_retained_deactivation_at', ''); 337 if ($deactivated_at === '') { 338 return ['status' => 'not_required']; 339 } 340 341 $lock = $this->acquire_generic_advisory_lock('statpl_generic_adapter_discovery'); 342 if (empty($lock['acquired'])) { 343 return ['status' => 'lock_unavailable']; 344 } 345 346 $reset = 0; 347 $failed = 0; 348 $scheduled_origins = []; 349 try { 350 $origins = statpl_db_get_results( 351 "SELECT source_id, origin_key, adapter_key FROM {$this->view_source_origins_table} WHERE detection_status = 'available'", 352 ARRAY_A 353 ); 354 if (!empty($wpdb->last_error) || !is_array($origins)) { 355 return ['status' => 'database_error']; 356 } 357 358 foreach ($origins as $origin) { 359 if (!$this->generic_advisory_lock_session_is_current($lock)) { 360 return ['status' => 'lock_session_lost', 'reset' => $reset, 'failed' => $failed]; 361 } 362 $adapter = $this->get_historical_view_adapter((string) $origin['adapter_key']); 363 if (!$adapter) { 364 $failed++; 365 continue; 366 } 367 try { 368 $snapshot = $adapter->get_origin_snapshot((string) $origin['origin_key']); 369 } catch (Throwable $exception) { 370 $failed++; 371 continue; 372 } 373 if (!$this->generic_advisory_lock_session_is_current($lock)) { 374 return ['status' => 'lock_session_lost', 'reset' => $reset, 'failed' => $failed]; 375 } 376 if (!is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 377 $failed++; 378 continue; 379 } 380 if (!isset($snapshot['captured_at'])) { 381 $snapshot['captured_at'] = current_time('mysql'); 382 } 383 $initial = array_key_exists('initial_checkpoint', $snapshot) ? $snapshot['initial_checkpoint'] : null; 384 if ($initial !== null && !$this->is_valid_generic_historical_cursor($initial)) { 385 $failed++; 386 continue; 387 } 388 unset($snapshot['initial_checkpoint']); 389 $saved = $this->update_view_source_origin_state( 390 (int) $origin['source_id'], 391 (string) $origin['origin_key'], 392 [ 393 'snapshot_data' => $snapshot, 394 'checkpoint_data' => ['cursor' => $initial, 'processed' => 0, 'snapshot_captured_at' => $snapshot['captured_at']], 395 'status' => 'pending', 396 'last_error' => null, 397 'started_at' => null, 398 'completed_at' => null, 399 ], 400 $lock 401 ); 402 if (empty($saved['status']) || $saved['status'] !== 'updated') { 403 $failed++; 404 continue; 405 } 406 $reset++; 407 $scheduled_origins[] = [(int) $origin['source_id'], (string) $origin['origin_key']]; 408 } 409 } finally { 410 $this->release_generic_advisory_lock($lock, 'retained_history_gap'); 411 } 412 413 foreach ($scheduled_origins as $scheduled_origin) { 414 $this->schedule_generic_historical_view_import_batch(15, $scheduled_origin[0], $scheduled_origin[1]); 415 } 416 417 // SlimStat uses a separate importer and event ledger. Rewind its cursor to 418 // the beginning while extending both source boundaries; its unique event 419 // ledger prevents already-applied records from being counted twice. 420 $slimstat_state = statpl_db_get_var(statpl_db_prepare( 421 "SELECT source FROM {$this->view_import_state_table} WHERE source = %s", 422 'slimstat' 423 )); 424 if ($slimstat_state === 'slimstat') { 425 $snapshot = $this->get_slimstat_view_snapshot(); 426 if (($snapshot['status'] ?? '') === 'available') { 427 $updated = statpl_db_update( 428 $this->view_import_state_table, 429 [ 430 'snapshot_max_id' => max((int) $snapshot['snapshot']['active'], (int) $snapshot['snapshot']['archive']), 431 'snapshot_active_max_id' => (int) $snapshot['snapshot']['active'], 432 'snapshot_archive_max_id' => (int) $snapshot['snapshot']['archive'], 433 'last_processed_id' => 0, 434 'status' => 'pending', 435 'started_at' => null, 436 'completed_at' => null, 437 'updated_at' => current_time('mysql'), 438 ], 439 ['source' => 'slimstat'], 440 ['%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s'], 441 ['%s'] 442 ); 443 if ($updated === false) { 444 $failed++; 445 } 446 } else { 447 $failed++; 448 } 449 } 450 451 if ($failed === 0) { 452 delete_option('statpl_retained_deactivation_at'); 453 update_option('statpl_data_lifecycle_state', 'active', false); 454 } 455 return ['status' => $failed === 0 ? 'reset' : 'partial', 'reset' => $reset, 'failed' => $failed]; 456 } 457 40 458 public function activate() { 41 459 global $wpdb; … … 47 465 total_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 48 466 PRIMARY KEY (product_id) 467 ) $collate;"); 468 dbDelta("CREATE TABLE {$this->view_imports_table} ( 469 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 470 source VARCHAR(50) NOT NULL, 471 product_id BIGINT(20) UNSIGNED NOT NULL, 472 imported_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 473 imported_at DATETIME NOT NULL, 474 PRIMARY KEY (id), 475 UNIQUE KEY source_product (source, product_id), 476 KEY imported_at (imported_at) 49 477 ) $collate;"); 50 478 dbDelta("CREATE TABLE {$this->events_table} ( … … 59 487 // On a fresh install, avoid issuing SHOW INDEX against a table that has not yet been created. 60 488 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema detection is required before the conditional index migration; the identifier is built from the WordPress prefix and a fixed suffix. 61 $events_table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->order_events_table)));489 $events_table_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->order_events_table))); 62 490 if ($events_table_exists === $this->order_events_table) { 63 491 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this conditional migration checks the obsolete index. 64 $old_index = $wpdb->get_var($wpdb->prepare("SHOW INDEX FROM {$this->order_events_table} WHERE Key_name = %s", 'order_product_event'));492 $old_index = statpl_db_get_var(statpl_db_prepare("SHOW INDEX FROM {$this->order_events_table} WHERE Key_name = %s", 'order_product_event')); 65 493 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this conditional migration removes only the obsolete index. 66 if ($old_index) { $wpdb->query("ALTER TABLE {$this->order_events_table} DROP INDEX order_product_event"); }494 if ($old_index) { statpl_db_query("ALTER TABLE {$this->order_events_table} DROP INDEX order_product_event"); } 67 495 } 68 496 dbDelta("CREATE TABLE {$this->order_events_table} ( … … 94 522 KEY status_created (order_status, created_at) 95 523 ) $collate;"); 96 // Rebuild order snapshots once after an upgrade so completed_at and refund attribution 97 // are populated consistently for orders already present in the store. 524 // dbDelta can fail without throwing (for example during a transient 525 // database outage). Verify the core table structures before advancing 526 // the global DB version; otherwise a partial install would be treated as 527 // current and would not be retried on the next request. 528 $required_core_columns = [ 529 $this->views_table => ['product_id', 'total_views'], 530 $this->view_imports_table => ['id', 'source', 'product_id', 'imported_views', 'imported_at'], 531 $this->events_table => ['id', 'product_id', 'viewed_at'], 532 $this->order_events_table => ['id', 'order_id', 'product_id', 'event_type', 'event_ref_id', 'event_at'], 533 $this->order_items_table => ['order_id', 'product_id', 'order_status', 'created_at', 'paid_at', 'completed_at', 'quantity', 'net_quantity', 'net_revenue', 'has_refund'], 534 ]; 535 foreach ($required_core_columns as $table => $columns) { 536 if (!$this->verify_historical_table_columns($table, $columns)) { 537 return false; 538 } 539 } 540 541 // Upgrade side effects must run only after every core table has been 542 // verified. Previously these resets happened before verification, so a 543 // partial dbDelta/database outage could repeatedly rewind an in-progress 544 // order import even though the schema upgrade had not succeeded. 98 545 if ($previous_version !== '' && version_compare($previous_version, self::VERSION, '<')) { 99 546 update_option('statpl_import_complete', 0, false); … … 102 549 delete_transient('statpl_global_summary_v2'); 103 550 } 551 104 552 update_option('statpl_db_version', self::VERSION); 105 } 106 public function init() { 107 if (get_option('statpl_db_version') !== self::VERSION) { 108 $this->activate(); 109 } 553 if ((string) get_option('statpl_db_version', '') !== self::VERSION) { 554 return false; 555 } 556 return true; 557 } 558 /** Persist a schema marker and verify the stored value before proceeding. */ 559 private function persist_schema_version_marker($option_name, $version) { 560 update_option($option_name, $version, false); 561 562 // update_option() can return false for either an unchanged value or a failed write. 563 // The strict read-back is the authoritative persistence check. 564 return get_option($option_name, '') === $version; 565 } 566 567 /** Create the durable, privacy-safe summary ledger for historical import outcomes. */ 568 private function maybe_upgrade_historical_import_report_schema() { 569 global $wpdb; 570 if (get_option('statpl_history_report_schema_version', '') === self::HISTORY_REPORT_SCHEMA_VERSION) { 571 return true; 572 } 573 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 574 $collate = $wpdb->get_charset_collate(); 575 dbDelta("CREATE TABLE {$this->historical_import_report_table} ( 576 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 577 source_key VARCHAR(100) NOT NULL, 578 source_name VARCHAR(191) NOT NULL, 579 origin_key VARCHAR(100) NOT NULL DEFAULT '', 580 source_record_hash CHAR(64) NOT NULL, 581 outcome VARCHAR(20) NOT NULL DEFAULT 'failed', 582 attempts INT(10) UNSIGNED NOT NULL DEFAULT 0, 583 reason_code VARCHAR(100) NOT NULL DEFAULT '', 584 updated_at DATETIME NOT NULL, 585 PRIMARY KEY (id), 586 UNIQUE KEY source_record (source_key, source_record_hash), 587 KEY source_outcome (source_key, outcome), 588 KEY outcome (outcome) 589 ) $collate;"); 590 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->historical_import_report_table))); 591 if ($exists !== $this->historical_import_report_table || !$this->verify_historical_table_columns($this->historical_import_report_table, ['id','source_key','source_name','origin_key','source_record_hash','outcome','attempts','reason_code','updated_at'])) { 592 return false; 593 } 594 // Preserve visibility of records imported before this report ledger existed. 595 statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table} 596 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 597 SELECT s.source_key, s.display_name, '', e.source_record_hash, 'imported', 0, '', e.updated_at 598 FROM {$this->view_source_events_table} e 599 INNER JOIN {$this->view_sources_table} s ON s.id = e.source_id"); 600 if (!empty($wpdb->last_error)) { return false; } 601 statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table} 602 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 603 SELECT s.source_key, s.display_name, f.origin_key, f.source_record_hash, IF(f.status = 'retry', 'retry', 'failed'), f.attempts, 'record_import_failed', f.updated_at 604 FROM {$this->view_source_failures_table} f 605 INNER JOIN {$this->view_sources_table} s ON s.id = f.source_id"); 606 if (!empty($wpdb->last_error)) { return false; } 607 statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table} 608 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 609 SELECT 'slimstat', 'SlimStat', 'slimstat', SHA2(CONCAT('slimstat', CHAR(0), e.source_view_id), 256), 'imported', 0, '', e.imported_at 610 FROM {$this->view_import_events_table} e WHERE e.source = 'slimstat' AND e.counts_applied = 1"); 611 if (!empty($wpdb->last_error)) { return false; } 612 statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table} 613 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 614 SELECT 'slimstat', 'SlimStat', 'slimstat', SHA2(CONCAT('slimstat', CHAR(0), f.source_view_id), 256), IF(f.status = 'retry', 'retry', 'failed'), f.attempts, 'record_import_failed', f.updated_at 615 FROM {$this->view_import_failures_table} f WHERE f.source = 'slimstat'"); 616 if (!empty($wpdb->last_error)) { return false; } 617 return $this->persist_schema_version_marker('statpl_history_report_schema_version', self::HISTORY_REPORT_SCHEMA_VERSION); 618 } 619 620 /** Save one eligible historical record's latest durable outcome; no visitor identifiers are stored. */ 621 private function save_historical_import_report_record($source_key, $source_name, $origin_key, $record_hash, $outcome, $attempts = 0, $reason_code = '') { 622 global $wpdb; 623 $source_key = sanitize_key((string) $source_key); 624 $source_name = sanitize_text_field((string) $source_name); 625 $origin_key = sanitize_key((string) $origin_key); 626 $record_hash = strtolower((string) $record_hash); 627 if ($source_key === '' || $source_name === '' || !preg_match('/^[a-f0-9]{64}$/D', $record_hash) 628 || !in_array($outcome, ['imported','retry','failed'], true)) { 629 return false; 630 } 631 $result = statpl_db_query(statpl_db_prepare( 632 "INSERT INTO {$this->historical_import_report_table} 633 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 634 VALUES (%s, %s, %s, %s, %s, %d, %s, %s) 635 ON DUPLICATE KEY UPDATE 636 source_name = VALUES(source_name), 637 outcome = VALUES(outcome), 638 attempts = GREATEST(attempts, VALUES(attempts)), 639 reason_code = VALUES(reason_code), 640 updated_at = VALUES(updated_at)", 641 $source_key, $source_name, $origin_key, $record_hash, $outcome, 642 max(0, (int) $attempts), sanitize_key((string) $reason_code), current_time('mysql') 643 )); 644 return $result !== false && empty($wpdb->last_error); 645 } 646 647 /** Resolve a generic source's stable public name for the administrator report. */ 648 private function save_generic_historical_report_record($source_id, $origin_key, $record_hash, $outcome, $attempts = 0, $reason_code = '') { 649 global $wpdb; 650 $source = statpl_db_get_row(statpl_db_prepare( 651 "SELECT source_key, display_name FROM {$this->view_sources_table} WHERE id = %d LIMIT 1", 652 absint($source_id) 653 ), ARRAY_A); 654 if (!is_array($source) || !empty($wpdb->last_error)) { 655 return false; 656 } 657 return $this->save_historical_import_report_record( 658 (string) $source['source_key'], (string) $source['display_name'], 659 (string) $origin_key, (string) $record_hash, (string) $outcome, 660 (int) $attempts, (string) $reason_code 661 ); 662 } 663 664 /** Aggregate historical import state without exposing source errors or personal data. */ 665 private function get_historical_import_report_summary() { 666 global $wpdb; 667 $rows = statpl_db_get_results( 668 "SELECT source_key, source_name, COUNT(*) AS eligible, 669 SUM(CASE WHEN outcome = 'imported' THEN 1 ELSE 0 END) AS imported, 670 SUM(CASE WHEN outcome IN ('retry','failed') THEN 1 ELSE 0 END) AS not_imported, 671 SUM(CASE WHEN outcome = 'retry' THEN 1 ELSE 0 END) AS pending_retries, 672 SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS failed, 673 SUM(CASE WHEN outcome = 'imported' AND attempts > 0 THEN 1 ELSE 0 END) AS retry_resolved, 674 SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS final_failures 675 FROM {$this->historical_import_report_table} 676 GROUP BY source_key, source_name ORDER BY source_name ASC", 677 ARRAY_A 678 ); 679 if (!empty($wpdb->last_error) || !is_array($rows)) { 680 return ['available' => false, 'sources' => [], 'eligible' => 0, 'imported' => 0, 'not_imported' => 0, 'pending_retries' => 0, 'failed' => 0, 'retry_resolved' => 0]; 681 } 682 $summary = ['available' => true, 'sources' => $rows, 'eligible' => 0, 'imported' => 0, 'not_imported' => 0, 'pending_retries' => 0, 'failed' => 0, 'retry_resolved' => 0]; 683 foreach ($rows as $row) { 684 foreach (['eligible','imported','not_imported','pending_retries','failed','retry_resolved'] as $key) { 685 $summary[$key] += (int) ($row[$key] ?? 0); 686 } 687 } 688 return $summary; 689 } 690 691 /** Determine the user-facing historical-import state from persisted source and record outcomes. */ 692 private function get_historical_import_lifecycle_state() { 693 global $wpdb; 694 $report = $this->get_historical_import_report_summary(); 695 if (empty($report['available'])) { 696 return ['state' => 'technical', 'report' => $report]; 697 } 698 $slimstat = statpl_db_get_row(statpl_db_prepare( 699 "SELECT status FROM {$this->view_import_state_table} WHERE source = %s LIMIT 1", 'slimstat' 700 ), ARRAY_A); 701 $sources = statpl_db_get_results("SELECT status FROM {$this->view_sources_table}", ARRAY_A); 702 if (!empty($wpdb->last_error) || !is_array($sources)) { 703 return ['state' => 'importing', 'report' => $report]; 704 } 705 $probe = (string) get_option('statpl_history_slimstat_probe', 'not_checked'); 706 $discovery = (string) get_option('statpl_history_discovery_status', 'not_checked'); 707 $slimstat_pending = is_array($slimstat) && in_array((string) ($slimstat['status'] ?? ''), ['pending','running'], true); 708 $technical = in_array($probe, ['missing_tables','incompatible_schema','database_error'], true) 709 || ($slimstat_pending && $probe === 'not_installed') 710 || in_array($discovery, ['provider_error','invalid_registry','lock_unavailable','lock_session_lost'], true); 711 if (in_array($discovery, ['busy','not_checked','unknown'], true)) { 712 return ['state' => 'importing', 'report' => $report]; 713 } 714 $has_source = is_array($slimstat) || !empty($sources); 715 $active = false; 716 foreach ($sources as $source) { 717 if (in_array((string) ($source['status'] ?? ''), ['detected','importing','retry_wait'], true)) { 718 $active = true; 719 } 720 if (in_array((string) ($source['status'] ?? ''), ['needs_attention','partial'], true)) { 721 $technical = true; 722 } 723 } 724 if ($slimstat_pending && !$technical) { 725 $active = true; 726 } 727 $unavailable_active_origins = statpl_db_get_var( 728 "SELECT COUNT(*) FROM {$this->view_source_origins_table} WHERE detection_status = 'available' AND status = 'unavailable'" 729 ); 730 if (empty($wpdb->last_error) && (int) $unavailable_active_origins > 0) { 731 $technical = true; 732 } 733 if ($active || (int) ($report['pending_retries'] ?? 0) > 0) { 734 return ['state' => 'importing', 'report' => $report]; 735 } 736 if (!$has_source) { 737 return ['state' => $technical ? 'technical' : 'none', 'report' => $report]; 738 } 739 if ((int) $report['failed'] > 0) { 740 return ['state' => ((int) $report['imported'] > 0) ? 'partial' : 'technical', 'report' => $report]; 741 } 742 if ($technical) { 743 return ['state' => ((int) $report['imported'] > 0) ? 'partial' : 'technical', 'report' => $report]; 744 } 745 if ((int) $report['eligible'] === 0) { 746 return ['state' => 'none', 'report' => $report]; 747 } 748 return ['state' => 'success', 'report' => $report]; 749 } 750 751 /** Render the same concise historical-import notice above both dashboard views. */ 752 private function render_historical_import_notice() { 753 $lifecycle = $this->get_historical_import_lifecycle_state(); 754 $state = (string) ($lifecycle['state'] ?? 'importing'); 755 756 // The in-progress notice remains visible while work is active. A terminal 757 // notice is presented only once for the lifetime of the current Statora 758 // data set; the marker is a Statora-owned option and is removed by the 759 // explicit data-deletion path. Retention/reactivation preserves it. 760 if ($state !== 'importing') { 761 $notice_marker = 'statpl_history_final_notice_shown'; 762 if (get_option($notice_marker, '') !== '') { 763 return; 764 } 765 // add_option() is atomic for a unique option_name. Only the request 766 // that successfully creates the marker may render the final notice. 767 if (!add_option($notice_marker, $state, '', false)) { 768 return; 769 } 770 // Start the short report-access window when the terminal notice is 771 // first rendered, not when the administrator later opens the report. 772 if (in_array($state, ['partial','technical'], true)) { 773 update_option('statpl_history_final_notice_at', time(), false); 774 } 775 } 776 777 $report_url = admin_url('admin.php?page=statpl-history-report'); 778 $messages = [ 779 'importing' => ['title' => 'Επεξεργασία ιστορικού', 'body' => 'Η αρχική εισαγωγή των ιστορικών προβολών προϊόντων βρίσκεται σε εξέλιξη. Τα στατιστικά ενδέχεται να μην είναι ακόμη πλήρη μέχρι να ολοκληρωθεί η διαδικασία.'], 780 'success' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών για τα προϊόντα του καταστήματός σας ολοκληρώθηκε επιτυχώς.'], 781 'partial' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών για τα προϊόντα του καταστήματός σας ολοκληρώθηκε μερικώς.'], 782 'none' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Δεν βρέθηκαν διαθέσιμες προηγούμενες καταγεγραμμένες προβολές προϊόντων για εισαγωγή.'], 783 'technical' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Δεν ήταν δυνατή η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών των προϊόντων σας λόγω τεχνικού προβλήματος.'], 784 ]; 785 $message = $messages[$state] ?? $messages['importing']; 786 echo '<div class="statpl-history-notice statpl-history-notice-' . esc_attr($state) . '" role="status" aria-live="polite">'; 787 echo '<button type="button" class="statpl-history-notice-close" aria-label="Κλείσιμο ειδοποίησης">×</button>'; 788 if ($state === 'importing') { echo '<span class="statpl-history-notice-icon dashicons dashicons-info-outline" aria-hidden="true"></span>'; } 789 echo '<strong>' . esc_html($message['title']) . '</strong>'; 790 echo '<p>' . esc_html($message['body']) . '</p>'; 791 if ($state !== 'importing') { 792 if ($state === 'none') { 793 echo '<p>Το Statora Plus ξεκινά την καταγραφή των προβολών των προϊόντων σας από τώρα και λειτουργεί αυτόνομα.</p>'; 794 } elseif ($state === 'success') { 795 echo '<p>Από εδώ και στο εξής, το Statora Plus καταγράφει αυτόνομα τις νέες προβολές των προϊόντων σας.</p>'; 796 } else { 797 echo '<p>Το Statora Plus συνεχίζει να καταγράφει αυτόνομα τις νέες προβολές των προϊόντων σας.</p>'; 798 } 799 echo '<p><strong>Δεν απαιτείται κάποια άλλη ενέργεια εκ μέρους σας.</strong></p>'; 800 } 801 if (in_array($state, ['partial','technical'], true)) { 802 echo '<a class="statpl-history-report-link" href="' . esc_url($report_url) . '">Περισσότερες πληροφορίες →</a>'; 803 } 804 echo '</div>'; 805 } 806 807 /** Settings page with an administrator-friendly, privacy-safe import summary. */ 808 public function render_historical_import_report_page() { 809 if (!current_user_can('manage_woocommerce')) { 810 wp_die(esc_html__('Δεν έχεις δικαίωμα προβολής αυτής της σελίδας.', 'statora-plus')); 811 } 812 $notice_state = (string) get_option('statpl_history_final_notice_shown', ''); 813 $notice_time = absint(get_option('statpl_history_final_notice_at', 0)); 814 $report_window_seconds = 15 * MINUTE_IN_SECONDS; 815 $report_is_available = in_array($notice_state, ['partial','technical'], true) 816 && $notice_time > 0 817 && time() >= $notice_time 818 && (time() - $notice_time) < $report_window_seconds; 819 if (!$report_is_available) { 820 wp_safe_redirect(admin_url('admin.php?page=statpl-product-metrics')); 821 exit; 822 } 823 $report = $this->get_historical_import_report_summary(); 824 $lifecycle = $this->get_historical_import_lifecycle_state(); 825 echo '<div class="wrap"><h1>Αρχική εγκατάσταση και ιστορικό προβολών</h1>'; 826 if (($lifecycle['state'] ?? '') === 'technical') { 827 echo '<p>Δεν ήταν δυνατή η ολοκλήρωση της ανάγνωσης μίας διαθέσιμης πηγής ιστορικών προβολών. Το Statora Plus συνεχίζει αυτόνομα την καταγραφή νέων προβολών προϊόντων.</p>'; 828 $slimstat_probe = (string) get_option('statpl_history_slimstat_probe', ''); 829 if (in_array($slimstat_probe, ['missing_tables','incompatible_schema','database_error'], true)) { 830 echo '<p><strong>SlimStat:</strong> Δεν ήταν δυνατή η ανάγνωση των ιστορικών δεδομένων του.</p>'; 831 } 832 } elseif (($lifecycle['state'] ?? '') === 'partial') { 833 echo '<p>Η εισαγωγή ολοκληρώθηκε με μερικό αποτέλεσμα. Οι εγγραφές που εισήχθησαν επιτυχώς διατηρούνται και η αυτόνομη καταγραφή συνεχίζεται.</p>'; 834 } 835 if (empty($report['available'])) { 836 echo '<p>Η αναλυτική αναφορά δεν είναι προσωρινά διαθέσιμη. Τα στατιστικά του καταστήματος δεν επηρεάζονται.</p></div>'; 837 return; 838 } 839 echo '<p>Συγκεντρωτικά αποτελέσματα εισαγωγής ιστορικών προβολών προϊόντων.</p>'; 840 echo '<table class="widefat striped"><thead><tr><th>Πηγή</th><th>Επιλέξιμες εγγραφές</th><th>Εισήχθησαν</th><th>Δεν εισήχθησαν</th><th>Σε επαναπροσπάθεια</th><th>Επιλύθηκαν με επαναπροσπάθεια</th><th>Οριστικές αποτυχίες</th><th>Αιτία αποτυχίας</th></tr></thead><tbody>'; 841 if (empty($report['sources'])) { 842 echo '<tr><td colspan="8">Δεν υπάρχουν διαθέσιμα ιστορικά δεδομένα προς αναφορά.</td></tr>'; 843 } else { 844 foreach ($report['sources'] as $row) { 845 echo '<tr><td>' . esc_html((string) $row['source_name']) . '</td><td>' . esc_html((string) (int) $row['eligible']) . '</td><td>' . esc_html((string) (int) $row['imported']) . '</td><td>' . esc_html((string) (int) $row['not_imported']) . '</td><td>' . esc_html((string) (int) $row['pending_retries']) . '</td><td>' . esc_html((string) (int) $row['retry_resolved']) . '</td><td>' . esc_html((string) (int) $row['final_failures']) . '</td><td>' . ((int) $row['failed'] > 0 ? 'Ορισμένες εγγραφές δεν μπόρεσαν να καταγραφούν μετά από επαναπροσπάθειες.' : '—') . '</td></tr>'; 846 } 847 } 848 echo '</tbody></table>'; 849 if ((int) $report['failed'] > 0) { 850 echo '<h2>Τι σημαίνουν τα αποτελέσματα</h2><p>Ορισμένες ιστορικές εγγραφές δεν ήταν δυνατό να εισαχθούν. Οι επιτυχημένες εισαγωγές διατηρούνται και το Statora Plus συνεχίζει κανονικά την αυτόνομη καταγραφή νέων προβολών.</p>'; 851 } 852 echo '<p>Η αναφορά περιέχει μόνο συγκεντρωτικά αποτελέσματα. Δεν αποθηκεύονται ή εμφανίζονται προσωπικά στοιχεία επισκεπτών.</p></div>'; 853 } 854 855 /** Verify every required column before persisting a schema-version marker. */ 856 private function verify_historical_table_columns($table, $required_columns) { 857 global $wpdb; 858 859 if (!is_object($wpdb) || !isset($wpdb->last_error) || !is_string($table) || !is_array($required_columns)) { 860 return false; 861 } 862 863 // Only Statora-owned tables may be inspected by this helper. Validate the 864 // complete identifier here rather than relying on each caller to do so. 865 $allowed_tables = [ 866 $this->views_table, 867 $this->events_table, 868 $this->order_events_table, 869 $this->order_items_table, 870 $this->view_imports_table, 871 $this->view_import_state_table, 872 $this->view_import_events_table, 873 $this->view_import_failures_table, 874 $this->view_sources_table, 875 $this->view_source_origins_table, 876 $this->view_source_state_table, 877 $this->view_source_events_table, 878 $this->view_source_failures_table, 879 $this->view_source_reconciliation_table, 880 $this->view_source_reconciliation_history_table, 881 $this->historical_import_report_table, 882 ]; 883 if (!in_array($table, $allowed_tables, true) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 884 return false; 885 } 886 foreach ($required_columns as $column) { 887 if (!is_string($column) || !preg_match('/^[A-Za-z0-9_]+$/D', $column)) { 888 return false; 889 } 890 } 891 892 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is restricted to the explicit Statora-owned table allowlist and quoted as one identifier. 893 $rows = statpl_db_get_results("SHOW COLUMNS FROM `{$table}`", ARRAY_A); 894 if ($wpdb->last_error !== '' || !is_array($rows)) { 895 return false; 896 } 897 898 $found = []; 899 foreach ($rows as $row) { 900 if (isset($row['Field'])) { 901 $found[$row['Field']] = true; 902 } 903 } 904 905 foreach ($required_columns as $column) { 906 if (!isset($found[$column])) { 907 return false; 908 } 909 } 910 911 return true; 912 } 913 914 private function maybe_upgrade_view_import_schema() { 915 global $wpdb; 916 917 $schema_version = get_option('statpl_view_import_schema_version', ''); 918 919 if ($schema_version === self::VIEW_IMPORT_SCHEMA_VERSION) { 920 return; 921 } 922 923 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 924 925 $collate = $wpdb->get_charset_collate(); 926 927 dbDelta("CREATE TABLE {$this->view_imports_table} ( 928 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 929 source VARCHAR(50) NOT NULL, 930 product_id BIGINT(20) UNSIGNED NOT NULL, 931 imported_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 932 imported_at DATETIME NOT NULL, 933 PRIMARY KEY (id), 934 UNIQUE KEY source_product (source, product_id), 935 KEY imported_at (imported_at) 936 ) $collate;"); 937 938 $table_exists = statpl_db_get_var( 939 statpl_db_prepare( 940 'SHOW TABLES LIKE %s', 941 $wpdb->esc_like($this->view_imports_table) 942 ) 943 ); 944 945 if ($table_exists === $this->view_imports_table && $this->verify_historical_table_columns($this->view_imports_table, ['id', 'source', 'product_id', 'imported_views', 'imported_at'])) { 946 if (!$this->persist_schema_version_marker('statpl_view_import_schema_version', self::VIEW_IMPORT_SCHEMA_VERSION)) { return false; } 947 } 948 } 949 private function maybe_upgrade_view_import_state_schema() { 950 global $wpdb; 951 952 $schema_version = get_option('statpl_view_import_state_schema_version', ''); 953 954 if ($schema_version === self::VIEW_IMPORT_STATE_SCHEMA_VERSION) { 955 return; 956 } 957 958 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 959 960 $collate = $wpdb->get_charset_collate(); 961 962 dbDelta("CREATE TABLE {$this->view_import_state_table} ( 963 source VARCHAR(50) NOT NULL, 964 snapshot_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 965 snapshot_active_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 966 snapshot_archive_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 967 last_processed_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 968 status VARCHAR(20) NOT NULL DEFAULT 'pending', 969 updated_at DATETIME NOT NULL, 970 PRIMARY KEY (source) 971 ) $collate;"); 972 973 $table_exists = statpl_db_get_var( 974 statpl_db_prepare( 975 'SHOW TABLES LIKE %s', 976 $wpdb->esc_like($this->view_import_state_table) 977 ) 978 ); 979 980 if ($table_exists === $this->view_import_state_table && $this->verify_historical_table_columns($this->view_import_state_table, ['source', 'snapshot_max_id', 'snapshot_active_max_id', 'snapshot_archive_max_id', 'last_processed_id', 'status', 'updated_at'])) { 981 if (!$this->persist_schema_version_marker('statpl_view_import_state_schema_version', self::VIEW_IMPORT_STATE_SCHEMA_VERSION)) { return false; } 982 } 983 } 984 985 private function maybe_upgrade_view_import_events_schema() { 986 global $wpdb; 987 988 $schema_version = get_option('statpl_view_import_events_schema_version', ''); 989 990 if ($schema_version === self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION) { 991 return; 992 } 993 994 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 995 996 $collate = $wpdb->get_charset_collate(); 997 998 dbDelta("CREATE TABLE {$this->view_import_events_table} ( 999 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1000 source VARCHAR(50) NOT NULL, 1001 source_view_id BIGINT(20) UNSIGNED NOT NULL, 1002 product_id BIGINT(20) UNSIGNED NOT NULL, 1003 imported_at DATETIME NOT NULL, 1004 counts_applied TINYINT(1) UNSIGNED NOT NULL DEFAULT 1, 1005 PRIMARY KEY (id), 1006 UNIQUE KEY source_view (source, source_view_id), 1007 KEY product_id (product_id) 1008 ) $collate;"); 1009 1010 $table_exists = statpl_db_get_var( 1011 statpl_db_prepare( 1012 'SHOW TABLES LIKE %s', 1013 $wpdb->esc_like($this->view_import_events_table) 1014 ) 1015 ); 1016 1017 $column_exists = false; 1018 1019 if ($table_exists === $this->view_import_events_table) { 1020 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The table identifier is built from the WordPress prefix and a fixed plugin suffix. 1021 $column_exists = statpl_db_get_var( 1022 statpl_db_prepare( 1023 "SHOW COLUMNS FROM {$this->view_import_events_table} LIKE %s", 1024 'counts_applied' 1025 ) 1026 ); 1027 } 1028 1029 if ( 1030 $table_exists === $this->view_import_events_table 1031 && 'counts_applied' === $column_exists 1032 && $this->verify_historical_table_columns($this->view_import_events_table, ['id', 'source', 'source_view_id', 'product_id', 'imported_at', 'counts_applied']) 1033 ) { 1034 if (!$this->persist_schema_version_marker('statpl_view_import_events_schema_version', self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION)) { return false; } 1035 } 1036 } 1037 1038 private function maybe_upgrade_view_import_failures_schema() { 1039 global $wpdb; 1040 1041 $schema_version = get_option('statpl_view_import_failures_schema_version', ''); 1042 1043 if ($schema_version === self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION) { 1044 return; 1045 } 1046 1047 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 1048 1049 $collate = $wpdb->get_charset_collate(); 1050 1051 dbDelta("CREATE TABLE {$this->view_import_failures_table} ( 1052 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1053 source VARCHAR(50) NOT NULL, 1054 source_view_id BIGINT(20) UNSIGNED NOT NULL, 1055 product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 1056 attempts INT(10) UNSIGNED NOT NULL DEFAULT 0, 1057 last_error TEXT NOT NULL, 1058 status VARCHAR(20) NOT NULL DEFAULT 'retry', 1059 updated_at DATETIME NOT NULL, 1060 PRIMARY KEY (id), 1061 UNIQUE KEY source_view (source, source_view_id), 1062 KEY status (status) 1063 ) $collate;"); 1064 1065 $table_exists = statpl_db_get_var( 1066 statpl_db_prepare( 1067 'SHOW TABLES LIKE %s', 1068 $wpdb->esc_like($this->view_import_failures_table) 1069 ) 1070 ); 1071 1072 if ($table_exists === $this->view_import_failures_table && $this->verify_historical_table_columns($this->view_import_failures_table, ['id', 'source', 'source_view_id', 'product_id', 'attempts', 'last_error', 'status', 'updated_at'])) { 1073 if (!$this->persist_schema_version_marker('statpl_view_import_failures_schema_version', self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION)) { return false; } 1074 } 1075 } 1076 1077 private function maybe_upgrade_view_source_schema() { 1078 global $wpdb; 1079 1080 $schema_version = self::VIEW_SOURCE_SCHEMA_VERSION; 1081 if (get_option('statpl_view_source_schema_version', '') === $schema_version) { 1082 return true; 1083 } 1084 1085 $collate = $wpdb->get_charset_collate(); 1086 1087 $tables = [ 1088 $this->view_sources_table, 1089 $this->view_source_origins_table, 1090 $this->view_source_state_table, 1091 $this->view_source_events_table, 1092 $this->view_source_failures_table, 1093 $this->view_source_reconciliation_table, 1094 $this->view_source_reconciliation_history_table, 1095 ]; 1096 1097 $definitions = [ 1098 $this->view_sources_table => [ 1099 'columns' => [ 1100 'id' => ['bigint(20) unsigned', 'NO', null, true], 1101 'source_key' => ['varchar(100)', 'NO', null, false], 1102 'adapter_key' => ['varchar(100)', 'YES', null, false], 1103 'display_name' => ['varchar(191)', 'NO', null, false], 1104 'adapter_version' => ['varchar(32)', 'NO', null, false], 1105 'granularity' => ['varchar(20)', 'NO', null, false], 1106 'status' => ['varchar(20)', 'NO', 'detected', false], 1107 'created_at' => ['datetime', 'NO', null, false], 1108 'updated_at' => ['datetime', 'NO', null, false], 1109 ], 1110 'indexes' => [ 1111 'PRIMARY' => [true, ['id']], 1112 'source_key' => [true, ['source_key']], 1113 'status' => [false, ['status']], 1114 ], 1115 ], 1116 $this->view_source_origins_table => [ 1117 'columns' => [ 1118 'source_id' => ['bigint(20) unsigned', 'NO', null, false], 1119 'origin_key' => ['varchar(100)', 'NO', null, false], 1120 'adapter_key' => ['varchar(100)', 'NO', null, false], 1121 'capabilities' => ['longtext', 'YES', null, false], 1122 'snapshot_data' => ['longtext', 'YES', null, false], 1123 'checkpoint_data' => ['longtext', 'YES', null, false], 1124 'detection_status' => ['varchar(20)', 'NO', 'unknown', false], 1125 'status' => ['varchar(20)', 'NO', 'pending', false], 1126 'last_error' => ['text', 'YES', null, false], 1127 'started_at' => ['datetime', 'YES', null, false], 1128 'completed_at' => ['datetime', 'YES', null, false], 1129 'updated_at' => ['datetime', 'NO', null, false], 1130 ], 1131 'indexes' => [ 1132 'PRIMARY' => [true, ['source_id', 'origin_key']], 1133 'status' => [false, ['status']], 1134 ], 1135 ], 1136 $this->view_source_state_table => [ 1137 'columns' => [ 1138 'source_id' => ['bigint(20) unsigned', 'NO', null, false], 1139 'snapshot_data' => ['longtext', 'YES', null, false], 1140 'checkpoint_data' => ['longtext', 'YES', null, false], 1141 'status' => ['varchar(20)', 'NO', 'pending', false], 1142 'last_error' => ['text', 'YES', null, false], 1143 'started_at' => ['datetime', 'YES', null, false], 1144 'completed_at' => ['datetime', 'YES', null, false], 1145 'updated_at' => ['datetime', 'NO', null, false], 1146 ], 1147 'indexes' => [ 1148 'PRIMARY' => [true, ['source_id']], 1149 'status' => [false, ['status']], 1150 ], 1151 ], 1152 $this->view_source_events_table => [ 1153 'columns' => [ 1154 'id' => ['bigint(20) unsigned', 'NO', null, true], 1155 'source_id' => ['bigint(20) unsigned', 'NO', null, false], 1156 'source_record_key' => ['varchar(191)', 'NO', null, false], 1157 'source_record_hash' => ['char(64)', 'NO', null, false], 1158 'product_id' => ['bigint(20) unsigned', 'NO', null, false], 1159 'record_type' => ['varchar(20)', 'NO', 'event', false], 1160 'period_start' => ['datetime', 'YES', null, false], 1161 'period_end' => ['datetime', 'YES', null, false], 1162 'observed_views' => ['bigint(20) unsigned', 'NO', '0', false], 1163 'applied_views' => ['bigint(20) unsigned', 'NO', '0', false], 1164 'source_fingerprint' => ['char(64)', 'YES', null, false], 1165 'status' => ['varchar(20)', 'NO', 'pending', false], 1166 'created_at' => ['datetime', 'NO', null, false], 1167 'updated_at' => ['datetime', 'NO', null, false], 1168 ], 1169 'indexes' => [ 1170 'PRIMARY' => [true, ['id']], 1171 'source_record' => [true, ['source_id', 'source_record_hash']], 1172 'product_id' => [false, ['product_id']], 1173 'source_product' => [false, ['source_id', 'product_id']], 1174 'source_fingerprint' => [false, ['source_fingerprint']], 1175 'product_record_period' => [false, ['product_id', 'record_type', 'period_start', 'period_end']], 1176 'status' => [false, ['status']], 1177 ], 1178 ], 1179 $this->view_source_reconciliation_table => [ 1180 'columns' => [ 1181 'id' => ['bigint(20) unsigned', 'NO', null, true], 1182 'event_id' => ['bigint(20) unsigned', 'NO', null, false], 1183 'status' => ['varchar(30)', 'NO', 'unprocessed', false], 1184 'reason_code' => ['varchar(100)', 'NO', null, false], 1185 'reason' => ['text', 'YES', null, false], 1186 'matched_event_id' => ['bigint(20) unsigned', 'YES', null, false], 1187 'evidence' => ['longtext', 'YES', null, false], 1188 'algorithm_version' => ['varchar(32)', 'NO', null, false], 1189 'created_at' => ['datetime', 'NO', null, false], 1190 'updated_at' => ['datetime', 'NO', null, false], 1191 ], 1192 'indexes' => [ 1193 'PRIMARY' => [true, ['id']], 1194 'event_id' => [true, ['event_id']], 1195 'status' => [false, ['status']], 1196 'matched_event_id' => [false, ['matched_event_id']], 1197 ], 1198 ], 1199 $this->view_source_reconciliation_history_table => [ 1200 'columns' => [ 1201 'id' => ['bigint(20) unsigned', 'NO', null, true], 1202 'event_id' => ['bigint(20) unsigned', 'NO', null, false], 1203 'previous_status' => ['varchar(30)', 'YES', null, false], 1204 'new_status' => ['varchar(30)', 'NO', null, false], 1205 'reason_code' => ['varchar(100)', 'NO', null, false], 1206 'reason' => ['text', 'YES', null, false], 1207 'matched_event_id' => ['bigint(20) unsigned', 'YES', null, false], 1208 'evidence' => ['longtext', 'YES', null, false], 1209 'algorithm_version' => ['varchar(32)', 'NO', null, false], 1210 'created_at' => ['datetime', 'NO', null, false], 1211 ], 1212 'indexes' => [ 1213 'PRIMARY' => [true, ['id']], 1214 'event_id' => [false, ['event_id']], 1215 'created_at' => [false, ['created_at']], 1216 ], 1217 ], 1218 $this->view_source_failures_table => [ 1219 'columns' => [ 1220 'id' => ['bigint(20) unsigned', 'NO', null, true], 1221 'source_id' => ['bigint(20) unsigned', 'NO', null, false], 1222 'origin_key' => ['varchar(100)', 'NO', null, false], 1223 'source_record_key' => ['varchar(191)', 'NO', null, false], 1224 'source_record_hash' => ['char(64)', 'NO', null, false], 1225 'product_id' => ['bigint(20) unsigned', 'NO', '0', false], 1226 'attempts' => ['int(10) unsigned', 'NO', '0', false], 1227 'last_error' => ['text', 'NO', null, false], 1228 'status' => ['varchar(20)', 'NO', 'retry', false], 1229 'next_retry_at' => ['datetime', 'YES', null, false], 1230 'retry_checkpoint' => ['longtext', 'YES', null, false], 1231 'updated_at' => ['datetime', 'NO', null, false], 1232 ], 1233 'indexes' => [ 1234 'PRIMARY' => [true, ['id']], 1235 'source_record' => [true, ['source_id', 'source_record_hash']], 1236 'status_retry' => [false, ['status', 'next_retry_at']], 1237 'source_origin' => [false, ['source_id', 'origin_key']], 1238 'product_id' => [false, ['product_id']], 1239 ], 1240 ], 1241 ]; 1242 1243 $sql = [ 1244 "CREATE TABLE {$this->view_sources_table} ( 1245 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1246 source_key VARCHAR(100) NOT NULL, 1247 adapter_key VARCHAR(100) NULL DEFAULT NULL, 1248 display_name VARCHAR(191) NOT NULL, 1249 adapter_version VARCHAR(32) NOT NULL, 1250 granularity VARCHAR(20) NOT NULL, 1251 status VARCHAR(20) NOT NULL DEFAULT 'detected', 1252 created_at DATETIME NOT NULL, 1253 updated_at DATETIME NOT NULL, 1254 PRIMARY KEY (id), 1255 UNIQUE KEY source_key (source_key), 1256 KEY status (status) 1257 ) ENGINE=InnoDB $collate;", 1258 "CREATE TABLE {$this->view_source_origins_table} ( 1259 source_id BIGINT(20) UNSIGNED NOT NULL, 1260 origin_key VARCHAR(100) NOT NULL, 1261 adapter_key VARCHAR(100) NOT NULL, 1262 capabilities LONGTEXT NULL DEFAULT NULL, 1263 snapshot_data LONGTEXT NULL DEFAULT NULL, 1264 checkpoint_data LONGTEXT NULL DEFAULT NULL, 1265 detection_status VARCHAR(20) NOT NULL DEFAULT 'unknown', 1266 status VARCHAR(20) NOT NULL DEFAULT 'pending', 1267 last_error TEXT NULL DEFAULT NULL, 1268 started_at DATETIME NULL DEFAULT NULL, 1269 completed_at DATETIME NULL DEFAULT NULL, 1270 updated_at DATETIME NOT NULL, 1271 PRIMARY KEY (source_id, origin_key), 1272 KEY status (status) 1273 ) ENGINE=InnoDB $collate;", 1274 "CREATE TABLE {$this->view_source_state_table} ( 1275 source_id BIGINT(20) UNSIGNED NOT NULL, 1276 snapshot_data LONGTEXT NULL DEFAULT NULL, 1277 checkpoint_data LONGTEXT NULL DEFAULT NULL, 1278 status VARCHAR(20) NOT NULL DEFAULT 'pending', 1279 last_error TEXT NULL DEFAULT NULL, 1280 started_at DATETIME NULL DEFAULT NULL, 1281 completed_at DATETIME NULL DEFAULT NULL, 1282 updated_at DATETIME NOT NULL, 1283 PRIMARY KEY (source_id), 1284 KEY status (status) 1285 ) ENGINE=InnoDB $collate;", 1286 "CREATE TABLE {$this->view_source_events_table} ( 1287 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1288 source_id BIGINT(20) UNSIGNED NOT NULL, 1289 source_record_key VARCHAR(191) NOT NULL, 1290 source_record_hash CHAR(64) NOT NULL, 1291 product_id BIGINT(20) UNSIGNED NOT NULL, 1292 record_type VARCHAR(20) NOT NULL DEFAULT 'event', 1293 period_start DATETIME NULL DEFAULT NULL, 1294 period_end DATETIME NULL DEFAULT NULL, 1295 observed_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 1296 applied_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 1297 source_fingerprint CHAR(64) NULL DEFAULT NULL, 1298 status VARCHAR(20) NOT NULL DEFAULT 'pending', 1299 created_at DATETIME NOT NULL, 1300 updated_at DATETIME NOT NULL, 1301 PRIMARY KEY (id), 1302 UNIQUE KEY source_record (source_id, source_record_hash), 1303 KEY product_id (product_id), 1304 KEY source_product (source_id, product_id), 1305 KEY source_fingerprint (source_fingerprint), 1306 KEY product_record_period (product_id, record_type, period_start, period_end), 1307 KEY status (status) 1308 ) ENGINE=InnoDB $collate;", 1309 "CREATE TABLE {$this->view_source_failures_table} ( 1310 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1311 source_id BIGINT(20) UNSIGNED NOT NULL, 1312 origin_key VARCHAR(100) NOT NULL, 1313 source_record_key VARCHAR(191) NOT NULL, 1314 source_record_hash CHAR(64) NOT NULL, 1315 product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 1316 attempts INT(10) UNSIGNED NOT NULL DEFAULT 0, 1317 last_error TEXT NOT NULL, 1318 status VARCHAR(20) NOT NULL DEFAULT 'retry', 1319 next_retry_at DATETIME NULL DEFAULT NULL, 1320 retry_checkpoint LONGTEXT NULL DEFAULT NULL, 1321 updated_at DATETIME NOT NULL, 1322 PRIMARY KEY (id), 1323 UNIQUE KEY source_record (source_id, source_record_hash), 1324 KEY status_retry (status, next_retry_at), 1325 KEY source_origin (source_id, origin_key), 1326 KEY product_id (product_id) 1327 ) ENGINE=InnoDB $collate;", 1328 "CREATE TABLE {$this->view_source_reconciliation_table} ( 1329 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1330 event_id BIGINT(20) UNSIGNED NOT NULL, 1331 status VARCHAR(30) NOT NULL DEFAULT 'unprocessed', 1332 reason_code VARCHAR(100) NOT NULL, 1333 reason TEXT NULL DEFAULT NULL, 1334 matched_event_id BIGINT(20) UNSIGNED NULL DEFAULT NULL, 1335 evidence LONGTEXT NULL DEFAULT NULL, 1336 algorithm_version VARCHAR(32) NOT NULL, 1337 created_at DATETIME NOT NULL, 1338 updated_at DATETIME NOT NULL, 1339 PRIMARY KEY (id), 1340 UNIQUE KEY event_id (event_id), 1341 KEY status (status), 1342 KEY matched_event_id (matched_event_id) 1343 ) ENGINE=InnoDB $collate;", 1344 "CREATE TABLE {$this->view_source_reconciliation_history_table} ( 1345 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1346 event_id BIGINT(20) UNSIGNED NOT NULL, 1347 previous_status VARCHAR(30) NULL DEFAULT NULL, 1348 new_status VARCHAR(30) NOT NULL, 1349 reason_code VARCHAR(100) NOT NULL, 1350 reason TEXT NULL DEFAULT NULL, 1351 matched_event_id BIGINT(20) UNSIGNED NULL DEFAULT NULL, 1352 evidence LONGTEXT NULL DEFAULT NULL, 1353 algorithm_version VARCHAR(32) NOT NULL, 1354 created_at DATETIME NOT NULL, 1355 PRIMARY KEY (id), 1356 KEY event_id (event_id), 1357 KEY created_at (created_at) 1358 ) ENGINE=InnoDB $collate;", 1359 ]; 1360 1361 $fail = static function ($message) { 1362 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Preserve an actionable migration failure in the PHP error log when schema setup cannot complete. 1363 error_log('[Statora Plus] View-source schema migration failed: ' . $message); 1364 return false; 1365 }; 1366 1367 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 1368 1369 try { 1370 foreach ($sql as $statement) { 1371 dbDelta($statement); 1372 } 1373 } catch (Throwable $e) { 1374 return $fail($e->getMessage()); 1375 } 1376 1377 // Convert legacy detection values out of the processing-status column. 1378 // Existing processing states are preserved; only legacy detection labels are migrated. 1379 $legacy_status_migration = statpl_db_query( 1380 "UPDATE {$this->view_source_origins_table} 1381 SET detection_status = CASE 1382 WHEN status IN ('available', 'missing_table', 'incompatible_schema', 'database_error') 1383 THEN status 1384 ELSE 'unknown' 1385 END, 1386 status = 'pending' 1387 WHERE status IN ('detected', 'available', 'missing_table', 'incompatible_schema', 'database_error')" 1388 ); 1389 1390 if ($legacy_status_migration === false || $wpdb->last_error !== '') { 1391 return $fail('Could not migrate legacy origin detection statuses: ' . $wpdb->last_error); 1392 } 1393 1394 foreach ($tables as $table) { 1395 // The migration metadata is the allowlist for identifiers used by the 1396 // SHOW COLUMNS / SHOW INDEX statements below. Reject anything outside it. 1397 if ( 1398 !is_string($table) 1399 || !array_key_exists($table, $definitions) 1400 || !preg_match('/^[A-Za-z0-9_]+$/D', $table) 1401 ) { 1402 return $fail('Invalid internal table identifier during schema verification.'); 1403 } 1404 1405 $found_table = statpl_db_get_var( 1406 statpl_db_prepare( 1407 'SHOW TABLES LIKE %s', 1408 $wpdb->esc_like($table) 1409 ) 1410 ); 1411 1412 if ($wpdb->last_error !== '') { 1413 return $fail('Could not inspect table existence for ' . $table . ': ' . $wpdb->last_error); 1414 } 1415 1416 if ($found_table !== $table) { 1417 return $fail('Required table was not created: ' . $table); 1418 } 1419 1420 $table_status = statpl_db_get_row( 1421 statpl_db_prepare( 1422 'SHOW TABLE STATUS LIKE %s', 1423 $wpdb->esc_like($table) 1424 ), 1425 ARRAY_A 1426 ); 1427 1428 if ($wpdb->last_error !== '') { 1429 return $fail('Could not inspect table engine for ' . $table . ': ' . $wpdb->last_error); 1430 } 1431 1432 if (!$table_status || !isset($table_status['Engine']) || strcasecmp($table_status['Engine'], 'InnoDB') !== 0) { 1433 return $fail('Table engine is not InnoDB: ' . $table); 1434 } 1435 1436 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Identifier is built from the trusted WordPress prefix and fixed plugin suffix. 1437 $column_rows = statpl_db_get_results("SHOW COLUMNS FROM `{$table}`", ARRAY_A); 1438 1439 if ($wpdb->last_error !== '') { 1440 return $fail('Could not inspect columns for ' . $table . ': ' . $wpdb->last_error); 1441 } 1442 1443 if (!is_array($column_rows)) { 1444 return $fail('Invalid column metadata for ' . $table); 1445 } 1446 1447 $actual_columns = []; 1448 foreach ($column_rows as $column) { 1449 $actual_columns[$column['Field']] = $column; 1450 } 1451 1452 $expected_columns = $definitions[$table]['columns']; 1453 1454 if (count($actual_columns) !== count($expected_columns)) { 1455 return $fail('Unexpected column count in ' . $table); 1456 } 1457 1458 foreach ($expected_columns as $name => $expected) { 1459 if (!isset($actual_columns[$name])) { 1460 return $fail('Missing column ' . $table . '.' . $name); 1461 } 1462 1463 $actual = $actual_columns[$name]; 1464 $actual_type = strtolower((string) $actual['Type']); 1465 $actual_null = strtoupper((string) $actual['Null']); 1466 $actual_default = $actual['Default']; 1467 $actual_extra = strtolower(trim((string) $actual['Extra'])); 1468 $actual_extra = preg_replace('/\\s+/', ' ', $actual_extra); 1469 $expected_extra = $expected[3] ? 'auto_increment' : ''; 1470 1471 if ($actual_type !== $expected[0]) { 1472 return $fail('Wrong type for ' . $table . '.' . $name . ': ' . $actual_type); 1473 } 1474 1475 if ($actual_null !== $expected[1]) { 1476 return $fail('Wrong NULL definition for ' . $table . '.' . $name); 1477 } 1478 1479 if ($expected[2] === null) { 1480 if ($actual_default !== null) { 1481 return $fail('Unexpected default for ' . $table . '.' . $name); 1482 } 1483 } elseif ((string) $actual_default !== $expected[2]) { 1484 return $fail('Wrong default for ' . $table . '.' . $name); 1485 } 1486 1487 if ($actual_extra !== $expected_extra) { 1488 return $fail('Wrong Extra definition for ' . $table . '.' . $name . ': ' . $actual_extra); 1489 } 1490 } 1491 1492 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Identifier is built from the trusted WordPress prefix and fixed plugin suffix. 1493 $index_rows = statpl_db_get_results("SHOW INDEX FROM `{$table}`", ARRAY_A); 1494 1495 if ($wpdb->last_error !== '') { 1496 return $fail('Could not inspect indexes for ' . $table . ': ' . $wpdb->last_error); 1497 } 1498 1499 if (!is_array($index_rows)) { 1500 return $fail('Invalid index metadata for ' . $table); 1501 } 1502 1503 $actual_indexes = []; 1504 foreach ($index_rows as $index) { 1505 $key_name = $index['Key_name']; 1506 1507 if (!isset($actual_indexes[$key_name])) { 1508 $actual_indexes[$key_name] = [ 1509 'unique' => ((int) $index['Non_unique'] === 0), 1510 'columns' => [], 1511 ]; 1512 } elseif ($actual_indexes[$key_name]['unique'] !== ((int) $index['Non_unique'] === 0)) { 1513 return $fail('Inconsistent uniqueness metadata for index ' . $table . '.' . $key_name); 1514 } 1515 1516 $sequence = (string) $index['Seq_in_index']; 1517 if (!preg_match('/^[1-9][0-9]*$/D', $sequence)) { 1518 return $fail('Invalid Seq_in_index for ' . $table . '.' . $key_name); 1519 } 1520 1521 $sequence = (int) $sequence; 1522 if (isset($actual_indexes[$key_name]['columns'][$sequence])) { 1523 return $fail('Duplicate Seq_in_index for ' . $table . '.' . $key_name); 1524 } 1525 1526 if (!array_key_exists('Sub_part', $index) || $index['Sub_part'] !== null) { 1527 return $fail('Unexpected index prefix length for ' . $table . '.' . $key_name); 1528 } 1529 1530 $actual_indexes[$key_name]['columns'][$sequence] = $index['Column_name']; 1531 } 1532 1533 foreach ($actual_indexes as $key_name => &$index_data) { 1534 ksort($index_data['columns']); 1535 $positions = array_keys($index_data['columns']); 1536 1537 if ($positions !== range(1, count($positions))) { 1538 return $fail('Non-contiguous Seq_in_index for ' . $table . '.' . $key_name); 1539 } 1540 1541 $index_data['columns'] = array_values($index_data['columns']); 1542 } 1543 unset($index_data); 1544 1545 $expected_indexes = $definitions[$table]['indexes']; 1546 1547 if (count($actual_indexes) !== count($expected_indexes)) { 1548 return $fail('Unexpected index count in ' . $table); 1549 } 1550 1551 foreach ($expected_indexes as $name => $expected) { 1552 if (!isset($actual_indexes[$name])) { 1553 return $fail('Missing index ' . $table . '.' . $name); 1554 } 1555 1556 if ( 1557 $actual_indexes[$name]['unique'] !== $expected[0] 1558 || $actual_indexes[$name]['columns'] !== $expected[1] 1559 ) { 1560 return $fail('Incorrect index definition for ' . $table . '.' . $name); 1561 } 1562 } 1563 } 1564 1565 update_option('statpl_view_source_schema_version', $schema_version, false); 1566 1567 if (get_option('statpl_view_source_schema_version', '') !== $schema_version) { 1568 return $fail('Could not persist the verified view-source schema version.'); 1569 } 1570 1571 return true; 1572 } 1573 1574 /** 1575 * Reclassify legacy aggregate matches that relied on the synthetic 1576 * product/date/count fingerprint. This is audit-only: totals and 1577 * applied_views are deliberately not modified because the old records 1578 * cannot prove whether the underlying views were actually duplicated. 1579 */ 1580 private function migrate_legacy_aggregate_fingerprint_matches() { 1581 global $wpdb; 1582 1583 $complete_option = 'statpl_legacy_aggregate_fp_migration_v1_complete'; 1584 $cursor_option = 'statpl_legacy_aggregate_fp_migration_v1_cursor'; 1585 if (get_option($complete_option, '') === '1') { 1586 return true; 1587 } 1588 1589 $lock_name = 'statpl_legacy_agg_' . substr(hash('sha256', $wpdb->prefix), 0, 40); 1590 $migration_lock = $this->acquire_generic_advisory_lock($lock_name); 1591 if (empty($migration_lock['acquired'])) { 1592 $this->schedule_legacy_aggregate_reconciliation_migration(60); 1593 return false; 1594 } 1595 1596 $batch_size = 100; 1597 $cursor = max(0, (int) get_option($cursor_option, 0)); 1598 $processed = 0; 1599 $failed = false; 1600 $migration_transaction_started = false; 1601 $migration_transaction_committed = false; 1602 1603 try { 1604 $rows = statpl_db_get_results(statpl_db_prepare( 1605 "SELECT r.event_id, r.status, r.reason_code, r.reason, r.matched_event_id, 1606 r.evidence, r.algorithm_version, 1607 e.source_id, e.product_id, e.record_type, e.observed_views, 1608 e.period_start, e.period_end, e.source_fingerprint, 1609 m.source_id AS matched_source_id, 1610 m.record_type AS matched_record_type, 1611 m.source_fingerprint AS matched_source_fingerprint 1612 FROM {$this->view_source_reconciliation_table} r 1613 INNER JOIN {$this->view_source_events_table} e ON e.id = r.event_id 1614 INNER JOIN {$this->view_source_events_table} m ON m.id = r.matched_event_id 1615 WHERE r.event_id > %d 1616 AND r.status = 'matched_exact' 1617 AND r.reason_code = 'shared_fingerprint_exact_match' 1618 AND e.record_type = 'aggregate' 1619 AND m.record_type = 'aggregate' 1620 AND e.source_id <> m.source_id 1621 ORDER BY r.event_id ASC 1622 LIMIT %d", 1623 $cursor, 1624 $batch_size 1625 ), ARRAY_A); 1626 1627 if (!is_array($rows)) { 1628 $failed = true; 1629 return false; 1630 } 1631 1632 foreach ($rows as $row) { 1633 $event_id = (int) $row['event_id']; 1634 $expected_fingerprint = ''; 1635 if ($row['period_start'] !== null && $row['period_end'] !== null) { 1636 $expected_fingerprint = hash( 1637 'sha256', 1638 "statpl-product-day-aggregate-v1\0" 1639 . (int) $row['product_id'] . "\0" 1640 . (string) $row['period_start'] . "\0" 1641 . (string) $row['period_end'] . "\0" 1642 . (int) $row['observed_views'] 1643 ); 1644 } 1645 1646 $is_legacy_synthetic_match = $expected_fingerprint !== '' 1647 && is_string($row['source_fingerprint']) 1648 && is_string($row['matched_source_fingerprint']) 1649 && hash_equals($expected_fingerprint, strtolower($row['source_fingerprint'])) 1650 && hash_equals($expected_fingerprint, strtolower($row['matched_source_fingerprint'])); 1651 1652 if ($is_legacy_synthetic_match) { 1653 $evidence = json_decode((string) $row['evidence'], true); 1654 if (!is_array($evidence)) { 1655 $evidence = ['legacy_evidence_raw' => (string) $row['evidence']]; 1656 } 1657 $evidence['legacy_reclassification'] = [ 1658 'migration' => 'legacy_aggregate_fingerprint_v1', 1659 'previous_status' => 'matched_exact', 1660 'new_status' => 'legacy_exact_unverified', 1661 'reason' => 'The previous synthetic fingerprint represented product, date and count, not a shared view-event identity. The old exact-match decision is therefore unverified.', 1662 'totals_changed' => false, 1663 'applied_views_changed' => false, 1664 ]; 1665 $encoded_evidence = wp_json_encode($evidence); 1666 if (!is_string($encoded_evidence)) { 1667 $failed = true; 1668 return false; 1669 } 1670 1671 if (!$this->generic_advisory_lock_session_is_current($migration_lock)) { 1672 $failed = true; 1673 return false; 1674 } 1675 1676 $migration_transaction_started = false; 1677 $migration_transaction_committed = false; 1678 $transaction_started = (statpl_db_query('START TRANSACTION') !== false); 1679 if (!$transaction_started) { 1680 $failed = true; 1681 return false; 1682 } 1683 1684 $migration_transaction_started = true; 1685 if (!$this->generic_advisory_lock_session_is_current($migration_lock)) { 1686 // Lock ownership and connection identity are separate checks. 1687 // Roll back only when this is still the original DB session. 1688 if ($this->generic_db_session_matches_lock($migration_lock)) { 1689 statpl_db_query('ROLLBACK'); 1690 $migration_transaction_started = false; 1691 } 1692 $failed = true; 1693 return false; 1694 } 1695 $updated = statpl_db_update( 1696 $this->view_source_reconciliation_table, 1697 [ 1698 'status' => 'legacy_exact_unverified', 1699 'reason_code' => 'legacy_aggregate_fingerprint_unverified', 1700 'reason' => 'Legacy exact match based on product/date/count only; actual shared view identity was not established. Totals were intentionally left unchanged pending a controlled reconciliation decision.', 1701 'evidence' => $encoded_evidence, 1702 'algorithm_version' => '1.2.0', 1703 'updated_at' => current_time('mysql'), 1704 ], 1705 ['event_id' => $event_id, 'status' => 'matched_exact'] 1706 ); 1707 $history = $updated === 1 ? statpl_db_insert( 1708 $this->view_source_reconciliation_history_table, 1709 [ 1710 'event_id' => $event_id, 1711 'previous_status' => 'matched_exact', 1712 'new_status' => 'legacy_exact_unverified', 1713 'reason_code' => 'legacy_aggregate_fingerprint_unverified', 1714 'reason' => 'Reclassified because the legacy aggregate fingerprint did not prove shared view-event identity. No totals or applied_views were changed.', 1715 'matched_event_id' => (int) $row['matched_event_id'], 1716 'evidence' => $encoded_evidence, 1717 'algorithm_version' => '1.2.0', 1718 'created_at' => current_time('mysql'), 1719 ] 1720 ) : false; 1721 1722 if ($updated !== 1 || $history !== 1 1723 || !$this->generic_advisory_lock_session_is_current($migration_lock) 1724 || statpl_db_query('COMMIT') === false) { 1725 // Roll back only if the original lock-owning session is still current. 1726 // A reconnect implicitly abandons the old transaction; never issue 1727 // transaction control against an unverified replacement session. 1728 if ($migration_transaction_started && $this->generic_db_session_matches_lock($migration_lock)) { 1729 statpl_db_query('ROLLBACK'); 1730 $migration_transaction_started = false; 1731 } 1732 $failed = true; 1733 return false; 1734 } 1735 $migration_transaction_committed = true; 1736 $migration_transaction_started = false; 1737 } 1738 1739 $cursor = $event_id; 1740 // Persist the cursor only while this exact MySQL session still owns 1741 // the migration lock. A PHP-level check followed by update_option() 1742 // would leave a reconnect race between the check and the write. 1743 $cursor_persisted = statpl_db_query(statpl_db_prepare( 1744 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) 1745 SELECT %s, %s, 'no' 1746 WHERE IS_USED_LOCK(%s) = CONNECTION_ID() 1747 ON DUPLICATE KEY UPDATE option_value = IF( 1748 IS_USED_LOCK(%s) = CONNECTION_ID(), 1749 VALUES(option_value), 1750 option_value 1751 )", 1752 $cursor_option, 1753 (string) $cursor, 1754 $lock_name, 1755 $lock_name 1756 )); 1757 if (!in_array($cursor_persisted, [0, 1, 2], true) 1758 || !$this->generic_advisory_lock_session_is_current($migration_lock)) { 1759 $failed = true; 1760 return false; 1761 } 1762 $persisted_cursor = statpl_db_get_var(statpl_db_prepare( 1763 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 1764 $cursor_option 1765 )); 1766 if (!empty($wpdb->last_error) || (int) $persisted_cursor !== $cursor) { 1767 $failed = true; 1768 return false; 1769 } 1770 $processed++; 1771 } 1772 1773 if (count($rows) < $batch_size) { 1774 // Completion is also lock-owner guarded so a stale/reconnected 1775 // worker cannot mark an unfinished migration complete. 1776 $complete_persisted = statpl_db_query(statpl_db_prepare( 1777 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) 1778 SELECT %s, '1', 'no' 1779 WHERE IS_USED_LOCK(%s) = CONNECTION_ID() 1780 ON DUPLICATE KEY UPDATE option_value = IF( 1781 IS_USED_LOCK(%s) = CONNECTION_ID(), '1', option_value 1782 )", 1783 $complete_option, 1784 $lock_name, 1785 $lock_name 1786 )); 1787 $persisted_completion = statpl_db_get_var(statpl_db_prepare( 1788 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 1789 $complete_option 1790 )); 1791 $completion_verified = in_array($complete_persisted, [0, 1, 2], true) 1792 && $this->generic_advisory_lock_session_is_current($migration_lock) 1793 && empty($wpdb->last_error) 1794 && (string) $persisted_completion === '1'; 1795 if (!$completion_verified) { 1796 $failed = true; 1797 } 1798 return $completion_verified; 1799 } 1800 1801 $this->schedule_legacy_aggregate_reconciliation_migration(30); 1802 return true; 1803 } catch (Throwable $exception) { 1804 if (defined('WP_DEBUG') && WP_DEBUG) { 1805 $this->debug_log('[Statora Plus] Legacy aggregate reconciliation audit migration failed: ' . $exception->getMessage()); 1806 } 1807 $failed = true; 1808 return false; 1809 } finally { 1810 if ($migration_transaction_started && !$migration_transaction_committed 1811 && $this->generic_db_session_matches_lock($migration_lock)) { 1812 statpl_db_query('ROLLBACK'); 1813 $migration_transaction_started = false; 1814 } 1815 $this->release_generic_advisory_lock($migration_lock, 'legacy_aggregate_fingerprint_migration'); 1816 if ($failed) { 1817 $this->schedule_legacy_aggregate_reconciliation_migration(60); 1818 if (defined('WP_DEBUG') && WP_DEBUG) { 1819 $this->debug_log('[Statora Plus] Legacy aggregate reconciliation audit migration was rescheduled after a failed batch.'); 1820 } 1821 } 1822 } 1823 } 1824 1825 /** 1826 * Schedule the audit migration outside the normal frontend initialization path. 1827 * Action Scheduler is preferred; WP-Cron remains the compatibility fallback. 1828 */ 1829 public function schedule_legacy_aggregate_reconciliation_migration($delay = 15) { 1830 if (get_option('statpl_legacy_aggregate_fp_migration_v1_complete', '') === '1') { 1831 return; 1832 } 1833 1834 $delay = max(5, (int) $delay); 1835 $hook = 'statpl_reconcile_legacy_aggregate_fingerprints'; 1836 $args = []; 1837 $group = 'statpl-plus'; 1838 1839 if (function_exists('as_schedule_single_action') && !did_action('action_scheduler_init')) { 1840 add_action('action_scheduler_init', [$this, 'schedule_legacy_aggregate_reconciliation_migration'], 10, 0); 1841 return; 1842 } 1843 1844 if ( 1845 function_exists('as_has_scheduled_action') 1846 && did_action('action_scheduler_init') 1847 && as_has_scheduled_action($hook, $args, $group) 1848 ) { 1849 return; 1850 } 1851 1852 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 1853 $action_id = as_schedule_single_action(time() + $delay, $hook, $args, $group); 1854 if ($action_id) { 1855 return; 1856 } 1857 } 1858 1859 if (!wp_next_scheduled($hook, $args)) { 1860 wp_schedule_single_event(time() + $delay, $hook, $args); 1861 } 1862 } 1863 1864 public function init_legacy_aggregate_reconciliation_migration() { 1865 $this->migrate_legacy_aggregate_fingerprint_matches(); 1866 } 1867 1868 public function init() { 1869 // Register runtime hooks before touching the database. If MySQL is 1870 // temporarily unavailable during plugins_loaded, frontend/AJAX and 1871 // WooCommerce hooks must still exist for the remainder of this request. 110 1872 add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend']); 111 1873 add_action('wp_ajax_statpl_record_view', [$this, 'record_view']); … … 113 1875 add_action('admin_menu', [$this, 'admin_menu']); 114 1876 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_styles']); 1877 add_action('admin_enqueue_scripts', [$this, 'enqueue_deactivation_prompt']); 115 1878 add_action('admin_notices', [$this, 'woocommerce_notice']); 1879 add_action('admin_footer', [$this, 'enqueue_historical_notice_script']); 116 1880 add_action('woocommerce_order_status_cancelled', [$this, 'record_cancel_event'], 10, 2); 117 1881 add_action('woocommerce_order_refunded', [$this, 'record_refund_event'], 10, 2); 118 // Invalidate cached aggregates when order data or status changes.119 1882 foreach (['woocommerce_new_order', 'woocommerce_update_order', 'woocommerce_order_status_changed', 'woocommerce_order_payment_status_changed', 'woocommerce_saved_order_items'] as $hook) { 120 1883 add_action($hook, [$this, 'invalidate_order_stats_cache'], 10, 3); 121 1884 } 122 1885 add_action('woocommerce_order_refunded', [$this, 'invalidate_order_stats_cache'], 20, 2); 123 // A refund changes net quantity/revenue in the order snapshot as well as refund-event history.124 1886 add_action('woocommerce_order_refunded', [$this, 'queue_order_snapshot'], 30, 2); 125 1887 add_action('woocommerce_before_delete_order', [$this, 'delete_order_snapshot'], 10, 1); … … 128 1890 } 129 1891 add_action('statpl_import_batch', [$this, 'run_import_batch']); 1892 add_action('statpl_view_import_batch', [$this, 'run_slimstat_view_import_batch']); 1893 add_action('statpl_generic_view_import_batch', [$this, 'run_generic_historical_view_import_batch'], 10, 2); 1894 add_action('statpl_generic_view_snapshot_retry', [$this, 'retry_generic_historical_view_snapshot'], 10, 2); 1895 add_action('statpl_generic_view_retry_failed_records', [$this, 'retry_generic_historical_view_failures'], 10, 2); 130 1896 add_action('statpl_sync_order', [$this, 'sync_order_snapshot'], 10, 1); 131 if (!get_option('statpl_import_complete')) { $this->schedule_import_batch(); } 132 } 1897 add_action('statpl_reconcile_legacy_aggregate_fingerprints', [$this, 'init_legacy_aggregate_reconciliation_migration']); 1898 1899 global $wpdb; 1900 // Do not interpret failed option/table reads as missing state. A simple 1901 // round-trip probe distinguishes a usable connection from a DB outage. 1902 try { 1903 $database_ready = isset($wpdb) && is_object($wpdb) 1904 && (string) statpl_db_get_var('SELECT 1') === '1' 1905 && empty($wpdb->last_error); 1906 } catch (Throwable $exception) { 1907 $database_ready = false; 1908 } 1909 if (!$database_ready) { 1910 if (defined('WP_DEBUG') && WP_DEBUG) { 1911 $this->debug_log('[Statora Plus] Database unavailable during initialization; schema upgrades and historical discovery will retry on the next request. Runtime hooks remain registered.'); 1912 } 1913 return; 1914 } 1915 1916 try { 1917 if (get_option('statpl_db_version') !== self::VERSION) { 1918 // Core tables must be verified before historical schema upgrades 1919 // or adapter discovery can run. A failed dbDelta pass must leave 1920 // the version marker unchanged so the next request retries it. 1921 if (!$this->activate()) { 1922 if (defined('WP_DEBUG') && WP_DEBUG) { 1923 $this->debug_log('[Statora Plus] Core schema verification failed; initialization will retry on the next request.'); 1924 } 1925 return; 1926 } 1927 } 1928 $this->maybe_upgrade_view_import_schema(); 1929 $this->maybe_upgrade_view_import_failures_schema(); 1930 $this->maybe_upgrade_view_import_state_schema(); 1931 $this->maybe_upgrade_view_import_events_schema(); 1932 $this->maybe_upgrade_view_source_schema(); 1933 $this->maybe_upgrade_historical_import_report_schema(); 1934 } catch (Throwable $exception) { 1935 if (defined('WP_DEBUG') && WP_DEBUG) { 1936 $this->debug_log('[Statora Plus] Schema initialization interrupted; version markers remain subject to verification and initialization will retry on the next request.'); 1937 } 1938 return; 1939 } 1940 1941 // Schema helpers intentionally leave their version markers unchanged 1942 // when verification fails. Do not let discovery/workers query partially 1943 // initialized tables; the next request will retry the outstanding steps. 1944 $historical_schema_ready = 1945 get_option('statpl_view_import_schema_version', '') === self::VIEW_IMPORT_SCHEMA_VERSION 1946 && get_option('statpl_view_import_failures_schema_version', '') === self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION 1947 && get_option('statpl_view_import_state_schema_version', '') === self::VIEW_IMPORT_STATE_SCHEMA_VERSION 1948 && get_option('statpl_view_import_events_schema_version', '') === self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION 1949 && get_option('statpl_view_source_schema_version', '') === self::VIEW_SOURCE_SCHEMA_VERSION 1950 && get_option('statpl_history_report_schema_version', '') === self::HISTORY_REPORT_SCHEMA_VERSION; 1951 if (!$historical_schema_ready) { 1952 if (defined('WP_DEBUG') && WP_DEBUG) { 1953 $this->debug_log('[Statora Plus] Historical schema is not fully verified; historical discovery and recovery are deferred until a later request.'); 1954 } 1955 return; 1956 } 1957 1958 // Schedule the audit migration outside the normal request path. The worker 1959 // uses bounded batches and a connection-scoped lock for safe continuation. 1960 $this->schedule_legacy_aggregate_reconciliation_migration(15); 1961 1962 // Keep each startup subsystem isolated. A scheduler or adapter-provider 1963 // failure in one subsystem must not prevent the remaining durable work 1964 // from being discovered and recovered on this request. 1965 if (!get_option('statpl_import_complete')) { 1966 try { 1967 $this->schedule_import_batch(); 1968 } catch (Throwable $exception) { 1969 if (defined('WP_DEBUG') && WP_DEBUG) { 1970 $this->debug_log('[Statora Plus] Core import scheduling failed; it will be retried on a later request.'); 1971 } 1972 } 1973 } 1974 1975 // SlimStat remains on its existing, independent initialization path. 1976 // Contain unexpected failures at the call site so they cannot block 1977 // generic adapter discovery or recovery. 1978 try { 1979 $slimstat_result = $this->initialize_slimstat_view_import(); 1980 if (isset($slimstat_result['status']) && $slimstat_result['status'] !== 'already_initialized') { 1981 update_option('statpl_history_slimstat_probe', sanitize_key((string) $slimstat_result['status']), false); 1982 } elseif (($slimstat_result['status'] ?? '') === 'already_initialized') { 1983 $slimstat_state_status = statpl_db_get_var(statpl_db_prepare( 1984 "SELECT status FROM {$this->view_import_state_table} WHERE source = %s", 'slimstat' 1985 )); 1986 if (in_array((string) $slimstat_state_status, ['pending','running'], true)) { 1987 $slimstat_probe_result = $this->get_slimstat_view_snapshot(); 1988 update_option('statpl_history_slimstat_probe', sanitize_key((string) ($slimstat_probe_result['status'] ?? 'database_error')), false); 1989 } 1990 } 1991 if ( 1992 is_array($slimstat_result) 1993 && isset($slimstat_result['status']) 1994 && in_array($slimstat_result['status'], ['initialized', 'already_initialized'], true) 1995 ) { 1996 try { 1997 $this->schedule_slimstat_view_import_batch(); 1998 } catch (Throwable $exception) { 1999 if (defined('WP_DEBUG') && WP_DEBUG) { 2000 $this->debug_log('[Statora Plus] SlimStat import scheduling failed; its existing recovery path will retry.'); 2001 } 2002 } 2003 } 2004 } catch (Throwable $exception) { 2005 if (defined('WP_DEBUG') && WP_DEBUG) { 2006 $this->debug_log('[Statora Plus] SlimStat initialization failed; generic historical sources will continue independently.'); 2007 } 2008 } 2009 2010 // Discovery failure must not suppress recovery of already persisted 2011 // work. Conversely, recovery is attempted even if discovery throws. 2012 try { 2013 $generic_result = $this->discover_historical_view_adapters(); 2014 update_option('statpl_history_discovery_status', sanitize_key((string) ($generic_result['status'] ?? 'unknown')), false); 2015 if (!empty($generic_result['registered'])) { 2016 try { 2017 $this->schedule_generic_historical_view_import_batch(15); 2018 } catch (Throwable $exception) { 2019 if (defined('WP_DEBUG') && WP_DEBUG) { 2020 $this->debug_log('[Statora Plus] Generic historical import scheduling failed; durable work will be recovered later.'); 2021 } 2022 } 2023 } 2024 } catch (Throwable $exception) { 2025 if (defined('WP_DEBUG') && WP_DEBUG) { 2026 $this->debug_log('[Statora Plus] Generic adapter discovery failed; persisted historical work recovery will still run.'); 2027 } 2028 } 2029 2030 if (get_option('statpl_retained_deactivation_at', '') !== '') { 2031 $gap_result = $this->resume_retained_history_gap(); 2032 if (($gap_result['status'] ?? '') === 'reset') { 2033 try { 2034 $this->schedule_slimstat_view_import_batch(15); 2035 } catch (Throwable $exception) { 2036 if (defined('WP_DEBUG') && WP_DEBUG) { 2037 $this->debug_log('[Statora Plus] Retained history recovery scheduling failed; it will be retried on a later request.'); 2038 } 2039 } 2040 } 2041 } 2042 2043 try { 2044 $this->recover_unscheduled_generic_historical_work(); 2045 } catch (Throwable $exception) { 2046 if (defined('WP_DEBUG') && WP_DEBUG) { 2047 $this->debug_log('[Statora Plus] Generic historical work recovery failed; it will be retried on a later request.'); 2048 } 2049 } 2050 $lifecycle = $this->get_historical_import_lifecycle_state(); 2051 update_option('statpl_history_import_state', sanitize_key((string) ($lifecycle['state'] ?? 'importing')), false); 2052 } 2053 133 2054 public function woocommerce_notice() { 134 2055 if (!current_user_can('manage_options') || class_exists('WooCommerce')) return; … … 163 2084 $now = current_time('mysql'); 164 2085 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this atomic view-count write must not be cached. 165 $wpdb->query($wpdb->prepare(2086 statpl_db_query(statpl_db_prepare( 166 2087 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Internal table identifier uses the WordPress prefix plus a fixed plugin suffix; product ID is prepared. 167 2088 "INSERT INTO {$this->views_table} (product_id, total_views) VALUES (%d, 1) … … 169 2090 )); 170 2091 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Event writes must be persisted immediately and cannot be cached. 171 $wpdb->insert($this->events_table, ['product_id' => $product_id, 'viewed_at' => $now], ['%d','%s']);2092 statpl_db_insert($this->events_table, ['product_id' => $product_id, 'viewed_at' => $now], ['%d','%s']); 172 2093 setcookie($cookie, '1', time() + 1200, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); 173 2094 $_COOKIE[$cookie] = '1'; … … 175 2096 $cutoff = gmdate('Y-m-d H:i:s', time() - 172800); 176 2097 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is internal and the cutoff is prepared; this cleanup deletes expired rows, so caching is not applicable. 177 $wpdb->query($wpdb->prepare("DELETE FROM {$this->events_table} WHERE viewed_at < %s", get_date_from_gmt($cutoff)));2098 statpl_db_query(statpl_db_prepare("DELETE FROM {$this->events_table} WHERE viewed_at < %s", get_date_from_gmt($cutoff))); 178 2099 wp_send_json_success(['counted' => true]); 179 2100 } … … 187 2108 // This prevents a later snapshot sync from making an older cancellation appear to happen today. 188 2109 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the query values are prepared. 189 $event_at = $wpdb->get_var($wpdb->prepare(2110 $event_at = statpl_db_get_var(statpl_db_prepare( 190 2111 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; both query values are prepared. 191 2112 "SELECT MIN(event_at) FROM {$this->order_events_table} WHERE order_id = %d AND event_type = %s AND event_ref_id = 0", … … 280 2201 // from being counted more than once in the store overview. 281 2202 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared. 282 $wpdb->query($wpdb->prepare(2203 statpl_db_query(statpl_db_prepare( 283 2204 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared. 284 2205 "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, 0, %s, %d, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))", … … 288 2209 foreach (array_keys($product_ids) as $product_id) { 289 2210 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared. 290 $wpdb->query($wpdb->prepare(2211 statpl_db_query(statpl_db_prepare( 291 2212 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared. 292 2213 "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, %d, %s, %d, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))", … … 309 2230 // Product ID 0 is reserved for order-level dashboard aggregation. 310 2231 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared. 311 $wpdb->query($wpdb->prepare(2232 statpl_db_query(statpl_db_prepare( 312 2233 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared. 313 2234 "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, 0, %s, 0, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))", … … 316 2237 foreach ($this->order_product_ids($order) as $product_id) { 317 2238 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared. 318 $wpdb->query($wpdb->prepare(2239 statpl_db_query(statpl_db_prepare( 319 2240 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared. 320 2241 "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, %d, %s, 0, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))", … … 325 2246 delete_transient('statpl_global_summary_v2'); 326 2247 } 2248 /** Add a two-choice confirmation dialog to this plugin's Plugins-screen action. */ 2249 public function enqueue_deactivation_prompt($hook_suffix) { 2250 if ($hook_suffix !== 'plugins.php' || !current_user_can('activate_plugins')) { 2251 return; 2252 } 2253 wp_enqueue_script('jquery-core'); 2254 $config = [ 2255 'plugin' => plugin_basename(__FILE__), 2256 'endpoint' => admin_url('admin-post.php'), 2257 'nonce' => wp_create_nonce('statpl_prepare_deactivation'), 2258 'title' => 'Απενεργοποίηση Statora Plus', 2259 'message' => 'Τι θέλεις να γίνει με τα στατιστικά και τα δεδομένα του Statora Plus;', 2260 'retain' => 'Ναι, διατήρηση όλων των δεδομένων', 2261 'delete' => 'Όχι, διαγραφή όλων των δεδομένων', 2262 'cancel' => 'Ακύρωση', 2263 'warning' => 'Η διαγραφή δεν μπορεί να αναιρεθεί.', 2264 ]; 2265 wp_add_inline_script('jquery-core', 'window.StatoraDeactivatePrompt = ' . wp_json_encode($config) . ';', 'before'); 2266 $js = <<<'STATPL_DEACTIVATION_JS' 2267 (function () { 2268 var cfg = window.StatoraDeactivatePrompt; 2269 if (!cfg) return; 2270 function findDeactivateLink(target) { 2271 var link = target.closest('a'); 2272 if (!link) return null; 2273 try { 2274 var url = new URL(link.href, window.location.href); 2275 if (url.searchParams.get('action') !== 'deactivate' || url.searchParams.get('plugin') !== cfg.plugin) return null; 2276 return link; 2277 } catch (e) { return null; } 2278 } 2279 document.addEventListener('click', function (event) { 2280 var link = findDeactivateLink(event.target); 2281 if (!link) return; 2282 event.preventDefault(); 2283 event.stopImmediatePropagation(); 2284 if (document.getElementById('statpl-deactivation-dialog')) return; 2285 var dialog = document.createElement('div'); 2286 dialog.id = 'statpl-deactivation-dialog'; 2287 dialog.style.cssText = 'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:20px'; 2288 var panel = document.createElement('div'); 2289 panel.style.cssText = 'background:#fff;max-width:520px;width:100%;padding:24px;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,.25);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'; 2290 var title = document.createElement('h2'); title.textContent = cfg.title; title.style.marginTop = '0'; 2291 var message = document.createElement('p'); message.textContent = cfg.message; 2292 var warning = document.createElement('p'); warning.textContent = cfg.warning; warning.style.color = '#b32d2e'; 2293 var actions = document.createElement('div'); actions.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;margin-top:20px'; 2294 function button(label, choice, primary) { 2295 var b = document.createElement('button'); b.type = 'button'; b.textContent = label; 2296 b.className = primary ? 'button button-primary' : 'button'; 2297 b.addEventListener('click', function () { 2298 if (!choice) { dialog.remove(); return; } 2299 var destination = new URL(cfg.endpoint, window.location.href); 2300 destination.searchParams.set('action', 'statpl_prepare_deactivation'); 2301 destination.searchParams.set('choice', choice); 2302 destination.searchParams.set('_wpnonce', cfg.nonce); 2303 destination.searchParams.set('redirect_to', link.href); 2304 window.location.assign(destination.toString()); 2305 }); 2306 actions.appendChild(b); 2307 } 2308 button(cfg.retain, 'retain', true); 2309 button(cfg.delete, 'delete', false); 2310 button(cfg.cancel, '', false); 2311 panel.appendChild(title); panel.appendChild(message); panel.appendChild(warning); panel.appendChild(actions); 2312 dialog.appendChild(panel); document.body.appendChild(dialog); 2313 dialog.addEventListener('click', function (e) { if (e.target === dialog) dialog.remove(); }); 2314 }, true); 2315 })(); 2316 STATPL_DEACTIVATION_JS; 2317 wp_add_inline_script('jquery-core', $js, 'after'); 2318 } 2319 327 2320 public function enqueue_admin_styles($hook_suffix) { 328 2321 if ('toplevel_page_statpl-product-metrics' !== $hook_suffix) { … … 333 2326 $css = <<<'STATPL_CSS' 334 2327 .statpl-table-scroll{overflow-x:auto} 335 .statpl-stock-not-tracked{color:#646970} 2328 .statpl-stock-not-tracked{color:#646970}.statpl-product-visibility{display:inline-block;margin-left:7px;color:#646970;font-size:12px;font-weight:400} 336 2329 .statpl-metrics-wrap .nav-tab{border-color:#c9d7ff;background:#f2f6ff;color:#3155F5;font-weight:600} 337 2330 .statpl-metrics-wrap .nav-tab-active{background:#fff;color:#1d2327;border-bottom-color:#fff;border-top:3px solid #3155F5} … … 351 2344 .statpl-metrics-wrap .notice-success.inline p{color:#263b8f} 352 2345 .statpl-metrics-wrap .statpl-overview-note{color:#505b6b} 2346 .statpl-history-notice{position:relative;box-sizing:border-box;margin:15px 0;padding:14px 42px 14px 14px;border:1px solid #c5e2a9;border-radius:6px;background:#DDF2C8;color:#000}.statpl-history-notice-importing{background:#D9EEFF;border-color:#b8dcf5}.statpl-history-notice-icon{color:#2271b1;margin-right:6px;vertical-align:middle}.statpl-history-notice p{margin:8px 0;color:#000}.statpl-history-notice strong{color:#000}.statpl-history-notice-close{position:absolute;top:5px;right:8px;padding:0 5px;border:0;background:transparent;color:#44515e;font-size:22px;line-height:1.2;cursor:pointer}.statpl-history-report-link{display:inline-block;margin-top:3px;color:#0073aa;text-decoration:none}.statpl-history-report-link:hover{text-decoration:underline} 353 2347 .statpl-metrics-wrap .statpl-metrics-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin:18px 0 12px}.statpl-metrics-wrap .statpl-sort-form{display:flex;align-items:center;gap:8px}.statpl-metrics-wrap .statpl-metrics-search{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin:0}.statpl-metrics-wrap .statpl-metrics-search input[type=search]{width:min(375px,100%)}.statpl-metrics-wrap .statpl-metrics-search input[type=submit],.statpl-metrics-wrap .button-primary{background:#3155F5!important;border-color:#3155F5!important;color:#fff!important} 354 2348 .statpl-metrics-wrap .widefat{border-color:#c9d7ff;border-collapse:separate;border-spacing:0} … … 366 2360 367 2361 public function admin_menu() { 2362 // The detailed import report is intentionally not a permanent menu item. 2363 // It is reachable only through the temporary notice link for partial or 2364 // technical outcomes, and only during the 15-minute access window. 368 2365 add_menu_page('Statora Plus', 'Statora Plus', 'manage_woocommerce', 'statpl-product-metrics', [$this, 'render_page'], 'dashicons-chart-bar', 56); 2366 } 2367 2368 /** Add the silent 20-second dismissal behavior; manual close cancels the timer. */ 2369 public function enqueue_historical_notice_script() { 2370 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a read-only admin-screen routing check; it does not process form data or perform a state-changing action. Mutating admin actions use their own nonce verification. 2371 if (!isset($_GET['page']) || !in_array(sanitize_key(wp_unslash($_GET['page'])), ['statpl-product-metrics','statpl-history-report'], true)) { return; } 2372 echo '<script>(function(){var n=document.querySelector(".statpl-history-notice");if(!n)return;var t=window.setTimeout(function(){if(n.parentNode)n.parentNode.removeChild(n);},20000);var b=n.querySelector(".statpl-history-notice-close");if(b)b.addEventListener("click",function(){window.clearTimeout(t);if(n.parentNode)n.parentNode.removeChild(n);});})();</script>'; 369 2373 } 370 2374 /** Bulk-load view counts for a product set to avoid two or more queries per product. */ … … 376 2380 return $this->today_start_datetime()->format('Y-m-d H:i:s'); 377 2381 } 378 /** Return a displayable stock quantity without double-counting parent and variation stock. */ 2382 /** 2383 * Return stock quantity and WooCommerce availability without double-counting 2384 * parent-managed and variation-managed inventory. 2385 */ 379 2386 private function stock_summary_for_product($product) { 380 if (!$product) return ['managed' => false, 'quantity' => null]; 2387 if (!$product) { 2388 return ['managed' => false, 'quantity' => null, 'in_stock' => false]; 2389 } 2390 2391 // WooCommerce's native availability API treats both instock and 2392 // onbackorder as purchasable, and respects its stock-status filters. 2393 $in_stock = (bool) $product->is_in_stock(); 2394 2395 // When the parent manages stock, its quantity is authoritative. 2396 // Do not add variation quantities to it. 381 2397 if ($product->managing_stock()) { 382 2398 $quantity = $product->get_stock_quantity(); 383 return ['managed' => true, 'quantity' => is_numeric($quantity) ? (float) $quantity : 0.0]; 384 } 2399 return [ 2400 'managed' => true, 2401 'quantity' => is_numeric($quantity) ? (float) $quantity : 0.0, 2402 'in_stock' => $in_stock, 2403 ]; 2404 } 2405 2406 // Otherwise, sum only variations that explicitly manage their own stock. 385 2407 if ($product->is_type('variable')) { 386 2408 $managed_any = false; … … 388 2410 foreach ($product->get_children() as $variation_id) { 389 2411 $variation = wc_get_product($variation_id); 390 if (!$variation || !$variation->managing_stock()) continue; 2412 if (!$variation || !$variation->managing_stock()) { 2413 continue; 2414 } 391 2415 $managed_any = true; 392 2416 $quantity = $variation->get_stock_quantity(); 393 if (is_numeric($quantity)) $total += (float) $quantity; 394 } 395 if ($managed_any) return ['managed' => true, 'quantity' => $total]; 396 } 397 return ['managed' => false, 'quantity' => null]; 2417 if (is_numeric($quantity)) { 2418 $total += (float) $quantity; 2419 } 2420 } 2421 if ($managed_any) { 2422 return ['managed' => true, 'quantity' => $total, 'in_stock' => $in_stock]; 2423 } 2424 } 2425 2426 return ['managed' => false, 'quantity' => null, 'in_stock' => $in_stock]; 398 2427 } 399 2428 … … 411 2440 $placeholders = implode(',', array_fill(0, count($ids), '%d')); 412 2441 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all query values are prepared. 413 $totals = $wpdb->get_results(414 $wpdb->prepare(2442 $totals = statpl_db_get_results( 2443 statpl_db_prepare( 415 2444 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated; the IN placeholders are generated from the count of integer IDs, and every ID is passed to prepare() as a value. 416 2445 "SELECT product_id, total_views FROM {$this->views_table} WHERE product_id IN ($placeholders)", … … 428 2457 $since = $this->today_start_mysql(); 429 2458 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all query values are prepared. 430 $recent = $wpdb->get_results(431 $wpdb->prepare(2459 $recent = statpl_db_get_results( 2460 statpl_db_prepare( 432 2461 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated; the date and every product ID are passed to prepare() as values. The IN placeholders are generated from the count of integer IDs. 433 2462 "SELECT product_id, COUNT(*) AS views_today FROM {$this->events_table} WHERE viewed_at >= %s AND product_id IN ($placeholders) GROUP BY product_id", … … 471 2500 // Replace a snapshot atomically so readers never see a half-written order. 472 2501 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control is required for an atomic snapshot replacement and cannot be cached. 473 if ( $wpdb->query('START TRANSACTION') === false) return false;2502 if (statpl_db_query('START TRANSACTION') === false) return false; 474 2503 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This write replaces the stored order snapshot inside a transaction; it must execute immediately and must not be cached. 475 $deleted = $wpdb->delete($this->order_items_table, ['order_id' => $order_id], ['%d']);2504 $deleted = statpl_db_delete($this->order_items_table, ['order_id' => $order_id], ['%d']); 476 2505 if ($deleted === false) { 477 2506 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction rollback is required after a failed write; caching is not applicable. 478 $wpdb->query('ROLLBACK');2507 statpl_db_query('ROLLBACK'); 479 2508 return false; 480 2509 } … … 539 2568 foreach ($by_product as $pid => $row) { 540 2569 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- These rows are the authoritative refreshed order snapshot; writes must execute immediately and must not be cached. 541 $inserted = $wpdb->insert($this->order_items_table, [2570 $inserted = statpl_db_insert($this->order_items_table, [ 542 2571 'order_id'=>(int)$order_id, 'product_id'=>(int)$pid, 'order_status'=>$order->get_status(), 543 2572 'created_at'=>$created_at, 'paid_at'=>$paid_at, 'completed_at'=>$completed_at, … … 547 2576 if ($inserted === false) { 548 2577 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction rollback is required after a failed write; caching is not applicable. 549 $wpdb->query('ROLLBACK');2578 statpl_db_query('ROLLBACK'); 550 2579 return false; 551 2580 } 552 2581 } 553 2582 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction commit is required to persist the completed snapshot atomically. 554 if ( $wpdb->query('COMMIT') === false) {555 $wpdb->query('ROLLBACK');2583 if (statpl_db_query('COMMIT') === false) { 2584 statpl_db_query('ROLLBACK'); 556 2585 return false; 557 2586 } … … 574 2603 global $wpdb; 575 2604 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Deleting a removed order snapshot is a required write; caching is not applicable. 576 $wpdb->delete($this->order_items_table, ['order_id'=>absint($order_id)], ['%d']);2605 statpl_db_delete($this->order_items_table, ['order_id'=>absint($order_id)], ['%d']); 577 2606 delete_transient('statpl_order_stats_v1'); 578 2607 delete_transient('statpl_global_summary_v2'); 579 2608 } 2609 /** 2610 * Discover source-specific adapters registered by compatible analytics plugins. 2611 * This registry is intentionally explicit: Statora Plus does not guess the 2612 * meaning of arbitrary third-party database tables. 2613 */ 2614 public function discover_historical_view_adapters() { 2615 global $wpdb; 2616 2617 // Discovery mutates the persisted source/origin inventory and schedules 2618 // snapshot/import work. Serialize it across concurrent WordPress requests 2619 // so two provider scans cannot race while reconciling the same origins. 2620 // Acquire the lock before clearing the request-local registry: a request 2621 // that cannot enter discovery must not discard a still-valid registry. 2622 $lock_name = 'statpl_generic_adapter_discovery'; 2623 $discovery_lock = $this->acquire_generic_advisory_lock($lock_name); 2624 if (empty($discovery_lock['acquired'])) { 2625 $status = isset($discovery_lock['status']) ? $discovery_lock['status'] : 'lock_unavailable'; 2626 if ($status !== 'lock_busy_or_unavailable') { 2627 $this->log_generic_historical_scheduler_error( 2628 'adapter_discovery_lock', 2629 isset($discovery_lock['error']) ? $discovery_lock['error'] : $status 2630 ); 2631 } 2632 return ['status' => $status === 'lock_busy_or_unavailable' ? 'busy' : 'lock_unavailable', 'registered' => 0, 'rejected' => 0]; 2633 } 2634 2635 try { 2636 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2637 return ['status' => 'lock_session_lost', 'registered' => 0, 'rejected' => 0]; 2638 } 2639 return $this->discover_historical_view_adapters_locked($discovery_lock); 2640 } finally { 2641 // Never attempt to release a connection-scoped lock through a replacement session. 2642 $this->release_generic_advisory_lock($discovery_lock, 'adapter_discovery'); 2643 } 2644 } 2645 2646 /** Perform provider discovery while holding the global discovery lock. */ 2647 private function discover_historical_view_adapters_locked($discovery_lock) { 2648 // Invalidate request-local adapter objects before asking providers to 2649 // rebuild the registry. If a provider throws, retaining objects from an 2650 // earlier discovery pass could execute an adapter whose plugin is no 2651 // longer available or whose registration is now failing. Persisted 2652 // adapter identity/snapshot/checkpoint remain untouched in the database. 2653 $this->historical_view_adapters = []; 2654 2655 // A third-party provider callback can throw while the registry filter is 2656 // being assembled. Contain that failure here so it cannot interrupt 2657 // Statora Plus initialization or the independent live-view tracker. 2658 try { 2659 $provided = apply_filters('statpl_historical_view_adapters', []); 2660 } catch (Throwable $exception) { 2661 if (defined('WP_DEBUG') && WP_DEBUG) { 2662 $this->debug_log('Statora Plus: historical adapter registry filter failed: ' . $exception->getMessage()); 2663 } 2664 return ['status' => 'provider_error', 'registered' => 0, 'rejected' => 1]; 2665 } 2666 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2667 return ['status' => 'lock_session_lost', 'registered' => 0, 'rejected' => 0]; 2668 } 2669 if (!is_array($provided)) { 2670 return ['status' => 'invalid_registry', 'registered' => 0, 'rejected' => 0]; 2671 } 2672 2673 // A successful discovery pass is authoritative; the registry was 2674 // cleared before invoking third-party provider callbacks above. 2675 2676 $registered = 0; 2677 $rejected = 0; 2678 $seen_adapter_keys = []; 2679 // A source_key is a persistent namespace, so two different adapter 2680 // identities must not claim it during the same discovery pass—even if 2681 // the first adapter currently reports no origins and therefore has no 2682 // origin row from which the database conflict check could detect it. 2683 $seen_source_keys = []; 2684 2685 foreach ($provided as $adapter) { 2686 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2687 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2688 } 2689 if (!is_object($adapter) || !$this->is_valid_historical_view_adapter($adapter)) { 2690 $rejected++; 2691 continue; 2692 } 2693 2694 try { 2695 $adapter_key = sanitize_key((string) $adapter->get_adapter_key()); 2696 $source_key = sanitize_key((string) $adapter->get_source_key()); 2697 $display_name = sanitize_text_field((string) $adapter->get_display_name()); 2698 $adapter_version = sanitize_text_field((string) $adapter->get_adapter_version()); 2699 2700 // Adapter metadata methods are third-party callbacks. They may 2701 // perform database work or trigger a reconnect; never continue 2702 // discovery writes under an assumed connection after invoking them. 2703 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2704 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2705 } 2706 2707 if ( 2708 $adapter_key === '' 2709 || $source_key === '' 2710 || $display_name === '' 2711 || $adapter_version === '' 2712 || strlen($adapter_key) > 100 2713 || strlen($source_key) > 100 2714 || strlen($display_name) > 191 2715 || strlen($adapter_version) > 32 2716 || isset($seen_adapter_keys[$adapter_key]) 2717 ) { 2718 $rejected++; 2719 continue; 2720 } 2721 2722 // SlimStat already has a mature, separately tested import pipeline. 2723 // It must not be registered a second time through this generic path. 2724 if ($source_key === 'slimstat' || $adapter_key === 'slimstat') { 2725 continue; 2726 } 2727 2728 if (isset($seen_source_keys[$source_key]) && $seen_source_keys[$source_key] !== $adapter_key) { 2729 $rejected++; 2730 if (defined('WP_DEBUG') && WP_DEBUG) { 2731 $this->debug_log('Statora Plus: rejected adapter "' . $adapter_key . '" because source_key "' . $source_key . '" was already claimed by another adapter in this discovery pass.'); 2732 } 2733 continue; 2734 } 2735 $seen_source_keys[$source_key] = $adapter_key; 2736 2737 // A source_key is the persistent namespace for all of its origins. 2738 // Do not allow a different adapter identity to append a second set 2739 // of origins to an existing source, even when the origin_key values 2740 // do not overlap. The discovery advisory lock makes this check and 2741 // the subsequent registration one serialized discovery operation. 2742 global $wpdb; 2743 $conflicting_adapter_key = statpl_db_get_var(statpl_db_prepare( 2744 "SELECT origins.adapter_key 2745 FROM {$this->view_source_origins_table} AS origins 2746 INNER JOIN {$this->view_sources_table} AS sources 2747 ON sources.id = origins.source_id 2748 WHERE sources.source_key = %s 2749 AND (sources.adapter_key <> %s OR origins.adapter_key <> %s) 2750 LIMIT 1", 2751 $source_key, 2752 $adapter_key, 2753 $adapter_key 2754 )); 2755 if (!empty($wpdb->last_error)) { 2756 $rejected++; 2757 $this->log_generic_historical_scheduler_error( 2758 'adapter_identity_lookup', 2759 $wpdb->last_error 2760 ); 2761 continue; 2762 } 2763 if ($conflicting_adapter_key !== null && $conflicting_adapter_key !== '') { 2764 $rejected++; 2765 if (defined('WP_DEBUG') && WP_DEBUG) { 2766 $this->debug_log('Statora Plus: rejected adapter "' . $adapter_key . '" because source_key "' . $source_key . '" is already bound to another adapter identity.'); 2767 } 2768 continue; 2769 } 2770 2771 $origins = $adapter->discover_origins(); 2772 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2773 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2774 } 2775 if (!is_array($origins)) { 2776 $rejected++; 2777 continue; 2778 } 2779 2780 $valid_origins = []; 2781 $seen_origin_keys = []; 2782 $origin_declaration_invalid = false; 2783 foreach ($origins as $origin) { 2784 if (!is_array($origin)) { 2785 $rejected++; 2786 $origin_declaration_invalid = true; 2787 continue; 2788 } 2789 2790 $origin_key = isset($origin['origin_key']) 2791 ? sanitize_key((string) $origin['origin_key']) 2792 : ''; 2793 $capabilities = isset($origin['capabilities']) && is_array($origin['capabilities']) 2794 ? $origin['capabilities'] 2795 : []; 2796 $detection_status = isset($origin['detection_status']) 2797 ? (string) $origin['detection_status'] 2798 : 'unknown'; 2799 2800 // Only explicit product-view capability qualifies as a source. 2801 if ( 2802 $origin_key === '' 2803 || strlen($origin_key) > 100 2804 || isset($seen_origin_keys[$origin_key]) 2805 || empty($capabilities['product_views']) 2806 || !in_array($detection_status, ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true) 2807 ) { 2808 $rejected++; 2809 $origin_declaration_invalid = true; 2810 continue; 2811 } 2812 2813 // Sanitization can collapse distinct provider values to the 2814 // same key. Register each origin only once per discovery pass 2815 // so duplicate declarations cannot overwrite capabilities or 2816 // schedule duplicate snapshot/import initialization work. 2817 $seen_origin_keys[$origin_key] = true; 2818 $valid_origins[] = [ 2819 'origin_key' => $origin_key, 2820 'capabilities' => $capabilities, 2821 'detection_status' => $detection_status, 2822 ]; 2823 } 2824 2825 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2826 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2827 } 2828 $source_result = $this->register_view_source( 2829 $source_key, 2830 $display_name, 2831 $adapter_version, 2832 'event', 2833 'detected', 2834 $adapter_key 2835 ); 2836 2837 if (empty($source_result['source_id'])) { 2838 $rejected++; 2839 continue; 2840 } 2841 2842 // A successful discovery response is authoritative for the 2843 // adapter's current origin inventory. If the provider returns a 2844 // temporarily incomplete (or empty) list, preserve omitted 2845 // origins and their cursors but take them out of active work. 2846 // Do not infer omissions from a malformed/partially rejected 2847 // declaration list, because that is not a trustworthy inventory. 2848 $seen_adapter_keys[$adapter_key] = true; 2849 if (!$origin_declaration_invalid) { 2850 $missing_origins = $this->mark_missing_generic_historical_origins_unavailable( 2851 $source_result['source_id'], 2852 $adapter_key, 2853 array_column($valid_origins, 'origin_key'), 2854 $discovery_lock 2855 ); 2856 if (empty($missing_origins['status']) || $missing_origins['status'] !== 'updated') { 2857 // Do not register or schedule this adapter's newly reported 2858 // origins against a partially reconciled inventory. Keep the 2859 // previous persisted inventory intact and retry on the next 2860 // discovery pass after the database is healthy. 2861 $rejected++; 2862 $this->log_generic_historical_scheduler_error( 2863 'origin_inventory_reconciliation', 2864 isset($missing_origins['error']) ? $missing_origins['error'] : 'Origin inventory reconciliation did not commit.' 2865 ); 2866 continue; 2867 } 2868 } 2869 2870 if (!$valid_origins) { 2871 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2872 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2873 } 2874 $this->refresh_generic_historical_source_status($source_result['source_id']); 2875 continue; 2876 } 2877 2878 $registered_origins = []; 2879 $origin_registration_failures = 0; 2880 $adapter_mismatch_detected = false; 2881 foreach ($valid_origins as $origin) { 2882 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2883 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2884 } 2885 // Isolate each origin completely. A provider/database exception 2886 // while registering one origin must not prevent sibling origins 2887 // from being registered and scheduled in this discovery pass. 2888 try { 2889 $origin_result = $this->register_view_source_origin( 2890 $source_result['source_id'], 2891 $origin['origin_key'], 2892 $adapter_key, 2893 $origin['capabilities'], 2894 $origin['detection_status'] 2895 ); 2896 } catch (Throwable $exception) { 2897 $origin_result = [ 2898 'status' => 'origin_exception', 2899 'error' => $exception->getMessage(), 2900 ]; 2901 if (defined('WP_DEBUG') && WP_DEBUG) { 2902 $this->debug_log('Statora Plus: origin registration failed for "' . $origin['origin_key'] . '": ' . $exception->getMessage()); 2903 } 2904 } 2905 2906 if (!empty($origin_result['status']) && $origin_result['status'] === 'registered') { 2907 $registered_origins[] = $origin; 2908 } elseif (!empty($origin_result['status']) && $origin_result['status'] === 'adapter_mismatch') { 2909 $adapter_mismatch_detected = true; 2910 $origin_registration_failures++; 2911 } else { 2912 $origin_registration_failures++; 2913 } 2914 } 2915 if ($adapter_mismatch_detected) { 2916 // Preserve the established identity and cursor of the mismatched 2917 // origin, but do not let that isolated conflict block sibling 2918 // origins that were registered successfully with this adapter. 2919 $this->refresh_generic_historical_source_status($source_result['source_id']); 2920 } 2921 2922 if ($registered_origins) { 2923 $this->historical_view_adapters[$adapter_key] = $adapter; 2924 $snapshot_result = $this->initialize_generic_historical_view_snapshots( 2925 $source_result['source_id'], 2926 $adapter_key, 2927 $registered_origins, 2928 $discovery_lock 2929 ); 2930 if (($snapshot_result['status'] ?? '') === 'lock_session_lost' 2931 || !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2932 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2933 } 2934 $this->refresh_generic_historical_source_status($source_result['source_id']); 2935 $registered++; 2936 if (!empty($snapshot_result['failed']) || $origin_registration_failures > 0) { 2937 $rejected++; 2938 } 2939 foreach ($registered_origins as $origin) { 2940 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2941 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2942 } 2943 if ($origin['detection_status'] === 'available') { 2944 $this->schedule_generic_historical_view_import_batch(15, $source_result['source_id'], $origin['origin_key']); 2945 } 2946 } 2947 } else { 2948 $rejected++; 2949 } 2950 } catch (Throwable $exception) { 2951 $rejected++; 2952 if (defined('WP_DEBUG') && WP_DEBUG) { 2953 $this->debug_log('Statora Plus: historical adapter discovery failed: ' . $exception->getMessage()); 2954 } 2955 } 2956 } 2957 2958 return [ 2959 'status' => 'completed', 2960 'registered' => $registered, 2961 'rejected' => $rejected, 2962 ]; 2963 } 2964 2965 /** Persist snapshot and its initial checkpoint together, exactly once per origin. */ 2966 private function save_generic_historical_snapshot_if_empty($source_id, $origin_key, $snapshot, $checkpoint) { 2967 global $wpdb; 2968 2969 $source_id = absint($source_id); 2970 $origin_key = is_string($origin_key) ? trim($origin_key) : ''; 2971 if (!$source_id || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 2972 || !is_array($snapshot) || empty($snapshot) || !is_array($checkpoint)) { 2973 return ['status' => 'invalid_input']; 2974 } 2975 2976 $snapshot_json = wp_json_encode($snapshot); 2977 $checkpoint_json = wp_json_encode($checkpoint); 2978 if (!is_string($snapshot_json) || !is_string($checkpoint_json)) { 2979 return ['status' => 'encoding_error']; 2980 } 2981 2982 $result = statpl_db_query(statpl_db_prepare( 2983 "UPDATE {$this->view_source_origins_table} 2984 SET snapshot_data = %s, checkpoint_data = %s, status = %s, 2985 started_at = %s, last_error = NULL, updated_at = %s 2986 WHERE source_id = %d AND origin_key = %s 2987 AND snapshot_data IS NULL AND checkpoint_data IS NULL", 2988 $snapshot_json, 2989 $checkpoint_json, 2990 'snapshot_ready', 2991 current_time('mysql'), 2992 current_time('mysql'), 2993 $source_id, 2994 $origin_key 2995 )); 2996 2997 if ($result === false || $wpdb->last_error !== '') { 2998 return ['status' => 'database_error', 'error' => $wpdb->last_error]; 2999 } 3000 if ($result === 1) { 3001 return ['status' => 'updated', 'source_id' => $source_id, 'origin_key' => $origin_key]; 3002 } 3003 3004 $existing = $this->get_view_source_origin_state($source_id, $origin_key); 3005 if ($existing['status'] !== 'found') { 3006 return $existing; 3007 } 3008 if (is_array($existing['snapshot']) && !empty($existing['snapshot']) 3009 && is_array($existing['checkpoint'])) { 3010 return [ 3011 'status' => 'already_initialized', 3012 'source_id' => $source_id, 3013 'origin_key' => $origin_key, 3014 'snapshot' => $existing['snapshot'], 3015 'checkpoint' => $existing['checkpoint'], 3016 ]; 3017 } 3018 return ['status' => 'initialization_conflict', 'source_id' => $source_id, 'origin_key' => $origin_key]; 3019 } 3020 3021 /** 3022 * Capture an immutable upper-bound snapshot for every detected generic origin. 3023 * Existing snapshots are never overwritten, so retries/re-discovery cannot move 3024 * the historical import boundary forward and accidentally include live traffic. 3025 */ 3026 private function initialize_generic_historical_view_snapshots($source_id, $adapter_key, $origins, $discovery_lock = null) { 3027 $adapter = $this->get_historical_view_adapter($adapter_key); 3028 if (!$adapter) { 3029 return ['status' => 'adapter_unavailable', 'failed' => 1]; 3030 } 3031 3032 $initialized = 0; 3033 $skipped = 0; 3034 $failed = 0; 3035 3036 foreach ($origins as $origin) { 3037 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3038 return [ 3039 'status' => 'lock_session_lost', 3040 'initialized' => $initialized, 3041 'skipped' => $skipped, 3042 'failed' => $failed, 3043 ]; 3044 } 3045 if (!is_array($origin) || empty($origin['origin_key'])) { 3046 $failed++; 3047 continue; 3048 } 3049 if (($origin['detection_status'] ?? '') !== 'available') { 3050 $skipped++; 3051 continue; 3052 } 3053 3054 $state = $this->get_view_source_origin_state($source_id, $origin['origin_key']); 3055 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3056 return [ 3057 'status' => 'lock_session_lost', 3058 'initialized' => $initialized, 3059 'skipped' => $skipped, 3060 'failed' => $failed, 3061 ]; 3062 } 3063 if (empty($state['status']) || $state['status'] !== 'found') { 3064 $failed++; 3065 $failure = $this->record_generic_historical_view_failure($source_id, 3066 $origin['origin_key'], 3067 'snapshot_initialization', 3068 0, 3069 'Could not read the stored origin state while initializing its historical snapshot.', 3070 null, 3071 8, 3072 $discovery_lock); 3073 if (!$failure['blocked']) { 3074 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3075 } 3076 continue; 3077 } 3078 if (is_array($state['snapshot']) && !empty($state['snapshot'])) { 3079 // A persisted snapshot is already valid; clear any stale 3080 // initialization failure only while this discovery worker still 3081 // owns the lock that serializes snapshot-ledger changes. 3082 if (!$this->clear_generic_historical_view_failure($source_id, $origin['origin_key'], 'snapshot_initialization', $discovery_lock)) { 3083 $failed++; 3084 $this->schedule_generic_historical_snapshot_retry(60, $source_id, $origin['origin_key']); 3085 continue; 3086 } 3087 $skipped++; 3088 continue; 3089 } 3090 3091 try { 3092 $snapshot = $adapter->get_origin_snapshot($origin['origin_key']); 3093 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3094 return [ 3095 'status' => 'lock_session_lost', 3096 'initialized' => $initialized, 3097 'skipped' => $skipped, 3098 'failed' => $failed, 3099 ]; 3100 } 3101 if (!is_array($snapshot) || empty($snapshot)) { 3102 $failed++; 3103 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3104 return [ 3105 'status' => 'lock_session_lost', 3106 'initialized' => $initialized, 3107 'skipped' => $skipped, 3108 'failed' => $failed, 3109 ]; 3110 } 3111 $failure = $this->record_generic_historical_view_failure($source_id, 3112 $origin['origin_key'], 3113 'snapshot_initialization', 3114 0, 3115 'Adapter returned an empty or invalid historical snapshot.', 3116 null, 3117 8, 3118 $discovery_lock); 3119 if (!$failure['blocked']) { 3120 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3121 } 3122 continue; 3123 } 3124 3125 // Adapter snapshots must declare their own stable boundary. The core 3126 // records the capture time but does not guess source-specific cursors. 3127 if (!isset($snapshot['captured_at'])) { 3128 $snapshot['captured_at'] = current_time('mysql'); 3129 } 3130 if (!isset($snapshot['boundary']) || !is_array($snapshot['boundary']) || empty($snapshot['boundary'])) { 3131 $failed++; 3132 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3133 return [ 3134 'status' => 'lock_session_lost', 3135 'initialized' => $initialized, 3136 'skipped' => $skipped, 3137 'failed' => $failed, 3138 ]; 3139 } 3140 $failure = $this->record_generic_historical_view_failure($source_id, 3141 $origin['origin_key'], 3142 'snapshot_initialization', 3143 0, 3144 'Adapter snapshot did not provide the required immutable boundary descriptor.', 3145 null, 3146 8, 3147 $discovery_lock); 3148 if (!$failure['blocked']) { 3149 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3150 } 3151 continue; 3152 } 3153 3154 $initial_checkpoint = array_key_exists('initial_checkpoint', $snapshot) 3155 ? $snapshot['initial_checkpoint'] 3156 : null; 3157 // A null initial cursor is valid (many sources begin at the start 3158 // of their history). Non-null cursors must be JSON-safe scalar/array 3159 // values within the same storage limit used for subsequent cursors. 3160 if ($initial_checkpoint !== null && !$this->is_valid_generic_historical_cursor($initial_checkpoint)) { 3161 $failed++; 3162 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3163 return [ 3164 'status' => 'lock_session_lost', 3165 'initialized' => $initialized, 3166 'skipped' => $skipped, 3167 'failed' => $failed, 3168 ]; 3169 } 3170 $failure = $this->record_generic_historical_view_failure($source_id, 3171 $origin['origin_key'], 3172 'snapshot_initialization', 3173 0, 3174 'Adapter snapshot contained an invalid initial checkpoint. Expected null or a JSON-safe scalar/array cursor no larger than 60 KB.', 3175 null, 3176 8, 3177 $discovery_lock); 3178 if (!$failure['blocked']) { 3179 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3180 } 3181 continue; 3182 } 3183 3184 $checkpoint = [ 3185 'cursor' => $initial_checkpoint, 3186 'processed' => 0, 3187 'snapshot_captured_at' => $snapshot['captured_at'], 3188 ]; 3189 unset($snapshot['initial_checkpoint']); 3190 3191 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3192 return [ 3193 'status' => 'lock_session_lost', 3194 'initialized' => $initialized, 3195 'skipped' => $skipped, 3196 'failed' => $failed, 3197 ]; 3198 } 3199 $saved = $this->save_generic_historical_snapshot_if_empty( 3200 $source_id, 3201 $origin['origin_key'], 3202 $snapshot, 3203 $checkpoint 3204 ); 3205 if (empty($saved['status']) || !in_array($saved['status'], ['updated', 'already_initialized'], true)) { 3206 $failed++; 3207 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3208 return [ 3209 'status' => 'lock_session_lost', 3210 'initialized' => $initialized, 3211 'skipped' => $skipped, 3212 'failed' => $failed, 3213 ]; 3214 } 3215 $failure = $this->record_generic_historical_view_failure($source_id, 3216 $origin['origin_key'], 3217 'snapshot_initialization', 3218 0, 3219 'Could not persist the immutable snapshot and initial checkpoint: ' . (isset($saved['status']) ? $saved['status'] : 'unknown error'), 3220 null, 3221 8, 3222 $discovery_lock); 3223 if (!$failure['blocked']) { 3224 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3225 } 3226 continue; 3227 } 3228 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3229 return [ 3230 'status' => 'lock_session_lost', 3231 'initialized' => $initialized, 3232 'skipped' => $skipped, 3233 'failed' => $failed, 3234 ]; 3235 } 3236 $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin['origin_key'], 'snapshot_initialization', $discovery_lock); 3237 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3238 return [ 3239 'status' => 'lock_session_lost', 3240 'initialized' => $initialized, 3241 'skipped' => $skipped, 3242 'failed' => $failed, 3243 ]; 3244 } 3245 if (!$failure_cleared) { 3246 $failed++; 3247 $this->schedule_generic_historical_snapshot_retry(60, $source_id, $origin['origin_key']); 3248 continue; 3249 } 3250 if ($saved['status'] === 'updated') { 3251 $initialized++; 3252 } else { 3253 $skipped++; 3254 } 3255 } catch (Throwable $exception) { 3256 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3257 return [ 3258 'status' => 'lock_session_lost', 3259 'initialized' => $initialized, 3260 'skipped' => $skipped, 3261 'failed' => $failed, 3262 ]; 3263 } 3264 $failed++; 3265 $failure = $this->record_generic_historical_view_failure($source_id, 3266 $origin['origin_key'], 3267 'snapshot_initialization', 3268 0, 3269 $exception->getMessage(), 3270 null, 3271 8, 3272 $discovery_lock); 3273 if (!$failure['blocked']) { 3274 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3275 } 3276 if (defined('WP_DEBUG') && WP_DEBUG) { 3277 $this->debug_log('Statora Plus: historical origin snapshot failed: ' . $exception->getMessage()); 3278 } 3279 } 3280 } 3281 3282 return [ 3283 'status' => $failed ? 'partial' : 'completed', 3284 'initialized' => $initialized, 3285 'skipped' => $skipped, 3286 'failed' => $failed, 3287 ]; 3288 } 3289 3290 /** 3291 * Preserve origins omitted by a successful adapter discovery pass without 3292 * allowing stale origins to continue importing. Their immutable adapter 3293 * identity, snapshot, checkpoint, events and failure ledger are untouched. 3294 */ 3295 private function mark_missing_generic_historical_origins_unavailable($source_id, $adapter_key, $discovered_origin_keys, $discovery_lock = null) { 3296 global $wpdb; 3297 3298 $source_id = absint($source_id); 3299 $adapter_key = sanitize_key((string) $adapter_key); 3300 if (!$source_id || $adapter_key === '' || !is_array($discovered_origin_keys)) { 3301 return ['status' => 'invalid_input', 'updated' => 0]; 3302 } 3303 3304 $discovered = []; 3305 foreach ($discovered_origin_keys as $origin_key) { 3306 $origin_key = sanitize_key((string) $origin_key); 3307 if ($origin_key !== '') { 3308 $discovered[$origin_key] = true; 3309 } 3310 } 3311 3312 if (!is_array($discovery_lock) || !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3313 return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session is no longer current.']; 3314 } 3315 3316 $transaction_started = false; 3317 $transaction_committed = false; 3318 try { 3319 // Reconciliation is one logical inventory operation. Keep it atomic so 3320 // a database error halfway through cannot leave only part of the omitted 3321 // origins marked unavailable. A later discovery can safely retry it. 3322 $begin_result = statpl_db_query('START TRANSACTION'); 3323 if ($begin_result === false || $wpdb->last_error !== '') { 3324 return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error]; 3325 } 3326 $transaction_started = true; 3327 3328 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3329 return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed before origin inventory read.']; 3330 } 3331 $existing = statpl_db_get_results( 3332 statpl_db_prepare( 3333 "SELECT origin_key, status 3334 FROM {$this->view_source_origins_table} 3335 WHERE source_id = %d AND adapter_key = %s 3336 FOR UPDATE", 3337 $source_id, 3338 $adapter_key 3339 ), 3340 ARRAY_A 3341 ); 3342 if (!empty($wpdb->last_error) || !is_array($existing)) { 3343 return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin inventory query returned an invalid result.']; 3344 } 3345 3346 $updated = 0; 3347 foreach ($existing as $row) { 3348 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3349 return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed during origin inventory reconciliation.']; 3350 } 3351 $origin_key = isset($row['origin_key']) ? (string) $row['origin_key'] : ''; 3352 if ($origin_key === '' || isset($discovered[$origin_key])) { 3353 continue; 3354 } 3355 3356 // Terminal lifecycle states are retained. Detection becomes unknown 3357 // until the provider declares this origin again; active work becomes 3358 // unavailable so stale adapter inventories cannot process it. 3359 $updates = [ 3360 'detection_status' => 'unknown', 3361 'updated_at' => current_time('mysql'), 3362 ]; 3363 $formats = ['%s', '%s']; 3364 if (in_array($row['status'] ?? '', ['pending', 'snapshot_ready', 'importing', 'retry_wait'], true)) { 3365 $updates['status'] = 'unavailable'; 3366 $updates['last_error'] = 'This origin was not declared by the adapter during the latest successful discovery pass; its saved snapshot and checkpoint were preserved.'; 3367 $formats = ['%s', '%s', '%s', '%s']; 3368 } 3369 3370 $result = statpl_db_update( 3371 $this->view_source_origins_table, 3372 $updates, 3373 ['source_id' => $source_id, 'origin_key' => $origin_key], 3374 $formats, 3375 ['%d', '%s'] 3376 ); 3377 if ($result === false || $wpdb->last_error !== '') { 3378 return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin reconciliation update failed.']; 3379 } 3380 $updated++; 3381 } 3382 3383 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3384 return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed before origin inventory commit.']; 3385 } 3386 $commit_result = statpl_db_query('COMMIT'); 3387 if ($commit_result === false || $wpdb->last_error !== '') { 3388 return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin reconciliation commit failed.']; 3389 } 3390 $transaction_committed = true; 3391 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3392 return ['status' => 'commit_outcome_unknown', 'updated' => 0, 'error' => 'Origin inventory commit completed but discovery lock session could not be confirmed.']; 3393 } 3394 return ['status' => 'updated', 'updated' => $updated]; 3395 } catch (Throwable $exception) { 3396 return ['status' => 'database_error', 'updated' => 0, 'error' => $exception->getMessage()]; 3397 } finally { 3398 // Includes thrown exceptions and ambiguous failures before COMMIT. 3399 // A rollback after a successful commit is intentionally avoided. 3400 if ($transaction_started && !$transaction_committed && $this->generic_advisory_lock_session_is_current($discovery_lock)) { 3401 try { 3402 statpl_db_query('ROLLBACK'); 3403 } catch (Throwable $rollback_exception) { 3404 $this->log_generic_historical_scheduler_error('origin_inventory_rollback', $rollback_exception->getMessage()); 3405 } 3406 } 3407 } 3408 } 3409 3410 /** Validate the minimum public adapter contract without trusting a provider. */ 3411 private function is_valid_historical_view_adapter($adapter) { 3412 foreach ([ 3413 'get_adapter_key', 3414 'get_source_key', 3415 'get_display_name', 3416 'get_adapter_version', 3417 'discover_origins', 3418 'get_origin_snapshot', 3419 'get_product_view_batch', 3420 'normalize_record', 3421 ] as $method) { 3422 if (!is_callable([$adapter, $method])) { 3423 return false; 3424 } 3425 } 3426 3427 return true; 3428 } 3429 3430 /** 3431 * Recompute the persisted status of one generic source from all its origins. 3432 * This is intentionally called only by the generic pipeline; the independent 3433 * SlimStat lifecycle and its status model are left untouched. 3434 */ 3435 private function refresh_generic_historical_source_status($source_id) { 3436 global $wpdb; 3437 3438 $source_id = absint($source_id); 3439 if (!$source_id) { 3440 return ['status' => 'invalid_source']; 3441 } 3442 3443 // Serialize aggregate status calculation and persistence per source. 3444 // Use the shared lock contract so the owning connection is captured 3445 // atomically and RELEASE_LOCK is never attempted on a replacement session. 3446 $status_lock_name = 'statpl_status_' . substr(md5((string) $source_id), 0, 32); 3447 $status_lock = $this->acquire_generic_advisory_lock($status_lock_name); 3448 if (empty($status_lock['acquired'])) { 3449 if (($status_lock['status'] ?? '') === 'lock_busy_or_unavailable' && empty($status_lock['error'])) { 3450 return ['status' => 'status_lock_busy']; 3451 } 3452 return ['status' => 'database_error', 'error' => (string) ($status_lock['error'] ?? 'Unable to acquire source status lock.')]; 3453 } 3454 3455 try { 3456 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3457 return ['status' => 'status_lock_lost']; 3458 } 3459 $origins = statpl_db_get_results(statpl_db_prepare( 3460 "SELECT detection_status, status FROM {$this->view_source_origins_table} WHERE source_id = %d", 3461 $source_id 3462 ), ARRAY_A); 3463 if (!empty($wpdb->last_error)) { 3464 return ['status' => 'database_error', 'error' => $wpdb->last_error]; 3465 } 3466 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3467 return ['status' => 'status_lock_lost']; 3468 } 3469 if (!is_array($origins) || !$origins) { 3470 return ['status' => 'no_origins']; 3471 } 3472 3473 $available_origins = array_values(array_filter($origins, function ($origin) { 3474 // Detection describes what the provider reported on its last pass; 3475 // lifecycle status additionally records whether this exact persisted 3476 // adapter identity is currently safe to execute. A replacement 3477 // adapter can be detected as available while its origin remains 3478 // deliberately unavailable because it cannot inherit the old cursor. 3479 return isset($origin['detection_status'], $origin['status']) 3480 && $origin['detection_status'] === 'available' 3481 && $origin['status'] !== 'unavailable'; 3482 })); 3483 $available = count($available_origins); 3484 $available_completed = count(array_filter($available_origins, function ($origin) { 3485 return isset($origin['status']) && $origin['status'] === 'completed'; 3486 })); 3487 // Only available origins participate in import lifecycle aggregation. 3488 // An unavailable/detected sibling must never keep an otherwise finished 3489 // source stuck in importing or retry_wait. 3490 $available_statuses = array_column($available_origins, 'status'); 3491 3492 // A source remains actively importing while ANY available origin is 3493 // still progressing. A blocked sibling origin must not make the whole 3494 // source appear terminal before the remaining origins finish. 3495 if (in_array('importing', $available_statuses, true) || in_array('snapshot_ready', $available_statuses, true) || in_array('pending', $available_statuses, true)) { 3496 $source_status = 'importing'; 3497 } elseif (in_array('retry_wait', $available_statuses, true)) { 3498 $source_status = 'retry_wait'; 3499 } elseif (in_array('needs_attention', $available_statuses, true)) { 3500 $source_status = 'needs_attention'; 3501 } elseif ($available === 0) { 3502 $source_status = 'unavailable'; 3503 } elseif ($available_completed === $available) { 3504 // A source with unavailable origins is only partial, never fully complete. 3505 $source_status = ($available === count($origins)) ? 'completed' : 'partial'; 3506 } elseif (in_array('completed', $available_statuses, true)) { 3507 $source_status = 'partial'; 3508 } else { 3509 $source_status = 'detected'; 3510 } 3511 3512 // Status reconciliation is not a source-registration/content update. 3513 // Keep updated_at reserved for genuine source changes; otherwise the 3514 // recovery scanner would generate needless writes on every request. 3515 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3516 return ['status' => 'status_lock_lost']; 3517 } 3518 // Bind source-status persistence to the same MySQL session that owns 3519 // this status lock. A PHP-side check alone leaves a reconnect race between 3520 // validation and UPDATE; the SQL predicate closes that window. 3521 if (empty($status_lock['lock_name']) || !$this->generic_advisory_lock_session_is_current($status_lock)) { 3522 return ['status' => 'status_lock_lost']; 3523 } 3524 $updated = statpl_db_query(statpl_db_prepare( 3525 "UPDATE {$this->view_sources_table} 3526 SET status = %s 3527 WHERE id = %d AND IS_USED_LOCK(%s) = CONNECTION_ID()", 3528 $source_status, 3529 $source_id, 3530 (string) $status_lock['lock_name'] 3531 )); 3532 if ($updated === false || $wpdb->last_error !== '') { 3533 return ['status' => 'database_error', 'error' => $wpdb->last_error]; 3534 } 3535 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3536 return ['status' => 'status_lock_lost']; 3537 } 3538 3539 // Do not treat an accepted UPDATE as durable source-state convergence. 3540 // The row may have been unchanged (0 affected rows), or an ambiguous 3541 // acknowledgement may have occurred. Read the aggregate status back and 3542 // require an exact match before reporting success to callers. 3543 $persisted_status = statpl_db_get_var(statpl_db_prepare( 3544 "SELECT status FROM {$this->view_sources_table} WHERE id = %d", 3545 $source_id 3546 )); 3547 if ($wpdb->last_error !== '') { 3548 return ['status' => 'database_error', 'error' => $wpdb->last_error]; 3549 } 3550 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3551 return ['status' => 'status_lock_lost']; 3552 } 3553 if ($persisted_status === null) { 3554 // The source may have been removed after the bounded scan selected it. 3555 // Treat that as a vanished work item, not as a database failure. 3556 return ['status' => 'source_missing']; 3557 } 3558 if (!is_string($persisted_status) || $persisted_status !== $source_status) { 3559 return [ 3560 'status' => 'state_unverified', 3561 'expected_status' => $source_status, 3562 'persisted_status' => is_string($persisted_status) ? $persisted_status : null, 3563 ]; 3564 } 3565 3566 return ['status' => 'updated', 'source_status' => $source_status]; 3567 } finally { 3568 // Release only if the original connection still owns this lock. 3569 $this->release_generic_advisory_lock($status_lock, 'source_status_reconciliation'); 3570 } 3571 } 3572 3573 /** Retry adapter discovery and snapshot initialization after a bounded delay. */ 3574 public function retry_generic_historical_view_snapshot($source_id = 0, $origin_key = '') { 3575 $source_id = absint($source_id); 3576 $origin_key = sanitize_key((string) $origin_key); 3577 if (!$source_id || $origin_key === '') { 3578 return ['status' => 'invalid_input']; 3579 } 3580 3581 // Discovery re-resolves the provider in this request and retries only origins 3582 // whose immutable snapshot has not yet been persisted. Existing snapshots are 3583 // protected by save_generic_historical_snapshot_if_empty(). 3584 $result = $this->discover_historical_view_adapters(); 3585 $state = $this->get_view_source_origin_state($source_id, $origin_key); 3586 if (!empty($state['status']) && $state['status'] === 'found' 3587 && is_array($state['snapshot']) && !empty($state['snapshot'])) { 3588 // The discovery worker owns the snapshot failure ledger lifecycle. This 3589 // follow-up request has no discovery lock context, so it must not delete 3590 // the ledger row outside that ownership boundary. 3591 $this->schedule_generic_historical_view_import_batch(15, $source_id, $origin_key); 3592 $this->refresh_generic_historical_source_status($source_id); 3593 return ['status' => 'snapshot_ready', 'discovery' => $result]; 3594 } 3595 3596 $this->refresh_generic_historical_source_status($source_id); 3597 return ['status' => 'snapshot_still_unavailable', 'discovery' => $result]; 3598 } 3599 3600 /** Schedule bounded retries for transient snapshot/provider initialization failures. */ 3601 private function schedule_generic_historical_snapshot_retry($delay, $source_id, $origin_key) { 3602 $args = [absint($source_id), sanitize_key((string) $origin_key)]; 3603 $hook = 'statpl_generic_view_snapshot_retry'; 3604 $requested_timestamp = time() + max(1, (int) $delay); 3605 3606 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 3607 $existing_timestamp = false; 3608 if (function_exists('as_next_scheduled_action')) { 3609 try { 3610 $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus'); 3611 } catch (Throwable $exception) { 3612 $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage()); 3613 } 3614 } 3615 3616 // A newly discovered transient failure may have a shorter backoff than 3617 // an older snapshot retry. Keep the earliest deadline, not merely any 3618 // existing action, so stale long delays cannot postpone recovery. 3619 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 3620 return true; 3621 } 3622 if ($existing_timestamp && function_exists('as_unschedule_all_actions')) { 3623 try { 3624 as_unschedule_all_actions($hook, $args, 'statpl-plus'); 3625 } catch (Throwable $exception) { 3626 $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage()); 3627 return true; 3628 } 3629 } elseif ($existing_timestamp) { 3630 return true; 3631 } elseif (function_exists('as_has_scheduled_action')) { 3632 try { 3633 if (as_has_scheduled_action($hook, $args, 'statpl-plus')) { 3634 // Without a reliable timestamp/removal API, retain the 3635 // existing action rather than creating an uncontrolled duplicate. 3636 return true; 3637 } 3638 } catch (Throwable $exception) { 3639 $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage()); 3640 } 3641 } 3642 3643 try { 3644 $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus'); 3645 if ($action_id) { 3646 return true; 3647 } 3648 } catch (Throwable $exception) { 3649 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 3650 } 3651 // Action Scheduler can be unavailable or fail transiently. Fall back to WP-Cron. 3652 } 3653 3654 // WP-Cron follows the same earliest-deadline rule. If replacing a later 3655 // event fails, retain it rather than creating a duplicate with uncertain order. 3656 try { 3657 $existing_timestamp = wp_next_scheduled($hook, $args); 3658 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 3659 return true; 3660 } 3661 if ($existing_timestamp) { 3662 $unscheduled = wp_unschedule_event((int) $existing_timestamp, $hook, $args); 3663 if (!$unscheduled || is_wp_error($unscheduled)) { 3664 $this->log_generic_historical_scheduler_error( 3665 $hook . '_unschedule', 3666 is_wp_error($unscheduled) ? $unscheduled->get_error_message() : 'Could not replace the later WP-Cron event.' 3667 ); 3668 return true; 3669 } 3670 } 3671 $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args); 3672 if (!$scheduled || is_wp_error($scheduled)) { 3673 $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.'); 3674 return false; 3675 } 3676 return true; 3677 } catch (Throwable $exception) { 3678 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 3679 return false; 3680 } 3681 } 3682 3683 /** Requeue persisted generic-import work after a previous scheduler outage. */ 3684 private function recover_unscheduled_generic_historical_work() { 3685 global $wpdb; 3686 3687 $origins = statpl_db_get_results( 3688 "SELECT source_id, origin_key, snapshot_data, status, detection_status 3689 FROM {$this->view_source_origins_table} 3690 WHERE detection_status = 'available' 3691 AND status IN ('pending', 'snapshot_ready', 'importing', 'retry_wait') 3692 ORDER BY updated_at ASC 3693 LIMIT 50", 3694 ARRAY_A 3695 ); 3696 if (!is_array($origins) || !empty($wpdb->last_error)) { 3697 $this->log_generic_historical_scheduler_error('recovery_scan', !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to read pending origins.'); 3698 return 0; 3699 } 3700 3701 $requeued = 0; 3702 foreach ($origins as $origin) { 3703 $source_id = absint($origin['source_id'] ?? 0); 3704 $origin_key = sanitize_key((string) ($origin['origin_key'] ?? '')); 3705 if (!$source_id || $origin_key === '') { 3706 continue; 3707 } 3708 3709 if (empty($origin['snapshot_data'])) { 3710 $scheduled = $this->schedule_generic_historical_snapshot_retry(300, $source_id, $origin_key); 3711 } else { 3712 $scheduled = $this->schedule_generic_historical_view_import_batch(300, $source_id, $origin_key); 3713 } 3714 if ($scheduled) { 3715 $requeued++; 3716 } 3717 } 3718 3719 // Retry rows are durable in the failure ledger. Recreate their worker 3720 // trigger independently of the main import cursor. 3721 $retry_origins = statpl_db_get_results( 3722 "SELECT source_id, origin_key, MIN(next_retry_at) AS next_retry_at 3723 FROM {$this->view_source_failures_table} 3724 WHERE status = 'retry' 3725 AND retry_checkpoint IS NOT NULL 3726 AND retry_checkpoint <> '' 3727 GROUP BY source_id, origin_key 3728 ORDER BY MIN(next_retry_at) ASC 3729 LIMIT 50", 3730 ARRAY_A 3731 ); 3732 if (is_array($retry_origins) && empty($wpdb->last_error)) { 3733 foreach ($retry_origins as $retry_origin) { 3734 $source_id = absint($retry_origin['source_id'] ?? 0); 3735 $origin_key = sanitize_key((string) ($retry_origin['origin_key'] ?? '')); 3736 if (!$source_id || $origin_key === '') { 3737 continue; 3738 } 3739 $next_timestamp = !empty($retry_origin['next_retry_at']) ? strtotime((string) $retry_origin['next_retry_at']) : false; 3740 $delay = $next_timestamp !== false ? max(1, $next_timestamp - current_time('timestamp')) : 300; 3741 if ($this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key)) { 3742 $requeued++; 3743 } 3744 } 3745 } elseif (!empty($wpdb->last_error)) { 3746 $this->log_generic_historical_scheduler_error('retry_recovery_scan', $wpdb->last_error); 3747 } 3748 3749 // Serialize the cursor read, bounded page, and cursor write. Per-source 3750 // locks protect each aggregate update; this scan-level lock additionally 3751 // prevents concurrent requests from moving the persistent cursor backwards. 3752 // Use the shared lock helper so acquisition, ownership checks and release 3753 // follow the same connection-identity contract as the other workers. 3754 $scan_lock_name = 'statpl_srcscan_' . substr(md5((string) $wpdb->prefix), 0, 32); 3755 $scan_lock = $this->acquire_generic_advisory_lock($scan_lock_name); 3756 if (empty($scan_lock['acquired'])) { 3757 $scan_status = sanitize_key((string) ($scan_lock['status'] ?? 'lock_unavailable')); 3758 if ($scan_status !== 'lock_busy_or_unavailable') { 3759 $this->log_generic_historical_scheduler_error( 3760 'source_status_scan_lock', 3761 (string) ($scan_lock['error'] ?? $scan_status) 3762 ); 3763 } 3764 } else { 3765 try { 3766 if (!$this->generic_advisory_lock_session_is_current($scan_lock)) { 3767 $this->log_generic_historical_scheduler_error( 3768 'source_status_scan_lock', 3769 'The MySQL connection changed immediately after acquiring the source-status scan lock; this scan was skipped.' 3770 ); 3771 } else { 3772 // Source-status reconciliation is durable work even when every 3773 // origin is already completed. A persistent keyset cursor ensures 3774 // that a busy or failing source cannot pin the first page and 3775 // starve later sources when more than 50 sources exist. 3776 $cursor_option = 'statpl_generic_status_recovery_cursor'; 3777 // The cursor is written directly with SQL while proving lock 3778 // ownership. Read it directly too: get_option() may return a 3779 // stale object-cache value because direct SQL bypasses the 3780 // WordPress options cache. 3781 $cursor_value = statpl_db_get_var(statpl_db_prepare( 3782 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 3783 $cursor_option 3784 )); 3785 if ($wpdb->last_error !== '') { 3786 $this->log_generic_historical_scheduler_error('source_status_recovery_cursor', $wpdb->last_error); 3787 $cursor_value = '0'; 3788 } 3789 $source_cursor = absint($cursor_value); 3790 $sources = statpl_db_get_results(statpl_db_prepare( 3791 "SELECT sources.id FROM {$this->view_sources_table} AS sources 3792 WHERE sources.id > %d 3793 AND EXISTS ( 3794 SELECT 1 FROM {$this->view_source_origins_table} AS origins 3795 WHERE origins.source_id = sources.id 3796 ) 3797 ORDER BY sources.id ASC 3798 LIMIT 50", 3799 $source_cursor 3800 ), ARRAY_A); 3801 3802 if (!is_array($sources) || !empty($wpdb->last_error)) { 3803 $this->log_generic_historical_scheduler_error( 3804 'source_status_recovery_scan', 3805 !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to read generic sources for status reconciliation.' 3806 ); 3807 } elseif ($this->generic_advisory_lock_session_is_current($scan_lock)) { 3808 // Wrap to the beginning only after reaching the end of the source set. 3809 $wrapped_to_start = false; 3810 if (!$sources && $source_cursor > 0) { 3811 $source_cursor = 0; 3812 $wrapped_to_start = true; 3813 $sources = statpl_db_get_results( 3814 "SELECT sources.id FROM {$this->view_sources_table} AS sources 3815 WHERE EXISTS ( 3816 SELECT 1 FROM {$this->view_source_origins_table} AS origins 3817 WHERE origins.source_id = sources.id 3818 ) 3819 ORDER BY sources.id ASC 3820 LIMIT 50", 3821 ARRAY_A 3822 ); 3823 if (!is_array($sources) || !empty($wpdb->last_error)) { 3824 $this->log_generic_historical_scheduler_error( 3825 'source_status_recovery_scan', 3826 !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to restart generic source status scan.' 3827 ); 3828 $sources = false; 3829 } 3830 } 3831 3832 if (is_array($sources) && $sources) { 3833 $last_scanned_source_id = $source_cursor; 3834 $scan_page_completed = true; 3835 foreach ($sources as $source) { 3836 // If wpdb transparently reconnects, the connection-scoped 3837 // scan lock is gone. Stop and leave the cursor unchanged; 3838 // already attempted rows are safe to revisit. 3839 if (!$this->generic_advisory_lock_session_is_current($scan_lock)) { 3840 $scan_page_completed = false; 3841 $this->log_generic_historical_scheduler_error( 3842 'source_status_scan_lock', 3843 'The MySQL connection changed during source reconciliation; the cursor was not advanced.' 3844 ); 3845 break; 3846 } 3847 3848 $source_id = absint($source['id'] ?? 0); 3849 if (!$source_id) { 3850 continue; 3851 } 3852 $last_scanned_source_id = max($last_scanned_source_id, $source_id); 3853 try { 3854 $reconciliation = $this->refresh_generic_historical_source_status($source_id); 3855 if (!in_array($reconciliation['status'] ?? '', ['updated', 'status_lock_busy', 'source_missing'], true)) { 3856 $this->log_generic_historical_scheduler_error( 3857 'source_status_recovery', 3858 'Source ' . $source_id . ' status reconciliation did not verify: ' . sanitize_key((string) ($reconciliation['status'] ?? 'unknown')) 3859 ); 3860 } 3861 } catch (Throwable $exception) { 3862 // One corrupt or temporarily failing source must not prevent 3863 // the cursor from reaching the remaining sources. 3864 $this->log_generic_historical_scheduler_error( 3865 'source_status_recovery', 3866 'Source ' . $source_id . ' reconciliation threw: ' . $exception->getMessage() 3867 ); 3868 } 3869 } 3870 3871 // Advance only after the complete bounded page was attempted 3872 // and the original scan-lock session is still alive. A source 3873 // failure or lock contention is attempted work, not a reason 3874 // to starve later IDs; a lost scan session is different. 3875 if ($scan_page_completed && $this->generic_advisory_lock_session_is_current($scan_lock)) { 3876 // Persist the cursor only in a SQL statement that proves 3877 // this exact connection still owns the scan lock. A PHP- 3878 // level check followed by update_option() is insufficient: 3879 // wpdb may reconnect between those calls and a stale scan 3880 // could overwrite the cursor of a newer lock owner. 3881 $cursor_persisted = statpl_db_query(statpl_db_prepare( 3882 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) 3883 SELECT %s, %s, 'no' 3884 WHERE IS_USED_LOCK(%s) = CONNECTION_ID() 3885 ON DUPLICATE KEY UPDATE option_value = IF( 3886 IS_USED_LOCK(%s) = CONNECTION_ID(), 3887 VALUES(option_value), 3888 option_value 3889 )", 3890 $cursor_option, 3891 (string) $last_scanned_source_id, 3892 $scan_lock_name, 3893 $scan_lock_name 3894 )); 3895 if (!in_array($cursor_persisted, [0, 1, 2], true)) { 3896 $this->log_generic_historical_scheduler_error( 3897 'source_status_recovery_cursor', 3898 !empty($wpdb->last_error) ? $wpdb->last_error : 'Could not persist the source-status recovery cursor while owning the scan lock.' 3899 ); 3900 } 3901 if (!$this->generic_advisory_lock_session_is_current($scan_lock)) { 3902 $this->log_generic_historical_scheduler_error( 3903 'source_status_recovery_cursor', 3904 'The MySQL connection changed while persisting the cursor; the next scan will safely re-evaluate the cursor.' 3905 ); 3906 } else { 3907 $persisted_cursor_value = statpl_db_get_var(statpl_db_prepare( 3908 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 3909 $cursor_option 3910 )); 3911 $persisted_cursor = absint($persisted_cursor_value); 3912 if (!empty($wpdb->last_error) || $persisted_cursor !== $last_scanned_source_id) { 3913 $this->log_generic_historical_scheduler_error('source_status_recovery_cursor', 'Could not verify the source-status recovery cursor.'); 3914 } 3915 } 3916 } 3917 } elseif (is_array($sources) && !$sources && $wrapped_to_start) { 3918 if ($this->generic_advisory_lock_session_is_current($scan_lock)) { 3919 // Reset only while the current session still owns the 3920 // scan lock; the predicate is checked atomically by SQL. 3921 $reset_result = statpl_db_query(statpl_db_prepare( 3922 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) 3923 SELECT %s, '0', 'no' 3924 WHERE IS_USED_LOCK(%s) = CONNECTION_ID() 3925 ON DUPLICATE KEY UPDATE option_value = IF( 3926 IS_USED_LOCK(%s) = CONNECTION_ID(), '0', option_value 3927 )", 3928 $cursor_option, 3929 $scan_lock_name, 3930 $scan_lock_name 3931 )); 3932 if ($reset_result === false || !empty($wpdb->last_error)) { 3933 $this->log_generic_historical_scheduler_error( 3934 'source_status_recovery_cursor', 3935 !empty($wpdb->last_error) ? $wpdb->last_error : 'Could not reset the source-status recovery cursor.' 3936 ); 3937 } elseif ($this->generic_advisory_lock_session_is_current($scan_lock)) { 3938 $reset_cursor = statpl_db_get_var(statpl_db_prepare( 3939 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 3940 $cursor_option 3941 )); 3942 if (!empty($wpdb->last_error) || absint($reset_cursor) !== 0) { 3943 $this->log_generic_historical_scheduler_error( 3944 'source_status_recovery_cursor', 3945 'Could not verify the reset source-status recovery cursor.' 3946 ); 3947 } 3948 } 3949 } 3950 } 3951 } else { 3952 $this->log_generic_historical_scheduler_error( 3953 'source_status_scan_lock', 3954 'The MySQL connection changed while reading the source-status page; this page was discarded.' 3955 ); 3956 } 3957 } 3958 } catch (Throwable $exception) { 3959 $this->log_generic_historical_scheduler_error('source_status_recovery_scan', $exception->getMessage()); 3960 } finally { 3961 $this->release_generic_advisory_lock($scan_lock, 'source_status_recovery_scan'); 3962 } 3963 } 3964 3965 return $requeued; 3966 } 3967 3968 /** Write diagnostic messages only when WordPress debugging is enabled. */ 3969 private function debug_log($message) { 3970 if (defined('WP_DEBUG') && WP_DEBUG) { 3971 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- This diagnostic logger writes only when the site owner explicitly enables WP_DEBUG. 3972 error_log((string) $message); 3973 } 3974 } 3975 3976 /** Log scheduler failures without allowing diagnostics to interrupt tracking. */ 3977 private function log_generic_historical_scheduler_error($hook, $message) { 3978 $this->debug_log('[Statora Plus] Historical scheduler failure for ' . sanitize_key((string) $hook) . ': ' . sanitize_text_field((string) $message)); 3979 } 3980 3981 /** Schedule retries for deferred records independently from the main import cursor. */ 3982 private function schedule_generic_historical_failure_retry($delay, $source_id, $origin_key) { 3983 $args = [absint($source_id), sanitize_key((string) $origin_key)]; 3984 $hook = 'statpl_generic_view_retry_failed_records'; 3985 $requested_timestamp = time() + max(1, (int) $delay); 3986 3987 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 3988 $existing_timestamp = false; 3989 if (function_exists('as_next_scheduled_action')) { 3990 try { 3991 $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus'); 3992 } catch (Throwable $exception) { 3993 $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage()); 3994 } 3995 } 3996 3997 // Keep an earlier/equal action, but replace a later one. Otherwise a 3998 // newly-created short-backoff failure could wait behind an older, 3999 // long-backoff retry for the same source-origin. 4000 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 4001 return true; 4002 } 4003 if ($existing_timestamp && function_exists('as_unschedule_all_actions')) { 4004 try { 4005 as_unschedule_all_actions($hook, $args, 'statpl-plus'); 4006 } catch (Throwable $exception) { 4007 // The existing action may still be queued. Retain it rather 4008 // than create a duplicate whose execution order is unknown. 4009 $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage()); 4010 return true; 4011 } 4012 } elseif (function_exists('as_has_scheduled_action')) { 4013 try { 4014 if (as_has_scheduled_action($hook, $args, 'statpl-plus')) { 4015 // Without reliable timestamp/removal APIs, retain the 4016 // existing action rather than duplicate the worker. 4017 return true; 4018 } 4019 } catch (Throwable $exception) { 4020 $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage()); 4021 } 4022 } 4023 try { 4024 $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus'); 4025 if ($action_id) { 4026 return true; 4027 } 4028 } catch (Throwable $exception) { 4029 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 4030 } 4031 // Fall through to WP-Cron if Action Scheduler did not accept the action. 4032 } 4033 4034 try { 4035 $existing_timestamp = wp_next_scheduled($hook, $args); 4036 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 4037 return true; 4038 } 4039 if ($existing_timestamp) { 4040 wp_unschedule_event((int) $existing_timestamp, $hook, $args); 4041 } 4042 $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args); 4043 if (!$scheduled || is_wp_error($scheduled)) { 4044 $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.'); 4045 return false; 4046 } 4047 return true; 4048 } catch (Throwable $exception) { 4049 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 4050 return false; 4051 } 4052 } 4053 4054 /** Acquire a connection-scoped advisory lock and capture its owning session. */ 4055 private function acquire_generic_advisory_lock($lock_name) { 4056 global $wpdb; 4057 try { 4058 $row = statpl_db_get_row(statpl_db_prepare( 4059 'SELECT CONNECTION_ID() AS connection_id, GET_LOCK(%s, 0) AS lock_acquired', 4060 (string) $lock_name 4061 ), ARRAY_A); 4062 } catch (Throwable $exception) { 4063 return ['acquired' => false, 'status' => 'lock_error', 'error' => $exception->getMessage()]; 4064 } 4065 if (!is_array($row) || !empty($wpdb->last_error) || (string) ($row['lock_acquired'] ?? '') !== '1' || empty($row['connection_id'])) { 4066 return ['acquired' => false, 'status' => 'lock_busy_or_unavailable', 'error' => (string) $wpdb->last_error]; 4067 } 4068 return ['acquired' => true, 'connection_id' => (string) $row['connection_id'], 'lock_name' => (string) $lock_name]; 4069 } 4070 4071 /** Roll back an open import transaction only on its original DB session. */ 4072 private function rollback_generic_import_transaction(&$transaction_open, $origin_lock) { 4073 global $wpdb; 4074 if (!$transaction_open) { 4075 return true; 4076 } 4077 // Never issue transaction control on a transparently reconnected session. 4078 // The caller may retry once from finally if this attempt fails. 4079 for ($attempt = 0; $attempt < 1; $attempt++) { 4080 if (!$this->generic_db_session_matches_lock($origin_lock)) { 4081 return false; 4082 } 4083 $result = statpl_db_query('ROLLBACK'); 4084 if ($result !== false) { 4085 $transaction_open = false; 4086 return true; 4087 } 4088 } 4089 return false; 4090 } 4091 4092 /** Confirm the current wpdb connection is the session that acquired a lock, regardless of lock ownership. */ 4093 private function generic_db_session_matches_lock($lock) { 4094 global $wpdb; 4095 if (!is_array($lock) || empty($lock['acquired']) || empty($lock['connection_id'])) { 4096 return false; 4097 } 4098 try { 4099 $connection_id = statpl_db_get_var('SELECT CONNECTION_ID()'); 4100 } catch (Throwable $exception) { 4101 return false; 4102 } 4103 return $connection_id !== null 4104 && empty($wpdb->last_error) 4105 && (string) $connection_id === (string) $lock['connection_id']; 4106 } 4107 4108 /** Confirm the current wpdb session still owns the lock-acquiring connection. */ 4109 private function generic_advisory_lock_session_is_current($lock) { 4110 global $wpdb; 4111 if (!is_array($lock) || empty($lock['acquired']) || empty($lock['connection_id'])) { 4112 return false; 4113 } 4114 try { 4115 // Session identity alone is insufficient: code running on the same 4116 // connection could have released or replaced the named lock. Verify 4117 // both the connection and the actual lock owner in one SQL read. 4118 $row = statpl_db_get_row(statpl_db_prepare( 4119 'SELECT CONNECTION_ID() AS connection_id, IS_USED_LOCK(%s) AS lock_owner', 4120 (string) $lock['lock_name'] 4121 ), ARRAY_A); 4122 } catch (Throwable $exception) { 4123 return false; 4124 } 4125 return is_array($row) 4126 && empty($wpdb->last_error) 4127 && (string) ($row['connection_id'] ?? '') === (string) $lock['connection_id'] 4128 && (string) ($row['lock_owner'] ?? '') === (string) $lock['connection_id']; 4129 } 4130 4131 /** Release only on the original session; a lost session releases its own locks server-side. */ 4132 private function release_generic_advisory_lock($lock, $context = '') { 4133 global $wpdb; 4134 if (!$this->generic_advisory_lock_session_is_current($lock)) { 4135 if (defined('WP_DEBUG') && WP_DEBUG) { 4136 $this->debug_log('Statora Plus: advisory lock session changed before release' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . '.'); 4137 } 4138 return false; 4139 } 4140 try { 4141 $released = statpl_db_get_var(statpl_db_prepare('SELECT RELEASE_LOCK(%s)', (string) $lock['lock_name'])); 4142 if ((string) $released === '1' && empty($wpdb->last_error)) { 4143 return true; 4144 } 4145 } catch (Throwable $exception) { 4146 if (defined('WP_DEBUG') && WP_DEBUG) { 4147 $this->debug_log('Statora Plus: advisory lock release failed' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . ': ' . $exception->getMessage()); 4148 } 4149 return false; 4150 } 4151 if (defined('WP_DEBUG') && WP_DEBUG) { 4152 $this->debug_log('Statora Plus: advisory lock release was not confirmed' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . '.'); 4153 } 4154 return false; 4155 } 4156 4157 /** Replay due failed records from their saved immutable batch checkpoint. */ 4158 public function retry_generic_historical_view_failures($source_id = 0, $origin_key = '') { 4159 global $wpdb; 4160 $source_id = absint($source_id); 4161 $origin_key = sanitize_key((string) $origin_key); 4162 if (!$source_id || $origin_key === '') { 4163 return ['status' => 'invalid_input']; 4164 } 4165 4166 $now = current_time('mysql'); 4167 $failure = statpl_db_get_row(statpl_db_prepare( 4168 "SELECT retry_checkpoint, next_retry_at FROM {$this->view_source_failures_table} 4169 WHERE source_id = %d AND origin_key = %s AND status = 'retry' AND next_retry_at <= %s 4170 AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> '' 4171 ORDER BY next_retry_at ASC, id ASC LIMIT 1", 4172 $source_id, 4173 $origin_key, 4174 $now 4175 ), ARRAY_A); 4176 if (!empty($wpdb->last_error)) { 4177 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4178 return ['status' => 'failure_lookup_error']; 4179 } 4180 if (!$failure) { 4181 $next = statpl_db_get_var(statpl_db_prepare( 4182 "SELECT MIN(next_retry_at) FROM {$this->view_source_failures_table} 4183 WHERE source_id = %d AND origin_key = %s AND status = 'retry' 4184 AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''", 4185 $source_id, 4186 $origin_key 4187 )); 4188 if (!empty($wpdb->last_error)) { 4189 // A failed deadline lookup is not evidence that the retry queue is 4190 // empty. Preserve the worker trigger and avoid status aggregation 4191 // from an incomplete view of the durable failure ledger. 4192 $this->log_generic_historical_scheduler_error('retry_ledger_next_deadline', $wpdb->last_error); 4193 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4194 return ['status' => 'failure_ledger_deadline_failed']; 4195 } 4196 if ($next) { 4197 $next_timestamp = strtotime((string) $next); 4198 $delay = ($next_timestamp !== false) ? max(1, $next_timestamp - current_time('timestamp')) : 300; 4199 $this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key); 4200 } 4201 $this->refresh_generic_historical_source_status($source_id); 4202 return ['status' => 'no_due_failures']; 4203 } 4204 4205 $lock_name = 'statpl_generic_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_id . ':' . $origin_key), 0, 40); 4206 $origin_lock = $this->acquire_generic_advisory_lock($lock_name); 4207 if (empty($origin_lock['acquired'])) { 4208 $this->schedule_generic_historical_failure_retry(30, $source_id, $origin_key); 4209 return ['status' => 'worker_already_running']; 4210 } 4211 4212 try { 4213 // The initial lookup above only determines whether this worker appears 4214 // eligible. Re-read the durable row after acquiring the same origin lock 4215 // used by the main importer: another worker may have resolved or changed 4216 // this retry while we were waiting. Never replay a stale checkpoint. 4217 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 4218 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4219 return ['status' => 'origin_lock_session_lost']; 4220 } 4221 $locked_failure = statpl_db_get_row(statpl_db_prepare( 4222 "SELECT retry_checkpoint, next_retry_at FROM {$this->view_source_failures_table} 4223 WHERE source_id = %d AND origin_key = %s AND status = 'retry' 4224 AND next_retry_at <= %s AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> '' 4225 ORDER BY next_retry_at ASC, id ASC LIMIT 1", 4226 $source_id, 4227 $origin_key, 4228 current_time('mysql') 4229 ), ARRAY_A); 4230 if (!empty($wpdb->last_error)) { 4231 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4232 return ['status' => 'failure_lookup_error']; 4233 } 4234 if (!$locked_failure) { 4235 return ['status' => 'retry_no_longer_due']; 4236 } 4237 4238 $retry_checkpoint = json_decode($locked_failure['retry_checkpoint'], true); 4239 if (!is_array($retry_checkpoint) || !$retry_checkpoint) { 4240 // Corrupt replay metadata is not silently discarded or counted as a view. 4241 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4242 return ['status' => 'invalid_retry_checkpoint']; 4243 } 4244 4245 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 4246 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4247 return ['status' => 'origin_lock_session_lost']; 4248 } 4249 $result = $this->process_generic_historical_view_import_batch($source_id, $origin_key, $retry_checkpoint, $origin_lock); 4250 } finally { 4251 $this->release_generic_advisory_lock($origin_lock, 'retry_origin'); 4252 } 4253 4254 // The processing lock above protects replay itself. Reacquire the same 4255 // origin lock before reconciling ledger counts and origin status: the main 4256 // importer may have started immediately after the replay lock was released. 4257 // Re-reading under the lock prevents this worker from applying stale status 4258 // decisions over a newer main-import checkpoint/state transition. 4259 $post_origin_lock = $this->acquire_generic_advisory_lock($lock_name); 4260 if (empty($post_origin_lock['acquired'])) { 4261 $this->schedule_generic_historical_failure_retry(30, $source_id, $origin_key); 4262 return ['status' => 'post_processing_lock_busy']; 4263 } 4264 4265 try { 4266 if (!$this->generic_advisory_lock_session_is_current($post_origin_lock)) { 4267 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4268 return ['status' => 'post_processing_lock_session_lost']; 4269 } 4270 $remaining_value = statpl_db_get_var(statpl_db_prepare( 4271 "SELECT COUNT(*) FROM {$this->view_source_failures_table} 4272 WHERE source_id = %d AND origin_key = %s AND status = 'retry' 4273 AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''", 4274 $source_id, 4275 $origin_key 4276 )); 4277 if (!empty($wpdb->last_error) || $remaining_value === null) { 4278 // A failed count must never be interpreted as an empty ledger. Keep 4279 // the worker alive and let the next pass re-read durable retry rows. 4280 $this->log_generic_historical_scheduler_error('retry_ledger_count', $wpdb->last_error ?: 'Retry ledger count returned NULL.'); 4281 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4282 return ['status' => 'failure_ledger_count_failed']; 4283 } 4284 $remaining = (int) $remaining_value; 4285 if ($remaining > 0) { 4286 $next = statpl_db_get_var(statpl_db_prepare( 4287 "SELECT MIN(next_retry_at) FROM {$this->view_source_failures_table} 4288 WHERE source_id = %d AND origin_key = %s AND status = 'retry' 4289 AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''", 4290 $source_id, 4291 $origin_key 4292 )); 4293 if (!empty($wpdb->last_error)) { 4294 $this->log_generic_historical_scheduler_error('retry_ledger_deadline', $wpdb->last_error); 4295 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4296 return ['status' => 'failure_ledger_deadline_failed', 'remaining' => $remaining]; 4297 } 4298 $next_timestamp = $next ? strtotime((string) $next) : false; 4299 $delay = ($next_timestamp !== false) ? max(1, $next_timestamp - current_time('timestamp')) : 300; 4300 $this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key); 4301 } else { 4302 $blocked_value = statpl_db_get_var(statpl_db_prepare( 4303 "SELECT COUNT(*) FROM {$this->view_source_failures_table} 4304 WHERE source_id = %d AND origin_key = %s AND status = 'blocked'", 4305 $source_id, 4306 $origin_key 4307 )); 4308 if (!empty($wpdb->last_error) || $blocked_value === null) { 4309 $this->log_generic_historical_scheduler_error('blocked_ledger_count', $wpdb->last_error ?: 'Blocked ledger count returned NULL.'); 4310 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4311 return ['status' => 'failure_ledger_count_failed']; 4312 } 4313 $blocked = (int) $blocked_value; 4314 $current_state = $this->get_view_source_origin_state($source_id, $origin_key); 4315 $origin_status = $current_state['origin_status'] ?? ''; 4316 if (in_array($origin_status, ['pending', 'snapshot_ready', 'importing', 'retry_wait'], true)) { 4317 // The retry worker cannot infer whether the main scan has reached 4318 // the immutable snapshot boundary. This remains true when some 4319 // records are already blocked: the main cursor may still have 4320 // later batches to import. Resume the main scan and let its own 4321 // has_more result, together with the durable ledger counts, decide 4322 // whether the final state is completed or needs_attention. 4323 $updates = [ 4324 'status' => 'importing', 4325 'completed_at' => null, 4326 ]; 4327 if ($blocked === 0) { 4328 $updates['last_error'] = null; 4329 } 4330 $origin_state_result = $this->update_view_source_origin_state($source_id, $origin_key, $updates, $post_origin_lock); 4331 if (!in_array($origin_state_result['status'] ?? '', ['updated'], true)) { 4332 // Do not treat a failed/ambiguous state transition as a 4333 // successful retry finalization. Keep recovery scheduled. 4334 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4335 return [ 4336 'status' => 'origin_state_recovery_failed', 4337 'origin_state' => $origin_state_result, 4338 'result' => $result, 4339 'remaining' => $remaining, 4340 ]; 4341 } 4342 $this->schedule_generic_historical_view_import_batch(5, $source_id, $origin_key); 4343 } elseif ($blocked > 0 && $origin_status !== 'needs_attention') { 4344 // Only a non-progressing, nonterminal origin may be finalized here. 4345 // Never downgrade an already-finalized attention state. 4346 $origin_state_result = $this->update_view_source_origin_state($source_id, $origin_key, [ 4347 'status' => 'needs_attention', 4348 'last_error' => 'Some historical records could not be recovered after three total attempts.', 4349 ], $post_origin_lock); 4350 if (!in_array($origin_state_result['status'] ?? '', ['updated'], true)) { 4351 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4352 return [ 4353 'status' => 'origin_state_recovery_failed', 4354 'origin_state' => $origin_state_result, 4355 'result' => $result, 4356 'remaining' => $remaining, 4357 ]; 4358 } 4359 } 4360 } 4361 4362 // Ledger reconciliation may itself encounter a reconnect after the 4363 // post-processing lock was acquired. Never aggregate source status unless 4364 // this request still owns that exact MySQL lock session. 4365 if ($this->generic_advisory_lock_session_is_current($post_origin_lock)) { 4366 $this->refresh_generic_historical_source_status($source_id); 4367 } else { 4368 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4369 return ['status' => 'post_processing_session_lost', 'result' => $result, 'remaining' => $remaining]; 4370 } 4371 return ['status' => 'retry_processed', 'result' => $result, 'remaining' => $remaining]; 4372 } finally { 4373 $this->release_generic_advisory_lock($post_origin_lock, 'retry_post_processing'); 4374 } 4375 } 4376 4377 /** Return a discovered adapter instance for later snapshot/import workers. */ 4378 private function get_historical_view_adapter($adapter_key) { 4379 $adapter_key = sanitize_key((string) $adapter_key); 4380 return isset($this->historical_view_adapters[$adapter_key]) 4381 ? $this->historical_view_adapters[$adapter_key] 4382 : null; 4383 } 4384 4385 /** Schedule the generic historical importer without overlapping scheduled runs. */ 4386 private function schedule_generic_historical_view_import_batch($delay = 10, $source_id = 0, $origin_key = '') { 4387 $args = [(int) $source_id, (string) $origin_key]; 4388 $hook = 'statpl_generic_view_import_batch'; 4389 $requested_timestamp = time() + max(1, (int) $delay); 4390 4391 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 4392 $existing_timestamp = false; 4393 if (function_exists('as_next_scheduled_action')) { 4394 try { 4395 $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus'); 4396 } catch (Throwable $exception) { 4397 $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage()); 4398 } 4399 } 4400 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 4401 return true; 4402 } 4403 if ($existing_timestamp && function_exists('as_unschedule_all_actions')) { 4404 try { 4405 as_unschedule_all_actions($hook, $args, 'statpl-plus'); 4406 } catch (Throwable $exception) { 4407 $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage()); 4408 return true; 4409 } 4410 } elseif ($existing_timestamp) { 4411 return true; 4412 } elseif (function_exists('as_has_scheduled_action')) { 4413 try { 4414 if (as_has_scheduled_action($hook, $args, 'statpl-plus')) { 4415 // Without reliable timing/removal APIs, retain the existing action. 4416 return true; 4417 } 4418 } catch (Throwable $exception) { 4419 $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage()); 4420 } 4421 } 4422 try { 4423 $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus'); 4424 if ($action_id) { 4425 return true; 4426 } 4427 } catch (Throwable $exception) { 4428 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 4429 } 4430 // Fall through to WP-Cron if Action Scheduler did not accept the action. 4431 } 4432 4433 try { 4434 $existing_timestamp = wp_next_scheduled($hook, $args); 4435 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 4436 return true; 4437 } 4438 if ($existing_timestamp) { 4439 wp_unschedule_event((int) $existing_timestamp, $hook, $args); 4440 } 4441 $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args); 4442 if (!$scheduled || is_wp_error($scheduled)) { 4443 $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.'); 4444 return false; 4445 } 4446 return true; 4447 } catch (Throwable $exception) { 4448 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 4449 return false; 4450 } 4451 } 4452 4453 /** Persist a generic-source failure with bounded exponential retry metadata. */ 4454 private function record_generic_historical_view_failure($source_id, $origin_key, $record_key, $product_id, $error, $retry_checkpoint = null, $max_attempts = 8, $lock_context = null) { 4455 global $wpdb; 4456 4457 $source_id = absint($source_id); 4458 if (!$this->generic_advisory_lock_session_is_current($lock_context)) { 4459 return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost']; 4460 } 4461 $record_key = substr((string) $record_key, 0, 191); 4462 if ($record_key === '') { 4463 $record_key = 'batch:' . substr(hash('sha256', (string) $origin_key), 0, 32); 4464 } 4465 $record_hash = hash('sha256', (string) $origin_key . "\0" . $record_key); 4466 $existing = statpl_db_get_row(statpl_db_prepare( 4467 "SELECT id, attempts, status, next_retry_at, retry_checkpoint, last_error FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s LIMIT 1", 4468 $source_id, 4469 $record_hash 4470 ), ARRAY_A); 4471 if (!empty($wpdb->last_error)) { 4472 $this->log_generic_historical_scheduler_error('failure_ledger_lookup', $wpdb->last_error); 4473 return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_lookup_failed']; 4474 } 4475 if (!$this->generic_advisory_lock_session_is_current($lock_context)) { 4476 return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost']; 4477 } 4478 $now = current_time('mysql'); 4479 if ($existing && isset($existing['status']) && $existing['status'] === 'blocked') { 4480 // The ledger row may have been committed even if the corresponding 4481 // origin-state update failed on the previous pass. Repair that 4482 // derived state on every subsequent locked observation without 4483 // incrementing attempts or rewriting the durable failure record. 4484 $existing_origin_status = empty($existing['retry_checkpoint']) ? 'needs_attention' : 'retry_wait'; 4485 $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [ 4486 'last_error' => substr((string) ($existing['last_error'] ?? ''), 0, 2000), 4487 'status' => $existing_origin_status, 4488 ], $lock_context); 4489 if (empty($origin_state_saved['status']) || $origin_state_saved['status'] !== 'updated') { 4490 $this->log_generic_historical_scheduler_error('failure_origin_state_recovery', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to recover origin state from the existing blocked failure record.')); 4491 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 4492 return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_recovery_failed']; 4493 } 4494 return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'already_blocked']; 4495 } 4496 if ($existing && !empty($existing['next_retry_at']) && $existing['next_retry_at'] > $now) { 4497 $retry_at = strtotime((string) $existing['next_retry_at']); 4498 $now_at = strtotime((string) $now); 4499 $remaining = ($retry_at !== false && $now_at !== false) ? max(1, $retry_at - $now_at) : 60; 4500 $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [ 4501 'last_error' => substr((string) ($existing['last_error'] ?? ''), 0, 2000), 4502 'status' => 'retry_wait', 4503 ], $lock_context); 4504 if (empty($origin_state_saved['status']) || $origin_state_saved['status'] !== 'updated') { 4505 $this->log_generic_historical_scheduler_error('failure_origin_state_recovery', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to recover origin state from the existing retry failure record.')); 4506 $this->schedule_generic_historical_failure_retry(min(86400, $remaining), $source_id, $origin_key); 4507 return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_recovery_failed']; 4508 } 4509 return ['attempts' => (int) $existing['attempts'], 'blocked' => false, 'persisted' => true, 'retry_delay' => min(86400, $remaining), 'status' => 'retry_already_scheduled']; 4510 } 4511 4512 $attempts = $existing ? ((int) $existing['attempts'] + 1) : 1; 4513 $max_attempts = max(1, absint($max_attempts)); 4514 $blocked = $attempts >= $max_attempts; 4515 $delay = min(86400, 60 * (2 ** min(10, max(0, $attempts - 1)))); 4516 $next_retry = $blocked ? null : current_datetime()->modify('+' . $delay . ' seconds')->format('Y-m-d H:i:s'); 4517 $data = [ 4518 'source_id' => $source_id, 4519 'origin_key' => sanitize_key((string) $origin_key), 4520 'source_record_key' => $record_key, 4521 'source_record_hash' => $record_hash, 4522 'product_id' => absint($product_id), 4523 'attempts' => $attempts, 4524 'last_error' => substr(sanitize_textarea_field((string) $error), 0, 60000), 4525 'status' => $blocked ? 'blocked' : 'retry', 4526 'next_retry_at' => $next_retry, 4527 'retry_checkpoint' => is_array($retry_checkpoint) ? wp_json_encode($retry_checkpoint) : null, 4528 'updated_at' => $now, 4529 ]; 4530 $allowed_columns = [ 4531 'source_id', 'origin_key', 'source_record_key', 'source_record_hash', 4532 'product_id', 'attempts', 'last_error', 'status', 'next_retry_at', 4533 'retry_checkpoint', 'updated_at', 4534 ]; 4535 $columns = array_keys($data); 4536 if (array_diff($columns, $allowed_columns) || array_diff($allowed_columns, $columns)) { 4537 $this->log_generic_historical_scheduler_error('failure_ledger_schema', 'Failure ledger data did not match the fixed column allowlist.'); 4538 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_schema_mismatch']; 4539 } 4540 $placeholders = []; 4541 $values = []; 4542 foreach ($allowed_columns as $column) { 4543 $value = $data[$column]; 4544 $placeholders[] = is_int($value) ? '%d' : '%s'; 4545 $values[] = $value; 4546 } 4547 $lock_name = (string) ($lock_context['lock_name'] ?? ''); 4548 if ($lock_name === '' || !$this->generic_advisory_lock_session_is_current($lock_context)) { 4549 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost']; 4550 } 4551 4552 if ($existing) { 4553 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The SET placeholders are generated from the fixed allowlist; update_values is generated from the same allowlist with the same immutable key columns excluded, followed by id and lock_name. 4554 $set = []; 4555 foreach ($allowed_columns as $column) { 4556 if (in_array($column, ['source_id', 'source_record_hash'], true)) { 4557 continue; 4558 } 4559 $value = $data[$column]; 4560 $set[] = "`{$column}` = " . (is_int($value) ? '%d' : '%s'); 4561 } 4562 $update_values = []; 4563 foreach ($allowed_columns as $column) { 4564 if (!in_array($column, ['source_id', 'source_record_hash'], true)) { 4565 $update_values[] = $data[$column]; 4566 } 4567 } 4568 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The SET placeholder list and update_values are built in the same pass over the fixed 11-column allowlist, excluding the same two immutable key columns; the two trailing placeholders are always id and lock_name. 4569 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4570 $query = /* phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder fragments and their values are assembled together from the same fixed allowlists; optional lock predicates and matching values are added together. */ statpl_db_prepare( 4571 "UPDATE {$this->view_source_failures_table} SET " . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $set) . " WHERE id = %d AND IS_USED_LOCK(%s) = CONNECTION_ID()", 4572 array_merge($update_values, [(int) $existing['id'], $lock_name]) 4573 ); 4574 } else { 4575 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4576 $query = statpl_db_prepare( 4577 "INSERT INTO {$this->view_source_failures_table} (" . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ array_map(function ($column) { return "`{$column}`"; }, $columns)) . ") SELECT " . implode(', ', $placeholders) . " WHERE IS_USED_LOCK(%s) = CONNECTION_ID()", 4578 array_merge($values, [$lock_name]) 4579 ); 4580 } 4581 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query is assigned exclusively from statpl_db_prepare() in both branches; dynamic identifiers and column names come from fixed allowlists, and all data values use placeholders. 4582 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4583 $persisted = statpl_db_query($query); 4584 if ($persisted === false || $wpdb->last_error !== '') { 4585 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_write_failed', 'error' => $wpdb->last_error]; 4586 } 4587 if ($persisted !== 1) { 4588 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost']; 4589 } 4590 if (strpos($record_key, 'batch:') !== 0 && $record_key !== 'snapshot_initialization') { 4591 $this->save_generic_historical_report_record($source_id, $origin_key, $record_hash, $blocked ? 'failed' : 'retry', $attempts, 'record_import_failed'); 4592 } 4593 4594 $origin_status = ($blocked && !is_array($retry_checkpoint)) ? 'needs_attention' : 'retry_wait'; 4595 $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [ 4596 'last_error' => substr((string) $error, 0, 2000), 4597 'status' => $origin_status, 4598 ], $lock_context); 4599 if (empty($origin_state_saved['status']) || !in_array($origin_state_saved['status'], ['updated'], true)) { 4600 $this->log_generic_historical_scheduler_error('failure_origin_state', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to update origin state after recording a failure.')); 4601 // The failure row is durable, but its derived origin status may be 4602 // stale. Ensure another locked pass is scheduled to repair the 4603 // origin state; do not rely on callers, which correctly stop when 4604 // this operation reports blocked=true. 4605 if (is_array($retry_checkpoint)) { 4606 $this->schedule_generic_historical_failure_retry(max(60, $delay), $source_id, $origin_key); 4607 } else { 4608 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 4609 } 4610 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_update_failed']; 4611 } 4612 4613 return ['attempts' => $attempts, 'blocked' => $blocked, 'persisted' => true, 'retry_delay' => $blocked ? 0 : $delay]; 4614 } 4615 4616 /** Clear a previously recorded failure after its record is safely handled. */ 4617 private function clear_generic_historical_view_failure($source_id, $origin_key, $record_key, $lock_context = null, $report_outcome = 'imported') { 4618 global $wpdb; 4619 if (!$this->generic_advisory_lock_session_is_current($lock_context)) { 4620 return false; 4621 } 4622 $record_key = substr((string) $record_key, 0, 191); 4623 if ($record_key === '') { 4624 return false; 4625 } 4626 $record_hash = hash('sha256', (string) $origin_key . "\0" . $record_key); 4627 $existing_failure = statpl_db_get_row(statpl_db_prepare( 4628 "SELECT attempts FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s LIMIT 1", 4629 absint($source_id), $record_hash 4630 ), ARRAY_A); 4631 if (!empty($wpdb->last_error)) { return false; } 4632 if ($report_outcome === 'imported' && strpos($record_key, 'batch:') !== 0 && $record_key !== 'snapshot_initialization') { 4633 $attempts = is_array($existing_failure) ? (int) ($existing_failure['attempts'] ?? 0) : 0; 4634 if (!$this->save_generic_historical_report_record($source_id, $origin_key, $record_hash, 'imported', $attempts, '')) { return false; } 4635 } 4636 $lock_name = (string) ($lock_context['lock_name'] ?? ''); 4637 if ($lock_name === '' || !$this->generic_advisory_lock_session_is_current($lock_context)) { 4638 return false; 4639 } 4640 $deleted = statpl_db_query(statpl_db_prepare( 4641 "DELETE FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s AND IS_USED_LOCK(%s) = CONNECTION_ID()", 4642 absint($source_id), 4643 $record_hash, 4644 $lock_name 4645 )); 4646 if ($deleted === false || $wpdb->last_error !== '') { 4647 $this->log_generic_historical_scheduler_error('failure_ledger_clear', $wpdb->last_error ?: 'Unable to clear a resolved failure record.'); 4648 return false; 4649 } 4650 // A zero-row DELETE is ambiguous: the failure row may already be absent, 4651 // or the SQL ownership predicate may have rejected the operation after a 4652 // reconnect/lock loss. Only treat the former as successful, and confirm 4653 // that this worker still owns the original lock session after the query. 4654 if (!$this->generic_advisory_lock_session_is_current($lock_context)) { 4655 return false; 4656 } 4657 return true; 4658 } 4659 4660 /** 4661 * Process one generic source-origin batch. Each accepted record is committed 4662 * atomically with its source event and product total; the checkpoint advances 4663 * only after the record is safely applied or recognized as an exact duplicate. 4664 */ 4665 public function run_generic_historical_view_import_batch($source_id = 0, $origin_key = '') { 4666 global $wpdb; 4667 4668 $source_id = absint($source_id); 4669 $origin_key = sanitize_key((string) $origin_key); 4670 if (!$source_id || $origin_key === '') { 4671 return ['status' => 'invalid_input']; 4672 } 4673 4674 // A connection-scoped MySQL advisory lock prevents two cron/Action 4675 // Scheduler workers from advancing the same source-origin checkpoint. 4676 // The lock is released automatically if PHP or the DB connection dies. 4677 $lock_name = 'statpl_generic_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_id . ':' . $origin_key), 0, 40); 4678 $origin_lock = $this->acquire_generic_advisory_lock($lock_name); 4679 if (empty($origin_lock['acquired'])) { 4680 $this->schedule_generic_historical_view_import_batch(30, $source_id, $origin_key); 4681 return ['status' => 'worker_already_running']; 4682 } 4683 4684 try { 4685 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 4686 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 4687 return ['status' => 'origin_lock_session_lost']; 4688 } 4689 $result = $this->process_generic_historical_view_import_batch($source_id, $origin_key, null, $origin_lock); 4690 // The processing method can return after detecting a lost MySQL session. 4691 // Do not start a new source-status write on a transparently reconnected 4692 // session: it would run outside the origin lock that serialized this worker. 4693 if ($this->generic_advisory_lock_session_is_current($origin_lock)) { 4694 $this->refresh_generic_historical_source_status($source_id); 4695 } else { 4696 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 4697 } 4698 return $result; 4699 } finally { 4700 $this->release_generic_advisory_lock($origin_lock, 'main_importer'); 4701 } 4702 } 4703 4704 /** Validate a normalized source datetime without allowing silent MySQL coercion. */ 4705 private function is_valid_generic_historical_datetime($value) { 4706 if ($value === null || $value === '') { 4707 return true; 4708 } 4709 if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)) { 4710 return false; 4711 } 4712 $date = DateTime::createFromFormat('!Y-m-d H:i:s', $value); 4713 $errors = DateTime::getLastErrors(); 4714 return $date instanceof DateTime 4715 && ($errors === false || (empty($errors['warning_count']) && empty($errors['error_count']))) 4716 && $date->format('Y-m-d H:i:s') === $value; 4717 } 4718 4719 /** Accept only a JSON-safe scalar/array cursor that can be persisted and resumed. */ 4720 private function is_valid_generic_historical_cursor($cursor) { 4721 if ($cursor === null || is_resource($cursor) || is_object($cursor)) { 4722 return false; 4723 } 4724 $encoded = wp_json_encode($cursor); 4725 return is_string($encoded) && strlen($encoded) <= 60000; 4726 } 4727 4728 /** 4729 * Discover overlap candidates fairly across source IDs. A global LIMIT can let 4730 * a high-volume source crowd every other adapter out of the reconciliation set. 4731 * The per-source fetch is bounded, then candidates are interleaved round-robin. 4732 */ 4733 private function get_generic_reconciliation_candidates_by_source($mode, $criteria, $limit = 25) { 4734 global $wpdb; 4735 4736 if (!is_array($criteria)) { 4737 return ['status' => 'error', 'error' => 'Invalid reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false]; 4738 } 4739 if ($mode === 'fingerprint') { 4740 $fingerprint = isset($criteria['fingerprint']) ? (string) $criteria['fingerprint'] : ''; 4741 $source_id = isset($criteria['source_id']) ? absint($criteria['source_id']) : 0; 4742 if ($fingerprint === '' || !$source_id) { 4743 return ['status' => 'error', 'error' => 'Invalid fingerprint reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false]; 4744 } 4745 $where_sql = 'events.source_fingerprint = %s AND events.source_id <> %d'; 4746 $where_args = [$fingerprint, $source_id]; 4747 } elseif ($mode === 'temporal') { 4748 $source_id = isset($criteria['source_id']) ? absint($criteria['source_id']) : 0; 4749 $product_id = isset($criteria['product_id']) ? absint($criteria['product_id']) : 0; 4750 $period_start = isset($criteria['period_start']) ? (string) $criteria['period_start'] : ''; 4751 $period_end = isset($criteria['period_end']) ? (string) $criteria['period_end'] : ''; 4752 if (!$source_id || !$product_id || $period_start === '' || $period_end === '') { 4753 return ['status' => 'error', 'error' => 'Invalid temporal reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false]; 4754 } 4755 $where_sql = 'events.source_id <> %d AND events.product_id = %d AND events.period_start <= %s AND events.period_end >= %s'; 4756 $where_args = [$source_id, $product_id, $period_end, $period_start]; 4757 } else { 4758 return ['status' => 'error', 'error' => 'Unsupported reconciliation mode.', 'events' => [], 'observed' => 0, 'truncated' => false]; 4759 } 4760 $limit = max(1, min(25, absint($limit))); 4761 $reconciliation_sql = "SELECT DISTINCT events.source_id 4762 FROM {$this->view_source_events_table} AS events 4763 WHERE {$where_sql} 4764 ORDER BY events.source_id ASC"; 4765 // The criteria branches above define either four temporal placeholders 4766 // or two exact-match placeholders; prepare receives the matching values. 4767 if ($mode === 'temporal') { 4768 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4769 $source_ids = statpl_db_get_col(statpl_db_prepare( 4770 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $reconciliation_sql, 4771 $source_id, 4772 $product_id, 4773 $period_end, 4774 $period_start 4775 )); 4776 } else { 4777 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4778 $source_ids = statpl_db_get_col(statpl_db_prepare( 4779 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $reconciliation_sql, 4780 $fingerprint, 4781 $source_id 4782 )); 4783 } 4784 if (!empty($wpdb->last_error)) { 4785 return ['status' => 'error', 'error' => (string) $wpdb->last_error, 'events' => [], 'observed' => 0, 'truncated' => false]; 4786 } 4787 4788 $per_source = []; 4789 $observed = 0; 4790 $truncated = false; 4791 foreach ((array) $source_ids as $candidate_source_id) { 4792 $candidate_source_id = absint($candidate_source_id); 4793 if (!$candidate_source_id) { 4794 continue; 4795 } 4796 $rows = statpl_db_get_results(statpl_db_prepare( 4797 "SELECT events.id, events.source_id, events.source_record_key, 4798 sources.source_key AS candidate_source_key, 4799 sources.adapter_key AS candidate_adapter_key, events.product_id, 4800 events.record_type, events.observed_views, events.period_start, 4801 events.period_end, events.applied_views 4802 FROM {$this->view_source_events_table} AS events 4803 LEFT JOIN {$this->view_sources_table} AS sources ON sources.id = events.source_id 4804 WHERE ({$where_sql}) AND events.source_id = %d 4805 ORDER BY events.period_start ASC, events.period_end ASC, events.id ASC 4806 LIMIT 26 FOR UPDATE", 4807 array_merge($where_args, [$candidate_source_id]) 4808 ), ARRAY_A); 4809 if (!empty($wpdb->last_error)) { 4810 return ['status' => 'error', 'error' => (string) $wpdb->last_error, 'events' => [], 'observed' => $observed, 'truncated' => $truncated]; 4811 } 4812 if (count($rows) > 25) { 4813 $truncated = true; 4814 $rows = array_slice($rows, 0, 25); 4815 } 4816 $observed += count($rows); 4817 if ($rows) { 4818 $per_source[$candidate_source_id] = array_values($rows); 4819 } 4820 } 4821 4822 $events = []; 4823 $round = 0; 4824 do { 4825 $added_this_round = false; 4826 foreach ($per_source as $source_rows) { 4827 if (isset($source_rows[$round])) { 4828 $events[] = $source_rows[$round]; 4829 $added_this_round = true; 4830 if (count($events) > $limit) { 4831 $truncated = true; 4832 break 2; 4833 } 4834 } 4835 } 4836 $round++; 4837 } while ($added_this_round); 4838 4839 if ($observed > count($events)) { 4840 $truncated = true; 4841 } 4842 return [ 4843 'status' => 'ok', 4844 'events' => array_slice($events, 0, $limit), 4845 'observed' => $observed, 4846 'truncated' => $truncated, 4847 ]; 4848 } 4849 4850 /** 4851 * Build a source-wide, deterministic overlap profile for the current record. 4852 * This is deliberately descriptive: timestamps and aggregate periods are not 4853 * proof that two providers observed the same pageview. The profile gives the 4854 * later reconciliation pass comparable signals without changing applied_views. 4855 */ 4856 /** 4857 * Return declared source capabilities for reconciliation review. 4858 * This is a descriptive capability inventory, not a numeric reliability score. 4859 * Missing declarations remain unknown; they are never inferred from plugin names. 4860 */ 4861 private function get_generic_source_capability_profiles($source_ids) { 4862 global $wpdb; 4863 4864 $source_ids = array_values(array_unique(array_filter(array_map('absint', (array) $source_ids)))); 4865 if (!$source_ids) { 4866 return ['status' => 'empty', 'sources' => [], 'requested_source_count' => 0]; 4867 } 4868 4869 // The list is built only from normalized positive integer source IDs. 4870 // A fixed-size batch limit keeps the SQL placeholder count bounded. 4871 $placeholders = implode(',', array_fill(0, count($source_ids), '%d')); 4872 // Start from the source registry and LEFT JOIN origins. A persisted source 4873 // with no origin row must remain visible as an unknown-capability source; 4874 // an INNER JOIN would silently omit it from the reconciliation inventory. 4875 $inventory_sql = "SELECT s.id AS source_id, s.source_key, s.display_name, 4876 s.adapter_key AS source_adapter_key, s.granularity AS source_granularity, 4877 o.origin_key, o.adapter_key, o.capabilities, 4878 o.detection_status 4879 FROM {$this->view_sources_table} AS s 4880 LEFT JOIN {$this->view_source_origins_table} AS o ON o.source_id = s.id 4881 WHERE s.id IN ({$placeholders}) 4882 ORDER BY s.id ASC, o.origin_key ASC"; 4883 // prepare() accepts an argument array as its second parameter. This keeps 4884 // the dynamically sized placeholder list paired with its normalized IDs. 4885 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4886 $rows = statpl_db_get_results(statpl_db_prepare($inventory_sql, $source_ids), ARRAY_A); 4887 if (!empty($wpdb->last_error) || !is_array($rows)) { 4888 return [ 4889 'status' => 'query_failed', 4890 'sources' => [], 4891 'requested_source_count' => count($source_ids), 4892 'error' => (string) $wpdb->last_error, 4893 ]; 4894 } 4895 4896 $profiles = []; 4897 foreach ($rows as $row) { 4898 $id = absint($row['source_id'] ?? 0); 4899 if (!$id) { continue; } 4900 $profiles[$id] = $profiles[$id] ?? [ 4901 'source_id' => $id, 4902 'source_key' => sanitize_key((string) ($row['source_key'] ?? '')), 4903 'display_name' => sanitize_text_field((string) ($row['display_name'] ?? '')), 4904 'source_adapter_key' => sanitize_key((string) ($row['source_adapter_key'] ?? '')), 4905 'source_granularity' => sanitize_key((string) ($row['source_granularity'] ?? 'unknown')), 4906 'origin_profile_status' => 'available', 4907 'origins' => [], 4908 ]; 4909 4910 // A NULL origin row is meaningful: the source exists, but no declared 4911 // origin capabilities are available. Do not infer them from its name. 4912 if ($row['origin_key'] === null || $row['origin_key'] === '') { 4913 $profiles[$id]['origin_profile_status'] = 'no_origin_declarations'; 4914 continue; 4915 } 4916 4917 $declared = json_decode((string) ($row['capabilities'] ?? ''), true); 4918 if (!is_array($declared)) { $declared = []; } 4919 4920 $granularity = sanitize_key((string) ($declared['granularity'] ?? 'unknown')); 4921 $precision = sanitize_key((string) ($declared['timestamp_precision'] ?? 'unknown')); 4922 $storage_granularity = sanitize_key((string) ($declared['source_storage_granularity'] ?? 'unknown')); 4923 $profiles[$id]['origins'][] = [ 4924 'origin_key' => sanitize_key((string) ($row['origin_key'] ?? '')), 4925 'adapter_key' => sanitize_key((string) ($row['adapter_key'] ?? '')), 4926 'detection_status' => sanitize_key((string) ($row['detection_status'] ?? 'unknown')), 4927 'granularity' => $granularity, 4928 'timestamp_precision' => $precision, 4929 'source_storage_granularity' => $storage_granularity, 4930 'product_id_direct' => array_key_exists('product_id_direct', $declared) ? (bool) $declared['product_id_direct'] : null, 4931 'target_requires_product_resolution' => array_key_exists('target_requires_product_resolution', $declared) ? (bool) $declared['target_requires_product_resolution'] : null, 4932 'stable_event_identity_declared' => array_key_exists('stable_event_identity', $declared) ? (bool) $declared['stable_event_identity'] : null, 4933 'bot_filtered' => array_key_exists('bot_filtered', $declared) ? (bool) $declared['bot_filtered'] : null, 4934 'immutable_past_days' => array_key_exists('immutable_past_days', $declared) ? (bool) $declared['immutable_past_days'] : null, 4935 'capability_comparability' => [ 4936 'event_level' => $granularity === 'event', 4937 'second_precision' => $precision === 'seconds', 4938 'direct_product_mapping' => !empty($declared['product_id_direct']), 4939 'aggregate_only_or_unknown' => in_array($granularity, ['daily_aggregate', 'hourly_aggregate', 'unknown'], true), 4940 ], 4941 ]; 4942 } 4943 4944 // Preserve requested IDs that have no matching source-registry row as 4945 // explicit unresolved entries instead of silently shrinking the scope. 4946 foreach ($source_ids as $requested_id) { 4947 if (!isset($profiles[$requested_id])) { 4948 $profiles[$requested_id] = [ 4949 'source_id' => $requested_id, 4950 'source_key' => '', 4951 'display_name' => '', 4952 'source_adapter_key' => '', 4953 'source_granularity' => 'unknown', 4954 'origin_profile_status' => 'source_registry_row_missing', 4955 'origins' => [], 4956 ]; 4957 } 4958 } 4959 ksort($profiles, SORT_NUMERIC); 4960 4961 $resolved_count = 0; 4962 $without_origin_count = 0; 4963 foreach ($profiles as $profile) { 4964 if (($profile['origin_profile_status'] ?? '') === 'source_registry_row_missing') { 4965 continue; 4966 } 4967 $resolved_count++; 4968 if (($profile['origin_profile_status'] ?? '') === 'no_origin_declarations') { 4969 $without_origin_count++; 4970 } 4971 } 4972 $status = $resolved_count < count($source_ids) 4973 ? 'partial' 4974 : ($without_origin_count > 0 ? 'partial' : 'available'); 4975 4976 return [ 4977 'status' => $status, 4978 'sources' => array_values($profiles), 4979 'requested_source_count' => count($source_ids), 4980 'resolved_source_count' => $resolved_count, 4981 'sources_without_origin_declarations' => $without_origin_count, 4982 ]; 4983 } 4984 4985 private function build_generic_cross_source_overlap_assessment($product_id, $source_id, $adapter_key, $record_type, $period_start, $period_end, $candidates, $truncated = false, $observed_views = 0) { 4986 $product_id = absint($product_id); 4987 $record_type = sanitize_key((string) $record_type); 4988 $period_start = $period_start !== null ? (string) $period_start : ''; 4989 $period_end = $period_end !== null ? (string) $period_end : ''; 4990 $sources = []; 4991 $same_instant_event_candidates = 0; 4992 $same_bounds_aggregate_candidates = 0; 4993 $intersecting_aggregate_candidates = 0; 4994 $different_type_candidates = 0; 4995 4996 foreach ((array) $candidates as $candidate) { 4997 if (!is_array($candidate) || (int) ($candidate['product_id'] ?? 0) !== $product_id) { 4998 continue; 4999 } 5000 $candidate_source_id = absint($candidate['source_id'] ?? 0); 5001 if (!$candidate_source_id) { 5002 continue; 5003 } 5004 $candidate_type = sanitize_key((string) ($candidate['record_type'] ?? '')); 5005 $candidate_start = isset($candidate['period_start']) ? (string) $candidate['period_start'] : ''; 5006 $candidate_end = isset($candidate['period_end']) ? (string) $candidate['period_end'] : ''; 5007 $same_bounds = $period_start !== '' && $period_end !== '' 5008 && $candidate_start === $period_start && $candidate_end === $period_end; 5009 $intersects = $period_start !== '' && $period_end !== '' 5010 && $candidate_start !== '' && $candidate_end !== '' 5011 && $candidate_start <= $period_end && $candidate_end >= $period_start; 5012 5013 if (!isset($sources[$candidate_source_id])) { 5014 $sources[$candidate_source_id] = [ 5015 'source_id' => $candidate_source_id, 5016 'source_key' => isset($candidate['candidate_source_key']) ? (string) $candidate['candidate_source_key'] : null, 5017 'adapter_key' => isset($candidate['candidate_adapter_key']) ? (string) $candidate['candidate_adapter_key'] : null, 5018 'record_types' => [], 5019 'candidate_records' => 0, 5020 ]; 5021 } 5022 $sources[$candidate_source_id]['candidate_records']++; 5023 if ($candidate_type !== '') { 5024 $sources[$candidate_source_id]['record_types'][$candidate_type] = true; 5025 } 5026 5027 if ($candidate_type !== $record_type) { 5028 $different_type_candidates++; 5029 } 5030 if ($record_type === 'event' && $candidate_type === 'event' && $same_bounds) { 5031 $same_instant_event_candidates++; 5032 } elseif ($record_type === 'aggregate' && $candidate_type === 'aggregate' && $same_bounds) { 5033 $same_bounds_aggregate_candidates++; 5034 } elseif ($intersects && ($record_type === 'aggregate' || $candidate_type === 'aggregate')) { 5035 $intersecting_aggregate_candidates++; 5036 } 5037 } 5038 5039 foreach ($sources as &$source) { 5040 $source['record_types'] = array_keys($source['record_types']); 5041 sort($source['record_types'], SORT_STRING); 5042 } 5043 unset($source); 5044 ksort($sources, SORT_NUMERIC); 5045 5046 // Identical naive datetime labels cannot establish a shared temporal 5047 // bucket without persisted source-timezone provenance; no count range is inferred. 5048 $same_bucket_range = [ 5049 'status' => 'unavailable', 5050 'reason' => 'timezone_basis_not_persisted', 5051 'minimum_unique_views' => null, 5052 'maximum_unique_views' => null, 5053 'minimum_possible_duplicate_views' => null, 5054 'maximum_possible_duplicate_views' => null, 5055 'source_contributions' => [], 5056 ]; 5057 5058 $capability_source_ids = array_merge([absint($source_id)], array_keys($sources)); 5059 $capability_profiles = $this->get_generic_source_capability_profiles($capability_source_ids); 5060 5061 return [ 5062 'version' => '0.2.0', 5063 'scope' => 'same_product_different_sources', 5064 'incoming_source' => [ 5065 'source_id' => absint($source_id), 5066 'adapter_key' => sanitize_key((string) $adapter_key), 5067 'record_type' => $record_type, 5068 ], 5069 'candidate_sources' => array_values($sources), 5070 'source_capability_profiles' => $capability_profiles, 5071 'reliability_policy' => 'declared_capabilities_only_no_numeric_score_or_inferred_trust_rank', 5072 'candidate_source_count' => count($sources), 5073 'candidate_record_count' => array_sum(array_column(array_values($sources), 'candidate_records')), 5074 'same_instant_event_candidates' => $same_instant_event_candidates, 5075 'same_bounds_aggregate_candidates' => $same_bounds_aggregate_candidates, 5076 'intersecting_aggregate_candidates' => $intersecting_aggregate_candidates, 5077 'period_comparison_semantics' => 'raw_datetime_string_representation_only_timezone_unverified', 5078 'different_record_type_candidates' => $different_type_candidates, 5079 'candidate_set_truncated' => (bool) $truncated, 5080 'identical_bucket_count_range' => $same_bucket_range, 5081 'identity_confidence' => 'unproven_without_shared_event_identity', 5082 'temporal_basis_status' => 'timezone_provenance_not_persisted', 5083 'absolute_temporal_comparison_performed' => false, 5084 'counting_decision' => 'preserve_contribution_pending_global_reconciliation', 5085 'policy' => 'Stored datetime strings and identical period bounds are representation-level signals only because source timezone provenance is not persisted; no absolute-time overlap or unique-view range is asserted and no contribution is suppressed by this assessment.', 5086 ]; 5087 } 5088 5089 /** 5090 * Build a bounded product-wide inventory across every persisted generic source. 5091 * This is a descriptive synthesis of recorded contributions, not a unique-view 5092 * estimate: source totals are never added, ranked, or suppressed here. 5093 */ 5094 private function build_generic_product_source_synthesis($product_id) { 5095 global $wpdb; 5096 5097 $product_id = absint($product_id); 5098 if (!$product_id) { 5099 return ['status' => 'unavailable', 'reason' => 'invalid_product_id', 'sources' => []]; 5100 } 5101 5102 // Keep the source-ID inventory independent of the bounded record-group 5103 // result so capability profiles cover every source, even when the 5104 // descriptive record inventory reaches its output limit. 5105 $source_id_rows = statpl_db_get_col(statpl_db_prepare( 5106 "SELECT DISTINCT source_id 5107 FROM {$this->view_source_events_table} 5108 WHERE product_id = %d 5109 ORDER BY source_id ASC", 5110 $product_id 5111 )); 5112 if (!empty($wpdb->last_error) || !is_array($source_id_rows)) { 5113 return ['status' => 'query_failed', 'reason' => 'product_source_id_inventory_query_failed', 'sources' => []]; 5114 } 5115 $all_source_ids = array_values(array_unique(array_filter(array_map('absint', $source_id_rows)))); 5116 5117 $rows = statpl_db_get_results(statpl_db_prepare( 5118 "SELECT e.source_id, s.source_key, s.display_name, e.record_type, 5119 COUNT(*) AS record_count, SUM(e.observed_views) AS observed_views, 5120 SUM(e.applied_views) AS applied_views, 5121 MIN(e.period_start) AS first_period_start, 5122 MAX(e.period_end) AS last_period_end 5123 FROM {$this->view_source_events_table} AS e 5124 LEFT JOIN {$this->view_sources_table} AS s ON s.id = e.source_id 5125 WHERE e.product_id = %d 5126 GROUP BY e.source_id, s.source_key, s.display_name, e.record_type 5127 ORDER BY e.source_id ASC, e.record_type ASC 5128 LIMIT 101", 5129 $product_id 5130 ), ARRAY_A); 5131 if (!empty($wpdb->last_error) || !is_array($rows)) { 5132 return ['status' => 'query_failed', 'reason' => 'product_source_inventory_query_failed', 'sources' => []]; 5133 } 5134 $truncated = count($rows) > 100; 5135 if ($truncated) { 5136 $rows = array_slice($rows, 0, 100); 5137 } 5138 $sources = []; 5139 foreach ($rows as $row) { 5140 $source_id = absint($row['source_id'] ?? 0); 5141 if (!$source_id) { continue; } 5142 if (!isset($sources[$source_id])) { 5143 $sources[$source_id] = [ 5144 'source_id' => $source_id, 5145 'source_key' => sanitize_key((string) ($row['source_key'] ?? '')), 5146 'display_name' => sanitize_text_field((string) ($row['display_name'] ?? '')), 5147 'record_types' => [], 5148 ]; 5149 } 5150 $sources[$source_id]['record_types'][] = [ 5151 'record_type' => sanitize_key((string) ($row['record_type'] ?? 'unknown')), 5152 'record_count' => max(0, (int) ($row['record_count'] ?? 0)), 5153 'observed_views' => max(0, (int) ($row['observed_views'] ?? 0)), 5154 'applied_views' => max(0, (int) ($row['applied_views'] ?? 0)), 5155 'first_period_start' => $row['first_period_start'] !== null ? (string) $row['first_period_start'] : null, 5156 'last_period_end' => $row['last_period_end'] !== null ? (string) $row['last_period_end'] : null, 5157 ]; 5158 } 5159 ksort($sources, SORT_NUMERIC); 5160 5161 $capability_profiles = $this->get_generic_source_capability_profiles($all_source_ids); 5162 $capability_profiles_by_source = []; 5163 // A partial capability lookup still contains useful, successfully 5164 // resolved profiles plus explicit unresolved entries. Retain every 5165 // returned profile; the status separately communicates incompleteness. 5166 if (in_array(($capability_profiles['status'] ?? ''), ['available', 'partial'], true)) { 5167 foreach ((array) ($capability_profiles['sources'] ?? []) as $profile) { 5168 $profile_source_id = absint($profile['source_id'] ?? 0); 5169 if ($profile_source_id) { 5170 $capability_profiles_by_source[$profile_source_id] = $profile; 5171 } 5172 } 5173 } 5174 5175 return [ 5176 'status' => 'available', 5177 'scope' => 'all_persisted_generic_sources_for_product', 5178 'sources' => array_values($sources), 5179 'source_ids_in_scope' => $all_source_ids, 5180 'source_count_in_scope' => count($all_source_ids), 5181 'source_capability_profiles' => array_values($capability_profiles_by_source), 5182 'capability_profile_status' => sanitize_key((string) ($capability_profiles['status'] ?? 'unknown')), 5183 'capability_profile_source_count' => count($capability_profiles_by_source), 5184 'source_record_groups_truncated' => $truncated, 5185 'unique_view_total' => null, 5186 'counting_decision' => 'no_cross_source_sum_or_suppression', 5187 'policy' => 'Observed and applied contributions are reported separately by source and record type; declared capabilities are attached for every source in scope where available, without numeric reliability ranking or a deduplicated product-view total.', 5188 ]; 5189 } 5190 5191 /** 5192 * Combine the independently collected reconciliation evidence into one 5193 * product-level audit summary. This is classification only: it never 5194 * estimates unique views, changes applied_views, or authorizes suppression. 5195 */ 5196 /** 5197 * Classify the completeness and ambiguity of the retained cross-source candidate set. 5198 * Candidate ordering identifies an audit primary only; it never establishes identity. 5199 */ 5200 private function build_generic_candidate_set_assessment($candidates, $candidates_truncated = false, $observed_count = null) { 5201 $candidates = is_array($candidates) ? $candidates : []; 5202 $by_id = []; 5203 $source_ids = []; 5204 foreach ($candidates as $candidate) { 5205 if (!is_array($candidate)) { 5206 continue; 5207 } 5208 $event_id = isset($candidate['id']) ? (int) $candidate['id'] : 0; 5209 $source_id = isset($candidate['source_id']) ? (int) $candidate['source_id'] : 0; 5210 if ($event_id > 0) { 5211 $by_id[$event_id] = true; 5212 } 5213 if ($source_id > 0) { 5214 $source_ids[$source_id] = true; 5215 } 5216 } 5217 $candidate_count = count($by_id); 5218 $source_count = count($source_ids); 5219 $observed_count = $observed_count === null ? $candidate_count : max($candidate_count, (int) $observed_count); 5220 $truncated = (bool) $candidates_truncated; 5221 5222 if ($candidate_count > 1 || $truncated || $observed_count > $candidate_count) { 5223 $classification = 'multiple_or_truncated_candidates_require_review'; 5224 } elseif ($candidate_count === 1) { 5225 $classification = 'single_candidate_requires_review'; 5226 } else { 5227 $classification = 'no_candidate_available'; 5228 } 5229 5230 return [ 5231 'classification' => $classification, 5232 'candidate_count_retained' => $candidate_count, 5233 'candidate_event_ids' => array_map('intval', array_keys($by_id)), 5234 'candidate_source_ids_by_event_id' => array_intersect_key(array_reduce($candidates, static function ($carry, $candidate) { 5235 if (is_array($candidate) && !empty($candidate['id']) && !empty($candidate['source_id'])) { 5236 $carry[(int) $candidate['id']] = (int) $candidate['source_id']; 5237 } 5238 return $carry; 5239 }, []), $by_id), 5240 'candidate_count_observed' => $observed_count, 5241 'candidate_source_count_retained' => $source_count, 5242 'multiple_candidate_ambiguity' => $candidate_count > 1 || $observed_count > 1, 5243 'multiple_source_ambiguity' => $source_count > 1, 5244 'candidate_set_truncated' => $truncated, 5245 'candidate_count_is_lower_bound' => $truncated, 5246 'primary_candidate_is_identity_proof' => false, 5247 'automatic_suppression_authorized' => false, 5248 'policy' => 'All retained candidates remain evidence. Primary ordering is for audit navigation only and does not resolve identity or authorize count suppression.', 5249 ]; 5250 } 5251 5252 /** 5253 * Describe conflicting or heterogeneous dimensions across retained candidates. 5254 * Differences are classified as evidence only; this method never resolves identity 5255 * or changes the contribution applied to product totals. 5256 */ 5257 /** 5258 * Validate the integrity of the per-candidate audit records. Missing or duplicate 5259 * identifiers and malformed classifications are retained as audit errors; this 5260 * method never infers identity or authorizes suppression. 5261 */ 5262 private function build_generic_candidate_record_integrity_assessment($candidate_details, $expected_unique_candidate_count) { 5263 $candidate_details = is_array($candidate_details) ? $candidate_details : []; 5264 $expected_unique_candidate_count = max(0, (int) $expected_unique_candidate_count); 5265 $errors = []; 5266 $seen_ids = []; 5267 $missing_id_count = 0; 5268 $duplicate_id_count = 0; 5269 $invalid_classification_count = 0; 5270 $invalid_dimension_check_count = 0; 5271 $allowed_classifications = [ 5272 'fingerprint_dimension_mismatch', 5273 'temporal_candidate_different_record_shape', 5274 'candidate_dimensions_match_without_identity_proof', 5275 'insufficient_candidate_dimensions', 5276 ]; 5277 $required_dimensions = ['product_id', 'record_type', 'observed_views', 'period_start', 'period_end']; 5278 5279 foreach ($candidate_details as $detail) { 5280 if (!is_array($detail)) { 5281 $missing_id_count++; 5282 $invalid_classification_count++; 5283 $invalid_dimension_check_count++; 5284 continue; 5285 } 5286 $candidate_id = absint($detail['candidate_event_id'] ?? 0); 5287 if (!$candidate_id) { 5288 $missing_id_count++; 5289 } elseif (isset($seen_ids[$candidate_id])) { 5290 $duplicate_id_count++; 5291 } else { 5292 $seen_ids[$candidate_id] = true; 5293 } 5294 if (!in_array($detail['classification'] ?? null, $allowed_classifications, true)) { 5295 $invalid_classification_count++; 5296 } 5297 $checks = $detail['dimension_checks'] ?? null; 5298 $checks_valid = is_array($checks); 5299 if ($checks_valid) { 5300 foreach ($required_dimensions as $dimension) { 5301 if (!array_key_exists($dimension, $checks) 5302 || !in_array($checks[$dimension], [true, false, null], true)) { 5303 $checks_valid = false; 5304 break; 5305 } 5306 } 5307 } 5308 if (!$checks_valid) { 5309 $invalid_dimension_check_count++; 5310 } 5311 } 5312 5313 $detail_count = count($candidate_details); 5314 $unique_id_count = count($seen_ids); 5315 if ($missing_id_count > 0) { 5316 $errors[] = 'candidate_audit_record_missing_valid_event_id'; 5317 } 5318 if ($duplicate_id_count > 0) { 5319 $errors[] = 'candidate_audit_records_contain_duplicate_event_ids'; 5320 } 5321 if ($invalid_classification_count > 0) { 5322 $errors[] = 'candidate_audit_record_has_invalid_classification'; 5323 } 5324 if ($invalid_dimension_check_count > 0) { 5325 $errors[] = 'candidate_audit_record_has_invalid_dimension_checks'; 5326 } 5327 if ($detail_count !== $expected_unique_candidate_count || $unique_id_count !== $expected_unique_candidate_count) { 5328 $errors[] = 'candidate_audit_record_count_does_not_match_unique_candidate_inventory'; 5329 } 5330 5331 return [ 5332 'status' => $errors ? 'candidate_records_require_review' : 'candidate_records_valid', 5333 'consistent' => !$errors, 5334 'errors' => array_values(array_unique($errors)), 5335 'candidate_detail_count' => $detail_count, 5336 'unique_valid_candidate_id_count' => $unique_id_count, 5337 'expected_unique_candidate_count' => $expected_unique_candidate_count, 5338 'missing_id_count' => $missing_id_count, 5339 'duplicate_id_count' => $duplicate_id_count, 5340 'invalid_classification_count' => $invalid_classification_count, 5341 'invalid_dimension_check_count' => $invalid_dimension_check_count, 5342 'identity_proven' => false, 5343 'automatic_suppression_authorized' => false, 5344 'policy' => 'Record validation is audit-only. Invalid records remain visible for review and cannot establish identity or authorize suppression.', 5345 ]; 5346 } 5347 5348 private function build_generic_candidate_conflict_assessment($incoming, $candidates) { 5349 $incoming = is_array($incoming) ? $incoming : []; 5350 $candidates = is_array($candidates) ? $candidates : []; 5351 $counts = [ 5352 'fingerprint_dimension_mismatch' => 0, 5353 'temporal_candidate_different_record_shape' => 0, 5354 'candidate_dimensions_match_without_identity_proof' => 0, 5355 'insufficient_candidate_dimensions' => 0, 5356 ]; 5357 $candidate_ids = []; 5358 $dimension_signatures = []; 5359 $details = []; 5360 foreach ($candidates as $candidate) { 5361 if (!is_array($candidate)) { 5362 continue; 5363 } 5364 $candidate_id = absint($candidate['id'] ?? 0); 5365 if ($candidate_id) { 5366 $candidate_ids[$candidate_id] = true; 5367 } 5368 $discovered_by = array_values((array) ($candidate['_discovered_by'] ?? [])); 5369 $has_fingerprint_signal = in_array('fingerprint', $discovered_by, true); 5370 $has_temporal_signal = in_array('temporal', $discovered_by, true); 5371 $checks = [ 5372 'product_id' => isset($incoming['product_id'], $candidate['product_id']) 5373 ? (int) $incoming['product_id'] === (int) $candidate['product_id'] : null, 5374 'record_type' => isset($incoming['record_type'], $candidate['record_type']) 5375 ? (string) $incoming['record_type'] === (string) $candidate['record_type'] : null, 5376 'observed_views' => isset($incoming['observed_views'], $candidate['observed_views']) 5377 ? (int) $incoming['observed_views'] === (int) $candidate['observed_views'] : null, 5378 'period_start' => !empty($incoming['period_start']) && !empty($candidate['period_start']) 5379 ? (string) $incoming['period_start'] === (string) $candidate['period_start'] : null, 5380 'period_end' => !empty($incoming['period_end']) && !empty($candidate['period_end']) 5381 ? (string) $incoming['period_end'] === (string) $candidate['period_end'] : null, 5382 ]; 5383 $known_checks = array_filter($checks, static function ($value) { return $value !== null; }); 5384 $all_known_dimensions_match = count($known_checks) === count($checks) 5385 && !in_array(false, $known_checks, true); 5386 $has_dimension_difference = in_array(false, $known_checks, true); 5387 if ($has_fingerprint_signal && $has_dimension_difference) { 5388 $classification = 'fingerprint_dimension_mismatch'; 5389 } elseif ($has_temporal_signal && ( 5390 $checks['record_type'] === false || $checks['observed_views'] === false 5391 )) { 5392 $classification = 'temporal_candidate_different_record_shape'; 5393 } elseif ($all_known_dimensions_match) { 5394 $classification = 'candidate_dimensions_match_without_identity_proof'; 5395 } else { 5396 $classification = 'insufficient_candidate_dimensions'; 5397 } 5398 $counts[$classification]++; 5399 $signature = implode('|', [ 5400 (string) ($candidate['product_id'] ?? 'unknown'), 5401 (string) ($candidate['record_type'] ?? 'unknown'), 5402 (string) ($candidate['observed_views'] ?? 'unknown'), 5403 (string) ($candidate['period_start'] ?? ''), 5404 (string) ($candidate['period_end'] ?? ''), 5405 ]); 5406 $dimension_signatures[$signature] = true; 5407 $details[] = [ 5408 'candidate_event_id' => $candidate_id ?: null, 5409 'candidate_source_id' => !empty($candidate['source_id']) ? (int) $candidate['source_id'] : null, 5410 'discovered_by' => $discovered_by, 5411 'classification' => $classification, 5412 'dimension_checks' => $checks, 5413 'identity_proven' => false, 5414 'automatic_suppression_authorized' => false, 5415 ]; 5416 } 5417 $candidate_count = count($candidate_ids); 5418 $record_integrity = $this->build_generic_candidate_record_integrity_assessment($details, $candidate_count); 5419 $heterogeneous = count($dimension_signatures) > 1; 5420 $has_conflict = $counts['fingerprint_dimension_mismatch'] > 0 5421 || $counts['temporal_candidate_different_record_shape'] > 0; 5422 return [ 5423 'status' => $candidate_count ? 'available' : 'no_candidates', 5424 'candidate_count' => $candidate_count, 5425 'candidate_dimension_signature_count' => count($dimension_signatures), 5426 'candidate_set_is_heterogeneous' => $heterogeneous, 5427 'conflicting_evidence_present' => $has_conflict, 5428 'classification_counts' => $counts, 5429 'candidate_record_integrity' => $record_integrity, 5430 'candidates' => $details, 5431 'identity_proven' => false, 5432 'automatic_suppression_authorized' => false, 5433 'policy' => 'Dimension differences and heterogeneous candidates are retained as review evidence only; they do not establish identity, select a winning candidate, or authorize count suppression.', 5434 ]; 5435 } 5436 5437 /** 5438 * Make an explicit audit decision from candidate support and contradiction evidence. 5439 * Absence of candidates is not evidence that sources do not overlap. A clean dimensional 5440 * comparison is not identity proof, and truncation always limits the conclusion. 5441 */ 5442 private function build_generic_candidate_evidence_decision($conflict_assessment, $candidate_set_assessment = [], $candidate_search_truncated = false) { 5443 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5444 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 5445 $counts = (array) ($conflict_assessment['classification_counts'] ?? []); 5446 $fingerprint_conflicts = max(0, (int) ($counts['fingerprint_dimension_mismatch'] ?? 0)); 5447 $temporal_shape_conflicts = max(0, (int) ($counts['temporal_candidate_different_record_shape'] ?? 0)); 5448 $matching_dimensions = max(0, (int) ($counts['candidate_dimensions_match_without_identity_proof'] ?? 0)); 5449 $insufficient_dimensions = max(0, (int) ($counts['insufficient_candidate_dimensions'] ?? 0)); 5450 $candidate_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0)); 5451 $truncated = (bool) $candidate_search_truncated 5452 || !empty($candidate_set_assessment['candidate_set_truncated']) 5453 || !empty($candidate_set_assessment['candidate_count_is_lower_bound']); 5454 5455 $support = []; 5456 $contradictions = []; 5457 $limitations = []; 5458 if ($matching_dimensions > 0) { 5459 $support[] = 'candidate_dimensions_match_but_do_not_prove_shared_identity'; 5460 } 5461 if ($fingerprint_conflicts > 0) { 5462 $contradictions[] = 'fingerprint_discovery_signal_has_dimension_mismatches'; 5463 } 5464 if ($temporal_shape_conflicts > 0) { 5465 $contradictions[] = 'temporal_candidates_have_different_record_shapes'; 5466 } 5467 if ($insufficient_dimensions > 0) { 5468 $limitations[] = 'candidate_dimensions_are_incomplete'; 5469 } 5470 if ($truncated) { 5471 $limitations[] = 'candidate_search_truncated_conclusion_is_incomplete'; 5472 } 5473 5474 if ($candidate_count === 0) { 5475 $classification = $truncated 5476 ? 'candidate_search_incomplete_no_retained_candidate_no_overlap_conclusion' 5477 : 'no_candidate_found_within_search_bounds_overlap_remains_unknown'; 5478 } elseif ($fingerprint_conflicts > 0 || $temporal_shape_conflicts > 0) { 5479 $classification = ($matching_dimensions > 0) 5480 ? 'supporting_and_contradictory_evidence_require_review' 5481 : 'contradictory_candidate_evidence_requires_review'; 5482 } elseif ($matching_dimensions > 0) { 5483 $classification = 'supportive_dimensions_without_identity_proof'; 5484 } else { 5485 $classification = 'insufficient_candidate_evidence_requires_review'; 5486 } 5487 if ($truncated && $candidate_count > 0) { 5488 $limitations[] = 'retained_candidates_are_not_the_complete_observed_set'; 5489 } 5490 5491 return [ 5492 'classification' => $classification, 5493 'supporting_evidence' => array_values(array_unique($support)), 5494 'contradictory_evidence' => array_values(array_unique($contradictions)), 5495 'limitations' => array_values(array_unique($limitations)), 5496 'supporting_candidate_count' => $matching_dimensions, 5497 'fingerprint_dimension_conflict_count' => $fingerprint_conflicts, 5498 'temporal_record_shape_conflict_count' => $temporal_shape_conflicts, 5499 'insufficient_dimension_candidate_count' => $insufficient_dimensions, 5500 'candidate_count' => $candidate_count, 5501 'candidate_search_truncated' => $truncated, 5502 'absence_of_candidates_proves_no_overlap' => false, 5503 'identity_proven' => false, 5504 'automatic_suppression_authorized' => false, 5505 'policy' => 'Support, contradiction, and missing evidence are reported separately. No candidate is not proof of no overlap; matching dimensions are not proof of identity; no evidence classification changes applied_views or authorizes suppression.', 5506 ]; 5507 } 5508 5509 /** 5510 * Validate that candidate inventory, conflict assessment, and evidence decision 5511 * describe the same retained evidence and preserve the non-suppression policy. 5512 * This is an audit integrity check only; it never changes reconciliation outcomes. 5513 */ 5514 private function build_generic_candidate_audit_consistency($candidate_set_assessment, $conflict_assessment, $evidence_decision) { 5515 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 5516 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5517 $evidence_decision = is_array($evidence_decision) ? $evidence_decision : []; 5518 $errors = []; 5519 $candidate_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0)); 5520 $retained_count = max(0, (int) ($candidate_set_assessment['candidate_count_retained'] ?? 0)); 5521 $decision_count = max(0, (int) ($evidence_decision['candidate_count'] ?? 0)); 5522 $counts = (array) ($conflict_assessment['classification_counts'] ?? []); 5523 $record_integrity = $conflict_assessment['candidate_record_integrity'] ?? null; 5524 $record_integrity = is_array($record_integrity) ? $record_integrity : []; 5525 $classified_count = 0; 5526 foreach ([ 5527 'fingerprint_dimension_mismatch', 5528 'temporal_candidate_different_record_shape', 5529 'candidate_dimensions_match_without_identity_proof', 5530 'insufficient_candidate_dimensions', 5531 ] as $key) { 5532 $classified_count += max(0, (int) ($counts[$key] ?? 0)); 5533 } 5534 if ($candidate_count !== $retained_count || $candidate_count !== $decision_count) { 5535 $errors[] = 'candidate_counts_disagree_across_audit_layers'; 5536 } 5537 if (empty($conflict_assessment['candidate_record_integrity']) || !is_array($conflict_assessment['candidate_record_integrity'])) { 5538 $errors[] = 'candidate_record_integrity_assessment_missing_from_conflict_audit'; 5539 } elseif (empty($record_integrity['consistent'])) { 5540 $errors[] = 'candidate_record_integrity_validation_failed'; 5541 } 5542 if (isset($record_integrity['expected_unique_candidate_count']) 5543 && (int) $record_integrity['expected_unique_candidate_count'] !== $candidate_count) { 5544 $errors[] = 'candidate_record_integrity_expected_count_disagrees_with_conflict_inventory'; 5545 } 5546 if ($classified_count > $candidate_count) { 5547 $errors[] = 'candidate_classification_count_exceeds_candidate_inventory'; 5548 } elseif ($classified_count < $candidate_count) { 5549 $errors[] = 'candidate_classification_coverage_incomplete'; 5550 } 5551 $classification_coverage_complete = $classified_count === $candidate_count; 5552 foreach ([ 5553 'supporting_candidate_count' => 'candidate_dimensions_match_without_identity_proof', 5554 'fingerprint_dimension_conflict_count' => 'fingerprint_dimension_mismatch', 5555 'temporal_record_shape_conflict_count' => 'temporal_candidate_different_record_shape', 5556 'insufficient_dimension_candidate_count' => 'insufficient_candidate_dimensions', 5557 ] as $decision_key => $conflict_key) { 5558 if (max(0, (int) ($evidence_decision[$decision_key] ?? 0)) !== max(0, (int) ($counts[$conflict_key] ?? 0))) { 5559 $errors[] = 'evidence_decision_count_mismatch_' . $decision_key; 5560 } 5561 } 5562 $set_truncated = !empty($candidate_set_assessment['candidate_set_truncated']); 5563 $decision_truncated = !empty($evidence_decision['candidate_search_truncated']); 5564 if ($set_truncated && !$decision_truncated) { 5565 $errors[] = 'candidate_truncation_not_propagated_to_evidence_decision'; 5566 } 5567 if (!empty($conflict_assessment['identity_proven']) || !empty($evidence_decision['identity_proven'])) { 5568 $errors[] = 'candidate_evidence_layer_claims_identity_proof'; 5569 } 5570 if (!empty($candidate_set_assessment['automatic_suppression_authorized']) 5571 || !empty($conflict_assessment['automatic_suppression_authorized']) 5572 || !empty($evidence_decision['automatic_suppression_authorized'])) { 5573 $errors[] = 'candidate_evidence_layer_authorizes_automatic_suppression'; 5574 } 5575 if ($candidate_count === 0 && !empty($evidence_decision['absence_of_candidates_proves_no_overlap'])) { 5576 $errors[] = 'absence_of_candidates_misrepresented_as_no_overlap_proof'; 5577 } 5578 return [ 5579 'status' => $errors ? 'inconsistent_audit_evidence_requires_review' : 'candidate_audit_layers_consistent', 5580 'consistent' => !$errors, 5581 'errors' => array_values(array_unique($errors)), 5582 'candidate_count_conflict_assessment' => $candidate_count, 5583 'candidate_count_inventory' => $retained_count, 5584 'candidate_count_evidence_decision' => $decision_count, 5585 'classified_candidate_count' => $classified_count, 5586 'unclassified_candidate_count' => max(0, $candidate_count - $classified_count), 5587 'classification_coverage_complete' => $classification_coverage_complete, 5588 'candidate_record_integrity' => [ 5589 'present' => !empty($conflict_assessment['candidate_record_integrity']) && is_array($conflict_assessment['candidate_record_integrity']), 5590 'consistent' => !empty($record_integrity['consistent']), 5591 'errors' => array_values((array) ($record_integrity['errors'] ?? [])), 5592 'candidate_detail_count' => max(0, (int) ($record_integrity['candidate_detail_count'] ?? 0)), 5593 'unique_valid_candidate_id_count' => max(0, (int) ($record_integrity['unique_valid_candidate_id_count'] ?? 0)), 5594 'expected_unique_candidate_count' => max(0, (int) ($record_integrity['expected_unique_candidate_count'] ?? 0)), 5595 ], 5596 'candidate_search_truncated' => $decision_truncated, 5597 'identity_proven' => false, 5598 'automatic_suppression_authorized' => false, 5599 'policy' => 'Consistency validation is audit-only. A failed validation requires review and never changes applied_views or authorizes count suppression.', 5600 ]; 5601 } 5602 5603 /** 5604 * Compare the event/source references retained by candidate inventory and 5605 * per-candidate conflict records. Reference mismatches require review only. 5606 */ 5607 private function build_generic_candidate_reference_consistency($candidate_set_assessment, $conflict_assessment) { 5608 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 5609 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5610 $inventory_ids = array_values(array_unique(array_filter(array_map('absint', (array) ($candidate_set_assessment['candidate_event_ids'] ?? []))))); 5611 $detail_ids = []; 5612 $detail_sources = []; 5613 $duplicate_detail_ids = []; 5614 foreach ((array) ($conflict_assessment['candidates'] ?? []) as $detail) { 5615 if (!is_array($detail) || empty($detail['candidate_event_id'])) { 5616 continue; 5617 } 5618 $id = absint($detail['candidate_event_id']); 5619 if (isset($detail_ids[$id])) { 5620 $duplicate_detail_ids[] = $id; 5621 } 5622 $detail_ids[$id] = true; 5623 $detail_sources[$id] = isset($detail['candidate_source_id']) ? absint($detail['candidate_source_id']) : 0; 5624 } 5625 $inventory_set = array_fill_keys($inventory_ids, true); 5626 $detail_set = $detail_ids; 5627 $missing_details = array_values(array_diff($inventory_ids, array_keys($detail_set))); 5628 $unexpected_details = array_values(array_diff(array_keys($detail_set), $inventory_ids)); 5629 $source_mismatches = []; 5630 $inventory_sources = (array) ($candidate_set_assessment['candidate_source_ids_by_event_id'] ?? []); 5631 foreach ($inventory_sources as $event_id => $source_id) { 5632 $event_id = absint($event_id); 5633 $source_id = absint($source_id); 5634 if (isset($detail_sources[$event_id]) && $detail_sources[$event_id] !== $source_id) { 5635 $source_mismatches[] = $event_id; 5636 } 5637 } 5638 $errors = []; 5639 if ($missing_details) { $errors[] = 'candidate_inventory_event_missing_conflict_detail'; } 5640 if ($unexpected_details) { $errors[] = 'conflict_detail_event_missing_from_candidate_inventory'; } 5641 if ($duplicate_detail_ids) { $errors[] = 'conflict_details_contain_duplicate_event_references'; } 5642 if ($source_mismatches) { $errors[] = 'candidate_source_reference_mismatch'; } 5643 return [ 5644 'status' => $errors ? 'candidate_references_require_review' : 'candidate_references_consistent', 5645 'consistent' => !$errors, 5646 'errors' => $errors, 5647 'inventory_event_ids' => $inventory_ids, 5648 'conflict_detail_event_ids' => array_map('intval', array_keys($detail_set)), 5649 'missing_conflict_detail_event_ids' => $missing_details, 5650 'unexpected_conflict_detail_event_ids' => $unexpected_details, 5651 'duplicate_conflict_detail_event_ids' => array_values(array_unique($duplicate_detail_ids)), 5652 'source_reference_mismatch_event_ids' => array_values(array_unique($source_mismatches)), 5653 'identity_proven' => false, 5654 'automatic_suppression_authorized' => false, 5655 'policy' => 'Reference consistency validates audit traceability only; it does not establish shared event identity or authorize suppression.', 5656 ]; 5657 } 5658 5659 /** 5660 * Validate discovery-signal provenance for every retained candidate. Signals 5661 * explain why a candidate was found; they are never treated as identity proof. 5662 */ 5663 private function build_generic_candidate_discovery_signal_consistency($candidate_set_assessment, $conflict_assessment) { 5664 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 5665 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5666 $inventory_ids = array_values(array_unique(array_filter(array_map('absint', (array) ($candidate_set_assessment['candidate_event_ids'] ?? []))))); 5667 $allowed_signals = ['fingerprint', 'temporal']; 5668 $candidate_signals = []; 5669 $missing_signal_ids = []; 5670 $unknown_signals = []; 5671 $duplicate_signals = []; 5672 $unexpected_ids = []; 5673 $inventory_set = array_fill_keys($inventory_ids, true); 5674 foreach ((array) ($conflict_assessment['candidates'] ?? []) as $detail) { 5675 if (!is_array($detail) || empty($detail['candidate_event_id'])) { 5676 continue; 5677 } 5678 $event_id = absint($detail['candidate_event_id']); 5679 if (!isset($inventory_set[$event_id])) { 5680 $unexpected_ids[] = $event_id; 5681 } 5682 $signals = $detail['discovered_by'] ?? null; 5683 if (!is_array($signals) || !$signals) { 5684 $missing_signal_ids[] = $event_id; 5685 $candidate_signals[$event_id] = []; 5686 continue; 5687 } 5688 $normalized = []; 5689 foreach ($signals as $signal) { 5690 if (!is_string($signal) || !in_array($signal, $allowed_signals, true)) { 5691 $unknown_signals[] = ['candidate_event_id' => $event_id, 'signal' => is_scalar($signal) ? (string) $signal : gettype($signal)]; 5692 continue; 5693 } 5694 if (in_array($signal, $normalized, true)) { 5695 $duplicate_signals[] = ['candidate_event_id' => $event_id, 'signal' => $signal]; 5696 } 5697 $normalized[] = $signal; 5698 } 5699 $candidate_signals[$event_id] = array_values(array_unique($normalized)); 5700 } 5701 $missing_candidate_ids = array_values(array_diff($inventory_ids, array_keys($candidate_signals))); 5702 $errors = []; 5703 if ($missing_signal_ids) { $errors[] = 'candidate_discovery_signal_missing'; } 5704 if ($missing_candidate_ids) { $errors[] = 'candidate_discovery_record_missing'; } 5705 if ($unexpected_ids) { $errors[] = 'discovery_signal_candidate_outside_inventory'; } 5706 if ($unknown_signals) { $errors[] = 'unknown_candidate_discovery_signal'; } 5707 if ($duplicate_signals) { $errors[] = 'duplicate_candidate_discovery_signal'; } 5708 return [ 5709 'status' => $errors ? 'candidate_discovery_signals_require_review' : 'candidate_discovery_signals_consistent', 5710 'consistent' => !$errors, 5711 'errors' => $errors, 5712 'allowed_signal_types' => $allowed_signals, 5713 'candidate_signals_by_event_id' => $candidate_signals, 5714 'missing_signal_event_ids' => array_values(array_unique($missing_signal_ids)), 5715 'missing_candidate_record_event_ids' => $missing_candidate_ids, 5716 'unexpected_candidate_event_ids' => array_values(array_unique($unexpected_ids)), 5717 'unknown_signals' => $unknown_signals, 5718 'duplicate_signals' => $duplicate_signals, 5719 'identity_proven' => false, 5720 'automatic_suppression_authorized' => false, 5721 'policy' => 'Fingerprint and temporal signals describe discovery provenance only; neither signal establishes shared event identity or authorizes suppression.', 5722 ]; 5723 } 5724 5725 /** 5726 * Verify that adapter evidence preserves the discovery signals recorded for 5727 * each candidate. Discovery provenance remains advisory and is never identity proof. 5728 */ 5729 private function build_generic_adapter_discovery_evidence_consistency($conflict_assessment, $adapter_overlap_evidence) { 5730 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5731 $adapter_overlap_evidence = is_array($adapter_overlap_evidence) ? $adapter_overlap_evidence : []; 5732 $expected = []; 5733 foreach ((array) ($conflict_assessment['candidates'] ?? []) as $candidate) { 5734 if (!is_array($candidate) || empty($candidate['candidate_event_id'])) { continue; } 5735 $id = absint($candidate['candidate_event_id']); 5736 $expected[$id] = array_values(array_unique(array_filter((array) ($candidate['discovered_by'] ?? []), 'is_string'))); 5737 } 5738 $seen = []; 5739 $missing = []; 5740 $unexpected = []; 5741 $mismatched = []; 5742 $duplicate = []; 5743 $invalid = []; 5744 $allowed = ['fingerprint', 'temporal']; 5745 foreach ($adapter_overlap_evidence as $entry) { 5746 if (!is_array($entry) || empty($entry['candidate_event_id'])) { $invalid[] = 'missing_candidate_event_id'; continue; } 5747 $id = absint($entry['candidate_event_id']); 5748 if (isset($seen[$id])) { $duplicate[] = $id; } 5749 $seen[$id] = true; 5750 if (!array_key_exists($id, $expected)) { $unexpected[] = $id; continue; } 5751 $signals = $entry['discovered_by'] ?? null; 5752 if (!is_array($signals)) { $invalid[] = $id; continue; } 5753 foreach ($signals as $signal) { 5754 if (!is_string($signal) || !in_array($signal, $allowed, true)) { $invalid[] = $id; } 5755 } 5756 $normalized = array_values(array_unique(array_filter($signals, 'is_string'))); 5757 sort($normalized); 5758 $expected_normalized = $expected[$id]; 5759 sort($expected_normalized); 5760 $comparison_signals = $entry['comparison']['identity_assessment']['candidate_discovery_signals'] ?? null; 5761 if (!is_array($comparison_signals)) { 5762 $mismatched[] = $id; 5763 } else { 5764 $comparison_normalized = array_values(array_unique(array_filter($comparison_signals, 'is_string'))); 5765 sort($comparison_normalized); 5766 if ($comparison_normalized !== $normalized) { $mismatched[] = $id; } 5767 } 5768 if ($normalized !== $expected_normalized) { $mismatched[] = $id; } 5769 } 5770 $missing = array_values(array_diff(array_keys($expected), array_keys($seen))); 5771 $errors = []; 5772 if ($missing) { $errors[] = 'adapter_discovery_evidence_missing_candidates'; } 5773 if ($unexpected) { $errors[] = 'adapter_discovery_evidence_has_unexpected_candidates'; } 5774 if ($mismatched) { $errors[] = 'adapter_discovery_signals_disagree_with_candidate_inventory'; } 5775 if ($duplicate) { $errors[] = 'adapter_discovery_evidence_has_duplicate_candidates'; } 5776 if ($invalid) { $errors[] = 'adapter_discovery_evidence_has_invalid_signals_or_records'; } 5777 return [ 5778 'status' => $errors ? 'adapter_discovery_evidence_requires_review' : 'adapter_discovery_evidence_consistent', 5779 'consistent' => !$errors, 5780 'errors' => $errors, 5781 'candidate_count_expected' => count($expected), 5782 'candidate_count_observed' => count($seen), 5783 'missing_candidate_event_ids' => $missing, 5784 'unexpected_candidate_event_ids' => array_values(array_unique($unexpected)), 5785 'mismatched_candidate_event_ids' => array_values(array_unique($mismatched)), 5786 'duplicate_candidate_event_ids' => array_values(array_unique($duplicate)), 5787 'invalid_candidate_records' => array_values(array_unique($invalid, SORT_REGULAR)), 5788 'identity_proven' => false, 5789 'automatic_suppression_authorized' => false, 5790 'policy' => 'Adapter evidence must preserve discovery provenance; matching signals remain discovery evidence only and never establish shared event identity.', 5791 ]; 5792 } 5793 5794 /** 5795 * Compare the normalized dimensions in adapter evidence with the dimensions 5796 * independently classified for each candidate. This validates audit transport, 5797 * not shared identity; missing values remain unknown rather than matching. 5798 */ 5799 /** 5800 * Classify temporal granularity without treating interval overlap as identity. 5801 * Daily windows, aggregate periods and point timestamps are distinct evidence shapes. 5802 */ 5803 /** 5804 * Describe the temporal basis that can actually be established from the 5805 * adapter contract. The current event table does not persist a timezone or 5806 * timezone-version fingerprint, so even WordPress-local values produced by 5807 * two adapters cannot safely be compared as absolute instants across imports. 5808 * Calendar aggregates retain date-label semantics and are not converted to 5809 * timestamps. This helper is descriptive and never authorizes suppression. 5810 */ 5811 private function classify_reconciliation_temporal_basis($adapter_key, $origin_key, $record_type) { 5812 $adapter_key = sanitize_key((string) $adapter_key); 5813 $origin_key = sanitize_key((string) $origin_key); 5814 $record_type = sanitize_key((string) $record_type); 5815 5816 if ($record_type === 'aggregate') { 5817 return [ 5818 'basis' => 'aggregate_period_timezone_unverified', 5819 'source_representation' => 'date_bounded_aggregate_or_period', 5820 'absolute_time_comparable' => false, 5821 'reason' => 'The adapter imports an aggregate period, but its exact calendar semantics, source timezone, and historical site-timezone setting are not persisted here.', 5822 ]; 5823 } 5824 5825 if ($record_type === 'event' && $adapter_key === 'burst_statistics') { 5826 return [ 5827 'basis' => 'wordpress_site_local_timezone_unrecorded', 5828 'source_representation' => 'unix_timestamp_converted_with_wp_date', 5829 'absolute_time_comparable' => false, 5830 'reason' => 'The source instant is known, but the WordPress timezone identifier/offset at import time is not persisted with the event.', 5831 ]; 5832 } 5833 5834 if ($record_type === 'event' && $adapter_key === 'independent_analytics') { 5835 return [ 5836 'basis' => 'wordpress_site_local_timezone_unrecorded', 5837 'source_representation' => 'utc_timestamp_converted_to_wp_local_with_wp_date', 5838 'absolute_time_comparable' => false, 5839 'reason' => 'The source is UTC and is converted to WordPress local time, but the conversion timezone identifier/offset is not persisted with the event.', 5840 ]; 5841 } 5842 5843 if ($record_type === 'event' && $adapter_key === 'pulseviews' && $origin_key === 'recent_raw_views') { 5844 return [ 5845 'basis' => 'source_local_timezone_unknown', 5846 'source_representation' => 'naive_sql_datetime', 5847 'absolute_time_comparable' => false, 5848 'reason' => 'The raw source datetime has no documented timezone or UTC offset.', 5849 ]; 5850 } 5851 5852 return [ 5853 'basis' => 'unknown', 5854 'source_representation' => 'unverified', 5855 'absolute_time_comparable' => false, 5856 'reason' => 'The adapter does not provide sufficient persisted timezone provenance for absolute-time comparison.', 5857 ]; 5858 } 5859 5860 private function classify_reconciliation_temporal_granularity($record_type, $period_start, $period_end, $precision_hint = 'unknown') { 5861 if (!$this->is_valid_reconciliation_period_range($period_start, $period_end)) { 5862 return [ 5863 'granularity' => 'unknown', 5864 'classification' => 'insufficient_or_invalid_temporal_bounds', 5865 'comparable_at_same_granularity' => false, 5866 ]; 5867 } 5868 if ($precision_hint === 'calendar_day' 5869 || (substr($period_start, 11) === '00:00:00' && substr($period_end, 11) === '23:59:59')) { 5870 return [ 5871 'granularity' => 'calendar_day', 5872 'classification' => 'day_level_interval', 5873 'comparable_at_same_granularity' => true, 5874 ]; 5875 } 5876 if ($record_type === 'aggregate') { 5877 return [ 5878 'granularity' => 'aggregate_period', 5879 'classification' => 'aggregate_interval', 5880 'comparable_at_same_granularity' => true, 5881 ]; 5882 } 5883 if ($record_type === 'event' && $period_start === $period_end) { 5884 return [ 5885 'granularity' => 'point_timestamp', 5886 'classification' => 'event_timestamp_unverified', 5887 'comparable_at_same_granularity' => true, 5888 ]; 5889 } 5890 return [ 5891 'granularity' => 'unknown', 5892 'classification' => 'unrecognized_temporal_shape', 5893 'comparable_at_same_granularity' => false, 5894 ]; 5895 } 5896 5897 /** 5898 * Accept only canonical local SQL datetime values for reconciliation periods. 5899 * This validates representation, not timezone equivalence or event identity. 5900 */ 5901 private function is_valid_reconciliation_period_datetime($value) { 5902 if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)) { 5903 return false; 5904 } 5905 $parsed = DateTime::createFromFormat('!Y-m-d H:i:s', $value); 5906 $errors = DateTime::getLastErrors(); 5907 return $parsed instanceof DateTime 5908 && $parsed->format('Y-m-d H:i:s') === $value 5909 && ($errors === false || (empty($errors['warning_count']) && empty($errors['error_count']))); 5910 } 5911 5912 /** 5913 * Validate chronological ordering when both period boundaries are known. 5914 * Canonical local SQL datetimes can be compared lexically; this does not 5915 * infer timezone equivalence or prove event identity. 5916 */ 5917 private function is_valid_reconciliation_period_range($period_start, $period_end) { 5918 if (!$this->is_valid_reconciliation_period_datetime($period_start) 5919 || !$this->is_valid_reconciliation_period_datetime($period_end)) { 5920 return null; 5921 } 5922 return strcmp($period_end, $period_start) >= 0; 5923 } 5924 5925 private function build_generic_adapter_dimension_evidence_consistency($conflict_assessment, $adapter_overlap_evidence) { 5926 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5927 $adapter_overlap_evidence = is_array($adapter_overlap_evidence) ? $adapter_overlap_evidence : []; 5928 $expected = []; 5929 foreach ((array) ($conflict_assessment['candidates'] ?? []) as $candidate) { 5930 if (!is_array($candidate) || empty($candidate['candidate_event_id'])) { continue; } 5931 $expected[absint($candidate['candidate_event_id'])] = (array) ($candidate['dimension_checks'] ?? []); 5932 } 5933 $seen = []; 5934 $missing = []; 5935 $unexpected = []; 5936 $mismatched = []; 5937 $incomplete = []; 5938 $invalid_period_ranges = []; 5939 $dimensions = ['product_id', 'record_type', 'observed_views', 'period_start', 'period_end']; 5940 foreach ($adapter_overlap_evidence as $entry) { 5941 if (!is_array($entry) || empty($entry['candidate_event_id'])) { $incomplete[] = 'missing_candidate_event_id'; continue; } 5942 $id = absint($entry['candidate_event_id']); 5943 if (isset($seen[$id])) { $mismatched[] = $id; } 5944 $seen[$id] = true; 5945 if (!array_key_exists($id, $expected)) { $unexpected[] = $id; continue; } 5946 $incoming = $entry['comparison']['incoming'] ?? null; 5947 $candidate = $entry['comparison']['candidate'] ?? null; 5948 if (!is_array($incoming) || !is_array($candidate)) { $incomplete[] = $id; continue; } 5949 $actual = []; 5950 foreach ($dimensions as $dimension) { 5951 if (!array_key_exists($dimension, $incoming) || !array_key_exists($dimension, $candidate)) { 5952 $actual[$dimension] = null; 5953 continue; 5954 } 5955 if ($dimension === 'product_id' || $dimension === 'observed_views') { 5956 $left_value = $incoming[$dimension]; 5957 $right_value = $candidate[$dimension]; 5958 if ($left_value === null || $right_value === null || $left_value === '' || $right_value === '' 5959 || !is_numeric($left_value) || !is_numeric($right_value)) { 5960 $actual[$dimension] = null; 5961 $incomplete[] = $id . ':' . $dimension; 5962 } else { 5963 $left_number = (float) $left_value; 5964 $right_number = (float) $right_value; 5965 if ($left_number < 0 || $right_number < 0 5966 || ($dimension === 'product_id' && ($left_number <= 0 || $right_number <= 0))) { 5967 $actual[$dimension] = null; 5968 $incomplete[] = $id . ':' . $dimension; 5969 } else { 5970 $actual[$dimension] = $left_number === $right_number; 5971 } 5972 } 5973 } elseif ($dimension === 'record_type') { 5974 $left = is_scalar($incoming[$dimension]) ? (string) $incoming[$dimension] : ''; 5975 $right = is_scalar($candidate[$dimension]) ? (string) $candidate[$dimension] : ''; 5976 $actual[$dimension] = ($left !== '' && $right !== '') ? $left === $right : null; 5977 if ($actual[$dimension] === null) { $incomplete[] = $id . ':' . $dimension; } 5978 } else { 5979 $left = $incoming[$dimension]; 5980 $right = $candidate[$dimension]; 5981 if (!$this->is_valid_reconciliation_period_datetime($left) 5982 || !$this->is_valid_reconciliation_period_datetime($right)) { 5983 $actual[$dimension] = null; 5984 $incomplete[] = $id . ':' . $dimension; 5985 } else { 5986 $actual[$dimension] = $left === $right; 5987 } 5988 } 5989 } 5990 foreach (['incoming' => $incoming, 'candidate' => $candidate] as $side => $period_data) { 5991 $range_valid = $this->is_valid_reconciliation_period_range( 5992 $period_data['period_start'] ?? null, 5993 $period_data['period_end'] ?? null 5994 ); 5995 if ($range_valid === false) { 5996 $invalid_period_ranges[] = $id . ':' . $side; 5997 $mismatched[] = $id; 5998 } elseif ($range_valid === null) { 5999 // Boundary validity is already reported per dimension above. 6000 // Do not turn unknown boundaries into an ordering conclusion. 6001 } 6002 } 6003 $expected_checks = $expected[$id]; 6004 foreach ($dimensions as $dimension) { 6005 if (!array_key_exists($dimension, $expected_checks) || $expected_checks[$dimension] !== $actual[$dimension]) { 6006 $mismatched[] = $id; 6007 break; 6008 } 6009 } 6010 } 6011 $missing = array_values(array_diff(array_keys($expected), array_keys($seen))); 6012 $errors = []; 6013 if ($missing) { $errors[] = 'adapter_dimension_evidence_missing_candidates'; } 6014 if ($unexpected) { $errors[] = 'adapter_dimension_evidence_has_unexpected_candidates'; } 6015 if ($mismatched) { $errors[] = 'adapter_dimension_checks_disagree_with_conflict_assessment'; } 6016 if ($incomplete) { $errors[] = 'adapter_dimension_evidence_incomplete'; } 6017 if ($invalid_period_ranges) { $errors[] = 'adapter_period_range_end_precedes_start'; } 6018 return [ 6019 'status' => $errors ? 'adapter_dimension_evidence_requires_review' : 'adapter_dimension_evidence_consistent', 6020 'consistent' => !$errors, 6021 'errors' => $errors, 6022 'candidate_count_expected' => count($expected), 6023 'candidate_count_observed' => count($seen), 6024 'missing_candidate_event_ids' => $missing, 6025 'unexpected_candidate_event_ids' => array_values(array_unique($unexpected)), 6026 'mismatched_candidate_event_ids' => array_values(array_unique($mismatched)), 6027 'incomplete_candidate_records' => array_values(array_unique($incomplete, SORT_REGULAR)), 6028 'invalid_period_ranges' => array_values(array_unique($invalid_period_ranges)), 6029 'identity_proven' => false, 6030 'automatic_suppression_authorized' => false, 6031 'policy' => 'Adapter dimension evidence must reproduce the independently recorded comparison checks. Missing dimensions remain unknown; reversed known period boundaries require review. Consistency validates audit integrity only and never proves shared event identity.', 6032 ]; 6033 } 6034 6035 /** 6036 * Validate the complete candidate reconciliation audit chain, including the 6037 * independent candidate ordering checks. This is diagnostic only and cannot 6038 * promote evidence to identity proof or change any applied contribution. 6039 */ 6040 private function build_generic_candidate_reconciliation_chain_assessment($candidate_set_assessment, $conflict_assessment, $evidence_decision, $audit_consistency, $candidate_consistency, $reference_consistency = [], $discovery_signal_consistency = []) { 6041 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 6042 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 6043 $evidence_decision = is_array($evidence_decision) ? $evidence_decision : []; 6044 $audit_consistency = is_array($audit_consistency) ? $audit_consistency : []; 6045 $candidate_consistency = is_array($candidate_consistency) ? $candidate_consistency : []; 6046 $reference_consistency = is_array($reference_consistency) ? $reference_consistency : []; 6047 $discovery_signal_consistency = is_array($discovery_signal_consistency) ? $discovery_signal_consistency : []; 6048 $errors = []; 6049 6050 $inventory_count = max(0, (int) ($candidate_set_assessment['candidate_count_retained'] ?? 0)); 6051 $conflict_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0)); 6052 $decision_count = max(0, (int) ($evidence_decision['candidate_count'] ?? 0)); 6053 $ordering_count = max(0, (int) ($candidate_consistency['candidate_count'] ?? 0)); 6054 $adapter_count = max(0, (int) ($candidate_consistency['adapter_evidence_candidate_count'] ?? 0)); 6055 6056 if (count(array_unique([$inventory_count, $conflict_count, $decision_count, $ordering_count])) > 1) { 6057 $errors[] = 'candidate_inventory_conflict_decision_and_ordering_counts_disagree'; 6058 } 6059 if (!empty($candidate_consistency['adapter_evidence_order_consistent']) && $adapter_count !== $ordering_count) { 6060 $errors[] = 'adapter_evidence_count_disagrees_despite_order_consistency_claim'; 6061 } 6062 if (!empty($candidate_consistency['primary_in_candidate_list']) === false) { 6063 $errors[] = 'primary_candidate_missing_from_candidate_list'; 6064 } 6065 if (!empty($candidate_consistency['adapter_evidence_primary_consistent']) === false) { 6066 $errors[] = 'adapter_evidence_primary_candidate_inconsistent'; 6067 } 6068 if (!empty($candidate_consistency['adapter_evidence_order_consistent']) === false) { 6069 $errors[] = 'adapter_evidence_candidate_order_inconsistent'; 6070 } 6071 if (array_key_exists('adapter_discovery_evidence_consistent', $candidate_consistency) 6072 && empty($candidate_consistency['adapter_discovery_evidence_consistent'])) { 6073 $errors[] = 'adapter_discovery_evidence_provenance_inconsistent'; 6074 } 6075 if (array_key_exists('adapter_dimension_evidence_consistent', $candidate_consistency) 6076 && empty($candidate_consistency['adapter_dimension_evidence_consistent'])) { 6077 $errors[] = 'adapter_dimension_evidence_inconsistent_with_conflict_assessment'; 6078 } 6079 if (empty($audit_consistency['consistent'])) { 6080 $errors[] = 'underlying_candidate_audit_layers_are_inconsistent'; 6081 } 6082 if (array_key_exists('consistent', $reference_consistency) && empty($reference_consistency['consistent'])) { 6083 $errors[] = 'candidate_event_or_source_references_are_inconsistent'; 6084 } 6085 if (array_key_exists('consistent', $discovery_signal_consistency) && empty($discovery_signal_consistency['consistent'])) { 6086 $errors[] = 'candidate_discovery_signal_provenance_is_inconsistent'; 6087 } 6088 if (array_key_exists('classification_coverage_complete', $audit_consistency) 6089 && empty($audit_consistency['classification_coverage_complete'])) { 6090 $errors[] = 'candidate_classification_coverage_incomplete_in_chain'; 6091 } 6092 if (!empty($candidate_set_assessment['candidate_set_truncated']) && empty($evidence_decision['candidate_search_truncated'])) { 6093 $errors[] = 'truncation_missing_from_final_candidate_decision'; 6094 } 6095 if (!empty($evidence_decision['identity_proven']) || !empty($candidate_consistency['identity_proven'])) { 6096 $errors[] = 'candidate_reconciliation_chain_claims_identity_proof'; 6097 } 6098 if (!empty($evidence_decision['automatic_suppression_authorized']) || !empty($candidate_consistency['automatic_suppression_authorized'])) { 6099 $errors[] = 'candidate_reconciliation_chain_authorizes_automatic_suppression'; 6100 } 6101 6102 return [ 6103 'status' => $errors ? 'reconciliation_chain_requires_review' : 'reconciliation_chain_consistent', 6104 'consistent' => !$errors, 6105 'errors' => array_values(array_unique($errors)), 6106 'candidate_counts' => [ 6107 'inventory' => $inventory_count, 6108 'conflict_assessment' => $conflict_count, 6109 'evidence_decision' => $decision_count, 6110 'candidate_ordering' => $ordering_count, 6111 'adapter_evidence' => $adapter_count, 6112 ], 6113 'candidate_search_truncated' => !empty($evidence_decision['candidate_search_truncated']), 6114 'classification_coverage_complete' => !empty($audit_consistency['classification_coverage_complete']), 6115 'classified_candidate_count' => max(0, (int) ($audit_consistency['classified_candidate_count'] ?? 0)), 6116 'unclassified_candidate_count' => max(0, (int) ($audit_consistency['unclassified_candidate_count'] ?? 0)), 6117 'candidate_reference_consistency' => $reference_consistency, 6118 'candidate_discovery_signal_consistency' => $discovery_signal_consistency, 6119 'adapter_discovery_evidence_consistent' => !empty($candidate_consistency['adapter_discovery_evidence_consistent']), 6120 'adapter_dimension_evidence_consistent' => !empty($candidate_consistency['adapter_dimension_evidence_consistent']), 6121 'identity_proven' => false, 6122 'automatic_suppression_authorized' => false, 6123 'policy' => 'This end-to-end check validates audit-chain consistency only. Any inconsistency requires review; no result changes applied_views, totals, or count suppression.', 6124 ]; 6125 } 6126 6127 private function build_generic_product_reconciliation_summary($product_id, $cross_source, $partial_overlap, $product_synthesis, $matched_event = null, $candidate_set = null) { 6128 $cross_source = is_array($cross_source) ? $cross_source : []; 6129 $partial_overlap = is_array($partial_overlap) ? $partial_overlap : []; 6130 $product_synthesis = is_array($product_synthesis) ? $product_synthesis : []; 6131 $candidate_set = is_array($candidate_set) ? $candidate_set : []; 6132 $candidate_set_assessment = $this->build_generic_candidate_set_assessment( 6133 $candidate_set['candidates'] ?? [], 6134 !empty($candidate_set['truncated']), 6135 $candidate_set['observed_count'] ?? null 6136 ); 6137 6138 $capability_status = sanitize_key((string) ($product_synthesis['capability_profile_status'] ?? 'unknown')); 6139 $synthesis_status = sanitize_key((string) ($product_synthesis['status'] ?? 'unknown')); 6140 $partial_status = sanitize_key((string) ($partial_overlap['status'] ?? 'unknown')); 6141 $cross_truncated = !empty($cross_source['candidate_set_truncated']); 6142 $inventory_truncated = !empty($product_synthesis['source_record_groups_truncated']); 6143 $partial_truncated = !empty($partial_overlap['records_truncated']); 6144 6145 $limitations = []; 6146 if ($synthesis_status !== 'available') { $limitations[] = 'product_source_synthesis_' . ($synthesis_status ?: 'unknown'); } 6147 if (!in_array($capability_status, ['available'], true)) { $limitations[] = 'source_capability_profiles_' . ($capability_status ?: 'unknown'); } 6148 if ($cross_truncated) { $limitations[] = 'cross_source_candidate_set_truncated'; } 6149 if ($inventory_truncated) { $limitations[] = 'product_source_record_groups_truncated'; } 6150 if ($partial_status !== 'ok') { $limitations[] = 'partial_overlap_' . ($partial_status ?: 'unknown'); } 6151 if ($partial_truncated) { $limitations[] = 'partial_overlap_records_truncated'; } 6152 6153 $source_count = max(0, (int) ($product_synthesis['source_count_in_scope'] ?? 0)); 6154 $capability_count = max(0, (int) ($product_synthesis['capability_profile_source_count'] ?? 0)); 6155 $complete = !$limitations && $source_count === $capability_count; 6156 6157 // Classify the available evidence without making a new counting decision. 6158 // The exact-match flag is supplied only by the importer's existing path, 6159 // which has already validated the shared fingerprint and normalized dimensions. 6160 $candidate_records = max(0, (int) ($cross_source['candidate_record_count'] ?? 0)); 6161 $candidate_sources = max(0, (int) ($cross_source['candidate_source_count'] ?? 0)); 6162 if (is_array($matched_event) && !empty($matched_event['id'])) { 6163 $decision_classification = 'confirmed_exact_shared_fingerprint_match'; 6164 $decision_basis = 'existing_importer_exact_match_contract'; 6165 $decision_identity_proven = true; 6166 } elseif (!empty($candidate_set_assessment['multiple_candidate_ambiguity'])) { 6167 $decision_classification = 'multiple_cross_source_candidates_require_review'; 6168 $decision_basis = 'all_candidates_retained_without_identity_resolution'; 6169 $decision_identity_proven = false; 6170 } elseif ($candidate_records > 0 || $candidate_sources > 0) { 6171 $decision_classification = 'possible_cross_source_overlap_requires_review'; 6172 $decision_basis = 'candidate_fingerprint_or_temporal_evidence_only'; 6173 $decision_identity_proven = false; 6174 } else { 6175 $decision_classification = 'insufficient_evidence_to_establish_cross_source_overlap'; 6176 $decision_basis = 'no_candidate_found_within_declared_search_bounds'; 6177 $decision_identity_proven = false; 6178 } 6179 6180 return [ 6181 'version' => '1.0.0', 6182 'status' => $complete ? 'evidence_inventory_complete_within_declared_bounds' : 'evidence_inventory_partial_or_limited', 6183 'product_id' => absint($product_id), 6184 'source_scope' => 'all_persisted_generic_sources_for_product', 6185 'source_count' => $source_count, 6186 'capability_profile_source_count' => $capability_count, 6187 'cross_source_candidate_count' => max(0, (int) ($cross_source['candidate_source_count'] ?? 0)), 6188 'partial_overlap_source_count' => count((array) ($partial_overlap['profiles'] ?? [])), 6189 'same_bucket_count_bounds' => $cross_source['identical_bucket_count_range'] ?? ['status' => 'unavailable'], 6190 'reconciliation_decision' => [ 6191 'classification' => $decision_classification, 6192 'basis' => $decision_basis, 6193 'identity_proven' => $decision_identity_proven, 6194 'summary_authorizes_count_change' => false, 6195 'candidate_search_truncated' => $cross_truncated, 6196 'candidate_set_assessment' => $candidate_set_assessment, 6197 'policy' => 'This classification describes evidence only. It does not independently authorize suppression, adjustment, or rewriting of applied_views.', 6198 ], 6199 'unique_view_total' => null, 6200 'counting_decision' => 'preserve_all_applied_contributions_no_automatic_suppression', 6201 'limitations' => array_values(array_unique($limitations)), 6202 'interpretation' => 'This summary combines source inventory, declared capabilities, candidate overlap evidence, and the availability limits of partial-period comparison. It does not prove event identity or calculate a deduplicated unique-view total.', 6203 ]; 6204 } 6205 6206 /** 6207 * Partial aggregate-period duration comparisons are withheld until source 6208 * timezone provenance is persisted. Naive datetime strings cannot safely be 6209 * converted into shared elapsed-time intervals. 6210 */ 6211 private function build_generic_partial_overlap_profiles($product_id, $source_id, $period_start, $period_end) { 6212 // Persisted period strings do not include source timezone provenance. 6213 // Do not report elapsed seconds or coverage ratios from naive datetimes. 6214 return [ 6215 'status' => 'unavailable', 6216 'reason' => 'timezone_basis_not_persisted', 6217 'incoming_period_seconds' => null, 6218 'profiles' => [], 6219 'records_truncated' => false, 6220 'identity_confidence' => 'unproven_without_shared_event_identity', 6221 'temporal_comparison' => 'not_performed', 6222 ]; 6223 } 6224 6225 /** Execute the importer while the caller holds the source-origin advisory lock. */ 6226 private function process_generic_historical_view_import_batch($source_id, $origin_key, $retry_checkpoint_override = null, $origin_lock = null) { 6227 global $wpdb; 6228 6229 $source_id = absint($source_id); 6230 $origin_key = sanitize_key((string) $origin_key); 6231 if (!$source_id || $origin_key === '') { 6232 return ['status' => 'invalid_input']; 6233 } 6234 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 6235 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6236 return ['status' => 'origin_lock_session_lost']; 6237 } 6238 6239 $state = $this->get_view_source_origin_state($source_id, $origin_key); 6240 if (empty($state['status']) || $state['status'] !== 'found') { 6241 return ['status' => 'origin_not_found']; 6242 } 6243 // A queued action may outlive a provider's latest discovery inventory. 6244 // Never let such a stale action process an origin that has since been 6245 // marked unavailable or is no longer explicitly detected as available. 6246 if ($state['origin_status'] === 'unavailable' || $state['detection_status'] !== 'available') { 6247 return ['status' => 'origin_unavailable']; 6248 } 6249 if ($state['origin_status'] === 'completed' && $retry_checkpoint_override === null) { 6250 return ['status' => 'already_completed']; 6251 } 6252 // A terminal failure for one record must not stop the main source scan. 6253 // The failed record remains in the ledger for review, while later records 6254 // continue importing. Only the independent retry worker is capped at three 6255 // total attempts for that individual record. 6256 if (!is_array($state['snapshot']) || empty($state['snapshot']['boundary']) || !is_array($state['checkpoint'])) { 6257 return ['status' => 'snapshot_not_ready']; 6258 } 6259 6260 $adapter_key = isset($state['adapter_key']) ? sanitize_key($state['adapter_key']) : ''; 6261 $adapter = $this->get_historical_view_adapter($adapter_key); 6262 if (!$adapter) { 6263 // A scheduled request may have arrived before provider registration. 6264 $this->discover_historical_view_adapters(); 6265 $adapter = $this->get_historical_view_adapter($adapter_key); 6266 } 6267 if (!$adapter) { 6268 // Provider plugins may be temporarily inactive, load later than this 6269 // worker, or briefly fail while assembling their registry. Keep the 6270 // immutable snapshot/checkpoint untouched and retry discovery later. 6271 // Normal plugin initialization also rediscovers providers, so a 6272 // returning adapter can resume automatically without administrator 6273 // intervention. A replacement adapter still cannot reinterpret the 6274 // persisted identity because register_view_source_origin() guards it. 6275 $this->update_view_source_origin_state($source_id, $origin_key, [ 6276 'last_error' => 'The registered historical adapter is temporarily unavailable; automatic discovery will be retried.', 6277 ], $origin_lock); 6278 $this->schedule_generic_historical_view_import_batch(21600, $source_id, $origin_key); 6279 return ['status' => 'adapter_unavailable', 'retry_scheduled' => true]; 6280 } 6281 6282 $checkpoint = ($retry_checkpoint_override !== null && is_array($retry_checkpoint_override)) 6283 ? $retry_checkpoint_override 6284 : $state['checkpoint']; 6285 $batch_start_checkpoint = $checkpoint; 6286 try { 6287 $batch = $adapter->get_product_view_batch($origin_key, $checkpoint, $state['snapshot'], 100); 6288 } catch (Throwable $exception) { 6289 $failure = $this->record_generic_historical_view_failure($source_id, 6290 $origin_key, 6291 'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32), 6292 0, 6293 $exception->getMessage(), 6294 null, 6295 8, 6296 $origin_lock); 6297 if (!$failure['blocked']) { 6298 $this->schedule_generic_historical_view_import_batch($failure['retry_delay'], $source_id, $origin_key); 6299 } 6300 return ['status' => $failure['blocked'] ? 'needs_attention' : 'source_read_failed', 'attempts' => $failure['attempts']]; 6301 } 6302 6303 $batch_rows_are_valid_list = is_array($batch) 6304 && isset($batch['rows']) 6305 && is_array($batch['rows']) 6306 && array_keys($batch['rows']) === (count($batch['rows']) > 0 ? range(0, count($batch['rows']) - 1) : []) 6307 && count($batch['rows']) <= 100; 6308 if ($batch_rows_are_valid_list) { 6309 foreach ($batch['rows'] as $batch_row) { 6310 if (!is_array($batch_row)) { 6311 $batch_rows_are_valid_list = false; 6312 break; 6313 } 6314 } 6315 } 6316 6317 $batch_is_valid = is_array($batch) 6318 && $batch_rows_are_valid_list 6319 && array_key_exists('has_more', $batch) 6320 && is_bool($batch['has_more']) 6321 // The adapter must apply the immutable snapshot boundary in its source query. 6322 && isset($batch['boundary_applied']) 6323 && $batch['boundary_applied'] === true; 6324 6325 if ($batch_is_valid && $batch['has_more']) { 6326 $batch_is_valid = !empty($batch['rows']) 6327 && array_key_exists('next_checkpoint', $batch) 6328 && (is_array($batch['next_checkpoint']) || is_scalar($batch['next_checkpoint'])) 6329 && $this->is_valid_generic_historical_cursor($batch['next_checkpoint']) 6330 && wp_json_encode($batch['next_checkpoint']) !== wp_json_encode($checkpoint); 6331 } 6332 6333 if (!$batch_is_valid) { 6334 $failure = $this->record_generic_historical_view_failure($source_id, 6335 $origin_key, 6336 'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32), 6337 0, 6338 'Adapter returned an invalid or non-progressing batch. Expected a sequential list of at most 100 array rows, a boolean has_more, boundary_applied=true, and an advancing next_checkpoint whenever more rows remain.', 6339 null, 6340 8, 6341 $origin_lock); 6342 if (!$failure['blocked']) { 6343 $this->schedule_generic_historical_view_import_batch($failure['retry_delay'], $source_id, $origin_key); 6344 } 6345 return ['status' => $failure['blocked'] ? 'needs_attention' : 'invalid_batch', 'attempts' => $failure['attempts']]; 6346 } 6347 6348 // The source has now returned a structurally valid, snapshot-bounded batch; 6349 // clear a previous transient batch-read failure before processing its records. 6350 $batch_failure_cleared = $this->clear_generic_historical_view_failure( 6351 $source_id, 6352 $origin_key, 6353 'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32) 6354 , $origin_lock); 6355 if (!$batch_failure_cleared) { 6356 // Do not consume this batch while its previous batch-level failure 6357 // remains unresolved in the durable ledger. 6358 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6359 return ['status' => 'failure_ledger_clear_failed']; 6360 } 6361 6362 $processed = 0; 6363 $duplicates = 0; 6364 $failed = 0; 6365 $skipped = 0; 6366 $next_checkpoint = $checkpoint; 6367 6368 $checkpoint_persistence_failed = false; 6369 foreach ($batch['rows'] as $record) { 6370 try { 6371 $normalized = $adapter->normalize_record($origin_key, $record); 6372 } catch (Throwable $exception) { 6373 $normalized = null; 6374 $normalization_error = $exception->getMessage(); 6375 } 6376 6377 // A replay of a saved batch must retry only records whose own retry 6378 // deadline has arrived. Otherwise one due record could cause blocked 6379 // or not-yet-due neighbours in the same batch to be retried indefinitely. 6380 if ($retry_checkpoint_override !== null) { 6381 if (is_array($normalized)) { 6382 $retry_record_key = !empty($normalized['source_record_key']) 6383 ? (string) $normalized['source_record_key'] 6384 : wp_json_encode($record); 6385 } elseif (is_array($record) && isset($record['source_record_key'])) { 6386 $retry_record_key = (string) $record['source_record_key']; 6387 } else { 6388 $retry_record_key = wp_json_encode($record); 6389 } 6390 $retry_record_key = substr((string) $retry_record_key, 0, 191); 6391 $retry_record_hash = hash('sha256', (string) $origin_key . "\0" . $retry_record_key); 6392 $retry_is_due = statpl_db_get_var(statpl_db_prepare( 6393 "SELECT id FROM {$this->view_source_failures_table} 6394 WHERE source_id = %d AND source_record_hash = %s 6395 AND status = 'retry' AND next_retry_at <= %s 6396 LIMIT 1", 6397 $source_id, 6398 $retry_record_hash, 6399 current_time('mysql') 6400 )); 6401 if (!empty($wpdb->last_error)) { 6402 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 6403 return ['status' => 'retry_ledger_lookup_failed', 'processed' => $processed, 'duplicates' => $duplicates]; 6404 } 6405 if (!$retry_is_due) { 6406 continue; 6407 } 6408 } 6409 6410 if (is_array($normalized) && !empty($normalized['skip'])) { 6411 // Adapters may explicitly identify a source row as outside their 6412 // product-view scope (for example, a site-wide analytics row for 6413 // a blog page). Such rows are not failures and must not block the 6414 // scan, but their source key and cursor still require validation. 6415 $skip_record_key = isset($normalized['source_record_key']) 6416 ? trim((string) $normalized['source_record_key']) : ''; 6417 $skip_cursor = array_key_exists('cursor', $normalized) ? $normalized['cursor'] : null; 6418 if ($skip_record_key === '' || strlen($skip_record_key) > 191 6419 || !$this->is_valid_generic_historical_cursor($skip_cursor)) { 6420 $normalized = null; 6421 $normalization_error = 'Adapter returned an invalid explicit skip record.'; 6422 } else { 6423 $skip_failure_cleared = $this->clear_generic_historical_view_failure( 6424 $source_id, 6425 $origin_key, 6426 $skip_record_key 6427 , $origin_lock, null); 6428 if (!$skip_failure_cleared) { 6429 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6430 $checkpoint_persistence_failed = true; 6431 break; 6432 } 6433 $next_checkpoint['cursor'] = $skip_cursor; 6434 $skipped++; 6435 continue; 6436 } 6437 } 6438 6439 if (!is_array($normalized)) { 6440 $failed++; 6441 $failure = $this->record_generic_historical_view_failure( 6442 $source_id, 6443 $origin_key, 6444 (is_array($record) && isset($record['source_record_key'])) ? $record['source_record_key'] : wp_json_encode($record), 6445 0, 6446 isset($normalization_error) ? $normalization_error : 'Adapter could not normalize a source record.', 6447 $batch_start_checkpoint, 6448 3 6449 , $origin_lock); 6450 if (empty($failure['persisted'])) { 6451 // Without a durable failure-ledger entry, do not advance past this record. 6452 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6453 $checkpoint_persistence_failed = true; 6454 break; 6455 } 6456 if (!$failure['blocked']) { 6457 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6458 } 6459 unset($normalization_error); 6460 continue; 6461 } 6462 6463 $product_id = isset($normalized['product_id']) ? absint($normalized['product_id']) : 0; 6464 $record_key = isset($normalized['source_record_key']) ? trim((string) $normalized['source_record_key']) : ''; 6465 $raw_views = array_key_exists('views', $normalized) ? $normalized['views'] : null; 6466 $views_is_valid = is_int($raw_views) && $raw_views >= 0; 6467 if (is_string($raw_views) && preg_match('/^(0|[1-9]\d*)$/D', $raw_views)) { 6468 // Do not compare large decimal strings through float: on 64-bit PHP, 6469 // (float) PHP_INT_MAX rounds to 2^63 and can admit an overflowing value. 6470 $max_views_string = (string) PHP_INT_MAX; 6471 $raw_views_length = strlen($raw_views); 6472 $max_views_length = strlen($max_views_string); 6473 $views_is_valid = $raw_views_length < $max_views_length 6474 || ($raw_views_length === $max_views_length && strcmp($raw_views, $max_views_string) <= 0); 6475 } 6476 $views = $views_is_valid ? (int) $raw_views : -1; 6477 $cursor = array_key_exists('cursor', $normalized) ? $normalized['cursor'] : null; 6478 $record_type = isset($normalized['record_type']) ? sanitize_key($normalized['record_type']) : 'event'; 6479 $period_start = array_key_exists('period_start', $normalized) && $normalized['period_start'] !== null 6480 ? (string) $normalized['period_start'] : null; 6481 $period_end = array_key_exists('period_end', $normalized) && $normalized['period_end'] !== null 6482 ? (string) $normalized['period_end'] : null; 6483 $periods_are_valid = $this->is_valid_generic_historical_datetime($period_start) 6484 && $this->is_valid_generic_historical_datetime($period_end); 6485 if ($periods_are_valid && $period_start !== null && $period_end !== null) { 6486 $periods_are_valid = strcmp($period_start, $period_end) <= 0; 6487 } 6488 6489 if ( 6490 !$product_id || get_post_type($product_id) !== 'product' 6491 || $record_key === '' || strlen($record_key) > 191 6492 || !$views_is_valid || $views < 0 || !in_array($record_type, ['event', 'aggregate'], true) 6493 || !$this->is_valid_generic_historical_cursor($cursor) 6494 || !$periods_are_valid 6495 || ($record_type === 'aggregate' && ($period_start === null || $period_end === null)) 6496 ) { 6497 $failed++; 6498 $failure = $this->record_generic_historical_view_failure( 6499 $source_id, 6500 $origin_key, 6501 $record_key !== '' ? $record_key : wp_json_encode($record), 6502 $product_id, 6503 'Normalized record failed validation (product, key, views, type or cursor).', 6504 $batch_start_checkpoint, 6505 3 6506 , $origin_lock); 6507 if (empty($failure['persisted'])) { 6508 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6509 $checkpoint_persistence_failed = true; 6510 break; 6511 } 6512 if (!$failure['blocked']) { 6513 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6514 } 6515 unset($normalization_error); 6516 continue; 6517 } 6518 6519 $record_hash = hash('sha256', $origin_key . "\0" . $record_key); 6520 $now = current_time('mysql'); 6521 $source_fingerprint = isset($normalized['source_fingerprint']) 6522 ? strtolower(trim((string) $normalized['source_fingerprint'])) 6523 : ''; 6524 if ($source_fingerprint !== '' && !preg_match('/^[a-f0-9]{64}$/D', $source_fingerprint)) { 6525 $failed++; 6526 $failure = $this->record_generic_historical_view_failure( 6527 $source_id, 6528 $origin_key, 6529 $record_key, 6530 $product_id, 6531 'Adapter supplied an invalid cross-source fingerprint; expected exactly 64 hexadecimal characters.', 6532 $batch_start_checkpoint, 6533 3 6534 , $origin_lock); 6535 if (empty($failure['persisted'])) { 6536 // Without a durable failure-ledger entry, do not advance past this record. 6537 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6538 $checkpoint_persistence_failed = true; 6539 break; 6540 } 6541 if (!$failure['blocked']) { 6542 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6543 } 6544 continue; 6545 } 6546 6547 // Cross-plugin reconciliation is deliberately conservative: suppress a 6548 // second contribution only when both adapters provide the same explicit 6549 // stable fingerprint AND all normalized event dimensions agree exactly. 6550 // A fingerprint-scoped advisory lock also serializes the no-existing-row 6551 // case, which SELECT ... FOR UPDATE alone cannot reliably protect. 6552 $reconciliation_lock = 'statpl_xr_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_fingerprint), 0, 48); 6553 $reconciliation_lock_state = null; 6554 // Aggregate fingerprints derived from product/date/count are not shared 6555 // event identities. They must never drive exact cross-source suppression. 6556 // Keep exact fingerprint reconciliation restricted to individual events. 6557 $has_exact_event_fingerprint = $source_fingerprint !== '' && $record_type === 'event'; 6558 if ($has_exact_event_fingerprint) { 6559 $reconciliation_lock_state = $this->acquire_generic_advisory_lock($reconciliation_lock); 6560 if (empty($reconciliation_lock_state['acquired'])) { 6561 $this->schedule_generic_historical_view_import_batch(30, $source_id, $origin_key); 6562 return ['status' => 'cross_source_lock_busy', 'processed' => $processed, 'duplicates' => $duplicates]; 6563 } 6564 if (!$this->generic_advisory_lock_session_is_current($reconciliation_lock_state)) { 6565 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6566 return ['status' => 'cross_source_lock_session_lost', 'processed' => $processed, 'duplicates' => $duplicates]; 6567 } 6568 } 6569 6570 $transaction_open = false; 6571 try { 6572 if (statpl_db_query('START TRANSACTION') === false) { 6573 $failed++; 6574 $transaction_error = (string) $wpdb->last_error; 6575 $failure = $this->record_generic_historical_view_failure( 6576 $source_id, 6577 $origin_key, 6578 $record_key, 6579 $product_id, 6580 'Could not start atomic source-event transaction: ' . $transaction_error, 6581 $batch_start_checkpoint, 6582 3 6583 , $origin_lock); 6584 if (empty($failure['persisted'])) { 6585 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6586 $checkpoint_persistence_failed = true; 6587 break; 6588 } 6589 if (!$failure['blocked']) { 6590 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6591 } 6592 continue; 6593 } 6594 $transaction_open = true; 6595 $matched_event = null; 6596 $possible_overlap_event = null; 6597 $possible_overlap_events = []; 6598 $possible_overlap_candidates_truncated = false; 6599 $possible_overlap_fingerprint_candidates_observed = 0; 6600 $possible_overlap_temporal_candidates_observed = 0; 6601 $possible_overlap_candidate_count_observed = 0; 6602 $possible_overlap_candidate_count_is_lower_bound = false; 6603 $possible_overlap_reason_code = ''; 6604 if ($has_exact_event_fingerprint) { 6605 $matched_event = statpl_db_get_row(statpl_db_prepare( 6606 "SELECT id, source_id, product_id, record_type, observed_views, period_start, period_end, applied_views 6607 FROM {$this->view_source_events_table} 6608 WHERE source_fingerprint = %s 6609 AND source_id <> %d 6610 AND product_id = %d 6611 AND record_type = %s 6612 AND observed_views = %d 6613 AND ((period_start IS NULL AND %s = '') OR period_start = %s) 6614 AND ((period_end IS NULL AND %s = '') OR period_end = %s) 6615 ORDER BY id ASC LIMIT 1 FOR UPDATE", 6616 $source_fingerprint, 6617 $source_id, 6618 $product_id, 6619 $record_type, 6620 $views, 6621 (string) $period_start, 6622 $period_start, 6623 (string) $period_end, 6624 $period_end 6625 ), ARRAY_A); 6626 if (!empty($wpdb->last_error)) { 6627 $lookup_error = $wpdb->last_error; 6628 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6629 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6630 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6631 } 6632 $failed++; 6633 $failure = $this->record_generic_historical_view_failure( 6634 $source_id, 6635 $origin_key, 6636 $record_key, 6637 $product_id, 6638 'Cross-source reconciliation lookup failed: ' . $lookup_error, 6639 $batch_start_checkpoint, 6640 3 6641 , $origin_lock); 6642 if (empty($failure['persisted'])) { 6643 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6644 $checkpoint_persistence_failed = true; 6645 break; 6646 } 6647 if (!$failure['blocked']) { 6648 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6649 } 6650 continue; 6651 } 6652 if (!$matched_event) { 6653 // Same explicit fingerprint with different normalized dimensions is 6654 // evidence of a possible/partial overlap, not proof of a duplicate. 6655 // Preserve and count this contribution, but retain an auditable 6656 // candidate link so a source-specific reconciliation rule can review it. 6657 $fingerprint_candidate_result = $this->get_generic_reconciliation_candidates_by_source( 6658 'fingerprint', 6659 ['fingerprint' => $source_fingerprint, 'source_id' => $source_id], 6660 25 6661 ); 6662 $possible_overlap_events = $fingerprint_candidate_result['events']; 6663 $possible_overlap_fingerprint_candidates_observed = (int) $fingerprint_candidate_result['observed']; 6664 if ($fingerprint_candidate_result['status'] === 'error') { 6665 $lookup_error = $fingerprint_candidate_result['error']; 6666 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6667 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6668 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6669 } 6670 $failed++; 6671 $failure = $this->record_generic_historical_view_failure( 6672 $source_id, $origin_key, $record_key, $product_id, 6673 'Possible-overlap reconciliation lookup failed: ' . $lookup_error, 6674 $batch_start_checkpoint, 3, $origin_lock 6675 ); 6676 if (empty($failure['persisted'])) { 6677 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6678 $checkpoint_persistence_failed = true; 6679 break; 6680 } 6681 if (!$failure['blocked']) { 6682 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6683 } 6684 continue; 6685 } 6686 if (!empty($fingerprint_candidate_result['truncated'])) { 6687 $possible_overlap_candidates_truncated = true; 6688 $possible_overlap_candidate_count_is_lower_bound = true; 6689 } 6690 if (!empty($possible_overlap_events)) { 6691 $possible_overlap_event = $possible_overlap_events[0]; 6692 $possible_overlap_reason_code = 'shared_fingerprint_dimension_mismatch'; 6693 } 6694 if (!empty($wpdb->last_error)) { 6695 $lookup_error = $wpdb->last_error; 6696 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6697 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6698 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6699 } 6700 $failed++; 6701 $failure = $this->record_generic_historical_view_failure( 6702 $source_id, 6703 $origin_key, 6704 $record_key, 6705 $product_id, 6706 'Possible-overlap reconciliation lookup failed: ' . $lookup_error, 6707 $batch_start_checkpoint, 6708 3 6709 , $origin_lock); 6710 if (empty($failure['persisted'])) { 6711 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6712 $checkpoint_persistence_failed = true; 6713 break; 6714 } 6715 if (!$failure['blocked']) { 6716 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6717 } 6718 continue; 6719 } 6720 } 6721 } 6722 // Cross-source temporal overlap is retained as evidence only. This 6723 // includes aggregate/event, aggregate/aggregate, and event/event pairs. 6724 // Event timestamps can reveal strong candidate relationships even when 6725 // providers do not share an event ID; temporal proximity alone still does 6726 // not prove identity, so this discovery step never suppresses counts. 6727 if ( 6728 !$matched_event 6729 && in_array($record_type, ['aggregate', 'event'], true) 6730 && $period_start !== null 6731 && $period_end !== null 6732 ) { 6733 $temporal_candidate_result = $this->get_generic_reconciliation_candidates_by_source( 6734 'temporal', 6735 ['source_id' => $source_id, 'product_id' => $product_id, 'period_start' => $period_start, 'period_end' => $period_end], 6736 25 6737 ); 6738 $temporal_overlap_events = $temporal_candidate_result['events']; 6739 if ($temporal_candidate_result['status'] === 'error') { 6740 $lookup_error = $temporal_candidate_result['error']; 6741 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6742 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6743 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6744 } 6745 $failed++; 6746 $failure = $this->record_generic_historical_view_failure( 6747 $source_id, $origin_key, $record_key, $product_id, 6748 'Cross-source temporal-overlap lookup failed: ' . $lookup_error, 6749 $batch_start_checkpoint, 3, $origin_lock 6750 ); 6751 if (empty($failure['persisted'])) { 6752 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6753 $checkpoint_persistence_failed = true; 6754 break; 6755 } 6756 if (!$failure['blocked']) { 6757 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6758 } 6759 continue; 6760 } 6761 if (!empty($wpdb->last_error)) { 6762 $lookup_error = $wpdb->last_error; 6763 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6764 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6765 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6766 } 6767 $failed++; 6768 $failure = $this->record_generic_historical_view_failure( 6769 $source_id, 6770 $origin_key, 6771 $record_key, 6772 $product_id, 6773 'Cross-source temporal-overlap lookup failed: ' . $lookup_error, 6774 $batch_start_checkpoint, 6775 3 6776 , $origin_lock); 6777 if (empty($failure['persisted'])) { 6778 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6779 $checkpoint_persistence_failed = true; 6780 break; 6781 } 6782 if (!$failure['blocked']) { 6783 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6784 } 6785 continue; 6786 } 6787 $possible_overlap_temporal_candidates_observed = (int) $temporal_candidate_result['observed']; 6788 if (!empty($temporal_candidate_result['truncated'])) { 6789 $possible_overlap_candidates_truncated = true; 6790 $possible_overlap_candidate_count_is_lower_bound = true; 6791 } 6792 6793 $candidate_discovery_by_id = []; 6794 foreach ($possible_overlap_events as $candidate_event) { 6795 $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0; 6796 if ($candidate_id > 0) { 6797 $candidate_discovery_by_id[$candidate_id]['fingerprint'] = true; 6798 } 6799 } 6800 foreach ($temporal_overlap_events as $candidate_event) { 6801 $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0; 6802 if ($candidate_id > 0) { 6803 $candidate_discovery_by_id[$candidate_id]['temporal'] = true; 6804 } 6805 } 6806 6807 $candidate_events_by_id = []; 6808 foreach (array_merge($possible_overlap_events, $temporal_overlap_events) as $candidate_event) { 6809 $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0; 6810 if ($candidate_id > 0) { 6811 $candidate_event['_discovered_by'] = array_keys($candidate_discovery_by_id[$candidate_id] ?? []); 6812 $candidate_events_by_id[$candidate_id] = $candidate_event; 6813 } 6814 } 6815 $possible_overlap_events = array_values($candidate_events_by_id); 6816 usort($possible_overlap_events, static function ($left, $right) { 6817 $left_sources = isset($left['_discovered_by']) ? $left['_discovered_by'] : []; 6818 $right_sources = isset($right['_discovered_by']) ? $right['_discovered_by'] : []; 6819 $left_rank = count($left_sources) > 1 ? 0 : (in_array('fingerprint', $left_sources, true) ? 1 : 2); 6820 $right_rank = count($right_sources) > 1 ? 0 : (in_array('fingerprint', $right_sources, true) ? 1 : 2); 6821 if ($left_rank !== $right_rank) { 6822 return $left_rank <=> $right_rank; 6823 } 6824 return (int) $left['id'] <=> (int) $right['id']; 6825 }); 6826 $possible_overlap_candidate_count_observed = count($possible_overlap_events); 6827 if (count($possible_overlap_events) > 25) { 6828 $possible_overlap_candidates_truncated = true; 6829 $possible_overlap_candidate_count_is_lower_bound = true; 6830 $possible_overlap_events = array_slice($possible_overlap_events, 0, 25); 6831 } 6832 if (!empty($possible_overlap_events)) { 6833 $possible_overlap_event = $possible_overlap_events[0]; 6834 } 6835 if ($possible_overlap_event) { 6836 $has_fingerprint_candidate = false; 6837 $has_cross_type_candidate = false; 6838 $has_same_type_event_candidate = false; 6839 foreach ($possible_overlap_events as $candidate_event) { 6840 $discovery = isset($candidate_event['_discovered_by']) ? $candidate_event['_discovered_by'] : []; 6841 if (in_array('fingerprint', $discovery, true)) { 6842 $has_fingerprint_candidate = true; 6843 } 6844 if ( 6845 in_array('temporal', $discovery, true) 6846 && isset($candidate_event['record_type']) 6847 && $candidate_event['record_type'] !== $record_type 6848 ) { 6849 $has_cross_type_candidate = true; 6850 } 6851 if ( 6852 in_array('temporal', $discovery, true) 6853 && $record_type === 'event' 6854 && isset($candidate_event['record_type']) 6855 && $candidate_event['record_type'] === 'event' 6856 ) { 6857 $has_same_type_event_candidate = true; 6858 } 6859 } 6860 if ($has_fingerprint_candidate) { 6861 $possible_overlap_reason_code = 'shared_fingerprint_dimension_mismatch'; 6862 } elseif ($has_cross_type_candidate) { 6863 $possible_overlap_reason_code = 'aggregate_event_temporal_overlap'; 6864 } elseif ($has_same_type_event_candidate) { 6865 $possible_overlap_reason_code = 'event_timestamp_overlap'; 6866 } elseif ($possible_overlap_reason_code === '') { 6867 $possible_overlap_reason_code = 'overlapping_aggregate_periods'; 6868 } 6869 } 6870 } 6871 6872 if (empty($temporal_overlap_events) && !empty($possible_overlap_events)) { 6873 foreach ($possible_overlap_events as &$candidate_event) { 6874 $candidate_event['_discovered_by'] = ['fingerprint']; 6875 } 6876 unset($candidate_event); 6877 $possible_overlap_candidate_count_observed = count($possible_overlap_events); 6878 if (!empty($possible_overlap_events)) { 6879 $possible_overlap_event = $possible_overlap_events[0]; 6880 } 6881 } 6882 6883 // Optional source-specific analysis enriches the audit trail for partial 6884 // overlaps. It is deliberately advisory: an adapter cannot silently change 6885 // the counting policy or suppress a contribution through this extension. 6886 $adapter_overlap_evidence = []; 6887 $adapter_evidence_bytes = 0; 6888 $adapter_evidence_total_limit = 65536; 6889 $adapter_evidence_candidate_limit = 4096; 6890 $adapter_evidence_omitted = 0; 6891 if (!$matched_event && $possible_overlap_event && method_exists($adapter, 'get_reconciliation_evidence')) { 6892 $overlap_candidates = !empty($possible_overlap_events) 6893 ? $possible_overlap_events 6894 : [$possible_overlap_event]; 6895 foreach ($overlap_candidates as $candidate_event) { 6896 try { 6897 $adapter_evidence = $adapter->get_reconciliation_evidence( 6898 $origin_key, 6899 $normalized, 6900 $candidate_event 6901 ); 6902 } catch (Throwable $exception) { 6903 $adapter_evidence = [ 6904 'analysis_status' => 'failed', 6905 'error' => substr(sanitize_text_field($exception->getMessage()), 0, 500), 6906 ]; 6907 } 6908 6909 if (!is_array($adapter_evidence)) { 6910 $adapter_evidence = ['analysis_status' => 'invalid_result']; 6911 } 6912 $encoded_adapter_evidence = wp_json_encode($adapter_evidence); 6913 $candidate_evidence_size = is_string($encoded_adapter_evidence) ? strlen($encoded_adapter_evidence) : 0; 6914 if (!is_string($encoded_adapter_evidence)) { 6915 $adapter_evidence = [ 6916 'analysis_status' => 'rejected', 6917 'reason' => 'Evidence was not JSON serializable.', 6918 ]; 6919 $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence)); 6920 } elseif ($candidate_evidence_size > $adapter_evidence_candidate_limit) { 6921 $adapter_evidence = [ 6922 'analysis_status' => 'rejected', 6923 'reason' => 'Evidence exceeded the 4 KiB per-candidate limit.', 6924 'original_bytes' => $candidate_evidence_size, 6925 ]; 6926 $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence)); 6927 } 6928 if ($adapter_evidence_bytes + $candidate_evidence_size > $adapter_evidence_total_limit) { 6929 $adapter_evidence_omitted++; 6930 $adapter_evidence = [ 6931 'analysis_status' => 'omitted_budget', 6932 'reason' => 'The 64 KiB total adapter-evidence budget was reached.', 6933 ]; 6934 $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence)); 6935 } 6936 $adapter_evidence_bytes += $candidate_evidence_size; 6937 $candidate_start = isset($candidate_event['period_start']) ? (string) $candidate_event['period_start'] : ''; 6938 $candidate_end = isset($candidate_event['period_end']) ? (string) $candidate_event['period_end'] : ''; 6939 $incoming_start = (string) $period_start; 6940 $incoming_end = (string) $period_end; 6941 $incoming_has_period = $incoming_start !== '' && $incoming_end !== ''; 6942 $candidate_has_period = $candidate_start !== '' && $candidate_end !== ''; 6943 // Exact string equality remains a representation-level signal; 6944 // it is not a timezone-safe temporal comparison. 6945 $same_start = $incoming_has_period && $candidate_has_period && $incoming_start === $candidate_start; 6946 $same_end = $incoming_has_period && $candidate_has_period && $incoming_end === $candidate_end; 6947 $incoming_temporal_basis = $this->classify_reconciliation_temporal_basis( 6948 $adapter_key, 6949 $origin_key, 6950 $record_type 6951 ); 6952 $candidate_adapter_key = isset($candidate_event['candidate_adapter_key']) 6953 ? sanitize_key((string) $candidate_event['candidate_adapter_key']) : ''; 6954 $candidate_record_key = isset($candidate_event['source_record_key']) 6955 ? (string) $candidate_event['source_record_key'] : ''; 6956 $candidate_origin_key = ''; 6957 if ($candidate_adapter_key === 'pulseviews') { 6958 if (strpos($candidate_record_key, 'pulseviews_day_') === 0) { 6959 $candidate_origin_key = 'daily_aggregates'; 6960 } elseif (strpos($candidate_record_key, 'pulseviews_view_') === 0) { 6961 $candidate_origin_key = 'recent_raw_views'; 6962 } 6963 } 6964 $candidate_temporal_basis = $this->classify_reconciliation_temporal_basis( 6965 $candidate_adapter_key, 6966 $candidate_origin_key, 6967 isset($candidate_event['record_type']) ? $candidate_event['record_type'] : '' 6968 ); 6969 $periods_intersect = false; 6970 $temporal_relation = ($incoming_has_period && $candidate_has_period) 6971 ? 'not_compared_timezone_basis_unverified' 6972 : 'insufficient_temporal_data'; 6973 $incoming_precision = 'unknown'; 6974 if ($record_type === 'aggregate') { 6975 $incoming_precision = 'aggregate_period'; 6976 } elseif ($adapter_key === 'statify') { 6977 $incoming_precision = 'calendar_day'; 6978 } elseif (in_array($adapter_key, ['burst_statistics', 'independent_analytics'], true)) { 6979 $incoming_precision = 'seconds'; 6980 } elseif ($adapter_key === 'pulseviews') { 6981 $incoming_precision = $origin_key === 'daily_aggregates' ? 'calendar_day' : 'seconds'; 6982 } elseif ($record_type === 'event' && $incoming_start !== '' && $incoming_start === $incoming_end) { 6983 $incoming_precision = 'source_event_timestamp_unverified'; 6984 } 6985 $candidate_precision = (isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'aggregate') 6986 ? 'aggregate_period' 6987 : ((isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'event' && $candidate_start !== '' && $candidate_start === $candidate_end) 6988 ? 'stored_event_timestamp_unverified' 6989 : 'unknown'); 6990 $incoming_granularity = $this->classify_reconciliation_temporal_granularity( 6991 (string) $record_type, 6992 $incoming_start, 6993 $incoming_end, 6994 $incoming_precision 6995 ); 6996 $candidate_granularity = $this->classify_reconciliation_temporal_granularity( 6997 isset($candidate_event['record_type']) ? (string) $candidate_event['record_type'] : '', 6998 $candidate_start, 6999 $candidate_end, 7000 $candidate_precision 7001 ); 7002 $same_temporal_granularity = $incoming_granularity['granularity'] !== 'unknown' 7003 && $incoming_granularity['granularity'] === $candidate_granularity['granularity']; 7004 $temporal_granularity_relation = $same_temporal_granularity 7005 ? 'same_granularity_temporal_evidence_only' 7006 : (($incoming_granularity['granularity'] !== 'unknown' && $candidate_granularity['granularity'] !== 'unknown') 7007 ? 'different_temporal_granularity' 7008 : 'insufficient_temporal_granularity'); 7009 7010 $candidate_rank = count($adapter_overlap_evidence) + 1; 7011 $adapter_overlap_evidence[] = [ 7012 'candidate_event_id' => (int) $candidate_event['id'], 7013 'candidate_rank' => $candidate_rank, 7014 'is_primary_candidate' => $candidate_rank === 1 7015 && (int) $candidate_event['id'] === (int) $possible_overlap_event['id'], 7016 'discovered_by' => isset($candidate_event['_discovered_by']) ? $candidate_event['_discovered_by'] : [], 7017 'adapter_key' => $adapter_key, 7018 'comparison' => [ 7019 'identity_assessment' => [ 7020 'candidate_discovery_signals' => isset($candidate_event['_discovered_by']) 7021 ? array_values((array) $candidate_event['_discovered_by']) 7022 : [], 7023 'same_explicit_fingerprint_signal' => in_array( 7024 'fingerprint', 7025 (array) ($candidate_event['_discovered_by'] ?? []), 7026 true 7027 ) && $source_fingerprint !== '', 7028 'classification' => ( 7029 in_array('fingerprint', (array) ($candidate_event['_discovered_by'] ?? []), true) 7030 && $source_fingerprint !== '' 7031 ) ? 'shared_fingerprint_candidate_requires_contract_and_dimension_validation' : 'temporal_or_other_candidate_without_shared_identity', 7032 'identity_proven' => false, 7033 'automatic_suppression_authorized' => false, 7034 'reason' => 'Candidate discovery by a matching fingerprint is a review signal only. Identity is confirmed only by the separate exact-match path, which also requires event type and all normalized dimensions to agree.', 7035 ], 7036 'incoming' => [ 7037 'source_id' => (int) $source_id, 7038 'origin_key' => (string) $origin_key, 7039 'product_id' => (int) $product_id, 7040 'record_type' => (string) $record_type, 7041 'observed_views' => (int) $views, 7042 'period_start' => $incoming_start, 7043 'period_end' => $incoming_end, 7044 'temporal_basis' => $incoming_temporal_basis, 7045 ], 7046 'candidate' => [ 7047 'source_id' => isset($candidate_event['source_id']) ? (int) $candidate_event['source_id'] : 0, 7048 'source_key' => isset($candidate_event['candidate_source_key']) ? (string) $candidate_event['candidate_source_key'] : null, 7049 'adapter_key' => isset($candidate_event['candidate_adapter_key']) ? (string) $candidate_event['candidate_adapter_key'] : null, 7050 'product_id' => isset($candidate_event['product_id']) ? (int) $candidate_event['product_id'] : 0, 7051 'record_type' => isset($candidate_event['record_type']) ? (string) $candidate_event['record_type'] : '', 7052 'observed_views' => isset($candidate_event['observed_views']) ? (int) $candidate_event['observed_views'] : 0, 7053 'applied_views' => isset($candidate_event['applied_views']) ? (int) $candidate_event['applied_views'] : 0, 7054 'period_start' => $candidate_start, 7055 'period_end' => $candidate_end, 7056 'temporal_basis' => $candidate_temporal_basis, 7057 ], 7058 'temporal_analysis' => [ 7059 'incoming_precision' => $incoming_precision, 7060 'candidate_precision' => $candidate_precision, 7061 'relation' => $temporal_relation, 7062 'incoming_basis' => $incoming_temporal_basis, 7063 'candidate_basis' => $candidate_temporal_basis, 7064 'absolute_time_comparison_performed' => false, 7065 'incoming_granularity' => $incoming_granularity, 7066 'candidate_granularity' => $candidate_granularity, 7067 'granularity_relation' => $temporal_granularity_relation, 7068 'same_granularity_is_identity_proof' => false, 7069 'same_granularity_establishes_timezone_compatibility' => false, 7070 'same_period_bounds' => $same_start && $same_end, 7071 'same_instant_bounds' => false, 7072 'same_event_datetime_string_representation' => $same_start && $same_end 7073 && $record_type === 'event' 7074 && isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'event', 7075 'same_instant_is_proven' => false, 7076 'interpretation' => 'Temporal similarity is evidence of possible overlap only; it does not establish event identity.', 7077 ], 7078 'signals' => [ 7079 'same_product' => isset($candidate_event['product_id']) && (int) $candidate_event['product_id'] === (int) $product_id, 7080 'same_record_type' => isset($candidate_event['record_type']) && (string) $candidate_event['record_type'] === (string) $record_type, 7081 'same_period_start' => $candidate_start !== '' && $candidate_start === $incoming_start, 7082 'same_period_end' => $candidate_end !== '' && $candidate_end === $incoming_end, 7083 'same_observed_view_count' => isset($candidate_event['observed_views']) && (int) $candidate_event['observed_views'] === (int) $views, 7084 'same_explicit_fingerprint_candidate_signal' => in_array('fingerprint', (array) ($candidate_event['_discovered_by'] ?? []), true) && $source_fingerprint !== '', 7085 'periods_intersect' => null, 7086 'same_period_string_representation' => $same_start && $same_end, 7087 'period_string_equality_is_temporal_proof' => false, 7088 ], 7089 ], 7090 'evidence_contract_version' => '1.7.0', 7091 'evidence' => $adapter_evidence, 7092 ]; 7093 } 7094 } 7095 7096 if ($adapter_evidence_omitted > 0 && !empty($adapter_overlap_evidence)) { 7097 $adapter_overlap_evidence[0]['evidence_budget_summary'] = [ 7098 'total_limit_bytes' => $adapter_evidence_total_limit, 7099 'candidate_limit_bytes' => $adapter_evidence_candidate_limit, 7100 'omitted_candidates' => $adapter_evidence_omitted, 7101 'stored_evidence_bytes' => $adapter_evidence_bytes, 7102 ]; 7103 } 7104 7105 $applied_views = $matched_event ? 0 : $views; 7106 $inserted = statpl_db_insert($this->view_source_events_table, [ 7107 'source_id' => $source_id, 7108 'source_record_key' => $record_key, 7109 'source_record_hash' => $record_hash, 7110 'product_id' => $product_id, 7111 'record_type' => $record_type, 7112 'period_start' => $period_start, 7113 'period_end' => $period_end, 7114 'observed_views' => $views, 7115 'applied_views' => $applied_views, 7116 'source_fingerprint' => $has_exact_event_fingerprint ? $source_fingerprint : null, 7117 'status' => $matched_event ? 'reconciled' : ($possible_overlap_event ? 'possible_overlap' : 'applied'), 7118 'created_at' => $now, 7119 'updated_at' => $now, 7120 ]); 7121 7122 if ($inserted === 1) { 7123 $new_event_id = (int) $wpdb->insert_id; 7124 // Build the shared audit context for both exact matches and possible 7125 // overlaps. The possible-overlap persistence path below also stores 7126 // these values, so they must never depend on the exact-match branch. 7127 $cross_source_assessment = null; 7128 $partial_overlap_profiles = null; 7129 $product_source_synthesis = null; 7130 $product_reconciliation_summary = null; 7131 if ($matched_event || $possible_overlap_event) { 7132 $audit_candidates = !empty($possible_overlap_events) 7133 ? $possible_overlap_events 7134 : ($matched_event ? [$matched_event] : [$possible_overlap_event]); 7135 $cross_source_assessment = $this->build_generic_cross_source_overlap_assessment( 7136 $product_id, 7137 $source_id, 7138 $adapter_key, 7139 $record_type, 7140 $period_start, 7141 $period_end, 7142 $audit_candidates, 7143 $possible_overlap_candidates_truncated, 7144 $views 7145 ); 7146 $partial_overlap_profiles = $this->build_generic_partial_overlap_profiles( 7147 $product_id, 7148 $source_id, 7149 $period_start, 7150 $period_end 7151 ); 7152 $product_source_synthesis = $this->build_generic_product_source_synthesis($product_id); 7153 $product_reconciliation_summary = $this->build_generic_product_reconciliation_summary( 7154 $product_id, 7155 $cross_source_assessment, 7156 $partial_overlap_profiles, 7157 $product_source_synthesis, 7158 $matched_event, 7159 [ 7160 'candidates' => !empty($possible_overlap_events) 7161 ? $possible_overlap_events 7162 : ($matched_event ? [$matched_event] : ($possible_overlap_event ? [$possible_overlap_event] : [])), 7163 'truncated' => $possible_overlap_candidates_truncated, 7164 'observed_count' => $possible_overlap_candidate_count_observed, 7165 ] 7166 ); 7167 } 7168 if ($matched_event) { 7169 $adapter_discovery_evidence_consistency = $this->build_generic_adapter_discovery_evidence_consistency( 7170 $candidate_conflict_assessment, 7171 $adapter_overlap_evidence 7172 ); 7173 7174 $evidence = wp_json_encode([ 7175 'match_type' => 'exact_shared_fingerprint', 7176 'source_id' => $source_id, 7177 'matched_source_id' => (int) $matched_event['source_id'], 7178 'product_id' => $product_id, 7179 'record_type' => $record_type, 7180 'observed_views' => $views, 7181 'period_start' => $period_start, 7182 'period_end' => $period_end, 7183 'source_fingerprint' => $source_fingerprint, 7184 'cross_source_assessment' => $cross_source_assessment, 7185 'partial_overlap_profiles' => $partial_overlap_profiles, 7186 'product_source_synthesis' => $product_source_synthesis, 7187 'product_reconciliation_summary' => $product_reconciliation_summary, 7188 'candidate_set_assessment' => $this->build_generic_candidate_set_assessment( 7189 !empty($possible_overlap_events) ? $possible_overlap_events : [$matched_event], 7190 $possible_overlap_candidates_truncated, 7191 $possible_overlap_candidate_count_observed 7192 ), 7193 ]); 7194 $reconciliation_inserted = statpl_db_insert($this->view_source_reconciliation_table, [ 7195 'event_id' => $new_event_id, 7196 'status' => 'matched_exact', 7197 'reason_code' => 'shared_fingerprint_exact_match', 7198 'reason' => 'A different source supplied the same explicit fingerprint and identical normalized event dimensions. This contribution was retained for audit but not added again.', 7199 'matched_event_id' => (int) $matched_event['id'], 7200 'evidence' => $evidence, 7201 'algorithm_version' => '1.0.0', 7202 'created_at' => $now, 7203 'updated_at' => $now, 7204 ]); 7205 $history_inserted = $reconciliation_inserted === 1 ? statpl_db_insert($this->view_source_reconciliation_history_table, [ 7206 'event_id' => $new_event_id, 7207 'previous_status' => null, 7208 'new_status' => 'matched_exact', 7209 'reason_code' => 'shared_fingerprint_exact_match', 7210 'reason' => 'Exact cross-source match using an adapter-provided shared fingerprint and identical normalized dimensions.', 7211 'matched_event_id' => (int) $matched_event['id'], 7212 'evidence' => $evidence, 7213 'algorithm_version' => '1.0.0', 7214 'created_at' => $now, 7215 ]) : false; 7216 if ($reconciliation_inserted !== 1 || $history_inserted !== 1) { 7217 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7218 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7219 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7220 } 7221 $failed++; 7222 $failure = $this->record_generic_historical_view_failure( 7223 $source_id, 7224 $origin_key, 7225 $record_key, 7226 $product_id, 7227 'Could not persist cross-source reconciliation evidence: ' . $wpdb->last_error, 7228 $batch_start_checkpoint, 7229 3 7230 , $origin_lock); 7231 if (empty($failure['persisted'])) { 7232 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7233 $checkpoint_persistence_failed = true; 7234 break; 7235 } 7236 if (!$failure['blocked']) { 7237 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7238 } 7239 continue; 7240 } 7241 } 7242 if ($possible_overlap_event) { 7243 $adapter_dimension_evidence_consistency = $this->build_generic_adapter_dimension_evidence_consistency( 7244 $candidate_conflict_assessment, 7245 $adapter_overlap_evidence 7246 ); 7247 // Validate that the primary reference, ordered candidate set and 7248 // adapter evidence describe the same candidate identities before 7249 // persisting the reconciliation record. 7250 $primary_candidate_id = (int) $possible_overlap_event['id']; 7251 $candidate_ids = array_values(array_unique(array_map( 7252 static function ($candidate) { 7253 return isset($candidate['id']) ? (int) $candidate['id'] : 0; 7254 }, 7255 !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event] 7256 ))); 7257 $candidate_ids = array_values(array_filter($candidate_ids, static function ($id) { 7258 return $id > 0; 7259 })); 7260 $candidate_list_primary_consistent = in_array($primary_candidate_id, $candidate_ids, true) 7261 && !empty($candidate_ids) 7262 && $candidate_ids[0] === $primary_candidate_id; 7263 7264 $adapter_candidate_ids = array_values(array_map( 7265 static function ($entry) { 7266 return isset($entry['candidate_event_id']) ? (int) $entry['candidate_event_id'] : 0; 7267 }, 7268 $adapter_overlap_evidence 7269 )); 7270 $adapter_evidence_primary_consistent = empty($adapter_overlap_evidence) 7271 || ( 7272 isset($adapter_overlap_evidence[0]['is_primary_candidate']) 7273 && $adapter_overlap_evidence[0]['is_primary_candidate'] === true 7274 && $adapter_candidate_ids[0] === $primary_candidate_id 7275 ); 7276 $adapter_evidence_order_consistent = true; 7277 foreach ($adapter_overlap_evidence as $index => $entry) { 7278 if ( 7279 !isset($entry['candidate_rank'], $entry['candidate_event_id']) 7280 || (int) $entry['candidate_rank'] !== $index + 1 7281 || (int) $entry['candidate_event_id'] !== (int) ($candidate_ids[$index] ?? 0) 7282 ) { 7283 $adapter_evidence_order_consistent = false; 7284 break; 7285 } 7286 } 7287 7288 $evidence = wp_json_encode([ 7289 'match_type' => $possible_overlap_reason_code, 7290 'source_id' => $source_id, 7291 'matched_source_id' => (int) $possible_overlap_event['source_id'], 7292 'matched_event_id' => (int) $possible_overlap_event['id'], 7293 'current' => [ 7294 'product_id' => $product_id, 7295 'record_type' => $record_type, 7296 'observed_views' => $views, 7297 'period_start' => $period_start, 7298 'period_end' => $period_end, 7299 ], 7300 'candidates_truncated' => $possible_overlap_candidates_truncated, 7301 'candidate_limit' => 25, 7302 'candidate_count_observed' => $possible_overlap_candidate_count_observed, 7303 'candidate_count_is_lower_bound' => $possible_overlap_candidate_count_is_lower_bound, 7304 'candidate_set_assessment' => $this->build_generic_candidate_set_assessment( 7305 !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event], 7306 $possible_overlap_candidates_truncated, 7307 $possible_overlap_candidate_count_observed 7308 ), 7309 'candidate_conflict_assessment' => $candidate_conflict_assessment = $this->build_generic_candidate_conflict_assessment( 7310 [ 7311 'product_id' => $product_id, 7312 'record_type' => $record_type, 7313 'observed_views' => $views, 7314 'period_start' => $period_start, 7315 'period_end' => $period_end, 7316 ], 7317 !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event] 7318 ), 7319 'candidate_evidence_decision' => $candidate_evidence_decision = $this->build_generic_candidate_evidence_decision( 7320 $candidate_conflict_assessment, 7321 $candidate_set_assessment = $this->build_generic_candidate_set_assessment( 7322 !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event], 7323 $possible_overlap_candidates_truncated, 7324 $possible_overlap_candidate_count_observed 7325 ), 7326 $possible_overlap_candidates_truncated 7327 ), 7328 'candidate_audit_consistency' => $this->build_generic_candidate_audit_consistency( 7329 $candidate_set_assessment, 7330 $candidate_conflict_assessment, 7331 $candidate_evidence_decision 7332 ), 7333 'candidate_reference_consistency' => $this->build_generic_candidate_reference_consistency( 7334 $candidate_set_assessment, 7335 $candidate_conflict_assessment 7336 ), 7337 'candidate_discovery_signal_consistency' => $this->build_generic_candidate_discovery_signal_consistency( 7338 $candidate_set_assessment, 7339 $candidate_conflict_assessment 7340 ), 7341 'adapter_discovery_evidence_consistency' => $adapter_discovery_evidence_consistency, 7342 'adapter_dimension_evidence_consistency' => $adapter_dimension_evidence_consistency, 7343 'candidate_consistency' => [ 7344 'primary_in_candidate_list' => $candidate_list_primary_consistent, 7345 'adapter_evidence_primary_consistent' => $adapter_evidence_primary_consistent, 7346 'adapter_evidence_order_consistent' => $adapter_evidence_order_consistent, 7347 'adapter_discovery_evidence_consistent' => !empty($adapter_discovery_evidence_consistency['consistent']), 7348 'adapter_dimension_evidence_consistent' => !empty($adapter_dimension_evidence_consistency['consistent']), 7349 'candidate_count' => count($candidate_ids), 7350 'adapter_evidence_candidate_count' => count($adapter_candidate_ids), 7351 'validation_policy' => 'audit_only_no_automatic_count_suppression', 7352 ], 7353 'candidate_reconciliation_chain' => $this->build_generic_candidate_reconciliation_chain_assessment( 7354 $candidate_set_assessment, 7355 $candidate_conflict_assessment, 7356 $candidate_evidence_decision, 7357 $this->build_generic_candidate_audit_consistency( 7358 $candidate_set_assessment, 7359 $candidate_conflict_assessment, 7360 $candidate_evidence_decision 7361 ), 7362 [ 7363 'primary_in_candidate_list' => $candidate_list_primary_consistent, 7364 'adapter_evidence_primary_consistent' => $adapter_evidence_primary_consistent, 7365 'adapter_evidence_order_consistent' => $adapter_evidence_order_consistent, 7366 'adapter_discovery_evidence_consistent' => !empty($adapter_discovery_evidence_consistency['consistent']), 7367 'adapter_dimension_evidence_consistent' => !empty($adapter_dimension_evidence_consistency['consistent']), 7368 'candidate_count' => count($candidate_ids), 7369 'adapter_evidence_candidate_count' => count($adapter_candidate_ids), 7370 ], 7371 $this->build_generic_candidate_reference_consistency( 7372 $candidate_set_assessment, 7373 $candidate_conflict_assessment 7374 ), 7375 $this->build_generic_candidate_discovery_signal_consistency( 7376 $candidate_set_assessment, 7377 $candidate_conflict_assessment 7378 ) 7379 ), 7380 'primary_candidate' => [ 7381 'event_id' => (int) $possible_overlap_event['id'], 7382 'source_id' => (int) $possible_overlap_event['source_id'], 7383 'source_key' => isset($possible_overlap_event['candidate_source_key']) ? (string) $possible_overlap_event['candidate_source_key'] : null, 7384 'adapter_key' => isset($possible_overlap_event['candidate_adapter_key']) ? (string) $possible_overlap_event['candidate_adapter_key'] : null, 7385 'discovered_by' => isset($possible_overlap_event['_discovered_by']) ? $possible_overlap_event['_discovered_by'] : [], 7386 'selection_policy' => 'highest_discovery_evidence_then_lowest_event_id', 7387 ], 7388 'fingerprint_candidates_observed' => $possible_overlap_fingerprint_candidates_observed, 7389 'temporal_candidates_observed' => $possible_overlap_temporal_candidates_observed, 7390 'candidates' => array_map(static function ($candidate) { 7391 return [ 7392 'event_id' => (int) $candidate['id'], 7393 'discovered_by' => isset($candidate['_discovered_by']) ? $candidate['_discovered_by'] : [], 7394 'source_id' => (int) $candidate['source_id'], 7395 'source_key' => isset($candidate['candidate_source_key']) ? (string) $candidate['candidate_source_key'] : null, 7396 'adapter_key' => isset($candidate['candidate_adapter_key']) ? (string) $candidate['candidate_adapter_key'] : null, 7397 'product_id' => (int) $candidate['product_id'], 7398 'record_type' => (string) $candidate['record_type'], 7399 'observed_views' => (int) $candidate['observed_views'], 7400 'applied_views' => (int) $candidate['applied_views'], 7401 'period_start' => $candidate['period_start'], 7402 'period_end' => $candidate['period_end'], 7403 ]; 7404 }, !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event]), 7405 'cross_source_assessment' => $cross_source_assessment, 7406 'partial_overlap_profiles' => $partial_overlap_profiles, 7407 'product_source_synthesis' => $product_source_synthesis, 7408 'product_reconciliation_summary' => $product_reconciliation_summary, 7409 'source_fingerprint' => $source_fingerprint !== '' ? $source_fingerprint : null, 7410 'adapter_overlap_evidence' => $adapter_overlap_evidence, 7411 'counting_policy' => 'preserve_both_pending_source_specific_evidence', 7412 ]); 7413 $reconciliation_inserted = statpl_db_insert($this->view_source_reconciliation_table, [ 7414 'event_id' => $new_event_id, 7415 'status' => 'possible_overlap', 7416 'reason_code' => $possible_overlap_reason_code, 7417 'reason' => 'A different source contains one or more potentially overlapping records. All matching candidates were retained as evidence; the contribution was preserved and counted because no automatic suppression was justified without stronger source-specific evidence.', 7418 'matched_event_id' => (int) $possible_overlap_event['id'], 7419 'evidence' => $evidence, 7420 'algorithm_version' => '1.6.0', 7421 'created_at' => $now, 7422 'updated_at' => $now, 7423 ]); 7424 $history_inserted = $reconciliation_inserted === 1 ? statpl_db_insert($this->view_source_reconciliation_history_table, [ 7425 'event_id' => $new_event_id, 7426 'previous_status' => null, 7427 'new_status' => 'possible_overlap', 7428 'reason_code' => $possible_overlap_reason_code, 7429 'reason' => 'Potential partial overlap recorded for review. Both source contributions remain counted until source-specific evidence justifies adjustment.', 7430 'matched_event_id' => (int) $possible_overlap_event['id'], 7431 'evidence' => $evidence, 7432 'algorithm_version' => '1.6.0', 7433 'created_at' => $now, 7434 ]) : false; 7435 if ($reconciliation_inserted !== 1 || $history_inserted !== 1) { 7436 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7437 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7438 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7439 } 7440 $failed++; 7441 $failure = $this->record_generic_historical_view_failure( 7442 $source_id, 7443 $origin_key, 7444 $record_key, 7445 $product_id, 7446 'Could not persist possible-overlap reconciliation evidence: ' . $wpdb->last_error, 7447 $batch_start_checkpoint, 7448 3 7449 , $origin_lock); 7450 if (empty($failure['persisted'])) { 7451 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7452 $checkpoint_persistence_failed = true; 7453 break; 7454 } 7455 if (!$failure['blocked']) { 7456 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7457 } 7458 continue; 7459 } 7460 } 7461 if ($applied_views > 0) { 7462 $total_result = statpl_db_query(statpl_db_prepare( 7463 "INSERT INTO {$this->views_table} (product_id, total_views) VALUES (%d, %d) 7464 ON DUPLICATE KEY UPDATE total_views = total_views + VALUES(total_views)", 7465 $product_id, 7466 $applied_views 7467 )); 7468 if ($total_result === false) { 7469 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7470 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7471 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7472 } 7473 $failed++; 7474 $failure = $this->record_generic_historical_view_failure( 7475 $source_id, 7476 $origin_key, 7477 $record_key, 7478 $product_id, 7479 'Could not update product view totals: ' . $wpdb->last_error, 7480 $batch_start_checkpoint, 7481 3 7482 , $origin_lock); 7483 if (empty($failure['persisted'])) { 7484 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7485 $checkpoint_persistence_failed = true; 7486 break; 7487 } 7488 if (!$failure['blocked']) { 7489 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7490 } 7491 continue; 7492 } 7493 } 7494 $origin_session_current = $this->generic_advisory_lock_session_is_current($origin_lock); 7495 $fingerprint_session_current = !is_array($reconciliation_lock_state) 7496 || empty($reconciliation_lock_state['acquired']) 7497 || $this->generic_advisory_lock_session_is_current($reconciliation_lock_state); 7498 if (!$origin_session_current || !$fingerprint_session_current) { 7499 // The transaction belongs to the origin-lock connection. A lost 7500 // advisory lock does not itself mean the DB connection changed. 7501 // Never send ROLLBACK to a transparently reconnected session. 7502 if ($transaction_open && !$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7503 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7504 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7505 } 7506 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7507 return [ 7508 'status' => 'transaction_lock_session_lost', 7509 'processed' => $processed, 7510 'duplicates' => $duplicates, 7511 'failed' => $failed, 7512 ]; 7513 } 7514 7515 if (statpl_db_query('COMMIT') === false) { 7516 $commit_error = (string) $wpdb->last_error; 7517 7518 // A failed COMMIT acknowledgement is ambiguous when WordPress has 7519 // transparently reconnected: the server may have committed the 7520 // transaction before the client lost its connection. Do not issue 7521 // ledger writes on a replacement session or classify the record 7522 // as failed. Replaying the same record is safe because the source 7523 // event's unique key and transactional totals update are idempotent. 7524 $origin_session_after_commit = $this->generic_advisory_lock_session_is_current($origin_lock); 7525 $fingerprint_session_after_commit = !is_array($reconciliation_lock_state) 7526 || empty($reconciliation_lock_state['acquired']) 7527 || $this->generic_advisory_lock_session_is_current($reconciliation_lock_state); 7528 if (!$origin_session_after_commit || !$fingerprint_session_after_commit) { 7529 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7530 return [ 7531 'status' => 'commit_outcome_unknown', 7532 'processed' => $processed, 7533 'duplicates' => $duplicates, 7534 'failed' => $failed, 7535 ]; 7536 } 7537 7538 // The original session is still alive, so the failed COMMIT can 7539 // be rolled back and recorded as a normal retryable row failure. 7540 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7541 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7542 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7543 } 7544 $failed++; 7545 $failure = $this->record_generic_historical_view_failure( 7546 $source_id, 7547 $origin_key, 7548 $record_key, 7549 $product_id, 7550 'Could not commit imported source event: ' . $commit_error, 7551 $batch_start_checkpoint, 7552 3 7553 , $origin_lock); 7554 if (empty($failure['persisted'])) { 7555 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7556 $checkpoint_persistence_failed = true; 7557 break; 7558 } 7559 if (!$failure['blocked']) { 7560 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7561 } 7562 continue; 7563 } 7564 $transaction_open = false; 7565 $processed++; 7566 } elseif ($inserted === false && stripos((string) $wpdb->last_error, 'duplicate') !== false) { 7567 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7568 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7569 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7570 } 7571 // A repeated source_record_key is idempotent only when the stored 7572 // normalized payload is identical. A changed payload under the same 7573 // key signals an adapter/source inconsistency and must not be silently 7574 // accepted as an ordinary duplicate. 7575 $existing_event = statpl_db_get_row(statpl_db_prepare( 7576 "SELECT id, product_id, record_type, observed_views, period_start, period_end, source_fingerprint, status 7577 FROM {$this->view_source_events_table} 7578 WHERE source_id = %d AND source_record_hash = %s 7579 LIMIT 1", 7580 $source_id, 7581 $record_hash 7582 ), ARRAY_A); 7583 if (!empty($wpdb->last_error) || !is_array($existing_event)) { 7584 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7585 return ['status' => 'duplicate_verification_failed', 'processed' => $processed, 'duplicates' => $duplicates]; 7586 } 7587 $existing_period_start = $existing_event['period_start'] !== null ? (string) $existing_event['period_start'] : null; 7588 $existing_period_end = $existing_event['period_end'] !== null ? (string) $existing_event['period_end'] : null; 7589 $existing_fingerprint = $existing_event['source_fingerprint'] !== null ? (string) $existing_event['source_fingerprint'] : ''; 7590 $same_existing_payload = (int) $existing_event['product_id'] === $product_id 7591 && (string) $existing_event['record_type'] === $record_type 7592 && (int) $existing_event['observed_views'] === $views 7593 && $existing_period_start === $period_start 7594 && $existing_period_end === $period_end 7595 && $existing_fingerprint === $source_fingerprint; 7596 if ($same_existing_payload) { 7597 // A retry must not rewrite reconciliation evidence or append 7598 // another history row. Verify the already committed audit record 7599 // before accepting the source event as a complete duplicate. 7600 $existing_event_id = isset($existing_event['id']) ? (int) $existing_event['id'] : 0; 7601 $existing_event_status = isset($existing_event['status']) ? (string) $existing_event['status'] : ''; 7602 if (in_array($existing_event_status, ['possible_overlap', 'reconciled'], true)) { 7603 $existing_audit = statpl_db_get_row(statpl_db_prepare( 7604 "SELECT status, reason_code, matched_event_id, evidence, algorithm_version 7605 FROM {$this->view_source_reconciliation_table} 7606 WHERE event_id = %d 7607 LIMIT 1", 7608 $existing_event_id 7609 ), ARRAY_A); 7610 if (!empty($wpdb->last_error) || !is_array($existing_audit)) { 7611 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7612 return [ 7613 'status' => 'duplicate_audit_missing_or_unreadable', 7614 'processed' => $processed, 7615 'duplicates' => $duplicates, 7616 ]; 7617 } 7618 $existing_audit_evidence = !empty($existing_audit['evidence']) 7619 ? json_decode((string) $existing_audit['evidence'], true) 7620 : null; 7621 $audit_matches_event = is_array($existing_audit_evidence) 7622 && isset($existing_audit_evidence['source_id']) 7623 && (int) $existing_audit_evidence['source_id'] === (int) $source_id; 7624 if ($existing_event_status === 'possible_overlap') { 7625 $audit_matches_event = $audit_matches_event 7626 && isset($existing_audit['matched_event_id'], $existing_audit_evidence['primary_candidate']['event_id']) 7627 && (int) $existing_audit['matched_event_id'] === (int) $existing_audit_evidence['primary_candidate']['event_id'] 7628 && (int) $existing_audit_evidence['primary_candidate']['event_id'] > 0; 7629 } elseif ($existing_event_status === 'reconciled') { 7630 $audit_matches_event = $audit_matches_event 7631 && (string) $existing_audit['status'] === 'matched_exact' 7632 && isset($existing_audit['matched_event_id']) 7633 && (int) $existing_audit['matched_event_id'] > 0; 7634 } 7635 if (!$audit_matches_event) { 7636 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7637 return [ 7638 'status' => 'duplicate_audit_integrity_mismatch', 7639 'processed' => $processed, 7640 'duplicates' => $duplicates, 7641 ]; 7642 } 7643 7644 // The current reconciliation row and its latest history 7645 // snapshot must agree. History is verified, never synthesized 7646 // or repaired during a duplicate retry. 7647 $latest_history = statpl_db_get_row(statpl_db_prepare( 7648 "SELECT new_status, reason_code, matched_event_id, evidence, algorithm_version 7649 FROM {$this->view_source_reconciliation_history_table} 7650 WHERE event_id = %d 7651 ORDER BY id DESC 7652 LIMIT 1", 7653 $existing_event_id 7654 ), ARRAY_A); 7655 if (!empty($wpdb->last_error) || !is_array($latest_history)) { 7656 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7657 return [ 7658 'status' => 'duplicate_audit_history_missing_or_unreadable', 7659 'processed' => $processed, 7660 'duplicates' => $duplicates, 7661 ]; 7662 } 7663 7664 $history_matches_audit = (string) $latest_history['new_status'] === (string) $existing_audit['status'] 7665 && (string) $latest_history['reason_code'] === (string) $existing_audit['reason_code'] 7666 && (int) ($latest_history['matched_event_id'] ?? 0) === (int) ($existing_audit['matched_event_id'] ?? 0) 7667 && (string) ($latest_history['evidence'] ?? '') === (string) ($existing_audit['evidence'] ?? '') 7668 && (string) $latest_history['algorithm_version'] === (string) $existing_audit['algorithm_version']; 7669 7670 // Validate the recorded transition chain in ascending history 7671 // order. The first row is treated as the chain anchor because 7672 // older installations may not have retained its predecessor. 7673 $history_chain = statpl_db_get_results(statpl_db_prepare( 7674 "SELECT id, previous_status, new_status 7675 FROM {$this->view_source_reconciliation_history_table} 7676 WHERE event_id = %d 7677 ORDER BY id ASC", 7678 $existing_event_id 7679 ), ARRAY_A); 7680 if (!empty($wpdb->last_error) || !is_array($history_chain) || empty($history_chain)) { 7681 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7682 return [ 7683 'status' => 'duplicate_audit_history_chain_unreadable', 7684 'processed' => $processed, 7685 'duplicates' => $duplicates, 7686 ]; 7687 } 7688 7689 $history_chain_consistent = true; 7690 $previous_new_status = null; 7691 $previous_history_id = 0; 7692 foreach ($history_chain as $history_entry) { 7693 $history_id = isset($history_entry['id']) ? (int) $history_entry['id'] : 0; 7694 $new_status = isset($history_entry['new_status']) ? (string) $history_entry['new_status'] : ''; 7695 $previous_status = isset($history_entry['previous_status']) && $history_entry['previous_status'] !== null 7696 ? (string) $history_entry['previous_status'] 7697 : null; 7698 7699 if ($history_id <= $previous_history_id || $new_status === '') { 7700 $history_chain_consistent = false; 7701 break; 7702 } 7703 if ($previous_new_status !== null && $previous_status !== $previous_new_status) { 7704 $history_chain_consistent = false; 7705 break; 7706 } 7707 $previous_new_status = $new_status; 7708 $previous_history_id = $history_id; 7709 } 7710 7711 if (!$history_chain_consistent) { 7712 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7713 return [ 7714 'status' => 'duplicate_audit_history_transition_chain_mismatch', 7715 'processed' => $processed, 7716 'duplicates' => $duplicates, 7717 ]; 7718 } 7719 7720 if (!$history_matches_audit) { 7721 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7722 return [ 7723 'status' => 'duplicate_audit_history_integrity_mismatch', 7724 'processed' => $processed, 7725 'duplicates' => $duplicates, 7726 ]; 7727 } 7728 } 7729 $duplicates++; 7730 } else { 7731 $failed++; 7732 $failure = $this->record_generic_historical_view_failure( 7733 $source_id, 7734 $origin_key, 7735 $record_key, 7736 $product_id, 7737 'The adapter returned different normalized data for an existing source record key. The original imported event was preserved.', 7738 $batch_start_checkpoint, 7739 3 7740 , $origin_lock); 7741 if (empty($failure['persisted'])) { 7742 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7743 $checkpoint_persistence_failed = true; 7744 break; 7745 } 7746 if (!$failure['blocked']) { 7747 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7748 } 7749 continue; 7750 } 7751 } else { 7752 $error = (string) $wpdb->last_error; 7753 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7754 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7755 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7756 } 7757 $failed++; 7758 $failure = $this->record_generic_historical_view_failure( 7759 $source_id, 7760 $origin_key, 7761 $record_key, 7762 $product_id, 7763 'Could not persist source event: ' . $error, 7764 $batch_start_checkpoint, 7765 3 7766 , $origin_lock); 7767 if (empty($failure['persisted'])) { 7768 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7769 $checkpoint_persistence_failed = true; 7770 break; 7771 } 7772 if (!$failure['blocked']) { 7773 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7774 } 7775 continue; 7776 } 7777 } finally { 7778 if ($transaction_open) { 7779 // A changed or unverifiable session means the original transaction 7780 // outcome cannot be inspected here. Do not issue transaction control 7781 // on a replacement connection; ensure the record is retried instead. 7782 if (!$this->generic_db_session_matches_lock($origin_lock)) { 7783 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7784 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7785 } 7786 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7787 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7788 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7789 } 7790 } 7791 if (is_array($reconciliation_lock_state) && !empty($reconciliation_lock_state['acquired'])) { 7792 $this->release_generic_advisory_lock($reconciliation_lock_state, 'cross_source_fingerprint'); 7793 } 7794 } 7795 7796 $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, $record_key, $origin_lock); 7797 if ($failure_cleared && is_array($record) && !empty($record['source_record_key']) && (string) $record['source_record_key'] !== $record_key) { 7798 $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, $record['source_record_key'], $origin_lock, null); 7799 } elseif ($failure_cleared && (!is_array($record) || empty($record['source_record_key']))) { 7800 $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, wp_json_encode($record), $origin_lock, null); 7801 } 7802 if (!$failure_cleared) { 7803 // The event may already be committed. Idempotency makes replay 7804 // safe, but the cursor must not move beyond this record until 7805 // its stale failure-ledger entries have been cleared. 7806 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7807 $checkpoint_persistence_failed = true; 7808 break; 7809 } 7810 $next_checkpoint['cursor'] = $cursor; 7811 $next_checkpoint['processed'] = isset($next_checkpoint['processed']) ? (int) $next_checkpoint['processed'] + 1 : 1; 7812 unset($normalization_error); 7813 } 7814 7815 if ($checkpoint_persistence_failed) { 7816 // Do not persist the adapter's batch-level next_checkpoint when a 7817 // row could not be durably represented in the failure ledger. The 7818 // next main-import attempt must revisit this batch and this row. 7819 return [ 7820 'status' => 'failure_ledger_persist_failed', 7821 'processed' => $processed, 7822 'duplicates' => $duplicates, 7823 'failed' => $failed, 7824 'skipped' => $skipped, 7825 ]; 7826 } 7827 7828 // A connection can be transparently re-established after a mid-batch DB outage. 7829 // Never persist checkpoint/status progress unless this request still owns the 7830 // original origin lock session that serialized the batch. 7831 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 7832 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7833 return [ 7834 'status' => 'checkpoint_blocked_origin_lock_session_lost', 7835 'processed' => $processed, 7836 'duplicates' => $duplicates, 7837 'failed' => $failed, 7838 'skipped' => $skipped, 7839 ]; 7840 } 7841 7842 // Individual bad records have been durably placed in the failure ledger. 7843 // Continue advancing the main batch checkpoint; a separate replay worker 7844 // revisits the saved batch checkpoint without blocking later records. 7845 if ($retry_checkpoint_override !== null) { 7846 return [ 7847 'status' => $failed ? 'retry_replay_partial' : 'retry_replay_completed', 7848 'processed' => $processed, 7849 'duplicates' => $duplicates, 7850 'failed' => $failed, 7851 ]; 7852 } 7853 7854 if (isset($batch['next_checkpoint'])) { 7855 $next_checkpoint['cursor'] = $batch['next_checkpoint']; 7856 } 7857 $has_more = !empty($batch['has_more']); 7858 $failure_counts = statpl_db_get_row(statpl_db_prepare( 7859 "SELECT SUM(CASE WHEN status = 'retry' THEN 1 ELSE 0 END) AS pending, 7860 SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked 7861 FROM {$this->view_source_failures_table} WHERE source_id = %d AND origin_key = %s", 7862 $source_id, 7863 $origin_key 7864 ), ARRAY_A); 7865 if (!empty($wpdb->last_error) || !is_array($failure_counts)) { 7866 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7867 return ['status' => 'failure_ledger_read_failed', 'processed' => $processed, 'duplicates' => $duplicates]; 7868 } 7869 $pending_failures = (int) $failure_counts['pending']; 7870 $blocked_failures = (int) $failure_counts['blocked']; 7871 $origin_status = $has_more ? 'importing' : (($pending_failures > 0 || $blocked_failures > 0) ? ($blocked_failures > 0 ? 'needs_attention' : 'retry_wait') : 'completed'); 7872 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 7873 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7874 return ['status' => 'checkpoint_blocked_origin_lock_session_lost', 'processed' => $processed, 'duplicates' => $duplicates]; 7875 } 7876 7877 $updates = [ 7878 'checkpoint_data' => $next_checkpoint, 7879 'last_error' => ($pending_failures > 0 || $blocked_failures > 0) ? 'One or more historical records remain unresolved; the main import continues independently.' : null, 7880 'status' => $origin_status, 7881 ]; 7882 if (!$has_more && $pending_failures === 0 && $blocked_failures === 0) { 7883 $updates['completed_at'] = current_time('mysql'); 7884 } else { 7885 $updates['completed_at'] = null; 7886 } 7887 $saved = $this->update_view_source_origin_state($source_id, $origin_key, $updates, $origin_lock); 7888 if (empty($saved['status']) || $saved['status'] !== 'updated') { 7889 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7890 return ['status' => 'checkpoint_write_failed', 'processed' => $processed, 'duplicates' => $duplicates]; 7891 } 7892 7893 // A successful statpl_db_query() confirms that MySQL accepted the UPDATE, 7894 // but a worker must not report durable progress until the exact state is 7895 // readable again. This also handles ambiguous acknowledgements and 7896 // storage-layer anomalies without advancing the scheduler to the next 7897 // batch on an unverified checkpoint. 7898 $persisted_state = $this->get_view_source_origin_state($source_id, $origin_key); 7899 $checkpoint_is_persisted = isset($persisted_state['status']) 7900 && $persisted_state['status'] === 'found' 7901 && isset($persisted_state['checkpoint']) 7902 && wp_json_encode($persisted_state['checkpoint']) === wp_json_encode($next_checkpoint) 7903 && isset($persisted_state['origin_status']) 7904 && $persisted_state['origin_status'] === $origin_status; 7905 $completion_is_persisted = array_key_exists('completed_at', $updates) 7906 && ((array_key_exists('completed_at', $persisted_state) && $updates['completed_at'] === null && $persisted_state['completed_at'] === null) 7907 || (array_key_exists('completed_at', $persisted_state) && $updates['completed_at'] !== null && $persisted_state['completed_at'] === $updates['completed_at'])); 7908 if (!$checkpoint_is_persisted || !$completion_is_persisted || !$this->generic_advisory_lock_session_is_current($origin_lock)) { 7909 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7910 return [ 7911 'status' => 'checkpoint_write_unverified', 7912 'processed' => $processed, 7913 'duplicates' => $duplicates, 7914 ]; 7915 } 7916 7917 if ($has_more) { 7918 $this->schedule_generic_historical_view_import_batch(5, $source_id, $origin_key); 7919 } 7920 if ($pending_failures > 0) { 7921 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 7922 } 7923 return [ 7924 'status' => $has_more ? 'batch_completed' : ($pending_failures > 0 ? 'completed_with_pending_retries' : ($blocked_failures > 0 ? 'completed_with_unresolved_records' : 'completed')), 7925 'processed' => $processed, 7926 'duplicates' => $duplicates, 7927 'checkpoint' => $next_checkpoint, 7928 ]; 7929 } 7930 7931 private function register_view_source($source_key, $display_name, $adapter_version, $granularity, $status = 'detected', $adapter_key = null) { 7932 global $wpdb; 7933 7934 $source_key = (string) $source_key; 7935 $display_name = sanitize_text_field($display_name); 7936 $adapter_version = sanitize_text_field($adapter_version); 7937 $granularity = (string) $granularity; 7938 $status = (string) $status; 7939 $adapter_key = $adapter_key === null ? null : sanitize_key((string) $adapter_key); 7940 7941 if ( 7942 ($adapter_key !== null && ($adapter_key === '' || strlen($adapter_key) > 100)) 7943 || 7944 !preg_match('/^[a-z0-9_-]{1,100}$/D', $source_key) 7945 || $display_name === '' 7946 || $adapter_version === '' 7947 || !preg_match('/^[a-z0-9_-]{1,20}$/D', $granularity) 7948 || !preg_match('/^[a-z0-9_-]{1,20}$/D', $status) 7949 || strlen($display_name) > 191 7950 || strlen($adapter_version) > 32 7951 ) { 7952 return [ 7953 'status' => 'invalid_input', 7954 'source_id' => 0, 7955 ]; 7956 } 7957 7958 $now = current_time('mysql'); 7959 7960 $result = statpl_db_query(statpl_db_prepare( 7961 "INSERT INTO {$this->view_sources_table} 7962 (source_key, adapter_key, display_name, adapter_version, granularity, status, created_at, updated_at) 7963 VALUES (%s, %s, %s, %s, %s, %s, %s, %s) 7964 ON DUPLICATE KEY UPDATE 7965 display_name = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(display_name), display_name), 7966 adapter_version = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(adapter_version), adapter_version), 7967 granularity = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(granularity), granularity), 7968 updated_at = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(updated_at), updated_at)", 7969 $source_key, 7970 $adapter_key, 7971 $display_name, 7972 $adapter_version, 7973 $granularity, 7974 $status, 7975 $now, 7976 $now 7977 )); 7978 7979 if ($result === false || $wpdb->last_error !== '') { 7980 return [ 7981 'status' => 'database_error', 7982 'source_id' => 0, 7983 'error' => $wpdb->last_error, 7984 ]; 7985 } 7986 7987 $source_id = statpl_db_get_var( 7988 statpl_db_prepare( 7989 "SELECT id FROM {$this->view_sources_table} WHERE source_key = %s", 7990 $source_key 7991 ) 7992 ); 7993 7994 if (!empty($wpdb->last_error) || !$source_id) { 7995 return [ 7996 'status' => 'database_error', 7997 'source_id' => 0, 7998 'error' => $wpdb->last_error !== '' 7999 ? $wpdb->last_error 8000 : 'Registered source ID could not be retrieved.', 8001 ]; 8002 } 8003 8004 if ($adapter_key !== null) { 8005 $ownership_result = statpl_db_query( 8006 statpl_db_prepare( 8007 "UPDATE {$this->view_sources_table} 8008 SET adapter_key = %s 8009 WHERE id = %d AND (adapter_key IS NULL OR adapter_key = %s)", 8010 $adapter_key, 8011 (int) $source_id, 8012 $adapter_key 8013 ) 8014 ); 8015 if ($ownership_result === false || $wpdb->last_error !== '') { 8016 return [ 8017 'status' => 'database_error', 8018 'source_id' => 0, 8019 'error' => $wpdb->last_error, 8020 ]; 8021 } 8022 8023 $stored_adapter_key = statpl_db_get_var(statpl_db_prepare( 8024 "SELECT adapter_key FROM {$this->view_sources_table} WHERE id = %d", 8025 (int) $source_id 8026 )); 8027 if ($wpdb->last_error !== '') { 8028 return [ 8029 'status' => 'database_error', 8030 'source_id' => 0, 8031 'error' => $wpdb->last_error, 8032 ]; 8033 } 8034 if ((string) $stored_adapter_key !== $adapter_key) { 8035 return [ 8036 'status' => 'adapter_mismatch', 8037 'source_id' => 0, 8038 ]; 8039 } 8040 } 8041 8042 return [ 8043 'status' => 'registered', 8044 'source_id' => (int) $source_id, 8045 ]; 8046 } 8047 8048 private function register_view_source_origin($source_id, $origin_key, $adapter_key, $capabilities = [], $detection_status = 'unknown') { 8049 global $wpdb; 8050 8051 $source_id = absint($source_id); 8052 $origin_key = (string) $origin_key; 8053 $adapter_key = (string) $adapter_key; 8054 $detection_status = (string) $detection_status; 8055 8056 if ( 8057 !$source_id 8058 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 8059 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $adapter_key) 8060 || !in_array($detection_status, ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true) 8061 || !is_array($capabilities) 8062 ) { 8063 return [ 8064 'status' => 'invalid_input', 8065 'source_id' => $source_id, 8066 'origin_key' => $origin_key, 8067 ]; 8068 } 8069 8070 $source_exists = statpl_db_get_var( 8071 statpl_db_prepare( 8072 "SELECT id FROM {$this->view_sources_table} WHERE id = %d", 8073 $source_id 8074 ) 8075 ); 8076 8077 if (!empty($wpdb->last_error)) { 8078 return [ 8079 'status' => 'database_error', 8080 'source_id' => $source_id, 8081 'origin_key' => $origin_key, 8082 'error' => $wpdb->last_error, 8083 ]; 8084 } 8085 8086 if (!$source_exists) { 8087 return [ 8088 'status' => 'source_not_found', 8089 'source_id' => $source_id, 8090 'origin_key' => $origin_key, 8091 ]; 8092 } 8093 8094 // An adapter identity is part of the immutable meaning of an origin's 8095 // snapshot and checkpoint. Never silently replace it after initialization. 8096 $existing_origin = statpl_db_get_row( 8097 statpl_db_prepare( 8098 "SELECT adapter_key, snapshot_data, checkpoint_data, status 8099 FROM {$this->view_source_origins_table} 8100 WHERE source_id = %d AND origin_key = %s", 8101 $source_id, 8102 $origin_key 8103 ), 8104 ARRAY_A 8105 ); 8106 8107 if (!empty($wpdb->last_error)) { 8108 return [ 8109 'status' => 'database_error', 8110 'source_id' => $source_id, 8111 'origin_key' => $origin_key, 8112 'error' => $wpdb->last_error, 8113 ]; 8114 } 8115 8116 $existing_source_events = 0; 8117 $existing_origin_failures = 0; 8118 if (is_array($existing_origin) && isset($existing_origin['adapter_key']) && (string) $existing_origin['adapter_key'] !== $adapter_key) { 8119 $existing_source_events = (int) statpl_db_get_var(statpl_db_prepare( 8120 "SELECT COUNT(*) FROM {$this->view_source_events_table} WHERE source_id = %d", 8121 $source_id 8122 )); 8123 if (!empty($wpdb->last_error)) { 8124 return [ 8125 'status' => 'database_error', 8126 'source_id' => $source_id, 8127 'origin_key' => $origin_key, 8128 'error' => $wpdb->last_error, 8129 ]; 8130 } 8131 $existing_origin_failures = (int) statpl_db_get_var(statpl_db_prepare( 8132 "SELECT COUNT(*) FROM {$this->view_source_failures_table} WHERE source_id = %d AND origin_key = %s", 8133 $source_id, 8134 $origin_key 8135 )); 8136 if (!empty($wpdb->last_error)) { 8137 return [ 8138 'status' => 'database_error', 8139 'source_id' => $source_id, 8140 'origin_key' => $origin_key, 8141 'error' => $wpdb->last_error, 8142 ]; 8143 } 8144 } 8145 8146 if ( 8147 is_array($existing_origin) 8148 && isset($existing_origin['adapter_key']) 8149 && (string) $existing_origin['adapter_key'] !== $adapter_key 8150 && ( 8151 !empty($existing_origin['snapshot_data']) 8152 || !empty($existing_origin['checkpoint_data']) 8153 || !in_array($existing_origin['status'], ['pending', 'unavailable'], true) 8154 || $existing_source_events > 0 8155 || $existing_origin_failures > 0 8156 ) 8157 ) { 8158 $message = sprintf( 8159 'Historical adapter identity changed from "%1$s" to "%2$s" after this origin was initialized. Existing historical data and checkpoints were preserved; the replacement adapter was not allowed to reinterpret them.', 8160 sanitize_key((string) $existing_origin['adapter_key']), 8161 sanitize_key($adapter_key) 8162 ); 8163 // A replacement adapter cannot safely inherit the old adapter's 8164 // snapshot or checkpoint. Keep the origin automatically unavailable 8165 // (rather than requiring administrator intervention), while normal 8166 // discovery continues to look for the original adapter. If that 8167 // original identity returns, the ordinary registration path can resume 8168 // from the preserved checkpoint without reinterpreting historical data. 8169 statpl_db_update( 8170 $this->view_source_origins_table, 8171 [ 8172 'status' => 'unavailable', 8173 'last_error' => $message, 8174 'updated_at' => current_time('mysql'), 8175 ], 8176 [ 8177 'source_id' => $source_id, 8178 'origin_key' => $origin_key, 8179 ], 8180 ['%s', '%s', '%s'], 8181 ['%d', '%s'] 8182 ); 8183 if (!empty($wpdb->last_error)) { 8184 return [ 8185 'status' => 'database_error', 8186 'source_id' => $source_id, 8187 'origin_key' => $origin_key, 8188 'error' => $wpdb->last_error, 8189 ]; 8190 } 8191 return [ 8192 'status' => 'adapter_mismatch', 8193 'source_id' => $source_id, 8194 'origin_key' => $origin_key, 8195 ]; 8196 } 8197 8198 $capabilities_json = wp_json_encode($capabilities); 8199 8200 if (!is_string($capabilities_json)) { 8201 return [ 8202 'status' => 'invalid_capabilities', 8203 'source_id' => $source_id, 8204 'origin_key' => $origin_key, 8205 ]; 8206 } 8207 8208 $now = current_time('mysql'); 8209 8210 $result = statpl_db_query( 8211 statpl_db_prepare( 8212 "INSERT INTO {$this->view_source_origins_table} 8213 (source_id, origin_key, adapter_key, capabilities, detection_status, status, updated_at) 8214 VALUES (%d, %s, %s, %s, %s, %s, %s) 8215 ON DUPLICATE KEY UPDATE 8216 adapter_key = VALUES(adapter_key), 8217 capabilities = VALUES(capabilities), 8218 detection_status = %s, 8219 status = CASE 8220 WHEN VALUES(detection_status) <> 'available' AND status NOT IN ('completed', 'needs_attention') THEN 'unavailable' 8221 WHEN VALUES(detection_status) = 'available' AND status = 'unavailable' THEN 'pending' 8222 ELSE status 8223 END, 8224 updated_at = %s", 8225 $source_id, 8226 $origin_key, 8227 $adapter_key, 8228 $capabilities_json, 8229 $detection_status, 8230 $detection_status === 'available' ? 'pending' : 'unavailable', 8231 $now, 8232 $detection_status, 8233 $now 8234 ) 8235 ); 8236 8237 if ($result === false || $wpdb->last_error !== '') { 8238 return [ 8239 'status' => 'database_error', 8240 'source_id' => $source_id, 8241 'origin_key' => $origin_key, 8242 'error' => $wpdb->last_error, 8243 ]; 8244 } 8245 8246 return [ 8247 'status' => 'registered', 8248 'source_id' => $source_id, 8249 'origin_key' => $origin_key, 8250 ]; 8251 } 8252 8253 private function get_view_source_origin_state($source_id, $origin_key) { 8254 global $wpdb; 8255 8256 $source_id = absint($source_id); 8257 $origin_key = is_string($origin_key) ? trim($origin_key) : ''; 8258 8259 if ( 8260 !$source_id 8261 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 8262 ) { 8263 return [ 8264 'status' => 'invalid_input', 8265 ]; 8266 } 8267 8268 $row = statpl_db_get_row( 8269 statpl_db_prepare( 8270 "SELECT 8271 source_id, 8272 origin_key, 8273 adapter_key, 8274 capabilities, 8275 snapshot_data, 8276 checkpoint_data, 8277 detection_status, 8278 status, 8279 last_error, 8280 started_at, 8281 completed_at, 8282 updated_at 8283 FROM {$this->view_source_origins_table} 8284 WHERE source_id = %d 8285 AND origin_key = %s", 8286 $source_id, 8287 $origin_key 8288 ), 8289 ARRAY_A 8290 ); 8291 8292 if (!empty($wpdb->last_error)) { 8293 return [ 8294 'status' => 'database_error', 8295 'error' => $wpdb->last_error, 8296 ]; 8297 } 8298 8299 if (!$row) { 8300 return [ 8301 'status' => 'not_found', 8302 'source_id' => $source_id, 8303 'origin_key' => $origin_key, 8304 ]; 8305 } 8306 8307 $capabilities = null; 8308 if ($row['capabilities'] !== null && $row['capabilities'] !== '') { 8309 $capabilities = json_decode($row['capabilities'], true); 8310 if (json_last_error() !== JSON_ERROR_NONE) { 8311 return [ 8312 'status' => 'invalid_stored_data', 8313 'source_id' => $source_id, 8314 'origin_key' => $origin_key, 8315 'field' => 'capabilities', 8316 ]; 8317 } 8318 } 8319 8320 $snapshot = null; 8321 if ($row['snapshot_data'] !== null && $row['snapshot_data'] !== '') { 8322 $snapshot = json_decode($row['snapshot_data'], true); 8323 if (json_last_error() !== JSON_ERROR_NONE) { 8324 return [ 8325 'status' => 'invalid_stored_data', 8326 'source_id' => $source_id, 8327 'origin_key' => $origin_key, 8328 'field' => 'snapshot_data', 8329 ]; 8330 } 8331 } 8332 8333 $checkpoint = null; 8334 if ($row['checkpoint_data'] !== null && $row['checkpoint_data'] !== '') { 8335 $checkpoint = json_decode($row['checkpoint_data'], true); 8336 if (json_last_error() !== JSON_ERROR_NONE) { 8337 return [ 8338 'status' => 'invalid_stored_data', 8339 'source_id' => $source_id, 8340 'origin_key' => $origin_key, 8341 'field' => 'checkpoint_data', 8342 ]; 8343 } 8344 } 8345 8346 return [ 8347 'status' => 'found', 8348 'source_id' => (int) $row['source_id'], 8349 'origin_key' => $row['origin_key'], 8350 'adapter_key' => $row['adapter_key'], 8351 'capabilities' => $capabilities, 8352 'snapshot' => $snapshot, 8353 'checkpoint' => $checkpoint, 8354 'detection_status' => $row['detection_status'], 8355 'origin_status' => $row['status'], 8356 'last_error' => $row['last_error'], 8357 'started_at' => $row['started_at'], 8358 'completed_at' => $row['completed_at'], 8359 'updated_at' => $row['updated_at'], 8360 ]; 8361 } 8362 8363 private function update_view_source_origin_state($source_id, $origin_key, $updates, $lock_context = null) { 8364 global $wpdb; 8365 8366 $source_id = absint($source_id); 8367 $origin_key = is_string($origin_key) ? trim($origin_key) : ''; 8368 8369 if ( 8370 !$source_id 8371 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 8372 || !is_array($updates) 8373 || empty($updates) 8374 ) { 8375 return [ 8376 'status' => 'invalid_input', 8377 ]; 8378 } 8379 8380 $allowed_fields = [ 8381 'snapshot_data', 8382 'checkpoint_data', 8383 'status', 8384 'last_error', 8385 'started_at', 8386 'completed_at', 8387 ]; 8388 8389 $columns = []; 8390 $values = []; 8391 8392 foreach ($updates as $field => $value) { 8393 if (!in_array($field, $allowed_fields, true)) { 8394 return [ 8395 'status' => 'invalid_field', 8396 'field' => $field, 8397 ]; 8398 } 8399 8400 if (in_array($field, ['snapshot_data', 'checkpoint_data'], true)) { 8401 if ($value !== null && !is_array($value)) { 8402 return [ 8403 'status' => 'invalid_input', 8404 'field' => $field, 8405 ]; 8406 } 8407 8408 $encoded = $value === null ? null : wp_json_encode($value); 8409 8410 if ($value !== null && !is_string($encoded)) { 8411 return [ 8412 'status' => 'encoding_error', 8413 'field' => $field, 8414 ]; 8415 } 8416 8417 $columns[] = "`{$field}` = %s"; 8418 $values[] = $encoded; 8419 continue; 8420 } 8421 8422 if ($field === 'status') { 8423 if ( 8424 !is_string($value) 8425 || !preg_match('/^[a-z0-9_-]{1,20}$/D', $value) 8426 ) { 8427 return [ 8428 'status' => 'invalid_input', 8429 'field' => $field, 8430 ]; 8431 } 8432 8433 $columns[] = "`{$field}` = %s"; 8434 $values[] = $value; 8435 continue; 8436 } 8437 8438 if ($field === 'last_error') { 8439 if ($value !== null && !is_string($value)) { 8440 return [ 8441 'status' => 'invalid_input', 8442 'field' => $field, 8443 ]; 8444 } 8445 8446 $columns[] = "`{$field}` = %s"; 8447 $values[] = $value; 8448 continue; 8449 } 8450 8451 if ($value !== null && ( 8452 !is_string($value) 8453 || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value) 8454 )) { 8455 return [ 8456 'status' => 'invalid_input', 8457 'field' => $field, 8458 ]; 8459 } 8460 8461 $columns[] = "`{$field}` = %s"; 8462 $values[] = $value; 8463 } 8464 8465 $columns[] = '`updated_at` = %s'; 8466 $values[] = current_time('mysql'); 8467 8468 $values[] = $source_id; 8469 $values[] = $origin_key; 8470 8471 $lock_name = ''; 8472 if ($lock_context !== null) { 8473 if (!$this->generic_advisory_lock_session_is_current($lock_context) || empty($lock_context['lock_name'])) { 8474 return ['status' => 'lock_session_lost']; 8475 } 8476 $lock_name = (string) $lock_context['lock_name']; 8477 } 8478 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The optional lock predicate and its matching lock-name argument are both added only when lock_context is present. 8479 $lock_predicate = $lock_context !== null ? ' AND IS_USED_LOCK(%s) = CONNECTION_ID()' : ''; 8480 if ($lock_context !== null) { 8481 $values[] = $lock_name; 8482 } 8483 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SET placeholders and values are appended together from the validated origin-state field allowlist; source_id/origin_key and the optional lock placeholder are appended to the values in matching order. 8484 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 8485 $result = statpl_db_query(/* phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder fragments and their values are assembled together from the same fixed allowlists; optional lock predicates and matching values are added together. */ statpl_db_prepare( 8486 "UPDATE {$this->view_source_origins_table} 8487 SET " . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $columns) . " 8488 WHERE source_id = %d AND origin_key = %s" . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $lock_predicate, 8489 ...$values 8490 )); 8491 8492 if ($result === false || $wpdb->last_error !== '') { 8493 return [ 8494 'status' => 'database_error', 8495 'error' => $wpdb->last_error, 8496 ]; 8497 } 8498 8499 if ($result === 0) { 8500 if ($lock_context !== null && !$this->generic_advisory_lock_session_is_current($lock_context)) { 8501 return ['status' => 'lock_session_lost']; 8502 } 8503 $exists = statpl_db_get_var( 8504 statpl_db_prepare( 8505 "SELECT 1 FROM {$this->view_source_origins_table} 8506 WHERE source_id = %d AND origin_key = %s", 8507 $source_id, 8508 $origin_key 8509 ) 8510 ); 8511 8512 if (!empty($wpdb->last_error)) { 8513 return [ 8514 'status' => 'database_error', 8515 'error' => $wpdb->last_error, 8516 ]; 8517 } 8518 8519 // The existence read can itself trigger a transparent wpdb reconnect. 8520 // Revalidate the original lock session after that read before treating 8521 // a zero-row UPDATE as a successful no-op. Otherwise a replacement 8522 // connection could make an unperformed state transition look complete. 8523 if ($lock_context !== null && !$this->generic_advisory_lock_session_is_current($lock_context)) { 8524 return ['status' => 'lock_session_lost']; 8525 } 8526 8527 if (!$exists) { 8528 return [ 8529 'status' => 'not_found', 8530 'source_id' => $source_id, 8531 'origin_key' => $origin_key, 8532 ]; 8533 } 8534 } 8535 8536 return [ 8537 'status' => 'updated', 8538 'source_id' => $source_id, 8539 'origin_key' => $origin_key, 8540 ]; 8541 } 8542 8543 private function save_view_source_origin_snapshot_if_empty($source_id, $origin_key, $snapshot) { 8544 global $wpdb; 8545 8546 $source_id = absint($source_id); 8547 $origin_key = is_string($origin_key) ? trim($origin_key) : ''; 8548 8549 if ( 8550 !$source_id 8551 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 8552 || !is_array($snapshot) 8553 || empty($snapshot) 8554 ) { 8555 return [ 8556 'status' => 'invalid_input', 8557 ]; 8558 } 8559 8560 $encoded = wp_json_encode($snapshot); 8561 8562 if (!is_string($encoded)) { 8563 return [ 8564 'status' => 'encoding_error', 8565 ]; 8566 } 8567 8568 $result = statpl_db_query( 8569 statpl_db_prepare( 8570 "UPDATE {$this->view_source_origins_table} 8571 SET snapshot_data = %s, updated_at = %s 8572 WHERE source_id = %d 8573 AND origin_key = %s 8574 AND snapshot_data IS NULL", 8575 $encoded, 8576 current_time('mysql'), 8577 $source_id, 8578 $origin_key 8579 ) 8580 ); 8581 8582 if ($result === false || $wpdb->last_error !== '') { 8583 return [ 8584 'status' => 'database_error', 8585 'error' => $wpdb->last_error, 8586 ]; 8587 } 8588 8589 if ($result === 1) { 8590 return [ 8591 'status' => 'updated', 8592 'source_id' => $source_id, 8593 'origin_key' => $origin_key, 8594 ]; 8595 } 8596 8597 $existing = $this->get_view_source_origin_state($source_id, $origin_key); 8598 8599 if ($existing['status'] !== 'found') { 8600 return $existing; 8601 } 8602 8603 if ($existing['snapshot'] !== null) { 8604 return [ 8605 'status' => 'already_initialized', 8606 'source_id' => $source_id, 8607 'origin_key' => $origin_key, 8608 'snapshot' => $existing['snapshot'], 8609 ]; 8610 } 8611 8612 return [ 8613 'status' => 'database_error', 8614 'source_id' => $source_id, 8615 'origin_key' => $origin_key, 8616 ]; 8617 } 8618 8619 private function initialize_view_source_origin_snapshot($source_id, $origin_key) { 8620 global $wpdb; 8621 8622 $state = $this->get_view_source_origin_state($source_id, $origin_key); 8623 8624 if ($state['status'] !== 'found') { 8625 return $state; 8626 } 8627 8628 if (!empty($state['snapshot'])) { 8629 return [ 8630 'status' => 'already_initialized', 8631 'source_id' => (int) $source_id, 8632 'origin_key' => $origin_key, 8633 'snapshot' => $state['snapshot'], 8634 ]; 8635 } 8636 8637 if ($state['detection_status'] !== 'available') { 8638 return [ 8639 'status' => 'origin_unavailable', 8640 'source_id' => (int) $source_id, 8641 'origin_key' => $origin_key, 8642 'detection_status' => $state['detection_status'], 8643 ]; 8644 } 8645 8646 $origin_tables = [ 8647 'slim_stats' => $wpdb->prefix . 'slim_stats', 8648 'slim_stats_archive' => $wpdb->prefix . 'slim_stats_archive', 8649 ]; 8650 8651 if (!isset($origin_tables[$origin_key])) { 8652 return [ 8653 'status' => 'unsupported_origin', 8654 'source_id' => (int) $source_id, 8655 'origin_key' => $origin_key, 8656 ]; 8657 } 8658 8659 $table = $origin_tables[$origin_key]; 8660 8661 $max_id = statpl_db_get_var( 8662 "SELECT MAX(id) FROM `{$table}`" 8663 ); 8664 8665 if (!empty($wpdb->last_error)) { 8666 return [ 8667 'status' => 'database_error', 8668 'source_id' => (int) $source_id, 8669 'origin_key' => $origin_key, 8670 'error' => $wpdb->last_error, 8671 ]; 8672 } 8673 8674 $snapshot = [ 8675 'max_id' => $max_id === null ? 0 : (int) $max_id, 8676 'captured_at' => current_time('mysql'), 8677 ]; 8678 8679 $saved = $this->save_view_source_origin_snapshot_if_empty( 8680 $source_id, 8681 $origin_key, 8682 $snapshot 8683 ); 8684 8685 if ($saved['status'] === 'already_initialized') { 8686 return $saved; 8687 } 8688 8689 if ($saved['status'] !== 'updated') { 8690 return $saved; 8691 } 8692 8693 return [ 8694 'status' => 'initialized', 8695 'source_id' => (int) $source_id, 8696 'origin_key' => $origin_key, 8697 'snapshot' => $snapshot, 8698 ]; 8699 } 8700 8701 private function save_view_source_origin_snapshot($source_id, $origin_key, $snapshot) { 8702 if (!is_array($snapshot) || empty($snapshot)) { 8703 return [ 8704 'status' => 'invalid_snapshot', 8705 ]; 8706 } 8707 8708 return $this->update_view_source_origin_state( 8709 $source_id, 8710 $origin_key, 8711 [ 8712 'snapshot_data' => $snapshot, 8713 ] 8714 ); 8715 } 8716 8717 private function save_view_source_origin_checkpoint($source_id, $origin_key, $checkpoint) { 8718 if (!is_array($checkpoint) || empty($checkpoint)) { 8719 return [ 8720 'status' => 'invalid_checkpoint', 8721 ]; 8722 } 8723 8724 return $this->update_view_source_origin_state( 8725 $source_id, 8726 $origin_key, 8727 [ 8728 'checkpoint_data' => $checkpoint, 8729 ] 8730 ); 8731 } 8732 8733 private function detect_slimstat_view_origins() { 8734 global $wpdb; 8735 8736 $origins = [ 8737 'slim_stats' => $wpdb->prefix . 'slim_stats', 8738 'slim_stats_archive' => $wpdb->prefix . 'slim_stats_archive', 8739 ]; 8740 8741 $required_columns = [ 8742 'id', 8743 'content_type', 8744 'content_id', 8745 'dt', 8746 ]; 8747 8748 $results = []; 8749 8750 foreach ($origins as $origin_key => $table) { 8751 $exists = statpl_db_get_var( 8752 statpl_db_prepare( 8753 'SHOW TABLES LIKE %s', 8754 $wpdb->esc_like($table) 8755 ) 8756 ); 8757 8758 if (!empty($wpdb->last_error)) { 8759 $results[$origin_key] = [ 8760 'status' => 'database_error', 8761 'error' => $wpdb->last_error, 8762 ]; 8763 continue; 8764 } 8765 8766 if ($exists !== $table) { 8767 $results[$origin_key] = [ 8768 'status' => 'missing_table', 8769 ]; 8770 continue; 8771 } 8772 8773 $columns = statpl_db_get_col( 8774 "SHOW COLUMNS FROM `{$table}`", 8775 0 8776 ); 8777 8778 if (!empty($wpdb->last_error)) { 8779 $results[$origin_key] = [ 8780 'status' => 'database_error', 8781 'error' => $wpdb->last_error, 8782 ]; 8783 continue; 8784 } 8785 8786 $missing_columns = array_values( 8787 array_diff($required_columns, $columns) 8788 ); 8789 8790 if (!empty($missing_columns)) { 8791 $results[$origin_key] = [ 8792 'status' => 'incompatible_schema', 8793 'missing_columns' => $missing_columns, 8794 ]; 8795 continue; 8796 } 8797 8798 $results[$origin_key] = [ 8799 'status' => 'available', 8800 'capabilities' => [ 8801 'historical_views' => true, 8802 'record_id' => true, 8803 'product_reference' => true, 8804 'event_timestamp' => true, 8805 ], 8806 ]; 8807 } 8808 8809 return $results; 8810 } 8811 8812 private function initialize_slimstat_view_source_origins() { 8813 $registration = $this->register_slimstat_view_source(); 8814 8815 if ( 8816 !in_array($registration['status'], ['registered', 'partial'], true) 8817 || empty($registration['source']['source_id']) 8818 ) { 8819 return [ 8820 'status' => 'registration_failed', 8821 'registration' => $registration, 8822 'origins' => [], 8823 ]; 8824 } 8825 8826 $source_id = (int) $registration['source']['source_id']; 8827 $origins = []; 8828 $initialization_failed = false; 8829 $available_origins = 0; 8830 8831 foreach ($registration['origins'] as $origin_key => $origin_data) { 8832 $origin_registration = isset($origin_data['registration']) 8833 ? $origin_data['registration'] 8834 : []; 8835 8836 if (($origin_registration['status'] ?? '') !== 'registered') { 8837 $origins[$origin_key] = [ 8838 'status' => 'registration_failed', 8839 'registration' => $origin_registration, 8840 ]; 8841 $initialization_failed = true; 8842 continue; 8843 } 8844 8845 $detection_status = $origin_data['detection']['status'] ?? 'unknown'; 8846 8847 if ($detection_status !== 'available') { 8848 $origins[$origin_key] = [ 8849 'status' => 'skipped', 8850 'reason' => $detection_status, 8851 ]; 8852 continue; 8853 } 8854 8855 $available_origins++; 8856 8857 $result = $this->initialize_view_source_origin_snapshot( 8858 $source_id, 8859 $origin_key 8860 ); 8861 8862 $origins[$origin_key] = $result; 8863 8864 if (!in_array( 8865 $result['status'], 8866 ['initialized', 'already_initialized'], 8867 true 8868 )) { 8869 $initialization_failed = true; 8870 } 8871 } 8872 8873 if ($available_origins === 0 && !$initialization_failed) { 8874 $status = 'no_available_origins'; 8875 } else { 8876 $status = $initialization_failed ? 'partial' : 'initialized'; 8877 } 8878 8879 return [ 8880 'status' => $status, 8881 'source_id' => $source_id, 8882 'registration' => $registration, 8883 'origins' => $origins, 8884 ]; 8885 } 8886 8887 private function register_slimstat_view_source() { 8888 $source = $this->register_view_source( 8889 'slimstat', 8890 'SlimStat', 8891 '1.0.0', 8892 'event', 8893 'registered' 8894 ); 8895 8896 if ($source['status'] !== 'registered' || empty($source['source_id'])) { 8897 return [ 8898 'status' => 'source_registration_failed', 8899 'source' => $source, 8900 'origins' => [], 8901 ]; 8902 } 8903 8904 $source_id = (int) $source['source_id']; 8905 $detected_origins = $this->detect_slimstat_view_origins(); 8906 $origins = []; 8907 $registration_failed = false; 8908 8909 foreach ($detected_origins as $origin_key => $detection) { 8910 $capabilities = isset($detection['capabilities']) 8911 ? $detection['capabilities'] 8912 : []; 8913 8914 $registration = $this->register_view_source_origin( 8915 $source_id, 8916 $origin_key, 8917 'slimstat', 8918 $capabilities, 8919 in_array($detection['status'], ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true) 8920 ? $detection['status'] 8921 : 'unknown' 8922 ); 8923 8924 $origins[$origin_key] = [ 8925 'detection' => $detection, 8926 'registration' => $registration, 8927 ]; 8928 8929 if ($registration['status'] !== 'registered') { 8930 $registration_failed = true; 8931 } 8932 } 8933 8934 return [ 8935 'status' => $registration_failed ? 'partial' : 'registered', 8936 'source' => $source, 8937 'origins' => $origins, 8938 ]; 8939 } 8940 8941 private function detect_slimstat_source() { 8942 global $wpdb; 8943 8944 // Check whether SlimStat is installed and loaded. 8945 if (!class_exists('wp_slimstat', false)) { 8946 return [ 8947 'status' => 'not_installed', 8948 ]; 8949 } 8950 8951 // SlimStat source tables. 8952 $tables = [ 8953 'active' => $wpdb->prefix . 'slim_stats', 8954 'archive' => $wpdb->prefix . 'slim_stats_archive', 8955 ]; 8956 8957 $missing_tables = []; 8958 8959 // Check that both tables exist. 8960 foreach ($tables as $key => $table) { 8961 $exists = statpl_db_get_var( 8962 statpl_db_prepare( 8963 'SHOW TABLES LIKE %s', 8964 $wpdb->esc_like($table) 8965 ) 8966 ); 8967 8968 if (!empty($wpdb->last_error)) { 8969 return [ 8970 'status' => 'database_error', 8971 ]; 8972 } 8973 8974 if ($exists !== $table) { 8975 $missing_tables[] = $table; 8976 } 8977 } 8978 8979 if (!empty($missing_tables)) { 8980 return [ 8981 'status' => 'missing_tables', 8982 'missing' => $missing_tables, 8983 ]; 8984 } 8985 8986 // Required columns for historical product-view import. 8987 $required_columns = [ 8988 'id', 8989 'content_type', 8990 'content_id', 8991 'dt', 8992 ]; 8993 8994 $incompatible_tables = []; 8995 8996 // Check the required schema in both tables. 8997 foreach ($tables as $key => $table) { 8998 $columns = statpl_db_get_col( 8999 "SHOW COLUMNS FROM `{$table}`", 9000 0 9001 ); 9002 9003 if (!empty($wpdb->last_error)) { 9004 return [ 9005 'status' => 'database_error', 9006 ]; 9007 } 9008 9009 $missing_columns = array_diff( 9010 $required_columns, 9011 $columns 9012 ); 9013 9014 if (!empty($missing_columns)) { 9015 $incompatible_tables[$table] = array_values( 9016 $missing_columns 9017 ); 9018 } 9019 } 9020 9021 if (!empty($incompatible_tables)) { 9022 return [ 9023 'status' => 'incompatible_schema', 9024 'missing_columns' => $incompatible_tables, 9025 ]; 9026 } 9027 9028 return [ 9029 'status' => 'available', 9030 'tables' => $tables, 9031 ]; 9032 } 9033 private function get_slimstat_view_snapshot() { 9034 global $wpdb; 9035 9036 $source = $this->detect_slimstat_source(); 9037 9038 if ($source['status'] !== 'available') { 9039 return $source; 9040 } 9041 9042 $snapshot = []; 9043 9044 foreach ($source['tables'] as $key => $table) { 9045 $max_id = statpl_db_get_var( 9046 statpl_db_prepare( 9047 "SELECT MAX(id) FROM `{$table}` WHERE content_type = %s", 9048 'cpt:product' 9049 ) 9050 ); 9051 9052 if (!empty($wpdb->last_error)) { 9053 return [ 9054 'status' => 'database_error', 9055 ]; 9056 } 9057 9058 $snapshot[$key] = (int) $max_id; 9059 } 9060 9061 return [ 9062 'status' => 'available', 9063 'snapshot' => $snapshot, 9064 ]; 9065 } 9066 private function initialize_slimstat_view_import() { 9067 global $wpdb; 9068 9069 $existing = statpl_db_get_var( 9070 statpl_db_prepare( 9071 "SELECT source FROM {$this->view_import_state_table} WHERE source = %s", 9072 'slimstat' 9073 ) 9074 ); 9075 9076 if (!empty($wpdb->last_error)) { 9077 return [ 9078 'status' => 'database_error', 9079 ]; 9080 } 9081 9082 if ($existing === 'slimstat') { 9083 return [ 9084 'status' => 'already_initialized', 9085 ]; 9086 } 9087 9088 $result = $this->get_slimstat_view_snapshot(); 9089 9090 if ($result['status'] !== 'available') { 9091 return $result; 9092 } 9093 9094 $inserted = statpl_db_query( 9095 statpl_db_prepare( 9096 "INSERT IGNORE INTO {$this->view_import_state_table} 9097 (source, snapshot_max_id, snapshot_active_max_id, snapshot_archive_max_id, last_processed_id, status, updated_at) 9098 VALUES (%s, %d, %d, %d, %d, %s, %s)", 9099 'slimstat', 9100 max($result['snapshot']['active'], $result['snapshot']['archive']), 9101 $result['snapshot']['active'], 9102 $result['snapshot']['archive'], 9103 0, 9104 'pending', 9105 current_time('mysql') 9106 ) 9107 ); 9108 9109 if (false === $inserted) { 9110 return [ 9111 'status' => 'database_error', 9112 ]; 9113 } 9114 9115 if (0 === $inserted) { 9116 $existing = statpl_db_get_var( 9117 statpl_db_prepare( 9118 "SELECT source FROM {$this->view_import_state_table} WHERE source = %s", 9119 'slimstat' 9120 ) 9121 ); 9122 9123 if (!empty($wpdb->last_error)) { 9124 return [ 9125 'status' => 'database_error', 9126 ]; 9127 } 9128 9129 if ($existing === 'slimstat') { 9130 return [ 9131 'status' => 'already_initialized', 9132 ]; 9133 } 9134 9135 return [ 9136 'status' => 'insert_not_confirmed', 9137 ]; 9138 } 9139 9140 return [ 9141 'status' => 'initialized', 9142 'snapshot' => $result['snapshot'], 9143 ]; 9144 } 9145 9146 /** 9147 * Retrieve the next historical SlimStat product-view batch. 9148 * This method is read-only: it never changes the checkpoint or import tables. 9149 */ 9150 private function get_slimstat_view_import_batch($last_processed_id, $batch_size = 50) { 9151 global $wpdb; 9152 9153 $state = statpl_db_get_row( 9154 statpl_db_prepare( 9155 "SELECT snapshot_active_max_id, snapshot_archive_max_id 9156 FROM {$this->view_import_state_table} 9157 WHERE source = %s", 9158 'slimstat' 9159 ), 9160 ARRAY_A 9161 ); 9162 9163 if (!empty($wpdb->last_error)) { 9164 return [ 9165 'status' => 'database_error', 9166 'rows' => [], 9167 ]; 9168 } 9169 9170 if (!is_array($state)) { 9171 return [ 9172 'status' => 'state_missing', 9173 'rows' => [], 9174 ]; 9175 } 9176 9177 $active_max_id = (int) $state['snapshot_active_max_id']; 9178 $archive_max_id = (int) $state['snapshot_archive_max_id']; 9179 $last_processed_id = max(0, (int) $last_processed_id); 9180 $batch_size = max(1, min(500, (int) $batch_size)); 9181 9182 $active_table = $wpdb->prefix . 'slim_stats'; 9183 $archive_table = $wpdb->prefix . 'slim_stats_archive'; 9184 9185 $sql = " 9186 SELECT id, product_id, dt, source 9187 FROM ( 9188 SELECT 9189 s.id AS id, 9190 s.content_id AS product_id, 9191 s.dt AS dt, 9192 'active' AS source 9193 FROM `{$active_table}` s 9194 WHERE s.content_type = %s 9195 AND s.id > %d 9196 AND s.id <= %d 9197 9198 UNION ALL 9199 9200 SELECT 9201 a.id AS id, 9202 a.content_id AS product_id, 9203 a.dt AS dt, 9204 'archive' AS source 9205 FROM `{$archive_table}` a 9206 WHERE a.content_type = %s 9207 AND a.id > %d 9208 AND a.id <= %d 9209 AND NOT EXISTS ( 9210 SELECT 1 9211 FROM `{$active_table}` s 9212 WHERE s.id = a.id 9213 AND s.id <= %d 9214 ) 9215 ) AS combined 9216 ORDER BY id ASC 9217 LIMIT %d 9218 "; 9219 9220 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is a fixed SQL template with value placeholders; execution below passes it through statpl_db_prepare() with the complete value list. 9221 $rows = statpl_db_get_results( 9222 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 9223 statpl_db_prepare( 9224 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $sql, 9225 'cpt:product', 9226 $last_processed_id, 9227 $active_max_id, 9228 'cpt:product', 9229 $last_processed_id, 9230 $archive_max_id, 9231 $active_max_id, 9232 $batch_size 9233 ), 9234 ARRAY_A 9235 ); 9236 9237 if (!empty($wpdb->last_error)) { 9238 return [ 9239 'status' => 'database_error', 9240 'rows' => [], 9241 ]; 9242 } 9243 9244 return [ 9245 'status' => 'available', 9246 'rows' => is_array($rows) ? $rows : [], 9247 ]; 9248 } 9249 9250 /** 9251 * Import one historical SlimStat product-view event idempotently. 9252 * 9253 * The event ledger is the unique source of truth. A guarded multi-table 9254 * update applies the metrics and marks the event in one atomic statement. 9255 * 9256 * The caller is responsible for checkpoint progression and retry handling. 9257 * 9258 * @param array $row Historical SlimStat source row. 9259 * @return string imported|duplicate|failed 9260 */ 9261 private function import_slimstat_view_event($row) { 9262 global $wpdb; 9263 9264 if ( 9265 !is_array($row) 9266 || !isset($row['id'], $row['product_id']) 9267 ) { 9268 return 'failed'; 9269 } 9270 9271 $source_view_id = (int) $row['id']; 9272 $product_id = (int) $row['product_id']; 9273 9274 if ($source_view_id <= 0 || $product_id <= 0) { 9275 return 'failed'; 9276 } 9277 9278 $now = current_time('mysql'); 9279 9280 // Insert the event as unapplied. Repeating this insert is harmless. 9281 $ledger_inserted = statpl_db_query( 9282 statpl_db_prepare( 9283 "INSERT INTO {$this->view_import_events_table} 9284 (source, source_view_id, product_id, imported_at, counts_applied) 9285 VALUES (%s, %d, %d, %s, 0) 9286 ON DUPLICATE KEY UPDATE 9287 source_view_id = VALUES(source_view_id)", 9288 'slimstat', 9289 $source_view_id, 9290 $product_id, 9291 $now 9292 ) 9293 ); 9294 9295 if (false === $ledger_inserted) { 9296 return 'failed'; 9297 } 9298 9299 // Verify the existing event identity; never reassign it to another product. 9300 $event = statpl_db_get_row( 9301 statpl_db_prepare( 9302 "SELECT product_id, counts_applied 9303 FROM {$this->view_import_events_table} 9304 WHERE source = %s AND source_view_id = %d", 9305 'slimstat', 9306 $source_view_id 9307 ), 9308 ARRAY_A 9309 ); 9310 9311 if ( 9312 !is_array($event) 9313 || !isset($event['product_id'], $event['counts_applied']) 9314 || (int) $event['product_id'] !== $product_id 9315 ) { 9316 return 'failed'; 9317 } 9318 9319 if (1 === (int) $event['counts_applied']) { 9320 return 'duplicate'; 9321 } 9322 9323 // Ensure both metric rows exist. These writes are safe to repeat. 9324 $total_ready = statpl_db_query( 9325 statpl_db_prepare( 9326 "INSERT INTO {$this->views_table} 9327 (product_id, total_views) 9328 VALUES (%d, 0) 9329 ON DUPLICATE KEY UPDATE product_id = VALUES(product_id)", 9330 $product_id 9331 ) 9332 ); 9333 9334 if (false === $total_ready) { 9335 return 'failed'; 9336 } 9337 9338 $aggregate_ready = statpl_db_query( 9339 statpl_db_prepare( 9340 "INSERT INTO {$this->view_imports_table} 9341 (source, product_id, imported_views, imported_at) 9342 VALUES (%s, %d, 0, %s) 9343 ON DUPLICATE KEY UPDATE product_id = VALUES(product_id)", 9344 'slimstat', 9345 $product_id, 9346 $now 9347 ) 9348 ); 9349 9350 if (false === $aggregate_ready) { 9351 return 'failed'; 9352 } 9353 9354 /* 9355 * Apply all three changes atomically. 9356 * Replaying this statement cannot increment the metrics twice. 9357 */ 9358 $metrics_updated = statpl_db_query( 9359 statpl_db_prepare( 9360 "UPDATE {$this->views_table} t 9361 INNER JOIN {$this->view_imports_table} i 9362 ON i.source = %s AND i.product_id = t.product_id 9363 INNER JOIN {$this->view_import_events_table} e 9364 ON e.source = %s 9365 AND e.source_view_id = %d 9366 AND e.product_id = t.product_id 9367 SET 9368 t.total_views = t.total_views + 1, 9369 i.imported_views = i.imported_views + 1, 9370 i.imported_at = %s, 9371 e.counts_applied = 1 9372 WHERE t.product_id = %d 9373 AND e.counts_applied = 0", 9374 'slimstat', 9375 'slimstat', 9376 $source_view_id, 9377 $now, 9378 $product_id 9379 ) 9380 ); 9381 9382 if (false === $metrics_updated) { 9383 return 'failed'; 9384 } 9385 9386 if (3 === $metrics_updated) { 9387 delete_transient('statpl_global_summary_v2'); 9388 return 'imported'; 9389 } 9390 9391 // Verify an ambiguous or concurrent result using the durable marker. 9392 if (0 === $metrics_updated) { 9393 $event = statpl_db_get_row( 9394 statpl_db_prepare( 9395 "SELECT product_id, counts_applied 9396 FROM {$this->view_import_events_table} 9397 WHERE source = %s AND source_view_id = %d", 9398 'slimstat', 9399 $source_view_id 9400 ), 9401 ARRAY_A 9402 ); 9403 9404 if ( 9405 is_array($event) 9406 && isset($event['product_id'], $event['counts_applied']) 9407 && (int) $event['product_id'] === $product_id 9408 && 1 === (int) $event['counts_applied'] 9409 ) { 9410 delete_transient('statpl_global_summary_v2'); 9411 return 'duplicate'; 9412 } 9413 } 9414 9415 return 'failed'; 9416 } 9417 9418 9419 /** Persist an initial SlimStat import failure without counting a replay as another attempt. */ 9420 private function record_slimstat_view_import_failure($row, $error) { 9421 global $wpdb; 9422 9423 $source_view_id = isset($row['id']) ? absint($row['id']) : 0; 9424 $product_id = isset($row['product_id']) ? absint($row['product_id']) : 0; 9425 9426 if ($source_view_id < 1) { 9427 return false; 9428 } 9429 9430 $error = substr(wp_strip_all_tags((string) $error), 0, 4000); 9431 if ($error === '') { 9432 $error = 'SlimStat historical view could not be imported.'; 9433 } 9434 9435 $result = statpl_db_query( 9436 statpl_db_prepare( 9437 "INSERT INTO {$this->view_import_failures_table} 9438 (source, source_view_id, product_id, attempts, last_error, status, updated_at) 9439 VALUES (%s, %d, %d, 1, %s, 'retry', %s) 9440 ON DUPLICATE KEY UPDATE last_error = VALUES(last_error)", 9441 'slimstat', 9442 $source_view_id, 9443 $product_id, 9444 $error, 9445 current_time('mysql') 9446 ) 9447 ); 9448 9449 if (false === $result) { 9450 return false; 9451 } 9452 $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . $source_view_id), 'retry', 1, 'record_import_failed'); 9453 9454 $failure = statpl_db_get_row( 9455 statpl_db_prepare( 9456 "SELECT product_id 9457 FROM {$this->view_import_failures_table} 9458 WHERE source = %s AND source_view_id = %d", 9459 'slimstat', 9460 $source_view_id 9461 ), 9462 ARRAY_A 9463 ); 9464 9465 return empty($wpdb->last_error) 9466 && is_array($failure) 9467 && isset($failure['product_id']) 9468 && (int) $failure['product_id'] === $product_id; 9469 } 9470 9471 /** Process independently due SlimStat retries. */ 9472 private function process_slimstat_view_import_retries($lock = null) { 9473 global $wpdb; 9474 9475 // Retry processing is part of the same serialized worker as the main 9476 // batch. Verify ownership per event, not merely before and after the 9477 // entire retry loop, because a transparent reconnect can occur midway. 9478 if (!$this->slimstat_import_lock_is_current($lock)) { 9479 return false; 9480 } 9481 9482 $now = current_time('mysql'); 9483 9484 $failures = statpl_db_get_results( 9485 statpl_db_prepare( 9486 "SELECT id, source_view_id, product_id, attempts 9487 FROM {$this->view_import_failures_table} 9488 WHERE source = %s 9489 AND status = 'retry' 9490 AND ( 9491 (attempts = 1 AND updated_at <= DATE_SUB(%s, INTERVAL 1 MINUTE)) 9492 OR 9493 (attempts = 2 AND updated_at <= DATE_SUB(%s, INTERVAL 2 MINUTE)) 9494 ) 9495 ORDER BY updated_at ASC 9496 LIMIT 50", 9497 'slimstat', 9498 $now, 9499 $now 9500 ), 9501 ARRAY_A 9502 ); 9503 9504 if (!empty($wpdb->last_error) || !is_array($failures)) { 9505 return false; 9506 } 9507 9508 foreach ($failures as $failure) { 9509 if (!$this->slimstat_import_lock_is_current($lock)) { 9510 return false; 9511 } 9512 9513 $result = $this->import_slimstat_view_event([ 9514 'id' => (int) $failure['source_view_id'], 9515 'product_id' => (int) $failure['product_id'], 9516 ]); 9517 9518 // The event may have committed immediately before a DB reconnect. 9519 // Its counts_applied marker makes replay safe, but no retry-ledger 9520 // mutation may continue on a replacement session without the lock. 9521 if (!$this->slimstat_import_lock_is_current($lock)) { 9522 return false; 9523 } 9524 9525 if (in_array($result, ['imported', 'duplicate'], true)) { 9526 $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $failure['source_view_id']), 'imported', (int) $failure['attempts'], ''); 9527 $deleted = statpl_db_delete( 9528 $this->view_import_failures_table, 9529 [ 9530 'id' => (int) $failure['id'], 9531 'source' => 'slimstat', 9532 ], 9533 ['%d', '%s'] 9534 ); 9535 9536 if (false === $deleted || !$this->slimstat_import_lock_is_current($lock)) { 9537 return false; 9538 } 9539 9540 continue; 9541 } 9542 9543 $error = !empty($wpdb->last_error) 9544 ? $wpdb->last_error 9545 : 'SlimStat historical view retry failed.'; 9546 9547 $updated = statpl_db_query( 9548 statpl_db_prepare( 9549 "UPDATE {$this->view_import_failures_table} 9550 SET 9551 status = IF(attempts >= 2, 'failed', 'retry'), 9552 attempts = attempts + 1, 9553 last_error = %s, 9554 updated_at = %s 9555 WHERE id = %d 9556 AND source = %s 9557 AND status = 'retry' 9558 AND attempts < 3", 9559 substr(wp_strip_all_tags((string) $error), 0, 4000), 9560 current_time('mysql'), 9561 (int) $failure['id'], 9562 'slimstat' 9563 ) 9564 ); 9565 9566 if (1 !== $updated || !$this->slimstat_import_lock_is_current($lock)) { 9567 return false; 9568 } 9569 $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $failure['source_view_id']), ((int) $failure['attempts'] >= 2 ? 'failed' : 'retry'), (int) $failure['attempts'] + 1, 'record_import_failed'); 9570 } 9571 9572 return $this->slimstat_import_lock_is_current($lock); 9573 } 9574 9575 /** Return the delay until the earliest pending SlimStat retry. */ 9576 private function get_slimstat_view_import_retry_delay() { 9577 global $wpdb; 9578 9579 $delay = statpl_db_get_var( 9580 statpl_db_prepare( 9581 "SELECT GREATEST( 9582 5, 9583 TIMESTAMPDIFF( 9584 SECOND, 9585 %s, 9586 CASE 9587 WHEN attempts = 1 THEN DATE_ADD(updated_at, INTERVAL 1 MINUTE) 9588 ELSE DATE_ADD(updated_at, INTERVAL 2 MINUTE) 9589 END 9590 ) 9591 ) 9592 FROM {$this->view_import_failures_table} 9593 WHERE source = %s AND status = 'retry' 9594 ORDER BY 9595 CASE 9596 WHEN attempts = 1 THEN DATE_ADD(updated_at, INTERVAL 1 MINUTE) 9597 ELSE DATE_ADD(updated_at, INTERVAL 2 MINUTE) 9598 END ASC 9599 LIMIT 1", 9600 current_time('mysql'), 9601 'slimstat' 9602 ) 9603 ); 9604 9605 if (!empty($wpdb->last_error)) { 9606 return 60; 9607 } 9608 9609 return null === $delay ? 5 : max(5, (int) $delay); 9610 } 9611 9612 /** Advance the SlimStat checkpoint only after the current row is durably resolved. */ 9613 private function update_slimstat_view_import_checkpoint($expected_id, $next_id, $lock) { 9614 global $wpdb; 9615 9616 $expected_id = max(0, (int) $expected_id); 9617 $next_id = max(0, (int) $next_id); 9618 9619 if ($next_id <= $expected_id) { 9620 return $this->slimstat_import_lock_is_current($lock); 9621 } 9622 if (!$this->slimstat_import_lock_is_current($lock)) { 9623 return false; 9624 } 9625 9626 // Enforce ownership inside the checkpoint UPDATE itself. This closes 9627 // the check/write race if wpdb reconnects between separate statements. 9628 $updated = statpl_db_query( 9629 statpl_db_prepare( 9630 "UPDATE {$this->view_import_state_table} 9631 SET last_processed_id = %d, updated_at = %s 9632 WHERE source = %s AND last_processed_id = %d 9633 AND CONNECTION_ID() = %s 9634 AND IS_USED_LOCK(%s) = CONNECTION_ID()", 9635 $next_id, 9636 current_time('mysql'), 9637 'slimstat', 9638 $expected_id, 9639 (string) $lock['connection_id'], 9640 (string) $lock['lock_name'] 9641 ) 9642 ); 9643 9644 return 1 === $updated && $this->slimstat_import_lock_is_current($lock); 9645 } 9646 9647 /** Capture and verify the exact MySQL session that owns the SlimStat import lock. */ 9648 private function acquire_slimstat_import_lock($lock_name) { 9649 global $wpdb; 9650 try { 9651 $row = statpl_db_get_row(statpl_db_prepare( 9652 'SELECT CONNECTION_ID() AS connection_id, GET_LOCK(%s, 0) AS lock_acquired', 9653 (string) $lock_name 9654 ), ARRAY_A); 9655 } catch (Throwable $exception) { 9656 return ['acquired' => false, 'error' => $exception->getMessage()]; 9657 } 9658 if (!is_array($row) || !empty($wpdb->last_error) 9659 || (string) ($row['lock_acquired'] ?? '') !== '1' 9660 || empty($row['connection_id'])) { 9661 return ['acquired' => false, 'error' => (string) $wpdb->last_error]; 9662 } 9663 return [ 9664 'acquired' => true, 9665 'connection_id' => (string) $row['connection_id'], 9666 'lock_name' => (string) $lock_name, 9667 ]; 9668 } 9669 9670 /** Verify both connection identity and actual ownership of the SlimStat lock. */ 9671 private function slimstat_import_lock_is_current($lock) { 9672 global $wpdb; 9673 if (!is_array($lock) || empty($lock['acquired']) 9674 || empty($lock['connection_id']) || empty($lock['lock_name'])) { 9675 return false; 9676 } 9677 try { 9678 $row = statpl_db_get_row(statpl_db_prepare( 9679 'SELECT CONNECTION_ID() AS connection_id, IS_USED_LOCK(%s) AS lock_owner', 9680 (string) $lock['lock_name'] 9681 ), ARRAY_A); 9682 } catch (Throwable $exception) { 9683 return false; 9684 } 9685 return is_array($row) && empty($wpdb->last_error) 9686 && (string) ($row['connection_id'] ?? '') === (string) $lock['connection_id'] 9687 && (string) ($row['lock_owner'] ?? '') === (string) $lock['connection_id']; 9688 } 9689 9690 /** Release the SlimStat lock only through the original lock-owning session. */ 9691 private function release_slimstat_import_lock($lock) { 9692 global $wpdb; 9693 if (!$this->slimstat_import_lock_is_current($lock)) { 9694 if (defined('WP_DEBUG') && WP_DEBUG) { 9695 $this->debug_log('Statora Plus: SlimStat advisory lock session changed before release.'); 9696 } 9697 return false; 9698 } 9699 try { 9700 $released = statpl_db_get_var(statpl_db_prepare( 9701 'SELECT RELEASE_LOCK(%s)', (string) $lock['lock_name'] 9702 )); 9703 return (string) $released === '1' && empty($wpdb->last_error); 9704 } catch (Throwable $exception) { 9705 if (defined('WP_DEBUG') && WP_DEBUG) { 9706 $this->debug_log('Statora Plus: SlimStat advisory lock release failed: ' . $exception->getMessage()); 9707 } 9708 return false; 9709 } 9710 } 9711 9712 /** Execute one protected SlimStat historical import batch. */ 9713 public function run_slimstat_view_import_batch() { 9714 global $wpdb; 9715 9716 // MySQL advisory lock names must remain within the server's 64-character limit. 9717 $lock_name = 'statpl_view_import_' . substr(md5($wpdb->prefix), 0, 32); 9718 9719 $slimstat_lock = $this->acquire_slimstat_import_lock($lock_name); 9720 9721 if (empty($slimstat_lock['acquired'])) { 9722 $this->schedule_slimstat_view_import_batch( 9723 !empty($wpdb->last_error) ? 60 : 15 9724 ); 9725 return; 9726 } 9727 9728 try { 9729 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9730 $this->schedule_slimstat_view_import_batch(60); 9731 return; 9732 } 9733 $state = statpl_db_get_row( 9734 statpl_db_prepare( 9735 "SELECT snapshot_max_id, last_processed_id, status 9736 FROM {$this->view_import_state_table} 9737 WHERE source = %s", 9738 'slimstat' 9739 ), 9740 ARRAY_A 9741 ); 9742 9743 if (!empty($wpdb->last_error)) { 9744 $this->schedule_slimstat_view_import_batch(60); 9745 return; 9746 } 9747 9748 if (!is_array($state)) { 9749 return; 9750 } 9751 9752 if (!in_array($state['status'], ['pending', 'running'], true)) { 9753 return; 9754 } 9755 9756 $state_updated = statpl_db_query( 9757 statpl_db_prepare( 9758 "UPDATE {$this->view_import_state_table} 9759 SET status = 'running', updated_at = %s 9760 WHERE source = %s AND status IN ('pending', 'running') 9761 AND CONNECTION_ID() = %s 9762 AND IS_USED_LOCK(%s) = CONNECTION_ID()", 9763 current_time('mysql'), 9764 'slimstat', 9765 (string) $slimstat_lock['connection_id'], 9766 (string) $slimstat_lock['lock_name'] 9767 ) 9768 ); 9769 9770 if (false === $state_updated) { 9771 $this->schedule_slimstat_view_import_batch(60); 9772 return; 9773 } 9774 9775 // Retry previously failed rows independently of the sequential source batch. 9776 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9777 $this->schedule_slimstat_view_import_batch(60); 9778 return; 9779 } 9780 if (!$this->process_slimstat_view_import_retries($slimstat_lock)) { 9781 $this->schedule_slimstat_view_import_batch(60); 9782 return; 9783 } 9784 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9785 // A retry may have committed its event before a transparent DB 9786 // reconnect. Its durable event marker makes replay safe, but this 9787 // worker must not advance the sequential checkpoint on a new session. 9788 $this->schedule_slimstat_view_import_batch(60); 9789 return; 9790 } 9791 9792 $checkpoint = (int) $state['last_processed_id']; 9793 $snapshot_max_id = (int) $state['snapshot_max_id']; 9794 9795 $batch = $this->get_slimstat_view_import_batch($checkpoint, 50); 9796 9797 if ( 9798 !is_array($batch) 9799 || !isset($batch['status'], $batch['rows']) 9800 || 'available' !== $batch['status'] 9801 || !is_array($batch['rows']) 9802 ) { 9803 $this->schedule_slimstat_view_import_batch(60); 9804 return; 9805 } 9806 9807 foreach ($batch['rows'] as $row) { 9808 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9809 $this->schedule_slimstat_view_import_batch(60); 9810 return; 9811 } 9812 // A checkpoint replay must not consume another import attempt. 9813 $existing_failure = statpl_db_get_row( 9814 statpl_db_prepare( 9815 "SELECT product_id, status 9816 FROM {$this->view_import_failures_table} 9817 WHERE source = %s AND source_view_id = %d", 9818 'slimstat', 9819 (int) $row['id'] 9820 ), 9821 ARRAY_A 9822 ); 9823 9824 if (!empty($wpdb->last_error)) { 9825 $this->schedule_slimstat_view_import_batch(60); 9826 return; 9827 } 9828 9829 if (is_array($existing_failure)) { 9830 if ((int) $existing_failure['product_id'] !== (int) $row['product_id']) { 9831 $this->debug_log( 9832 'Statora Plus: SlimStat failure ledger product ID mismatch for source event ' 9833 . (int) $row['id'] 9834 ); 9835 $this->schedule_slimstat_view_import_batch(60); 9836 return; 9837 } 9838 9839 if (!in_array($existing_failure['status'], ['retry', 'failed'], true)) { 9840 $this->schedule_slimstat_view_import_batch(60); 9841 return; 9842 } 9843 9844 // The failure is already durable; wait for its independent retry. 9845 $next_id = (int) $row['id']; 9846 9847 if (!$this->slimstat_import_lock_is_current($slimstat_lock) 9848 || !$this->update_slimstat_view_import_checkpoint($checkpoint, $next_id, $slimstat_lock)) { 9849 $this->schedule_slimstat_view_import_batch(60); 9850 return; 9851 } 9852 9853 $checkpoint = $next_id; 9854 continue; 9855 } 9856 9857 $result = $this->import_slimstat_view_event($row); 9858 9859 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9860 // The event helper may have completed its atomic metric update 9861 // just before a connection loss. Do not move the checkpoint; 9862 // replay is protected by counts_applied and the unique event key. 9863 $this->schedule_slimstat_view_import_batch(60); 9864 return; 9865 } 9866 9867 if (in_array($result, ['imported', 'duplicate'], true)) { 9868 $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $row['id']), 'imported', is_array($existing_failure) ? (int) ($existing_failure['attempts'] ?? 0) : 0, ''); 9869 // A previous retry record is no longer needed after confirmed success. 9870 $deleted = statpl_db_delete( 9871 $this->view_import_failures_table, 9872 [ 9873 'source' => 'slimstat', 9874 'source_view_id' => (int) $row['id'], 9875 ], 9876 ['%s', '%d'] 9877 ); 9878 9879 if (false === $deleted) { 9880 $this->schedule_slimstat_view_import_batch(60); 9881 return; 9882 } 9883 } else { 9884 // Capture the helper's SQL error before executing another database query. 9885 $error = !empty($wpdb->last_error) 9886 ? $wpdb->last_error 9887 : 'SlimStat historical view could not be imported.'; 9888 9889 if (!$this->record_slimstat_view_import_failure($row, $error)) { 9890 $this->schedule_slimstat_view_import_batch(60); 9891 return; 9892 } 9893 } 9894 9895 $next_id = (int) $row['id']; 9896 9897 if (!$this->slimstat_import_lock_is_current($slimstat_lock) 9898 || !$this->update_slimstat_view_import_checkpoint($checkpoint, $next_id, $slimstat_lock)) { 9899 $this->schedule_slimstat_view_import_batch(60); 9900 return; 9901 } 9902 9903 $checkpoint = $next_id; 9904 } 9905 9906 if (!empty($batch['rows'])) { 9907 $this->schedule_slimstat_view_import_batch(5); 9908 return; 9909 } 9910 9911 // An empty successful source query means the snapshot has been exhausted. 9912 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9913 $this->schedule_slimstat_view_import_batch(60); 9914 return; 9915 } 9916 if ($checkpoint < $snapshot_max_id) { 9917 if (!$this->update_slimstat_view_import_checkpoint($checkpoint, $snapshot_max_id, $slimstat_lock)) { 9918 $this->schedule_slimstat_view_import_batch(60); 9919 return; 9920 } 9921 9922 $checkpoint = $snapshot_max_id; 9923 } 9924 9925 $failure_counts = statpl_db_get_row( 9926 statpl_db_prepare( 9927 "SELECT 9928 SUM(CASE WHEN status = 'retry' THEN 1 ELSE 0 END) AS pending_retries, 9929 SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS terminal_failures 9930 FROM {$this->view_import_failures_table} 9931 WHERE source = %s", 9932 'slimstat' 9933 ), 9934 ARRAY_A 9935 ); 9936 9937 if (!empty($wpdb->last_error) || !is_array($failure_counts)) { 9938 $this->schedule_slimstat_view_import_batch(60); 9939 return; 9940 } 9941 9942 $pending_retries = (int) $failure_counts['pending_retries']; 9943 $terminal_failures = (int) $failure_counts['terminal_failures']; 9944 9945 if ($pending_retries > 0) { 9946 $this->schedule_slimstat_view_import_batch( 9947 $this->get_slimstat_view_import_retry_delay() 9948 ); 9949 return; 9950 } 9951 9952 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9953 $this->schedule_slimstat_view_import_batch(60); 9954 return; 9955 } 9956 $final_status = $terminal_failures > 0 ? 'error' : 'complete'; 9957 9958 $finalized = statpl_db_query( 9959 statpl_db_prepare( 9960 "UPDATE {$this->view_import_state_table} 9961 SET status = %s, updated_at = %s 9962 WHERE source = %s AND status IN ('pending', 'running') 9963 AND CONNECTION_ID() = %s 9964 AND IS_USED_LOCK(%s) = CONNECTION_ID()", 9965 $final_status, 9966 current_time('mysql'), 9967 'slimstat', 9968 (string) $slimstat_lock['connection_id'], 9969 (string) $slimstat_lock['lock_name'] 9970 ) 9971 ); 9972 9973 if (1 !== $finalized || !$this->slimstat_import_lock_is_current($slimstat_lock)) { 9974 $this->schedule_slimstat_view_import_batch(60); 9975 } 9976 } finally { 9977 // Release only through the original connection. If WordPress transparently 9978 // reconnected, MySQL has already released the old session's advisory lock. 9979 $this->release_slimstat_import_lock($slimstat_lock); 9980 } 9981 } 9982 9983 /** Schedule one independent SlimStat view import batch. */ 9984 public function schedule_slimstat_view_import_batch($delay = 10) { 9985 global $wpdb; 9986 9987 $status = statpl_db_get_var( 9988 statpl_db_prepare( 9989 "SELECT status FROM {$this->view_import_state_table} WHERE source = %s", 9990 'slimstat' 9991 ) 9992 ); 9993 9994 if (!empty($wpdb->last_error) || !in_array($status, ['pending', 'running'], true)) { 9995 return; 9996 } 9997 9998 $delay = max(5, (int) $delay); 9999 10000 if (function_exists('as_schedule_single_action') && !did_action('action_scheduler_init')) { 10001 add_action('action_scheduler_init', [$this, 'schedule_slimstat_view_import_batch'], 10, 0); 10002 return; 10003 } 10004 10005 if ( 10006 did_action('action_scheduler_init') 10007 && class_exists('ActionScheduler') 10008 && class_exists('ActionScheduler_Store') 10009 && null !== ActionScheduler::store()->query_action( 10010 [ 10011 'hook' => 'statpl_view_import_batch', 10012 'status' => ActionScheduler_Store::STATUS_PENDING, 10013 'group' => 'statpl-plus', 10014 'args' => [], 10015 'orderby' => 'none', 10016 ] 10017 ) 10018 ) { 10019 return; 10020 } 10021 10022 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 10023 $action_id = as_schedule_single_action( 10024 time() + $delay, 10025 'statpl_view_import_batch', 10026 [], 10027 'statpl-plus' 10028 ); 10029 10030 if ($action_id) { 10031 return; 10032 } 10033 } 10034 10035 if (!wp_next_scheduled('statpl_view_import_batch')) { 10036 wp_schedule_single_event(time() + $delay, 'statpl_view_import_batch'); 10037 } 10038 } 10039 580 10040 /** Schedule one import batch with a delay to avoid overlapping batch execution. */ 581 10041 public function schedule_import_batch($delay = 10) { … … 645 10105 $args = array_merge([$since,$since,$since], $ids); 646 10106 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared with all variable values; table name is a class property built from the WordPress table prefix and fixed suffix; event counts must be current. 647 $rows = $wpdb->get_results($wpdb->prepare($sql, $args), ARRAY_A);10107 $rows = statpl_db_get_results(statpl_db_prepare($sql, $args), ARRAY_A); 648 10108 foreach ($rows as $r) { 649 10109 $id = (int)$r['product_id']; if (!isset($out[$id])) continue; … … 662 10122 $event_args = array_merge([$event_since, $event_since], $ids); 663 10123 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared with all variable values; table name is a class property built from the WordPress table prefix and fixed suffix; event counts must be current. 664 $event_rows = $wpdb->get_results($wpdb->prepare($event_sql, $event_args), ARRAY_A);10124 $event_rows = statpl_db_get_results(statpl_db_prepare($event_sql, $event_args), ARRAY_A); 665 10125 foreach ($event_rows as $event_row) { 666 10126 $id = (int)$event_row['product_id']; … … 686 10146 $summary = [ 687 10147 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This dashboard aggregate is cached by global_summary_metrics() for 60 seconds and invalidated when relevant data changes. 688 'products' => (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status IN ('publish','private','draft','pending')"),10148 'products' => (int) statpl_db_get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status IN ('publish','private','draft','pending')"), 689 10149 'views' => 0, 'views24' => 0, 'orders' => 0, 'orders24' => 0, 690 10150 'units' => 0.0, 'units24' => 0.0, 'revenue' => 0.0, 'revenue24' => 0.0, … … 694 10154 // All product views are recorded and aggregated by Statora Plus. 695 10155 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix; the dashboard aggregate is cached for 60 seconds and view recording invalidates that cache. 696 $summary['views'] = (int) $wpdb->get_var("SELECT COALESCE(SUM(total_views),0) FROM {$this->views_table}");10156 $summary['views'] = (int) statpl_db_get_var("SELECT COALESCE(SUM(total_views),0) FROM {$this->views_table}"); 697 10157 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the date value is prepared. 698 $summary['views24'] = (int) $wpdb->get_var(699 $wpdb->prepare(10158 $summary['views24'] = (int) statpl_db_get_var( 10159 statpl_db_prepare( 700 10160 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the date value is prepared. 701 10161 "SELECT COUNT(*) FROM {$this->events_table} WHERE viewed_at >= %s", … … 707 10167 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL identifiers cannot be bound as value placeholders; the identifier is internal, not user-controlled. 708 10168 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This aggregate is cached by global_summary_metrics() for 60 seconds and invalidated on relevant changes. 709 $sales = $wpdb->get_row($wpdb->prepare("SELECT10169 $sales = statpl_db_get_row(statpl_db_prepare("SELECT 710 10170 COALESCE(SUM(CASE WHEN statpl_snapshot.order_status='completed' AND statpl_snapshot.completed_at IS NOT NULL THEN statpl_snapshot.net_quantity ELSE 0 END),0) AS units_total, 711 10171 COALESCE(SUM(CASE WHEN statpl_snapshot.order_status='completed' AND statpl_snapshot.completed_at >= %s THEN statpl_snapshot.net_quantity ELSE 0 END),0) AS units24, … … 723 10183 // Product-level detail is calculated separately from rows with actual product IDs. 724 10184 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; no user-supplied SQL values are used. 725 $event_totals = $wpdb->get_row(10185 $event_totals = statpl_db_get_row( 726 10186 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; this aggregate has no user-supplied SQL values. 727 10187 "SELECT COUNT(DISTINCT CASE WHEN event_type='cancelled' AND product_id=0 THEN order_id END) AS cancelled_total, COUNT(DISTINCT CASE WHEN event_type='refund' AND product_id=0 AND event_ref_id > 0 THEN event_ref_id END) AS refunds_total FROM {$this->order_events_table}", … … 734 10194 // Every normal order is represented once per product in the snapshot; count order IDs only once. 735 10195 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix.; Table identifier is internally generated from the WordPress prefix and a fixed suffix.; This aggregate is part of the 60-second cached dashboard summary. 736 $summary['total_orders'] = (int) $wpdb->get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table}");10196 $summary['total_orders'] = (int) statpl_db_get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table}"); 737 10197 // Clean orders are currently processing/completed and retain some non-refunded product quantity/value. 738 10198 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix.; Table identifier is internally generated from the WordPress prefix and a fixed suffix.; This aggregate is part of the 60-second cached dashboard summary. 739 $summary['orders'] = (int) $wpdb->get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0)");10199 $summary['orders'] = (int) statpl_db_get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0)"); 740 10200 // Today's clean orders are based on order creation date, not payment/completion date. 741 10201 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary. 742 $summary['orders24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND created_at >= %s AND (net_quantity > 0 OR net_revenue > 0)", $since));10202 $summary['orders24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND created_at >= %s AND (net_quantity > 0 OR net_revenue > 0)", $since)); 743 10203 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary. 744 $summary['cancelled24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='cancelled' AND event_at >= %s", $since));10204 $summary['cancelled24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='cancelled' AND event_at >= %s", $since)); 745 10205 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary. 746 $summary['refunds24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT event_ref_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='refund' AND event_ref_id > 0 AND event_at >= %s", $since));10206 $summary['refunds24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT event_ref_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='refund' AND event_ref_id > 0 AND event_at >= %s", $since)); 747 10207 $summary['revenue'] = round($summary['revenue'], wc_get_price_decimals()); 748 10208 $summary['revenue24'] = round($summary['revenue24'], wc_get_price_decimals()); … … 829 10289 SELECT product_id, COUNT(*) AS views_today 830 10290 FROM {$this->events_table} 831 WHERE viewed_at >= " . $wpdb->prepare('%s', $since_mysql) . "10291 WHERE viewed_at >= " . statpl_db_prepare('%s', $since_mysql) . " 832 10292 GROUP BY product_id 833 10293 ) recent ON recent.product_id = totals.product_id … … 837 10297 SELECT product_id, COUNT(*) AS views_today 838 10298 FROM {$this->events_table} 839 WHERE viewed_at >= " . $wpdb->prepare('%s', $since_mysql) . "10299 WHERE viewed_at >= " . statpl_db_prepare('%s', $since_mysql) . " 840 10300 GROUP BY product_id 841 10301 ) statpl_recent_views ON statpl_recent_views.product_id = p.ID"; … … 849 10309 SELECT product_id, 850 10310 COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders, 851 COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND created_at >= " . $wpdb->prepare('%s', $since) . " AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders24,10311 COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND created_at >= " . statpl_db_prepare('%s', $since) . " AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders24, 852 10312 SUM(CASE WHEN order_status='completed' AND completed_at IS NOT NULL THEN net_quantity ELSE 0 END) AS units, 853 SUM(CASE WHEN order_status='completed' AND completed_at >= " . $wpdb->prepare('%s', $since) . " THEN net_quantity ELSE 0 END) AS units24,10313 SUM(CASE WHEN order_status='completed' AND completed_at >= " . statpl_db_prepare('%s', $since) . " THEN net_quantity ELSE 0 END) AS units24, 854 10314 SUM(CASE WHEN order_status='completed' AND completed_at IS NOT NULL THEN net_revenue ELSE 0 END) AS revenue, 855 SUM(CASE WHEN order_status='completed' AND completed_at >= " . $wpdb->prepare('%s', $since) . " THEN net_revenue ELSE 0 END) AS revenue2410315 SUM(CASE WHEN order_status='completed' AND completed_at >= " . statpl_db_prepare('%s', $since) . " THEN net_revenue ELSE 0 END) AS revenue24 856 10316 FROM {$this->order_items_table} GROUP BY product_id 857 10317 ) statpl_orders ON statpl_orders.product_id = p.ID … … 859 10319 SELECT product_id, 860 10320 COUNT(DISTINCT CASE WHEN event_type='cancelled' THEN order_id END) AS cancelled, 861 COUNT(DISTINCT CASE WHEN event_type='cancelled' AND event_at >= " . $wpdb->prepare('%s', $since) . " THEN order_id END) AS cancelled24,10321 COUNT(DISTINCT CASE WHEN event_type='cancelled' AND event_at >= " . statpl_db_prepare('%s', $since) . " THEN order_id END) AS cancelled24, 862 10322 COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 THEN event_ref_id END) AS refunds, 863 COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 AND event_at >= " . $wpdb->prepare('%s', $since) . " THEN event_ref_id END) AS refunds2410323 COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 AND event_at >= " . statpl_db_prepare('%s', $since) . " THEN event_ref_id END) AS refunds24 864 10324 FROM {$this->order_events_table} GROUP BY product_id 865 10325 ) statpl_events ON statpl_events.product_id = p.ID"; … … 869 10329 // $join and $where are assembled only from fixed SQL fragments and prepared placeholders; user input is passed in $args. 870 10330 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query fragments are plugin-controlled; all user-supplied values are bound through prepare(); product filters are evaluated live. 871 $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $args));10331 $total = (int) statpl_db_get_var(statpl_db_prepare($count_sql, $args)); 872 10332 $order_column = $order_columns[$orderby] ?? 'p.post_title'; 873 10333 $pages_for_query = max(1, (int) ceil($total / $per_page)); … … 878 10338 // $order_column is selected from the internal allowlist and $order is normalized before query construction. 879 10339 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query fragments are plugin-controlled; all user-supplied values are bound through prepare(); product filters are evaluated live. 880 $product_ids = array_map('intval', $wpdb->get_col($wpdb->prepare($id_sql, $id_args)));10340 $product_ids = array_map('intval', statpl_db_get_col(statpl_db_prepare($id_sql, $id_args))); 881 10341 } else { 882 10342 $base_args = ['post_type'=>'product','post_status'=>$statuses,'posts_per_page'=>-1,'fields'=>'ids','orderby'=>'ID','order'=>'ASC']; … … 891 10351 if (false === $sku_ids) { 892 10352 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Prepared partial-SKU lookup is required; results are cached for 60 seconds. 893 $sku_ids = $wpdb->get_col(894 $wpdb->prepare(10353 $sku_ids = statpl_db_get_col( 10354 statpl_db_prepare( 895 10355 "SELECT DISTINCT p.ID 896 10356 FROM {$wpdb->posts} p … … 928 10388 $st = $stats_by_product[$product_id] ?? ['orders'=>0,'orders24'=>0,'units'=>0,'units24'=>0,'revenue'=>0.0,'revenue24'=>0.0,'cancelled'=>0,'cancelled24'=>0,'refunds'=>0,'refunds24'=>0]; 929 10389 $stock_summary = $this->stock_summary_for_product($product); 10390 $product_status = get_post_status($product_id); 10391 $visibility_labels = [ 10392 'private' => 'Κρυφό', 10393 'draft' => 'Πρόχειρο', 10394 'pending' => 'Σε αναμονή', 10395 ]; 930 10396 $rows[] = ['id'=>$product_id,'title'=>$product->get_name(),'date'=>get_post_field('post_date',$product_id),'sku'=>$product->get_sku(), 931 'views'=>(int)$views['total'],'views24'=>(int)$views['today'],'source'=>$views['source'],'stock_managed'=>$stock_summary['managed'],'stock'=>$stock_summary['quantity'],'edit_url'=>get_edit_post_link($product_id, 'raw')] + $st; 10397 'product_visibility'=>$visibility_labels[$product_status] ?? '', 10398 'views'=>(int)$views['total'],'views24'=>(int)$views['today'],'source'=>$views['source'],'stock_managed'=>$stock_summary['managed'],'stock'=>$stock_summary['quantity'],'stock_in_stock'=>$stock_summary['in_stock'],'edit_url'=>get_edit_post_link($product_id, 'raw')] + $st; 932 10399 } 933 10400 if (!$db_paged) { … … 945 10412 $summary = $this->global_summary_metrics(); 946 10413 947 echo '<div class="wrap statpl-metrics-wrap"><h1>Statora Plus</h1> <p class="description">Στατιστικά προϊόντων WooCommerce σε μία συνοπτική εικόνα.</p>';10414 echo '<div class="wrap statpl-metrics-wrap"><h1>Statora Plus</h1>'; 948 10415 $overview_url = add_query_arg(['page'=>'statpl-product-metrics','view'=>'overview'], admin_url('admin.php')); 949 10416 $products_url = add_query_arg(['page'=>'statpl-product-metrics','view'=>'products'], admin_url('admin.php')); 950 10417 echo '<nav class="nav-tab-wrapper"><a class="nav-tab '.($view === 'overview' ? 'nav-tab-active' : '').'" href="'.esc_url($overview_url).'">Συνολική εικόνα</a><a class="nav-tab '.($view === 'products' ? 'nav-tab-active' : '').'" href="'.esc_url($products_url).'">Στατιστικά ανά προϊόν</a></nav>'; 951 10418 952 if (get_option('statpl_import_complete')) { 953 echo '<div class="notice notice-success inline"><p>Η αρχική εισαγωγή ιστορικού έχει επισημανθεί ως ολοκληρωμένη.</p></div>'; 954 } else { 955 $import_page = max(1, (int) get_option('statpl_import_page', 1)); 956 echo '<div class="notice notice-info inline"><p>Η αρχική εισαγωγή ιστορικού εκτελείται στο παρασκήνιο. Επεξεργασία παρτίδας: '.esc_html((string)$import_page).'. Τα στατιστικά ενδέχεται να είναι ελλιπή μέχρι να ολοκληρωθεί.</p></div>'; 957 } 10419 $this->render_historical_import_notice(); 958 10420 if ($view === 'overview') { 959 10421 echo '<h2>Συνολικά στατιστικά καταστήματος</h2>'; … … 1006 10468 if (!$visible_rows) echo '<tr><td colspan="14">Δεν βρέθηκαν προϊόντα.</td></tr>'; 1007 10469 foreach ($visible_rows as $r) { 1008 echo '<tr><td><a class="statpl-product-title" href="'.esc_url($r['edit_url']).'">'.esc_html($r['title']).'</a></td>'; 1009 echo '<td>'.($r['stock_managed'] ? esc_html(number_format_i18n($r['stock'])) : '<span class="statpl-stock-not-tracked">Δεν παρακολουθείται</span>').'</td>'; 10470 echo '<tr><td><a class="statpl-product-title" href="'.esc_url($r['edit_url']).'">'.esc_html($r['title']).'</a>'; 10471 if (!empty($r['product_visibility'])) { 10472 echo ' <span class="statpl-product-visibility">'.esc_html($r['product_visibility']).'</span>'; 10473 } 10474 echo '</td>'; 10475 if ($r['stock_managed']) { 10476 $stock_quantity = (float) $r['stock']; 10477 if ($stock_quantity > 0) { 10478 echo '<td>'.esc_html(number_format_i18n($stock_quantity)).'</td>'; 10479 } elseif (!empty($r['stock_in_stock'])) { 10480 // Zero quantity with backorders allowed remains purchasable. 10481 echo '<td>0</td>'; 10482 } else { 10483 echo '<td>Εξαντλημένο</td>'; 10484 } 10485 } else { 10486 echo '<td><span class="statpl-stock-not-tracked">Δεν γίνεται διαχείριση αποθέματος</span></td>'; 10487 } 1010 10488 echo '<td>'.esc_html(number_format_i18n($r['views'])).'</td><td>'.esc_html(number_format_i18n($r['views24'])).'</td>'; 1011 10489 foreach (['orders','orders24'] as $k) echo '<td>'.esc_html(number_format_i18n($r[$k])).'</td>'; … … 1022 10500 } 1023 10501 STATPL_Core::instance(); 10502 10503 10504 /** 10505 * Page View Count adapter. 10506 * 10507 * Reads only the provider's immutable, per-product daily rows from pvc_daily. 10508 * The current day is intentionally excluded because Page View Count updates 10509 * that row in place; importing a mutable count would violate the immutable 10510 * source snapshot contract. Lifetime totals in pvc_total are deliberately not 10511 * imported because they overlap the daily rows and have no date boundaries. 10512 */ 10513 if (!class_exists('STATPL_Page_View_Count_Historical_Adapter')) { 10514 final class STATPL_Page_View_Count_Historical_Adapter implements STATPL_Historical_View_Adapter { 10515 const ADAPTER_KEY = 'page_view_count'; 10516 const SOURCE_KEY = 'page_view_count'; 10517 const ADAPTER_VERSION = '1.0.0'; 10518 const ORIGIN_KEY = 'daily_product_views'; 10519 10520 public function get_adapter_key() { 10521 return self::ADAPTER_KEY; 10522 } 10523 10524 public function get_source_key() { 10525 return self::SOURCE_KEY; 10526 } 10527 10528 public function get_display_name() { 10529 return 'Page Views Count'; 10530 } 10531 10532 public function get_adapter_version() { 10533 return self::ADAPTER_VERSION; 10534 } 10535 10536 private function table_name() { 10537 global $wpdb; 10538 return $wpdb->prefix . 'pvc_daily'; 10539 } 10540 10541 private function quoted_table_name() { 10542 $table = $this->table_name(); 10543 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 10544 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 10545 } 10546 return '`' . $table . '`'; 10547 } 10548 10549 private function inspect_table() { 10550 global $wpdb; 10551 $table = $this->table_name(); 10552 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SHOW TABLES LIKE has one %s placeholder and receives exactly one escaped table-name value. 10553 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 10554 if (!empty($wpdb->last_error)) { 10555 return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; 10556 } 10557 if ($exists !== $table) { 10558 return ['status' => 'missing_table']; 10559 } 10560 10561 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SHOW COLUMNS contains no value placeholders; the table identifier is validated and quoted by quoted_table_name(). 10562 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10563 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 10564 if (!empty($wpdb->last_error) || !is_array($columns)) { 10565 return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; 10566 } 10567 $names = []; 10568 foreach ($columns as $column) { 10569 if (isset($column['Field'])) { 10570 $names[] = (string) $column['Field']; 10571 } 10572 } 10573 $required = ['id', 'time', 'postnum', 'postcount']; 10574 if (array_diff($required, $names)) { 10575 return ['status' => 'incompatible_schema']; 10576 } 10577 return ['status' => 'available']; 10578 } 10579 10580 public function should_register_adapter() { 10581 global $wpdb; 10582 $inspection = $this->inspect_table(); 10583 if ($inspection['status'] !== 'missing_table') { return true; } 10584 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 10585 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 10586 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 10587 } 10588 $registry_table = '`' . $registry_name . '`'; 10589 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 10590 if (!empty($wpdb->last_error)) { return true; } 10591 if ($registry_exists !== $registry_name) { return false; } 10592 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 10593 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10594 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 10595 return !empty($wpdb->last_error) || !empty($source_id); 10596 } 10597 10598 public function discover_origins() { 10599 $inspection = $this->inspect_table(); 10600 if ($inspection['status'] === 'missing_table') { 10601 // A previously registered source can be marked unavailable by the 10602 // core's authoritative inventory reconciliation if its table was 10603 // removed. A never-installed source is not registered needlessly. 10604 return []; 10605 } 10606 return [[ 10607 'origin_key' => self::ORIGIN_KEY, 10608 'capabilities' => [ 10609 'product_views' => true, 10610 'granularity' => 'daily_aggregate', 10611 'immutable_past_days' => true, 10612 ], 10613 'detection_status' => $inspection['status'], 10614 ]]; 10615 } 10616 10617 public function get_origin_snapshot($origin_key) { 10618 global $wpdb; 10619 if ($origin_key !== self::ORIGIN_KEY) { 10620 return []; 10621 } 10622 $inspection = $this->inspect_table(); 10623 if ($inspection['status'] !== 'available') { 10624 throw new RuntimeException('Page View Count source table is not available or has an incompatible schema.'); 10625 } 10626 10627 $through_date = current_datetime()->modify('-1 day')->format('Y-m-d'); 10628 $posts_table_name = $wpdb->posts; 10629 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 10630 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 10631 } 10632 $posts_table = '`' . $posts_table_name . '`'; 10633 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10634 $max_id = statpl_db_get_var(statpl_db_prepare( 10635 'SELECT MAX(d.id) 10636 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS d 10637 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p 10638 ON p.ID = CAST(d.postnum AS UNSIGNED) 10639 AND p.post_type = %s 10640 WHERE d.postnum REGEXP %s AND d.time <= %s', 10641 'product', 10642 '^[0-9]+$', 10643 $through_date 10644 )); 10645 if (!empty($wpdb->last_error)) { 10646 throw new RuntimeException('Page View Count snapshot query failed.'); 10647 } 10648 10649 return [ 10650 'boundary' => [ 10651 'max_id' => max(0, (int) $max_id), 10652 'through_date' => $through_date, 10653 ], 10654 'initial_checkpoint' => null, 10655 'captured_at' => current_time('mysql'), 10656 ]; 10657 } 10658 10659 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 10660 global $wpdb; 10661 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 10662 throw new RuntimeException('Invalid Page View Count origin or snapshot.'); 10663 } 10664 $inspection = $this->inspect_table(); 10665 if ($inspection['status'] !== 'available') { 10666 throw new RuntimeException('Page View Count source table became unavailable or incompatible.'); 10667 } 10668 10669 $boundary = $snapshot['boundary']; 10670 $max_id = isset($boundary['max_id']) ? absint($boundary['max_id']) : -1; 10671 $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : ''; 10672 $date = DateTime::createFromFormat('!Y-m-d', $through_date); 10673 if ($max_id < 0 || !$date || $date->format('Y-m-d') !== $through_date) { 10674 throw new RuntimeException('Invalid Page View Count snapshot boundary.'); 10675 } 10676 10677 $cursor_id = 0; 10678 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 10679 $cursor_value = $checkpoint['cursor']; 10680 if ($cursor_value !== null) { 10681 $cursor_id = absint($cursor_value); 10682 } 10683 } elseif (is_numeric($checkpoint)) { 10684 $cursor_id = absint($checkpoint); 10685 } 10686 10687 $limit = max(1, min(100, absint($limit))); 10688 $posts_table_name = $wpdb->posts; 10689 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 10690 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 10691 } 10692 $posts_table = '`' . $posts_table_name . '`'; 10693 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10694 $rows = statpl_db_get_results(statpl_db_prepare( 10695 'SELECT MAX(d.id) AS id, d.time, d.postnum, SUM(d.postcount) AS postcount 10696 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS d 10697 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p 10698 ON p.ID = CAST(d.postnum AS UNSIGNED) 10699 AND p.post_type = %s 10700 WHERE d.postnum REGEXP %s 10701 AND d.id <= %d 10702 AND d.time <= %s 10703 GROUP BY d.time, d.postnum 10704 HAVING MAX(d.id) > %d 10705 ORDER BY MAX(d.id) ASC 10706 LIMIT %d', 10707 'product', 10708 '^[0-9]+$', 10709 $max_id, 10710 $through_date, 10711 $cursor_id, 10712 $limit + 1 10713 ), ARRAY_A); 10714 if (!empty($wpdb->last_error) || !is_array($rows)) { 10715 throw new RuntimeException('Page View Count batch query failed.'); 10716 } 10717 10718 $has_more = count($rows) > $limit; 10719 if ($has_more) { 10720 $rows = array_slice($rows, 0, $limit); 10721 } 10722 $next_checkpoint = $cursor_id; 10723 if ($rows) { 10724 $last = end($rows); 10725 $next_checkpoint = absint($last['id']); 10726 } 10727 10728 return [ 10729 'rows' => array_values($rows), 10730 'has_more' => $has_more, 10731 'next_checkpoint' => $next_checkpoint, 10732 'boundary_applied' => true, 10733 ]; 10734 } 10735 10736 public function normalize_record($origin_key, $record) { 10737 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { 10738 return null; 10739 } 10740 $record_id = isset($record['id']) ? absint($record['id']) : 0; 10741 $product_raw = isset($record['postnum']) ? (string) $record['postnum'] : ''; 10742 $views_raw = $record['postcount'] ?? null; 10743 $date = isset($record['time']) ? (string) $record['time'] : ''; 10744 if (!$record_id || !preg_match('/^[1-9][0-9]*$/D', $product_raw) || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { 10745 return null; 10746 } 10747 $parsed_date = DateTime::createFromFormat('!Y-m-d', $date); 10748 $errors = DateTime::getLastErrors(); 10749 if (!$parsed_date || $parsed_date->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { 10750 return null; 10751 } 10752 10753 $product_id = (int) $product_raw; 10754 $views = (int) $views_raw; 10755 $period_start = $date . ' 00:00:00'; 10756 $period_end = $date . ' 23:59:59'; 10757 10758 return [ 10759 'product_id' => $product_id, 10760 'source_record_key' => 'pvc_daily_' . $product_id . '_' . $date, 10761 'views' => $views, 10762 'record_type' => 'aggregate', 10763 'period_start' => $period_start, 10764 'period_end' => $period_end, 10765 'cursor' => $record_id, 10766 ]; 10767 } 10768 } 10769 } 10770 10771 add_filter('statpl_historical_view_adapters', static function ($adapters) { 10772 if (!is_array($adapters)) { 10773 return $adapters; 10774 } 10775 $adapter = new STATPL_Page_View_Count_Historical_Adapter(); 10776 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 10777 return $adapters; 10778 }, 30); 10779 10780 /** 10781 * WP Statistics adapter. 10782 * 10783 * Imports daily rows from statistics_pages where WP Statistics has explicitly 10784 * classified the viewed resource as a WooCommerce product. The historical 10785 * summary table is not read here: it no longer preserves a trustworthy date 10786 * interval after detailed rows have been aggregated. 10787 */ 10788 if (!class_exists('STATPL_WP_Statistics_Historical_Adapter')) { 10789 final class STATPL_WP_Statistics_Historical_Adapter implements STATPL_Historical_View_Adapter { 10790 const ADAPTER_KEY = 'wp_statistics_pages'; 10791 const SOURCE_KEY = 'wp_statistics'; 10792 const ADAPTER_VERSION = '1.0.0'; 10793 const ORIGIN_KEY = 'product_daily_pages'; 10794 10795 public function get_adapter_key() { return self::ADAPTER_KEY; } 10796 public function get_source_key() { return self::SOURCE_KEY; } 10797 public function get_display_name() { return 'WP Statistics'; } 10798 public function get_adapter_version() { return self::ADAPTER_VERSION; } 10799 10800 private function table_name() { 10801 global $wpdb; 10802 return $wpdb->prefix . 'statistics_pages'; 10803 } 10804 10805 private function quoted_table_name() { 10806 $table = $this->table_name(); 10807 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 10808 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 10809 } 10810 return '`' . $table . '`'; 10811 } 10812 10813 private function inspect_table() { 10814 global $wpdb; 10815 $table = $this->table_name(); 10816 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 10817 if (!empty($wpdb->last_error)) { 10818 return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; 10819 } 10820 if ($exists !== $table) { 10821 return ['status' => 'missing_table']; 10822 } 10823 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10824 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 10825 if (!empty($wpdb->last_error) || !is_array($columns)) { 10826 return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; 10827 } 10828 $names = []; 10829 foreach ($columns as $column) { 10830 if (isset($column['Field'])) { $names[] = (string) $column['Field']; } 10831 } 10832 if (array_diff(['page_id', 'uri', 'type', 'date', 'count', 'id'], $names)) { 10833 return ['status' => 'incompatible_schema']; 10834 } 10835 return ['status' => 'available']; 10836 } 10837 10838 public function should_register_adapter() { 10839 global $wpdb; 10840 $inspection = $this->inspect_table(); 10841 if ($inspection['status'] !== 'missing_table') { return true; } 10842 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 10843 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 10844 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 10845 } 10846 $registry_table = '`' . $registry_name . '`'; 10847 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 10848 if (!empty($wpdb->last_error)) { return true; } 10849 if ($registry_exists !== $registry_name) { return false; } 10850 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 10851 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10852 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 10853 return !empty($wpdb->last_error) || !empty($source_id); 10854 } 10855 10856 public function discover_origins() { 10857 $inspection = $this->inspect_table(); 10858 if ($inspection['status'] === 'missing_table') { return []; } 10859 return [[ 10860 'origin_key' => self::ORIGIN_KEY, 10861 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true], 10862 'detection_status' => $inspection['status'], 10863 ]]; 10864 } 10865 10866 public function get_origin_snapshot($origin_key) { 10867 global $wpdb; 10868 if ($origin_key !== self::ORIGIN_KEY) { return []; } 10869 if ($this->inspect_table()['status'] !== 'available') { 10870 throw new RuntimeException('WP Statistics pages table is unavailable or incompatible.'); 10871 } 10872 $through_date = current_datetime()->modify('-1 day')->format('Y-m-d'); 10873 $posts_table_name = $wpdb->posts; 10874 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 10875 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 10876 } 10877 $posts_table = '`' . $posts_table_name . '`'; 10878 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10879 $max_id = statpl_db_get_var(statpl_db_prepare( 10880 'SELECT MAX(s.page_id) 10881 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 10882 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.id AND p.post_type = %s 10883 WHERE s.type = %s AND s.date <= %s', 10884 'product', 'product', $through_date 10885 )); 10886 if (!empty($wpdb->last_error)) { 10887 throw new RuntimeException('WP Statistics snapshot query failed.'); 10888 } 10889 return [ 10890 'boundary' => ['max_page_id' => max(0, (int) $max_id), 'through_date' => $through_date], 10891 'initial_checkpoint' => null, 10892 'captured_at' => current_time('mysql'), 10893 ]; 10894 } 10895 10896 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 10897 global $wpdb; 10898 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 10899 throw new RuntimeException('Invalid WP Statistics origin or snapshot.'); 10900 } 10901 if ($this->inspect_table()['status'] !== 'available') { 10902 throw new RuntimeException('WP Statistics pages table became unavailable or incompatible.'); 10903 } 10904 $boundary = $snapshot['boundary']; 10905 $max_id = isset($boundary['max_page_id']) ? absint($boundary['max_page_id']) : -1; 10906 $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : ''; 10907 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 10908 if ($max_id < 0 || !$parsed || $parsed->format('Y-m-d') !== $through_date) { 10909 throw new RuntimeException('Invalid WP Statistics snapshot boundary.'); 10910 } 10911 $cursor_id = 0; 10912 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 10913 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 10914 } elseif (is_numeric($checkpoint)) { 10915 $cursor_id = absint($checkpoint); 10916 } 10917 $limit = max(1, min(100, absint($limit))); 10918 $posts_table_name = $wpdb->posts; 10919 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 10920 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 10921 } 10922 $posts_table = '`' . $posts_table_name . '`'; 10923 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10924 $rows = statpl_db_get_results(statpl_db_prepare( 10925 'SELECT MAX(s.page_id) AS page_id, s.date, SUM(s.count) AS count, s.id 10926 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 10927 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.id AND p.post_type = %s 10928 WHERE s.type = %s AND s.page_id <= %d AND s.date <= %s 10929 GROUP BY s.date, s.id 10930 HAVING MAX(s.page_id) > %d 10931 ORDER BY MAX(s.page_id) ASC 10932 LIMIT %d', 10933 'product', 'product', $max_id, $through_date, $cursor_id, $limit + 1 10934 ), ARRAY_A); 10935 if (!empty($wpdb->last_error) || !is_array($rows)) { 10936 throw new RuntimeException('WP Statistics batch query failed.'); 10937 } 10938 $has_more = count($rows) > $limit; 10939 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 10940 $next = $cursor_id; 10941 if ($rows) { $last = end($rows); $next = absint($last['page_id']); } 10942 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 10943 } 10944 10945 public function normalize_record($origin_key, $record) { 10946 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 10947 $record_id = isset($record['page_id']) ? absint($record['page_id']) : 0; 10948 $product_id = isset($record['id']) ? absint($record['id']) : 0; 10949 $views_raw = $record['count'] ?? null; 10950 $date = isset($record['date']) ? (string) $record['date'] : ''; 10951 if (!$record_id || !$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 10952 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 10953 $errors = DateTime::getLastErrors(); 10954 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 10955 $views = (int) $views_raw; 10956 $period_start = $date . ' 00:00:00'; 10957 $period_end = $date . ' 23:59:59'; 10958 return [ 10959 'product_id' => $product_id, 10960 'source_record_key' => 'wp_statistics_product_' . $product_id . '_' . $date, 10961 'views' => $views, 10962 'record_type' => 'aggregate', 10963 'period_start' => $period_start, 10964 'period_end' => $period_end, 10965 'cursor' => $record_id, 10966 ]; 10967 } 10968 } 10969 } 10970 10971 /** 10972 * Post Views Counter adapter. 10973 * 10974 * Reads only type=0 daily rows from wp_post_views. Week, month, year and total 10975 * rows are deliberately excluded because they are overlapping rollups of the 10976 * same views. Past-day rows are used so the source counts are stable while the 10977 * importer runs; today's row is still mutable and is left to live tracking. 10978 */ 10979 if (!class_exists('STATPL_Post_Views_Counter_Historical_Adapter')) { 10980 final class STATPL_Post_Views_Counter_Historical_Adapter implements STATPL_Historical_View_Adapter { 10981 const ADAPTER_KEY = 'post_views_counter'; 10982 const SOURCE_KEY = 'post_views_counter'; 10983 const ADAPTER_VERSION = '1.0.0'; 10984 const ORIGIN_KEY = 'daily_product_views'; 10985 10986 public function get_adapter_key() { return self::ADAPTER_KEY; } 10987 public function get_source_key() { return self::SOURCE_KEY; } 10988 public function get_display_name() { return 'Post Views Counter'; } 10989 public function get_adapter_version() { return self::ADAPTER_VERSION; } 10990 10991 private function table_name() { 10992 global $wpdb; 10993 return $wpdb->prefix . 'post_views'; 10994 } 10995 10996 private function quoted_table_name() { 10997 $table = $this->table_name(); 10998 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 10999 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11000 } 11001 return '`' . $table . '`'; 11002 } 11003 11004 private function inspect_table() { 11005 global $wpdb; 11006 $table = $this->table_name(); 11007 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11008 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11009 if ($exists !== $table) { return ['status' => 'missing_table']; } 11010 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11011 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11012 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11013 $names = []; 11014 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = (string) $column['Field']; } } 11015 if (array_diff(['id', 'type', 'period', 'count'], $names)) { return ['status' => 'incompatible_schema']; } 11016 return ['status' => 'available']; 11017 } 11018 11019 public function should_register_adapter() { 11020 global $wpdb; 11021 $inspection = $this->inspect_table(); 11022 if ($inspection['status'] !== 'missing_table') { return true; } 11023 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11024 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11025 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11026 } 11027 $registry_table = '`' . $registry_name . '`'; 11028 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11029 if (!empty($wpdb->last_error)) { return true; } 11030 if ($registry_exists !== $registry_name) { return false; } 11031 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11032 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11033 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11034 return !empty($wpdb->last_error) || !empty($source_id); 11035 } 11036 11037 public function discover_origins() { 11038 $inspection = $this->inspect_table(); 11039 if ($inspection['status'] === 'missing_table') { return []; } 11040 return [[ 11041 'origin_key' => self::ORIGIN_KEY, 11042 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true], 11043 'detection_status' => $inspection['status'], 11044 ]]; 11045 } 11046 11047 public function get_origin_snapshot($origin_key) { 11048 global $wpdb; 11049 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11050 if ($this->inspect_table()['status'] !== 'available') { 11051 throw new RuntimeException('Post Views Counter table is unavailable or incompatible.'); 11052 } 11053 $through_date = current_datetime()->modify('-1 day')->format('Ymd'); 11054 $posts_table_name = $wpdb->posts; 11055 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11056 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11057 } 11058 $posts_table = '`' . $posts_table_name . '`'; 11059 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The query has four fixed value placeholders and receives four values; the two table identifiers are independently validated SQL identifiers. 11060 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11061 $max_period = statpl_db_get_var(statpl_db_prepare( 11062 'SELECT MAX(v.period) 11063 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS v 11064 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.id AND p.post_type = %s 11065 WHERE v.type = %d AND v.period REGEXP %s AND v.period <= %s', 11066 'product', 0, '^[0-9]{8}$', $through_date 11067 )); 11068 if (!empty($wpdb->last_error)) { 11069 throw new RuntimeException('Post Views Counter snapshot query failed.'); 11070 } 11071 if (!is_string($max_period) || !preg_match('/^[0-9]{8}$/D', $max_period)) { $max_period = ''; } 11072 return [ 11073 'boundary' => ['max_period' => $max_period], 11074 'initial_checkpoint' => null, 11075 'captured_at' => current_time('mysql'), 11076 ]; 11077 } 11078 11079 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11080 global $wpdb; 11081 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11082 throw new RuntimeException('Invalid Post Views Counter origin or snapshot.'); 11083 } 11084 if ($this->inspect_table()['status'] !== 'available') { 11085 throw new RuntimeException('Post Views Counter table became unavailable or incompatible.'); 11086 } 11087 $max_period = isset($snapshot['boundary']['max_period']) ? (string) $snapshot['boundary']['max_period'] : ''; 11088 if ($max_period !== '' && !preg_match('/^[0-9]{8}$/D', $max_period)) { throw new RuntimeException('Invalid Post Views Counter snapshot boundary.'); } 11089 $cursor_period = ''; 11090 $cursor_id = 0; 11091 if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) { 11092 $cursor_period = isset($checkpoint['cursor']['period']) ? (string) $checkpoint['cursor']['period'] : ''; 11093 $cursor_id = isset($checkpoint['cursor']['id']) ? absint($checkpoint['cursor']['id']) : 0; 11094 } 11095 $limit = max(1, min(100, absint($limit))); 11096 $posts_table_name = $wpdb->posts; 11097 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11098 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11099 } 11100 $posts_table = '`' . $posts_table_name . '`'; 11101 if ($max_period === '') { $rows = []; } 11102 else { 11103 $cursor_sql = ''; 11104 $args = ['product', 0, '^[0-9]{8}$', $max_period]; 11105 if ($cursor_period !== '') { 11106 if (!preg_match('/^[0-9]{8}$/D', $cursor_period)) { throw new RuntimeException('Invalid Post Views Counter cursor.'); } 11107 $cursor_sql = ' AND (v.period > %s OR (v.period = %s AND v.id > %d))'; 11108 $args[] = $cursor_period; 11109 $args[] = $cursor_period; 11110 $args[] = $cursor_id; 11111 } 11112 $batch_sql = 'SELECT v.id, v.period, v.count 11113 FROM ' . $this->quoted_table_name() . ' AS v 11114 INNER JOIN ' . $posts_table . ' AS p ON p.ID = v.id AND p.post_type = %s 11115 WHERE v.type = %d AND v.period REGEXP %s AND v.period <= %s' . $cursor_sql . ' 11116 ORDER BY v.period ASC, v.id ASC 11117 LIMIT %d'; 11118 if ($cursor_period !== '') { 11119 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11120 $rows = statpl_db_get_results(statpl_db_prepare( 11121 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11122 'product', 0, '^[0-9]{8}$', $max_period, 11123 $cursor_period, $cursor_period, $cursor_id, $limit + 1 11124 ), ARRAY_A); 11125 } else { 11126 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11127 $rows = statpl_db_get_results(statpl_db_prepare( 11128 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11129 'product', 0, '^[0-9]{8}$', $max_period, $limit + 1 11130 ), ARRAY_A); 11131 } 11132 } 11133 if (!empty($wpdb->last_error) || !is_array($rows)) { 11134 throw new RuntimeException('Post Views Counter batch query failed.'); 11135 } 11136 $has_more = count($rows) > $limit; 11137 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11138 $next = ($cursor_period !== '') ? ['period' => $cursor_period, 'id' => $cursor_id] : null; 11139 if ($rows) { $last = end($rows); $next = ['period' => (string) $last['period'], 'id' => absint($last['id'])]; } 11140 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11141 } 11142 11143 public function normalize_record($origin_key, $record) { 11144 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11145 $product_id = isset($record['id']) ? absint($record['id']) : 0; 11146 $period = isset($record['period']) ? (string) $record['period'] : ''; 11147 $views_raw = $record['count'] ?? null; 11148 if (!$product_id || !preg_match('/^[0-9]{8}$/D', $period) || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 11149 $parsed = DateTime::createFromFormat('!Ymd', $period); 11150 $errors = DateTime::getLastErrors(); 11151 if (!$parsed || $parsed->format('Ymd') !== $period || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 11152 $date = $parsed->format('Y-m-d'); 11153 $views = (int) $views_raw; 11154 $period_start = $date . ' 00:00:00'; 11155 $period_end = $date . ' 23:59:59'; 11156 return [ 11157 'product_id' => $product_id, 11158 'source_record_key' => 'post_views_counter_day_' . $period . '_' . $product_id, 11159 'views' => $views, 11160 'record_type' => 'aggregate', 11161 'period_start' => $period_start, 11162 'period_end' => $period_end, 11163 'cursor' => ['period' => $period, 'id' => $product_id], 11164 ]; 11165 } 11166 } 11167 } 11168 11169 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11170 if (!is_array($adapters)) { return $adapters; } 11171 $wp_statistics = new STATPL_WP_Statistics_Historical_Adapter(); 11172 if ($wp_statistics->should_register_adapter()) { $adapters[] = $wp_statistics; } 11173 $post_views_counter = new STATPL_Post_Views_Counter_Historical_Adapter(); 11174 if ($post_views_counter->should_register_adapter()) { $adapters[] = $post_views_counter; } 11175 return $adapters; 11176 }, 31); 11177 11178 /** 11179 * Statify adapter. 11180 * 11181 * Statify stores one immutable row per page view, but its schema contains only 11182 * a calendar date and target URL (not a timestamp or WordPress post ID). The 11183 * adapter resolves same-site targets to published WooCommerce products and 11184 * explicitly skips non-product rows. It does not issue cross-source fingerprints: 11185 * a date-only row cannot be safely matched one-to-one against another provider's 11186 * event stream without risking suppression of legitimate repeat views. 11187 */ 11188 if (!class_exists('STATPL_Statify_Historical_Adapter')) { 11189 final class STATPL_Statify_Historical_Adapter implements STATPL_Historical_View_Adapter { 11190 const ADAPTER_KEY = 'statify'; 11191 const SOURCE_KEY = 'statify'; 11192 const ADAPTER_VERSION = '1.0.0'; 11193 const ORIGIN_KEY = 'page_view_events'; 11194 11195 public function get_adapter_key() { return self::ADAPTER_KEY; } 11196 public function get_source_key() { return self::SOURCE_KEY; } 11197 public function get_display_name() { return 'Statify'; } 11198 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11199 11200 private function table_name() { 11201 global $wpdb; 11202 return $wpdb->prefix . 'statify'; 11203 } 11204 11205 private function quoted_table_name() { 11206 $table = $this->table_name(); 11207 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11208 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11209 } 11210 return '`' . $table . '`'; 11211 } 11212 11213 private function inspect_table() { 11214 global $wpdb; 11215 $table = $this->table_name(); 11216 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11217 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11218 if ($exists !== $table) { return ['status' => 'missing_table']; } 11219 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11220 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11221 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11222 $names = []; 11223 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = (string) $column['Field']; } } 11224 if (array_diff(['id', 'created', 'referrer', 'target'], $names)) { return ['status' => 'incompatible_schema']; } 11225 return ['status' => 'available']; 11226 } 11227 11228 public function should_register_adapter() { 11229 global $wpdb; 11230 $inspection = $this->inspect_table(); 11231 if ($inspection['status'] !== 'missing_table') { return true; } 11232 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11233 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11234 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11235 } 11236 $registry_table = '`' . $registry_name . '`'; 11237 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11238 if (!empty($wpdb->last_error)) { return true; } 11239 if ($registry_exists !== $registry_name) { return false; } 11240 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11241 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11242 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11243 return !empty($wpdb->last_error) || !empty($source_id); 11244 } 11245 11246 public function discover_origins() { 11247 $inspection = $this->inspect_table(); 11248 if ($inspection['status'] === 'missing_table') { return []; } 11249 return [[ 11250 'origin_key' => self::ORIGIN_KEY, 11251 'capabilities' => ['product_views' => true, 'granularity' => 'event_date_only', 'target_requires_product_resolution' => true], 11252 'detection_status' => $inspection['status'], 11253 ]]; 11254 } 11255 11256 public function get_origin_snapshot($origin_key) { 11257 global $wpdb; 11258 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11259 if ($this->inspect_table()['status'] !== 'available') { 11260 throw new RuntimeException('Statify table is unavailable or incompatible.'); 11261 } 11262 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11263 $max_id = statpl_db_get_var('SELECT MAX(id) FROM ' . $this->quoted_table_name()); 11264 if (!empty($wpdb->last_error)) { 11265 throw new RuntimeException('Statify snapshot query failed.'); 11266 } 11267 return [ 11268 'boundary' => ['max_id' => max(0, (int) $max_id)], 11269 'initial_checkpoint' => null, 11270 'captured_at' => current_time('mysql'), 11271 ]; 11272 } 11273 11274 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11275 global $wpdb; 11276 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11277 throw new RuntimeException('Invalid Statify origin or snapshot.'); 11278 } 11279 if ($this->inspect_table()['status'] !== 'available') { 11280 throw new RuntimeException('Statify table became unavailable or incompatible.'); 11281 } 11282 $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1; 11283 if ($max_id < 0) { throw new RuntimeException('Invalid Statify snapshot boundary.'); } 11284 $cursor_id = 0; 11285 if (is_array($checkpoint) && array_key_exists('cursor', $checkpoint)) { 11286 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 11287 } elseif (is_numeric($checkpoint)) { 11288 $cursor_id = absint($checkpoint); 11289 } 11290 $limit = max(1, min(100, absint($limit))); 11291 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11292 $rows = statpl_db_get_results(statpl_db_prepare( 11293 'SELECT id, created, referrer, target 11294 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' 11295 WHERE id > %d AND id <= %d 11296 ORDER BY id ASC 11297 LIMIT %d', 11298 $cursor_id, 11299 $max_id, 11300 $limit + 1 11301 ), ARRAY_A); 11302 if (!empty($wpdb->last_error) || !is_array($rows)) { 11303 throw new RuntimeException('Statify batch query failed.'); 11304 } 11305 $has_more = count($rows) > $limit; 11306 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11307 $next = $cursor_id; 11308 if ($rows) { $last = end($rows); $next = absint($last['id']); } 11309 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11310 } 11311 11312 private function resolve_product_id($target) { 11313 if (!is_string($target) || trim($target) === '' || !function_exists('url_to_postid')) { return 0; } 11314 $target = trim($target); 11315 if (preg_match('#^https?://#i', $target)) { 11316 $url = $target; 11317 } elseif (strpos($target, '//') === 0) { 11318 return 0; 11319 } else { 11320 $url = home_url('/' . ltrim($target, '/')); 11321 } 11322 $target_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); 11323 $home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); 11324 if ($target_host === '' || $home_host === '' || $target_host !== $home_host) { return 0; } 11325 $product_id = absint(url_to_postid($url)); 11326 if (!$product_id || get_post_type($product_id) !== 'product') { return 0; } 11327 return $product_id; 11328 } 11329 11330 public function normalize_record($origin_key, $record) { 11331 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11332 $record_id = isset($record['id']) ? absint($record['id']) : 0; 11333 $record_key = $record_id ? 'statify_view_' . $record_id : ''; 11334 $cursor = $record_id; 11335 if (!$record_id) { return null; } 11336 $date = isset($record['created']) ? (string) $record['created'] : ''; 11337 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 11338 $errors = DateTime::getLastErrors(); 11339 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { 11340 return null; 11341 } 11342 $product_id = $this->resolve_product_id(isset($record['target']) ? (string) $record['target'] : ''); 11343 if (!$product_id) { 11344 return ['skip' => true, 'source_record_key' => $record_key, 'cursor' => $cursor]; 11345 } 11346 $referrer = isset($record['referrer']) ? (string) $record['referrer'] : ''; 11347 return [ 11348 'product_id' => $product_id, 11349 'source_record_key' => $record_key, 11350 'views' => 1, 11351 'record_type' => 'event', 11352 'period_start' => $date . ' 00:00:00', 11353 'period_end' => $date . ' 23:59:59', 11354 'cursor' => $cursor, 11355 'source_referrer_hash' => hash('sha256', $referrer), 11356 ]; 11357 } 11358 11359 public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) { 11360 return [ 11361 'source' => 'statify', 11362 'date_precision' => 'calendar_day_only', 11363 'referrer_sha256' => isset($normalized_record['source_referrer_hash']) ? $normalized_record['source_referrer_hash'] : null, 11364 'overlap_assessment' => 'possible_but_not_proven', 11365 'automatic_suppression' => false, 11366 'reason' => 'Statify has no per-view timestamp or stable cross-provider event identifier; a one-to-one match would risk suppressing legitimate repeat views.', 11367 ]; 11368 } 11369 } 11370 } 11371 11372 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11373 if (!is_array($adapters)) { return $adapters; } 11374 $adapter = new STATPL_Statify_Historical_Adapter(); 11375 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 11376 return $adapters; 11377 }, 32); 11378 11379 /** 11380 * WP Popular Posts adapter. 11381 * 11382 * Imports the per-post/per-day summary table only. The all-time data table is 11383 * deliberately excluded because it overlaps the daily summary and carries no 11384 * date range. WPP's configurable log retention determines how much daily 11385 * history remains available at the time Statora captures its snapshot. 11386 */ 11387 if (!class_exists('STATPL_WP_Popular_Posts_Historical_Adapter')) { 11388 final class STATPL_WP_Popular_Posts_Historical_Adapter implements STATPL_Historical_View_Adapter { 11389 const ADAPTER_KEY = 'wp_popular_posts'; 11390 const SOURCE_KEY = 'wp_popular_posts'; 11391 const ADAPTER_VERSION = '1.0.0'; 11392 const ORIGIN_KEY = 'product_daily_summary'; 11393 11394 public function get_adapter_key() { return self::ADAPTER_KEY; } 11395 public function get_source_key() { return self::SOURCE_KEY; } 11396 public function get_display_name() { return 'WP Popular Posts'; } 11397 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11398 11399 private function table_name() { 11400 global $wpdb; 11401 return $wpdb->prefix . 'popularpostssummary'; 11402 } 11403 11404 private function quoted_table_name() { 11405 $table = $this->table_name(); 11406 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11407 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11408 } 11409 return '`' . $table . '`'; 11410 } 11411 11412 private function inspect_table() { 11413 global $wpdb; 11414 $table = $this->table_name(); 11415 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11416 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11417 if ($exists !== $table) { return ['status' => 'missing_table']; } 11418 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11419 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11420 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11421 $names = []; 11422 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 11423 if (array_diff(['id', 'postid', 'pageviews', 'view_date', 'last_viewed'], $names)) { return ['status' => 'incompatible_schema']; } 11424 return ['status' => 'available']; 11425 } 11426 11427 public function should_register_adapter() { 11428 global $wpdb; 11429 $inspection = $this->inspect_table(); 11430 if ($inspection['status'] !== 'missing_table') { return true; } 11431 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11432 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11433 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11434 } 11435 $registry_table = '`' . $registry_name . '`'; 11436 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11437 if (!empty($wpdb->last_error)) { return true; } 11438 if ($registry_exists !== $registry_name) { return false; } 11439 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11440 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11441 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11442 return !empty($wpdb->last_error) || !empty($source_id); 11443 } 11444 11445 public function discover_origins() { 11446 $inspection = $this->inspect_table(); 11447 if ($inspection['status'] === 'missing_table') { return []; } 11448 return [[ 11449 'origin_key' => self::ORIGIN_KEY, 11450 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true], 11451 'detection_status' => $inspection['status'], 11452 ]]; 11453 } 11454 11455 public function get_origin_snapshot($origin_key) { 11456 global $wpdb; 11457 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11458 if ($this->inspect_table()['status'] !== 'available') { 11459 throw new RuntimeException('WP Popular Posts summary table is unavailable or incompatible.'); 11460 } 11461 $through_date = current_datetime()->modify('-1 day')->format('Y-m-d'); 11462 $posts_table_name = $wpdb->posts; 11463 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11464 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11465 } 11466 $posts_table = '`' . $posts_table_name . '`'; 11467 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11468 $max_id = statpl_db_get_var(statpl_db_prepare( 11469 'SELECT MAX(s.ID) 11470 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 11471 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.postid AND p.post_type = %s 11472 WHERE s.view_date <= %s', 11473 'product', $through_date 11474 )); 11475 if (!empty($wpdb->last_error)) { 11476 throw new RuntimeException('WP Popular Posts snapshot query failed.'); 11477 } 11478 return [ 11479 'boundary' => ['max_id' => max(0, (int) $max_id), 'through_date' => $through_date], 11480 'initial_checkpoint' => null, 11481 'captured_at' => current_time('mysql'), 11482 ]; 11483 } 11484 11485 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11486 global $wpdb; 11487 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11488 throw new RuntimeException('Invalid WP Popular Posts origin or snapshot.'); 11489 } 11490 if ($this->inspect_table()['status'] !== 'available') { 11491 throw new RuntimeException('WP Popular Posts summary table became unavailable or incompatible.'); 11492 } 11493 $boundary = $snapshot['boundary']; 11494 $max_id = isset($boundary['max_id']) ? absint($boundary['max_id']) : -1; 11495 $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : ''; 11496 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 11497 if ($max_id < 0 || !$parsed || $parsed->format('Y-m-d') !== $through_date) { 11498 throw new RuntimeException('Invalid WP Popular Posts snapshot boundary.'); 11499 } 11500 $cursor_id = 0; 11501 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 11502 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 11503 } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); } 11504 $limit = max(1, min(100, absint($limit))); 11505 $posts_table_name = $wpdb->posts; 11506 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11507 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11508 } 11509 $posts_table = '`' . $posts_table_name . '`'; 11510 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11511 $rows = statpl_db_get_results(statpl_db_prepare( 11512 'SELECT s.ID AS id, s.postid, s.pageviews, s.view_date 11513 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 11514 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.postid AND p.post_type = %s 11515 WHERE s.ID > %d AND s.ID <= %d AND s.view_date <= %s 11516 ORDER BY s.ID ASC 11517 LIMIT %d', 11518 'product', $cursor_id, $max_id, $through_date, $limit + 1 11519 ), ARRAY_A); 11520 if (!empty($wpdb->last_error) || !is_array($rows)) { 11521 throw new RuntimeException('WP Popular Posts batch query failed.'); 11522 } 11523 $has_more = count($rows) > $limit; 11524 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11525 $next = $cursor_id; 11526 if ($rows) { $last = end($rows); $next = absint($last['id']); } 11527 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11528 } 11529 11530 public function normalize_record($origin_key, $record) { 11531 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11532 $record_id = isset($record['id']) ? absint($record['id']) : 0; 11533 $product_id = isset($record['postid']) ? absint($record['postid']) : 0; 11534 $views_raw = $record['pageviews'] ?? null; 11535 $date = isset($record['view_date']) ? (string) $record['view_date'] : ''; 11536 if (!$record_id || !$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 11537 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 11538 $errors = DateTime::getLastErrors(); 11539 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 11540 $views = (int) $views_raw; 11541 $period_start = $date . ' 00:00:00'; 11542 $period_end = $date . ' 23:59:59'; 11543 return [ 11544 'product_id' => $product_id, 11545 'source_record_key' => 'wp_popular_posts_' . $product_id . '_' . $date, 11546 'views' => $views, 11547 'record_type' => 'aggregate', 11548 'period_start' => $period_start, 11549 'period_end' => $period_end, 11550 'cursor' => $record_id, 11551 ]; 11552 } 11553 } 11554 } 11555 11556 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11557 if (!is_array($adapters)) { return $adapters; } 11558 $adapter = new STATPL_WP_Popular_Posts_Historical_Adapter(); 11559 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 11560 return $adapters; 11561 }, 33); 11562 11563 /** 11564 * WebberZone Top 10 adapter. 11565 * 11566 * Imports only the per-post hourly rows from top_ten_daily, grouped into local 11567 * calendar-day totals. The all-time table, visits funnel and visits log are not 11568 * imported in parallel: the funnel/log feed the same daily and lifetime counts. 11569 * A two-day safety lag allows Top 10's background aggregation cron to drain 11570 * completed days before the snapshot is taken. 11571 */ 11572 if (!class_exists('STATPL_Top_Ten_Historical_Adapter')) { 11573 final class STATPL_Top_Ten_Historical_Adapter implements STATPL_Historical_View_Adapter { 11574 const ADAPTER_KEY = 'webberzone_top_10'; 11575 const SOURCE_KEY = 'webberzone_top_10'; 11576 const ADAPTER_VERSION = '1.0.0'; 11577 const ORIGIN_KEY = 'product_daily_counts'; 11578 11579 public function get_adapter_key() { return self::ADAPTER_KEY; } 11580 public function get_source_key() { return self::SOURCE_KEY; } 11581 public function get_display_name() { return 'WebberZone Top 10'; } 11582 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11583 11584 private function table_name() { 11585 global $wpdb; 11586 return $wpdb->base_prefix . 'top_ten_daily'; 11587 } 11588 11589 private function quoted_table_name() { 11590 $table = $this->table_name(); 11591 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11592 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11593 } 11594 return '`' . $table . '`'; 11595 } 11596 11597 private function inspect_table() { 11598 global $wpdb; 11599 $table = $this->table_name(); 11600 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11601 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11602 if ($exists !== $table) { return ['status' => 'missing_table']; } 11603 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11604 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11605 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11606 $names = []; 11607 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 11608 if (array_diff(['postnumber', 'cntaccess', 'dp_date', 'blog_id'], $names)) { return ['status' => 'incompatible_schema']; } 11609 return ['status' => 'available', 'has_feed_column' => in_array('feedcnt', $names, true)]; 11610 } 11611 11612 public function should_register_adapter() { 11613 global $wpdb; 11614 $inspection = $this->inspect_table(); 11615 if ($inspection['status'] !== 'missing_table') { return true; } 11616 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11617 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11618 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11619 } 11620 $registry_table = '`' . $registry_name . '`'; 11621 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11622 if (!empty($wpdb->last_error)) { return true; } 11623 if ($registry_exists !== $registry_name) { return false; } 11624 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11625 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11626 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11627 return !empty($wpdb->last_error) || !empty($source_id); 11628 } 11629 11630 public function discover_origins() { 11631 $inspection = $this->inspect_table(); 11632 if ($inspection['status'] === 'missing_table') { return []; } 11633 return [[ 11634 'origin_key' => self::ORIGIN_KEY, 11635 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'source_storage_granularity' => 'hourly', 'immutable_past_days' => true], 11636 'detection_status' => $inspection['status'], 11637 ]]; 11638 } 11639 11640 public function get_origin_snapshot($origin_key) { 11641 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11642 if ($this->inspect_table()['status'] !== 'available') { 11643 throw new RuntimeException('Top 10 daily table is unavailable or incompatible.'); 11644 } 11645 $through_date = current_datetime()->modify('-2 days')->format('Y-m-d'); 11646 return [ 11647 'boundary' => ['through_date' => $through_date, 'blog_id' => absint(get_current_blog_id())], 11648 'initial_checkpoint' => null, 11649 'captured_at' => current_time('mysql'), 11650 ]; 11651 } 11652 11653 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11654 global $wpdb; 11655 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11656 throw new RuntimeException('Invalid Top 10 origin or snapshot.'); 11657 } 11658 if ($this->inspect_table()['status'] !== 'available') { 11659 throw new RuntimeException('Top 10 daily table became unavailable or incompatible.'); 11660 } 11661 $through_date = isset($snapshot['boundary']['through_date']) ? (string) $snapshot['boundary']['through_date'] : ''; 11662 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 11663 $blog_id = isset($snapshot['boundary']['blog_id']) ? absint($snapshot['boundary']['blog_id']) : 0; 11664 if (!$parsed || $parsed->format('Y-m-d') !== $through_date || !$blog_id) { 11665 throw new RuntimeException('Invalid Top 10 snapshot boundary.'); 11666 } 11667 $cursor_date = ''; 11668 $cursor_product = 0; 11669 if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) { 11670 $cursor_date = isset($checkpoint['cursor']['date']) ? (string) $checkpoint['cursor']['date'] : ''; 11671 $cursor_product = isset($checkpoint['cursor']['product_id']) ? absint($checkpoint['cursor']['product_id']) : 0; 11672 } 11673 if ($cursor_date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $cursor_date) || !DateTime::createFromFormat('!Y-m-d', $cursor_date))) { 11674 throw new RuntimeException('Invalid Top 10 cursor date.'); 11675 } 11676 $limit = max(1, min(100, absint($limit))); 11677 $posts_table_name = $wpdb->posts; 11678 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11679 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11680 } 11681 $posts_table = '`' . $posts_table_name . '`'; 11682 $inspection = $this->inspect_table(); 11683 $view_sum_sql = !empty($inspection['has_feed_column']) 11684 ? 'SUM(GREATEST(t.cntaccess - t.feedcnt, 0))' 11685 : 'SUM(t.cntaccess)'; 11686 if (!in_array($view_sum_sql, ['SUM(GREATEST(t.cntaccess - t.feedcnt, 0))', 'SUM(t.cntaccess)'], true)) { 11687 throw new RuntimeException('Unsupported Top 10 view aggregation expression.'); 11688 } 11689 $cursor_sql = ''; 11690 $args = ['product', $blog_id, $through_date]; 11691 if ($cursor_date !== '') { 11692 $cursor_sql = ' HAVING (view_date > %s OR (view_date = %s AND product_id > %d))'; 11693 $args[] = $cursor_date; 11694 $args[] = $cursor_date; 11695 $args[] = $cursor_product; 11696 } 11697 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $view_sum_sql is selected from two fixed aggregate expressions and checked against the same strict allowlist; all runtime values remain prepared placeholders. 11698 $batch_sql = 'SELECT DATE(t.dp_date) AS view_date, t.postnumber AS product_id, ' . $view_sum_sql . ' AS views 11699 FROM ' . $this->quoted_table_name() . ' AS t 11700 INNER JOIN ' . $posts_table . ' AS p ON p.ID = t.postnumber AND p.post_type = %s 11701 WHERE t.blog_id = %d AND t.dp_date < DATE_ADD(%s, INTERVAL 1 DAY) 11702 GROUP BY DATE(t.dp_date), t.postnumber' . $cursor_sql . ' 11703 ORDER BY view_date ASC, product_id ASC 11704 LIMIT %d'; 11705 if ($cursor_date !== '') { 11706 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11707 $rows = statpl_db_get_results(statpl_db_prepare( 11708 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11709 'product', $blog_id, $through_date, 11710 $cursor_date, $cursor_date, $cursor_product, $limit + 1 11711 ), ARRAY_A); 11712 } else { 11713 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11714 $rows = statpl_db_get_results(statpl_db_prepare( 11715 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11716 'product', $blog_id, $through_date, $limit + 1 11717 ), ARRAY_A); 11718 } 11719 if (!empty($wpdb->last_error) || !is_array($rows)) { 11720 throw new RuntimeException('Top 10 batch query failed.'); 11721 } 11722 $has_more = count($rows) > $limit; 11723 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11724 $next = ($cursor_date !== '') ? ['date' => $cursor_date, 'product_id' => $cursor_product] : null; 11725 if ($rows) { $last = end($rows); $next = ['date' => (string) $last['view_date'], 'product_id' => absint($last['product_id'])]; } 11726 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11727 } 11728 11729 public function normalize_record($origin_key, $record) { 11730 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11731 $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0; 11732 $date = isset($record['view_date']) ? (string) $record['view_date'] : ''; 11733 $views_raw = $record['views'] ?? null; 11734 if (!$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 11735 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 11736 $errors = DateTime::getLastErrors(); 11737 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 11738 $views = (int) $views_raw; 11739 $period_start = $date . ' 00:00:00'; 11740 $period_end = $date . ' 23:59:59'; 11741 return [ 11742 'product_id' => $product_id, 11743 'source_record_key' => 'top_10_' . $product_id . '_' . $date, 11744 'views' => $views, 11745 'record_type' => 'aggregate', 11746 'period_start' => $period_start, 11747 'period_end' => $period_end, 11748 'cursor' => ['date' => $date, 'product_id' => $product_id], 11749 ]; 11750 } 11751 } 11752 } 11753 11754 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11755 if (!is_array($adapters)) { return $adapters; } 11756 $adapter = new STATPL_Top_Ten_Historical_Adapter(); 11757 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 11758 return $adapters; 11759 }, 34); 11760 11761 /** 11762 * Koko Analytics adapter. 11763 * 11764 * Reads the per-page daily statistics table. Current Koko schemas use path_id 11765 * and post_id; older schemas used id directly as the WordPress post ID. Rows 11766 * are grouped by product/date because one product can be represented by more 11767 * than one canonicalized path. Only completed days are imported. 11768 */ 11769 if (!class_exists('STATPL_Koko_Analytics_Historical_Adapter')) { 11770 final class STATPL_Koko_Analytics_Historical_Adapter implements STATPL_Historical_View_Adapter { 11771 const ADAPTER_KEY = 'koko_analytics'; 11772 const SOURCE_KEY = 'koko_analytics'; 11773 const ADAPTER_VERSION = '1.0.0'; 11774 const ORIGIN_KEY = 'product_daily_stats'; 11775 11776 public function get_adapter_key() { return self::ADAPTER_KEY; } 11777 public function get_source_key() { return self::SOURCE_KEY; } 11778 public function get_display_name() { return 'Koko Analytics'; } 11779 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11780 11781 private function table_name() { 11782 global $wpdb; 11783 return $wpdb->prefix . 'koko_analytics_post_stats'; 11784 } 11785 11786 private function quoted_table_name() { 11787 $table = $this->table_name(); 11788 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11789 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11790 } 11791 return '`' . $table . '`'; 11792 } 11793 11794 private function inspect_table() { 11795 global $wpdb; 11796 $table = $this->table_name(); 11797 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11798 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11799 if ($exists !== $table) { return ['status' => 'missing_table']; } 11800 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11801 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11802 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11803 $names = []; 11804 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 11805 if (!in_array('date', $names, true) || !in_array('pageviews', $names, true)) { return ['status' => 'incompatible_schema']; } 11806 if (in_array('post_id', $names, true) && in_array('path_id', $names, true)) { 11807 return ['status' => 'available', 'schema_mode' => 'path_id']; 11808 } 11809 if (in_array('id', $names, true)) { 11810 return ['status' => 'available', 'schema_mode' => 'legacy_post_id']; 11811 } 11812 return ['status' => 'incompatible_schema']; 11813 } 11814 11815 public function should_register_adapter() { 11816 global $wpdb; 11817 $inspection = $this->inspect_table(); 11818 if ($inspection['status'] !== 'missing_table') { return true; } 11819 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11820 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11821 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11822 } 11823 $registry_table = '`' . $registry_name . '`'; 11824 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11825 if (!empty($wpdb->last_error)) { return true; } 11826 if ($registry_exists !== $registry_name) { return false; } 11827 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11828 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11829 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11830 return !empty($wpdb->last_error) || !empty($source_id); 11831 } 11832 11833 public function discover_origins() { 11834 $inspection = $this->inspect_table(); 11835 if ($inspection['status'] === 'missing_table') { return []; } 11836 return [[ 11837 'origin_key' => self::ORIGIN_KEY, 11838 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'schema_mode' => isset($inspection['schema_mode']) ? $inspection['schema_mode'] : 'unknown'], 11839 'detection_status' => $inspection['status'], 11840 ]]; 11841 } 11842 11843 public function get_origin_snapshot($origin_key) { 11844 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11845 $inspection = $this->inspect_table(); 11846 if ($inspection['status'] !== 'available') { 11847 throw new RuntimeException('Koko Analytics post statistics table is unavailable or incompatible.'); 11848 } 11849 return [ 11850 'boundary' => [ 11851 'through_date' => current_datetime()->modify('-1 day')->format('Y-m-d'), 11852 'schema_mode' => $inspection['schema_mode'], 11853 ], 11854 'initial_checkpoint' => null, 11855 'captured_at' => current_time('mysql'), 11856 ]; 11857 } 11858 11859 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11860 global $wpdb; 11861 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11862 throw new RuntimeException('Invalid Koko Analytics origin or snapshot.'); 11863 } 11864 $inspection = $this->inspect_table(); 11865 if ($inspection['status'] !== 'available') { 11866 throw new RuntimeException('Koko Analytics post statistics table became unavailable or incompatible.'); 11867 } 11868 $boundary = $snapshot['boundary']; 11869 $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : ''; 11870 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 11871 $schema_mode = isset($boundary['schema_mode']) ? (string) $boundary['schema_mode'] : ''; 11872 if (!$parsed || $parsed->format('Y-m-d') !== $through_date || $schema_mode !== $inspection['schema_mode']) { 11873 throw new RuntimeException('Invalid or changed Koko Analytics snapshot boundary.'); 11874 } 11875 $cursor_date = ''; 11876 $cursor_product = 0; 11877 if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) { 11878 $cursor_date = isset($checkpoint['cursor']['date']) ? (string) $checkpoint['cursor']['date'] : ''; 11879 $cursor_product = isset($checkpoint['cursor']['product_id']) ? absint($checkpoint['cursor']['product_id']) : 0; 11880 } 11881 if ($cursor_date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $cursor_date) || !DateTime::createFromFormat('!Y-m-d', $cursor_date))) { 11882 throw new RuntimeException('Invalid Koko Analytics cursor date.'); 11883 } 11884 $limit = max(1, min(100, absint($limit))); 11885 $posts_table_name = $wpdb->posts; 11886 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11887 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11888 } 11889 $posts_table = '`' . $posts_table_name . '`'; 11890 if (!in_array($schema_mode, ['path_id', 'id'], true)) { 11891 throw new RuntimeException('Unsupported Koko Analytics schema mode.'); 11892 } 11893 $post_id_column = $schema_mode === 'path_id' ? 's.post_id' : 's.id'; 11894 if (!in_array($post_id_column, ['s.post_id', 's.id'], true)) { 11895 throw new RuntimeException('Unsupported Koko Analytics product identifier column.'); 11896 } 11897 $cursor_sql = ''; 11898 $args = ['product', $through_date]; 11899 if ($cursor_date !== '') { 11900 $cursor_sql = ' HAVING (view_date > %s OR (view_date = %s AND product_id > %d))'; 11901 $args[] = $cursor_date; 11902 $args[] = $cursor_date; 11903 $args[] = $cursor_product; 11904 } 11905 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $post_id_column is selected from the fixed allowlist s.post_id/s.id after schema-mode validation; runtime values remain prepared placeholders. 11906 $batch_sql = 'SELECT s.date AS view_date, ' . $post_id_column . ' AS product_id, SUM(s.pageviews) AS views 11907 FROM ' . $this->quoted_table_name() . ' AS s 11908 INNER JOIN ' . $posts_table . ' AS p ON p.ID = ' . $post_id_column . ' AND p.post_type = %s 11909 WHERE s.date <= %s 11910 GROUP BY s.date, ' . $post_id_column . $cursor_sql . ' 11911 ORDER BY view_date ASC, product_id ASC 11912 LIMIT %d'; 11913 if ($cursor_date !== '') { 11914 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11915 $rows = statpl_db_get_results(statpl_db_prepare( 11916 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11917 'product', $through_date, 11918 $cursor_date, $cursor_date, $cursor_product, $limit + 1 11919 ), ARRAY_A); 11920 } else { 11921 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11922 $rows = statpl_db_get_results(statpl_db_prepare( 11923 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11924 'product', $through_date, $limit + 1 11925 ), ARRAY_A); 11926 } 11927 if (!empty($wpdb->last_error) || !is_array($rows)) { 11928 throw new RuntimeException('Koko Analytics batch query failed.'); 11929 } 11930 $has_more = count($rows) > $limit; 11931 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11932 $next = ($cursor_date !== '') ? ['date' => $cursor_date, 'product_id' => $cursor_product] : null; 11933 if ($rows) { $last = end($rows); $next = ['date' => (string) $last['view_date'], 'product_id' => absint($last['product_id'])]; } 11934 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11935 } 11936 11937 public function normalize_record($origin_key, $record) { 11938 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11939 $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0; 11940 $date = isset($record['view_date']) ? (string) $record['view_date'] : ''; 11941 $views_raw = $record['views'] ?? null; 11942 if (!$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 11943 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 11944 $errors = DateTime::getLastErrors(); 11945 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 11946 $views = (int) $views_raw; 11947 $period_start = $date . ' 00:00:00'; 11948 $period_end = $date . ' 23:59:59'; 11949 return [ 11950 'product_id' => $product_id, 11951 'source_record_key' => 'koko_analytics_' . $product_id . '_' . $date, 11952 'views' => $views, 11953 'record_type' => 'aggregate', 11954 'period_start' => $period_start, 11955 'period_end' => $period_end, 11956 'cursor' => ['date' => $date, 'product_id' => $product_id], 11957 ]; 11958 } 11959 } 11960 } 11961 11962 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11963 if (!is_array($adapters)) { return $adapters; } 11964 $adapter = new STATPL_Koko_Analytics_Historical_Adapter(); 11965 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 11966 return $adapters; 11967 }, 35); 11968 11969 /** 11970 * Burst Statistics adapter. 11971 * 11972 * Imports raw product pageview rows from burst_statistics. Unlike aggregate 11973 * providers, Burst stores a WordPress post ID and a Unix timestamp per hit, so 11974 * product resolution is direct and each source row has a stable ID. Visitor 11975 * identifiers and session data are intentionally not read or imported. 11976 */ 11977 if (!class_exists('STATPL_Burst_Statistics_Historical_Adapter')) { 11978 final class STATPL_Burst_Statistics_Historical_Adapter implements STATPL_Historical_View_Adapter { 11979 const ADAPTER_KEY = 'burst_statistics'; 11980 const SOURCE_KEY = 'burst_statistics'; 11981 const ADAPTER_VERSION = '1.0.0'; 11982 const ORIGIN_KEY = 'product_pageview_events'; 11983 11984 public function get_adapter_key() { return self::ADAPTER_KEY; } 11985 public function get_source_key() { return self::SOURCE_KEY; } 11986 public function get_display_name() { return 'Burst Statistics'; } 11987 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11988 11989 private function table_name() { 11990 global $wpdb; 11991 return $wpdb->prefix . 'burst_statistics'; 11992 } 11993 11994 private function quoted_table_name() { 11995 $table = $this->table_name(); 11996 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11997 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11998 } 11999 return '`' . $table . '`'; 12000 } 12001 12002 private function inspect_table() { 12003 global $wpdb; 12004 $table = $this->table_name(); 12005 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 12006 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12007 if ($exists !== $table) { return ['status' => 'missing_table']; } 12008 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12009 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 12010 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12011 $names = []; 12012 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 12013 if (array_diff(['id', 'page_id', 'page_type', 'time'], $names)) { return ['status' => 'incompatible_schema']; } 12014 return ['status' => 'available']; 12015 } 12016 12017 public function should_register_adapter() { 12018 global $wpdb; 12019 $inspection = $this->inspect_table(); 12020 if ($inspection['status'] !== 'missing_table') { return true; } 12021 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 12022 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 12023 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 12024 } 12025 $registry_table = '`' . $registry_name . '`'; 12026 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 12027 if (!empty($wpdb->last_error)) { return true; } 12028 if ($registry_exists !== $registry_name) { return false; } 12029 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 12030 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12031 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 12032 return !empty($wpdb->last_error) || !empty($source_id); 12033 } 12034 12035 public function discover_origins() { 12036 $inspection = $this->inspect_table(); 12037 if ($inspection['status'] === 'missing_table') { return []; } 12038 return [[ 12039 'origin_key' => self::ORIGIN_KEY, 12040 'capabilities' => ['product_views' => true, 'granularity' => 'event', 'timestamp_precision' => 'seconds', 'product_id_direct' => true], 12041 'detection_status' => $inspection['status'], 12042 ]]; 12043 } 12044 12045 public function get_origin_snapshot($origin_key) { 12046 global $wpdb; 12047 if ($origin_key !== self::ORIGIN_KEY) { return []; } 12048 if ($this->inspect_table()['status'] !== 'available') { 12049 throw new RuntimeException('Burst Statistics table is unavailable or incompatible.'); 12050 } 12051 $posts_table_name = $wpdb->posts; 12052 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 12053 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 12054 } 12055 $posts_table = '`' . $posts_table_name . '`'; 12056 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12057 $max_id = statpl_db_get_var(statpl_db_prepare( 12058 'SELECT MAX(s.ID) 12059 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 12060 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.page_id AND p.post_type = %s 12061 WHERE s.page_id > 0 AND s.page_type = %s', 12062 'product', 'product' 12063 )); 12064 if (!empty($wpdb->last_error)) { 12065 throw new RuntimeException('Burst Statistics snapshot query failed.'); 12066 } 12067 return [ 12068 'boundary' => ['max_id' => max(0, (int) $max_id)], 12069 'initial_checkpoint' => null, 12070 'captured_at' => current_time('mysql'), 12071 ]; 12072 } 12073 12074 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 12075 global $wpdb; 12076 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 12077 throw new RuntimeException('Invalid Burst Statistics origin or snapshot.'); 12078 } 12079 if ($this->inspect_table()['status'] !== 'available') { 12080 throw new RuntimeException('Burst Statistics table became unavailable or incompatible.'); 12081 } 12082 $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1; 12083 if ($max_id < 0) { throw new RuntimeException('Invalid Burst Statistics snapshot boundary.'); } 12084 $cursor_id = 0; 12085 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 12086 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 12087 } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); } 12088 $limit = max(1, min(100, absint($limit))); 12089 $posts_table_name = $wpdb->posts; 12090 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 12091 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 12092 } 12093 $posts_table = '`' . $posts_table_name . '`'; 12094 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12095 $rows = statpl_db_get_results(statpl_db_prepare( 12096 'SELECT s.ID AS id, s.page_id, s.time 12097 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 12098 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.page_id AND p.post_type = %s 12099 WHERE s.page_type = %s AND s.page_id > 0 AND s.ID > %d AND s.ID <= %d 12100 ORDER BY s.ID ASC 12101 LIMIT %d', 12102 'product', 'product', $cursor_id, $max_id, $limit + 1 12103 ), ARRAY_A); 12104 if (!empty($wpdb->last_error) || !is_array($rows)) { 12105 throw new RuntimeException('Burst Statistics batch query failed.'); 12106 } 12107 $has_more = count($rows) > $limit; 12108 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 12109 $next = $cursor_id; 12110 if ($rows) { $last = end($rows); $next = absint($last['id']); } 12111 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 12112 } 12113 12114 public function normalize_record($origin_key, $record) { 12115 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 12116 $record_id = isset($record['id']) ? absint($record['id']) : 0; 12117 $product_id = isset($record['page_id']) ? absint($record['page_id']) : 0; 12118 $timestamp = isset($record['time']) ? absint($record['time']) : 0; 12119 if (!$record_id || !$product_id || !$timestamp || !function_exists('wp_date')) { return null; } 12120 $datetime = wp_date('Y-m-d H:i:s', $timestamp); 12121 if (!is_string($datetime) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $datetime)) { return null; } 12122 return [ 12123 'product_id' => $product_id, 12124 'source_record_key' => 'burst_statistic_' . $record_id, 12125 'views' => 1, 12126 'record_type' => 'event', 12127 'period_start' => $datetime, 12128 'period_end' => $datetime, 12129 'cursor' => $record_id, 12130 ]; 12131 } 12132 12133 public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) { 12134 return [ 12135 'source' => 'burst_statistics', 12136 'timestamp_precision' => 'seconds', 12137 'overlap_assessment' => 'possible_but_not_proven', 12138 'automatic_suppression' => false, 12139 'reason' => 'Burst supplies an exact local event timestamp and product ID, but other providers do not share its stable pageview ID or visitor identity.', 12140 ]; 12141 } 12142 } 12143 } 12144 12145 add_filter('statpl_historical_view_adapters', static function ($adapters) { 12146 if (!is_array($adapters)) { return $adapters; } 12147 $adapter = new STATPL_Burst_Statistics_Historical_Adapter(); 12148 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 12149 return $adapters; 12150 }, 36); 12151 12152 /** 12153 * Independent Analytics adapter. 12154 * 12155 * Imports one row per view from independent_analytics_views and resolves the 12156 * resource through independent_analytics_resources.singular_id/post_type. 12157 * Independent Analytics writes viewed_at in UTC. Only the timestamp and the 12158 * product ID are imported; visitor/session identifiers are intentionally not 12159 * accessed. 12160 */ 12161 if (!class_exists('STATPL_Independent_Analytics_Historical_Adapter')) { 12162 final class STATPL_Independent_Analytics_Historical_Adapter implements STATPL_Historical_View_Adapter { 12163 const ADAPTER_KEY = 'independent_analytics'; 12164 const SOURCE_KEY = 'independent_analytics'; 12165 const ADAPTER_VERSION = '1.0.0'; 12166 const ORIGIN_KEY = 'product_view_events'; 12167 12168 public function get_adapter_key() { return self::ADAPTER_KEY; } 12169 public function get_source_key() { return self::SOURCE_KEY; } 12170 public function get_display_name() { return 'Independent Analytics'; } 12171 public function get_adapter_version() { return self::ADAPTER_VERSION; } 12172 12173 private function views_table() { 12174 global $wpdb; 12175 return $wpdb->prefix . 'independent_analytics_views'; 12176 } 12177 12178 private function resources_table() { 12179 global $wpdb; 12180 return $wpdb->prefix . 'independent_analytics_resources'; 12181 } 12182 12183 private function quote_table($table) { 12184 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 12185 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 12186 } 12187 return '`' . $table . '`'; 12188 } 12189 12190 private function inspect_tables() { 12191 global $wpdb; 12192 $found_any = false; 12193 $missing_any = false; 12194 foreach ([$this->views_table() => ['id', 'resource_id', 'viewed_at'], $this->resources_table() => ['id', 'singular_id', 'post_type']] as $table => $required) { 12195 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 12196 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12197 if ($exists !== $table) { 12198 $missing_any = true; 12199 continue; 12200 } 12201 $found_any = true; 12202 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12203 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quote_table($table), ARRAY_A); 12204 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12205 $names = []; 12206 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 12207 if (array_diff($required, $names)) { return ['status' => 'incompatible_schema']; } 12208 } 12209 if (!$found_any) { return ['status' => 'missing_table']; } 12210 if ($missing_any) { return ['status' => 'incompatible_schema']; } 12211 return ['status' => 'available']; 12212 } 12213 12214 public function should_register_adapter() { 12215 global $wpdb; 12216 $inspection = $this->inspect_tables(); 12217 if ($inspection['status'] !== 'missing_table') { return true; } 12218 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 12219 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 12220 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 12221 } 12222 $registry_table = '`' . $registry_name . '`'; 12223 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 12224 if (!empty($wpdb->last_error)) { return true; } 12225 if ($registry_exists !== $registry_name) { return false; } 12226 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 12227 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12228 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 12229 return !empty($wpdb->last_error) || !empty($source_id); 12230 } 12231 12232 public function discover_origins() { 12233 $inspection = $this->inspect_tables(); 12234 if ($inspection['status'] === 'missing_table') { return []; } 12235 return [[ 12236 'origin_key' => self::ORIGIN_KEY, 12237 'capabilities' => ['product_views' => true, 'granularity' => 'event', 'timestamp_precision' => 'seconds', 'product_id_direct' => true], 12238 'detection_status' => $inspection['status'], 12239 ]]; 12240 } 12241 12242 public function get_origin_snapshot($origin_key) { 12243 global $wpdb; 12244 if ($origin_key !== self::ORIGIN_KEY) { return []; } 12245 if ($this->inspect_tables()['status'] !== 'available') { 12246 throw new RuntimeException('Independent Analytics tables are unavailable or incompatible.'); 12247 } 12248 $posts_table = $this->quote_table($wpdb->posts); 12249 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12250 $max_id = statpl_db_get_var(statpl_db_prepare( 12251 'SELECT MAX(v.id) 12252 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->views_table()) . ' AS v 12253 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->resources_table()) . ' AS r ON r.id = v.resource_id 12254 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = r.singular_id AND p.post_type = %s 12255 WHERE r.post_type = %s AND r.singular_id > 0', 12256 'product', 'product' 12257 )); 12258 if (!empty($wpdb->last_error)) { 12259 throw new RuntimeException('Independent Analytics snapshot query failed.'); 12260 } 12261 return [ 12262 'boundary' => ['max_id' => max(0, (int) $max_id)], 12263 'initial_checkpoint' => null, 12264 'captured_at' => current_time('mysql'), 12265 ]; 12266 } 12267 12268 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 12269 global $wpdb; 12270 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 12271 throw new RuntimeException('Invalid Independent Analytics origin or snapshot.'); 12272 } 12273 if ($this->inspect_tables()['status'] !== 'available') { 12274 throw new RuntimeException('Independent Analytics tables became unavailable or incompatible.'); 12275 } 12276 $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1; 12277 if ($max_id < 0) { throw new RuntimeException('Invalid Independent Analytics snapshot boundary.'); } 12278 $cursor_id = 0; 12279 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 12280 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 12281 } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); } 12282 $limit = max(1, min(100, absint($limit))); 12283 $posts_table = $this->quote_table($wpdb->posts); 12284 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12285 $rows = statpl_db_get_results(statpl_db_prepare( 12286 'SELECT v.id, r.singular_id AS product_id, v.viewed_at 12287 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->views_table()) . ' AS v 12288 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->resources_table()) . ' AS r ON r.id = v.resource_id 12289 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = r.singular_id AND p.post_type = %s 12290 WHERE r.post_type = %s AND r.singular_id > 0 AND v.id > %d AND v.id <= %d 12291 ORDER BY v.id ASC 12292 LIMIT %d', 12293 'product', 'product', $cursor_id, $max_id, $limit + 1 12294 ), ARRAY_A); 12295 if (!empty($wpdb->last_error) || !is_array($rows)) { 12296 throw new RuntimeException('Independent Analytics batch query failed.'); 12297 } 12298 $has_more = count($rows) > $limit; 12299 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 12300 $next = $cursor_id; 12301 if ($rows) { $last = end($rows); $next = absint($last['id']); } 12302 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 12303 } 12304 12305 public function normalize_record($origin_key, $record) { 12306 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 12307 $record_id = isset($record['id']) ? absint($record['id']) : 0; 12308 $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0; 12309 $viewed_at = isset($record['viewed_at']) ? trim((string) $record['viewed_at']) : ''; 12310 if (!$record_id || !$product_id || $viewed_at === '') { return null; } 12311 $timestamp = strtotime(str_replace('T', ' ', $viewed_at) . ' UTC'); 12312 if (!$timestamp || !function_exists('wp_date')) { return null; } 12313 $datetime = wp_date('Y-m-d H:i:s', $timestamp); 12314 if (!is_string($datetime) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $datetime)) { return null; } 12315 return [ 12316 'product_id' => $product_id, 12317 'source_record_key' => 'independent_analytics_view_' . $record_id, 12318 'views' => 1, 12319 'record_type' => 'event', 12320 'period_start' => $datetime, 12321 'period_end' => $datetime, 12322 'cursor' => $record_id, 12323 ]; 12324 } 12325 12326 public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) { 12327 return [ 12328 'source' => 'independent_analytics', 12329 'timestamp_precision' => 'seconds', 12330 'overlap_assessment' => 'possible_but_not_proven', 12331 'automatic_suppression' => false, 12332 'reason' => 'Independent Analytics provides a per-view timestamp and product ID, but no stable event identifier shared with other providers.', 12333 ]; 12334 } 12335 } 12336 } 12337 12338 add_filter('statpl_historical_view_adapters', static function ($adapters) { 12339 if (!is_array($adapters)) { return $adapters; } 12340 $adapter = new STATPL_Independent_Analytics_Historical_Adapter(); 12341 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 12342 return $adapters; 12343 }, 37); 12344 12345 /** 12346 * PulseViews adapter. 12347 * 12348 * Uses disjoint date windows: completed daily aggregates through two days ago, 12349 * then non-bot raw events after that boundary. Weekly/monthly rollups and the 12350 * lifetime totals cache are never imported, preventing overlap. PulseViews 12351 * prunes raw events after 90 days but retains its daily aggregate history. 12352 */ 12353 if (!class_exists('STATPL_PulseViews_Historical_Adapter')) { 12354 final class STATPL_PulseViews_Historical_Adapter implements STATPL_Historical_View_Adapter { 12355 const ADAPTER_KEY = 'pulseviews'; 12356 const SOURCE_KEY = 'pulseviews'; 12357 const ADAPTER_VERSION = '1.0.0'; 12358 const DAILY_ORIGIN = 'daily_aggregates'; 12359 const RAW_ORIGIN = 'recent_raw_views'; 12360 private $snapshot_through_date = null; 12361 private $snapshot_captured_at = null; 12362 12363 private function get_snapshot_through_date() { 12364 if ($this->snapshot_through_date === null) { 12365 $this->snapshot_through_date = current_datetime()->modify('-2 days')->format('Y-m-d'); 12366 } 12367 return $this->snapshot_through_date; 12368 } 12369 12370 private function get_snapshot_captured_at() { 12371 if ($this->snapshot_captured_at === null) { 12372 $this->snapshot_captured_at = current_time('mysql'); 12373 } 12374 return $this->snapshot_captured_at; 12375 } 12376 12377 public function get_adapter_key() { return self::ADAPTER_KEY; } 12378 public function get_source_key() { return self::SOURCE_KEY; } 12379 public function get_display_name() { return 'PulseViews'; } 12380 public function get_adapter_version() { return self::ADAPTER_VERSION; } 12381 12382 private function table_name($kind) { 12383 global $wpdb; 12384 if (!in_array($kind, ['daily', 'raw'], true)) { 12385 throw new InvalidArgumentException('Unsupported PulseViews table kind.'); 12386 } 12387 return $wpdb->prefix . ($kind === 'daily' ? 'pulseviews_aggregates' : 'pulseviews_views'); 12388 } 12389 12390 private function quote_table($table) { 12391 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 12392 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 12393 } 12394 return '`' . $table . '`'; 12395 } 12396 12397 private function inspect_table($kind) { 12398 global $wpdb; 12399 $table = $this->table_name($kind); 12400 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 12401 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12402 if ($exists !== $table) { return ['status' => 'missing_table']; } 12403 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12404 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quote_table($table), ARRAY_A); 12405 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12406 $names = []; 12407 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 12408 $required = $kind === 'daily' 12409 ? ['id', 'post_id', 'period_date', 'period_type', 'view_count'] 12410 : ['id', 'post_id', 'viewed_at', 'is_bot']; 12411 if (array_diff($required, $names)) { return ['status' => 'incompatible_schema']; } 12412 return ['status' => 'available']; 12413 } 12414 12415 public function should_register_adapter() { 12416 global $wpdb; 12417 $daily = $this->inspect_table('daily'); 12418 $raw = $this->inspect_table('raw'); 12419 if ($daily['status'] !== 'missing_table' || $raw['status'] !== 'missing_table') { return true; } 12420 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 12421 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 12422 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 12423 } 12424 $registry_table = '`' . $registry_name . '`'; 12425 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 12426 if (!empty($wpdb->last_error)) { return true; } 12427 if ($registry_exists !== $registry_name) { return false; } 12428 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 12429 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12430 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 12431 return !empty($wpdb->last_error) || !empty($source_id); 12432 } 12433 12434 public function discover_origins() { 12435 $origins = []; 12436 $daily = $this->inspect_table('daily'); 12437 if ($daily['status'] !== 'missing_table') { 12438 $origins[] = [ 12439 'origin_key' => self::DAILY_ORIGIN, 12440 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'window' => 'through_two_days_ago'], 12441 'detection_status' => $daily['status'], 12442 ]; 12443 } 12444 $raw = $this->inspect_table('raw'); 12445 if ($raw['status'] !== 'missing_table') { 12446 $origins[] = [ 12447 'origin_key' => self::RAW_ORIGIN, 12448 'capabilities' => ['product_views' => true, 'granularity' => 'event', 'window' => 'after_daily_aggregate_boundary', 'bot_filtered' => true], 12449 'detection_status' => $raw['status'], 12450 ]; 12451 } 12452 return $origins; 12453 } 12454 12455 public function get_origin_snapshot($origin_key) { 12456 global $wpdb; 12457 $through_date = $this->get_snapshot_through_date(); 12458 $posts_table = $this->quote_table($wpdb->posts); 12459 if ($origin_key === self::DAILY_ORIGIN) { 12460 if ($this->inspect_table('daily')['status'] !== 'available') { 12461 throw new RuntimeException('PulseViews daily aggregate table is unavailable or incompatible.'); 12462 } 12463 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12464 $max_id = statpl_db_get_var(statpl_db_prepare( 12465 'SELECT MAX(a.id) 12466 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('daily')) . ' AS a 12467 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = a.post_id AND p.post_type = %s 12468 WHERE a.period_type = %s AND a.period_date <= %s', 12469 'product', 'day', $through_date 12470 )); 12471 if (!empty($wpdb->last_error)) { throw new RuntimeException('PulseViews daily snapshot query failed.'); } 12472 return ['boundary' => ['max_id' => max(0, (int) $max_id), 'through_date' => $through_date], 'initial_checkpoint' => null, 'captured_at' => current_time('mysql')]; 12473 } 12474 if ($origin_key === self::RAW_ORIGIN) { 12475 if ($this->inspect_table('raw')['status'] !== 'available') { 12476 throw new RuntimeException('PulseViews raw views table is unavailable or incompatible.'); 12477 } 12478 $after_datetime = $through_date . ' 23:59:59'; 12479 $captured_at = $this->get_snapshot_captured_at(); 12480 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12481 $max_id = statpl_db_get_var(statpl_db_prepare( 12482 'SELECT MAX(v.id) 12483 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('raw')) . ' AS v 12484 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.post_id AND p.post_type = %s 12485 WHERE v.is_bot = 0 AND v.viewed_at > %s AND v.viewed_at <= %s', 12486 'product', $after_datetime, $captured_at 12487 )); 12488 if (!empty($wpdb->last_error)) { throw new RuntimeException('PulseViews raw snapshot query failed.'); } 12489 return ['boundary' => ['max_id' => max(0, (int) $max_id), 'after_datetime' => $after_datetime, 'captured_at' => $captured_at], 'initial_checkpoint' => null, 'captured_at' => $captured_at]; 12490 } 12491 return []; 12492 } 12493 12494 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 12495 global $wpdb; 12496 if (!is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 12497 throw new RuntimeException('Invalid PulseViews snapshot.'); 12498 } 12499 $cursor_id = 0; 12500 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); } 12501 elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); } 12502 $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1; 12503 if ($max_id < 0) { throw new RuntimeException('Invalid PulseViews snapshot boundary.'); } 12504 $limit = max(1, min(100, absint($limit))); 12505 $posts_table = $this->quote_table($wpdb->posts); 12506 if ($origin_key === self::DAILY_ORIGIN) { 12507 if ($this->inspect_table('daily')['status'] !== 'available') { throw new RuntimeException('PulseViews daily aggregate table became unavailable.'); } 12508 $through_date = isset($snapshot['boundary']['through_date']) ? (string) $snapshot['boundary']['through_date'] : ''; 12509 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 12510 if (!$parsed || $parsed->format('Y-m-d') !== $through_date) { throw new RuntimeException('Invalid PulseViews daily boundary.'); } 12511 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12512 $rows = statpl_db_get_results(statpl_db_prepare( 12513 'SELECT a.id, a.post_id, a.period_date, a.view_count 12514 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('daily')) . ' AS a 12515 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = a.post_id AND p.post_type = %s 12516 WHERE a.period_type = %s AND a.id > %d AND a.id <= %d AND a.period_date <= %s 12517 ORDER BY a.id ASC 12518 LIMIT %d', 12519 'product', 'day', $cursor_id, $max_id, $through_date, $limit + 1 12520 ), ARRAY_A); 12521 } elseif ($origin_key === self::RAW_ORIGIN) { 12522 if ($this->inspect_table('raw')['status'] !== 'available') { throw new RuntimeException('PulseViews raw views table became unavailable.'); } 12523 $after_datetime = isset($snapshot['boundary']['after_datetime']) ? (string) $snapshot['boundary']['after_datetime'] : ''; 12524 $captured_at = isset($snapshot['boundary']['captured_at']) ? (string) $snapshot['boundary']['captured_at'] : ''; 12525 if ($after_datetime === '' || $captured_at === '') { throw new RuntimeException('Invalid PulseViews raw boundary.'); } 12526 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12527 $rows = statpl_db_get_results(statpl_db_prepare( 12528 'SELECT v.id, v.post_id, v.viewed_at 12529 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('raw')) . ' AS v 12530 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.post_id AND p.post_type = %s 12531 WHERE v.is_bot = 0 AND v.id > %d AND v.id <= %d AND v.viewed_at > %s AND v.viewed_at <= %s 12532 ORDER BY v.id ASC 12533 LIMIT %d', 12534 'product', $cursor_id, $max_id, $after_datetime, $captured_at, $limit + 1 12535 ), ARRAY_A); 12536 } else { 12537 throw new RuntimeException('Unknown PulseViews origin.'); 12538 } 12539 if (!empty($wpdb->last_error) || !is_array($rows)) { throw new RuntimeException('PulseViews batch query failed.'); } 12540 $has_more = count($rows) > $limit; 12541 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 12542 $next = $cursor_id; 12543 if ($rows) { $last = end($rows); $next = absint($last['id']); } 12544 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 12545 } 12546 12547 public function normalize_record($origin_key, $record) { 12548 if (!is_array($record)) { return null; } 12549 $record_id = isset($record['id']) ? absint($record['id']) : 0; 12550 $product_id = isset($record['post_id']) ? absint($record['post_id']) : 0; 12551 if (!$record_id || !$product_id) { return null; } 12552 if ($origin_key === self::DAILY_ORIGIN) { 12553 $date = isset($record['period_date']) ? (string) $record['period_date'] : ''; 12554 $views_raw = $record['view_count'] ?? null; 12555 if (!preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 12556 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 12557 $errors = DateTime::getLastErrors(); 12558 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 12559 $views = (int) $views_raw; 12560 $period_start = $date . ' 00:00:00'; 12561 $period_end = $date . ' 23:59:59'; 12562 return ['product_id' => $product_id, 'source_record_key' => 'pulseviews_day_' . $product_id . '_' . $date, 'views' => $views, 'record_type' => 'aggregate', 'period_start' => $period_start, 'period_end' => $period_end, 'cursor' => $record_id]; 12563 } 12564 if ($origin_key === self::RAW_ORIGIN) { 12565 $viewed_at = isset($record['viewed_at']) ? (string) $record['viewed_at'] : ''; 12566 $parsed = DateTime::createFromFormat('!Y-m-d H:i:s', $viewed_at); 12567 $errors = DateTime::getLastErrors(); 12568 if (!$parsed || $parsed->format('Y-m-d H:i:s') !== $viewed_at || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 12569 return ['product_id' => $product_id, 'source_record_key' => 'pulseviews_view_' . $record_id, 'views' => 1, 'record_type' => 'event', 'period_start' => $viewed_at, 'period_end' => $viewed_at, 'cursor' => $record_id]; 12570 } 12571 return null; 12572 } 12573 12574 public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) { 12575 return [ 12576 'source' => 'pulseviews', 12577 'origin' => $origin_key, 12578 'overlap_assessment' => 'possible_but_not_proven', 12579 'automatic_suppression' => false, 12580 'reason' => 'PulseViews event rows have local timestamps but no stable cross-provider event identifier; daily aggregates are matched only on exact product/date/count dimensions.', 12581 ]; 12582 } 12583 } 12584 } 12585 12586 add_filter('statpl_historical_view_adapters', static function ($adapters) { 12587 if (!is_array($adapters)) { return $adapters; } 12588 $adapter = new STATPL_PulseViews_Historical_Adapter(); 12589 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 12590 return $adapters; 12591 }, 38); -
statora-plus/trunk/readme.txt
r3790156 r3795175 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 0.11.4 67 Stable tag: 0.11.47 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 82 82 == Changelog == 83 83 84 = 0.11.47 = 85 * Harden historical product-view source queries and validate adapter schema/table selections. 86 * Use prepared integer limits and WordPress timezone-aware retry timestamps. 87 * Use WordPress URL parsing for Statify source URLs. 88 84 89 = 0.11.46 = 85 90 * Place narrowly scoped Plugin Check security-rule annotations on the database-call lines that use constructor-generated table identifiers. -
statora-plus/trunk/statora-plus.php
r3788367 r3795175 3 3 * Plugin Name: Statora Plus 4 4 * Description: View key WooCommerce product statistics in one clear dashboard. 5 * Version: 0.11.4 65 * Version: 0.11.47 6 6 * Author: Panagiotis Pavlidis 7 7 * Author URI: https://gr.linkedin.com/in/frozenfiregr … … 16 16 if (!defined('ABSPATH')) { exit; } 17 17 18 19 /** 20 * Audited wpdb boundary for Statora SQL. 21 * 22 * All runtime data values must already be passed through statpl_db_prepare() 23 * with placeholders. Table/column identifiers are not SQL values and must be 24 * generated from the WordPress prefix plus fixed plugin/provider suffixes, or 25 * validated against a strict allowlist before reaching this boundary. 26 * 27 * These wrappers keep direct wpdb access in one reviewable location. They do 28 * not add caching: importer transactions, locks, schema checks and live metric 29 * reads require their existing consistency and freshness semantics. 30 */ 31 if (!function_exists('statpl_db_prepare')) { 32 function statpl_db_prepare($query, ...$args) { 33 global $wpdb; 34 if (!is_object($wpdb) || !method_exists($wpdb, 'prepare') || !is_string($query)) { 35 return false; 36 } 37 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Centralized preparation boundary; callers provide fixed SQL structure, validated identifiers and runtime values as placeholders. 38 return call_user_func_array(array($wpdb, 'prepare'), array_merge(array($query), $args)); 39 } 40 } 41 42 if (!function_exists('statpl_db_query')) { 43 function statpl_db_query($query) { 44 global $wpdb; 45 if (!is_object($wpdb) || !method_exists($wpdb, 'query') || !is_string($query)) { 46 return false; 47 } 48 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized execution boundary; query strings are prepared by statpl_db_prepare() or consist solely of fixed DDL/SQL and validated identifiers. 49 return $wpdb->query($query); 50 } 51 } 52 53 if (!function_exists('statpl_db_get_var')) { 54 function statpl_db_get_var($query, ...$args) { 55 global $wpdb; 56 if (!is_object($wpdb) || !method_exists($wpdb, 'get_var') || !is_string($query)) { 57 return null; 58 } 59 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers. 60 return $wpdb->get_var($query, ...$args); 61 } 62 } 63 64 if (!function_exists('statpl_db_get_row')) { 65 function statpl_db_get_row($query, ...$args) { 66 global $wpdb; 67 if (!is_object($wpdb) || !method_exists($wpdb, 'get_row') || !is_string($query)) { 68 return null; 69 } 70 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers. 71 return $wpdb->get_row($query, ...$args); 72 } 73 } 74 75 if (!function_exists('statpl_db_get_results')) { 76 function statpl_db_get_results($query, ...$args) { 77 global $wpdb; 78 if (!is_object($wpdb) || !method_exists($wpdb, 'get_results') || !is_string($query)) { 79 return null; 80 } 81 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers. 82 return $wpdb->get_results($query, ...$args); 83 } 84 } 85 86 if (!function_exists('statpl_db_get_col')) { 87 function statpl_db_get_col($query, ...$args) { 88 global $wpdb; 89 if (!is_object($wpdb) || !method_exists($wpdb, 'get_col') || !is_string($query)) { 90 return null; 91 } 92 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers. 93 return $wpdb->get_col($query, ...$args); 94 } 95 } 96 97 98 if (!function_exists('statpl_db_update')) { 99 function statpl_db_update($table, $data, $where, $format = null, $where_format = null) { 100 global $wpdb; 101 if (!is_object($wpdb) || !method_exists($wpdb, 'update')) { 102 return false; 103 } 104 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and data values use wpdb format handling. 105 return $wpdb->update($table, $data, $where, $format, $where_format); 106 } 107 } 108 109 if (!function_exists('statpl_db_insert')) { 110 function statpl_db_insert($table, $data, $format = null) { 111 global $wpdb; 112 if (!is_object($wpdb) || !method_exists($wpdb, 'insert')) { 113 return false; 114 } 115 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and data values use wpdb format handling. 116 return $wpdb->insert($table, $data, $format); 117 } 118 } 119 120 if (!function_exists('statpl_db_delete')) { 121 function statpl_db_delete($table, $where, $where_format = null) { 122 global $wpdb; 123 if (!is_object($wpdb) || !method_exists($wpdb, 'delete')) { 124 return false; 125 } 126 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and values use wpdb format handling. 127 return $wpdb->delete($table, $where, $where_format); 128 } 129 } 130 131 /** 132 * Contract implemented by source-specific historical product-view adapters. 133 * Adapters are supplied through the `statpl_historical_view_adapters` filter. 134 * 135 * `discover_origins()` must return only historical product-view origins; general 136 * page views, visits and non-product content must never be advertised here. 137 * `get_origin_snapshot()` must return an immutable, non-empty `boundary` descriptor. 138 * Every `get_product_view_batch()` result must include `boundary_applied => true`; 139 * the adapter must enforce that snapshot boundary in its source query, return no 140 * more than the requested limit, and advance `next_checkpoint` when `has_more` is true. 141 * `normalize_record()` may explicitly return `['skip' => true, 'source_record_key' => ..., 'cursor' => ...]` 142 * for a valid source row that is outside product scope. The core validates the key/cursor, 143 * clears any stale per-row failure, advances the batch cursor, and does not create an event 144 * or failure-ledger entry. Invalid product records must still return null and follow retries. 145 * 146 * Optional adapter extension: `get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event)`. 147 * It may return a small, JSON-serializable evidence array describing source-specific 148 * overlap signals. Its output is audit evidence only: it cannot suppress counts or 149 * alter the generic import decision. Automatic suppression remains limited to the 150 * generic exact-match rule requiring a shared fingerprint and identical dimensions. 151 */ 152 if (!interface_exists('STATPL_Historical_View_Adapter')) { 153 interface STATPL_Historical_View_Adapter { 154 public function get_adapter_key(); 155 public function get_source_key(); 156 public function get_display_name(); 157 public function get_adapter_version(); 158 public function discover_origins(); 159 public function get_origin_snapshot($origin_key); 160 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit); 161 public function normalize_record($origin_key, $record); 162 } 163 } 164 18 165 final class STATPL_Core { 19 const VERSION = '0.11.46'; 166 const VERSION = '0.11.47'; 167 const VIEW_IMPORT_SCHEMA_VERSION = '1.0.0'; 168 const VIEW_IMPORT_STATE_SCHEMA_VERSION = '1.1.0'; 169 const VIEW_IMPORT_EVENTS_SCHEMA_VERSION = '1.3.0'; 170 const VIEW_IMPORT_FAILURES_SCHEMA_VERSION = '1.3.0'; 171 const VIEW_SOURCE_SCHEMA_VERSION = '1.3.4'; 172 const HISTORY_REPORT_SCHEMA_VERSION = '1.0.0'; 20 173 const NONCE_ACTION = 'statpl_record_view'; 21 174 private static $instance; … … 24 177 private $order_events_table; 25 178 private $order_items_table; 26 27 public static function instance() { 179 private $view_imports_table; 180 private $view_import_failures_table; 181 182 private $view_import_state_table; 183 private $view_import_events_table; 184 private $view_sources_table; 185 private $view_source_origins_table; 186 private $view_source_state_table; 187 private $view_source_events_table; 188 private $view_source_failures_table; 189 private $view_source_reconciliation_table; 190 private $view_source_reconciliation_history_table; 191 private $historical_import_report_table; 192 private $historical_view_adapters = []; 193 public static function instance() { 28 194 if (!self::$instance) self::$instance = new self(); 29 195 return self::$instance; … … 35 201 $this->order_events_table = $wpdb->prefix . 'statpl_product_metric_events'; 36 202 $this->order_items_table = $wpdb->prefix . 'statpl_order_product_snapshot'; 203 $this->view_imports_table = $wpdb->prefix . 'statpl_view_imports'; 204 $this->view_import_state_table = $wpdb->prefix . 'statpl_view_import_state'; 205 $this->view_import_events_table = $wpdb->prefix . 'statpl_view_import_events'; 206 $this->view_import_failures_table = $wpdb->prefix . 'statpl_view_import_failures'; 207 $this->view_sources_table = $wpdb->prefix . 'statpl_view_sources'; 208 $this->view_source_origins_table = $wpdb->prefix . 'statpl_view_source_origins'; 209 $this->view_source_state_table = $wpdb->prefix . 'statpl_view_source_state'; 210 $this->view_source_events_table = $wpdb->prefix . 'statpl_view_source_events'; 211 $this->view_source_failures_table = $wpdb->prefix . 'statpl_view_source_failures'; 212 $this->view_source_reconciliation_table = $wpdb->prefix . 'statpl_view_source_reconciliation'; 213 $this->view_source_reconciliation_history_table = $wpdb->prefix . 'statpl_view_source_reconciliation_history'; 214 $this->historical_import_report_table = $wpdb->prefix . 'statpl_historical_import_report'; 37 215 register_activation_hook(__FILE__, [$this, 'activate']); 216 register_deactivation_hook(__FILE__, [$this, 'deactivate']); 217 add_action('admin_post_statpl_prepare_deactivation', [$this, 'prepare_deactivation']); 38 218 add_action('plugins_loaded', [$this, 'init']); 39 219 } 220 /** 221 * Intercept the Plugins-screen deactivation action and persist the explicit 222 * administrator choice before WordPress invokes the deactivation hook. 223 */ 224 public function prepare_deactivation() { 225 if (!current_user_can('activate_plugins')) { 226 wp_die(esc_html__('Δεν έχεις δικαίωμα εκτέλεσης αυτής της ενέργειας.', 'statora-plus')); 227 } 228 check_admin_referer('statpl_prepare_deactivation'); 229 230 $choice = isset($_GET['choice']) ? sanitize_key(wp_unslash($_GET['choice'])) : ''; 231 if (!in_array($choice, ['retain', 'delete'], true)) { 232 wp_die(esc_html__('Μη έγκυρη επιλογή διατήρησης δεδομένων.', 'statora-plus')); 233 } 234 235 $redirect = isset($_GET['redirect_to']) ? esc_url_raw(wp_unslash($_GET['redirect_to'])) : ''; 236 $parts = wp_parse_url($redirect); 237 $query = []; 238 if (!is_array($parts) || empty($parts['path']) || basename($parts['path']) !== 'plugins.php') { 239 wp_die(esc_html__('Μη έγκυρος προορισμός απενεργοποίησης.', 'statora-plus')); 240 } 241 if (!empty($parts['query'])) { 242 parse_str($parts['query'], $query); 243 } 244 if (($query['action'] ?? '') !== 'deactivate' || ($query['plugin'] ?? '') !== plugin_basename(__FILE__)) { 245 wp_die(esc_html__('Η ενέργεια δεν αφορά το Statora Plus.', 'statora-plus')); 246 } 247 248 update_option('statpl_deactivation_choice', $choice, false); 249 wp_safe_redirect($redirect); 250 exit; 251 } 252 253 /** Stop Statora-owned workers and either retain or remove Statora-owned data. */ 254 public function deactivate() { 255 global $wpdb; 256 257 $choice = (string) get_option('statpl_deactivation_choice', ''); 258 delete_option('statpl_deactivation_choice'); 259 260 $hooks = [ 261 'statpl_import_batch', 262 'statpl_view_import_batch', 263 'statpl_generic_view_import_batch', 264 'statpl_generic_view_snapshot_retry', 265 'statpl_generic_view_retry_failed_records', 266 'statpl_sync_order', 267 'statpl_reconcile_legacy_aggregate_fingerprints', 268 ]; 269 foreach ($hooks as $hook) { 270 wp_clear_scheduled_hook($hook); 271 if (function_exists('as_unschedule_all_actions')) { 272 as_unschedule_all_actions($hook, null, 'statpl-plus'); 273 } 274 } 275 276 if ($choice === 'retain') { 277 update_option('statpl_retained_deactivation_at', current_time('mysql'), false); 278 update_option('statpl_data_lifecycle_state', 'retained', false); 279 delete_transient('statpl_order_stats_v1'); 280 delete_transient('statpl_global_summary_v2'); 281 return; 282 } 283 284 // A missing/invalid choice must never silently destroy data. 285 if ($choice !== 'delete') { 286 update_option('statpl_deactivation_choice_missing', '1', false); 287 return; 288 } 289 290 $tables = [ 291 $this->views_table, 292 $this->events_table, 293 $this->order_events_table, 294 $this->order_items_table, 295 $this->view_imports_table, 296 $this->view_import_state_table, 297 $this->view_import_events_table, 298 $this->view_import_failures_table, 299 $this->view_sources_table, 300 $this->view_source_origins_table, 301 $this->view_source_state_table, 302 $this->view_source_events_table, 303 $this->view_source_failures_table, 304 $this->view_source_reconciliation_table, 305 $this->view_source_reconciliation_history_table, 306 $this->historical_import_report_table, 307 ]; 308 foreach ($tables as $table) { 309 // Table names are built exclusively from the active WordPress prefix 310 // and fixed Statora suffixes declared in the constructor. Validate the 311 // complete identifier before placing it in a DDL statement; identifiers 312 // cannot be supplied as ordinary prepared-statement value placeholders. 313 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 314 continue; 315 } 316 $quoted_table = '`' . $table . '`'; 317 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL identifiers cannot use value placeholders; the identifier is constrained to ASCII letters, digits and underscores and is quoted as one identifier. 318 statpl_db_query('DROP TABLE IF EXISTS ' . $quoted_table); 319 } 320 321 // Remove only Statora's reserved option/transient namespaces. 322 $option_like = $wpdb->esc_like('statpl_') . '%'; 323 statpl_db_query(statpl_db_prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $option_like)); 324 $transient_like = $wpdb->esc_like('_transient_statpl_') . '%'; 325 $timeout_like = $wpdb->esc_like('_transient_timeout_statpl_') . '%'; 326 statpl_db_query(statpl_db_prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $transient_like, $timeout_like)); 327 if (function_exists('wp_cache_flush_group')) { 328 wp_cache_flush_group('statpl_plus'); 329 } 330 } 331 332 /** Rebuild the historical import window after an explicit retain/reactivate cycle. */ 333 private function resume_retained_history_gap() { 334 global $wpdb; 335 336 $deactivated_at = (string) get_option('statpl_retained_deactivation_at', ''); 337 if ($deactivated_at === '') { 338 return ['status' => 'not_required']; 339 } 340 341 $lock = $this->acquire_generic_advisory_lock('statpl_generic_adapter_discovery'); 342 if (empty($lock['acquired'])) { 343 return ['status' => 'lock_unavailable']; 344 } 345 346 $reset = 0; 347 $failed = 0; 348 $scheduled_origins = []; 349 try { 350 $origins = statpl_db_get_results( 351 "SELECT source_id, origin_key, adapter_key FROM {$this->view_source_origins_table} WHERE detection_status = 'available'", 352 ARRAY_A 353 ); 354 if (!empty($wpdb->last_error) || !is_array($origins)) { 355 return ['status' => 'database_error']; 356 } 357 358 foreach ($origins as $origin) { 359 if (!$this->generic_advisory_lock_session_is_current($lock)) { 360 return ['status' => 'lock_session_lost', 'reset' => $reset, 'failed' => $failed]; 361 } 362 $adapter = $this->get_historical_view_adapter((string) $origin['adapter_key']); 363 if (!$adapter) { 364 $failed++; 365 continue; 366 } 367 try { 368 $snapshot = $adapter->get_origin_snapshot((string) $origin['origin_key']); 369 } catch (Throwable $exception) { 370 $failed++; 371 continue; 372 } 373 if (!$this->generic_advisory_lock_session_is_current($lock)) { 374 return ['status' => 'lock_session_lost', 'reset' => $reset, 'failed' => $failed]; 375 } 376 if (!is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 377 $failed++; 378 continue; 379 } 380 if (!isset($snapshot['captured_at'])) { 381 $snapshot['captured_at'] = current_time('mysql'); 382 } 383 $initial = array_key_exists('initial_checkpoint', $snapshot) ? $snapshot['initial_checkpoint'] : null; 384 if ($initial !== null && !$this->is_valid_generic_historical_cursor($initial)) { 385 $failed++; 386 continue; 387 } 388 unset($snapshot['initial_checkpoint']); 389 $saved = $this->update_view_source_origin_state( 390 (int) $origin['source_id'], 391 (string) $origin['origin_key'], 392 [ 393 'snapshot_data' => $snapshot, 394 'checkpoint_data' => ['cursor' => $initial, 'processed' => 0, 'snapshot_captured_at' => $snapshot['captured_at']], 395 'status' => 'pending', 396 'last_error' => null, 397 'started_at' => null, 398 'completed_at' => null, 399 ], 400 $lock 401 ); 402 if (empty($saved['status']) || $saved['status'] !== 'updated') { 403 $failed++; 404 continue; 405 } 406 $reset++; 407 $scheduled_origins[] = [(int) $origin['source_id'], (string) $origin['origin_key']]; 408 } 409 } finally { 410 $this->release_generic_advisory_lock($lock, 'retained_history_gap'); 411 } 412 413 foreach ($scheduled_origins as $scheduled_origin) { 414 $this->schedule_generic_historical_view_import_batch(15, $scheduled_origin[0], $scheduled_origin[1]); 415 } 416 417 // SlimStat uses a separate importer and event ledger. Rewind its cursor to 418 // the beginning while extending both source boundaries; its unique event 419 // ledger prevents already-applied records from being counted twice. 420 $slimstat_state = statpl_db_get_var(statpl_db_prepare( 421 "SELECT source FROM {$this->view_import_state_table} WHERE source = %s", 422 'slimstat' 423 )); 424 if ($slimstat_state === 'slimstat') { 425 $snapshot = $this->get_slimstat_view_snapshot(); 426 if (($snapshot['status'] ?? '') === 'available') { 427 $updated = statpl_db_update( 428 $this->view_import_state_table, 429 [ 430 'snapshot_max_id' => max((int) $snapshot['snapshot']['active'], (int) $snapshot['snapshot']['archive']), 431 'snapshot_active_max_id' => (int) $snapshot['snapshot']['active'], 432 'snapshot_archive_max_id' => (int) $snapshot['snapshot']['archive'], 433 'last_processed_id' => 0, 434 'status' => 'pending', 435 'started_at' => null, 436 'completed_at' => null, 437 'updated_at' => current_time('mysql'), 438 ], 439 ['source' => 'slimstat'], 440 ['%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s'], 441 ['%s'] 442 ); 443 if ($updated === false) { 444 $failed++; 445 } 446 } else { 447 $failed++; 448 } 449 } 450 451 if ($failed === 0) { 452 delete_option('statpl_retained_deactivation_at'); 453 update_option('statpl_data_lifecycle_state', 'active', false); 454 } 455 return ['status' => $failed === 0 ? 'reset' : 'partial', 'reset' => $reset, 'failed' => $failed]; 456 } 457 40 458 public function activate() { 41 459 global $wpdb; … … 47 465 total_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 48 466 PRIMARY KEY (product_id) 467 ) $collate;"); 468 dbDelta("CREATE TABLE {$this->view_imports_table} ( 469 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 470 source VARCHAR(50) NOT NULL, 471 product_id BIGINT(20) UNSIGNED NOT NULL, 472 imported_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 473 imported_at DATETIME NOT NULL, 474 PRIMARY KEY (id), 475 UNIQUE KEY source_product (source, product_id), 476 KEY imported_at (imported_at) 49 477 ) $collate;"); 50 478 dbDelta("CREATE TABLE {$this->events_table} ( … … 59 487 // On a fresh install, avoid issuing SHOW INDEX against a table that has not yet been created. 60 488 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema detection is required before the conditional index migration; the identifier is built from the WordPress prefix and a fixed suffix. 61 $events_table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->order_events_table)));489 $events_table_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->order_events_table))); 62 490 if ($events_table_exists === $this->order_events_table) { 63 491 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this conditional migration checks the obsolete index. 64 $old_index = $wpdb->get_var($wpdb->prepare("SHOW INDEX FROM {$this->order_events_table} WHERE Key_name = %s", 'order_product_event'));492 $old_index = statpl_db_get_var(statpl_db_prepare("SHOW INDEX FROM {$this->order_events_table} WHERE Key_name = %s", 'order_product_event')); 65 493 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this conditional migration removes only the obsolete index. 66 if ($old_index) { $wpdb->query("ALTER TABLE {$this->order_events_table} DROP INDEX order_product_event"); }494 if ($old_index) { statpl_db_query("ALTER TABLE {$this->order_events_table} DROP INDEX order_product_event"); } 67 495 } 68 496 dbDelta("CREATE TABLE {$this->order_events_table} ( … … 94 522 KEY status_created (order_status, created_at) 95 523 ) $collate;"); 96 // Rebuild order snapshots once after an upgrade so completed_at and refund attribution 97 // are populated consistently for orders already present in the store. 524 // dbDelta can fail without throwing (for example during a transient 525 // database outage). Verify the core table structures before advancing 526 // the global DB version; otherwise a partial install would be treated as 527 // current and would not be retried on the next request. 528 $required_core_columns = [ 529 $this->views_table => ['product_id', 'total_views'], 530 $this->view_imports_table => ['id', 'source', 'product_id', 'imported_views', 'imported_at'], 531 $this->events_table => ['id', 'product_id', 'viewed_at'], 532 $this->order_events_table => ['id', 'order_id', 'product_id', 'event_type', 'event_ref_id', 'event_at'], 533 $this->order_items_table => ['order_id', 'product_id', 'order_status', 'created_at', 'paid_at', 'completed_at', 'quantity', 'net_quantity', 'net_revenue', 'has_refund'], 534 ]; 535 foreach ($required_core_columns as $table => $columns) { 536 if (!$this->verify_historical_table_columns($table, $columns)) { 537 return false; 538 } 539 } 540 541 // Upgrade side effects must run only after every core table has been 542 // verified. Previously these resets happened before verification, so a 543 // partial dbDelta/database outage could repeatedly rewind an in-progress 544 // order import even though the schema upgrade had not succeeded. 98 545 if ($previous_version !== '' && version_compare($previous_version, self::VERSION, '<')) { 99 546 update_option('statpl_import_complete', 0, false); … … 102 549 delete_transient('statpl_global_summary_v2'); 103 550 } 551 104 552 update_option('statpl_db_version', self::VERSION); 105 } 106 public function init() { 107 if (get_option('statpl_db_version') !== self::VERSION) { 108 $this->activate(); 109 } 553 if ((string) get_option('statpl_db_version', '') !== self::VERSION) { 554 return false; 555 } 556 return true; 557 } 558 /** Persist a schema marker and verify the stored value before proceeding. */ 559 private function persist_schema_version_marker($option_name, $version) { 560 update_option($option_name, $version, false); 561 562 // update_option() can return false for either an unchanged value or a failed write. 563 // The strict read-back is the authoritative persistence check. 564 return get_option($option_name, '') === $version; 565 } 566 567 /** Create the durable, privacy-safe summary ledger for historical import outcomes. */ 568 private function maybe_upgrade_historical_import_report_schema() { 569 global $wpdb; 570 if (get_option('statpl_history_report_schema_version', '') === self::HISTORY_REPORT_SCHEMA_VERSION) { 571 return true; 572 } 573 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 574 $collate = $wpdb->get_charset_collate(); 575 dbDelta("CREATE TABLE {$this->historical_import_report_table} ( 576 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 577 source_key VARCHAR(100) NOT NULL, 578 source_name VARCHAR(191) NOT NULL, 579 origin_key VARCHAR(100) NOT NULL DEFAULT '', 580 source_record_hash CHAR(64) NOT NULL, 581 outcome VARCHAR(20) NOT NULL DEFAULT 'failed', 582 attempts INT(10) UNSIGNED NOT NULL DEFAULT 0, 583 reason_code VARCHAR(100) NOT NULL DEFAULT '', 584 updated_at DATETIME NOT NULL, 585 PRIMARY KEY (id), 586 UNIQUE KEY source_record (source_key, source_record_hash), 587 KEY source_outcome (source_key, outcome), 588 KEY outcome (outcome) 589 ) $collate;"); 590 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->historical_import_report_table))); 591 if ($exists !== $this->historical_import_report_table || !$this->verify_historical_table_columns($this->historical_import_report_table, ['id','source_key','source_name','origin_key','source_record_hash','outcome','attempts','reason_code','updated_at'])) { 592 return false; 593 } 594 // Preserve visibility of records imported before this report ledger existed. 595 statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table} 596 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 597 SELECT s.source_key, s.display_name, '', e.source_record_hash, 'imported', 0, '', e.updated_at 598 FROM {$this->view_source_events_table} e 599 INNER JOIN {$this->view_sources_table} s ON s.id = e.source_id"); 600 if (!empty($wpdb->last_error)) { return false; } 601 statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table} 602 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 603 SELECT s.source_key, s.display_name, f.origin_key, f.source_record_hash, IF(f.status = 'retry', 'retry', 'failed'), f.attempts, 'record_import_failed', f.updated_at 604 FROM {$this->view_source_failures_table} f 605 INNER JOIN {$this->view_sources_table} s ON s.id = f.source_id"); 606 if (!empty($wpdb->last_error)) { return false; } 607 statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table} 608 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 609 SELECT 'slimstat', 'SlimStat', 'slimstat', SHA2(CONCAT('slimstat', CHAR(0), e.source_view_id), 256), 'imported', 0, '', e.imported_at 610 FROM {$this->view_import_events_table} e WHERE e.source = 'slimstat' AND e.counts_applied = 1"); 611 if (!empty($wpdb->last_error)) { return false; } 612 statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table} 613 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 614 SELECT 'slimstat', 'SlimStat', 'slimstat', SHA2(CONCAT('slimstat', CHAR(0), f.source_view_id), 256), IF(f.status = 'retry', 'retry', 'failed'), f.attempts, 'record_import_failed', f.updated_at 615 FROM {$this->view_import_failures_table} f WHERE f.source = 'slimstat'"); 616 if (!empty($wpdb->last_error)) { return false; } 617 return $this->persist_schema_version_marker('statpl_history_report_schema_version', self::HISTORY_REPORT_SCHEMA_VERSION); 618 } 619 620 /** Save one eligible historical record's latest durable outcome; no visitor identifiers are stored. */ 621 private function save_historical_import_report_record($source_key, $source_name, $origin_key, $record_hash, $outcome, $attempts = 0, $reason_code = '') { 622 global $wpdb; 623 $source_key = sanitize_key((string) $source_key); 624 $source_name = sanitize_text_field((string) $source_name); 625 $origin_key = sanitize_key((string) $origin_key); 626 $record_hash = strtolower((string) $record_hash); 627 if ($source_key === '' || $source_name === '' || !preg_match('/^[a-f0-9]{64}$/D', $record_hash) 628 || !in_array($outcome, ['imported','retry','failed'], true)) { 629 return false; 630 } 631 $result = statpl_db_query(statpl_db_prepare( 632 "INSERT INTO {$this->historical_import_report_table} 633 (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at) 634 VALUES (%s, %s, %s, %s, %s, %d, %s, %s) 635 ON DUPLICATE KEY UPDATE 636 source_name = VALUES(source_name), 637 outcome = VALUES(outcome), 638 attempts = GREATEST(attempts, VALUES(attempts)), 639 reason_code = VALUES(reason_code), 640 updated_at = VALUES(updated_at)", 641 $source_key, $source_name, $origin_key, $record_hash, $outcome, 642 max(0, (int) $attempts), sanitize_key((string) $reason_code), current_time('mysql') 643 )); 644 return $result !== false && empty($wpdb->last_error); 645 } 646 647 /** Resolve a generic source's stable public name for the administrator report. */ 648 private function save_generic_historical_report_record($source_id, $origin_key, $record_hash, $outcome, $attempts = 0, $reason_code = '') { 649 global $wpdb; 650 $source = statpl_db_get_row(statpl_db_prepare( 651 "SELECT source_key, display_name FROM {$this->view_sources_table} WHERE id = %d LIMIT 1", 652 absint($source_id) 653 ), ARRAY_A); 654 if (!is_array($source) || !empty($wpdb->last_error)) { 655 return false; 656 } 657 return $this->save_historical_import_report_record( 658 (string) $source['source_key'], (string) $source['display_name'], 659 (string) $origin_key, (string) $record_hash, (string) $outcome, 660 (int) $attempts, (string) $reason_code 661 ); 662 } 663 664 /** Aggregate historical import state without exposing source errors or personal data. */ 665 private function get_historical_import_report_summary() { 666 global $wpdb; 667 $rows = statpl_db_get_results( 668 "SELECT source_key, source_name, COUNT(*) AS eligible, 669 SUM(CASE WHEN outcome = 'imported' THEN 1 ELSE 0 END) AS imported, 670 SUM(CASE WHEN outcome IN ('retry','failed') THEN 1 ELSE 0 END) AS not_imported, 671 SUM(CASE WHEN outcome = 'retry' THEN 1 ELSE 0 END) AS pending_retries, 672 SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS failed, 673 SUM(CASE WHEN outcome = 'imported' AND attempts > 0 THEN 1 ELSE 0 END) AS retry_resolved, 674 SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS final_failures 675 FROM {$this->historical_import_report_table} 676 GROUP BY source_key, source_name ORDER BY source_name ASC", 677 ARRAY_A 678 ); 679 if (!empty($wpdb->last_error) || !is_array($rows)) { 680 return ['available' => false, 'sources' => [], 'eligible' => 0, 'imported' => 0, 'not_imported' => 0, 'pending_retries' => 0, 'failed' => 0, 'retry_resolved' => 0]; 681 } 682 $summary = ['available' => true, 'sources' => $rows, 'eligible' => 0, 'imported' => 0, 'not_imported' => 0, 'pending_retries' => 0, 'failed' => 0, 'retry_resolved' => 0]; 683 foreach ($rows as $row) { 684 foreach (['eligible','imported','not_imported','pending_retries','failed','retry_resolved'] as $key) { 685 $summary[$key] += (int) ($row[$key] ?? 0); 686 } 687 } 688 return $summary; 689 } 690 691 /** Determine the user-facing historical-import state from persisted source and record outcomes. */ 692 private function get_historical_import_lifecycle_state() { 693 global $wpdb; 694 $report = $this->get_historical_import_report_summary(); 695 if (empty($report['available'])) { 696 return ['state' => 'technical', 'report' => $report]; 697 } 698 $slimstat = statpl_db_get_row(statpl_db_prepare( 699 "SELECT status FROM {$this->view_import_state_table} WHERE source = %s LIMIT 1", 'slimstat' 700 ), ARRAY_A); 701 $sources = statpl_db_get_results("SELECT status FROM {$this->view_sources_table}", ARRAY_A); 702 if (!empty($wpdb->last_error) || !is_array($sources)) { 703 return ['state' => 'importing', 'report' => $report]; 704 } 705 $probe = (string) get_option('statpl_history_slimstat_probe', 'not_checked'); 706 $discovery = (string) get_option('statpl_history_discovery_status', 'not_checked'); 707 $slimstat_pending = is_array($slimstat) && in_array((string) ($slimstat['status'] ?? ''), ['pending','running'], true); 708 $technical = in_array($probe, ['missing_tables','incompatible_schema','database_error'], true) 709 || ($slimstat_pending && $probe === 'not_installed') 710 || in_array($discovery, ['provider_error','invalid_registry','lock_unavailable','lock_session_lost'], true); 711 if (in_array($discovery, ['busy','not_checked','unknown'], true)) { 712 return ['state' => 'importing', 'report' => $report]; 713 } 714 $has_source = is_array($slimstat) || !empty($sources); 715 $active = false; 716 foreach ($sources as $source) { 717 if (in_array((string) ($source['status'] ?? ''), ['detected','importing','retry_wait'], true)) { 718 $active = true; 719 } 720 if (in_array((string) ($source['status'] ?? ''), ['needs_attention','partial'], true)) { 721 $technical = true; 722 } 723 } 724 if ($slimstat_pending && !$technical) { 725 $active = true; 726 } 727 $unavailable_active_origins = statpl_db_get_var( 728 "SELECT COUNT(*) FROM {$this->view_source_origins_table} WHERE detection_status = 'available' AND status = 'unavailable'" 729 ); 730 if (empty($wpdb->last_error) && (int) $unavailable_active_origins > 0) { 731 $technical = true; 732 } 733 if ($active || (int) ($report['pending_retries'] ?? 0) > 0) { 734 return ['state' => 'importing', 'report' => $report]; 735 } 736 if (!$has_source) { 737 return ['state' => $technical ? 'technical' : 'none', 'report' => $report]; 738 } 739 if ((int) $report['failed'] > 0) { 740 return ['state' => ((int) $report['imported'] > 0) ? 'partial' : 'technical', 'report' => $report]; 741 } 742 if ($technical) { 743 return ['state' => ((int) $report['imported'] > 0) ? 'partial' : 'technical', 'report' => $report]; 744 } 745 if ((int) $report['eligible'] === 0) { 746 return ['state' => 'none', 'report' => $report]; 747 } 748 return ['state' => 'success', 'report' => $report]; 749 } 750 751 /** Render the same concise historical-import notice above both dashboard views. */ 752 private function render_historical_import_notice() { 753 $lifecycle = $this->get_historical_import_lifecycle_state(); 754 $state = (string) ($lifecycle['state'] ?? 'importing'); 755 756 // The in-progress notice remains visible while work is active. A terminal 757 // notice is presented only once for the lifetime of the current Statora 758 // data set; the marker is a Statora-owned option and is removed by the 759 // explicit data-deletion path. Retention/reactivation preserves it. 760 if ($state !== 'importing') { 761 $notice_marker = 'statpl_history_final_notice_shown'; 762 if (get_option($notice_marker, '') !== '') { 763 return; 764 } 765 // add_option() is atomic for a unique option_name. Only the request 766 // that successfully creates the marker may render the final notice. 767 if (!add_option($notice_marker, $state, '', false)) { 768 return; 769 } 770 // Start the short report-access window when the terminal notice is 771 // first rendered, not when the administrator later opens the report. 772 if (in_array($state, ['partial','technical'], true)) { 773 update_option('statpl_history_final_notice_at', time(), false); 774 } 775 } 776 777 $report_url = admin_url('admin.php?page=statpl-history-report'); 778 $messages = [ 779 'importing' => ['title' => 'Επεξεργασία ιστορικού', 'body' => 'Η αρχική εισαγωγή των ιστορικών προβολών προϊόντων βρίσκεται σε εξέλιξη. Τα στατιστικά ενδέχεται να μην είναι ακόμη πλήρη μέχρι να ολοκληρωθεί η διαδικασία.'], 780 'success' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών για τα προϊόντα του καταστήματός σας ολοκληρώθηκε επιτυχώς.'], 781 'partial' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών για τα προϊόντα του καταστήματός σας ολοκληρώθηκε μερικώς.'], 782 'none' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Δεν βρέθηκαν διαθέσιμες προηγούμενες καταγεγραμμένες προβολές προϊόντων για εισαγωγή.'], 783 'technical' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Δεν ήταν δυνατή η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών των προϊόντων σας λόγω τεχνικού προβλήματος.'], 784 ]; 785 $message = $messages[$state] ?? $messages['importing']; 786 echo '<div class="statpl-history-notice statpl-history-notice-' . esc_attr($state) . '" role="status" aria-live="polite">'; 787 echo '<button type="button" class="statpl-history-notice-close" aria-label="Κλείσιμο ειδοποίησης">×</button>'; 788 if ($state === 'importing') { echo '<span class="statpl-history-notice-icon dashicons dashicons-info-outline" aria-hidden="true"></span>'; } 789 echo '<strong>' . esc_html($message['title']) . '</strong>'; 790 echo '<p>' . esc_html($message['body']) . '</p>'; 791 if ($state !== 'importing') { 792 if ($state === 'none') { 793 echo '<p>Το Statora Plus ξεκινά την καταγραφή των προβολών των προϊόντων σας από τώρα και λειτουργεί αυτόνομα.</p>'; 794 } elseif ($state === 'success') { 795 echo '<p>Από εδώ και στο εξής, το Statora Plus καταγράφει αυτόνομα τις νέες προβολές των προϊόντων σας.</p>'; 796 } else { 797 echo '<p>Το Statora Plus συνεχίζει να καταγράφει αυτόνομα τις νέες προβολές των προϊόντων σας.</p>'; 798 } 799 echo '<p><strong>Δεν απαιτείται κάποια άλλη ενέργεια εκ μέρους σας.</strong></p>'; 800 } 801 if (in_array($state, ['partial','technical'], true)) { 802 echo '<a class="statpl-history-report-link" href="' . esc_url($report_url) . '">Περισσότερες πληροφορίες →</a>'; 803 } 804 echo '</div>'; 805 } 806 807 /** Settings page with an administrator-friendly, privacy-safe import summary. */ 808 public function render_historical_import_report_page() { 809 if (!current_user_can('manage_woocommerce')) { 810 wp_die(esc_html__('Δεν έχεις δικαίωμα προβολής αυτής της σελίδας.', 'statora-plus')); 811 } 812 $notice_state = (string) get_option('statpl_history_final_notice_shown', ''); 813 $notice_time = absint(get_option('statpl_history_final_notice_at', 0)); 814 $report_window_seconds = 15 * MINUTE_IN_SECONDS; 815 $report_is_available = in_array($notice_state, ['partial','technical'], true) 816 && $notice_time > 0 817 && time() >= $notice_time 818 && (time() - $notice_time) < $report_window_seconds; 819 if (!$report_is_available) { 820 wp_safe_redirect(admin_url('admin.php?page=statpl-product-metrics')); 821 exit; 822 } 823 $report = $this->get_historical_import_report_summary(); 824 $lifecycle = $this->get_historical_import_lifecycle_state(); 825 echo '<div class="wrap"><h1>Αρχική εγκατάσταση και ιστορικό προβολών</h1>'; 826 if (($lifecycle['state'] ?? '') === 'technical') { 827 echo '<p>Δεν ήταν δυνατή η ολοκλήρωση της ανάγνωσης μίας διαθέσιμης πηγής ιστορικών προβολών. Το Statora Plus συνεχίζει αυτόνομα την καταγραφή νέων προβολών προϊόντων.</p>'; 828 $slimstat_probe = (string) get_option('statpl_history_slimstat_probe', ''); 829 if (in_array($slimstat_probe, ['missing_tables','incompatible_schema','database_error'], true)) { 830 echo '<p><strong>SlimStat:</strong> Δεν ήταν δυνατή η ανάγνωση των ιστορικών δεδομένων του.</p>'; 831 } 832 } elseif (($lifecycle['state'] ?? '') === 'partial') { 833 echo '<p>Η εισαγωγή ολοκληρώθηκε με μερικό αποτέλεσμα. Οι εγγραφές που εισήχθησαν επιτυχώς διατηρούνται και η αυτόνομη καταγραφή συνεχίζεται.</p>'; 834 } 835 if (empty($report['available'])) { 836 echo '<p>Η αναλυτική αναφορά δεν είναι προσωρινά διαθέσιμη. Τα στατιστικά του καταστήματος δεν επηρεάζονται.</p></div>'; 837 return; 838 } 839 echo '<p>Συγκεντρωτικά αποτελέσματα εισαγωγής ιστορικών προβολών προϊόντων.</p>'; 840 echo '<table class="widefat striped"><thead><tr><th>Πηγή</th><th>Επιλέξιμες εγγραφές</th><th>Εισήχθησαν</th><th>Δεν εισήχθησαν</th><th>Σε επαναπροσπάθεια</th><th>Επιλύθηκαν με επαναπροσπάθεια</th><th>Οριστικές αποτυχίες</th><th>Αιτία αποτυχίας</th></tr></thead><tbody>'; 841 if (empty($report['sources'])) { 842 echo '<tr><td colspan="8">Δεν υπάρχουν διαθέσιμα ιστορικά δεδομένα προς αναφορά.</td></tr>'; 843 } else { 844 foreach ($report['sources'] as $row) { 845 echo '<tr><td>' . esc_html((string) $row['source_name']) . '</td><td>' . esc_html((string) (int) $row['eligible']) . '</td><td>' . esc_html((string) (int) $row['imported']) . '</td><td>' . esc_html((string) (int) $row['not_imported']) . '</td><td>' . esc_html((string) (int) $row['pending_retries']) . '</td><td>' . esc_html((string) (int) $row['retry_resolved']) . '</td><td>' . esc_html((string) (int) $row['final_failures']) . '</td><td>' . ((int) $row['failed'] > 0 ? 'Ορισμένες εγγραφές δεν μπόρεσαν να καταγραφούν μετά από επαναπροσπάθειες.' : '—') . '</td></tr>'; 846 } 847 } 848 echo '</tbody></table>'; 849 if ((int) $report['failed'] > 0) { 850 echo '<h2>Τι σημαίνουν τα αποτελέσματα</h2><p>Ορισμένες ιστορικές εγγραφές δεν ήταν δυνατό να εισαχθούν. Οι επιτυχημένες εισαγωγές διατηρούνται και το Statora Plus συνεχίζει κανονικά την αυτόνομη καταγραφή νέων προβολών.</p>'; 851 } 852 echo '<p>Η αναφορά περιέχει μόνο συγκεντρωτικά αποτελέσματα. Δεν αποθηκεύονται ή εμφανίζονται προσωπικά στοιχεία επισκεπτών.</p></div>'; 853 } 854 855 /** Verify every required column before persisting a schema-version marker. */ 856 private function verify_historical_table_columns($table, $required_columns) { 857 global $wpdb; 858 859 if (!is_object($wpdb) || !isset($wpdb->last_error) || !is_string($table) || !is_array($required_columns)) { 860 return false; 861 } 862 863 // Only Statora-owned tables may be inspected by this helper. Validate the 864 // complete identifier here rather than relying on each caller to do so. 865 $allowed_tables = [ 866 $this->views_table, 867 $this->events_table, 868 $this->order_events_table, 869 $this->order_items_table, 870 $this->view_imports_table, 871 $this->view_import_state_table, 872 $this->view_import_events_table, 873 $this->view_import_failures_table, 874 $this->view_sources_table, 875 $this->view_source_origins_table, 876 $this->view_source_state_table, 877 $this->view_source_events_table, 878 $this->view_source_failures_table, 879 $this->view_source_reconciliation_table, 880 $this->view_source_reconciliation_history_table, 881 $this->historical_import_report_table, 882 ]; 883 if (!in_array($table, $allowed_tables, true) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 884 return false; 885 } 886 foreach ($required_columns as $column) { 887 if (!is_string($column) || !preg_match('/^[A-Za-z0-9_]+$/D', $column)) { 888 return false; 889 } 890 } 891 892 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is restricted to the explicit Statora-owned table allowlist and quoted as one identifier. 893 $rows = statpl_db_get_results("SHOW COLUMNS FROM `{$table}`", ARRAY_A); 894 if ($wpdb->last_error !== '' || !is_array($rows)) { 895 return false; 896 } 897 898 $found = []; 899 foreach ($rows as $row) { 900 if (isset($row['Field'])) { 901 $found[$row['Field']] = true; 902 } 903 } 904 905 foreach ($required_columns as $column) { 906 if (!isset($found[$column])) { 907 return false; 908 } 909 } 910 911 return true; 912 } 913 914 private function maybe_upgrade_view_import_schema() { 915 global $wpdb; 916 917 $schema_version = get_option('statpl_view_import_schema_version', ''); 918 919 if ($schema_version === self::VIEW_IMPORT_SCHEMA_VERSION) { 920 return; 921 } 922 923 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 924 925 $collate = $wpdb->get_charset_collate(); 926 927 dbDelta("CREATE TABLE {$this->view_imports_table} ( 928 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 929 source VARCHAR(50) NOT NULL, 930 product_id BIGINT(20) UNSIGNED NOT NULL, 931 imported_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 932 imported_at DATETIME NOT NULL, 933 PRIMARY KEY (id), 934 UNIQUE KEY source_product (source, product_id), 935 KEY imported_at (imported_at) 936 ) $collate;"); 937 938 $table_exists = statpl_db_get_var( 939 statpl_db_prepare( 940 'SHOW TABLES LIKE %s', 941 $wpdb->esc_like($this->view_imports_table) 942 ) 943 ); 944 945 if ($table_exists === $this->view_imports_table && $this->verify_historical_table_columns($this->view_imports_table, ['id', 'source', 'product_id', 'imported_views', 'imported_at'])) { 946 if (!$this->persist_schema_version_marker('statpl_view_import_schema_version', self::VIEW_IMPORT_SCHEMA_VERSION)) { return false; } 947 } 948 } 949 private function maybe_upgrade_view_import_state_schema() { 950 global $wpdb; 951 952 $schema_version = get_option('statpl_view_import_state_schema_version', ''); 953 954 if ($schema_version === self::VIEW_IMPORT_STATE_SCHEMA_VERSION) { 955 return; 956 } 957 958 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 959 960 $collate = $wpdb->get_charset_collate(); 961 962 dbDelta("CREATE TABLE {$this->view_import_state_table} ( 963 source VARCHAR(50) NOT NULL, 964 snapshot_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 965 snapshot_active_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 966 snapshot_archive_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 967 last_processed_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 968 status VARCHAR(20) NOT NULL DEFAULT 'pending', 969 updated_at DATETIME NOT NULL, 970 PRIMARY KEY (source) 971 ) $collate;"); 972 973 $table_exists = statpl_db_get_var( 974 statpl_db_prepare( 975 'SHOW TABLES LIKE %s', 976 $wpdb->esc_like($this->view_import_state_table) 977 ) 978 ); 979 980 if ($table_exists === $this->view_import_state_table && $this->verify_historical_table_columns($this->view_import_state_table, ['source', 'snapshot_max_id', 'snapshot_active_max_id', 'snapshot_archive_max_id', 'last_processed_id', 'status', 'updated_at'])) { 981 if (!$this->persist_schema_version_marker('statpl_view_import_state_schema_version', self::VIEW_IMPORT_STATE_SCHEMA_VERSION)) { return false; } 982 } 983 } 984 985 private function maybe_upgrade_view_import_events_schema() { 986 global $wpdb; 987 988 $schema_version = get_option('statpl_view_import_events_schema_version', ''); 989 990 if ($schema_version === self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION) { 991 return; 992 } 993 994 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 995 996 $collate = $wpdb->get_charset_collate(); 997 998 dbDelta("CREATE TABLE {$this->view_import_events_table} ( 999 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1000 source VARCHAR(50) NOT NULL, 1001 source_view_id BIGINT(20) UNSIGNED NOT NULL, 1002 product_id BIGINT(20) UNSIGNED NOT NULL, 1003 imported_at DATETIME NOT NULL, 1004 counts_applied TINYINT(1) UNSIGNED NOT NULL DEFAULT 1, 1005 PRIMARY KEY (id), 1006 UNIQUE KEY source_view (source, source_view_id), 1007 KEY product_id (product_id) 1008 ) $collate;"); 1009 1010 $table_exists = statpl_db_get_var( 1011 statpl_db_prepare( 1012 'SHOW TABLES LIKE %s', 1013 $wpdb->esc_like($this->view_import_events_table) 1014 ) 1015 ); 1016 1017 $column_exists = false; 1018 1019 if ($table_exists === $this->view_import_events_table) { 1020 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The table identifier is built from the WordPress prefix and a fixed plugin suffix. 1021 $column_exists = statpl_db_get_var( 1022 statpl_db_prepare( 1023 "SHOW COLUMNS FROM {$this->view_import_events_table} LIKE %s", 1024 'counts_applied' 1025 ) 1026 ); 1027 } 1028 1029 if ( 1030 $table_exists === $this->view_import_events_table 1031 && 'counts_applied' === $column_exists 1032 && $this->verify_historical_table_columns($this->view_import_events_table, ['id', 'source', 'source_view_id', 'product_id', 'imported_at', 'counts_applied']) 1033 ) { 1034 if (!$this->persist_schema_version_marker('statpl_view_import_events_schema_version', self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION)) { return false; } 1035 } 1036 } 1037 1038 private function maybe_upgrade_view_import_failures_schema() { 1039 global $wpdb; 1040 1041 $schema_version = get_option('statpl_view_import_failures_schema_version', ''); 1042 1043 if ($schema_version === self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION) { 1044 return; 1045 } 1046 1047 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 1048 1049 $collate = $wpdb->get_charset_collate(); 1050 1051 dbDelta("CREATE TABLE {$this->view_import_failures_table} ( 1052 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1053 source VARCHAR(50) NOT NULL, 1054 source_view_id BIGINT(20) UNSIGNED NOT NULL, 1055 product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 1056 attempts INT(10) UNSIGNED NOT NULL DEFAULT 0, 1057 last_error TEXT NOT NULL, 1058 status VARCHAR(20) NOT NULL DEFAULT 'retry', 1059 updated_at DATETIME NOT NULL, 1060 PRIMARY KEY (id), 1061 UNIQUE KEY source_view (source, source_view_id), 1062 KEY status (status) 1063 ) $collate;"); 1064 1065 $table_exists = statpl_db_get_var( 1066 statpl_db_prepare( 1067 'SHOW TABLES LIKE %s', 1068 $wpdb->esc_like($this->view_import_failures_table) 1069 ) 1070 ); 1071 1072 if ($table_exists === $this->view_import_failures_table && $this->verify_historical_table_columns($this->view_import_failures_table, ['id', 'source', 'source_view_id', 'product_id', 'attempts', 'last_error', 'status', 'updated_at'])) { 1073 if (!$this->persist_schema_version_marker('statpl_view_import_failures_schema_version', self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION)) { return false; } 1074 } 1075 } 1076 1077 private function maybe_upgrade_view_source_schema() { 1078 global $wpdb; 1079 1080 $schema_version = self::VIEW_SOURCE_SCHEMA_VERSION; 1081 if (get_option('statpl_view_source_schema_version', '') === $schema_version) { 1082 return true; 1083 } 1084 1085 $collate = $wpdb->get_charset_collate(); 1086 1087 $tables = [ 1088 $this->view_sources_table, 1089 $this->view_source_origins_table, 1090 $this->view_source_state_table, 1091 $this->view_source_events_table, 1092 $this->view_source_failures_table, 1093 $this->view_source_reconciliation_table, 1094 $this->view_source_reconciliation_history_table, 1095 ]; 1096 1097 $definitions = [ 1098 $this->view_sources_table => [ 1099 'columns' => [ 1100 'id' => ['bigint(20) unsigned', 'NO', null, true], 1101 'source_key' => ['varchar(100)', 'NO', null, false], 1102 'adapter_key' => ['varchar(100)', 'YES', null, false], 1103 'display_name' => ['varchar(191)', 'NO', null, false], 1104 'adapter_version' => ['varchar(32)', 'NO', null, false], 1105 'granularity' => ['varchar(20)', 'NO', null, false], 1106 'status' => ['varchar(20)', 'NO', 'detected', false], 1107 'created_at' => ['datetime', 'NO', null, false], 1108 'updated_at' => ['datetime', 'NO', null, false], 1109 ], 1110 'indexes' => [ 1111 'PRIMARY' => [true, ['id']], 1112 'source_key' => [true, ['source_key']], 1113 'status' => [false, ['status']], 1114 ], 1115 ], 1116 $this->view_source_origins_table => [ 1117 'columns' => [ 1118 'source_id' => ['bigint(20) unsigned', 'NO', null, false], 1119 'origin_key' => ['varchar(100)', 'NO', null, false], 1120 'adapter_key' => ['varchar(100)', 'NO', null, false], 1121 'capabilities' => ['longtext', 'YES', null, false], 1122 'snapshot_data' => ['longtext', 'YES', null, false], 1123 'checkpoint_data' => ['longtext', 'YES', null, false], 1124 'detection_status' => ['varchar(20)', 'NO', 'unknown', false], 1125 'status' => ['varchar(20)', 'NO', 'pending', false], 1126 'last_error' => ['text', 'YES', null, false], 1127 'started_at' => ['datetime', 'YES', null, false], 1128 'completed_at' => ['datetime', 'YES', null, false], 1129 'updated_at' => ['datetime', 'NO', null, false], 1130 ], 1131 'indexes' => [ 1132 'PRIMARY' => [true, ['source_id', 'origin_key']], 1133 'status' => [false, ['status']], 1134 ], 1135 ], 1136 $this->view_source_state_table => [ 1137 'columns' => [ 1138 'source_id' => ['bigint(20) unsigned', 'NO', null, false], 1139 'snapshot_data' => ['longtext', 'YES', null, false], 1140 'checkpoint_data' => ['longtext', 'YES', null, false], 1141 'status' => ['varchar(20)', 'NO', 'pending', false], 1142 'last_error' => ['text', 'YES', null, false], 1143 'started_at' => ['datetime', 'YES', null, false], 1144 'completed_at' => ['datetime', 'YES', null, false], 1145 'updated_at' => ['datetime', 'NO', null, false], 1146 ], 1147 'indexes' => [ 1148 'PRIMARY' => [true, ['source_id']], 1149 'status' => [false, ['status']], 1150 ], 1151 ], 1152 $this->view_source_events_table => [ 1153 'columns' => [ 1154 'id' => ['bigint(20) unsigned', 'NO', null, true], 1155 'source_id' => ['bigint(20) unsigned', 'NO', null, false], 1156 'source_record_key' => ['varchar(191)', 'NO', null, false], 1157 'source_record_hash' => ['char(64)', 'NO', null, false], 1158 'product_id' => ['bigint(20) unsigned', 'NO', null, false], 1159 'record_type' => ['varchar(20)', 'NO', 'event', false], 1160 'period_start' => ['datetime', 'YES', null, false], 1161 'period_end' => ['datetime', 'YES', null, false], 1162 'observed_views' => ['bigint(20) unsigned', 'NO', '0', false], 1163 'applied_views' => ['bigint(20) unsigned', 'NO', '0', false], 1164 'source_fingerprint' => ['char(64)', 'YES', null, false], 1165 'status' => ['varchar(20)', 'NO', 'pending', false], 1166 'created_at' => ['datetime', 'NO', null, false], 1167 'updated_at' => ['datetime', 'NO', null, false], 1168 ], 1169 'indexes' => [ 1170 'PRIMARY' => [true, ['id']], 1171 'source_record' => [true, ['source_id', 'source_record_hash']], 1172 'product_id' => [false, ['product_id']], 1173 'source_product' => [false, ['source_id', 'product_id']], 1174 'source_fingerprint' => [false, ['source_fingerprint']], 1175 'product_record_period' => [false, ['product_id', 'record_type', 'period_start', 'period_end']], 1176 'status' => [false, ['status']], 1177 ], 1178 ], 1179 $this->view_source_reconciliation_table => [ 1180 'columns' => [ 1181 'id' => ['bigint(20) unsigned', 'NO', null, true], 1182 'event_id' => ['bigint(20) unsigned', 'NO', null, false], 1183 'status' => ['varchar(30)', 'NO', 'unprocessed', false], 1184 'reason_code' => ['varchar(100)', 'NO', null, false], 1185 'reason' => ['text', 'YES', null, false], 1186 'matched_event_id' => ['bigint(20) unsigned', 'YES', null, false], 1187 'evidence' => ['longtext', 'YES', null, false], 1188 'algorithm_version' => ['varchar(32)', 'NO', null, false], 1189 'created_at' => ['datetime', 'NO', null, false], 1190 'updated_at' => ['datetime', 'NO', null, false], 1191 ], 1192 'indexes' => [ 1193 'PRIMARY' => [true, ['id']], 1194 'event_id' => [true, ['event_id']], 1195 'status' => [false, ['status']], 1196 'matched_event_id' => [false, ['matched_event_id']], 1197 ], 1198 ], 1199 $this->view_source_reconciliation_history_table => [ 1200 'columns' => [ 1201 'id' => ['bigint(20) unsigned', 'NO', null, true], 1202 'event_id' => ['bigint(20) unsigned', 'NO', null, false], 1203 'previous_status' => ['varchar(30)', 'YES', null, false], 1204 'new_status' => ['varchar(30)', 'NO', null, false], 1205 'reason_code' => ['varchar(100)', 'NO', null, false], 1206 'reason' => ['text', 'YES', null, false], 1207 'matched_event_id' => ['bigint(20) unsigned', 'YES', null, false], 1208 'evidence' => ['longtext', 'YES', null, false], 1209 'algorithm_version' => ['varchar(32)', 'NO', null, false], 1210 'created_at' => ['datetime', 'NO', null, false], 1211 ], 1212 'indexes' => [ 1213 'PRIMARY' => [true, ['id']], 1214 'event_id' => [false, ['event_id']], 1215 'created_at' => [false, ['created_at']], 1216 ], 1217 ], 1218 $this->view_source_failures_table => [ 1219 'columns' => [ 1220 'id' => ['bigint(20) unsigned', 'NO', null, true], 1221 'source_id' => ['bigint(20) unsigned', 'NO', null, false], 1222 'origin_key' => ['varchar(100)', 'NO', null, false], 1223 'source_record_key' => ['varchar(191)', 'NO', null, false], 1224 'source_record_hash' => ['char(64)', 'NO', null, false], 1225 'product_id' => ['bigint(20) unsigned', 'NO', '0', false], 1226 'attempts' => ['int(10) unsigned', 'NO', '0', false], 1227 'last_error' => ['text', 'NO', null, false], 1228 'status' => ['varchar(20)', 'NO', 'retry', false], 1229 'next_retry_at' => ['datetime', 'YES', null, false], 1230 'retry_checkpoint' => ['longtext', 'YES', null, false], 1231 'updated_at' => ['datetime', 'NO', null, false], 1232 ], 1233 'indexes' => [ 1234 'PRIMARY' => [true, ['id']], 1235 'source_record' => [true, ['source_id', 'source_record_hash']], 1236 'status_retry' => [false, ['status', 'next_retry_at']], 1237 'source_origin' => [false, ['source_id', 'origin_key']], 1238 'product_id' => [false, ['product_id']], 1239 ], 1240 ], 1241 ]; 1242 1243 $sql = [ 1244 "CREATE TABLE {$this->view_sources_table} ( 1245 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1246 source_key VARCHAR(100) NOT NULL, 1247 adapter_key VARCHAR(100) NULL DEFAULT NULL, 1248 display_name VARCHAR(191) NOT NULL, 1249 adapter_version VARCHAR(32) NOT NULL, 1250 granularity VARCHAR(20) NOT NULL, 1251 status VARCHAR(20) NOT NULL DEFAULT 'detected', 1252 created_at DATETIME NOT NULL, 1253 updated_at DATETIME NOT NULL, 1254 PRIMARY KEY (id), 1255 UNIQUE KEY source_key (source_key), 1256 KEY status (status) 1257 ) ENGINE=InnoDB $collate;", 1258 "CREATE TABLE {$this->view_source_origins_table} ( 1259 source_id BIGINT(20) UNSIGNED NOT NULL, 1260 origin_key VARCHAR(100) NOT NULL, 1261 adapter_key VARCHAR(100) NOT NULL, 1262 capabilities LONGTEXT NULL DEFAULT NULL, 1263 snapshot_data LONGTEXT NULL DEFAULT NULL, 1264 checkpoint_data LONGTEXT NULL DEFAULT NULL, 1265 detection_status VARCHAR(20) NOT NULL DEFAULT 'unknown', 1266 status VARCHAR(20) NOT NULL DEFAULT 'pending', 1267 last_error TEXT NULL DEFAULT NULL, 1268 started_at DATETIME NULL DEFAULT NULL, 1269 completed_at DATETIME NULL DEFAULT NULL, 1270 updated_at DATETIME NOT NULL, 1271 PRIMARY KEY (source_id, origin_key), 1272 KEY status (status) 1273 ) ENGINE=InnoDB $collate;", 1274 "CREATE TABLE {$this->view_source_state_table} ( 1275 source_id BIGINT(20) UNSIGNED NOT NULL, 1276 snapshot_data LONGTEXT NULL DEFAULT NULL, 1277 checkpoint_data LONGTEXT NULL DEFAULT NULL, 1278 status VARCHAR(20) NOT NULL DEFAULT 'pending', 1279 last_error TEXT NULL DEFAULT NULL, 1280 started_at DATETIME NULL DEFAULT NULL, 1281 completed_at DATETIME NULL DEFAULT NULL, 1282 updated_at DATETIME NOT NULL, 1283 PRIMARY KEY (source_id), 1284 KEY status (status) 1285 ) ENGINE=InnoDB $collate;", 1286 "CREATE TABLE {$this->view_source_events_table} ( 1287 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1288 source_id BIGINT(20) UNSIGNED NOT NULL, 1289 source_record_key VARCHAR(191) NOT NULL, 1290 source_record_hash CHAR(64) NOT NULL, 1291 product_id BIGINT(20) UNSIGNED NOT NULL, 1292 record_type VARCHAR(20) NOT NULL DEFAULT 'event', 1293 period_start DATETIME NULL DEFAULT NULL, 1294 period_end DATETIME NULL DEFAULT NULL, 1295 observed_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 1296 applied_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 1297 source_fingerprint CHAR(64) NULL DEFAULT NULL, 1298 status VARCHAR(20) NOT NULL DEFAULT 'pending', 1299 created_at DATETIME NOT NULL, 1300 updated_at DATETIME NOT NULL, 1301 PRIMARY KEY (id), 1302 UNIQUE KEY source_record (source_id, source_record_hash), 1303 KEY product_id (product_id), 1304 KEY source_product (source_id, product_id), 1305 KEY source_fingerprint (source_fingerprint), 1306 KEY product_record_period (product_id, record_type, period_start, period_end), 1307 KEY status (status) 1308 ) ENGINE=InnoDB $collate;", 1309 "CREATE TABLE {$this->view_source_failures_table} ( 1310 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1311 source_id BIGINT(20) UNSIGNED NOT NULL, 1312 origin_key VARCHAR(100) NOT NULL, 1313 source_record_key VARCHAR(191) NOT NULL, 1314 source_record_hash CHAR(64) NOT NULL, 1315 product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, 1316 attempts INT(10) UNSIGNED NOT NULL DEFAULT 0, 1317 last_error TEXT NOT NULL, 1318 status VARCHAR(20) NOT NULL DEFAULT 'retry', 1319 next_retry_at DATETIME NULL DEFAULT NULL, 1320 retry_checkpoint LONGTEXT NULL DEFAULT NULL, 1321 updated_at DATETIME NOT NULL, 1322 PRIMARY KEY (id), 1323 UNIQUE KEY source_record (source_id, source_record_hash), 1324 KEY status_retry (status, next_retry_at), 1325 KEY source_origin (source_id, origin_key), 1326 KEY product_id (product_id) 1327 ) ENGINE=InnoDB $collate;", 1328 "CREATE TABLE {$this->view_source_reconciliation_table} ( 1329 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1330 event_id BIGINT(20) UNSIGNED NOT NULL, 1331 status VARCHAR(30) NOT NULL DEFAULT 'unprocessed', 1332 reason_code VARCHAR(100) NOT NULL, 1333 reason TEXT NULL DEFAULT NULL, 1334 matched_event_id BIGINT(20) UNSIGNED NULL DEFAULT NULL, 1335 evidence LONGTEXT NULL DEFAULT NULL, 1336 algorithm_version VARCHAR(32) NOT NULL, 1337 created_at DATETIME NOT NULL, 1338 updated_at DATETIME NOT NULL, 1339 PRIMARY KEY (id), 1340 UNIQUE KEY event_id (event_id), 1341 KEY status (status), 1342 KEY matched_event_id (matched_event_id) 1343 ) ENGINE=InnoDB $collate;", 1344 "CREATE TABLE {$this->view_source_reconciliation_history_table} ( 1345 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, 1346 event_id BIGINT(20) UNSIGNED NOT NULL, 1347 previous_status VARCHAR(30) NULL DEFAULT NULL, 1348 new_status VARCHAR(30) NOT NULL, 1349 reason_code VARCHAR(100) NOT NULL, 1350 reason TEXT NULL DEFAULT NULL, 1351 matched_event_id BIGINT(20) UNSIGNED NULL DEFAULT NULL, 1352 evidence LONGTEXT NULL DEFAULT NULL, 1353 algorithm_version VARCHAR(32) NOT NULL, 1354 created_at DATETIME NOT NULL, 1355 PRIMARY KEY (id), 1356 KEY event_id (event_id), 1357 KEY created_at (created_at) 1358 ) ENGINE=InnoDB $collate;", 1359 ]; 1360 1361 $fail = static function ($message) { 1362 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Preserve an actionable migration failure in the PHP error log when schema setup cannot complete. 1363 error_log('[Statora Plus] View-source schema migration failed: ' . $message); 1364 return false; 1365 }; 1366 1367 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 1368 1369 try { 1370 foreach ($sql as $statement) { 1371 dbDelta($statement); 1372 } 1373 } catch (Throwable $e) { 1374 return $fail($e->getMessage()); 1375 } 1376 1377 // Convert legacy detection values out of the processing-status column. 1378 // Existing processing states are preserved; only legacy detection labels are migrated. 1379 $legacy_status_migration = statpl_db_query( 1380 "UPDATE {$this->view_source_origins_table} 1381 SET detection_status = CASE 1382 WHEN status IN ('available', 'missing_table', 'incompatible_schema', 'database_error') 1383 THEN status 1384 ELSE 'unknown' 1385 END, 1386 status = 'pending' 1387 WHERE status IN ('detected', 'available', 'missing_table', 'incompatible_schema', 'database_error')" 1388 ); 1389 1390 if ($legacy_status_migration === false || $wpdb->last_error !== '') { 1391 return $fail('Could not migrate legacy origin detection statuses: ' . $wpdb->last_error); 1392 } 1393 1394 foreach ($tables as $table) { 1395 // The migration metadata is the allowlist for identifiers used by the 1396 // SHOW COLUMNS / SHOW INDEX statements below. Reject anything outside it. 1397 if ( 1398 !is_string($table) 1399 || !array_key_exists($table, $definitions) 1400 || !preg_match('/^[A-Za-z0-9_]+$/D', $table) 1401 ) { 1402 return $fail('Invalid internal table identifier during schema verification.'); 1403 } 1404 1405 $found_table = statpl_db_get_var( 1406 statpl_db_prepare( 1407 'SHOW TABLES LIKE %s', 1408 $wpdb->esc_like($table) 1409 ) 1410 ); 1411 1412 if ($wpdb->last_error !== '') { 1413 return $fail('Could not inspect table existence for ' . $table . ': ' . $wpdb->last_error); 1414 } 1415 1416 if ($found_table !== $table) { 1417 return $fail('Required table was not created: ' . $table); 1418 } 1419 1420 $table_status = statpl_db_get_row( 1421 statpl_db_prepare( 1422 'SHOW TABLE STATUS LIKE %s', 1423 $wpdb->esc_like($table) 1424 ), 1425 ARRAY_A 1426 ); 1427 1428 if ($wpdb->last_error !== '') { 1429 return $fail('Could not inspect table engine for ' . $table . ': ' . $wpdb->last_error); 1430 } 1431 1432 if (!$table_status || !isset($table_status['Engine']) || strcasecmp($table_status['Engine'], 'InnoDB') !== 0) { 1433 return $fail('Table engine is not InnoDB: ' . $table); 1434 } 1435 1436 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Identifier is built from the trusted WordPress prefix and fixed plugin suffix. 1437 $column_rows = statpl_db_get_results("SHOW COLUMNS FROM `{$table}`", ARRAY_A); 1438 1439 if ($wpdb->last_error !== '') { 1440 return $fail('Could not inspect columns for ' . $table . ': ' . $wpdb->last_error); 1441 } 1442 1443 if (!is_array($column_rows)) { 1444 return $fail('Invalid column metadata for ' . $table); 1445 } 1446 1447 $actual_columns = []; 1448 foreach ($column_rows as $column) { 1449 $actual_columns[$column['Field']] = $column; 1450 } 1451 1452 $expected_columns = $definitions[$table]['columns']; 1453 1454 if (count($actual_columns) !== count($expected_columns)) { 1455 return $fail('Unexpected column count in ' . $table); 1456 } 1457 1458 foreach ($expected_columns as $name => $expected) { 1459 if (!isset($actual_columns[$name])) { 1460 return $fail('Missing column ' . $table . '.' . $name); 1461 } 1462 1463 $actual = $actual_columns[$name]; 1464 $actual_type = strtolower((string) $actual['Type']); 1465 $actual_null = strtoupper((string) $actual['Null']); 1466 $actual_default = $actual['Default']; 1467 $actual_extra = strtolower(trim((string) $actual['Extra'])); 1468 $actual_extra = preg_replace('/\\s+/', ' ', $actual_extra); 1469 $expected_extra = $expected[3] ? 'auto_increment' : ''; 1470 1471 if ($actual_type !== $expected[0]) { 1472 return $fail('Wrong type for ' . $table . '.' . $name . ': ' . $actual_type); 1473 } 1474 1475 if ($actual_null !== $expected[1]) { 1476 return $fail('Wrong NULL definition for ' . $table . '.' . $name); 1477 } 1478 1479 if ($expected[2] === null) { 1480 if ($actual_default !== null) { 1481 return $fail('Unexpected default for ' . $table . '.' . $name); 1482 } 1483 } elseif ((string) $actual_default !== $expected[2]) { 1484 return $fail('Wrong default for ' . $table . '.' . $name); 1485 } 1486 1487 if ($actual_extra !== $expected_extra) { 1488 return $fail('Wrong Extra definition for ' . $table . '.' . $name . ': ' . $actual_extra); 1489 } 1490 } 1491 1492 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Identifier is built from the trusted WordPress prefix and fixed plugin suffix. 1493 $index_rows = statpl_db_get_results("SHOW INDEX FROM `{$table}`", ARRAY_A); 1494 1495 if ($wpdb->last_error !== '') { 1496 return $fail('Could not inspect indexes for ' . $table . ': ' . $wpdb->last_error); 1497 } 1498 1499 if (!is_array($index_rows)) { 1500 return $fail('Invalid index metadata for ' . $table); 1501 } 1502 1503 $actual_indexes = []; 1504 foreach ($index_rows as $index) { 1505 $key_name = $index['Key_name']; 1506 1507 if (!isset($actual_indexes[$key_name])) { 1508 $actual_indexes[$key_name] = [ 1509 'unique' => ((int) $index['Non_unique'] === 0), 1510 'columns' => [], 1511 ]; 1512 } elseif ($actual_indexes[$key_name]['unique'] !== ((int) $index['Non_unique'] === 0)) { 1513 return $fail('Inconsistent uniqueness metadata for index ' . $table . '.' . $key_name); 1514 } 1515 1516 $sequence = (string) $index['Seq_in_index']; 1517 if (!preg_match('/^[1-9][0-9]*$/D', $sequence)) { 1518 return $fail('Invalid Seq_in_index for ' . $table . '.' . $key_name); 1519 } 1520 1521 $sequence = (int) $sequence; 1522 if (isset($actual_indexes[$key_name]['columns'][$sequence])) { 1523 return $fail('Duplicate Seq_in_index for ' . $table . '.' . $key_name); 1524 } 1525 1526 if (!array_key_exists('Sub_part', $index) || $index['Sub_part'] !== null) { 1527 return $fail('Unexpected index prefix length for ' . $table . '.' . $key_name); 1528 } 1529 1530 $actual_indexes[$key_name]['columns'][$sequence] = $index['Column_name']; 1531 } 1532 1533 foreach ($actual_indexes as $key_name => &$index_data) { 1534 ksort($index_data['columns']); 1535 $positions = array_keys($index_data['columns']); 1536 1537 if ($positions !== range(1, count($positions))) { 1538 return $fail('Non-contiguous Seq_in_index for ' . $table . '.' . $key_name); 1539 } 1540 1541 $index_data['columns'] = array_values($index_data['columns']); 1542 } 1543 unset($index_data); 1544 1545 $expected_indexes = $definitions[$table]['indexes']; 1546 1547 if (count($actual_indexes) !== count($expected_indexes)) { 1548 return $fail('Unexpected index count in ' . $table); 1549 } 1550 1551 foreach ($expected_indexes as $name => $expected) { 1552 if (!isset($actual_indexes[$name])) { 1553 return $fail('Missing index ' . $table . '.' . $name); 1554 } 1555 1556 if ( 1557 $actual_indexes[$name]['unique'] !== $expected[0] 1558 || $actual_indexes[$name]['columns'] !== $expected[1] 1559 ) { 1560 return $fail('Incorrect index definition for ' . $table . '.' . $name); 1561 } 1562 } 1563 } 1564 1565 update_option('statpl_view_source_schema_version', $schema_version, false); 1566 1567 if (get_option('statpl_view_source_schema_version', '') !== $schema_version) { 1568 return $fail('Could not persist the verified view-source schema version.'); 1569 } 1570 1571 return true; 1572 } 1573 1574 /** 1575 * Reclassify legacy aggregate matches that relied on the synthetic 1576 * product/date/count fingerprint. This is audit-only: totals and 1577 * applied_views are deliberately not modified because the old records 1578 * cannot prove whether the underlying views were actually duplicated. 1579 */ 1580 private function migrate_legacy_aggregate_fingerprint_matches() { 1581 global $wpdb; 1582 1583 $complete_option = 'statpl_legacy_aggregate_fp_migration_v1_complete'; 1584 $cursor_option = 'statpl_legacy_aggregate_fp_migration_v1_cursor'; 1585 if (get_option($complete_option, '') === '1') { 1586 return true; 1587 } 1588 1589 $lock_name = 'statpl_legacy_agg_' . substr(hash('sha256', $wpdb->prefix), 0, 40); 1590 $migration_lock = $this->acquire_generic_advisory_lock($lock_name); 1591 if (empty($migration_lock['acquired'])) { 1592 $this->schedule_legacy_aggregate_reconciliation_migration(60); 1593 return false; 1594 } 1595 1596 $batch_size = 100; 1597 $cursor = max(0, (int) get_option($cursor_option, 0)); 1598 $processed = 0; 1599 $failed = false; 1600 $migration_transaction_started = false; 1601 $migration_transaction_committed = false; 1602 1603 try { 1604 $rows = statpl_db_get_results(statpl_db_prepare( 1605 "SELECT r.event_id, r.status, r.reason_code, r.reason, r.matched_event_id, 1606 r.evidence, r.algorithm_version, 1607 e.source_id, e.product_id, e.record_type, e.observed_views, 1608 e.period_start, e.period_end, e.source_fingerprint, 1609 m.source_id AS matched_source_id, 1610 m.record_type AS matched_record_type, 1611 m.source_fingerprint AS matched_source_fingerprint 1612 FROM {$this->view_source_reconciliation_table} r 1613 INNER JOIN {$this->view_source_events_table} e ON e.id = r.event_id 1614 INNER JOIN {$this->view_source_events_table} m ON m.id = r.matched_event_id 1615 WHERE r.event_id > %d 1616 AND r.status = 'matched_exact' 1617 AND r.reason_code = 'shared_fingerprint_exact_match' 1618 AND e.record_type = 'aggregate' 1619 AND m.record_type = 'aggregate' 1620 AND e.source_id <> m.source_id 1621 ORDER BY r.event_id ASC 1622 LIMIT %d", 1623 $cursor, 1624 $batch_size 1625 ), ARRAY_A); 1626 1627 if (!is_array($rows)) { 1628 $failed = true; 1629 return false; 1630 } 1631 1632 foreach ($rows as $row) { 1633 $event_id = (int) $row['event_id']; 1634 $expected_fingerprint = ''; 1635 if ($row['period_start'] !== null && $row['period_end'] !== null) { 1636 $expected_fingerprint = hash( 1637 'sha256', 1638 "statpl-product-day-aggregate-v1\0" 1639 . (int) $row['product_id'] . "\0" 1640 . (string) $row['period_start'] . "\0" 1641 . (string) $row['period_end'] . "\0" 1642 . (int) $row['observed_views'] 1643 ); 1644 } 1645 1646 $is_legacy_synthetic_match = $expected_fingerprint !== '' 1647 && is_string($row['source_fingerprint']) 1648 && is_string($row['matched_source_fingerprint']) 1649 && hash_equals($expected_fingerprint, strtolower($row['source_fingerprint'])) 1650 && hash_equals($expected_fingerprint, strtolower($row['matched_source_fingerprint'])); 1651 1652 if ($is_legacy_synthetic_match) { 1653 $evidence = json_decode((string) $row['evidence'], true); 1654 if (!is_array($evidence)) { 1655 $evidence = ['legacy_evidence_raw' => (string) $row['evidence']]; 1656 } 1657 $evidence['legacy_reclassification'] = [ 1658 'migration' => 'legacy_aggregate_fingerprint_v1', 1659 'previous_status' => 'matched_exact', 1660 'new_status' => 'legacy_exact_unverified', 1661 'reason' => 'The previous synthetic fingerprint represented product, date and count, not a shared view-event identity. The old exact-match decision is therefore unverified.', 1662 'totals_changed' => false, 1663 'applied_views_changed' => false, 1664 ]; 1665 $encoded_evidence = wp_json_encode($evidence); 1666 if (!is_string($encoded_evidence)) { 1667 $failed = true; 1668 return false; 1669 } 1670 1671 if (!$this->generic_advisory_lock_session_is_current($migration_lock)) { 1672 $failed = true; 1673 return false; 1674 } 1675 1676 $migration_transaction_started = false; 1677 $migration_transaction_committed = false; 1678 $transaction_started = (statpl_db_query('START TRANSACTION') !== false); 1679 if (!$transaction_started) { 1680 $failed = true; 1681 return false; 1682 } 1683 1684 $migration_transaction_started = true; 1685 if (!$this->generic_advisory_lock_session_is_current($migration_lock)) { 1686 // Lock ownership and connection identity are separate checks. 1687 // Roll back only when this is still the original DB session. 1688 if ($this->generic_db_session_matches_lock($migration_lock)) { 1689 statpl_db_query('ROLLBACK'); 1690 $migration_transaction_started = false; 1691 } 1692 $failed = true; 1693 return false; 1694 } 1695 $updated = statpl_db_update( 1696 $this->view_source_reconciliation_table, 1697 [ 1698 'status' => 'legacy_exact_unverified', 1699 'reason_code' => 'legacy_aggregate_fingerprint_unverified', 1700 'reason' => 'Legacy exact match based on product/date/count only; actual shared view identity was not established. Totals were intentionally left unchanged pending a controlled reconciliation decision.', 1701 'evidence' => $encoded_evidence, 1702 'algorithm_version' => '1.2.0', 1703 'updated_at' => current_time('mysql'), 1704 ], 1705 ['event_id' => $event_id, 'status' => 'matched_exact'] 1706 ); 1707 $history = $updated === 1 ? statpl_db_insert( 1708 $this->view_source_reconciliation_history_table, 1709 [ 1710 'event_id' => $event_id, 1711 'previous_status' => 'matched_exact', 1712 'new_status' => 'legacy_exact_unverified', 1713 'reason_code' => 'legacy_aggregate_fingerprint_unverified', 1714 'reason' => 'Reclassified because the legacy aggregate fingerprint did not prove shared view-event identity. No totals or applied_views were changed.', 1715 'matched_event_id' => (int) $row['matched_event_id'], 1716 'evidence' => $encoded_evidence, 1717 'algorithm_version' => '1.2.0', 1718 'created_at' => current_time('mysql'), 1719 ] 1720 ) : false; 1721 1722 if ($updated !== 1 || $history !== 1 1723 || !$this->generic_advisory_lock_session_is_current($migration_lock) 1724 || statpl_db_query('COMMIT') === false) { 1725 // Roll back only if the original lock-owning session is still current. 1726 // A reconnect implicitly abandons the old transaction; never issue 1727 // transaction control against an unverified replacement session. 1728 if ($migration_transaction_started && $this->generic_db_session_matches_lock($migration_lock)) { 1729 statpl_db_query('ROLLBACK'); 1730 $migration_transaction_started = false; 1731 } 1732 $failed = true; 1733 return false; 1734 } 1735 $migration_transaction_committed = true; 1736 $migration_transaction_started = false; 1737 } 1738 1739 $cursor = $event_id; 1740 // Persist the cursor only while this exact MySQL session still owns 1741 // the migration lock. A PHP-level check followed by update_option() 1742 // would leave a reconnect race between the check and the write. 1743 $cursor_persisted = statpl_db_query(statpl_db_prepare( 1744 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) 1745 SELECT %s, %s, 'no' 1746 WHERE IS_USED_LOCK(%s) = CONNECTION_ID() 1747 ON DUPLICATE KEY UPDATE option_value = IF( 1748 IS_USED_LOCK(%s) = CONNECTION_ID(), 1749 VALUES(option_value), 1750 option_value 1751 )", 1752 $cursor_option, 1753 (string) $cursor, 1754 $lock_name, 1755 $lock_name 1756 )); 1757 if (!in_array($cursor_persisted, [0, 1, 2], true) 1758 || !$this->generic_advisory_lock_session_is_current($migration_lock)) { 1759 $failed = true; 1760 return false; 1761 } 1762 $persisted_cursor = statpl_db_get_var(statpl_db_prepare( 1763 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 1764 $cursor_option 1765 )); 1766 if (!empty($wpdb->last_error) || (int) $persisted_cursor !== $cursor) { 1767 $failed = true; 1768 return false; 1769 } 1770 $processed++; 1771 } 1772 1773 if (count($rows) < $batch_size) { 1774 // Completion is also lock-owner guarded so a stale/reconnected 1775 // worker cannot mark an unfinished migration complete. 1776 $complete_persisted = statpl_db_query(statpl_db_prepare( 1777 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) 1778 SELECT %s, '1', 'no' 1779 WHERE IS_USED_LOCK(%s) = CONNECTION_ID() 1780 ON DUPLICATE KEY UPDATE option_value = IF( 1781 IS_USED_LOCK(%s) = CONNECTION_ID(), '1', option_value 1782 )", 1783 $complete_option, 1784 $lock_name, 1785 $lock_name 1786 )); 1787 $persisted_completion = statpl_db_get_var(statpl_db_prepare( 1788 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 1789 $complete_option 1790 )); 1791 $completion_verified = in_array($complete_persisted, [0, 1, 2], true) 1792 && $this->generic_advisory_lock_session_is_current($migration_lock) 1793 && empty($wpdb->last_error) 1794 && (string) $persisted_completion === '1'; 1795 if (!$completion_verified) { 1796 $failed = true; 1797 } 1798 return $completion_verified; 1799 } 1800 1801 $this->schedule_legacy_aggregate_reconciliation_migration(30); 1802 return true; 1803 } catch (Throwable $exception) { 1804 if (defined('WP_DEBUG') && WP_DEBUG) { 1805 $this->debug_log('[Statora Plus] Legacy aggregate reconciliation audit migration failed: ' . $exception->getMessage()); 1806 } 1807 $failed = true; 1808 return false; 1809 } finally { 1810 if ($migration_transaction_started && !$migration_transaction_committed 1811 && $this->generic_db_session_matches_lock($migration_lock)) { 1812 statpl_db_query('ROLLBACK'); 1813 $migration_transaction_started = false; 1814 } 1815 $this->release_generic_advisory_lock($migration_lock, 'legacy_aggregate_fingerprint_migration'); 1816 if ($failed) { 1817 $this->schedule_legacy_aggregate_reconciliation_migration(60); 1818 if (defined('WP_DEBUG') && WP_DEBUG) { 1819 $this->debug_log('[Statora Plus] Legacy aggregate reconciliation audit migration was rescheduled after a failed batch.'); 1820 } 1821 } 1822 } 1823 } 1824 1825 /** 1826 * Schedule the audit migration outside the normal frontend initialization path. 1827 * Action Scheduler is preferred; WP-Cron remains the compatibility fallback. 1828 */ 1829 public function schedule_legacy_aggregate_reconciliation_migration($delay = 15) { 1830 if (get_option('statpl_legacy_aggregate_fp_migration_v1_complete', '') === '1') { 1831 return; 1832 } 1833 1834 $delay = max(5, (int) $delay); 1835 $hook = 'statpl_reconcile_legacy_aggregate_fingerprints'; 1836 $args = []; 1837 $group = 'statpl-plus'; 1838 1839 if (function_exists('as_schedule_single_action') && !did_action('action_scheduler_init')) { 1840 add_action('action_scheduler_init', [$this, 'schedule_legacy_aggregate_reconciliation_migration'], 10, 0); 1841 return; 1842 } 1843 1844 if ( 1845 function_exists('as_has_scheduled_action') 1846 && did_action('action_scheduler_init') 1847 && as_has_scheduled_action($hook, $args, $group) 1848 ) { 1849 return; 1850 } 1851 1852 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 1853 $action_id = as_schedule_single_action(time() + $delay, $hook, $args, $group); 1854 if ($action_id) { 1855 return; 1856 } 1857 } 1858 1859 if (!wp_next_scheduled($hook, $args)) { 1860 wp_schedule_single_event(time() + $delay, $hook, $args); 1861 } 1862 } 1863 1864 public function init_legacy_aggregate_reconciliation_migration() { 1865 $this->migrate_legacy_aggregate_fingerprint_matches(); 1866 } 1867 1868 public function init() { 1869 // Register runtime hooks before touching the database. If MySQL is 1870 // temporarily unavailable during plugins_loaded, frontend/AJAX and 1871 // WooCommerce hooks must still exist for the remainder of this request. 110 1872 add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend']); 111 1873 add_action('wp_ajax_statpl_record_view', [$this, 'record_view']); … … 113 1875 add_action('admin_menu', [$this, 'admin_menu']); 114 1876 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_styles']); 1877 add_action('admin_enqueue_scripts', [$this, 'enqueue_deactivation_prompt']); 115 1878 add_action('admin_notices', [$this, 'woocommerce_notice']); 1879 add_action('admin_footer', [$this, 'enqueue_historical_notice_script']); 116 1880 add_action('woocommerce_order_status_cancelled', [$this, 'record_cancel_event'], 10, 2); 117 1881 add_action('woocommerce_order_refunded', [$this, 'record_refund_event'], 10, 2); 118 // Invalidate cached aggregates when order data or status changes.119 1882 foreach (['woocommerce_new_order', 'woocommerce_update_order', 'woocommerce_order_status_changed', 'woocommerce_order_payment_status_changed', 'woocommerce_saved_order_items'] as $hook) { 120 1883 add_action($hook, [$this, 'invalidate_order_stats_cache'], 10, 3); 121 1884 } 122 1885 add_action('woocommerce_order_refunded', [$this, 'invalidate_order_stats_cache'], 20, 2); 123 // A refund changes net quantity/revenue in the order snapshot as well as refund-event history.124 1886 add_action('woocommerce_order_refunded', [$this, 'queue_order_snapshot'], 30, 2); 125 1887 add_action('woocommerce_before_delete_order', [$this, 'delete_order_snapshot'], 10, 1); … … 128 1890 } 129 1891 add_action('statpl_import_batch', [$this, 'run_import_batch']); 1892 add_action('statpl_view_import_batch', [$this, 'run_slimstat_view_import_batch']); 1893 add_action('statpl_generic_view_import_batch', [$this, 'run_generic_historical_view_import_batch'], 10, 2); 1894 add_action('statpl_generic_view_snapshot_retry', [$this, 'retry_generic_historical_view_snapshot'], 10, 2); 1895 add_action('statpl_generic_view_retry_failed_records', [$this, 'retry_generic_historical_view_failures'], 10, 2); 130 1896 add_action('statpl_sync_order', [$this, 'sync_order_snapshot'], 10, 1); 131 if (!get_option('statpl_import_complete')) { $this->schedule_import_batch(); } 132 } 1897 add_action('statpl_reconcile_legacy_aggregate_fingerprints', [$this, 'init_legacy_aggregate_reconciliation_migration']); 1898 1899 global $wpdb; 1900 // Do not interpret failed option/table reads as missing state. A simple 1901 // round-trip probe distinguishes a usable connection from a DB outage. 1902 try { 1903 $database_ready = isset($wpdb) && is_object($wpdb) 1904 && (string) statpl_db_get_var('SELECT 1') === '1' 1905 && empty($wpdb->last_error); 1906 } catch (Throwable $exception) { 1907 $database_ready = false; 1908 } 1909 if (!$database_ready) { 1910 if (defined('WP_DEBUG') && WP_DEBUG) { 1911 $this->debug_log('[Statora Plus] Database unavailable during initialization; schema upgrades and historical discovery will retry on the next request. Runtime hooks remain registered.'); 1912 } 1913 return; 1914 } 1915 1916 try { 1917 if (get_option('statpl_db_version') !== self::VERSION) { 1918 // Core tables must be verified before historical schema upgrades 1919 // or adapter discovery can run. A failed dbDelta pass must leave 1920 // the version marker unchanged so the next request retries it. 1921 if (!$this->activate()) { 1922 if (defined('WP_DEBUG') && WP_DEBUG) { 1923 $this->debug_log('[Statora Plus] Core schema verification failed; initialization will retry on the next request.'); 1924 } 1925 return; 1926 } 1927 } 1928 $this->maybe_upgrade_view_import_schema(); 1929 $this->maybe_upgrade_view_import_failures_schema(); 1930 $this->maybe_upgrade_view_import_state_schema(); 1931 $this->maybe_upgrade_view_import_events_schema(); 1932 $this->maybe_upgrade_view_source_schema(); 1933 $this->maybe_upgrade_historical_import_report_schema(); 1934 } catch (Throwable $exception) { 1935 if (defined('WP_DEBUG') && WP_DEBUG) { 1936 $this->debug_log('[Statora Plus] Schema initialization interrupted; version markers remain subject to verification and initialization will retry on the next request.'); 1937 } 1938 return; 1939 } 1940 1941 // Schema helpers intentionally leave their version markers unchanged 1942 // when verification fails. Do not let discovery/workers query partially 1943 // initialized tables; the next request will retry the outstanding steps. 1944 $historical_schema_ready = 1945 get_option('statpl_view_import_schema_version', '') === self::VIEW_IMPORT_SCHEMA_VERSION 1946 && get_option('statpl_view_import_failures_schema_version', '') === self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION 1947 && get_option('statpl_view_import_state_schema_version', '') === self::VIEW_IMPORT_STATE_SCHEMA_VERSION 1948 && get_option('statpl_view_import_events_schema_version', '') === self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION 1949 && get_option('statpl_view_source_schema_version', '') === self::VIEW_SOURCE_SCHEMA_VERSION 1950 && get_option('statpl_history_report_schema_version', '') === self::HISTORY_REPORT_SCHEMA_VERSION; 1951 if (!$historical_schema_ready) { 1952 if (defined('WP_DEBUG') && WP_DEBUG) { 1953 $this->debug_log('[Statora Plus] Historical schema is not fully verified; historical discovery and recovery are deferred until a later request.'); 1954 } 1955 return; 1956 } 1957 1958 // Schedule the audit migration outside the normal request path. The worker 1959 // uses bounded batches and a connection-scoped lock for safe continuation. 1960 $this->schedule_legacy_aggregate_reconciliation_migration(15); 1961 1962 // Keep each startup subsystem isolated. A scheduler or adapter-provider 1963 // failure in one subsystem must not prevent the remaining durable work 1964 // from being discovered and recovered on this request. 1965 if (!get_option('statpl_import_complete')) { 1966 try { 1967 $this->schedule_import_batch(); 1968 } catch (Throwable $exception) { 1969 if (defined('WP_DEBUG') && WP_DEBUG) { 1970 $this->debug_log('[Statora Plus] Core import scheduling failed; it will be retried on a later request.'); 1971 } 1972 } 1973 } 1974 1975 // SlimStat remains on its existing, independent initialization path. 1976 // Contain unexpected failures at the call site so they cannot block 1977 // generic adapter discovery or recovery. 1978 try { 1979 $slimstat_result = $this->initialize_slimstat_view_import(); 1980 if (isset($slimstat_result['status']) && $slimstat_result['status'] !== 'already_initialized') { 1981 update_option('statpl_history_slimstat_probe', sanitize_key((string) $slimstat_result['status']), false); 1982 } elseif (($slimstat_result['status'] ?? '') === 'already_initialized') { 1983 $slimstat_state_status = statpl_db_get_var(statpl_db_prepare( 1984 "SELECT status FROM {$this->view_import_state_table} WHERE source = %s", 'slimstat' 1985 )); 1986 if (in_array((string) $slimstat_state_status, ['pending','running'], true)) { 1987 $slimstat_probe_result = $this->get_slimstat_view_snapshot(); 1988 update_option('statpl_history_slimstat_probe', sanitize_key((string) ($slimstat_probe_result['status'] ?? 'database_error')), false); 1989 } 1990 } 1991 if ( 1992 is_array($slimstat_result) 1993 && isset($slimstat_result['status']) 1994 && in_array($slimstat_result['status'], ['initialized', 'already_initialized'], true) 1995 ) { 1996 try { 1997 $this->schedule_slimstat_view_import_batch(); 1998 } catch (Throwable $exception) { 1999 if (defined('WP_DEBUG') && WP_DEBUG) { 2000 $this->debug_log('[Statora Plus] SlimStat import scheduling failed; its existing recovery path will retry.'); 2001 } 2002 } 2003 } 2004 } catch (Throwable $exception) { 2005 if (defined('WP_DEBUG') && WP_DEBUG) { 2006 $this->debug_log('[Statora Plus] SlimStat initialization failed; generic historical sources will continue independently.'); 2007 } 2008 } 2009 2010 // Discovery failure must not suppress recovery of already persisted 2011 // work. Conversely, recovery is attempted even if discovery throws. 2012 try { 2013 $generic_result = $this->discover_historical_view_adapters(); 2014 update_option('statpl_history_discovery_status', sanitize_key((string) ($generic_result['status'] ?? 'unknown')), false); 2015 if (!empty($generic_result['registered'])) { 2016 try { 2017 $this->schedule_generic_historical_view_import_batch(15); 2018 } catch (Throwable $exception) { 2019 if (defined('WP_DEBUG') && WP_DEBUG) { 2020 $this->debug_log('[Statora Plus] Generic historical import scheduling failed; durable work will be recovered later.'); 2021 } 2022 } 2023 } 2024 } catch (Throwable $exception) { 2025 if (defined('WP_DEBUG') && WP_DEBUG) { 2026 $this->debug_log('[Statora Plus] Generic adapter discovery failed; persisted historical work recovery will still run.'); 2027 } 2028 } 2029 2030 if (get_option('statpl_retained_deactivation_at', '') !== '') { 2031 $gap_result = $this->resume_retained_history_gap(); 2032 if (($gap_result['status'] ?? '') === 'reset') { 2033 try { 2034 $this->schedule_slimstat_view_import_batch(15); 2035 } catch (Throwable $exception) { 2036 if (defined('WP_DEBUG') && WP_DEBUG) { 2037 $this->debug_log('[Statora Plus] Retained history recovery scheduling failed; it will be retried on a later request.'); 2038 } 2039 } 2040 } 2041 } 2042 2043 try { 2044 $this->recover_unscheduled_generic_historical_work(); 2045 } catch (Throwable $exception) { 2046 if (defined('WP_DEBUG') && WP_DEBUG) { 2047 $this->debug_log('[Statora Plus] Generic historical work recovery failed; it will be retried on a later request.'); 2048 } 2049 } 2050 $lifecycle = $this->get_historical_import_lifecycle_state(); 2051 update_option('statpl_history_import_state', sanitize_key((string) ($lifecycle['state'] ?? 'importing')), false); 2052 } 2053 133 2054 public function woocommerce_notice() { 134 2055 if (!current_user_can('manage_options') || class_exists('WooCommerce')) return; … … 163 2084 $now = current_time('mysql'); 164 2085 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this atomic view-count write must not be cached. 165 $wpdb->query($wpdb->prepare(2086 statpl_db_query(statpl_db_prepare( 166 2087 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Internal table identifier uses the WordPress prefix plus a fixed plugin suffix; product ID is prepared. 167 2088 "INSERT INTO {$this->views_table} (product_id, total_views) VALUES (%d, 1) … … 169 2090 )); 170 2091 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Event writes must be persisted immediately and cannot be cached. 171 $wpdb->insert($this->events_table, ['product_id' => $product_id, 'viewed_at' => $now], ['%d','%s']);2092 statpl_db_insert($this->events_table, ['product_id' => $product_id, 'viewed_at' => $now], ['%d','%s']); 172 2093 setcookie($cookie, '1', time() + 1200, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); 173 2094 $_COOKIE[$cookie] = '1'; … … 175 2096 $cutoff = gmdate('Y-m-d H:i:s', time() - 172800); 176 2097 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is internal and the cutoff is prepared; this cleanup deletes expired rows, so caching is not applicable. 177 $wpdb->query($wpdb->prepare("DELETE FROM {$this->events_table} WHERE viewed_at < %s", get_date_from_gmt($cutoff)));2098 statpl_db_query(statpl_db_prepare("DELETE FROM {$this->events_table} WHERE viewed_at < %s", get_date_from_gmt($cutoff))); 178 2099 wp_send_json_success(['counted' => true]); 179 2100 } … … 187 2108 // This prevents a later snapshot sync from making an older cancellation appear to happen today. 188 2109 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the query values are prepared. 189 $event_at = $wpdb->get_var($wpdb->prepare(2110 $event_at = statpl_db_get_var(statpl_db_prepare( 190 2111 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; both query values are prepared. 191 2112 "SELECT MIN(event_at) FROM {$this->order_events_table} WHERE order_id = %d AND event_type = %s AND event_ref_id = 0", … … 280 2201 // from being counted more than once in the store overview. 281 2202 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared. 282 $wpdb->query($wpdb->prepare(2203 statpl_db_query(statpl_db_prepare( 283 2204 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared. 284 2205 "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, 0, %s, %d, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))", … … 288 2209 foreach (array_keys($product_ids) as $product_id) { 289 2210 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared. 290 $wpdb->query($wpdb->prepare(2211 statpl_db_query(statpl_db_prepare( 291 2212 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared. 292 2213 "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, %d, %s, %d, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))", … … 309 2230 // Product ID 0 is reserved for order-level dashboard aggregation. 310 2231 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared. 311 $wpdb->query($wpdb->prepare(2232 statpl_db_query(statpl_db_prepare( 312 2233 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared. 313 2234 "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, 0, %s, 0, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))", … … 316 2237 foreach ($this->order_product_ids($order) as $product_id) { 317 2238 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared. 318 $wpdb->query($wpdb->prepare(2239 statpl_db_query(statpl_db_prepare( 319 2240 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared. 320 2241 "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, %d, %s, 0, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))", … … 325 2246 delete_transient('statpl_global_summary_v2'); 326 2247 } 2248 /** Add a two-choice confirmation dialog to this plugin's Plugins-screen action. */ 2249 public function enqueue_deactivation_prompt($hook_suffix) { 2250 if ($hook_suffix !== 'plugins.php' || !current_user_can('activate_plugins')) { 2251 return; 2252 } 2253 wp_enqueue_script('jquery-core'); 2254 $config = [ 2255 'plugin' => plugin_basename(__FILE__), 2256 'endpoint' => admin_url('admin-post.php'), 2257 'nonce' => wp_create_nonce('statpl_prepare_deactivation'), 2258 'title' => 'Απενεργοποίηση Statora Plus', 2259 'message' => 'Τι θέλεις να γίνει με τα στατιστικά και τα δεδομένα του Statora Plus;', 2260 'retain' => 'Ναι, διατήρηση όλων των δεδομένων', 2261 'delete' => 'Όχι, διαγραφή όλων των δεδομένων', 2262 'cancel' => 'Ακύρωση', 2263 'warning' => 'Η διαγραφή δεν μπορεί να αναιρεθεί.', 2264 ]; 2265 wp_add_inline_script('jquery-core', 'window.StatoraDeactivatePrompt = ' . wp_json_encode($config) . ';', 'before'); 2266 $js = <<<'STATPL_DEACTIVATION_JS' 2267 (function () { 2268 var cfg = window.StatoraDeactivatePrompt; 2269 if (!cfg) return; 2270 function findDeactivateLink(target) { 2271 var link = target.closest('a'); 2272 if (!link) return null; 2273 try { 2274 var url = new URL(link.href, window.location.href); 2275 if (url.searchParams.get('action') !== 'deactivate' || url.searchParams.get('plugin') !== cfg.plugin) return null; 2276 return link; 2277 } catch (e) { return null; } 2278 } 2279 document.addEventListener('click', function (event) { 2280 var link = findDeactivateLink(event.target); 2281 if (!link) return; 2282 event.preventDefault(); 2283 event.stopImmediatePropagation(); 2284 if (document.getElementById('statpl-deactivation-dialog')) return; 2285 var dialog = document.createElement('div'); 2286 dialog.id = 'statpl-deactivation-dialog'; 2287 dialog.style.cssText = 'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:20px'; 2288 var panel = document.createElement('div'); 2289 panel.style.cssText = 'background:#fff;max-width:520px;width:100%;padding:24px;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,.25);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'; 2290 var title = document.createElement('h2'); title.textContent = cfg.title; title.style.marginTop = '0'; 2291 var message = document.createElement('p'); message.textContent = cfg.message; 2292 var warning = document.createElement('p'); warning.textContent = cfg.warning; warning.style.color = '#b32d2e'; 2293 var actions = document.createElement('div'); actions.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;margin-top:20px'; 2294 function button(label, choice, primary) { 2295 var b = document.createElement('button'); b.type = 'button'; b.textContent = label; 2296 b.className = primary ? 'button button-primary' : 'button'; 2297 b.addEventListener('click', function () { 2298 if (!choice) { dialog.remove(); return; } 2299 var destination = new URL(cfg.endpoint, window.location.href); 2300 destination.searchParams.set('action', 'statpl_prepare_deactivation'); 2301 destination.searchParams.set('choice', choice); 2302 destination.searchParams.set('_wpnonce', cfg.nonce); 2303 destination.searchParams.set('redirect_to', link.href); 2304 window.location.assign(destination.toString()); 2305 }); 2306 actions.appendChild(b); 2307 } 2308 button(cfg.retain, 'retain', true); 2309 button(cfg.delete, 'delete', false); 2310 button(cfg.cancel, '', false); 2311 panel.appendChild(title); panel.appendChild(message); panel.appendChild(warning); panel.appendChild(actions); 2312 dialog.appendChild(panel); document.body.appendChild(dialog); 2313 dialog.addEventListener('click', function (e) { if (e.target === dialog) dialog.remove(); }); 2314 }, true); 2315 })(); 2316 STATPL_DEACTIVATION_JS; 2317 wp_add_inline_script('jquery-core', $js, 'after'); 2318 } 2319 327 2320 public function enqueue_admin_styles($hook_suffix) { 328 2321 if ('toplevel_page_statpl-product-metrics' !== $hook_suffix) { … … 333 2326 $css = <<<'STATPL_CSS' 334 2327 .statpl-table-scroll{overflow-x:auto} 335 .statpl-stock-not-tracked{color:#646970} 2328 .statpl-stock-not-tracked{color:#646970}.statpl-product-visibility{display:inline-block;margin-left:7px;color:#646970;font-size:12px;font-weight:400} 336 2329 .statpl-metrics-wrap .nav-tab{border-color:#c9d7ff;background:#f2f6ff;color:#3155F5;font-weight:600} 337 2330 .statpl-metrics-wrap .nav-tab-active{background:#fff;color:#1d2327;border-bottom-color:#fff;border-top:3px solid #3155F5} … … 351 2344 .statpl-metrics-wrap .notice-success.inline p{color:#263b8f} 352 2345 .statpl-metrics-wrap .statpl-overview-note{color:#505b6b} 2346 .statpl-history-notice{position:relative;box-sizing:border-box;margin:15px 0;padding:14px 42px 14px 14px;border:1px solid #c5e2a9;border-radius:6px;background:#DDF2C8;color:#000}.statpl-history-notice-importing{background:#D9EEFF;border-color:#b8dcf5}.statpl-history-notice-icon{color:#2271b1;margin-right:6px;vertical-align:middle}.statpl-history-notice p{margin:8px 0;color:#000}.statpl-history-notice strong{color:#000}.statpl-history-notice-close{position:absolute;top:5px;right:8px;padding:0 5px;border:0;background:transparent;color:#44515e;font-size:22px;line-height:1.2;cursor:pointer}.statpl-history-report-link{display:inline-block;margin-top:3px;color:#0073aa;text-decoration:none}.statpl-history-report-link:hover{text-decoration:underline} 353 2347 .statpl-metrics-wrap .statpl-metrics-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin:18px 0 12px}.statpl-metrics-wrap .statpl-sort-form{display:flex;align-items:center;gap:8px}.statpl-metrics-wrap .statpl-metrics-search{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin:0}.statpl-metrics-wrap .statpl-metrics-search input[type=search]{width:min(375px,100%)}.statpl-metrics-wrap .statpl-metrics-search input[type=submit],.statpl-metrics-wrap .button-primary{background:#3155F5!important;border-color:#3155F5!important;color:#fff!important} 354 2348 .statpl-metrics-wrap .widefat{border-color:#c9d7ff;border-collapse:separate;border-spacing:0} … … 366 2360 367 2361 public function admin_menu() { 2362 // The detailed import report is intentionally not a permanent menu item. 2363 // It is reachable only through the temporary notice link for partial or 2364 // technical outcomes, and only during the 15-minute access window. 368 2365 add_menu_page('Statora Plus', 'Statora Plus', 'manage_woocommerce', 'statpl-product-metrics', [$this, 'render_page'], 'dashicons-chart-bar', 56); 2366 } 2367 2368 /** Add the silent 20-second dismissal behavior; manual close cancels the timer. */ 2369 public function enqueue_historical_notice_script() { 2370 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a read-only admin-screen routing check; it does not process form data or perform a state-changing action. Mutating admin actions use their own nonce verification. 2371 if (!isset($_GET['page']) || !in_array(sanitize_key(wp_unslash($_GET['page'])), ['statpl-product-metrics','statpl-history-report'], true)) { return; } 2372 echo '<script>(function(){var n=document.querySelector(".statpl-history-notice");if(!n)return;var t=window.setTimeout(function(){if(n.parentNode)n.parentNode.removeChild(n);},20000);var b=n.querySelector(".statpl-history-notice-close");if(b)b.addEventListener("click",function(){window.clearTimeout(t);if(n.parentNode)n.parentNode.removeChild(n);});})();</script>'; 369 2373 } 370 2374 /** Bulk-load view counts for a product set to avoid two or more queries per product. */ … … 376 2380 return $this->today_start_datetime()->format('Y-m-d H:i:s'); 377 2381 } 378 /** Return a displayable stock quantity without double-counting parent and variation stock. */ 2382 /** 2383 * Return stock quantity and WooCommerce availability without double-counting 2384 * parent-managed and variation-managed inventory. 2385 */ 379 2386 private function stock_summary_for_product($product) { 380 if (!$product) return ['managed' => false, 'quantity' => null]; 2387 if (!$product) { 2388 return ['managed' => false, 'quantity' => null, 'in_stock' => false]; 2389 } 2390 2391 // WooCommerce's native availability API treats both instock and 2392 // onbackorder as purchasable, and respects its stock-status filters. 2393 $in_stock = (bool) $product->is_in_stock(); 2394 2395 // When the parent manages stock, its quantity is authoritative. 2396 // Do not add variation quantities to it. 381 2397 if ($product->managing_stock()) { 382 2398 $quantity = $product->get_stock_quantity(); 383 return ['managed' => true, 'quantity' => is_numeric($quantity) ? (float) $quantity : 0.0]; 384 } 2399 return [ 2400 'managed' => true, 2401 'quantity' => is_numeric($quantity) ? (float) $quantity : 0.0, 2402 'in_stock' => $in_stock, 2403 ]; 2404 } 2405 2406 // Otherwise, sum only variations that explicitly manage their own stock. 385 2407 if ($product->is_type('variable')) { 386 2408 $managed_any = false; … … 388 2410 foreach ($product->get_children() as $variation_id) { 389 2411 $variation = wc_get_product($variation_id); 390 if (!$variation || !$variation->managing_stock()) continue; 2412 if (!$variation || !$variation->managing_stock()) { 2413 continue; 2414 } 391 2415 $managed_any = true; 392 2416 $quantity = $variation->get_stock_quantity(); 393 if (is_numeric($quantity)) $total += (float) $quantity; 394 } 395 if ($managed_any) return ['managed' => true, 'quantity' => $total]; 396 } 397 return ['managed' => false, 'quantity' => null]; 2417 if (is_numeric($quantity)) { 2418 $total += (float) $quantity; 2419 } 2420 } 2421 if ($managed_any) { 2422 return ['managed' => true, 'quantity' => $total, 'in_stock' => $in_stock]; 2423 } 2424 } 2425 2426 return ['managed' => false, 'quantity' => null, 'in_stock' => $in_stock]; 398 2427 } 399 2428 … … 411 2440 $placeholders = implode(',', array_fill(0, count($ids), '%d')); 412 2441 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all query values are prepared. 413 $totals = $wpdb->get_results(414 $wpdb->prepare(2442 $totals = statpl_db_get_results( 2443 statpl_db_prepare( 415 2444 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated; the IN placeholders are generated from the count of integer IDs, and every ID is passed to prepare() as a value. 416 2445 "SELECT product_id, total_views FROM {$this->views_table} WHERE product_id IN ($placeholders)", … … 428 2457 $since = $this->today_start_mysql(); 429 2458 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all query values are prepared. 430 $recent = $wpdb->get_results(431 $wpdb->prepare(2459 $recent = statpl_db_get_results( 2460 statpl_db_prepare( 432 2461 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated; the date and every product ID are passed to prepare() as values. The IN placeholders are generated from the count of integer IDs. 433 2462 "SELECT product_id, COUNT(*) AS views_today FROM {$this->events_table} WHERE viewed_at >= %s AND product_id IN ($placeholders) GROUP BY product_id", … … 471 2500 // Replace a snapshot atomically so readers never see a half-written order. 472 2501 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control is required for an atomic snapshot replacement and cannot be cached. 473 if ( $wpdb->query('START TRANSACTION') === false) return false;2502 if (statpl_db_query('START TRANSACTION') === false) return false; 474 2503 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This write replaces the stored order snapshot inside a transaction; it must execute immediately and must not be cached. 475 $deleted = $wpdb->delete($this->order_items_table, ['order_id' => $order_id], ['%d']);2504 $deleted = statpl_db_delete($this->order_items_table, ['order_id' => $order_id], ['%d']); 476 2505 if ($deleted === false) { 477 2506 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction rollback is required after a failed write; caching is not applicable. 478 $wpdb->query('ROLLBACK');2507 statpl_db_query('ROLLBACK'); 479 2508 return false; 480 2509 } … … 539 2568 foreach ($by_product as $pid => $row) { 540 2569 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- These rows are the authoritative refreshed order snapshot; writes must execute immediately and must not be cached. 541 $inserted = $wpdb->insert($this->order_items_table, [2570 $inserted = statpl_db_insert($this->order_items_table, [ 542 2571 'order_id'=>(int)$order_id, 'product_id'=>(int)$pid, 'order_status'=>$order->get_status(), 543 2572 'created_at'=>$created_at, 'paid_at'=>$paid_at, 'completed_at'=>$completed_at, … … 547 2576 if ($inserted === false) { 548 2577 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction rollback is required after a failed write; caching is not applicable. 549 $wpdb->query('ROLLBACK');2578 statpl_db_query('ROLLBACK'); 550 2579 return false; 551 2580 } 552 2581 } 553 2582 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction commit is required to persist the completed snapshot atomically. 554 if ( $wpdb->query('COMMIT') === false) {555 $wpdb->query('ROLLBACK');2583 if (statpl_db_query('COMMIT') === false) { 2584 statpl_db_query('ROLLBACK'); 556 2585 return false; 557 2586 } … … 574 2603 global $wpdb; 575 2604 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Deleting a removed order snapshot is a required write; caching is not applicable. 576 $wpdb->delete($this->order_items_table, ['order_id'=>absint($order_id)], ['%d']);2605 statpl_db_delete($this->order_items_table, ['order_id'=>absint($order_id)], ['%d']); 577 2606 delete_transient('statpl_order_stats_v1'); 578 2607 delete_transient('statpl_global_summary_v2'); 579 2608 } 2609 /** 2610 * Discover source-specific adapters registered by compatible analytics plugins. 2611 * This registry is intentionally explicit: Statora Plus does not guess the 2612 * meaning of arbitrary third-party database tables. 2613 */ 2614 public function discover_historical_view_adapters() { 2615 global $wpdb; 2616 2617 // Discovery mutates the persisted source/origin inventory and schedules 2618 // snapshot/import work. Serialize it across concurrent WordPress requests 2619 // so two provider scans cannot race while reconciling the same origins. 2620 // Acquire the lock before clearing the request-local registry: a request 2621 // that cannot enter discovery must not discard a still-valid registry. 2622 $lock_name = 'statpl_generic_adapter_discovery'; 2623 $discovery_lock = $this->acquire_generic_advisory_lock($lock_name); 2624 if (empty($discovery_lock['acquired'])) { 2625 $status = isset($discovery_lock['status']) ? $discovery_lock['status'] : 'lock_unavailable'; 2626 if ($status !== 'lock_busy_or_unavailable') { 2627 $this->log_generic_historical_scheduler_error( 2628 'adapter_discovery_lock', 2629 isset($discovery_lock['error']) ? $discovery_lock['error'] : $status 2630 ); 2631 } 2632 return ['status' => $status === 'lock_busy_or_unavailable' ? 'busy' : 'lock_unavailable', 'registered' => 0, 'rejected' => 0]; 2633 } 2634 2635 try { 2636 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2637 return ['status' => 'lock_session_lost', 'registered' => 0, 'rejected' => 0]; 2638 } 2639 return $this->discover_historical_view_adapters_locked($discovery_lock); 2640 } finally { 2641 // Never attempt to release a connection-scoped lock through a replacement session. 2642 $this->release_generic_advisory_lock($discovery_lock, 'adapter_discovery'); 2643 } 2644 } 2645 2646 /** Perform provider discovery while holding the global discovery lock. */ 2647 private function discover_historical_view_adapters_locked($discovery_lock) { 2648 // Invalidate request-local adapter objects before asking providers to 2649 // rebuild the registry. If a provider throws, retaining objects from an 2650 // earlier discovery pass could execute an adapter whose plugin is no 2651 // longer available or whose registration is now failing. Persisted 2652 // adapter identity/snapshot/checkpoint remain untouched in the database. 2653 $this->historical_view_adapters = []; 2654 2655 // A third-party provider callback can throw while the registry filter is 2656 // being assembled. Contain that failure here so it cannot interrupt 2657 // Statora Plus initialization or the independent live-view tracker. 2658 try { 2659 $provided = apply_filters('statpl_historical_view_adapters', []); 2660 } catch (Throwable $exception) { 2661 if (defined('WP_DEBUG') && WP_DEBUG) { 2662 $this->debug_log('Statora Plus: historical adapter registry filter failed: ' . $exception->getMessage()); 2663 } 2664 return ['status' => 'provider_error', 'registered' => 0, 'rejected' => 1]; 2665 } 2666 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2667 return ['status' => 'lock_session_lost', 'registered' => 0, 'rejected' => 0]; 2668 } 2669 if (!is_array($provided)) { 2670 return ['status' => 'invalid_registry', 'registered' => 0, 'rejected' => 0]; 2671 } 2672 2673 // A successful discovery pass is authoritative; the registry was 2674 // cleared before invoking third-party provider callbacks above. 2675 2676 $registered = 0; 2677 $rejected = 0; 2678 $seen_adapter_keys = []; 2679 // A source_key is a persistent namespace, so two different adapter 2680 // identities must not claim it during the same discovery pass—even if 2681 // the first adapter currently reports no origins and therefore has no 2682 // origin row from which the database conflict check could detect it. 2683 $seen_source_keys = []; 2684 2685 foreach ($provided as $adapter) { 2686 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2687 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2688 } 2689 if (!is_object($adapter) || !$this->is_valid_historical_view_adapter($adapter)) { 2690 $rejected++; 2691 continue; 2692 } 2693 2694 try { 2695 $adapter_key = sanitize_key((string) $adapter->get_adapter_key()); 2696 $source_key = sanitize_key((string) $adapter->get_source_key()); 2697 $display_name = sanitize_text_field((string) $adapter->get_display_name()); 2698 $adapter_version = sanitize_text_field((string) $adapter->get_adapter_version()); 2699 2700 // Adapter metadata methods are third-party callbacks. They may 2701 // perform database work or trigger a reconnect; never continue 2702 // discovery writes under an assumed connection after invoking them. 2703 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2704 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2705 } 2706 2707 if ( 2708 $adapter_key === '' 2709 || $source_key === '' 2710 || $display_name === '' 2711 || $adapter_version === '' 2712 || strlen($adapter_key) > 100 2713 || strlen($source_key) > 100 2714 || strlen($display_name) > 191 2715 || strlen($adapter_version) > 32 2716 || isset($seen_adapter_keys[$adapter_key]) 2717 ) { 2718 $rejected++; 2719 continue; 2720 } 2721 2722 // SlimStat already has a mature, separately tested import pipeline. 2723 // It must not be registered a second time through this generic path. 2724 if ($source_key === 'slimstat' || $adapter_key === 'slimstat') { 2725 continue; 2726 } 2727 2728 if (isset($seen_source_keys[$source_key]) && $seen_source_keys[$source_key] !== $adapter_key) { 2729 $rejected++; 2730 if (defined('WP_DEBUG') && WP_DEBUG) { 2731 $this->debug_log('Statora Plus: rejected adapter "' . $adapter_key . '" because source_key "' . $source_key . '" was already claimed by another adapter in this discovery pass.'); 2732 } 2733 continue; 2734 } 2735 $seen_source_keys[$source_key] = $adapter_key; 2736 2737 // A source_key is the persistent namespace for all of its origins. 2738 // Do not allow a different adapter identity to append a second set 2739 // of origins to an existing source, even when the origin_key values 2740 // do not overlap. The discovery advisory lock makes this check and 2741 // the subsequent registration one serialized discovery operation. 2742 global $wpdb; 2743 $conflicting_adapter_key = statpl_db_get_var(statpl_db_prepare( 2744 "SELECT origins.adapter_key 2745 FROM {$this->view_source_origins_table} AS origins 2746 INNER JOIN {$this->view_sources_table} AS sources 2747 ON sources.id = origins.source_id 2748 WHERE sources.source_key = %s 2749 AND (sources.adapter_key <> %s OR origins.adapter_key <> %s) 2750 LIMIT 1", 2751 $source_key, 2752 $adapter_key, 2753 $adapter_key 2754 )); 2755 if (!empty($wpdb->last_error)) { 2756 $rejected++; 2757 $this->log_generic_historical_scheduler_error( 2758 'adapter_identity_lookup', 2759 $wpdb->last_error 2760 ); 2761 continue; 2762 } 2763 if ($conflicting_adapter_key !== null && $conflicting_adapter_key !== '') { 2764 $rejected++; 2765 if (defined('WP_DEBUG') && WP_DEBUG) { 2766 $this->debug_log('Statora Plus: rejected adapter "' . $adapter_key . '" because source_key "' . $source_key . '" is already bound to another adapter identity.'); 2767 } 2768 continue; 2769 } 2770 2771 $origins = $adapter->discover_origins(); 2772 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2773 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2774 } 2775 if (!is_array($origins)) { 2776 $rejected++; 2777 continue; 2778 } 2779 2780 $valid_origins = []; 2781 $seen_origin_keys = []; 2782 $origin_declaration_invalid = false; 2783 foreach ($origins as $origin) { 2784 if (!is_array($origin)) { 2785 $rejected++; 2786 $origin_declaration_invalid = true; 2787 continue; 2788 } 2789 2790 $origin_key = isset($origin['origin_key']) 2791 ? sanitize_key((string) $origin['origin_key']) 2792 : ''; 2793 $capabilities = isset($origin['capabilities']) && is_array($origin['capabilities']) 2794 ? $origin['capabilities'] 2795 : []; 2796 $detection_status = isset($origin['detection_status']) 2797 ? (string) $origin['detection_status'] 2798 : 'unknown'; 2799 2800 // Only explicit product-view capability qualifies as a source. 2801 if ( 2802 $origin_key === '' 2803 || strlen($origin_key) > 100 2804 || isset($seen_origin_keys[$origin_key]) 2805 || empty($capabilities['product_views']) 2806 || !in_array($detection_status, ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true) 2807 ) { 2808 $rejected++; 2809 $origin_declaration_invalid = true; 2810 continue; 2811 } 2812 2813 // Sanitization can collapse distinct provider values to the 2814 // same key. Register each origin only once per discovery pass 2815 // so duplicate declarations cannot overwrite capabilities or 2816 // schedule duplicate snapshot/import initialization work. 2817 $seen_origin_keys[$origin_key] = true; 2818 $valid_origins[] = [ 2819 'origin_key' => $origin_key, 2820 'capabilities' => $capabilities, 2821 'detection_status' => $detection_status, 2822 ]; 2823 } 2824 2825 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2826 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2827 } 2828 $source_result = $this->register_view_source( 2829 $source_key, 2830 $display_name, 2831 $adapter_version, 2832 'event', 2833 'detected', 2834 $adapter_key 2835 ); 2836 2837 if (empty($source_result['source_id'])) { 2838 $rejected++; 2839 continue; 2840 } 2841 2842 // A successful discovery response is authoritative for the 2843 // adapter's current origin inventory. If the provider returns a 2844 // temporarily incomplete (or empty) list, preserve omitted 2845 // origins and their cursors but take them out of active work. 2846 // Do not infer omissions from a malformed/partially rejected 2847 // declaration list, because that is not a trustworthy inventory. 2848 $seen_adapter_keys[$adapter_key] = true; 2849 if (!$origin_declaration_invalid) { 2850 $missing_origins = $this->mark_missing_generic_historical_origins_unavailable( 2851 $source_result['source_id'], 2852 $adapter_key, 2853 array_column($valid_origins, 'origin_key'), 2854 $discovery_lock 2855 ); 2856 if (empty($missing_origins['status']) || $missing_origins['status'] !== 'updated') { 2857 // Do not register or schedule this adapter's newly reported 2858 // origins against a partially reconciled inventory. Keep the 2859 // previous persisted inventory intact and retry on the next 2860 // discovery pass after the database is healthy. 2861 $rejected++; 2862 $this->log_generic_historical_scheduler_error( 2863 'origin_inventory_reconciliation', 2864 isset($missing_origins['error']) ? $missing_origins['error'] : 'Origin inventory reconciliation did not commit.' 2865 ); 2866 continue; 2867 } 2868 } 2869 2870 if (!$valid_origins) { 2871 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2872 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2873 } 2874 $this->refresh_generic_historical_source_status($source_result['source_id']); 2875 continue; 2876 } 2877 2878 $registered_origins = []; 2879 $origin_registration_failures = 0; 2880 $adapter_mismatch_detected = false; 2881 foreach ($valid_origins as $origin) { 2882 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2883 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2884 } 2885 // Isolate each origin completely. A provider/database exception 2886 // while registering one origin must not prevent sibling origins 2887 // from being registered and scheduled in this discovery pass. 2888 try { 2889 $origin_result = $this->register_view_source_origin( 2890 $source_result['source_id'], 2891 $origin['origin_key'], 2892 $adapter_key, 2893 $origin['capabilities'], 2894 $origin['detection_status'] 2895 ); 2896 } catch (Throwable $exception) { 2897 $origin_result = [ 2898 'status' => 'origin_exception', 2899 'error' => $exception->getMessage(), 2900 ]; 2901 if (defined('WP_DEBUG') && WP_DEBUG) { 2902 $this->debug_log('Statora Plus: origin registration failed for "' . $origin['origin_key'] . '": ' . $exception->getMessage()); 2903 } 2904 } 2905 2906 if (!empty($origin_result['status']) && $origin_result['status'] === 'registered') { 2907 $registered_origins[] = $origin; 2908 } elseif (!empty($origin_result['status']) && $origin_result['status'] === 'adapter_mismatch') { 2909 $adapter_mismatch_detected = true; 2910 $origin_registration_failures++; 2911 } else { 2912 $origin_registration_failures++; 2913 } 2914 } 2915 if ($adapter_mismatch_detected) { 2916 // Preserve the established identity and cursor of the mismatched 2917 // origin, but do not let that isolated conflict block sibling 2918 // origins that were registered successfully with this adapter. 2919 $this->refresh_generic_historical_source_status($source_result['source_id']); 2920 } 2921 2922 if ($registered_origins) { 2923 $this->historical_view_adapters[$adapter_key] = $adapter; 2924 $snapshot_result = $this->initialize_generic_historical_view_snapshots( 2925 $source_result['source_id'], 2926 $adapter_key, 2927 $registered_origins, 2928 $discovery_lock 2929 ); 2930 if (($snapshot_result['status'] ?? '') === 'lock_session_lost' 2931 || !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2932 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2933 } 2934 $this->refresh_generic_historical_source_status($source_result['source_id']); 2935 $registered++; 2936 if (!empty($snapshot_result['failed']) || $origin_registration_failures > 0) { 2937 $rejected++; 2938 } 2939 foreach ($registered_origins as $origin) { 2940 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 2941 return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected]; 2942 } 2943 if ($origin['detection_status'] === 'available') { 2944 $this->schedule_generic_historical_view_import_batch(15, $source_result['source_id'], $origin['origin_key']); 2945 } 2946 } 2947 } else { 2948 $rejected++; 2949 } 2950 } catch (Throwable $exception) { 2951 $rejected++; 2952 if (defined('WP_DEBUG') && WP_DEBUG) { 2953 $this->debug_log('Statora Plus: historical adapter discovery failed: ' . $exception->getMessage()); 2954 } 2955 } 2956 } 2957 2958 return [ 2959 'status' => 'completed', 2960 'registered' => $registered, 2961 'rejected' => $rejected, 2962 ]; 2963 } 2964 2965 /** Persist snapshot and its initial checkpoint together, exactly once per origin. */ 2966 private function save_generic_historical_snapshot_if_empty($source_id, $origin_key, $snapshot, $checkpoint) { 2967 global $wpdb; 2968 2969 $source_id = absint($source_id); 2970 $origin_key = is_string($origin_key) ? trim($origin_key) : ''; 2971 if (!$source_id || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 2972 || !is_array($snapshot) || empty($snapshot) || !is_array($checkpoint)) { 2973 return ['status' => 'invalid_input']; 2974 } 2975 2976 $snapshot_json = wp_json_encode($snapshot); 2977 $checkpoint_json = wp_json_encode($checkpoint); 2978 if (!is_string($snapshot_json) || !is_string($checkpoint_json)) { 2979 return ['status' => 'encoding_error']; 2980 } 2981 2982 $result = statpl_db_query(statpl_db_prepare( 2983 "UPDATE {$this->view_source_origins_table} 2984 SET snapshot_data = %s, checkpoint_data = %s, status = %s, 2985 started_at = %s, last_error = NULL, updated_at = %s 2986 WHERE source_id = %d AND origin_key = %s 2987 AND snapshot_data IS NULL AND checkpoint_data IS NULL", 2988 $snapshot_json, 2989 $checkpoint_json, 2990 'snapshot_ready', 2991 current_time('mysql'), 2992 current_time('mysql'), 2993 $source_id, 2994 $origin_key 2995 )); 2996 2997 if ($result === false || $wpdb->last_error !== '') { 2998 return ['status' => 'database_error', 'error' => $wpdb->last_error]; 2999 } 3000 if ($result === 1) { 3001 return ['status' => 'updated', 'source_id' => $source_id, 'origin_key' => $origin_key]; 3002 } 3003 3004 $existing = $this->get_view_source_origin_state($source_id, $origin_key); 3005 if ($existing['status'] !== 'found') { 3006 return $existing; 3007 } 3008 if (is_array($existing['snapshot']) && !empty($existing['snapshot']) 3009 && is_array($existing['checkpoint'])) { 3010 return [ 3011 'status' => 'already_initialized', 3012 'source_id' => $source_id, 3013 'origin_key' => $origin_key, 3014 'snapshot' => $existing['snapshot'], 3015 'checkpoint' => $existing['checkpoint'], 3016 ]; 3017 } 3018 return ['status' => 'initialization_conflict', 'source_id' => $source_id, 'origin_key' => $origin_key]; 3019 } 3020 3021 /** 3022 * Capture an immutable upper-bound snapshot for every detected generic origin. 3023 * Existing snapshots are never overwritten, so retries/re-discovery cannot move 3024 * the historical import boundary forward and accidentally include live traffic. 3025 */ 3026 private function initialize_generic_historical_view_snapshots($source_id, $adapter_key, $origins, $discovery_lock = null) { 3027 $adapter = $this->get_historical_view_adapter($adapter_key); 3028 if (!$adapter) { 3029 return ['status' => 'adapter_unavailable', 'failed' => 1]; 3030 } 3031 3032 $initialized = 0; 3033 $skipped = 0; 3034 $failed = 0; 3035 3036 foreach ($origins as $origin) { 3037 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3038 return [ 3039 'status' => 'lock_session_lost', 3040 'initialized' => $initialized, 3041 'skipped' => $skipped, 3042 'failed' => $failed, 3043 ]; 3044 } 3045 if (!is_array($origin) || empty($origin['origin_key'])) { 3046 $failed++; 3047 continue; 3048 } 3049 if (($origin['detection_status'] ?? '') !== 'available') { 3050 $skipped++; 3051 continue; 3052 } 3053 3054 $state = $this->get_view_source_origin_state($source_id, $origin['origin_key']); 3055 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3056 return [ 3057 'status' => 'lock_session_lost', 3058 'initialized' => $initialized, 3059 'skipped' => $skipped, 3060 'failed' => $failed, 3061 ]; 3062 } 3063 if (empty($state['status']) || $state['status'] !== 'found') { 3064 $failed++; 3065 $failure = $this->record_generic_historical_view_failure($source_id, 3066 $origin['origin_key'], 3067 'snapshot_initialization', 3068 0, 3069 'Could not read the stored origin state while initializing its historical snapshot.', 3070 null, 3071 8, 3072 $discovery_lock); 3073 if (!$failure['blocked']) { 3074 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3075 } 3076 continue; 3077 } 3078 if (is_array($state['snapshot']) && !empty($state['snapshot'])) { 3079 // A persisted snapshot is already valid; clear any stale 3080 // initialization failure only while this discovery worker still 3081 // owns the lock that serializes snapshot-ledger changes. 3082 if (!$this->clear_generic_historical_view_failure($source_id, $origin['origin_key'], 'snapshot_initialization', $discovery_lock)) { 3083 $failed++; 3084 $this->schedule_generic_historical_snapshot_retry(60, $source_id, $origin['origin_key']); 3085 continue; 3086 } 3087 $skipped++; 3088 continue; 3089 } 3090 3091 try { 3092 $snapshot = $adapter->get_origin_snapshot($origin['origin_key']); 3093 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3094 return [ 3095 'status' => 'lock_session_lost', 3096 'initialized' => $initialized, 3097 'skipped' => $skipped, 3098 'failed' => $failed, 3099 ]; 3100 } 3101 if (!is_array($snapshot) || empty($snapshot)) { 3102 $failed++; 3103 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3104 return [ 3105 'status' => 'lock_session_lost', 3106 'initialized' => $initialized, 3107 'skipped' => $skipped, 3108 'failed' => $failed, 3109 ]; 3110 } 3111 $failure = $this->record_generic_historical_view_failure($source_id, 3112 $origin['origin_key'], 3113 'snapshot_initialization', 3114 0, 3115 'Adapter returned an empty or invalid historical snapshot.', 3116 null, 3117 8, 3118 $discovery_lock); 3119 if (!$failure['blocked']) { 3120 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3121 } 3122 continue; 3123 } 3124 3125 // Adapter snapshots must declare their own stable boundary. The core 3126 // records the capture time but does not guess source-specific cursors. 3127 if (!isset($snapshot['captured_at'])) { 3128 $snapshot['captured_at'] = current_time('mysql'); 3129 } 3130 if (!isset($snapshot['boundary']) || !is_array($snapshot['boundary']) || empty($snapshot['boundary'])) { 3131 $failed++; 3132 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3133 return [ 3134 'status' => 'lock_session_lost', 3135 'initialized' => $initialized, 3136 'skipped' => $skipped, 3137 'failed' => $failed, 3138 ]; 3139 } 3140 $failure = $this->record_generic_historical_view_failure($source_id, 3141 $origin['origin_key'], 3142 'snapshot_initialization', 3143 0, 3144 'Adapter snapshot did not provide the required immutable boundary descriptor.', 3145 null, 3146 8, 3147 $discovery_lock); 3148 if (!$failure['blocked']) { 3149 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3150 } 3151 continue; 3152 } 3153 3154 $initial_checkpoint = array_key_exists('initial_checkpoint', $snapshot) 3155 ? $snapshot['initial_checkpoint'] 3156 : null; 3157 // A null initial cursor is valid (many sources begin at the start 3158 // of their history). Non-null cursors must be JSON-safe scalar/array 3159 // values within the same storage limit used for subsequent cursors. 3160 if ($initial_checkpoint !== null && !$this->is_valid_generic_historical_cursor($initial_checkpoint)) { 3161 $failed++; 3162 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3163 return [ 3164 'status' => 'lock_session_lost', 3165 'initialized' => $initialized, 3166 'skipped' => $skipped, 3167 'failed' => $failed, 3168 ]; 3169 } 3170 $failure = $this->record_generic_historical_view_failure($source_id, 3171 $origin['origin_key'], 3172 'snapshot_initialization', 3173 0, 3174 'Adapter snapshot contained an invalid initial checkpoint. Expected null or a JSON-safe scalar/array cursor no larger than 60 KB.', 3175 null, 3176 8, 3177 $discovery_lock); 3178 if (!$failure['blocked']) { 3179 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3180 } 3181 continue; 3182 } 3183 3184 $checkpoint = [ 3185 'cursor' => $initial_checkpoint, 3186 'processed' => 0, 3187 'snapshot_captured_at' => $snapshot['captured_at'], 3188 ]; 3189 unset($snapshot['initial_checkpoint']); 3190 3191 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3192 return [ 3193 'status' => 'lock_session_lost', 3194 'initialized' => $initialized, 3195 'skipped' => $skipped, 3196 'failed' => $failed, 3197 ]; 3198 } 3199 $saved = $this->save_generic_historical_snapshot_if_empty( 3200 $source_id, 3201 $origin['origin_key'], 3202 $snapshot, 3203 $checkpoint 3204 ); 3205 if (empty($saved['status']) || !in_array($saved['status'], ['updated', 'already_initialized'], true)) { 3206 $failed++; 3207 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3208 return [ 3209 'status' => 'lock_session_lost', 3210 'initialized' => $initialized, 3211 'skipped' => $skipped, 3212 'failed' => $failed, 3213 ]; 3214 } 3215 $failure = $this->record_generic_historical_view_failure($source_id, 3216 $origin['origin_key'], 3217 'snapshot_initialization', 3218 0, 3219 'Could not persist the immutable snapshot and initial checkpoint: ' . (isset($saved['status']) ? $saved['status'] : 'unknown error'), 3220 null, 3221 8, 3222 $discovery_lock); 3223 if (!$failure['blocked']) { 3224 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3225 } 3226 continue; 3227 } 3228 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3229 return [ 3230 'status' => 'lock_session_lost', 3231 'initialized' => $initialized, 3232 'skipped' => $skipped, 3233 'failed' => $failed, 3234 ]; 3235 } 3236 $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin['origin_key'], 'snapshot_initialization', $discovery_lock); 3237 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3238 return [ 3239 'status' => 'lock_session_lost', 3240 'initialized' => $initialized, 3241 'skipped' => $skipped, 3242 'failed' => $failed, 3243 ]; 3244 } 3245 if (!$failure_cleared) { 3246 $failed++; 3247 $this->schedule_generic_historical_snapshot_retry(60, $source_id, $origin['origin_key']); 3248 continue; 3249 } 3250 if ($saved['status'] === 'updated') { 3251 $initialized++; 3252 } else { 3253 $skipped++; 3254 } 3255 } catch (Throwable $exception) { 3256 if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3257 return [ 3258 'status' => 'lock_session_lost', 3259 'initialized' => $initialized, 3260 'skipped' => $skipped, 3261 'failed' => $failed, 3262 ]; 3263 } 3264 $failed++; 3265 $failure = $this->record_generic_historical_view_failure($source_id, 3266 $origin['origin_key'], 3267 'snapshot_initialization', 3268 0, 3269 $exception->getMessage(), 3270 null, 3271 8, 3272 $discovery_lock); 3273 if (!$failure['blocked']) { 3274 $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']); 3275 } 3276 if (defined('WP_DEBUG') && WP_DEBUG) { 3277 $this->debug_log('Statora Plus: historical origin snapshot failed: ' . $exception->getMessage()); 3278 } 3279 } 3280 } 3281 3282 return [ 3283 'status' => $failed ? 'partial' : 'completed', 3284 'initialized' => $initialized, 3285 'skipped' => $skipped, 3286 'failed' => $failed, 3287 ]; 3288 } 3289 3290 /** 3291 * Preserve origins omitted by a successful adapter discovery pass without 3292 * allowing stale origins to continue importing. Their immutable adapter 3293 * identity, snapshot, checkpoint, events and failure ledger are untouched. 3294 */ 3295 private function mark_missing_generic_historical_origins_unavailable($source_id, $adapter_key, $discovered_origin_keys, $discovery_lock = null) { 3296 global $wpdb; 3297 3298 $source_id = absint($source_id); 3299 $adapter_key = sanitize_key((string) $adapter_key); 3300 if (!$source_id || $adapter_key === '' || !is_array($discovered_origin_keys)) { 3301 return ['status' => 'invalid_input', 'updated' => 0]; 3302 } 3303 3304 $discovered = []; 3305 foreach ($discovered_origin_keys as $origin_key) { 3306 $origin_key = sanitize_key((string) $origin_key); 3307 if ($origin_key !== '') { 3308 $discovered[$origin_key] = true; 3309 } 3310 } 3311 3312 if (!is_array($discovery_lock) || !$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3313 return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session is no longer current.']; 3314 } 3315 3316 $transaction_started = false; 3317 $transaction_committed = false; 3318 try { 3319 // Reconciliation is one logical inventory operation. Keep it atomic so 3320 // a database error halfway through cannot leave only part of the omitted 3321 // origins marked unavailable. A later discovery can safely retry it. 3322 $begin_result = statpl_db_query('START TRANSACTION'); 3323 if ($begin_result === false || $wpdb->last_error !== '') { 3324 return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error]; 3325 } 3326 $transaction_started = true; 3327 3328 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3329 return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed before origin inventory read.']; 3330 } 3331 $existing = statpl_db_get_results( 3332 statpl_db_prepare( 3333 "SELECT origin_key, status 3334 FROM {$this->view_source_origins_table} 3335 WHERE source_id = %d AND adapter_key = %s 3336 FOR UPDATE", 3337 $source_id, 3338 $adapter_key 3339 ), 3340 ARRAY_A 3341 ); 3342 if (!empty($wpdb->last_error) || !is_array($existing)) { 3343 return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin inventory query returned an invalid result.']; 3344 } 3345 3346 $updated = 0; 3347 foreach ($existing as $row) { 3348 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3349 return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed during origin inventory reconciliation.']; 3350 } 3351 $origin_key = isset($row['origin_key']) ? (string) $row['origin_key'] : ''; 3352 if ($origin_key === '' || isset($discovered[$origin_key])) { 3353 continue; 3354 } 3355 3356 // Terminal lifecycle states are retained. Detection becomes unknown 3357 // until the provider declares this origin again; active work becomes 3358 // unavailable so stale adapter inventories cannot process it. 3359 $updates = [ 3360 'detection_status' => 'unknown', 3361 'updated_at' => current_time('mysql'), 3362 ]; 3363 $formats = ['%s', '%s']; 3364 if (in_array($row['status'] ?? '', ['pending', 'snapshot_ready', 'importing', 'retry_wait'], true)) { 3365 $updates['status'] = 'unavailable'; 3366 $updates['last_error'] = 'This origin was not declared by the adapter during the latest successful discovery pass; its saved snapshot and checkpoint were preserved.'; 3367 $formats = ['%s', '%s', '%s', '%s']; 3368 } 3369 3370 $result = statpl_db_update( 3371 $this->view_source_origins_table, 3372 $updates, 3373 ['source_id' => $source_id, 'origin_key' => $origin_key], 3374 $formats, 3375 ['%d', '%s'] 3376 ); 3377 if ($result === false || $wpdb->last_error !== '') { 3378 return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin reconciliation update failed.']; 3379 } 3380 $updated++; 3381 } 3382 3383 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3384 return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed before origin inventory commit.']; 3385 } 3386 $commit_result = statpl_db_query('COMMIT'); 3387 if ($commit_result === false || $wpdb->last_error !== '') { 3388 return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin reconciliation commit failed.']; 3389 } 3390 $transaction_committed = true; 3391 if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) { 3392 return ['status' => 'commit_outcome_unknown', 'updated' => 0, 'error' => 'Origin inventory commit completed but discovery lock session could not be confirmed.']; 3393 } 3394 return ['status' => 'updated', 'updated' => $updated]; 3395 } catch (Throwable $exception) { 3396 return ['status' => 'database_error', 'updated' => 0, 'error' => $exception->getMessage()]; 3397 } finally { 3398 // Includes thrown exceptions and ambiguous failures before COMMIT. 3399 // A rollback after a successful commit is intentionally avoided. 3400 if ($transaction_started && !$transaction_committed && $this->generic_advisory_lock_session_is_current($discovery_lock)) { 3401 try { 3402 statpl_db_query('ROLLBACK'); 3403 } catch (Throwable $rollback_exception) { 3404 $this->log_generic_historical_scheduler_error('origin_inventory_rollback', $rollback_exception->getMessage()); 3405 } 3406 } 3407 } 3408 } 3409 3410 /** Validate the minimum public adapter contract without trusting a provider. */ 3411 private function is_valid_historical_view_adapter($adapter) { 3412 foreach ([ 3413 'get_adapter_key', 3414 'get_source_key', 3415 'get_display_name', 3416 'get_adapter_version', 3417 'discover_origins', 3418 'get_origin_snapshot', 3419 'get_product_view_batch', 3420 'normalize_record', 3421 ] as $method) { 3422 if (!is_callable([$adapter, $method])) { 3423 return false; 3424 } 3425 } 3426 3427 return true; 3428 } 3429 3430 /** 3431 * Recompute the persisted status of one generic source from all its origins. 3432 * This is intentionally called only by the generic pipeline; the independent 3433 * SlimStat lifecycle and its status model are left untouched. 3434 */ 3435 private function refresh_generic_historical_source_status($source_id) { 3436 global $wpdb; 3437 3438 $source_id = absint($source_id); 3439 if (!$source_id) { 3440 return ['status' => 'invalid_source']; 3441 } 3442 3443 // Serialize aggregate status calculation and persistence per source. 3444 // Use the shared lock contract so the owning connection is captured 3445 // atomically and RELEASE_LOCK is never attempted on a replacement session. 3446 $status_lock_name = 'statpl_status_' . substr(md5((string) $source_id), 0, 32); 3447 $status_lock = $this->acquire_generic_advisory_lock($status_lock_name); 3448 if (empty($status_lock['acquired'])) { 3449 if (($status_lock['status'] ?? '') === 'lock_busy_or_unavailable' && empty($status_lock['error'])) { 3450 return ['status' => 'status_lock_busy']; 3451 } 3452 return ['status' => 'database_error', 'error' => (string) ($status_lock['error'] ?? 'Unable to acquire source status lock.')]; 3453 } 3454 3455 try { 3456 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3457 return ['status' => 'status_lock_lost']; 3458 } 3459 $origins = statpl_db_get_results(statpl_db_prepare( 3460 "SELECT detection_status, status FROM {$this->view_source_origins_table} WHERE source_id = %d", 3461 $source_id 3462 ), ARRAY_A); 3463 if (!empty($wpdb->last_error)) { 3464 return ['status' => 'database_error', 'error' => $wpdb->last_error]; 3465 } 3466 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3467 return ['status' => 'status_lock_lost']; 3468 } 3469 if (!is_array($origins) || !$origins) { 3470 return ['status' => 'no_origins']; 3471 } 3472 3473 $available_origins = array_values(array_filter($origins, function ($origin) { 3474 // Detection describes what the provider reported on its last pass; 3475 // lifecycle status additionally records whether this exact persisted 3476 // adapter identity is currently safe to execute. A replacement 3477 // adapter can be detected as available while its origin remains 3478 // deliberately unavailable because it cannot inherit the old cursor. 3479 return isset($origin['detection_status'], $origin['status']) 3480 && $origin['detection_status'] === 'available' 3481 && $origin['status'] !== 'unavailable'; 3482 })); 3483 $available = count($available_origins); 3484 $available_completed = count(array_filter($available_origins, function ($origin) { 3485 return isset($origin['status']) && $origin['status'] === 'completed'; 3486 })); 3487 // Only available origins participate in import lifecycle aggregation. 3488 // An unavailable/detected sibling must never keep an otherwise finished 3489 // source stuck in importing or retry_wait. 3490 $available_statuses = array_column($available_origins, 'status'); 3491 3492 // A source remains actively importing while ANY available origin is 3493 // still progressing. A blocked sibling origin must not make the whole 3494 // source appear terminal before the remaining origins finish. 3495 if (in_array('importing', $available_statuses, true) || in_array('snapshot_ready', $available_statuses, true) || in_array('pending', $available_statuses, true)) { 3496 $source_status = 'importing'; 3497 } elseif (in_array('retry_wait', $available_statuses, true)) { 3498 $source_status = 'retry_wait'; 3499 } elseif (in_array('needs_attention', $available_statuses, true)) { 3500 $source_status = 'needs_attention'; 3501 } elseif ($available === 0) { 3502 $source_status = 'unavailable'; 3503 } elseif ($available_completed === $available) { 3504 // A source with unavailable origins is only partial, never fully complete. 3505 $source_status = ($available === count($origins)) ? 'completed' : 'partial'; 3506 } elseif (in_array('completed', $available_statuses, true)) { 3507 $source_status = 'partial'; 3508 } else { 3509 $source_status = 'detected'; 3510 } 3511 3512 // Status reconciliation is not a source-registration/content update. 3513 // Keep updated_at reserved for genuine source changes; otherwise the 3514 // recovery scanner would generate needless writes on every request. 3515 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3516 return ['status' => 'status_lock_lost']; 3517 } 3518 // Bind source-status persistence to the same MySQL session that owns 3519 // this status lock. A PHP-side check alone leaves a reconnect race between 3520 // validation and UPDATE; the SQL predicate closes that window. 3521 if (empty($status_lock['lock_name']) || !$this->generic_advisory_lock_session_is_current($status_lock)) { 3522 return ['status' => 'status_lock_lost']; 3523 } 3524 $updated = statpl_db_query(statpl_db_prepare( 3525 "UPDATE {$this->view_sources_table} 3526 SET status = %s 3527 WHERE id = %d AND IS_USED_LOCK(%s) = CONNECTION_ID()", 3528 $source_status, 3529 $source_id, 3530 (string) $status_lock['lock_name'] 3531 )); 3532 if ($updated === false || $wpdb->last_error !== '') { 3533 return ['status' => 'database_error', 'error' => $wpdb->last_error]; 3534 } 3535 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3536 return ['status' => 'status_lock_lost']; 3537 } 3538 3539 // Do not treat an accepted UPDATE as durable source-state convergence. 3540 // The row may have been unchanged (0 affected rows), or an ambiguous 3541 // acknowledgement may have occurred. Read the aggregate status back and 3542 // require an exact match before reporting success to callers. 3543 $persisted_status = statpl_db_get_var(statpl_db_prepare( 3544 "SELECT status FROM {$this->view_sources_table} WHERE id = %d", 3545 $source_id 3546 )); 3547 if ($wpdb->last_error !== '') { 3548 return ['status' => 'database_error', 'error' => $wpdb->last_error]; 3549 } 3550 if (!$this->generic_advisory_lock_session_is_current($status_lock)) { 3551 return ['status' => 'status_lock_lost']; 3552 } 3553 if ($persisted_status === null) { 3554 // The source may have been removed after the bounded scan selected it. 3555 // Treat that as a vanished work item, not as a database failure. 3556 return ['status' => 'source_missing']; 3557 } 3558 if (!is_string($persisted_status) || $persisted_status !== $source_status) { 3559 return [ 3560 'status' => 'state_unverified', 3561 'expected_status' => $source_status, 3562 'persisted_status' => is_string($persisted_status) ? $persisted_status : null, 3563 ]; 3564 } 3565 3566 return ['status' => 'updated', 'source_status' => $source_status]; 3567 } finally { 3568 // Release only if the original connection still owns this lock. 3569 $this->release_generic_advisory_lock($status_lock, 'source_status_reconciliation'); 3570 } 3571 } 3572 3573 /** Retry adapter discovery and snapshot initialization after a bounded delay. */ 3574 public function retry_generic_historical_view_snapshot($source_id = 0, $origin_key = '') { 3575 $source_id = absint($source_id); 3576 $origin_key = sanitize_key((string) $origin_key); 3577 if (!$source_id || $origin_key === '') { 3578 return ['status' => 'invalid_input']; 3579 } 3580 3581 // Discovery re-resolves the provider in this request and retries only origins 3582 // whose immutable snapshot has not yet been persisted. Existing snapshots are 3583 // protected by save_generic_historical_snapshot_if_empty(). 3584 $result = $this->discover_historical_view_adapters(); 3585 $state = $this->get_view_source_origin_state($source_id, $origin_key); 3586 if (!empty($state['status']) && $state['status'] === 'found' 3587 && is_array($state['snapshot']) && !empty($state['snapshot'])) { 3588 // The discovery worker owns the snapshot failure ledger lifecycle. This 3589 // follow-up request has no discovery lock context, so it must not delete 3590 // the ledger row outside that ownership boundary. 3591 $this->schedule_generic_historical_view_import_batch(15, $source_id, $origin_key); 3592 $this->refresh_generic_historical_source_status($source_id); 3593 return ['status' => 'snapshot_ready', 'discovery' => $result]; 3594 } 3595 3596 $this->refresh_generic_historical_source_status($source_id); 3597 return ['status' => 'snapshot_still_unavailable', 'discovery' => $result]; 3598 } 3599 3600 /** Schedule bounded retries for transient snapshot/provider initialization failures. */ 3601 private function schedule_generic_historical_snapshot_retry($delay, $source_id, $origin_key) { 3602 $args = [absint($source_id), sanitize_key((string) $origin_key)]; 3603 $hook = 'statpl_generic_view_snapshot_retry'; 3604 $requested_timestamp = time() + max(1, (int) $delay); 3605 3606 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 3607 $existing_timestamp = false; 3608 if (function_exists('as_next_scheduled_action')) { 3609 try { 3610 $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus'); 3611 } catch (Throwable $exception) { 3612 $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage()); 3613 } 3614 } 3615 3616 // A newly discovered transient failure may have a shorter backoff than 3617 // an older snapshot retry. Keep the earliest deadline, not merely any 3618 // existing action, so stale long delays cannot postpone recovery. 3619 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 3620 return true; 3621 } 3622 if ($existing_timestamp && function_exists('as_unschedule_all_actions')) { 3623 try { 3624 as_unschedule_all_actions($hook, $args, 'statpl-plus'); 3625 } catch (Throwable $exception) { 3626 $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage()); 3627 return true; 3628 } 3629 } elseif ($existing_timestamp) { 3630 return true; 3631 } elseif (function_exists('as_has_scheduled_action')) { 3632 try { 3633 if (as_has_scheduled_action($hook, $args, 'statpl-plus')) { 3634 // Without a reliable timestamp/removal API, retain the 3635 // existing action rather than creating an uncontrolled duplicate. 3636 return true; 3637 } 3638 } catch (Throwable $exception) { 3639 $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage()); 3640 } 3641 } 3642 3643 try { 3644 $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus'); 3645 if ($action_id) { 3646 return true; 3647 } 3648 } catch (Throwable $exception) { 3649 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 3650 } 3651 // Action Scheduler can be unavailable or fail transiently. Fall back to WP-Cron. 3652 } 3653 3654 // WP-Cron follows the same earliest-deadline rule. If replacing a later 3655 // event fails, retain it rather than creating a duplicate with uncertain order. 3656 try { 3657 $existing_timestamp = wp_next_scheduled($hook, $args); 3658 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 3659 return true; 3660 } 3661 if ($existing_timestamp) { 3662 $unscheduled = wp_unschedule_event((int) $existing_timestamp, $hook, $args); 3663 if (!$unscheduled || is_wp_error($unscheduled)) { 3664 $this->log_generic_historical_scheduler_error( 3665 $hook . '_unschedule', 3666 is_wp_error($unscheduled) ? $unscheduled->get_error_message() : 'Could not replace the later WP-Cron event.' 3667 ); 3668 return true; 3669 } 3670 } 3671 $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args); 3672 if (!$scheduled || is_wp_error($scheduled)) { 3673 $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.'); 3674 return false; 3675 } 3676 return true; 3677 } catch (Throwable $exception) { 3678 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 3679 return false; 3680 } 3681 } 3682 3683 /** Requeue persisted generic-import work after a previous scheduler outage. */ 3684 private function recover_unscheduled_generic_historical_work() { 3685 global $wpdb; 3686 3687 $origins = statpl_db_get_results( 3688 "SELECT source_id, origin_key, snapshot_data, status, detection_status 3689 FROM {$this->view_source_origins_table} 3690 WHERE detection_status = 'available' 3691 AND status IN ('pending', 'snapshot_ready', 'importing', 'retry_wait') 3692 ORDER BY updated_at ASC 3693 LIMIT 50", 3694 ARRAY_A 3695 ); 3696 if (!is_array($origins) || !empty($wpdb->last_error)) { 3697 $this->log_generic_historical_scheduler_error('recovery_scan', !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to read pending origins.'); 3698 return 0; 3699 } 3700 3701 $requeued = 0; 3702 foreach ($origins as $origin) { 3703 $source_id = absint($origin['source_id'] ?? 0); 3704 $origin_key = sanitize_key((string) ($origin['origin_key'] ?? '')); 3705 if (!$source_id || $origin_key === '') { 3706 continue; 3707 } 3708 3709 if (empty($origin['snapshot_data'])) { 3710 $scheduled = $this->schedule_generic_historical_snapshot_retry(300, $source_id, $origin_key); 3711 } else { 3712 $scheduled = $this->schedule_generic_historical_view_import_batch(300, $source_id, $origin_key); 3713 } 3714 if ($scheduled) { 3715 $requeued++; 3716 } 3717 } 3718 3719 // Retry rows are durable in the failure ledger. Recreate their worker 3720 // trigger independently of the main import cursor. 3721 $retry_origins = statpl_db_get_results( 3722 "SELECT source_id, origin_key, MIN(next_retry_at) AS next_retry_at 3723 FROM {$this->view_source_failures_table} 3724 WHERE status = 'retry' 3725 AND retry_checkpoint IS NOT NULL 3726 AND retry_checkpoint <> '' 3727 GROUP BY source_id, origin_key 3728 ORDER BY MIN(next_retry_at) ASC 3729 LIMIT 50", 3730 ARRAY_A 3731 ); 3732 if (is_array($retry_origins) && empty($wpdb->last_error)) { 3733 foreach ($retry_origins as $retry_origin) { 3734 $source_id = absint($retry_origin['source_id'] ?? 0); 3735 $origin_key = sanitize_key((string) ($retry_origin['origin_key'] ?? '')); 3736 if (!$source_id || $origin_key === '') { 3737 continue; 3738 } 3739 $next_timestamp = !empty($retry_origin['next_retry_at']) ? strtotime((string) $retry_origin['next_retry_at']) : false; 3740 $delay = $next_timestamp !== false ? max(1, $next_timestamp - current_time('timestamp')) : 300; 3741 if ($this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key)) { 3742 $requeued++; 3743 } 3744 } 3745 } elseif (!empty($wpdb->last_error)) { 3746 $this->log_generic_historical_scheduler_error('retry_recovery_scan', $wpdb->last_error); 3747 } 3748 3749 // Serialize the cursor read, bounded page, and cursor write. Per-source 3750 // locks protect each aggregate update; this scan-level lock additionally 3751 // prevents concurrent requests from moving the persistent cursor backwards. 3752 // Use the shared lock helper so acquisition, ownership checks and release 3753 // follow the same connection-identity contract as the other workers. 3754 $scan_lock_name = 'statpl_srcscan_' . substr(md5((string) $wpdb->prefix), 0, 32); 3755 $scan_lock = $this->acquire_generic_advisory_lock($scan_lock_name); 3756 if (empty($scan_lock['acquired'])) { 3757 $scan_status = sanitize_key((string) ($scan_lock['status'] ?? 'lock_unavailable')); 3758 if ($scan_status !== 'lock_busy_or_unavailable') { 3759 $this->log_generic_historical_scheduler_error( 3760 'source_status_scan_lock', 3761 (string) ($scan_lock['error'] ?? $scan_status) 3762 ); 3763 } 3764 } else { 3765 try { 3766 if (!$this->generic_advisory_lock_session_is_current($scan_lock)) { 3767 $this->log_generic_historical_scheduler_error( 3768 'source_status_scan_lock', 3769 'The MySQL connection changed immediately after acquiring the source-status scan lock; this scan was skipped.' 3770 ); 3771 } else { 3772 // Source-status reconciliation is durable work even when every 3773 // origin is already completed. A persistent keyset cursor ensures 3774 // that a busy or failing source cannot pin the first page and 3775 // starve later sources when more than 50 sources exist. 3776 $cursor_option = 'statpl_generic_status_recovery_cursor'; 3777 // The cursor is written directly with SQL while proving lock 3778 // ownership. Read it directly too: get_option() may return a 3779 // stale object-cache value because direct SQL bypasses the 3780 // WordPress options cache. 3781 $cursor_value = statpl_db_get_var(statpl_db_prepare( 3782 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 3783 $cursor_option 3784 )); 3785 if ($wpdb->last_error !== '') { 3786 $this->log_generic_historical_scheduler_error('source_status_recovery_cursor', $wpdb->last_error); 3787 $cursor_value = '0'; 3788 } 3789 $source_cursor = absint($cursor_value); 3790 $sources = statpl_db_get_results(statpl_db_prepare( 3791 "SELECT sources.id FROM {$this->view_sources_table} AS sources 3792 WHERE sources.id > %d 3793 AND EXISTS ( 3794 SELECT 1 FROM {$this->view_source_origins_table} AS origins 3795 WHERE origins.source_id = sources.id 3796 ) 3797 ORDER BY sources.id ASC 3798 LIMIT 50", 3799 $source_cursor 3800 ), ARRAY_A); 3801 3802 if (!is_array($sources) || !empty($wpdb->last_error)) { 3803 $this->log_generic_historical_scheduler_error( 3804 'source_status_recovery_scan', 3805 !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to read generic sources for status reconciliation.' 3806 ); 3807 } elseif ($this->generic_advisory_lock_session_is_current($scan_lock)) { 3808 // Wrap to the beginning only after reaching the end of the source set. 3809 $wrapped_to_start = false; 3810 if (!$sources && $source_cursor > 0) { 3811 $source_cursor = 0; 3812 $wrapped_to_start = true; 3813 $sources = statpl_db_get_results( 3814 "SELECT sources.id FROM {$this->view_sources_table} AS sources 3815 WHERE EXISTS ( 3816 SELECT 1 FROM {$this->view_source_origins_table} AS origins 3817 WHERE origins.source_id = sources.id 3818 ) 3819 ORDER BY sources.id ASC 3820 LIMIT 50", 3821 ARRAY_A 3822 ); 3823 if (!is_array($sources) || !empty($wpdb->last_error)) { 3824 $this->log_generic_historical_scheduler_error( 3825 'source_status_recovery_scan', 3826 !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to restart generic source status scan.' 3827 ); 3828 $sources = false; 3829 } 3830 } 3831 3832 if (is_array($sources) && $sources) { 3833 $last_scanned_source_id = $source_cursor; 3834 $scan_page_completed = true; 3835 foreach ($sources as $source) { 3836 // If wpdb transparently reconnects, the connection-scoped 3837 // scan lock is gone. Stop and leave the cursor unchanged; 3838 // already attempted rows are safe to revisit. 3839 if (!$this->generic_advisory_lock_session_is_current($scan_lock)) { 3840 $scan_page_completed = false; 3841 $this->log_generic_historical_scheduler_error( 3842 'source_status_scan_lock', 3843 'The MySQL connection changed during source reconciliation; the cursor was not advanced.' 3844 ); 3845 break; 3846 } 3847 3848 $source_id = absint($source['id'] ?? 0); 3849 if (!$source_id) { 3850 continue; 3851 } 3852 $last_scanned_source_id = max($last_scanned_source_id, $source_id); 3853 try { 3854 $reconciliation = $this->refresh_generic_historical_source_status($source_id); 3855 if (!in_array($reconciliation['status'] ?? '', ['updated', 'status_lock_busy', 'source_missing'], true)) { 3856 $this->log_generic_historical_scheduler_error( 3857 'source_status_recovery', 3858 'Source ' . $source_id . ' status reconciliation did not verify: ' . sanitize_key((string) ($reconciliation['status'] ?? 'unknown')) 3859 ); 3860 } 3861 } catch (Throwable $exception) { 3862 // One corrupt or temporarily failing source must not prevent 3863 // the cursor from reaching the remaining sources. 3864 $this->log_generic_historical_scheduler_error( 3865 'source_status_recovery', 3866 'Source ' . $source_id . ' reconciliation threw: ' . $exception->getMessage() 3867 ); 3868 } 3869 } 3870 3871 // Advance only after the complete bounded page was attempted 3872 // and the original scan-lock session is still alive. A source 3873 // failure or lock contention is attempted work, not a reason 3874 // to starve later IDs; a lost scan session is different. 3875 if ($scan_page_completed && $this->generic_advisory_lock_session_is_current($scan_lock)) { 3876 // Persist the cursor only in a SQL statement that proves 3877 // this exact connection still owns the scan lock. A PHP- 3878 // level check followed by update_option() is insufficient: 3879 // wpdb may reconnect between those calls and a stale scan 3880 // could overwrite the cursor of a newer lock owner. 3881 $cursor_persisted = statpl_db_query(statpl_db_prepare( 3882 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) 3883 SELECT %s, %s, 'no' 3884 WHERE IS_USED_LOCK(%s) = CONNECTION_ID() 3885 ON DUPLICATE KEY UPDATE option_value = IF( 3886 IS_USED_LOCK(%s) = CONNECTION_ID(), 3887 VALUES(option_value), 3888 option_value 3889 )", 3890 $cursor_option, 3891 (string) $last_scanned_source_id, 3892 $scan_lock_name, 3893 $scan_lock_name 3894 )); 3895 if (!in_array($cursor_persisted, [0, 1, 2], true)) { 3896 $this->log_generic_historical_scheduler_error( 3897 'source_status_recovery_cursor', 3898 !empty($wpdb->last_error) ? $wpdb->last_error : 'Could not persist the source-status recovery cursor while owning the scan lock.' 3899 ); 3900 } 3901 if (!$this->generic_advisory_lock_session_is_current($scan_lock)) { 3902 $this->log_generic_historical_scheduler_error( 3903 'source_status_recovery_cursor', 3904 'The MySQL connection changed while persisting the cursor; the next scan will safely re-evaluate the cursor.' 3905 ); 3906 } else { 3907 $persisted_cursor_value = statpl_db_get_var(statpl_db_prepare( 3908 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 3909 $cursor_option 3910 )); 3911 $persisted_cursor = absint($persisted_cursor_value); 3912 if (!empty($wpdb->last_error) || $persisted_cursor !== $last_scanned_source_id) { 3913 $this->log_generic_historical_scheduler_error('source_status_recovery_cursor', 'Could not verify the source-status recovery cursor.'); 3914 } 3915 } 3916 } 3917 } elseif (is_array($sources) && !$sources && $wrapped_to_start) { 3918 if ($this->generic_advisory_lock_session_is_current($scan_lock)) { 3919 // Reset only while the current session still owns the 3920 // scan lock; the predicate is checked atomically by SQL. 3921 $reset_result = statpl_db_query(statpl_db_prepare( 3922 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) 3923 SELECT %s, '0', 'no' 3924 WHERE IS_USED_LOCK(%s) = CONNECTION_ID() 3925 ON DUPLICATE KEY UPDATE option_value = IF( 3926 IS_USED_LOCK(%s) = CONNECTION_ID(), '0', option_value 3927 )", 3928 $cursor_option, 3929 $scan_lock_name, 3930 $scan_lock_name 3931 )); 3932 if ($reset_result === false || !empty($wpdb->last_error)) { 3933 $this->log_generic_historical_scheduler_error( 3934 'source_status_recovery_cursor', 3935 !empty($wpdb->last_error) ? $wpdb->last_error : 'Could not reset the source-status recovery cursor.' 3936 ); 3937 } elseif ($this->generic_advisory_lock_session_is_current($scan_lock)) { 3938 $reset_cursor = statpl_db_get_var(statpl_db_prepare( 3939 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 3940 $cursor_option 3941 )); 3942 if (!empty($wpdb->last_error) || absint($reset_cursor) !== 0) { 3943 $this->log_generic_historical_scheduler_error( 3944 'source_status_recovery_cursor', 3945 'Could not verify the reset source-status recovery cursor.' 3946 ); 3947 } 3948 } 3949 } 3950 } 3951 } else { 3952 $this->log_generic_historical_scheduler_error( 3953 'source_status_scan_lock', 3954 'The MySQL connection changed while reading the source-status page; this page was discarded.' 3955 ); 3956 } 3957 } 3958 } catch (Throwable $exception) { 3959 $this->log_generic_historical_scheduler_error('source_status_recovery_scan', $exception->getMessage()); 3960 } finally { 3961 $this->release_generic_advisory_lock($scan_lock, 'source_status_recovery_scan'); 3962 } 3963 } 3964 3965 return $requeued; 3966 } 3967 3968 /** Write diagnostic messages only when WordPress debugging is enabled. */ 3969 private function debug_log($message) { 3970 if (defined('WP_DEBUG') && WP_DEBUG) { 3971 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- This diagnostic logger writes only when the site owner explicitly enables WP_DEBUG. 3972 error_log((string) $message); 3973 } 3974 } 3975 3976 /** Log scheduler failures without allowing diagnostics to interrupt tracking. */ 3977 private function log_generic_historical_scheduler_error($hook, $message) { 3978 $this->debug_log('[Statora Plus] Historical scheduler failure for ' . sanitize_key((string) $hook) . ': ' . sanitize_text_field((string) $message)); 3979 } 3980 3981 /** Schedule retries for deferred records independently from the main import cursor. */ 3982 private function schedule_generic_historical_failure_retry($delay, $source_id, $origin_key) { 3983 $args = [absint($source_id), sanitize_key((string) $origin_key)]; 3984 $hook = 'statpl_generic_view_retry_failed_records'; 3985 $requested_timestamp = time() + max(1, (int) $delay); 3986 3987 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 3988 $existing_timestamp = false; 3989 if (function_exists('as_next_scheduled_action')) { 3990 try { 3991 $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus'); 3992 } catch (Throwable $exception) { 3993 $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage()); 3994 } 3995 } 3996 3997 // Keep an earlier/equal action, but replace a later one. Otherwise a 3998 // newly-created short-backoff failure could wait behind an older, 3999 // long-backoff retry for the same source-origin. 4000 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 4001 return true; 4002 } 4003 if ($existing_timestamp && function_exists('as_unschedule_all_actions')) { 4004 try { 4005 as_unschedule_all_actions($hook, $args, 'statpl-plus'); 4006 } catch (Throwable $exception) { 4007 // The existing action may still be queued. Retain it rather 4008 // than create a duplicate whose execution order is unknown. 4009 $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage()); 4010 return true; 4011 } 4012 } elseif (function_exists('as_has_scheduled_action')) { 4013 try { 4014 if (as_has_scheduled_action($hook, $args, 'statpl-plus')) { 4015 // Without reliable timestamp/removal APIs, retain the 4016 // existing action rather than duplicate the worker. 4017 return true; 4018 } 4019 } catch (Throwable $exception) { 4020 $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage()); 4021 } 4022 } 4023 try { 4024 $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus'); 4025 if ($action_id) { 4026 return true; 4027 } 4028 } catch (Throwable $exception) { 4029 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 4030 } 4031 // Fall through to WP-Cron if Action Scheduler did not accept the action. 4032 } 4033 4034 try { 4035 $existing_timestamp = wp_next_scheduled($hook, $args); 4036 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 4037 return true; 4038 } 4039 if ($existing_timestamp) { 4040 wp_unschedule_event((int) $existing_timestamp, $hook, $args); 4041 } 4042 $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args); 4043 if (!$scheduled || is_wp_error($scheduled)) { 4044 $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.'); 4045 return false; 4046 } 4047 return true; 4048 } catch (Throwable $exception) { 4049 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 4050 return false; 4051 } 4052 } 4053 4054 /** Acquire a connection-scoped advisory lock and capture its owning session. */ 4055 private function acquire_generic_advisory_lock($lock_name) { 4056 global $wpdb; 4057 try { 4058 $row = statpl_db_get_row(statpl_db_prepare( 4059 'SELECT CONNECTION_ID() AS connection_id, GET_LOCK(%s, 0) AS lock_acquired', 4060 (string) $lock_name 4061 ), ARRAY_A); 4062 } catch (Throwable $exception) { 4063 return ['acquired' => false, 'status' => 'lock_error', 'error' => $exception->getMessage()]; 4064 } 4065 if (!is_array($row) || !empty($wpdb->last_error) || (string) ($row['lock_acquired'] ?? '') !== '1' || empty($row['connection_id'])) { 4066 return ['acquired' => false, 'status' => 'lock_busy_or_unavailable', 'error' => (string) $wpdb->last_error]; 4067 } 4068 return ['acquired' => true, 'connection_id' => (string) $row['connection_id'], 'lock_name' => (string) $lock_name]; 4069 } 4070 4071 /** Roll back an open import transaction only on its original DB session. */ 4072 private function rollback_generic_import_transaction(&$transaction_open, $origin_lock) { 4073 global $wpdb; 4074 if (!$transaction_open) { 4075 return true; 4076 } 4077 // Never issue transaction control on a transparently reconnected session. 4078 // The caller may retry once from finally if this attempt fails. 4079 for ($attempt = 0; $attempt < 1; $attempt++) { 4080 if (!$this->generic_db_session_matches_lock($origin_lock)) { 4081 return false; 4082 } 4083 $result = statpl_db_query('ROLLBACK'); 4084 if ($result !== false) { 4085 $transaction_open = false; 4086 return true; 4087 } 4088 } 4089 return false; 4090 } 4091 4092 /** Confirm the current wpdb connection is the session that acquired a lock, regardless of lock ownership. */ 4093 private function generic_db_session_matches_lock($lock) { 4094 global $wpdb; 4095 if (!is_array($lock) || empty($lock['acquired']) || empty($lock['connection_id'])) { 4096 return false; 4097 } 4098 try { 4099 $connection_id = statpl_db_get_var('SELECT CONNECTION_ID()'); 4100 } catch (Throwable $exception) { 4101 return false; 4102 } 4103 return $connection_id !== null 4104 && empty($wpdb->last_error) 4105 && (string) $connection_id === (string) $lock['connection_id']; 4106 } 4107 4108 /** Confirm the current wpdb session still owns the lock-acquiring connection. */ 4109 private function generic_advisory_lock_session_is_current($lock) { 4110 global $wpdb; 4111 if (!is_array($lock) || empty($lock['acquired']) || empty($lock['connection_id'])) { 4112 return false; 4113 } 4114 try { 4115 // Session identity alone is insufficient: code running on the same 4116 // connection could have released or replaced the named lock. Verify 4117 // both the connection and the actual lock owner in one SQL read. 4118 $row = statpl_db_get_row(statpl_db_prepare( 4119 'SELECT CONNECTION_ID() AS connection_id, IS_USED_LOCK(%s) AS lock_owner', 4120 (string) $lock['lock_name'] 4121 ), ARRAY_A); 4122 } catch (Throwable $exception) { 4123 return false; 4124 } 4125 return is_array($row) 4126 && empty($wpdb->last_error) 4127 && (string) ($row['connection_id'] ?? '') === (string) $lock['connection_id'] 4128 && (string) ($row['lock_owner'] ?? '') === (string) $lock['connection_id']; 4129 } 4130 4131 /** Release only on the original session; a lost session releases its own locks server-side. */ 4132 private function release_generic_advisory_lock($lock, $context = '') { 4133 global $wpdb; 4134 if (!$this->generic_advisory_lock_session_is_current($lock)) { 4135 if (defined('WP_DEBUG') && WP_DEBUG) { 4136 $this->debug_log('Statora Plus: advisory lock session changed before release' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . '.'); 4137 } 4138 return false; 4139 } 4140 try { 4141 $released = statpl_db_get_var(statpl_db_prepare('SELECT RELEASE_LOCK(%s)', (string) $lock['lock_name'])); 4142 if ((string) $released === '1' && empty($wpdb->last_error)) { 4143 return true; 4144 } 4145 } catch (Throwable $exception) { 4146 if (defined('WP_DEBUG') && WP_DEBUG) { 4147 $this->debug_log('Statora Plus: advisory lock release failed' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . ': ' . $exception->getMessage()); 4148 } 4149 return false; 4150 } 4151 if (defined('WP_DEBUG') && WP_DEBUG) { 4152 $this->debug_log('Statora Plus: advisory lock release was not confirmed' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . '.'); 4153 } 4154 return false; 4155 } 4156 4157 /** Replay due failed records from their saved immutable batch checkpoint. */ 4158 public function retry_generic_historical_view_failures($source_id = 0, $origin_key = '') { 4159 global $wpdb; 4160 $source_id = absint($source_id); 4161 $origin_key = sanitize_key((string) $origin_key); 4162 if (!$source_id || $origin_key === '') { 4163 return ['status' => 'invalid_input']; 4164 } 4165 4166 $now = current_time('mysql'); 4167 $failure = statpl_db_get_row(statpl_db_prepare( 4168 "SELECT retry_checkpoint, next_retry_at FROM {$this->view_source_failures_table} 4169 WHERE source_id = %d AND origin_key = %s AND status = 'retry' AND next_retry_at <= %s 4170 AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> '' 4171 ORDER BY next_retry_at ASC, id ASC LIMIT 1", 4172 $source_id, 4173 $origin_key, 4174 $now 4175 ), ARRAY_A); 4176 if (!empty($wpdb->last_error)) { 4177 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4178 return ['status' => 'failure_lookup_error']; 4179 } 4180 if (!$failure) { 4181 $next = statpl_db_get_var(statpl_db_prepare( 4182 "SELECT MIN(next_retry_at) FROM {$this->view_source_failures_table} 4183 WHERE source_id = %d AND origin_key = %s AND status = 'retry' 4184 AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''", 4185 $source_id, 4186 $origin_key 4187 )); 4188 if (!empty($wpdb->last_error)) { 4189 // A failed deadline lookup is not evidence that the retry queue is 4190 // empty. Preserve the worker trigger and avoid status aggregation 4191 // from an incomplete view of the durable failure ledger. 4192 $this->log_generic_historical_scheduler_error('retry_ledger_next_deadline', $wpdb->last_error); 4193 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4194 return ['status' => 'failure_ledger_deadline_failed']; 4195 } 4196 if ($next) { 4197 $next_timestamp = strtotime((string) $next); 4198 $delay = ($next_timestamp !== false) ? max(1, $next_timestamp - current_time('timestamp')) : 300; 4199 $this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key); 4200 } 4201 $this->refresh_generic_historical_source_status($source_id); 4202 return ['status' => 'no_due_failures']; 4203 } 4204 4205 $lock_name = 'statpl_generic_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_id . ':' . $origin_key), 0, 40); 4206 $origin_lock = $this->acquire_generic_advisory_lock($lock_name); 4207 if (empty($origin_lock['acquired'])) { 4208 $this->schedule_generic_historical_failure_retry(30, $source_id, $origin_key); 4209 return ['status' => 'worker_already_running']; 4210 } 4211 4212 try { 4213 // The initial lookup above only determines whether this worker appears 4214 // eligible. Re-read the durable row after acquiring the same origin lock 4215 // used by the main importer: another worker may have resolved or changed 4216 // this retry while we were waiting. Never replay a stale checkpoint. 4217 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 4218 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4219 return ['status' => 'origin_lock_session_lost']; 4220 } 4221 $locked_failure = statpl_db_get_row(statpl_db_prepare( 4222 "SELECT retry_checkpoint, next_retry_at FROM {$this->view_source_failures_table} 4223 WHERE source_id = %d AND origin_key = %s AND status = 'retry' 4224 AND next_retry_at <= %s AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> '' 4225 ORDER BY next_retry_at ASC, id ASC LIMIT 1", 4226 $source_id, 4227 $origin_key, 4228 current_time('mysql') 4229 ), ARRAY_A); 4230 if (!empty($wpdb->last_error)) { 4231 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4232 return ['status' => 'failure_lookup_error']; 4233 } 4234 if (!$locked_failure) { 4235 return ['status' => 'retry_no_longer_due']; 4236 } 4237 4238 $retry_checkpoint = json_decode($locked_failure['retry_checkpoint'], true); 4239 if (!is_array($retry_checkpoint) || !$retry_checkpoint) { 4240 // Corrupt replay metadata is not silently discarded or counted as a view. 4241 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4242 return ['status' => 'invalid_retry_checkpoint']; 4243 } 4244 4245 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 4246 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4247 return ['status' => 'origin_lock_session_lost']; 4248 } 4249 $result = $this->process_generic_historical_view_import_batch($source_id, $origin_key, $retry_checkpoint, $origin_lock); 4250 } finally { 4251 $this->release_generic_advisory_lock($origin_lock, 'retry_origin'); 4252 } 4253 4254 // The processing lock above protects replay itself. Reacquire the same 4255 // origin lock before reconciling ledger counts and origin status: the main 4256 // importer may have started immediately after the replay lock was released. 4257 // Re-reading under the lock prevents this worker from applying stale status 4258 // decisions over a newer main-import checkpoint/state transition. 4259 $post_origin_lock = $this->acquire_generic_advisory_lock($lock_name); 4260 if (empty($post_origin_lock['acquired'])) { 4261 $this->schedule_generic_historical_failure_retry(30, $source_id, $origin_key); 4262 return ['status' => 'post_processing_lock_busy']; 4263 } 4264 4265 try { 4266 if (!$this->generic_advisory_lock_session_is_current($post_origin_lock)) { 4267 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4268 return ['status' => 'post_processing_lock_session_lost']; 4269 } 4270 $remaining_value = statpl_db_get_var(statpl_db_prepare( 4271 "SELECT COUNT(*) FROM {$this->view_source_failures_table} 4272 WHERE source_id = %d AND origin_key = %s AND status = 'retry' 4273 AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''", 4274 $source_id, 4275 $origin_key 4276 )); 4277 if (!empty($wpdb->last_error) || $remaining_value === null) { 4278 // A failed count must never be interpreted as an empty ledger. Keep 4279 // the worker alive and let the next pass re-read durable retry rows. 4280 $this->log_generic_historical_scheduler_error('retry_ledger_count', $wpdb->last_error ?: 'Retry ledger count returned NULL.'); 4281 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4282 return ['status' => 'failure_ledger_count_failed']; 4283 } 4284 $remaining = (int) $remaining_value; 4285 if ($remaining > 0) { 4286 $next = statpl_db_get_var(statpl_db_prepare( 4287 "SELECT MIN(next_retry_at) FROM {$this->view_source_failures_table} 4288 WHERE source_id = %d AND origin_key = %s AND status = 'retry' 4289 AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''", 4290 $source_id, 4291 $origin_key 4292 )); 4293 if (!empty($wpdb->last_error)) { 4294 $this->log_generic_historical_scheduler_error('retry_ledger_deadline', $wpdb->last_error); 4295 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4296 return ['status' => 'failure_ledger_deadline_failed', 'remaining' => $remaining]; 4297 } 4298 $next_timestamp = $next ? strtotime((string) $next) : false; 4299 $delay = ($next_timestamp !== false) ? max(1, $next_timestamp - current_time('timestamp')) : 300; 4300 $this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key); 4301 } else { 4302 $blocked_value = statpl_db_get_var(statpl_db_prepare( 4303 "SELECT COUNT(*) FROM {$this->view_source_failures_table} 4304 WHERE source_id = %d AND origin_key = %s AND status = 'blocked'", 4305 $source_id, 4306 $origin_key 4307 )); 4308 if (!empty($wpdb->last_error) || $blocked_value === null) { 4309 $this->log_generic_historical_scheduler_error('blocked_ledger_count', $wpdb->last_error ?: 'Blocked ledger count returned NULL.'); 4310 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 4311 return ['status' => 'failure_ledger_count_failed']; 4312 } 4313 $blocked = (int) $blocked_value; 4314 $current_state = $this->get_view_source_origin_state($source_id, $origin_key); 4315 $origin_status = $current_state['origin_status'] ?? ''; 4316 if (in_array($origin_status, ['pending', 'snapshot_ready', 'importing', 'retry_wait'], true)) { 4317 // The retry worker cannot infer whether the main scan has reached 4318 // the immutable snapshot boundary. This remains true when some 4319 // records are already blocked: the main cursor may still have 4320 // later batches to import. Resume the main scan and let its own 4321 // has_more result, together with the durable ledger counts, decide 4322 // whether the final state is completed or needs_attention. 4323 $updates = [ 4324 'status' => 'importing', 4325 'completed_at' => null, 4326 ]; 4327 if ($blocked === 0) { 4328 $updates['last_error'] = null; 4329 } 4330 $origin_state_result = $this->update_view_source_origin_state($source_id, $origin_key, $updates, $post_origin_lock); 4331 if (!in_array($origin_state_result['status'] ?? '', ['updated'], true)) { 4332 // Do not treat a failed/ambiguous state transition as a 4333 // successful retry finalization. Keep recovery scheduled. 4334 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4335 return [ 4336 'status' => 'origin_state_recovery_failed', 4337 'origin_state' => $origin_state_result, 4338 'result' => $result, 4339 'remaining' => $remaining, 4340 ]; 4341 } 4342 $this->schedule_generic_historical_view_import_batch(5, $source_id, $origin_key); 4343 } elseif ($blocked > 0 && $origin_status !== 'needs_attention') { 4344 // Only a non-progressing, nonterminal origin may be finalized here. 4345 // Never downgrade an already-finalized attention state. 4346 $origin_state_result = $this->update_view_source_origin_state($source_id, $origin_key, [ 4347 'status' => 'needs_attention', 4348 'last_error' => 'Some historical records could not be recovered after three total attempts.', 4349 ], $post_origin_lock); 4350 if (!in_array($origin_state_result['status'] ?? '', ['updated'], true)) { 4351 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4352 return [ 4353 'status' => 'origin_state_recovery_failed', 4354 'origin_state' => $origin_state_result, 4355 'result' => $result, 4356 'remaining' => $remaining, 4357 ]; 4358 } 4359 } 4360 } 4361 4362 // Ledger reconciliation may itself encounter a reconnect after the 4363 // post-processing lock was acquired. Never aggregate source status unless 4364 // this request still owns that exact MySQL lock session. 4365 if ($this->generic_advisory_lock_session_is_current($post_origin_lock)) { 4366 $this->refresh_generic_historical_source_status($source_id); 4367 } else { 4368 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 4369 return ['status' => 'post_processing_session_lost', 'result' => $result, 'remaining' => $remaining]; 4370 } 4371 return ['status' => 'retry_processed', 'result' => $result, 'remaining' => $remaining]; 4372 } finally { 4373 $this->release_generic_advisory_lock($post_origin_lock, 'retry_post_processing'); 4374 } 4375 } 4376 4377 /** Return a discovered adapter instance for later snapshot/import workers. */ 4378 private function get_historical_view_adapter($adapter_key) { 4379 $adapter_key = sanitize_key((string) $adapter_key); 4380 return isset($this->historical_view_adapters[$adapter_key]) 4381 ? $this->historical_view_adapters[$adapter_key] 4382 : null; 4383 } 4384 4385 /** Schedule the generic historical importer without overlapping scheduled runs. */ 4386 private function schedule_generic_historical_view_import_batch($delay = 10, $source_id = 0, $origin_key = '') { 4387 $args = [(int) $source_id, (string) $origin_key]; 4388 $hook = 'statpl_generic_view_import_batch'; 4389 $requested_timestamp = time() + max(1, (int) $delay); 4390 4391 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 4392 $existing_timestamp = false; 4393 if (function_exists('as_next_scheduled_action')) { 4394 try { 4395 $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus'); 4396 } catch (Throwable $exception) { 4397 $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage()); 4398 } 4399 } 4400 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 4401 return true; 4402 } 4403 if ($existing_timestamp && function_exists('as_unschedule_all_actions')) { 4404 try { 4405 as_unschedule_all_actions($hook, $args, 'statpl-plus'); 4406 } catch (Throwable $exception) { 4407 $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage()); 4408 return true; 4409 } 4410 } elseif ($existing_timestamp) { 4411 return true; 4412 } elseif (function_exists('as_has_scheduled_action')) { 4413 try { 4414 if (as_has_scheduled_action($hook, $args, 'statpl-plus')) { 4415 // Without reliable timing/removal APIs, retain the existing action. 4416 return true; 4417 } 4418 } catch (Throwable $exception) { 4419 $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage()); 4420 } 4421 } 4422 try { 4423 $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus'); 4424 if ($action_id) { 4425 return true; 4426 } 4427 } catch (Throwable $exception) { 4428 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 4429 } 4430 // Fall through to WP-Cron if Action Scheduler did not accept the action. 4431 } 4432 4433 try { 4434 $existing_timestamp = wp_next_scheduled($hook, $args); 4435 if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) { 4436 return true; 4437 } 4438 if ($existing_timestamp) { 4439 wp_unschedule_event((int) $existing_timestamp, $hook, $args); 4440 } 4441 $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args); 4442 if (!$scheduled || is_wp_error($scheduled)) { 4443 $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.'); 4444 return false; 4445 } 4446 return true; 4447 } catch (Throwable $exception) { 4448 $this->log_generic_historical_scheduler_error($hook, $exception->getMessage()); 4449 return false; 4450 } 4451 } 4452 4453 /** Persist a generic-source failure with bounded exponential retry metadata. */ 4454 private function record_generic_historical_view_failure($source_id, $origin_key, $record_key, $product_id, $error, $retry_checkpoint = null, $max_attempts = 8, $lock_context = null) { 4455 global $wpdb; 4456 4457 $source_id = absint($source_id); 4458 if (!$this->generic_advisory_lock_session_is_current($lock_context)) { 4459 return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost']; 4460 } 4461 $record_key = substr((string) $record_key, 0, 191); 4462 if ($record_key === '') { 4463 $record_key = 'batch:' . substr(hash('sha256', (string) $origin_key), 0, 32); 4464 } 4465 $record_hash = hash('sha256', (string) $origin_key . "\0" . $record_key); 4466 $existing = statpl_db_get_row(statpl_db_prepare( 4467 "SELECT id, attempts, status, next_retry_at, retry_checkpoint, last_error FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s LIMIT 1", 4468 $source_id, 4469 $record_hash 4470 ), ARRAY_A); 4471 if (!empty($wpdb->last_error)) { 4472 $this->log_generic_historical_scheduler_error('failure_ledger_lookup', $wpdb->last_error); 4473 return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_lookup_failed']; 4474 } 4475 if (!$this->generic_advisory_lock_session_is_current($lock_context)) { 4476 return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost']; 4477 } 4478 $now = current_time('mysql'); 4479 if ($existing && isset($existing['status']) && $existing['status'] === 'blocked') { 4480 // The ledger row may have been committed even if the corresponding 4481 // origin-state update failed on the previous pass. Repair that 4482 // derived state on every subsequent locked observation without 4483 // incrementing attempts or rewriting the durable failure record. 4484 $existing_origin_status = empty($existing['retry_checkpoint']) ? 'needs_attention' : 'retry_wait'; 4485 $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [ 4486 'last_error' => substr((string) ($existing['last_error'] ?? ''), 0, 2000), 4487 'status' => $existing_origin_status, 4488 ], $lock_context); 4489 if (empty($origin_state_saved['status']) || $origin_state_saved['status'] !== 'updated') { 4490 $this->log_generic_historical_scheduler_error('failure_origin_state_recovery', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to recover origin state from the existing blocked failure record.')); 4491 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 4492 return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_recovery_failed']; 4493 } 4494 return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'already_blocked']; 4495 } 4496 if ($existing && !empty($existing['next_retry_at']) && $existing['next_retry_at'] > $now) { 4497 $retry_at = strtotime((string) $existing['next_retry_at']); 4498 $now_at = strtotime((string) $now); 4499 $remaining = ($retry_at !== false && $now_at !== false) ? max(1, $retry_at - $now_at) : 60; 4500 $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [ 4501 'last_error' => substr((string) ($existing['last_error'] ?? ''), 0, 2000), 4502 'status' => 'retry_wait', 4503 ], $lock_context); 4504 if (empty($origin_state_saved['status']) || $origin_state_saved['status'] !== 'updated') { 4505 $this->log_generic_historical_scheduler_error('failure_origin_state_recovery', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to recover origin state from the existing retry failure record.')); 4506 $this->schedule_generic_historical_failure_retry(min(86400, $remaining), $source_id, $origin_key); 4507 return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_recovery_failed']; 4508 } 4509 return ['attempts' => (int) $existing['attempts'], 'blocked' => false, 'persisted' => true, 'retry_delay' => min(86400, $remaining), 'status' => 'retry_already_scheduled']; 4510 } 4511 4512 $attempts = $existing ? ((int) $existing['attempts'] + 1) : 1; 4513 $max_attempts = max(1, absint($max_attempts)); 4514 $blocked = $attempts >= $max_attempts; 4515 $delay = min(86400, 60 * (2 ** min(10, max(0, $attempts - 1)))); 4516 $next_retry = $blocked ? null : current_datetime()->modify('+' . $delay . ' seconds')->format('Y-m-d H:i:s'); 4517 $data = [ 4518 'source_id' => $source_id, 4519 'origin_key' => sanitize_key((string) $origin_key), 4520 'source_record_key' => $record_key, 4521 'source_record_hash' => $record_hash, 4522 'product_id' => absint($product_id), 4523 'attempts' => $attempts, 4524 'last_error' => substr(sanitize_textarea_field((string) $error), 0, 60000), 4525 'status' => $blocked ? 'blocked' : 'retry', 4526 'next_retry_at' => $next_retry, 4527 'retry_checkpoint' => is_array($retry_checkpoint) ? wp_json_encode($retry_checkpoint) : null, 4528 'updated_at' => $now, 4529 ]; 4530 $allowed_columns = [ 4531 'source_id', 'origin_key', 'source_record_key', 'source_record_hash', 4532 'product_id', 'attempts', 'last_error', 'status', 'next_retry_at', 4533 'retry_checkpoint', 'updated_at', 4534 ]; 4535 $columns = array_keys($data); 4536 if (array_diff($columns, $allowed_columns) || array_diff($allowed_columns, $columns)) { 4537 $this->log_generic_historical_scheduler_error('failure_ledger_schema', 'Failure ledger data did not match the fixed column allowlist.'); 4538 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_schema_mismatch']; 4539 } 4540 $placeholders = []; 4541 $values = []; 4542 foreach ($allowed_columns as $column) { 4543 $value = $data[$column]; 4544 $placeholders[] = is_int($value) ? '%d' : '%s'; 4545 $values[] = $value; 4546 } 4547 $lock_name = (string) ($lock_context['lock_name'] ?? ''); 4548 if ($lock_name === '' || !$this->generic_advisory_lock_session_is_current($lock_context)) { 4549 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost']; 4550 } 4551 4552 if ($existing) { 4553 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The SET placeholders are generated from the fixed allowlist; update_values is generated from the same allowlist with the same immutable key columns excluded, followed by id and lock_name. 4554 $set = []; 4555 foreach ($allowed_columns as $column) { 4556 if (in_array($column, ['source_id', 'source_record_hash'], true)) { 4557 continue; 4558 } 4559 $value = $data[$column]; 4560 $set[] = "`{$column}` = " . (is_int($value) ? '%d' : '%s'); 4561 } 4562 $update_values = []; 4563 foreach ($allowed_columns as $column) { 4564 if (!in_array($column, ['source_id', 'source_record_hash'], true)) { 4565 $update_values[] = $data[$column]; 4566 } 4567 } 4568 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The SET placeholder list and update_values are built in the same pass over the fixed 11-column allowlist, excluding the same two immutable key columns; the two trailing placeholders are always id and lock_name. 4569 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4570 $query = /* phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder fragments and their values are assembled together from the same fixed allowlists; optional lock predicates and matching values are added together. */ statpl_db_prepare( 4571 "UPDATE {$this->view_source_failures_table} SET " . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $set) . " WHERE id = %d AND IS_USED_LOCK(%s) = CONNECTION_ID()", 4572 array_merge($update_values, [(int) $existing['id'], $lock_name]) 4573 ); 4574 } else { 4575 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4576 $query = statpl_db_prepare( 4577 "INSERT INTO {$this->view_source_failures_table} (" . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ array_map(function ($column) { return "`{$column}`"; }, $columns)) . ") SELECT " . implode(', ', $placeholders) . " WHERE IS_USED_LOCK(%s) = CONNECTION_ID()", 4578 array_merge($values, [$lock_name]) 4579 ); 4580 } 4581 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query is assigned exclusively from statpl_db_prepare() in both branches; dynamic identifiers and column names come from fixed allowlists, and all data values use placeholders. 4582 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4583 $persisted = statpl_db_query($query); 4584 if ($persisted === false || $wpdb->last_error !== '') { 4585 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_write_failed', 'error' => $wpdb->last_error]; 4586 } 4587 if ($persisted !== 1) { 4588 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost']; 4589 } 4590 if (strpos($record_key, 'batch:') !== 0 && $record_key !== 'snapshot_initialization') { 4591 $this->save_generic_historical_report_record($source_id, $origin_key, $record_hash, $blocked ? 'failed' : 'retry', $attempts, 'record_import_failed'); 4592 } 4593 4594 $origin_status = ($blocked && !is_array($retry_checkpoint)) ? 'needs_attention' : 'retry_wait'; 4595 $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [ 4596 'last_error' => substr((string) $error, 0, 2000), 4597 'status' => $origin_status, 4598 ], $lock_context); 4599 if (empty($origin_state_saved['status']) || !in_array($origin_state_saved['status'], ['updated'], true)) { 4600 $this->log_generic_historical_scheduler_error('failure_origin_state', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to update origin state after recording a failure.')); 4601 // The failure row is durable, but its derived origin status may be 4602 // stale. Ensure another locked pass is scheduled to repair the 4603 // origin state; do not rely on callers, which correctly stop when 4604 // this operation reports blocked=true. 4605 if (is_array($retry_checkpoint)) { 4606 $this->schedule_generic_historical_failure_retry(max(60, $delay), $source_id, $origin_key); 4607 } else { 4608 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 4609 } 4610 return ['attempts' => $attempts, 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_update_failed']; 4611 } 4612 4613 return ['attempts' => $attempts, 'blocked' => $blocked, 'persisted' => true, 'retry_delay' => $blocked ? 0 : $delay]; 4614 } 4615 4616 /** Clear a previously recorded failure after its record is safely handled. */ 4617 private function clear_generic_historical_view_failure($source_id, $origin_key, $record_key, $lock_context = null, $report_outcome = 'imported') { 4618 global $wpdb; 4619 if (!$this->generic_advisory_lock_session_is_current($lock_context)) { 4620 return false; 4621 } 4622 $record_key = substr((string) $record_key, 0, 191); 4623 if ($record_key === '') { 4624 return false; 4625 } 4626 $record_hash = hash('sha256', (string) $origin_key . "\0" . $record_key); 4627 $existing_failure = statpl_db_get_row(statpl_db_prepare( 4628 "SELECT attempts FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s LIMIT 1", 4629 absint($source_id), $record_hash 4630 ), ARRAY_A); 4631 if (!empty($wpdb->last_error)) { return false; } 4632 if ($report_outcome === 'imported' && strpos($record_key, 'batch:') !== 0 && $record_key !== 'snapshot_initialization') { 4633 $attempts = is_array($existing_failure) ? (int) ($existing_failure['attempts'] ?? 0) : 0; 4634 if (!$this->save_generic_historical_report_record($source_id, $origin_key, $record_hash, 'imported', $attempts, '')) { return false; } 4635 } 4636 $lock_name = (string) ($lock_context['lock_name'] ?? ''); 4637 if ($lock_name === '' || !$this->generic_advisory_lock_session_is_current($lock_context)) { 4638 return false; 4639 } 4640 $deleted = statpl_db_query(statpl_db_prepare( 4641 "DELETE FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s AND IS_USED_LOCK(%s) = CONNECTION_ID()", 4642 absint($source_id), 4643 $record_hash, 4644 $lock_name 4645 )); 4646 if ($deleted === false || $wpdb->last_error !== '') { 4647 $this->log_generic_historical_scheduler_error('failure_ledger_clear', $wpdb->last_error ?: 'Unable to clear a resolved failure record.'); 4648 return false; 4649 } 4650 // A zero-row DELETE is ambiguous: the failure row may already be absent, 4651 // or the SQL ownership predicate may have rejected the operation after a 4652 // reconnect/lock loss. Only treat the former as successful, and confirm 4653 // that this worker still owns the original lock session after the query. 4654 if (!$this->generic_advisory_lock_session_is_current($lock_context)) { 4655 return false; 4656 } 4657 return true; 4658 } 4659 4660 /** 4661 * Process one generic source-origin batch. Each accepted record is committed 4662 * atomically with its source event and product total; the checkpoint advances 4663 * only after the record is safely applied or recognized as an exact duplicate. 4664 */ 4665 public function run_generic_historical_view_import_batch($source_id = 0, $origin_key = '') { 4666 global $wpdb; 4667 4668 $source_id = absint($source_id); 4669 $origin_key = sanitize_key((string) $origin_key); 4670 if (!$source_id || $origin_key === '') { 4671 return ['status' => 'invalid_input']; 4672 } 4673 4674 // A connection-scoped MySQL advisory lock prevents two cron/Action 4675 // Scheduler workers from advancing the same source-origin checkpoint. 4676 // The lock is released automatically if PHP or the DB connection dies. 4677 $lock_name = 'statpl_generic_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_id . ':' . $origin_key), 0, 40); 4678 $origin_lock = $this->acquire_generic_advisory_lock($lock_name); 4679 if (empty($origin_lock['acquired'])) { 4680 $this->schedule_generic_historical_view_import_batch(30, $source_id, $origin_key); 4681 return ['status' => 'worker_already_running']; 4682 } 4683 4684 try { 4685 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 4686 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 4687 return ['status' => 'origin_lock_session_lost']; 4688 } 4689 $result = $this->process_generic_historical_view_import_batch($source_id, $origin_key, null, $origin_lock); 4690 // The processing method can return after detecting a lost MySQL session. 4691 // Do not start a new source-status write on a transparently reconnected 4692 // session: it would run outside the origin lock that serialized this worker. 4693 if ($this->generic_advisory_lock_session_is_current($origin_lock)) { 4694 $this->refresh_generic_historical_source_status($source_id); 4695 } else { 4696 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 4697 } 4698 return $result; 4699 } finally { 4700 $this->release_generic_advisory_lock($origin_lock, 'main_importer'); 4701 } 4702 } 4703 4704 /** Validate a normalized source datetime without allowing silent MySQL coercion. */ 4705 private function is_valid_generic_historical_datetime($value) { 4706 if ($value === null || $value === '') { 4707 return true; 4708 } 4709 if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)) { 4710 return false; 4711 } 4712 $date = DateTime::createFromFormat('!Y-m-d H:i:s', $value); 4713 $errors = DateTime::getLastErrors(); 4714 return $date instanceof DateTime 4715 && ($errors === false || (empty($errors['warning_count']) && empty($errors['error_count']))) 4716 && $date->format('Y-m-d H:i:s') === $value; 4717 } 4718 4719 /** Accept only a JSON-safe scalar/array cursor that can be persisted and resumed. */ 4720 private function is_valid_generic_historical_cursor($cursor) { 4721 if ($cursor === null || is_resource($cursor) || is_object($cursor)) { 4722 return false; 4723 } 4724 $encoded = wp_json_encode($cursor); 4725 return is_string($encoded) && strlen($encoded) <= 60000; 4726 } 4727 4728 /** 4729 * Discover overlap candidates fairly across source IDs. A global LIMIT can let 4730 * a high-volume source crowd every other adapter out of the reconciliation set. 4731 * The per-source fetch is bounded, then candidates are interleaved round-robin. 4732 */ 4733 private function get_generic_reconciliation_candidates_by_source($mode, $criteria, $limit = 25) { 4734 global $wpdb; 4735 4736 if (!is_array($criteria)) { 4737 return ['status' => 'error', 'error' => 'Invalid reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false]; 4738 } 4739 if ($mode === 'fingerprint') { 4740 $fingerprint = isset($criteria['fingerprint']) ? (string) $criteria['fingerprint'] : ''; 4741 $source_id = isset($criteria['source_id']) ? absint($criteria['source_id']) : 0; 4742 if ($fingerprint === '' || !$source_id) { 4743 return ['status' => 'error', 'error' => 'Invalid fingerprint reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false]; 4744 } 4745 $where_sql = 'events.source_fingerprint = %s AND events.source_id <> %d'; 4746 $where_args = [$fingerprint, $source_id]; 4747 } elseif ($mode === 'temporal') { 4748 $source_id = isset($criteria['source_id']) ? absint($criteria['source_id']) : 0; 4749 $product_id = isset($criteria['product_id']) ? absint($criteria['product_id']) : 0; 4750 $period_start = isset($criteria['period_start']) ? (string) $criteria['period_start'] : ''; 4751 $period_end = isset($criteria['period_end']) ? (string) $criteria['period_end'] : ''; 4752 if (!$source_id || !$product_id || $period_start === '' || $period_end === '') { 4753 return ['status' => 'error', 'error' => 'Invalid temporal reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false]; 4754 } 4755 $where_sql = 'events.source_id <> %d AND events.product_id = %d AND events.period_start <= %s AND events.period_end >= %s'; 4756 $where_args = [$source_id, $product_id, $period_end, $period_start]; 4757 } else { 4758 return ['status' => 'error', 'error' => 'Unsupported reconciliation mode.', 'events' => [], 'observed' => 0, 'truncated' => false]; 4759 } 4760 $limit = max(1, min(25, absint($limit))); 4761 $reconciliation_sql = "SELECT DISTINCT events.source_id 4762 FROM {$this->view_source_events_table} AS events 4763 WHERE {$where_sql} 4764 ORDER BY events.source_id ASC"; 4765 // The criteria branches above define either four temporal placeholders 4766 // or two exact-match placeholders; prepare receives the matching values. 4767 if ($mode === 'temporal') { 4768 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4769 $source_ids = statpl_db_get_col(statpl_db_prepare( 4770 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $reconciliation_sql, 4771 $source_id, 4772 $product_id, 4773 $period_end, 4774 $period_start 4775 )); 4776 } else { 4777 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4778 $source_ids = statpl_db_get_col(statpl_db_prepare( 4779 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $reconciliation_sql, 4780 $fingerprint, 4781 $source_id 4782 )); 4783 } 4784 if (!empty($wpdb->last_error)) { 4785 return ['status' => 'error', 'error' => (string) $wpdb->last_error, 'events' => [], 'observed' => 0, 'truncated' => false]; 4786 } 4787 4788 $per_source = []; 4789 $observed = 0; 4790 $truncated = false; 4791 foreach ((array) $source_ids as $candidate_source_id) { 4792 $candidate_source_id = absint($candidate_source_id); 4793 if (!$candidate_source_id) { 4794 continue; 4795 } 4796 $rows = statpl_db_get_results(statpl_db_prepare( 4797 "SELECT events.id, events.source_id, events.source_record_key, 4798 sources.source_key AS candidate_source_key, 4799 sources.adapter_key AS candidate_adapter_key, events.product_id, 4800 events.record_type, events.observed_views, events.period_start, 4801 events.period_end, events.applied_views 4802 FROM {$this->view_source_events_table} AS events 4803 LEFT JOIN {$this->view_sources_table} AS sources ON sources.id = events.source_id 4804 WHERE ({$where_sql}) AND events.source_id = %d 4805 ORDER BY events.period_start ASC, events.period_end ASC, events.id ASC 4806 LIMIT 26 FOR UPDATE", 4807 array_merge($where_args, [$candidate_source_id]) 4808 ), ARRAY_A); 4809 if (!empty($wpdb->last_error)) { 4810 return ['status' => 'error', 'error' => (string) $wpdb->last_error, 'events' => [], 'observed' => $observed, 'truncated' => $truncated]; 4811 } 4812 if (count($rows) > 25) { 4813 $truncated = true; 4814 $rows = array_slice($rows, 0, 25); 4815 } 4816 $observed += count($rows); 4817 if ($rows) { 4818 $per_source[$candidate_source_id] = array_values($rows); 4819 } 4820 } 4821 4822 $events = []; 4823 $round = 0; 4824 do { 4825 $added_this_round = false; 4826 foreach ($per_source as $source_rows) { 4827 if (isset($source_rows[$round])) { 4828 $events[] = $source_rows[$round]; 4829 $added_this_round = true; 4830 if (count($events) > $limit) { 4831 $truncated = true; 4832 break 2; 4833 } 4834 } 4835 } 4836 $round++; 4837 } while ($added_this_round); 4838 4839 if ($observed > count($events)) { 4840 $truncated = true; 4841 } 4842 return [ 4843 'status' => 'ok', 4844 'events' => array_slice($events, 0, $limit), 4845 'observed' => $observed, 4846 'truncated' => $truncated, 4847 ]; 4848 } 4849 4850 /** 4851 * Build a source-wide, deterministic overlap profile for the current record. 4852 * This is deliberately descriptive: timestamps and aggregate periods are not 4853 * proof that two providers observed the same pageview. The profile gives the 4854 * later reconciliation pass comparable signals without changing applied_views. 4855 */ 4856 /** 4857 * Return declared source capabilities for reconciliation review. 4858 * This is a descriptive capability inventory, not a numeric reliability score. 4859 * Missing declarations remain unknown; they are never inferred from plugin names. 4860 */ 4861 private function get_generic_source_capability_profiles($source_ids) { 4862 global $wpdb; 4863 4864 $source_ids = array_values(array_unique(array_filter(array_map('absint', (array) $source_ids)))); 4865 if (!$source_ids) { 4866 return ['status' => 'empty', 'sources' => [], 'requested_source_count' => 0]; 4867 } 4868 4869 // The list is built only from normalized positive integer source IDs. 4870 // A fixed-size batch limit keeps the SQL placeholder count bounded. 4871 $placeholders = implode(',', array_fill(0, count($source_ids), '%d')); 4872 // Start from the source registry and LEFT JOIN origins. A persisted source 4873 // with no origin row must remain visible as an unknown-capability source; 4874 // an INNER JOIN would silently omit it from the reconciliation inventory. 4875 $inventory_sql = "SELECT s.id AS source_id, s.source_key, s.display_name, 4876 s.adapter_key AS source_adapter_key, s.granularity AS source_granularity, 4877 o.origin_key, o.adapter_key, o.capabilities, 4878 o.detection_status 4879 FROM {$this->view_sources_table} AS s 4880 LEFT JOIN {$this->view_source_origins_table} AS o ON o.source_id = s.id 4881 WHERE s.id IN ({$placeholders}) 4882 ORDER BY s.id ASC, o.origin_key ASC"; 4883 // prepare() accepts an argument array as its second parameter. This keeps 4884 // the dynamically sized placeholder list paired with its normalized IDs. 4885 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 4886 $rows = statpl_db_get_results(statpl_db_prepare($inventory_sql, $source_ids), ARRAY_A); 4887 if (!empty($wpdb->last_error) || !is_array($rows)) { 4888 return [ 4889 'status' => 'query_failed', 4890 'sources' => [], 4891 'requested_source_count' => count($source_ids), 4892 'error' => (string) $wpdb->last_error, 4893 ]; 4894 } 4895 4896 $profiles = []; 4897 foreach ($rows as $row) { 4898 $id = absint($row['source_id'] ?? 0); 4899 if (!$id) { continue; } 4900 $profiles[$id] = $profiles[$id] ?? [ 4901 'source_id' => $id, 4902 'source_key' => sanitize_key((string) ($row['source_key'] ?? '')), 4903 'display_name' => sanitize_text_field((string) ($row['display_name'] ?? '')), 4904 'source_adapter_key' => sanitize_key((string) ($row['source_adapter_key'] ?? '')), 4905 'source_granularity' => sanitize_key((string) ($row['source_granularity'] ?? 'unknown')), 4906 'origin_profile_status' => 'available', 4907 'origins' => [], 4908 ]; 4909 4910 // A NULL origin row is meaningful: the source exists, but no declared 4911 // origin capabilities are available. Do not infer them from its name. 4912 if ($row['origin_key'] === null || $row['origin_key'] === '') { 4913 $profiles[$id]['origin_profile_status'] = 'no_origin_declarations'; 4914 continue; 4915 } 4916 4917 $declared = json_decode((string) ($row['capabilities'] ?? ''), true); 4918 if (!is_array($declared)) { $declared = []; } 4919 4920 $granularity = sanitize_key((string) ($declared['granularity'] ?? 'unknown')); 4921 $precision = sanitize_key((string) ($declared['timestamp_precision'] ?? 'unknown')); 4922 $storage_granularity = sanitize_key((string) ($declared['source_storage_granularity'] ?? 'unknown')); 4923 $profiles[$id]['origins'][] = [ 4924 'origin_key' => sanitize_key((string) ($row['origin_key'] ?? '')), 4925 'adapter_key' => sanitize_key((string) ($row['adapter_key'] ?? '')), 4926 'detection_status' => sanitize_key((string) ($row['detection_status'] ?? 'unknown')), 4927 'granularity' => $granularity, 4928 'timestamp_precision' => $precision, 4929 'source_storage_granularity' => $storage_granularity, 4930 'product_id_direct' => array_key_exists('product_id_direct', $declared) ? (bool) $declared['product_id_direct'] : null, 4931 'target_requires_product_resolution' => array_key_exists('target_requires_product_resolution', $declared) ? (bool) $declared['target_requires_product_resolution'] : null, 4932 'stable_event_identity_declared' => array_key_exists('stable_event_identity', $declared) ? (bool) $declared['stable_event_identity'] : null, 4933 'bot_filtered' => array_key_exists('bot_filtered', $declared) ? (bool) $declared['bot_filtered'] : null, 4934 'immutable_past_days' => array_key_exists('immutable_past_days', $declared) ? (bool) $declared['immutable_past_days'] : null, 4935 'capability_comparability' => [ 4936 'event_level' => $granularity === 'event', 4937 'second_precision' => $precision === 'seconds', 4938 'direct_product_mapping' => !empty($declared['product_id_direct']), 4939 'aggregate_only_or_unknown' => in_array($granularity, ['daily_aggregate', 'hourly_aggregate', 'unknown'], true), 4940 ], 4941 ]; 4942 } 4943 4944 // Preserve requested IDs that have no matching source-registry row as 4945 // explicit unresolved entries instead of silently shrinking the scope. 4946 foreach ($source_ids as $requested_id) { 4947 if (!isset($profiles[$requested_id])) { 4948 $profiles[$requested_id] = [ 4949 'source_id' => $requested_id, 4950 'source_key' => '', 4951 'display_name' => '', 4952 'source_adapter_key' => '', 4953 'source_granularity' => 'unknown', 4954 'origin_profile_status' => 'source_registry_row_missing', 4955 'origins' => [], 4956 ]; 4957 } 4958 } 4959 ksort($profiles, SORT_NUMERIC); 4960 4961 $resolved_count = 0; 4962 $without_origin_count = 0; 4963 foreach ($profiles as $profile) { 4964 if (($profile['origin_profile_status'] ?? '') === 'source_registry_row_missing') { 4965 continue; 4966 } 4967 $resolved_count++; 4968 if (($profile['origin_profile_status'] ?? '') === 'no_origin_declarations') { 4969 $without_origin_count++; 4970 } 4971 } 4972 $status = $resolved_count < count($source_ids) 4973 ? 'partial' 4974 : ($without_origin_count > 0 ? 'partial' : 'available'); 4975 4976 return [ 4977 'status' => $status, 4978 'sources' => array_values($profiles), 4979 'requested_source_count' => count($source_ids), 4980 'resolved_source_count' => $resolved_count, 4981 'sources_without_origin_declarations' => $without_origin_count, 4982 ]; 4983 } 4984 4985 private function build_generic_cross_source_overlap_assessment($product_id, $source_id, $adapter_key, $record_type, $period_start, $period_end, $candidates, $truncated = false, $observed_views = 0) { 4986 $product_id = absint($product_id); 4987 $record_type = sanitize_key((string) $record_type); 4988 $period_start = $period_start !== null ? (string) $period_start : ''; 4989 $period_end = $period_end !== null ? (string) $period_end : ''; 4990 $sources = []; 4991 $same_instant_event_candidates = 0; 4992 $same_bounds_aggregate_candidates = 0; 4993 $intersecting_aggregate_candidates = 0; 4994 $different_type_candidates = 0; 4995 4996 foreach ((array) $candidates as $candidate) { 4997 if (!is_array($candidate) || (int) ($candidate['product_id'] ?? 0) !== $product_id) { 4998 continue; 4999 } 5000 $candidate_source_id = absint($candidate['source_id'] ?? 0); 5001 if (!$candidate_source_id) { 5002 continue; 5003 } 5004 $candidate_type = sanitize_key((string) ($candidate['record_type'] ?? '')); 5005 $candidate_start = isset($candidate['period_start']) ? (string) $candidate['period_start'] : ''; 5006 $candidate_end = isset($candidate['period_end']) ? (string) $candidate['period_end'] : ''; 5007 $same_bounds = $period_start !== '' && $period_end !== '' 5008 && $candidate_start === $period_start && $candidate_end === $period_end; 5009 $intersects = $period_start !== '' && $period_end !== '' 5010 && $candidate_start !== '' && $candidate_end !== '' 5011 && $candidate_start <= $period_end && $candidate_end >= $period_start; 5012 5013 if (!isset($sources[$candidate_source_id])) { 5014 $sources[$candidate_source_id] = [ 5015 'source_id' => $candidate_source_id, 5016 'source_key' => isset($candidate['candidate_source_key']) ? (string) $candidate['candidate_source_key'] : null, 5017 'adapter_key' => isset($candidate['candidate_adapter_key']) ? (string) $candidate['candidate_adapter_key'] : null, 5018 'record_types' => [], 5019 'candidate_records' => 0, 5020 ]; 5021 } 5022 $sources[$candidate_source_id]['candidate_records']++; 5023 if ($candidate_type !== '') { 5024 $sources[$candidate_source_id]['record_types'][$candidate_type] = true; 5025 } 5026 5027 if ($candidate_type !== $record_type) { 5028 $different_type_candidates++; 5029 } 5030 if ($record_type === 'event' && $candidate_type === 'event' && $same_bounds) { 5031 $same_instant_event_candidates++; 5032 } elseif ($record_type === 'aggregate' && $candidate_type === 'aggregate' && $same_bounds) { 5033 $same_bounds_aggregate_candidates++; 5034 } elseif ($intersects && ($record_type === 'aggregate' || $candidate_type === 'aggregate')) { 5035 $intersecting_aggregate_candidates++; 5036 } 5037 } 5038 5039 foreach ($sources as &$source) { 5040 $source['record_types'] = array_keys($source['record_types']); 5041 sort($source['record_types'], SORT_STRING); 5042 } 5043 unset($source); 5044 ksort($sources, SORT_NUMERIC); 5045 5046 // Identical naive datetime labels cannot establish a shared temporal 5047 // bucket without persisted source-timezone provenance; no count range is inferred. 5048 $same_bucket_range = [ 5049 'status' => 'unavailable', 5050 'reason' => 'timezone_basis_not_persisted', 5051 'minimum_unique_views' => null, 5052 'maximum_unique_views' => null, 5053 'minimum_possible_duplicate_views' => null, 5054 'maximum_possible_duplicate_views' => null, 5055 'source_contributions' => [], 5056 ]; 5057 5058 $capability_source_ids = array_merge([absint($source_id)], array_keys($sources)); 5059 $capability_profiles = $this->get_generic_source_capability_profiles($capability_source_ids); 5060 5061 return [ 5062 'version' => '0.2.0', 5063 'scope' => 'same_product_different_sources', 5064 'incoming_source' => [ 5065 'source_id' => absint($source_id), 5066 'adapter_key' => sanitize_key((string) $adapter_key), 5067 'record_type' => $record_type, 5068 ], 5069 'candidate_sources' => array_values($sources), 5070 'source_capability_profiles' => $capability_profiles, 5071 'reliability_policy' => 'declared_capabilities_only_no_numeric_score_or_inferred_trust_rank', 5072 'candidate_source_count' => count($sources), 5073 'candidate_record_count' => array_sum(array_column(array_values($sources), 'candidate_records')), 5074 'same_instant_event_candidates' => $same_instant_event_candidates, 5075 'same_bounds_aggregate_candidates' => $same_bounds_aggregate_candidates, 5076 'intersecting_aggregate_candidates' => $intersecting_aggregate_candidates, 5077 'period_comparison_semantics' => 'raw_datetime_string_representation_only_timezone_unverified', 5078 'different_record_type_candidates' => $different_type_candidates, 5079 'candidate_set_truncated' => (bool) $truncated, 5080 'identical_bucket_count_range' => $same_bucket_range, 5081 'identity_confidence' => 'unproven_without_shared_event_identity', 5082 'temporal_basis_status' => 'timezone_provenance_not_persisted', 5083 'absolute_temporal_comparison_performed' => false, 5084 'counting_decision' => 'preserve_contribution_pending_global_reconciliation', 5085 'policy' => 'Stored datetime strings and identical period bounds are representation-level signals only because source timezone provenance is not persisted; no absolute-time overlap or unique-view range is asserted and no contribution is suppressed by this assessment.', 5086 ]; 5087 } 5088 5089 /** 5090 * Build a bounded product-wide inventory across every persisted generic source. 5091 * This is a descriptive synthesis of recorded contributions, not a unique-view 5092 * estimate: source totals are never added, ranked, or suppressed here. 5093 */ 5094 private function build_generic_product_source_synthesis($product_id) { 5095 global $wpdb; 5096 5097 $product_id = absint($product_id); 5098 if (!$product_id) { 5099 return ['status' => 'unavailable', 'reason' => 'invalid_product_id', 'sources' => []]; 5100 } 5101 5102 // Keep the source-ID inventory independent of the bounded record-group 5103 // result so capability profiles cover every source, even when the 5104 // descriptive record inventory reaches its output limit. 5105 $source_id_rows = statpl_db_get_col(statpl_db_prepare( 5106 "SELECT DISTINCT source_id 5107 FROM {$this->view_source_events_table} 5108 WHERE product_id = %d 5109 ORDER BY source_id ASC", 5110 $product_id 5111 )); 5112 if (!empty($wpdb->last_error) || !is_array($source_id_rows)) { 5113 return ['status' => 'query_failed', 'reason' => 'product_source_id_inventory_query_failed', 'sources' => []]; 5114 } 5115 $all_source_ids = array_values(array_unique(array_filter(array_map('absint', $source_id_rows)))); 5116 5117 $rows = statpl_db_get_results(statpl_db_prepare( 5118 "SELECT e.source_id, s.source_key, s.display_name, e.record_type, 5119 COUNT(*) AS record_count, SUM(e.observed_views) AS observed_views, 5120 SUM(e.applied_views) AS applied_views, 5121 MIN(e.period_start) AS first_period_start, 5122 MAX(e.period_end) AS last_period_end 5123 FROM {$this->view_source_events_table} AS e 5124 LEFT JOIN {$this->view_sources_table} AS s ON s.id = e.source_id 5125 WHERE e.product_id = %d 5126 GROUP BY e.source_id, s.source_key, s.display_name, e.record_type 5127 ORDER BY e.source_id ASC, e.record_type ASC 5128 LIMIT 101", 5129 $product_id 5130 ), ARRAY_A); 5131 if (!empty($wpdb->last_error) || !is_array($rows)) { 5132 return ['status' => 'query_failed', 'reason' => 'product_source_inventory_query_failed', 'sources' => []]; 5133 } 5134 $truncated = count($rows) > 100; 5135 if ($truncated) { 5136 $rows = array_slice($rows, 0, 100); 5137 } 5138 $sources = []; 5139 foreach ($rows as $row) { 5140 $source_id = absint($row['source_id'] ?? 0); 5141 if (!$source_id) { continue; } 5142 if (!isset($sources[$source_id])) { 5143 $sources[$source_id] = [ 5144 'source_id' => $source_id, 5145 'source_key' => sanitize_key((string) ($row['source_key'] ?? '')), 5146 'display_name' => sanitize_text_field((string) ($row['display_name'] ?? '')), 5147 'record_types' => [], 5148 ]; 5149 } 5150 $sources[$source_id]['record_types'][] = [ 5151 'record_type' => sanitize_key((string) ($row['record_type'] ?? 'unknown')), 5152 'record_count' => max(0, (int) ($row['record_count'] ?? 0)), 5153 'observed_views' => max(0, (int) ($row['observed_views'] ?? 0)), 5154 'applied_views' => max(0, (int) ($row['applied_views'] ?? 0)), 5155 'first_period_start' => $row['first_period_start'] !== null ? (string) $row['first_period_start'] : null, 5156 'last_period_end' => $row['last_period_end'] !== null ? (string) $row['last_period_end'] : null, 5157 ]; 5158 } 5159 ksort($sources, SORT_NUMERIC); 5160 5161 $capability_profiles = $this->get_generic_source_capability_profiles($all_source_ids); 5162 $capability_profiles_by_source = []; 5163 // A partial capability lookup still contains useful, successfully 5164 // resolved profiles plus explicit unresolved entries. Retain every 5165 // returned profile; the status separately communicates incompleteness. 5166 if (in_array(($capability_profiles['status'] ?? ''), ['available', 'partial'], true)) { 5167 foreach ((array) ($capability_profiles['sources'] ?? []) as $profile) { 5168 $profile_source_id = absint($profile['source_id'] ?? 0); 5169 if ($profile_source_id) { 5170 $capability_profiles_by_source[$profile_source_id] = $profile; 5171 } 5172 } 5173 } 5174 5175 return [ 5176 'status' => 'available', 5177 'scope' => 'all_persisted_generic_sources_for_product', 5178 'sources' => array_values($sources), 5179 'source_ids_in_scope' => $all_source_ids, 5180 'source_count_in_scope' => count($all_source_ids), 5181 'source_capability_profiles' => array_values($capability_profiles_by_source), 5182 'capability_profile_status' => sanitize_key((string) ($capability_profiles['status'] ?? 'unknown')), 5183 'capability_profile_source_count' => count($capability_profiles_by_source), 5184 'source_record_groups_truncated' => $truncated, 5185 'unique_view_total' => null, 5186 'counting_decision' => 'no_cross_source_sum_or_suppression', 5187 'policy' => 'Observed and applied contributions are reported separately by source and record type; declared capabilities are attached for every source in scope where available, without numeric reliability ranking or a deduplicated product-view total.', 5188 ]; 5189 } 5190 5191 /** 5192 * Combine the independently collected reconciliation evidence into one 5193 * product-level audit summary. This is classification only: it never 5194 * estimates unique views, changes applied_views, or authorizes suppression. 5195 */ 5196 /** 5197 * Classify the completeness and ambiguity of the retained cross-source candidate set. 5198 * Candidate ordering identifies an audit primary only; it never establishes identity. 5199 */ 5200 private function build_generic_candidate_set_assessment($candidates, $candidates_truncated = false, $observed_count = null) { 5201 $candidates = is_array($candidates) ? $candidates : []; 5202 $by_id = []; 5203 $source_ids = []; 5204 foreach ($candidates as $candidate) { 5205 if (!is_array($candidate)) { 5206 continue; 5207 } 5208 $event_id = isset($candidate['id']) ? (int) $candidate['id'] : 0; 5209 $source_id = isset($candidate['source_id']) ? (int) $candidate['source_id'] : 0; 5210 if ($event_id > 0) { 5211 $by_id[$event_id] = true; 5212 } 5213 if ($source_id > 0) { 5214 $source_ids[$source_id] = true; 5215 } 5216 } 5217 $candidate_count = count($by_id); 5218 $source_count = count($source_ids); 5219 $observed_count = $observed_count === null ? $candidate_count : max($candidate_count, (int) $observed_count); 5220 $truncated = (bool) $candidates_truncated; 5221 5222 if ($candidate_count > 1 || $truncated || $observed_count > $candidate_count) { 5223 $classification = 'multiple_or_truncated_candidates_require_review'; 5224 } elseif ($candidate_count === 1) { 5225 $classification = 'single_candidate_requires_review'; 5226 } else { 5227 $classification = 'no_candidate_available'; 5228 } 5229 5230 return [ 5231 'classification' => $classification, 5232 'candidate_count_retained' => $candidate_count, 5233 'candidate_event_ids' => array_map('intval', array_keys($by_id)), 5234 'candidate_source_ids_by_event_id' => array_intersect_key(array_reduce($candidates, static function ($carry, $candidate) { 5235 if (is_array($candidate) && !empty($candidate['id']) && !empty($candidate['source_id'])) { 5236 $carry[(int) $candidate['id']] = (int) $candidate['source_id']; 5237 } 5238 return $carry; 5239 }, []), $by_id), 5240 'candidate_count_observed' => $observed_count, 5241 'candidate_source_count_retained' => $source_count, 5242 'multiple_candidate_ambiguity' => $candidate_count > 1 || $observed_count > 1, 5243 'multiple_source_ambiguity' => $source_count > 1, 5244 'candidate_set_truncated' => $truncated, 5245 'candidate_count_is_lower_bound' => $truncated, 5246 'primary_candidate_is_identity_proof' => false, 5247 'automatic_suppression_authorized' => false, 5248 'policy' => 'All retained candidates remain evidence. Primary ordering is for audit navigation only and does not resolve identity or authorize count suppression.', 5249 ]; 5250 } 5251 5252 /** 5253 * Describe conflicting or heterogeneous dimensions across retained candidates. 5254 * Differences are classified as evidence only; this method never resolves identity 5255 * or changes the contribution applied to product totals. 5256 */ 5257 /** 5258 * Validate the integrity of the per-candidate audit records. Missing or duplicate 5259 * identifiers and malformed classifications are retained as audit errors; this 5260 * method never infers identity or authorizes suppression. 5261 */ 5262 private function build_generic_candidate_record_integrity_assessment($candidate_details, $expected_unique_candidate_count) { 5263 $candidate_details = is_array($candidate_details) ? $candidate_details : []; 5264 $expected_unique_candidate_count = max(0, (int) $expected_unique_candidate_count); 5265 $errors = []; 5266 $seen_ids = []; 5267 $missing_id_count = 0; 5268 $duplicate_id_count = 0; 5269 $invalid_classification_count = 0; 5270 $invalid_dimension_check_count = 0; 5271 $allowed_classifications = [ 5272 'fingerprint_dimension_mismatch', 5273 'temporal_candidate_different_record_shape', 5274 'candidate_dimensions_match_without_identity_proof', 5275 'insufficient_candidate_dimensions', 5276 ]; 5277 $required_dimensions = ['product_id', 'record_type', 'observed_views', 'period_start', 'period_end']; 5278 5279 foreach ($candidate_details as $detail) { 5280 if (!is_array($detail)) { 5281 $missing_id_count++; 5282 $invalid_classification_count++; 5283 $invalid_dimension_check_count++; 5284 continue; 5285 } 5286 $candidate_id = absint($detail['candidate_event_id'] ?? 0); 5287 if (!$candidate_id) { 5288 $missing_id_count++; 5289 } elseif (isset($seen_ids[$candidate_id])) { 5290 $duplicate_id_count++; 5291 } else { 5292 $seen_ids[$candidate_id] = true; 5293 } 5294 if (!in_array($detail['classification'] ?? null, $allowed_classifications, true)) { 5295 $invalid_classification_count++; 5296 } 5297 $checks = $detail['dimension_checks'] ?? null; 5298 $checks_valid = is_array($checks); 5299 if ($checks_valid) { 5300 foreach ($required_dimensions as $dimension) { 5301 if (!array_key_exists($dimension, $checks) 5302 || !in_array($checks[$dimension], [true, false, null], true)) { 5303 $checks_valid = false; 5304 break; 5305 } 5306 } 5307 } 5308 if (!$checks_valid) { 5309 $invalid_dimension_check_count++; 5310 } 5311 } 5312 5313 $detail_count = count($candidate_details); 5314 $unique_id_count = count($seen_ids); 5315 if ($missing_id_count > 0) { 5316 $errors[] = 'candidate_audit_record_missing_valid_event_id'; 5317 } 5318 if ($duplicate_id_count > 0) { 5319 $errors[] = 'candidate_audit_records_contain_duplicate_event_ids'; 5320 } 5321 if ($invalid_classification_count > 0) { 5322 $errors[] = 'candidate_audit_record_has_invalid_classification'; 5323 } 5324 if ($invalid_dimension_check_count > 0) { 5325 $errors[] = 'candidate_audit_record_has_invalid_dimension_checks'; 5326 } 5327 if ($detail_count !== $expected_unique_candidate_count || $unique_id_count !== $expected_unique_candidate_count) { 5328 $errors[] = 'candidate_audit_record_count_does_not_match_unique_candidate_inventory'; 5329 } 5330 5331 return [ 5332 'status' => $errors ? 'candidate_records_require_review' : 'candidate_records_valid', 5333 'consistent' => !$errors, 5334 'errors' => array_values(array_unique($errors)), 5335 'candidate_detail_count' => $detail_count, 5336 'unique_valid_candidate_id_count' => $unique_id_count, 5337 'expected_unique_candidate_count' => $expected_unique_candidate_count, 5338 'missing_id_count' => $missing_id_count, 5339 'duplicate_id_count' => $duplicate_id_count, 5340 'invalid_classification_count' => $invalid_classification_count, 5341 'invalid_dimension_check_count' => $invalid_dimension_check_count, 5342 'identity_proven' => false, 5343 'automatic_suppression_authorized' => false, 5344 'policy' => 'Record validation is audit-only. Invalid records remain visible for review and cannot establish identity or authorize suppression.', 5345 ]; 5346 } 5347 5348 private function build_generic_candidate_conflict_assessment($incoming, $candidates) { 5349 $incoming = is_array($incoming) ? $incoming : []; 5350 $candidates = is_array($candidates) ? $candidates : []; 5351 $counts = [ 5352 'fingerprint_dimension_mismatch' => 0, 5353 'temporal_candidate_different_record_shape' => 0, 5354 'candidate_dimensions_match_without_identity_proof' => 0, 5355 'insufficient_candidate_dimensions' => 0, 5356 ]; 5357 $candidate_ids = []; 5358 $dimension_signatures = []; 5359 $details = []; 5360 foreach ($candidates as $candidate) { 5361 if (!is_array($candidate)) { 5362 continue; 5363 } 5364 $candidate_id = absint($candidate['id'] ?? 0); 5365 if ($candidate_id) { 5366 $candidate_ids[$candidate_id] = true; 5367 } 5368 $discovered_by = array_values((array) ($candidate['_discovered_by'] ?? [])); 5369 $has_fingerprint_signal = in_array('fingerprint', $discovered_by, true); 5370 $has_temporal_signal = in_array('temporal', $discovered_by, true); 5371 $checks = [ 5372 'product_id' => isset($incoming['product_id'], $candidate['product_id']) 5373 ? (int) $incoming['product_id'] === (int) $candidate['product_id'] : null, 5374 'record_type' => isset($incoming['record_type'], $candidate['record_type']) 5375 ? (string) $incoming['record_type'] === (string) $candidate['record_type'] : null, 5376 'observed_views' => isset($incoming['observed_views'], $candidate['observed_views']) 5377 ? (int) $incoming['observed_views'] === (int) $candidate['observed_views'] : null, 5378 'period_start' => !empty($incoming['period_start']) && !empty($candidate['period_start']) 5379 ? (string) $incoming['period_start'] === (string) $candidate['period_start'] : null, 5380 'period_end' => !empty($incoming['period_end']) && !empty($candidate['period_end']) 5381 ? (string) $incoming['period_end'] === (string) $candidate['period_end'] : null, 5382 ]; 5383 $known_checks = array_filter($checks, static function ($value) { return $value !== null; }); 5384 $all_known_dimensions_match = count($known_checks) === count($checks) 5385 && !in_array(false, $known_checks, true); 5386 $has_dimension_difference = in_array(false, $known_checks, true); 5387 if ($has_fingerprint_signal && $has_dimension_difference) { 5388 $classification = 'fingerprint_dimension_mismatch'; 5389 } elseif ($has_temporal_signal && ( 5390 $checks['record_type'] === false || $checks['observed_views'] === false 5391 )) { 5392 $classification = 'temporal_candidate_different_record_shape'; 5393 } elseif ($all_known_dimensions_match) { 5394 $classification = 'candidate_dimensions_match_without_identity_proof'; 5395 } else { 5396 $classification = 'insufficient_candidate_dimensions'; 5397 } 5398 $counts[$classification]++; 5399 $signature = implode('|', [ 5400 (string) ($candidate['product_id'] ?? 'unknown'), 5401 (string) ($candidate['record_type'] ?? 'unknown'), 5402 (string) ($candidate['observed_views'] ?? 'unknown'), 5403 (string) ($candidate['period_start'] ?? ''), 5404 (string) ($candidate['period_end'] ?? ''), 5405 ]); 5406 $dimension_signatures[$signature] = true; 5407 $details[] = [ 5408 'candidate_event_id' => $candidate_id ?: null, 5409 'candidate_source_id' => !empty($candidate['source_id']) ? (int) $candidate['source_id'] : null, 5410 'discovered_by' => $discovered_by, 5411 'classification' => $classification, 5412 'dimension_checks' => $checks, 5413 'identity_proven' => false, 5414 'automatic_suppression_authorized' => false, 5415 ]; 5416 } 5417 $candidate_count = count($candidate_ids); 5418 $record_integrity = $this->build_generic_candidate_record_integrity_assessment($details, $candidate_count); 5419 $heterogeneous = count($dimension_signatures) > 1; 5420 $has_conflict = $counts['fingerprint_dimension_mismatch'] > 0 5421 || $counts['temporal_candidate_different_record_shape'] > 0; 5422 return [ 5423 'status' => $candidate_count ? 'available' : 'no_candidates', 5424 'candidate_count' => $candidate_count, 5425 'candidate_dimension_signature_count' => count($dimension_signatures), 5426 'candidate_set_is_heterogeneous' => $heterogeneous, 5427 'conflicting_evidence_present' => $has_conflict, 5428 'classification_counts' => $counts, 5429 'candidate_record_integrity' => $record_integrity, 5430 'candidates' => $details, 5431 'identity_proven' => false, 5432 'automatic_suppression_authorized' => false, 5433 'policy' => 'Dimension differences and heterogeneous candidates are retained as review evidence only; they do not establish identity, select a winning candidate, or authorize count suppression.', 5434 ]; 5435 } 5436 5437 /** 5438 * Make an explicit audit decision from candidate support and contradiction evidence. 5439 * Absence of candidates is not evidence that sources do not overlap. A clean dimensional 5440 * comparison is not identity proof, and truncation always limits the conclusion. 5441 */ 5442 private function build_generic_candidate_evidence_decision($conflict_assessment, $candidate_set_assessment = [], $candidate_search_truncated = false) { 5443 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5444 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 5445 $counts = (array) ($conflict_assessment['classification_counts'] ?? []); 5446 $fingerprint_conflicts = max(0, (int) ($counts['fingerprint_dimension_mismatch'] ?? 0)); 5447 $temporal_shape_conflicts = max(0, (int) ($counts['temporal_candidate_different_record_shape'] ?? 0)); 5448 $matching_dimensions = max(0, (int) ($counts['candidate_dimensions_match_without_identity_proof'] ?? 0)); 5449 $insufficient_dimensions = max(0, (int) ($counts['insufficient_candidate_dimensions'] ?? 0)); 5450 $candidate_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0)); 5451 $truncated = (bool) $candidate_search_truncated 5452 || !empty($candidate_set_assessment['candidate_set_truncated']) 5453 || !empty($candidate_set_assessment['candidate_count_is_lower_bound']); 5454 5455 $support = []; 5456 $contradictions = []; 5457 $limitations = []; 5458 if ($matching_dimensions > 0) { 5459 $support[] = 'candidate_dimensions_match_but_do_not_prove_shared_identity'; 5460 } 5461 if ($fingerprint_conflicts > 0) { 5462 $contradictions[] = 'fingerprint_discovery_signal_has_dimension_mismatches'; 5463 } 5464 if ($temporal_shape_conflicts > 0) { 5465 $contradictions[] = 'temporal_candidates_have_different_record_shapes'; 5466 } 5467 if ($insufficient_dimensions > 0) { 5468 $limitations[] = 'candidate_dimensions_are_incomplete'; 5469 } 5470 if ($truncated) { 5471 $limitations[] = 'candidate_search_truncated_conclusion_is_incomplete'; 5472 } 5473 5474 if ($candidate_count === 0) { 5475 $classification = $truncated 5476 ? 'candidate_search_incomplete_no_retained_candidate_no_overlap_conclusion' 5477 : 'no_candidate_found_within_search_bounds_overlap_remains_unknown'; 5478 } elseif ($fingerprint_conflicts > 0 || $temporal_shape_conflicts > 0) { 5479 $classification = ($matching_dimensions > 0) 5480 ? 'supporting_and_contradictory_evidence_require_review' 5481 : 'contradictory_candidate_evidence_requires_review'; 5482 } elseif ($matching_dimensions > 0) { 5483 $classification = 'supportive_dimensions_without_identity_proof'; 5484 } else { 5485 $classification = 'insufficient_candidate_evidence_requires_review'; 5486 } 5487 if ($truncated && $candidate_count > 0) { 5488 $limitations[] = 'retained_candidates_are_not_the_complete_observed_set'; 5489 } 5490 5491 return [ 5492 'classification' => $classification, 5493 'supporting_evidence' => array_values(array_unique($support)), 5494 'contradictory_evidence' => array_values(array_unique($contradictions)), 5495 'limitations' => array_values(array_unique($limitations)), 5496 'supporting_candidate_count' => $matching_dimensions, 5497 'fingerprint_dimension_conflict_count' => $fingerprint_conflicts, 5498 'temporal_record_shape_conflict_count' => $temporal_shape_conflicts, 5499 'insufficient_dimension_candidate_count' => $insufficient_dimensions, 5500 'candidate_count' => $candidate_count, 5501 'candidate_search_truncated' => $truncated, 5502 'absence_of_candidates_proves_no_overlap' => false, 5503 'identity_proven' => false, 5504 'automatic_suppression_authorized' => false, 5505 'policy' => 'Support, contradiction, and missing evidence are reported separately. No candidate is not proof of no overlap; matching dimensions are not proof of identity; no evidence classification changes applied_views or authorizes suppression.', 5506 ]; 5507 } 5508 5509 /** 5510 * Validate that candidate inventory, conflict assessment, and evidence decision 5511 * describe the same retained evidence and preserve the non-suppression policy. 5512 * This is an audit integrity check only; it never changes reconciliation outcomes. 5513 */ 5514 private function build_generic_candidate_audit_consistency($candidate_set_assessment, $conflict_assessment, $evidence_decision) { 5515 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 5516 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5517 $evidence_decision = is_array($evidence_decision) ? $evidence_decision : []; 5518 $errors = []; 5519 $candidate_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0)); 5520 $retained_count = max(0, (int) ($candidate_set_assessment['candidate_count_retained'] ?? 0)); 5521 $decision_count = max(0, (int) ($evidence_decision['candidate_count'] ?? 0)); 5522 $counts = (array) ($conflict_assessment['classification_counts'] ?? []); 5523 $record_integrity = $conflict_assessment['candidate_record_integrity'] ?? null; 5524 $record_integrity = is_array($record_integrity) ? $record_integrity : []; 5525 $classified_count = 0; 5526 foreach ([ 5527 'fingerprint_dimension_mismatch', 5528 'temporal_candidate_different_record_shape', 5529 'candidate_dimensions_match_without_identity_proof', 5530 'insufficient_candidate_dimensions', 5531 ] as $key) { 5532 $classified_count += max(0, (int) ($counts[$key] ?? 0)); 5533 } 5534 if ($candidate_count !== $retained_count || $candidate_count !== $decision_count) { 5535 $errors[] = 'candidate_counts_disagree_across_audit_layers'; 5536 } 5537 if (empty($conflict_assessment['candidate_record_integrity']) || !is_array($conflict_assessment['candidate_record_integrity'])) { 5538 $errors[] = 'candidate_record_integrity_assessment_missing_from_conflict_audit'; 5539 } elseif (empty($record_integrity['consistent'])) { 5540 $errors[] = 'candidate_record_integrity_validation_failed'; 5541 } 5542 if (isset($record_integrity['expected_unique_candidate_count']) 5543 && (int) $record_integrity['expected_unique_candidate_count'] !== $candidate_count) { 5544 $errors[] = 'candidate_record_integrity_expected_count_disagrees_with_conflict_inventory'; 5545 } 5546 if ($classified_count > $candidate_count) { 5547 $errors[] = 'candidate_classification_count_exceeds_candidate_inventory'; 5548 } elseif ($classified_count < $candidate_count) { 5549 $errors[] = 'candidate_classification_coverage_incomplete'; 5550 } 5551 $classification_coverage_complete = $classified_count === $candidate_count; 5552 foreach ([ 5553 'supporting_candidate_count' => 'candidate_dimensions_match_without_identity_proof', 5554 'fingerprint_dimension_conflict_count' => 'fingerprint_dimension_mismatch', 5555 'temporal_record_shape_conflict_count' => 'temporal_candidate_different_record_shape', 5556 'insufficient_dimension_candidate_count' => 'insufficient_candidate_dimensions', 5557 ] as $decision_key => $conflict_key) { 5558 if (max(0, (int) ($evidence_decision[$decision_key] ?? 0)) !== max(0, (int) ($counts[$conflict_key] ?? 0))) { 5559 $errors[] = 'evidence_decision_count_mismatch_' . $decision_key; 5560 } 5561 } 5562 $set_truncated = !empty($candidate_set_assessment['candidate_set_truncated']); 5563 $decision_truncated = !empty($evidence_decision['candidate_search_truncated']); 5564 if ($set_truncated && !$decision_truncated) { 5565 $errors[] = 'candidate_truncation_not_propagated_to_evidence_decision'; 5566 } 5567 if (!empty($conflict_assessment['identity_proven']) || !empty($evidence_decision['identity_proven'])) { 5568 $errors[] = 'candidate_evidence_layer_claims_identity_proof'; 5569 } 5570 if (!empty($candidate_set_assessment['automatic_suppression_authorized']) 5571 || !empty($conflict_assessment['automatic_suppression_authorized']) 5572 || !empty($evidence_decision['automatic_suppression_authorized'])) { 5573 $errors[] = 'candidate_evidence_layer_authorizes_automatic_suppression'; 5574 } 5575 if ($candidate_count === 0 && !empty($evidence_decision['absence_of_candidates_proves_no_overlap'])) { 5576 $errors[] = 'absence_of_candidates_misrepresented_as_no_overlap_proof'; 5577 } 5578 return [ 5579 'status' => $errors ? 'inconsistent_audit_evidence_requires_review' : 'candidate_audit_layers_consistent', 5580 'consistent' => !$errors, 5581 'errors' => array_values(array_unique($errors)), 5582 'candidate_count_conflict_assessment' => $candidate_count, 5583 'candidate_count_inventory' => $retained_count, 5584 'candidate_count_evidence_decision' => $decision_count, 5585 'classified_candidate_count' => $classified_count, 5586 'unclassified_candidate_count' => max(0, $candidate_count - $classified_count), 5587 'classification_coverage_complete' => $classification_coverage_complete, 5588 'candidate_record_integrity' => [ 5589 'present' => !empty($conflict_assessment['candidate_record_integrity']) && is_array($conflict_assessment['candidate_record_integrity']), 5590 'consistent' => !empty($record_integrity['consistent']), 5591 'errors' => array_values((array) ($record_integrity['errors'] ?? [])), 5592 'candidate_detail_count' => max(0, (int) ($record_integrity['candidate_detail_count'] ?? 0)), 5593 'unique_valid_candidate_id_count' => max(0, (int) ($record_integrity['unique_valid_candidate_id_count'] ?? 0)), 5594 'expected_unique_candidate_count' => max(0, (int) ($record_integrity['expected_unique_candidate_count'] ?? 0)), 5595 ], 5596 'candidate_search_truncated' => $decision_truncated, 5597 'identity_proven' => false, 5598 'automatic_suppression_authorized' => false, 5599 'policy' => 'Consistency validation is audit-only. A failed validation requires review and never changes applied_views or authorizes count suppression.', 5600 ]; 5601 } 5602 5603 /** 5604 * Compare the event/source references retained by candidate inventory and 5605 * per-candidate conflict records. Reference mismatches require review only. 5606 */ 5607 private function build_generic_candidate_reference_consistency($candidate_set_assessment, $conflict_assessment) { 5608 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 5609 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5610 $inventory_ids = array_values(array_unique(array_filter(array_map('absint', (array) ($candidate_set_assessment['candidate_event_ids'] ?? []))))); 5611 $detail_ids = []; 5612 $detail_sources = []; 5613 $duplicate_detail_ids = []; 5614 foreach ((array) ($conflict_assessment['candidates'] ?? []) as $detail) { 5615 if (!is_array($detail) || empty($detail['candidate_event_id'])) { 5616 continue; 5617 } 5618 $id = absint($detail['candidate_event_id']); 5619 if (isset($detail_ids[$id])) { 5620 $duplicate_detail_ids[] = $id; 5621 } 5622 $detail_ids[$id] = true; 5623 $detail_sources[$id] = isset($detail['candidate_source_id']) ? absint($detail['candidate_source_id']) : 0; 5624 } 5625 $inventory_set = array_fill_keys($inventory_ids, true); 5626 $detail_set = $detail_ids; 5627 $missing_details = array_values(array_diff($inventory_ids, array_keys($detail_set))); 5628 $unexpected_details = array_values(array_diff(array_keys($detail_set), $inventory_ids)); 5629 $source_mismatches = []; 5630 $inventory_sources = (array) ($candidate_set_assessment['candidate_source_ids_by_event_id'] ?? []); 5631 foreach ($inventory_sources as $event_id => $source_id) { 5632 $event_id = absint($event_id); 5633 $source_id = absint($source_id); 5634 if (isset($detail_sources[$event_id]) && $detail_sources[$event_id] !== $source_id) { 5635 $source_mismatches[] = $event_id; 5636 } 5637 } 5638 $errors = []; 5639 if ($missing_details) { $errors[] = 'candidate_inventory_event_missing_conflict_detail'; } 5640 if ($unexpected_details) { $errors[] = 'conflict_detail_event_missing_from_candidate_inventory'; } 5641 if ($duplicate_detail_ids) { $errors[] = 'conflict_details_contain_duplicate_event_references'; } 5642 if ($source_mismatches) { $errors[] = 'candidate_source_reference_mismatch'; } 5643 return [ 5644 'status' => $errors ? 'candidate_references_require_review' : 'candidate_references_consistent', 5645 'consistent' => !$errors, 5646 'errors' => $errors, 5647 'inventory_event_ids' => $inventory_ids, 5648 'conflict_detail_event_ids' => array_map('intval', array_keys($detail_set)), 5649 'missing_conflict_detail_event_ids' => $missing_details, 5650 'unexpected_conflict_detail_event_ids' => $unexpected_details, 5651 'duplicate_conflict_detail_event_ids' => array_values(array_unique($duplicate_detail_ids)), 5652 'source_reference_mismatch_event_ids' => array_values(array_unique($source_mismatches)), 5653 'identity_proven' => false, 5654 'automatic_suppression_authorized' => false, 5655 'policy' => 'Reference consistency validates audit traceability only; it does not establish shared event identity or authorize suppression.', 5656 ]; 5657 } 5658 5659 /** 5660 * Validate discovery-signal provenance for every retained candidate. Signals 5661 * explain why a candidate was found; they are never treated as identity proof. 5662 */ 5663 private function build_generic_candidate_discovery_signal_consistency($candidate_set_assessment, $conflict_assessment) { 5664 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 5665 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5666 $inventory_ids = array_values(array_unique(array_filter(array_map('absint', (array) ($candidate_set_assessment['candidate_event_ids'] ?? []))))); 5667 $allowed_signals = ['fingerprint', 'temporal']; 5668 $candidate_signals = []; 5669 $missing_signal_ids = []; 5670 $unknown_signals = []; 5671 $duplicate_signals = []; 5672 $unexpected_ids = []; 5673 $inventory_set = array_fill_keys($inventory_ids, true); 5674 foreach ((array) ($conflict_assessment['candidates'] ?? []) as $detail) { 5675 if (!is_array($detail) || empty($detail['candidate_event_id'])) { 5676 continue; 5677 } 5678 $event_id = absint($detail['candidate_event_id']); 5679 if (!isset($inventory_set[$event_id])) { 5680 $unexpected_ids[] = $event_id; 5681 } 5682 $signals = $detail['discovered_by'] ?? null; 5683 if (!is_array($signals) || !$signals) { 5684 $missing_signal_ids[] = $event_id; 5685 $candidate_signals[$event_id] = []; 5686 continue; 5687 } 5688 $normalized = []; 5689 foreach ($signals as $signal) { 5690 if (!is_string($signal) || !in_array($signal, $allowed_signals, true)) { 5691 $unknown_signals[] = ['candidate_event_id' => $event_id, 'signal' => is_scalar($signal) ? (string) $signal : gettype($signal)]; 5692 continue; 5693 } 5694 if (in_array($signal, $normalized, true)) { 5695 $duplicate_signals[] = ['candidate_event_id' => $event_id, 'signal' => $signal]; 5696 } 5697 $normalized[] = $signal; 5698 } 5699 $candidate_signals[$event_id] = array_values(array_unique($normalized)); 5700 } 5701 $missing_candidate_ids = array_values(array_diff($inventory_ids, array_keys($candidate_signals))); 5702 $errors = []; 5703 if ($missing_signal_ids) { $errors[] = 'candidate_discovery_signal_missing'; } 5704 if ($missing_candidate_ids) { $errors[] = 'candidate_discovery_record_missing'; } 5705 if ($unexpected_ids) { $errors[] = 'discovery_signal_candidate_outside_inventory'; } 5706 if ($unknown_signals) { $errors[] = 'unknown_candidate_discovery_signal'; } 5707 if ($duplicate_signals) { $errors[] = 'duplicate_candidate_discovery_signal'; } 5708 return [ 5709 'status' => $errors ? 'candidate_discovery_signals_require_review' : 'candidate_discovery_signals_consistent', 5710 'consistent' => !$errors, 5711 'errors' => $errors, 5712 'allowed_signal_types' => $allowed_signals, 5713 'candidate_signals_by_event_id' => $candidate_signals, 5714 'missing_signal_event_ids' => array_values(array_unique($missing_signal_ids)), 5715 'missing_candidate_record_event_ids' => $missing_candidate_ids, 5716 'unexpected_candidate_event_ids' => array_values(array_unique($unexpected_ids)), 5717 'unknown_signals' => $unknown_signals, 5718 'duplicate_signals' => $duplicate_signals, 5719 'identity_proven' => false, 5720 'automatic_suppression_authorized' => false, 5721 'policy' => 'Fingerprint and temporal signals describe discovery provenance only; neither signal establishes shared event identity or authorizes suppression.', 5722 ]; 5723 } 5724 5725 /** 5726 * Verify that adapter evidence preserves the discovery signals recorded for 5727 * each candidate. Discovery provenance remains advisory and is never identity proof. 5728 */ 5729 private function build_generic_adapter_discovery_evidence_consistency($conflict_assessment, $adapter_overlap_evidence) { 5730 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5731 $adapter_overlap_evidence = is_array($adapter_overlap_evidence) ? $adapter_overlap_evidence : []; 5732 $expected = []; 5733 foreach ((array) ($conflict_assessment['candidates'] ?? []) as $candidate) { 5734 if (!is_array($candidate) || empty($candidate['candidate_event_id'])) { continue; } 5735 $id = absint($candidate['candidate_event_id']); 5736 $expected[$id] = array_values(array_unique(array_filter((array) ($candidate['discovered_by'] ?? []), 'is_string'))); 5737 } 5738 $seen = []; 5739 $missing = []; 5740 $unexpected = []; 5741 $mismatched = []; 5742 $duplicate = []; 5743 $invalid = []; 5744 $allowed = ['fingerprint', 'temporal']; 5745 foreach ($adapter_overlap_evidence as $entry) { 5746 if (!is_array($entry) || empty($entry['candidate_event_id'])) { $invalid[] = 'missing_candidate_event_id'; continue; } 5747 $id = absint($entry['candidate_event_id']); 5748 if (isset($seen[$id])) { $duplicate[] = $id; } 5749 $seen[$id] = true; 5750 if (!array_key_exists($id, $expected)) { $unexpected[] = $id; continue; } 5751 $signals = $entry['discovered_by'] ?? null; 5752 if (!is_array($signals)) { $invalid[] = $id; continue; } 5753 foreach ($signals as $signal) { 5754 if (!is_string($signal) || !in_array($signal, $allowed, true)) { $invalid[] = $id; } 5755 } 5756 $normalized = array_values(array_unique(array_filter($signals, 'is_string'))); 5757 sort($normalized); 5758 $expected_normalized = $expected[$id]; 5759 sort($expected_normalized); 5760 $comparison_signals = $entry['comparison']['identity_assessment']['candidate_discovery_signals'] ?? null; 5761 if (!is_array($comparison_signals)) { 5762 $mismatched[] = $id; 5763 } else { 5764 $comparison_normalized = array_values(array_unique(array_filter($comparison_signals, 'is_string'))); 5765 sort($comparison_normalized); 5766 if ($comparison_normalized !== $normalized) { $mismatched[] = $id; } 5767 } 5768 if ($normalized !== $expected_normalized) { $mismatched[] = $id; } 5769 } 5770 $missing = array_values(array_diff(array_keys($expected), array_keys($seen))); 5771 $errors = []; 5772 if ($missing) { $errors[] = 'adapter_discovery_evidence_missing_candidates'; } 5773 if ($unexpected) { $errors[] = 'adapter_discovery_evidence_has_unexpected_candidates'; } 5774 if ($mismatched) { $errors[] = 'adapter_discovery_signals_disagree_with_candidate_inventory'; } 5775 if ($duplicate) { $errors[] = 'adapter_discovery_evidence_has_duplicate_candidates'; } 5776 if ($invalid) { $errors[] = 'adapter_discovery_evidence_has_invalid_signals_or_records'; } 5777 return [ 5778 'status' => $errors ? 'adapter_discovery_evidence_requires_review' : 'adapter_discovery_evidence_consistent', 5779 'consistent' => !$errors, 5780 'errors' => $errors, 5781 'candidate_count_expected' => count($expected), 5782 'candidate_count_observed' => count($seen), 5783 'missing_candidate_event_ids' => $missing, 5784 'unexpected_candidate_event_ids' => array_values(array_unique($unexpected)), 5785 'mismatched_candidate_event_ids' => array_values(array_unique($mismatched)), 5786 'duplicate_candidate_event_ids' => array_values(array_unique($duplicate)), 5787 'invalid_candidate_records' => array_values(array_unique($invalid, SORT_REGULAR)), 5788 'identity_proven' => false, 5789 'automatic_suppression_authorized' => false, 5790 'policy' => 'Adapter evidence must preserve discovery provenance; matching signals remain discovery evidence only and never establish shared event identity.', 5791 ]; 5792 } 5793 5794 /** 5795 * Compare the normalized dimensions in adapter evidence with the dimensions 5796 * independently classified for each candidate. This validates audit transport, 5797 * not shared identity; missing values remain unknown rather than matching. 5798 */ 5799 /** 5800 * Classify temporal granularity without treating interval overlap as identity. 5801 * Daily windows, aggregate periods and point timestamps are distinct evidence shapes. 5802 */ 5803 /** 5804 * Describe the temporal basis that can actually be established from the 5805 * adapter contract. The current event table does not persist a timezone or 5806 * timezone-version fingerprint, so even WordPress-local values produced by 5807 * two adapters cannot safely be compared as absolute instants across imports. 5808 * Calendar aggregates retain date-label semantics and are not converted to 5809 * timestamps. This helper is descriptive and never authorizes suppression. 5810 */ 5811 private function classify_reconciliation_temporal_basis($adapter_key, $origin_key, $record_type) { 5812 $adapter_key = sanitize_key((string) $adapter_key); 5813 $origin_key = sanitize_key((string) $origin_key); 5814 $record_type = sanitize_key((string) $record_type); 5815 5816 if ($record_type === 'aggregate') { 5817 return [ 5818 'basis' => 'aggregate_period_timezone_unverified', 5819 'source_representation' => 'date_bounded_aggregate_or_period', 5820 'absolute_time_comparable' => false, 5821 'reason' => 'The adapter imports an aggregate period, but its exact calendar semantics, source timezone, and historical site-timezone setting are not persisted here.', 5822 ]; 5823 } 5824 5825 if ($record_type === 'event' && $adapter_key === 'burst_statistics') { 5826 return [ 5827 'basis' => 'wordpress_site_local_timezone_unrecorded', 5828 'source_representation' => 'unix_timestamp_converted_with_wp_date', 5829 'absolute_time_comparable' => false, 5830 'reason' => 'The source instant is known, but the WordPress timezone identifier/offset at import time is not persisted with the event.', 5831 ]; 5832 } 5833 5834 if ($record_type === 'event' && $adapter_key === 'independent_analytics') { 5835 return [ 5836 'basis' => 'wordpress_site_local_timezone_unrecorded', 5837 'source_representation' => 'utc_timestamp_converted_to_wp_local_with_wp_date', 5838 'absolute_time_comparable' => false, 5839 'reason' => 'The source is UTC and is converted to WordPress local time, but the conversion timezone identifier/offset is not persisted with the event.', 5840 ]; 5841 } 5842 5843 if ($record_type === 'event' && $adapter_key === 'pulseviews' && $origin_key === 'recent_raw_views') { 5844 return [ 5845 'basis' => 'source_local_timezone_unknown', 5846 'source_representation' => 'naive_sql_datetime', 5847 'absolute_time_comparable' => false, 5848 'reason' => 'The raw source datetime has no documented timezone or UTC offset.', 5849 ]; 5850 } 5851 5852 return [ 5853 'basis' => 'unknown', 5854 'source_representation' => 'unverified', 5855 'absolute_time_comparable' => false, 5856 'reason' => 'The adapter does not provide sufficient persisted timezone provenance for absolute-time comparison.', 5857 ]; 5858 } 5859 5860 private function classify_reconciliation_temporal_granularity($record_type, $period_start, $period_end, $precision_hint = 'unknown') { 5861 if (!$this->is_valid_reconciliation_period_range($period_start, $period_end)) { 5862 return [ 5863 'granularity' => 'unknown', 5864 'classification' => 'insufficient_or_invalid_temporal_bounds', 5865 'comparable_at_same_granularity' => false, 5866 ]; 5867 } 5868 if ($precision_hint === 'calendar_day' 5869 || (substr($period_start, 11) === '00:00:00' && substr($period_end, 11) === '23:59:59')) { 5870 return [ 5871 'granularity' => 'calendar_day', 5872 'classification' => 'day_level_interval', 5873 'comparable_at_same_granularity' => true, 5874 ]; 5875 } 5876 if ($record_type === 'aggregate') { 5877 return [ 5878 'granularity' => 'aggregate_period', 5879 'classification' => 'aggregate_interval', 5880 'comparable_at_same_granularity' => true, 5881 ]; 5882 } 5883 if ($record_type === 'event' && $period_start === $period_end) { 5884 return [ 5885 'granularity' => 'point_timestamp', 5886 'classification' => 'event_timestamp_unverified', 5887 'comparable_at_same_granularity' => true, 5888 ]; 5889 } 5890 return [ 5891 'granularity' => 'unknown', 5892 'classification' => 'unrecognized_temporal_shape', 5893 'comparable_at_same_granularity' => false, 5894 ]; 5895 } 5896 5897 /** 5898 * Accept only canonical local SQL datetime values for reconciliation periods. 5899 * This validates representation, not timezone equivalence or event identity. 5900 */ 5901 private function is_valid_reconciliation_period_datetime($value) { 5902 if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)) { 5903 return false; 5904 } 5905 $parsed = DateTime::createFromFormat('!Y-m-d H:i:s', $value); 5906 $errors = DateTime::getLastErrors(); 5907 return $parsed instanceof DateTime 5908 && $parsed->format('Y-m-d H:i:s') === $value 5909 && ($errors === false || (empty($errors['warning_count']) && empty($errors['error_count']))); 5910 } 5911 5912 /** 5913 * Validate chronological ordering when both period boundaries are known. 5914 * Canonical local SQL datetimes can be compared lexically; this does not 5915 * infer timezone equivalence or prove event identity. 5916 */ 5917 private function is_valid_reconciliation_period_range($period_start, $period_end) { 5918 if (!$this->is_valid_reconciliation_period_datetime($period_start) 5919 || !$this->is_valid_reconciliation_period_datetime($period_end)) { 5920 return null; 5921 } 5922 return strcmp($period_end, $period_start) >= 0; 5923 } 5924 5925 private function build_generic_adapter_dimension_evidence_consistency($conflict_assessment, $adapter_overlap_evidence) { 5926 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 5927 $adapter_overlap_evidence = is_array($adapter_overlap_evidence) ? $adapter_overlap_evidence : []; 5928 $expected = []; 5929 foreach ((array) ($conflict_assessment['candidates'] ?? []) as $candidate) { 5930 if (!is_array($candidate) || empty($candidate['candidate_event_id'])) { continue; } 5931 $expected[absint($candidate['candidate_event_id'])] = (array) ($candidate['dimension_checks'] ?? []); 5932 } 5933 $seen = []; 5934 $missing = []; 5935 $unexpected = []; 5936 $mismatched = []; 5937 $incomplete = []; 5938 $invalid_period_ranges = []; 5939 $dimensions = ['product_id', 'record_type', 'observed_views', 'period_start', 'period_end']; 5940 foreach ($adapter_overlap_evidence as $entry) { 5941 if (!is_array($entry) || empty($entry['candidate_event_id'])) { $incomplete[] = 'missing_candidate_event_id'; continue; } 5942 $id = absint($entry['candidate_event_id']); 5943 if (isset($seen[$id])) { $mismatched[] = $id; } 5944 $seen[$id] = true; 5945 if (!array_key_exists($id, $expected)) { $unexpected[] = $id; continue; } 5946 $incoming = $entry['comparison']['incoming'] ?? null; 5947 $candidate = $entry['comparison']['candidate'] ?? null; 5948 if (!is_array($incoming) || !is_array($candidate)) { $incomplete[] = $id; continue; } 5949 $actual = []; 5950 foreach ($dimensions as $dimension) { 5951 if (!array_key_exists($dimension, $incoming) || !array_key_exists($dimension, $candidate)) { 5952 $actual[$dimension] = null; 5953 continue; 5954 } 5955 if ($dimension === 'product_id' || $dimension === 'observed_views') { 5956 $left_value = $incoming[$dimension]; 5957 $right_value = $candidate[$dimension]; 5958 if ($left_value === null || $right_value === null || $left_value === '' || $right_value === '' 5959 || !is_numeric($left_value) || !is_numeric($right_value)) { 5960 $actual[$dimension] = null; 5961 $incomplete[] = $id . ':' . $dimension; 5962 } else { 5963 $left_number = (float) $left_value; 5964 $right_number = (float) $right_value; 5965 if ($left_number < 0 || $right_number < 0 5966 || ($dimension === 'product_id' && ($left_number <= 0 || $right_number <= 0))) { 5967 $actual[$dimension] = null; 5968 $incomplete[] = $id . ':' . $dimension; 5969 } else { 5970 $actual[$dimension] = $left_number === $right_number; 5971 } 5972 } 5973 } elseif ($dimension === 'record_type') { 5974 $left = is_scalar($incoming[$dimension]) ? (string) $incoming[$dimension] : ''; 5975 $right = is_scalar($candidate[$dimension]) ? (string) $candidate[$dimension] : ''; 5976 $actual[$dimension] = ($left !== '' && $right !== '') ? $left === $right : null; 5977 if ($actual[$dimension] === null) { $incomplete[] = $id . ':' . $dimension; } 5978 } else { 5979 $left = $incoming[$dimension]; 5980 $right = $candidate[$dimension]; 5981 if (!$this->is_valid_reconciliation_period_datetime($left) 5982 || !$this->is_valid_reconciliation_period_datetime($right)) { 5983 $actual[$dimension] = null; 5984 $incomplete[] = $id . ':' . $dimension; 5985 } else { 5986 $actual[$dimension] = $left === $right; 5987 } 5988 } 5989 } 5990 foreach (['incoming' => $incoming, 'candidate' => $candidate] as $side => $period_data) { 5991 $range_valid = $this->is_valid_reconciliation_period_range( 5992 $period_data['period_start'] ?? null, 5993 $period_data['period_end'] ?? null 5994 ); 5995 if ($range_valid === false) { 5996 $invalid_period_ranges[] = $id . ':' . $side; 5997 $mismatched[] = $id; 5998 } elseif ($range_valid === null) { 5999 // Boundary validity is already reported per dimension above. 6000 // Do not turn unknown boundaries into an ordering conclusion. 6001 } 6002 } 6003 $expected_checks = $expected[$id]; 6004 foreach ($dimensions as $dimension) { 6005 if (!array_key_exists($dimension, $expected_checks) || $expected_checks[$dimension] !== $actual[$dimension]) { 6006 $mismatched[] = $id; 6007 break; 6008 } 6009 } 6010 } 6011 $missing = array_values(array_diff(array_keys($expected), array_keys($seen))); 6012 $errors = []; 6013 if ($missing) { $errors[] = 'adapter_dimension_evidence_missing_candidates'; } 6014 if ($unexpected) { $errors[] = 'adapter_dimension_evidence_has_unexpected_candidates'; } 6015 if ($mismatched) { $errors[] = 'adapter_dimension_checks_disagree_with_conflict_assessment'; } 6016 if ($incomplete) { $errors[] = 'adapter_dimension_evidence_incomplete'; } 6017 if ($invalid_period_ranges) { $errors[] = 'adapter_period_range_end_precedes_start'; } 6018 return [ 6019 'status' => $errors ? 'adapter_dimension_evidence_requires_review' : 'adapter_dimension_evidence_consistent', 6020 'consistent' => !$errors, 6021 'errors' => $errors, 6022 'candidate_count_expected' => count($expected), 6023 'candidate_count_observed' => count($seen), 6024 'missing_candidate_event_ids' => $missing, 6025 'unexpected_candidate_event_ids' => array_values(array_unique($unexpected)), 6026 'mismatched_candidate_event_ids' => array_values(array_unique($mismatched)), 6027 'incomplete_candidate_records' => array_values(array_unique($incomplete, SORT_REGULAR)), 6028 'invalid_period_ranges' => array_values(array_unique($invalid_period_ranges)), 6029 'identity_proven' => false, 6030 'automatic_suppression_authorized' => false, 6031 'policy' => 'Adapter dimension evidence must reproduce the independently recorded comparison checks. Missing dimensions remain unknown; reversed known period boundaries require review. Consistency validates audit integrity only and never proves shared event identity.', 6032 ]; 6033 } 6034 6035 /** 6036 * Validate the complete candidate reconciliation audit chain, including the 6037 * independent candidate ordering checks. This is diagnostic only and cannot 6038 * promote evidence to identity proof or change any applied contribution. 6039 */ 6040 private function build_generic_candidate_reconciliation_chain_assessment($candidate_set_assessment, $conflict_assessment, $evidence_decision, $audit_consistency, $candidate_consistency, $reference_consistency = [], $discovery_signal_consistency = []) { 6041 $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : []; 6042 $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : []; 6043 $evidence_decision = is_array($evidence_decision) ? $evidence_decision : []; 6044 $audit_consistency = is_array($audit_consistency) ? $audit_consistency : []; 6045 $candidate_consistency = is_array($candidate_consistency) ? $candidate_consistency : []; 6046 $reference_consistency = is_array($reference_consistency) ? $reference_consistency : []; 6047 $discovery_signal_consistency = is_array($discovery_signal_consistency) ? $discovery_signal_consistency : []; 6048 $errors = []; 6049 6050 $inventory_count = max(0, (int) ($candidate_set_assessment['candidate_count_retained'] ?? 0)); 6051 $conflict_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0)); 6052 $decision_count = max(0, (int) ($evidence_decision['candidate_count'] ?? 0)); 6053 $ordering_count = max(0, (int) ($candidate_consistency['candidate_count'] ?? 0)); 6054 $adapter_count = max(0, (int) ($candidate_consistency['adapter_evidence_candidate_count'] ?? 0)); 6055 6056 if (count(array_unique([$inventory_count, $conflict_count, $decision_count, $ordering_count])) > 1) { 6057 $errors[] = 'candidate_inventory_conflict_decision_and_ordering_counts_disagree'; 6058 } 6059 if (!empty($candidate_consistency['adapter_evidence_order_consistent']) && $adapter_count !== $ordering_count) { 6060 $errors[] = 'adapter_evidence_count_disagrees_despite_order_consistency_claim'; 6061 } 6062 if (!empty($candidate_consistency['primary_in_candidate_list']) === false) { 6063 $errors[] = 'primary_candidate_missing_from_candidate_list'; 6064 } 6065 if (!empty($candidate_consistency['adapter_evidence_primary_consistent']) === false) { 6066 $errors[] = 'adapter_evidence_primary_candidate_inconsistent'; 6067 } 6068 if (!empty($candidate_consistency['adapter_evidence_order_consistent']) === false) { 6069 $errors[] = 'adapter_evidence_candidate_order_inconsistent'; 6070 } 6071 if (array_key_exists('adapter_discovery_evidence_consistent', $candidate_consistency) 6072 && empty($candidate_consistency['adapter_discovery_evidence_consistent'])) { 6073 $errors[] = 'adapter_discovery_evidence_provenance_inconsistent'; 6074 } 6075 if (array_key_exists('adapter_dimension_evidence_consistent', $candidate_consistency) 6076 && empty($candidate_consistency['adapter_dimension_evidence_consistent'])) { 6077 $errors[] = 'adapter_dimension_evidence_inconsistent_with_conflict_assessment'; 6078 } 6079 if (empty($audit_consistency['consistent'])) { 6080 $errors[] = 'underlying_candidate_audit_layers_are_inconsistent'; 6081 } 6082 if (array_key_exists('consistent', $reference_consistency) && empty($reference_consistency['consistent'])) { 6083 $errors[] = 'candidate_event_or_source_references_are_inconsistent'; 6084 } 6085 if (array_key_exists('consistent', $discovery_signal_consistency) && empty($discovery_signal_consistency['consistent'])) { 6086 $errors[] = 'candidate_discovery_signal_provenance_is_inconsistent'; 6087 } 6088 if (array_key_exists('classification_coverage_complete', $audit_consistency) 6089 && empty($audit_consistency['classification_coverage_complete'])) { 6090 $errors[] = 'candidate_classification_coverage_incomplete_in_chain'; 6091 } 6092 if (!empty($candidate_set_assessment['candidate_set_truncated']) && empty($evidence_decision['candidate_search_truncated'])) { 6093 $errors[] = 'truncation_missing_from_final_candidate_decision'; 6094 } 6095 if (!empty($evidence_decision['identity_proven']) || !empty($candidate_consistency['identity_proven'])) { 6096 $errors[] = 'candidate_reconciliation_chain_claims_identity_proof'; 6097 } 6098 if (!empty($evidence_decision['automatic_suppression_authorized']) || !empty($candidate_consistency['automatic_suppression_authorized'])) { 6099 $errors[] = 'candidate_reconciliation_chain_authorizes_automatic_suppression'; 6100 } 6101 6102 return [ 6103 'status' => $errors ? 'reconciliation_chain_requires_review' : 'reconciliation_chain_consistent', 6104 'consistent' => !$errors, 6105 'errors' => array_values(array_unique($errors)), 6106 'candidate_counts' => [ 6107 'inventory' => $inventory_count, 6108 'conflict_assessment' => $conflict_count, 6109 'evidence_decision' => $decision_count, 6110 'candidate_ordering' => $ordering_count, 6111 'adapter_evidence' => $adapter_count, 6112 ], 6113 'candidate_search_truncated' => !empty($evidence_decision['candidate_search_truncated']), 6114 'classification_coverage_complete' => !empty($audit_consistency['classification_coverage_complete']), 6115 'classified_candidate_count' => max(0, (int) ($audit_consistency['classified_candidate_count'] ?? 0)), 6116 'unclassified_candidate_count' => max(0, (int) ($audit_consistency['unclassified_candidate_count'] ?? 0)), 6117 'candidate_reference_consistency' => $reference_consistency, 6118 'candidate_discovery_signal_consistency' => $discovery_signal_consistency, 6119 'adapter_discovery_evidence_consistent' => !empty($candidate_consistency['adapter_discovery_evidence_consistent']), 6120 'adapter_dimension_evidence_consistent' => !empty($candidate_consistency['adapter_dimension_evidence_consistent']), 6121 'identity_proven' => false, 6122 'automatic_suppression_authorized' => false, 6123 'policy' => 'This end-to-end check validates audit-chain consistency only. Any inconsistency requires review; no result changes applied_views, totals, or count suppression.', 6124 ]; 6125 } 6126 6127 private function build_generic_product_reconciliation_summary($product_id, $cross_source, $partial_overlap, $product_synthesis, $matched_event = null, $candidate_set = null) { 6128 $cross_source = is_array($cross_source) ? $cross_source : []; 6129 $partial_overlap = is_array($partial_overlap) ? $partial_overlap : []; 6130 $product_synthesis = is_array($product_synthesis) ? $product_synthesis : []; 6131 $candidate_set = is_array($candidate_set) ? $candidate_set : []; 6132 $candidate_set_assessment = $this->build_generic_candidate_set_assessment( 6133 $candidate_set['candidates'] ?? [], 6134 !empty($candidate_set['truncated']), 6135 $candidate_set['observed_count'] ?? null 6136 ); 6137 6138 $capability_status = sanitize_key((string) ($product_synthesis['capability_profile_status'] ?? 'unknown')); 6139 $synthesis_status = sanitize_key((string) ($product_synthesis['status'] ?? 'unknown')); 6140 $partial_status = sanitize_key((string) ($partial_overlap['status'] ?? 'unknown')); 6141 $cross_truncated = !empty($cross_source['candidate_set_truncated']); 6142 $inventory_truncated = !empty($product_synthesis['source_record_groups_truncated']); 6143 $partial_truncated = !empty($partial_overlap['records_truncated']); 6144 6145 $limitations = []; 6146 if ($synthesis_status !== 'available') { $limitations[] = 'product_source_synthesis_' . ($synthesis_status ?: 'unknown'); } 6147 if (!in_array($capability_status, ['available'], true)) { $limitations[] = 'source_capability_profiles_' . ($capability_status ?: 'unknown'); } 6148 if ($cross_truncated) { $limitations[] = 'cross_source_candidate_set_truncated'; } 6149 if ($inventory_truncated) { $limitations[] = 'product_source_record_groups_truncated'; } 6150 if ($partial_status !== 'ok') { $limitations[] = 'partial_overlap_' . ($partial_status ?: 'unknown'); } 6151 if ($partial_truncated) { $limitations[] = 'partial_overlap_records_truncated'; } 6152 6153 $source_count = max(0, (int) ($product_synthesis['source_count_in_scope'] ?? 0)); 6154 $capability_count = max(0, (int) ($product_synthesis['capability_profile_source_count'] ?? 0)); 6155 $complete = !$limitations && $source_count === $capability_count; 6156 6157 // Classify the available evidence without making a new counting decision. 6158 // The exact-match flag is supplied only by the importer's existing path, 6159 // which has already validated the shared fingerprint and normalized dimensions. 6160 $candidate_records = max(0, (int) ($cross_source['candidate_record_count'] ?? 0)); 6161 $candidate_sources = max(0, (int) ($cross_source['candidate_source_count'] ?? 0)); 6162 if (is_array($matched_event) && !empty($matched_event['id'])) { 6163 $decision_classification = 'confirmed_exact_shared_fingerprint_match'; 6164 $decision_basis = 'existing_importer_exact_match_contract'; 6165 $decision_identity_proven = true; 6166 } elseif (!empty($candidate_set_assessment['multiple_candidate_ambiguity'])) { 6167 $decision_classification = 'multiple_cross_source_candidates_require_review'; 6168 $decision_basis = 'all_candidates_retained_without_identity_resolution'; 6169 $decision_identity_proven = false; 6170 } elseif ($candidate_records > 0 || $candidate_sources > 0) { 6171 $decision_classification = 'possible_cross_source_overlap_requires_review'; 6172 $decision_basis = 'candidate_fingerprint_or_temporal_evidence_only'; 6173 $decision_identity_proven = false; 6174 } else { 6175 $decision_classification = 'insufficient_evidence_to_establish_cross_source_overlap'; 6176 $decision_basis = 'no_candidate_found_within_declared_search_bounds'; 6177 $decision_identity_proven = false; 6178 } 6179 6180 return [ 6181 'version' => '1.0.0', 6182 'status' => $complete ? 'evidence_inventory_complete_within_declared_bounds' : 'evidence_inventory_partial_or_limited', 6183 'product_id' => absint($product_id), 6184 'source_scope' => 'all_persisted_generic_sources_for_product', 6185 'source_count' => $source_count, 6186 'capability_profile_source_count' => $capability_count, 6187 'cross_source_candidate_count' => max(0, (int) ($cross_source['candidate_source_count'] ?? 0)), 6188 'partial_overlap_source_count' => count((array) ($partial_overlap['profiles'] ?? [])), 6189 'same_bucket_count_bounds' => $cross_source['identical_bucket_count_range'] ?? ['status' => 'unavailable'], 6190 'reconciliation_decision' => [ 6191 'classification' => $decision_classification, 6192 'basis' => $decision_basis, 6193 'identity_proven' => $decision_identity_proven, 6194 'summary_authorizes_count_change' => false, 6195 'candidate_search_truncated' => $cross_truncated, 6196 'candidate_set_assessment' => $candidate_set_assessment, 6197 'policy' => 'This classification describes evidence only. It does not independently authorize suppression, adjustment, or rewriting of applied_views.', 6198 ], 6199 'unique_view_total' => null, 6200 'counting_decision' => 'preserve_all_applied_contributions_no_automatic_suppression', 6201 'limitations' => array_values(array_unique($limitations)), 6202 'interpretation' => 'This summary combines source inventory, declared capabilities, candidate overlap evidence, and the availability limits of partial-period comparison. It does not prove event identity or calculate a deduplicated unique-view total.', 6203 ]; 6204 } 6205 6206 /** 6207 * Partial aggregate-period duration comparisons are withheld until source 6208 * timezone provenance is persisted. Naive datetime strings cannot safely be 6209 * converted into shared elapsed-time intervals. 6210 */ 6211 private function build_generic_partial_overlap_profiles($product_id, $source_id, $period_start, $period_end) { 6212 // Persisted period strings do not include source timezone provenance. 6213 // Do not report elapsed seconds or coverage ratios from naive datetimes. 6214 return [ 6215 'status' => 'unavailable', 6216 'reason' => 'timezone_basis_not_persisted', 6217 'incoming_period_seconds' => null, 6218 'profiles' => [], 6219 'records_truncated' => false, 6220 'identity_confidence' => 'unproven_without_shared_event_identity', 6221 'temporal_comparison' => 'not_performed', 6222 ]; 6223 } 6224 6225 /** Execute the importer while the caller holds the source-origin advisory lock. */ 6226 private function process_generic_historical_view_import_batch($source_id, $origin_key, $retry_checkpoint_override = null, $origin_lock = null) { 6227 global $wpdb; 6228 6229 $source_id = absint($source_id); 6230 $origin_key = sanitize_key((string) $origin_key); 6231 if (!$source_id || $origin_key === '') { 6232 return ['status' => 'invalid_input']; 6233 } 6234 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 6235 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6236 return ['status' => 'origin_lock_session_lost']; 6237 } 6238 6239 $state = $this->get_view_source_origin_state($source_id, $origin_key); 6240 if (empty($state['status']) || $state['status'] !== 'found') { 6241 return ['status' => 'origin_not_found']; 6242 } 6243 // A queued action may outlive a provider's latest discovery inventory. 6244 // Never let such a stale action process an origin that has since been 6245 // marked unavailable or is no longer explicitly detected as available. 6246 if ($state['origin_status'] === 'unavailable' || $state['detection_status'] !== 'available') { 6247 return ['status' => 'origin_unavailable']; 6248 } 6249 if ($state['origin_status'] === 'completed' && $retry_checkpoint_override === null) { 6250 return ['status' => 'already_completed']; 6251 } 6252 // A terminal failure for one record must not stop the main source scan. 6253 // The failed record remains in the ledger for review, while later records 6254 // continue importing. Only the independent retry worker is capped at three 6255 // total attempts for that individual record. 6256 if (!is_array($state['snapshot']) || empty($state['snapshot']['boundary']) || !is_array($state['checkpoint'])) { 6257 return ['status' => 'snapshot_not_ready']; 6258 } 6259 6260 $adapter_key = isset($state['adapter_key']) ? sanitize_key($state['adapter_key']) : ''; 6261 $adapter = $this->get_historical_view_adapter($adapter_key); 6262 if (!$adapter) { 6263 // A scheduled request may have arrived before provider registration. 6264 $this->discover_historical_view_adapters(); 6265 $adapter = $this->get_historical_view_adapter($adapter_key); 6266 } 6267 if (!$adapter) { 6268 // Provider plugins may be temporarily inactive, load later than this 6269 // worker, or briefly fail while assembling their registry. Keep the 6270 // immutable snapshot/checkpoint untouched and retry discovery later. 6271 // Normal plugin initialization also rediscovers providers, so a 6272 // returning adapter can resume automatically without administrator 6273 // intervention. A replacement adapter still cannot reinterpret the 6274 // persisted identity because register_view_source_origin() guards it. 6275 $this->update_view_source_origin_state($source_id, $origin_key, [ 6276 'last_error' => 'The registered historical adapter is temporarily unavailable; automatic discovery will be retried.', 6277 ], $origin_lock); 6278 $this->schedule_generic_historical_view_import_batch(21600, $source_id, $origin_key); 6279 return ['status' => 'adapter_unavailable', 'retry_scheduled' => true]; 6280 } 6281 6282 $checkpoint = ($retry_checkpoint_override !== null && is_array($retry_checkpoint_override)) 6283 ? $retry_checkpoint_override 6284 : $state['checkpoint']; 6285 $batch_start_checkpoint = $checkpoint; 6286 try { 6287 $batch = $adapter->get_product_view_batch($origin_key, $checkpoint, $state['snapshot'], 100); 6288 } catch (Throwable $exception) { 6289 $failure = $this->record_generic_historical_view_failure($source_id, 6290 $origin_key, 6291 'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32), 6292 0, 6293 $exception->getMessage(), 6294 null, 6295 8, 6296 $origin_lock); 6297 if (!$failure['blocked']) { 6298 $this->schedule_generic_historical_view_import_batch($failure['retry_delay'], $source_id, $origin_key); 6299 } 6300 return ['status' => $failure['blocked'] ? 'needs_attention' : 'source_read_failed', 'attempts' => $failure['attempts']]; 6301 } 6302 6303 $batch_rows_are_valid_list = is_array($batch) 6304 && isset($batch['rows']) 6305 && is_array($batch['rows']) 6306 && array_keys($batch['rows']) === (count($batch['rows']) > 0 ? range(0, count($batch['rows']) - 1) : []) 6307 && count($batch['rows']) <= 100; 6308 if ($batch_rows_are_valid_list) { 6309 foreach ($batch['rows'] as $batch_row) { 6310 if (!is_array($batch_row)) { 6311 $batch_rows_are_valid_list = false; 6312 break; 6313 } 6314 } 6315 } 6316 6317 $batch_is_valid = is_array($batch) 6318 && $batch_rows_are_valid_list 6319 && array_key_exists('has_more', $batch) 6320 && is_bool($batch['has_more']) 6321 // The adapter must apply the immutable snapshot boundary in its source query. 6322 && isset($batch['boundary_applied']) 6323 && $batch['boundary_applied'] === true; 6324 6325 if ($batch_is_valid && $batch['has_more']) { 6326 $batch_is_valid = !empty($batch['rows']) 6327 && array_key_exists('next_checkpoint', $batch) 6328 && (is_array($batch['next_checkpoint']) || is_scalar($batch['next_checkpoint'])) 6329 && $this->is_valid_generic_historical_cursor($batch['next_checkpoint']) 6330 && wp_json_encode($batch['next_checkpoint']) !== wp_json_encode($checkpoint); 6331 } 6332 6333 if (!$batch_is_valid) { 6334 $failure = $this->record_generic_historical_view_failure($source_id, 6335 $origin_key, 6336 'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32), 6337 0, 6338 'Adapter returned an invalid or non-progressing batch. Expected a sequential list of at most 100 array rows, a boolean has_more, boundary_applied=true, and an advancing next_checkpoint whenever more rows remain.', 6339 null, 6340 8, 6341 $origin_lock); 6342 if (!$failure['blocked']) { 6343 $this->schedule_generic_historical_view_import_batch($failure['retry_delay'], $source_id, $origin_key); 6344 } 6345 return ['status' => $failure['blocked'] ? 'needs_attention' : 'invalid_batch', 'attempts' => $failure['attempts']]; 6346 } 6347 6348 // The source has now returned a structurally valid, snapshot-bounded batch; 6349 // clear a previous transient batch-read failure before processing its records. 6350 $batch_failure_cleared = $this->clear_generic_historical_view_failure( 6351 $source_id, 6352 $origin_key, 6353 'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32) 6354 , $origin_lock); 6355 if (!$batch_failure_cleared) { 6356 // Do not consume this batch while its previous batch-level failure 6357 // remains unresolved in the durable ledger. 6358 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6359 return ['status' => 'failure_ledger_clear_failed']; 6360 } 6361 6362 $processed = 0; 6363 $duplicates = 0; 6364 $failed = 0; 6365 $skipped = 0; 6366 $next_checkpoint = $checkpoint; 6367 6368 $checkpoint_persistence_failed = false; 6369 foreach ($batch['rows'] as $record) { 6370 try { 6371 $normalized = $adapter->normalize_record($origin_key, $record); 6372 } catch (Throwable $exception) { 6373 $normalized = null; 6374 $normalization_error = $exception->getMessage(); 6375 } 6376 6377 // A replay of a saved batch must retry only records whose own retry 6378 // deadline has arrived. Otherwise one due record could cause blocked 6379 // or not-yet-due neighbours in the same batch to be retried indefinitely. 6380 if ($retry_checkpoint_override !== null) { 6381 if (is_array($normalized)) { 6382 $retry_record_key = !empty($normalized['source_record_key']) 6383 ? (string) $normalized['source_record_key'] 6384 : wp_json_encode($record); 6385 } elseif (is_array($record) && isset($record['source_record_key'])) { 6386 $retry_record_key = (string) $record['source_record_key']; 6387 } else { 6388 $retry_record_key = wp_json_encode($record); 6389 } 6390 $retry_record_key = substr((string) $retry_record_key, 0, 191); 6391 $retry_record_hash = hash('sha256', (string) $origin_key . "\0" . $retry_record_key); 6392 $retry_is_due = statpl_db_get_var(statpl_db_prepare( 6393 "SELECT id FROM {$this->view_source_failures_table} 6394 WHERE source_id = %d AND source_record_hash = %s 6395 AND status = 'retry' AND next_retry_at <= %s 6396 LIMIT 1", 6397 $source_id, 6398 $retry_record_hash, 6399 current_time('mysql') 6400 )); 6401 if (!empty($wpdb->last_error)) { 6402 $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key); 6403 return ['status' => 'retry_ledger_lookup_failed', 'processed' => $processed, 'duplicates' => $duplicates]; 6404 } 6405 if (!$retry_is_due) { 6406 continue; 6407 } 6408 } 6409 6410 if (is_array($normalized) && !empty($normalized['skip'])) { 6411 // Adapters may explicitly identify a source row as outside their 6412 // product-view scope (for example, a site-wide analytics row for 6413 // a blog page). Such rows are not failures and must not block the 6414 // scan, but their source key and cursor still require validation. 6415 $skip_record_key = isset($normalized['source_record_key']) 6416 ? trim((string) $normalized['source_record_key']) : ''; 6417 $skip_cursor = array_key_exists('cursor', $normalized) ? $normalized['cursor'] : null; 6418 if ($skip_record_key === '' || strlen($skip_record_key) > 191 6419 || !$this->is_valid_generic_historical_cursor($skip_cursor)) { 6420 $normalized = null; 6421 $normalization_error = 'Adapter returned an invalid explicit skip record.'; 6422 } else { 6423 $skip_failure_cleared = $this->clear_generic_historical_view_failure( 6424 $source_id, 6425 $origin_key, 6426 $skip_record_key 6427 , $origin_lock, null); 6428 if (!$skip_failure_cleared) { 6429 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6430 $checkpoint_persistence_failed = true; 6431 break; 6432 } 6433 $next_checkpoint['cursor'] = $skip_cursor; 6434 $skipped++; 6435 continue; 6436 } 6437 } 6438 6439 if (!is_array($normalized)) { 6440 $failed++; 6441 $failure = $this->record_generic_historical_view_failure( 6442 $source_id, 6443 $origin_key, 6444 (is_array($record) && isset($record['source_record_key'])) ? $record['source_record_key'] : wp_json_encode($record), 6445 0, 6446 isset($normalization_error) ? $normalization_error : 'Adapter could not normalize a source record.', 6447 $batch_start_checkpoint, 6448 3 6449 , $origin_lock); 6450 if (empty($failure['persisted'])) { 6451 // Without a durable failure-ledger entry, do not advance past this record. 6452 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6453 $checkpoint_persistence_failed = true; 6454 break; 6455 } 6456 if (!$failure['blocked']) { 6457 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6458 } 6459 unset($normalization_error); 6460 continue; 6461 } 6462 6463 $product_id = isset($normalized['product_id']) ? absint($normalized['product_id']) : 0; 6464 $record_key = isset($normalized['source_record_key']) ? trim((string) $normalized['source_record_key']) : ''; 6465 $raw_views = array_key_exists('views', $normalized) ? $normalized['views'] : null; 6466 $views_is_valid = is_int($raw_views) && $raw_views >= 0; 6467 if (is_string($raw_views) && preg_match('/^(0|[1-9]\d*)$/D', $raw_views)) { 6468 // Do not compare large decimal strings through float: on 64-bit PHP, 6469 // (float) PHP_INT_MAX rounds to 2^63 and can admit an overflowing value. 6470 $max_views_string = (string) PHP_INT_MAX; 6471 $raw_views_length = strlen($raw_views); 6472 $max_views_length = strlen($max_views_string); 6473 $views_is_valid = $raw_views_length < $max_views_length 6474 || ($raw_views_length === $max_views_length && strcmp($raw_views, $max_views_string) <= 0); 6475 } 6476 $views = $views_is_valid ? (int) $raw_views : -1; 6477 $cursor = array_key_exists('cursor', $normalized) ? $normalized['cursor'] : null; 6478 $record_type = isset($normalized['record_type']) ? sanitize_key($normalized['record_type']) : 'event'; 6479 $period_start = array_key_exists('period_start', $normalized) && $normalized['period_start'] !== null 6480 ? (string) $normalized['period_start'] : null; 6481 $period_end = array_key_exists('period_end', $normalized) && $normalized['period_end'] !== null 6482 ? (string) $normalized['period_end'] : null; 6483 $periods_are_valid = $this->is_valid_generic_historical_datetime($period_start) 6484 && $this->is_valid_generic_historical_datetime($period_end); 6485 if ($periods_are_valid && $period_start !== null && $period_end !== null) { 6486 $periods_are_valid = strcmp($period_start, $period_end) <= 0; 6487 } 6488 6489 if ( 6490 !$product_id || get_post_type($product_id) !== 'product' 6491 || $record_key === '' || strlen($record_key) > 191 6492 || !$views_is_valid || $views < 0 || !in_array($record_type, ['event', 'aggregate'], true) 6493 || !$this->is_valid_generic_historical_cursor($cursor) 6494 || !$periods_are_valid 6495 || ($record_type === 'aggregate' && ($period_start === null || $period_end === null)) 6496 ) { 6497 $failed++; 6498 $failure = $this->record_generic_historical_view_failure( 6499 $source_id, 6500 $origin_key, 6501 $record_key !== '' ? $record_key : wp_json_encode($record), 6502 $product_id, 6503 'Normalized record failed validation (product, key, views, type or cursor).', 6504 $batch_start_checkpoint, 6505 3 6506 , $origin_lock); 6507 if (empty($failure['persisted'])) { 6508 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6509 $checkpoint_persistence_failed = true; 6510 break; 6511 } 6512 if (!$failure['blocked']) { 6513 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6514 } 6515 unset($normalization_error); 6516 continue; 6517 } 6518 6519 $record_hash = hash('sha256', $origin_key . "\0" . $record_key); 6520 $now = current_time('mysql'); 6521 $source_fingerprint = isset($normalized['source_fingerprint']) 6522 ? strtolower(trim((string) $normalized['source_fingerprint'])) 6523 : ''; 6524 if ($source_fingerprint !== '' && !preg_match('/^[a-f0-9]{64}$/D', $source_fingerprint)) { 6525 $failed++; 6526 $failure = $this->record_generic_historical_view_failure( 6527 $source_id, 6528 $origin_key, 6529 $record_key, 6530 $product_id, 6531 'Adapter supplied an invalid cross-source fingerprint; expected exactly 64 hexadecimal characters.', 6532 $batch_start_checkpoint, 6533 3 6534 , $origin_lock); 6535 if (empty($failure['persisted'])) { 6536 // Without a durable failure-ledger entry, do not advance past this record. 6537 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6538 $checkpoint_persistence_failed = true; 6539 break; 6540 } 6541 if (!$failure['blocked']) { 6542 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6543 } 6544 continue; 6545 } 6546 6547 // Cross-plugin reconciliation is deliberately conservative: suppress a 6548 // second contribution only when both adapters provide the same explicit 6549 // stable fingerprint AND all normalized event dimensions agree exactly. 6550 // A fingerprint-scoped advisory lock also serializes the no-existing-row 6551 // case, which SELECT ... FOR UPDATE alone cannot reliably protect. 6552 $reconciliation_lock = 'statpl_xr_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_fingerprint), 0, 48); 6553 $reconciliation_lock_state = null; 6554 // Aggregate fingerprints derived from product/date/count are not shared 6555 // event identities. They must never drive exact cross-source suppression. 6556 // Keep exact fingerprint reconciliation restricted to individual events. 6557 $has_exact_event_fingerprint = $source_fingerprint !== '' && $record_type === 'event'; 6558 if ($has_exact_event_fingerprint) { 6559 $reconciliation_lock_state = $this->acquire_generic_advisory_lock($reconciliation_lock); 6560 if (empty($reconciliation_lock_state['acquired'])) { 6561 $this->schedule_generic_historical_view_import_batch(30, $source_id, $origin_key); 6562 return ['status' => 'cross_source_lock_busy', 'processed' => $processed, 'duplicates' => $duplicates]; 6563 } 6564 if (!$this->generic_advisory_lock_session_is_current($reconciliation_lock_state)) { 6565 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6566 return ['status' => 'cross_source_lock_session_lost', 'processed' => $processed, 'duplicates' => $duplicates]; 6567 } 6568 } 6569 6570 $transaction_open = false; 6571 try { 6572 if (statpl_db_query('START TRANSACTION') === false) { 6573 $failed++; 6574 $transaction_error = (string) $wpdb->last_error; 6575 $failure = $this->record_generic_historical_view_failure( 6576 $source_id, 6577 $origin_key, 6578 $record_key, 6579 $product_id, 6580 'Could not start atomic source-event transaction: ' . $transaction_error, 6581 $batch_start_checkpoint, 6582 3 6583 , $origin_lock); 6584 if (empty($failure['persisted'])) { 6585 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6586 $checkpoint_persistence_failed = true; 6587 break; 6588 } 6589 if (!$failure['blocked']) { 6590 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6591 } 6592 continue; 6593 } 6594 $transaction_open = true; 6595 $matched_event = null; 6596 $possible_overlap_event = null; 6597 $possible_overlap_events = []; 6598 $possible_overlap_candidates_truncated = false; 6599 $possible_overlap_fingerprint_candidates_observed = 0; 6600 $possible_overlap_temporal_candidates_observed = 0; 6601 $possible_overlap_candidate_count_observed = 0; 6602 $possible_overlap_candidate_count_is_lower_bound = false; 6603 $possible_overlap_reason_code = ''; 6604 if ($has_exact_event_fingerprint) { 6605 $matched_event = statpl_db_get_row(statpl_db_prepare( 6606 "SELECT id, source_id, product_id, record_type, observed_views, period_start, period_end, applied_views 6607 FROM {$this->view_source_events_table} 6608 WHERE source_fingerprint = %s 6609 AND source_id <> %d 6610 AND product_id = %d 6611 AND record_type = %s 6612 AND observed_views = %d 6613 AND ((period_start IS NULL AND %s = '') OR period_start = %s) 6614 AND ((period_end IS NULL AND %s = '') OR period_end = %s) 6615 ORDER BY id ASC LIMIT 1 FOR UPDATE", 6616 $source_fingerprint, 6617 $source_id, 6618 $product_id, 6619 $record_type, 6620 $views, 6621 (string) $period_start, 6622 $period_start, 6623 (string) $period_end, 6624 $period_end 6625 ), ARRAY_A); 6626 if (!empty($wpdb->last_error)) { 6627 $lookup_error = $wpdb->last_error; 6628 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6629 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6630 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6631 } 6632 $failed++; 6633 $failure = $this->record_generic_historical_view_failure( 6634 $source_id, 6635 $origin_key, 6636 $record_key, 6637 $product_id, 6638 'Cross-source reconciliation lookup failed: ' . $lookup_error, 6639 $batch_start_checkpoint, 6640 3 6641 , $origin_lock); 6642 if (empty($failure['persisted'])) { 6643 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6644 $checkpoint_persistence_failed = true; 6645 break; 6646 } 6647 if (!$failure['blocked']) { 6648 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6649 } 6650 continue; 6651 } 6652 if (!$matched_event) { 6653 // Same explicit fingerprint with different normalized dimensions is 6654 // evidence of a possible/partial overlap, not proof of a duplicate. 6655 // Preserve and count this contribution, but retain an auditable 6656 // candidate link so a source-specific reconciliation rule can review it. 6657 $fingerprint_candidate_result = $this->get_generic_reconciliation_candidates_by_source( 6658 'fingerprint', 6659 ['fingerprint' => $source_fingerprint, 'source_id' => $source_id], 6660 25 6661 ); 6662 $possible_overlap_events = $fingerprint_candidate_result['events']; 6663 $possible_overlap_fingerprint_candidates_observed = (int) $fingerprint_candidate_result['observed']; 6664 if ($fingerprint_candidate_result['status'] === 'error') { 6665 $lookup_error = $fingerprint_candidate_result['error']; 6666 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6667 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6668 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6669 } 6670 $failed++; 6671 $failure = $this->record_generic_historical_view_failure( 6672 $source_id, $origin_key, $record_key, $product_id, 6673 'Possible-overlap reconciliation lookup failed: ' . $lookup_error, 6674 $batch_start_checkpoint, 3, $origin_lock 6675 ); 6676 if (empty($failure['persisted'])) { 6677 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6678 $checkpoint_persistence_failed = true; 6679 break; 6680 } 6681 if (!$failure['blocked']) { 6682 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6683 } 6684 continue; 6685 } 6686 if (!empty($fingerprint_candidate_result['truncated'])) { 6687 $possible_overlap_candidates_truncated = true; 6688 $possible_overlap_candidate_count_is_lower_bound = true; 6689 } 6690 if (!empty($possible_overlap_events)) { 6691 $possible_overlap_event = $possible_overlap_events[0]; 6692 $possible_overlap_reason_code = 'shared_fingerprint_dimension_mismatch'; 6693 } 6694 if (!empty($wpdb->last_error)) { 6695 $lookup_error = $wpdb->last_error; 6696 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6697 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6698 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6699 } 6700 $failed++; 6701 $failure = $this->record_generic_historical_view_failure( 6702 $source_id, 6703 $origin_key, 6704 $record_key, 6705 $product_id, 6706 'Possible-overlap reconciliation lookup failed: ' . $lookup_error, 6707 $batch_start_checkpoint, 6708 3 6709 , $origin_lock); 6710 if (empty($failure['persisted'])) { 6711 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6712 $checkpoint_persistence_failed = true; 6713 break; 6714 } 6715 if (!$failure['blocked']) { 6716 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6717 } 6718 continue; 6719 } 6720 } 6721 } 6722 // Cross-source temporal overlap is retained as evidence only. This 6723 // includes aggregate/event, aggregate/aggregate, and event/event pairs. 6724 // Event timestamps can reveal strong candidate relationships even when 6725 // providers do not share an event ID; temporal proximity alone still does 6726 // not prove identity, so this discovery step never suppresses counts. 6727 if ( 6728 !$matched_event 6729 && in_array($record_type, ['aggregate', 'event'], true) 6730 && $period_start !== null 6731 && $period_end !== null 6732 ) { 6733 $temporal_candidate_result = $this->get_generic_reconciliation_candidates_by_source( 6734 'temporal', 6735 ['source_id' => $source_id, 'product_id' => $product_id, 'period_start' => $period_start, 'period_end' => $period_end], 6736 25 6737 ); 6738 $temporal_overlap_events = $temporal_candidate_result['events']; 6739 if ($temporal_candidate_result['status'] === 'error') { 6740 $lookup_error = $temporal_candidate_result['error']; 6741 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6742 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6743 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6744 } 6745 $failed++; 6746 $failure = $this->record_generic_historical_view_failure( 6747 $source_id, $origin_key, $record_key, $product_id, 6748 'Cross-source temporal-overlap lookup failed: ' . $lookup_error, 6749 $batch_start_checkpoint, 3, $origin_lock 6750 ); 6751 if (empty($failure['persisted'])) { 6752 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6753 $checkpoint_persistence_failed = true; 6754 break; 6755 } 6756 if (!$failure['blocked']) { 6757 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6758 } 6759 continue; 6760 } 6761 if (!empty($wpdb->last_error)) { 6762 $lookup_error = $wpdb->last_error; 6763 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 6764 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6765 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 6766 } 6767 $failed++; 6768 $failure = $this->record_generic_historical_view_failure( 6769 $source_id, 6770 $origin_key, 6771 $record_key, 6772 $product_id, 6773 'Cross-source temporal-overlap lookup failed: ' . $lookup_error, 6774 $batch_start_checkpoint, 6775 3 6776 , $origin_lock); 6777 if (empty($failure['persisted'])) { 6778 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 6779 $checkpoint_persistence_failed = true; 6780 break; 6781 } 6782 if (!$failure['blocked']) { 6783 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 6784 } 6785 continue; 6786 } 6787 $possible_overlap_temporal_candidates_observed = (int) $temporal_candidate_result['observed']; 6788 if (!empty($temporal_candidate_result['truncated'])) { 6789 $possible_overlap_candidates_truncated = true; 6790 $possible_overlap_candidate_count_is_lower_bound = true; 6791 } 6792 6793 $candidate_discovery_by_id = []; 6794 foreach ($possible_overlap_events as $candidate_event) { 6795 $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0; 6796 if ($candidate_id > 0) { 6797 $candidate_discovery_by_id[$candidate_id]['fingerprint'] = true; 6798 } 6799 } 6800 foreach ($temporal_overlap_events as $candidate_event) { 6801 $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0; 6802 if ($candidate_id > 0) { 6803 $candidate_discovery_by_id[$candidate_id]['temporal'] = true; 6804 } 6805 } 6806 6807 $candidate_events_by_id = []; 6808 foreach (array_merge($possible_overlap_events, $temporal_overlap_events) as $candidate_event) { 6809 $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0; 6810 if ($candidate_id > 0) { 6811 $candidate_event['_discovered_by'] = array_keys($candidate_discovery_by_id[$candidate_id] ?? []); 6812 $candidate_events_by_id[$candidate_id] = $candidate_event; 6813 } 6814 } 6815 $possible_overlap_events = array_values($candidate_events_by_id); 6816 usort($possible_overlap_events, static function ($left, $right) { 6817 $left_sources = isset($left['_discovered_by']) ? $left['_discovered_by'] : []; 6818 $right_sources = isset($right['_discovered_by']) ? $right['_discovered_by'] : []; 6819 $left_rank = count($left_sources) > 1 ? 0 : (in_array('fingerprint', $left_sources, true) ? 1 : 2); 6820 $right_rank = count($right_sources) > 1 ? 0 : (in_array('fingerprint', $right_sources, true) ? 1 : 2); 6821 if ($left_rank !== $right_rank) { 6822 return $left_rank <=> $right_rank; 6823 } 6824 return (int) $left['id'] <=> (int) $right['id']; 6825 }); 6826 $possible_overlap_candidate_count_observed = count($possible_overlap_events); 6827 if (count($possible_overlap_events) > 25) { 6828 $possible_overlap_candidates_truncated = true; 6829 $possible_overlap_candidate_count_is_lower_bound = true; 6830 $possible_overlap_events = array_slice($possible_overlap_events, 0, 25); 6831 } 6832 if (!empty($possible_overlap_events)) { 6833 $possible_overlap_event = $possible_overlap_events[0]; 6834 } 6835 if ($possible_overlap_event) { 6836 $has_fingerprint_candidate = false; 6837 $has_cross_type_candidate = false; 6838 $has_same_type_event_candidate = false; 6839 foreach ($possible_overlap_events as $candidate_event) { 6840 $discovery = isset($candidate_event['_discovered_by']) ? $candidate_event['_discovered_by'] : []; 6841 if (in_array('fingerprint', $discovery, true)) { 6842 $has_fingerprint_candidate = true; 6843 } 6844 if ( 6845 in_array('temporal', $discovery, true) 6846 && isset($candidate_event['record_type']) 6847 && $candidate_event['record_type'] !== $record_type 6848 ) { 6849 $has_cross_type_candidate = true; 6850 } 6851 if ( 6852 in_array('temporal', $discovery, true) 6853 && $record_type === 'event' 6854 && isset($candidate_event['record_type']) 6855 && $candidate_event['record_type'] === 'event' 6856 ) { 6857 $has_same_type_event_candidate = true; 6858 } 6859 } 6860 if ($has_fingerprint_candidate) { 6861 $possible_overlap_reason_code = 'shared_fingerprint_dimension_mismatch'; 6862 } elseif ($has_cross_type_candidate) { 6863 $possible_overlap_reason_code = 'aggregate_event_temporal_overlap'; 6864 } elseif ($has_same_type_event_candidate) { 6865 $possible_overlap_reason_code = 'event_timestamp_overlap'; 6866 } elseif ($possible_overlap_reason_code === '') { 6867 $possible_overlap_reason_code = 'overlapping_aggregate_periods'; 6868 } 6869 } 6870 } 6871 6872 if (empty($temporal_overlap_events) && !empty($possible_overlap_events)) { 6873 foreach ($possible_overlap_events as &$candidate_event) { 6874 $candidate_event['_discovered_by'] = ['fingerprint']; 6875 } 6876 unset($candidate_event); 6877 $possible_overlap_candidate_count_observed = count($possible_overlap_events); 6878 if (!empty($possible_overlap_events)) { 6879 $possible_overlap_event = $possible_overlap_events[0]; 6880 } 6881 } 6882 6883 // Optional source-specific analysis enriches the audit trail for partial 6884 // overlaps. It is deliberately advisory: an adapter cannot silently change 6885 // the counting policy or suppress a contribution through this extension. 6886 $adapter_overlap_evidence = []; 6887 $adapter_evidence_bytes = 0; 6888 $adapter_evidence_total_limit = 65536; 6889 $adapter_evidence_candidate_limit = 4096; 6890 $adapter_evidence_omitted = 0; 6891 if (!$matched_event && $possible_overlap_event && method_exists($adapter, 'get_reconciliation_evidence')) { 6892 $overlap_candidates = !empty($possible_overlap_events) 6893 ? $possible_overlap_events 6894 : [$possible_overlap_event]; 6895 foreach ($overlap_candidates as $candidate_event) { 6896 try { 6897 $adapter_evidence = $adapter->get_reconciliation_evidence( 6898 $origin_key, 6899 $normalized, 6900 $candidate_event 6901 ); 6902 } catch (Throwable $exception) { 6903 $adapter_evidence = [ 6904 'analysis_status' => 'failed', 6905 'error' => substr(sanitize_text_field($exception->getMessage()), 0, 500), 6906 ]; 6907 } 6908 6909 if (!is_array($adapter_evidence)) { 6910 $adapter_evidence = ['analysis_status' => 'invalid_result']; 6911 } 6912 $encoded_adapter_evidence = wp_json_encode($adapter_evidence); 6913 $candidate_evidence_size = is_string($encoded_adapter_evidence) ? strlen($encoded_adapter_evidence) : 0; 6914 if (!is_string($encoded_adapter_evidence)) { 6915 $adapter_evidence = [ 6916 'analysis_status' => 'rejected', 6917 'reason' => 'Evidence was not JSON serializable.', 6918 ]; 6919 $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence)); 6920 } elseif ($candidate_evidence_size > $adapter_evidence_candidate_limit) { 6921 $adapter_evidence = [ 6922 'analysis_status' => 'rejected', 6923 'reason' => 'Evidence exceeded the 4 KiB per-candidate limit.', 6924 'original_bytes' => $candidate_evidence_size, 6925 ]; 6926 $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence)); 6927 } 6928 if ($adapter_evidence_bytes + $candidate_evidence_size > $adapter_evidence_total_limit) { 6929 $adapter_evidence_omitted++; 6930 $adapter_evidence = [ 6931 'analysis_status' => 'omitted_budget', 6932 'reason' => 'The 64 KiB total adapter-evidence budget was reached.', 6933 ]; 6934 $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence)); 6935 } 6936 $adapter_evidence_bytes += $candidate_evidence_size; 6937 $candidate_start = isset($candidate_event['period_start']) ? (string) $candidate_event['period_start'] : ''; 6938 $candidate_end = isset($candidate_event['period_end']) ? (string) $candidate_event['period_end'] : ''; 6939 $incoming_start = (string) $period_start; 6940 $incoming_end = (string) $period_end; 6941 $incoming_has_period = $incoming_start !== '' && $incoming_end !== ''; 6942 $candidate_has_period = $candidate_start !== '' && $candidate_end !== ''; 6943 // Exact string equality remains a representation-level signal; 6944 // it is not a timezone-safe temporal comparison. 6945 $same_start = $incoming_has_period && $candidate_has_period && $incoming_start === $candidate_start; 6946 $same_end = $incoming_has_period && $candidate_has_period && $incoming_end === $candidate_end; 6947 $incoming_temporal_basis = $this->classify_reconciliation_temporal_basis( 6948 $adapter_key, 6949 $origin_key, 6950 $record_type 6951 ); 6952 $candidate_adapter_key = isset($candidate_event['candidate_adapter_key']) 6953 ? sanitize_key((string) $candidate_event['candidate_adapter_key']) : ''; 6954 $candidate_record_key = isset($candidate_event['source_record_key']) 6955 ? (string) $candidate_event['source_record_key'] : ''; 6956 $candidate_origin_key = ''; 6957 if ($candidate_adapter_key === 'pulseviews') { 6958 if (strpos($candidate_record_key, 'pulseviews_day_') === 0) { 6959 $candidate_origin_key = 'daily_aggregates'; 6960 } elseif (strpos($candidate_record_key, 'pulseviews_view_') === 0) { 6961 $candidate_origin_key = 'recent_raw_views'; 6962 } 6963 } 6964 $candidate_temporal_basis = $this->classify_reconciliation_temporal_basis( 6965 $candidate_adapter_key, 6966 $candidate_origin_key, 6967 isset($candidate_event['record_type']) ? $candidate_event['record_type'] : '' 6968 ); 6969 $periods_intersect = false; 6970 $temporal_relation = ($incoming_has_period && $candidate_has_period) 6971 ? 'not_compared_timezone_basis_unverified' 6972 : 'insufficient_temporal_data'; 6973 $incoming_precision = 'unknown'; 6974 if ($record_type === 'aggregate') { 6975 $incoming_precision = 'aggregate_period'; 6976 } elseif ($adapter_key === 'statify') { 6977 $incoming_precision = 'calendar_day'; 6978 } elseif (in_array($adapter_key, ['burst_statistics', 'independent_analytics'], true)) { 6979 $incoming_precision = 'seconds'; 6980 } elseif ($adapter_key === 'pulseviews') { 6981 $incoming_precision = $origin_key === 'daily_aggregates' ? 'calendar_day' : 'seconds'; 6982 } elseif ($record_type === 'event' && $incoming_start !== '' && $incoming_start === $incoming_end) { 6983 $incoming_precision = 'source_event_timestamp_unverified'; 6984 } 6985 $candidate_precision = (isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'aggregate') 6986 ? 'aggregate_period' 6987 : ((isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'event' && $candidate_start !== '' && $candidate_start === $candidate_end) 6988 ? 'stored_event_timestamp_unverified' 6989 : 'unknown'); 6990 $incoming_granularity = $this->classify_reconciliation_temporal_granularity( 6991 (string) $record_type, 6992 $incoming_start, 6993 $incoming_end, 6994 $incoming_precision 6995 ); 6996 $candidate_granularity = $this->classify_reconciliation_temporal_granularity( 6997 isset($candidate_event['record_type']) ? (string) $candidate_event['record_type'] : '', 6998 $candidate_start, 6999 $candidate_end, 7000 $candidate_precision 7001 ); 7002 $same_temporal_granularity = $incoming_granularity['granularity'] !== 'unknown' 7003 && $incoming_granularity['granularity'] === $candidate_granularity['granularity']; 7004 $temporal_granularity_relation = $same_temporal_granularity 7005 ? 'same_granularity_temporal_evidence_only' 7006 : (($incoming_granularity['granularity'] !== 'unknown' && $candidate_granularity['granularity'] !== 'unknown') 7007 ? 'different_temporal_granularity' 7008 : 'insufficient_temporal_granularity'); 7009 7010 $candidate_rank = count($adapter_overlap_evidence) + 1; 7011 $adapter_overlap_evidence[] = [ 7012 'candidate_event_id' => (int) $candidate_event['id'], 7013 'candidate_rank' => $candidate_rank, 7014 'is_primary_candidate' => $candidate_rank === 1 7015 && (int) $candidate_event['id'] === (int) $possible_overlap_event['id'], 7016 'discovered_by' => isset($candidate_event['_discovered_by']) ? $candidate_event['_discovered_by'] : [], 7017 'adapter_key' => $adapter_key, 7018 'comparison' => [ 7019 'identity_assessment' => [ 7020 'candidate_discovery_signals' => isset($candidate_event['_discovered_by']) 7021 ? array_values((array) $candidate_event['_discovered_by']) 7022 : [], 7023 'same_explicit_fingerprint_signal' => in_array( 7024 'fingerprint', 7025 (array) ($candidate_event['_discovered_by'] ?? []), 7026 true 7027 ) && $source_fingerprint !== '', 7028 'classification' => ( 7029 in_array('fingerprint', (array) ($candidate_event['_discovered_by'] ?? []), true) 7030 && $source_fingerprint !== '' 7031 ) ? 'shared_fingerprint_candidate_requires_contract_and_dimension_validation' : 'temporal_or_other_candidate_without_shared_identity', 7032 'identity_proven' => false, 7033 'automatic_suppression_authorized' => false, 7034 'reason' => 'Candidate discovery by a matching fingerprint is a review signal only. Identity is confirmed only by the separate exact-match path, which also requires event type and all normalized dimensions to agree.', 7035 ], 7036 'incoming' => [ 7037 'source_id' => (int) $source_id, 7038 'origin_key' => (string) $origin_key, 7039 'product_id' => (int) $product_id, 7040 'record_type' => (string) $record_type, 7041 'observed_views' => (int) $views, 7042 'period_start' => $incoming_start, 7043 'period_end' => $incoming_end, 7044 'temporal_basis' => $incoming_temporal_basis, 7045 ], 7046 'candidate' => [ 7047 'source_id' => isset($candidate_event['source_id']) ? (int) $candidate_event['source_id'] : 0, 7048 'source_key' => isset($candidate_event['candidate_source_key']) ? (string) $candidate_event['candidate_source_key'] : null, 7049 'adapter_key' => isset($candidate_event['candidate_adapter_key']) ? (string) $candidate_event['candidate_adapter_key'] : null, 7050 'product_id' => isset($candidate_event['product_id']) ? (int) $candidate_event['product_id'] : 0, 7051 'record_type' => isset($candidate_event['record_type']) ? (string) $candidate_event['record_type'] : '', 7052 'observed_views' => isset($candidate_event['observed_views']) ? (int) $candidate_event['observed_views'] : 0, 7053 'applied_views' => isset($candidate_event['applied_views']) ? (int) $candidate_event['applied_views'] : 0, 7054 'period_start' => $candidate_start, 7055 'period_end' => $candidate_end, 7056 'temporal_basis' => $candidate_temporal_basis, 7057 ], 7058 'temporal_analysis' => [ 7059 'incoming_precision' => $incoming_precision, 7060 'candidate_precision' => $candidate_precision, 7061 'relation' => $temporal_relation, 7062 'incoming_basis' => $incoming_temporal_basis, 7063 'candidate_basis' => $candidate_temporal_basis, 7064 'absolute_time_comparison_performed' => false, 7065 'incoming_granularity' => $incoming_granularity, 7066 'candidate_granularity' => $candidate_granularity, 7067 'granularity_relation' => $temporal_granularity_relation, 7068 'same_granularity_is_identity_proof' => false, 7069 'same_granularity_establishes_timezone_compatibility' => false, 7070 'same_period_bounds' => $same_start && $same_end, 7071 'same_instant_bounds' => false, 7072 'same_event_datetime_string_representation' => $same_start && $same_end 7073 && $record_type === 'event' 7074 && isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'event', 7075 'same_instant_is_proven' => false, 7076 'interpretation' => 'Temporal similarity is evidence of possible overlap only; it does not establish event identity.', 7077 ], 7078 'signals' => [ 7079 'same_product' => isset($candidate_event['product_id']) && (int) $candidate_event['product_id'] === (int) $product_id, 7080 'same_record_type' => isset($candidate_event['record_type']) && (string) $candidate_event['record_type'] === (string) $record_type, 7081 'same_period_start' => $candidate_start !== '' && $candidate_start === $incoming_start, 7082 'same_period_end' => $candidate_end !== '' && $candidate_end === $incoming_end, 7083 'same_observed_view_count' => isset($candidate_event['observed_views']) && (int) $candidate_event['observed_views'] === (int) $views, 7084 'same_explicit_fingerprint_candidate_signal' => in_array('fingerprint', (array) ($candidate_event['_discovered_by'] ?? []), true) && $source_fingerprint !== '', 7085 'periods_intersect' => null, 7086 'same_period_string_representation' => $same_start && $same_end, 7087 'period_string_equality_is_temporal_proof' => false, 7088 ], 7089 ], 7090 'evidence_contract_version' => '1.7.0', 7091 'evidence' => $adapter_evidence, 7092 ]; 7093 } 7094 } 7095 7096 if ($adapter_evidence_omitted > 0 && !empty($adapter_overlap_evidence)) { 7097 $adapter_overlap_evidence[0]['evidence_budget_summary'] = [ 7098 'total_limit_bytes' => $adapter_evidence_total_limit, 7099 'candidate_limit_bytes' => $adapter_evidence_candidate_limit, 7100 'omitted_candidates' => $adapter_evidence_omitted, 7101 'stored_evidence_bytes' => $adapter_evidence_bytes, 7102 ]; 7103 } 7104 7105 $applied_views = $matched_event ? 0 : $views; 7106 $inserted = statpl_db_insert($this->view_source_events_table, [ 7107 'source_id' => $source_id, 7108 'source_record_key' => $record_key, 7109 'source_record_hash' => $record_hash, 7110 'product_id' => $product_id, 7111 'record_type' => $record_type, 7112 'period_start' => $period_start, 7113 'period_end' => $period_end, 7114 'observed_views' => $views, 7115 'applied_views' => $applied_views, 7116 'source_fingerprint' => $has_exact_event_fingerprint ? $source_fingerprint : null, 7117 'status' => $matched_event ? 'reconciled' : ($possible_overlap_event ? 'possible_overlap' : 'applied'), 7118 'created_at' => $now, 7119 'updated_at' => $now, 7120 ]); 7121 7122 if ($inserted === 1) { 7123 $new_event_id = (int) $wpdb->insert_id; 7124 // Build the shared audit context for both exact matches and possible 7125 // overlaps. The possible-overlap persistence path below also stores 7126 // these values, so they must never depend on the exact-match branch. 7127 $cross_source_assessment = null; 7128 $partial_overlap_profiles = null; 7129 $product_source_synthesis = null; 7130 $product_reconciliation_summary = null; 7131 if ($matched_event || $possible_overlap_event) { 7132 $audit_candidates = !empty($possible_overlap_events) 7133 ? $possible_overlap_events 7134 : ($matched_event ? [$matched_event] : [$possible_overlap_event]); 7135 $cross_source_assessment = $this->build_generic_cross_source_overlap_assessment( 7136 $product_id, 7137 $source_id, 7138 $adapter_key, 7139 $record_type, 7140 $period_start, 7141 $period_end, 7142 $audit_candidates, 7143 $possible_overlap_candidates_truncated, 7144 $views 7145 ); 7146 $partial_overlap_profiles = $this->build_generic_partial_overlap_profiles( 7147 $product_id, 7148 $source_id, 7149 $period_start, 7150 $period_end 7151 ); 7152 $product_source_synthesis = $this->build_generic_product_source_synthesis($product_id); 7153 $product_reconciliation_summary = $this->build_generic_product_reconciliation_summary( 7154 $product_id, 7155 $cross_source_assessment, 7156 $partial_overlap_profiles, 7157 $product_source_synthesis, 7158 $matched_event, 7159 [ 7160 'candidates' => !empty($possible_overlap_events) 7161 ? $possible_overlap_events 7162 : ($matched_event ? [$matched_event] : ($possible_overlap_event ? [$possible_overlap_event] : [])), 7163 'truncated' => $possible_overlap_candidates_truncated, 7164 'observed_count' => $possible_overlap_candidate_count_observed, 7165 ] 7166 ); 7167 } 7168 if ($matched_event) { 7169 $adapter_discovery_evidence_consistency = $this->build_generic_adapter_discovery_evidence_consistency( 7170 $candidate_conflict_assessment, 7171 $adapter_overlap_evidence 7172 ); 7173 7174 $evidence = wp_json_encode([ 7175 'match_type' => 'exact_shared_fingerprint', 7176 'source_id' => $source_id, 7177 'matched_source_id' => (int) $matched_event['source_id'], 7178 'product_id' => $product_id, 7179 'record_type' => $record_type, 7180 'observed_views' => $views, 7181 'period_start' => $period_start, 7182 'period_end' => $period_end, 7183 'source_fingerprint' => $source_fingerprint, 7184 'cross_source_assessment' => $cross_source_assessment, 7185 'partial_overlap_profiles' => $partial_overlap_profiles, 7186 'product_source_synthesis' => $product_source_synthesis, 7187 'product_reconciliation_summary' => $product_reconciliation_summary, 7188 'candidate_set_assessment' => $this->build_generic_candidate_set_assessment( 7189 !empty($possible_overlap_events) ? $possible_overlap_events : [$matched_event], 7190 $possible_overlap_candidates_truncated, 7191 $possible_overlap_candidate_count_observed 7192 ), 7193 ]); 7194 $reconciliation_inserted = statpl_db_insert($this->view_source_reconciliation_table, [ 7195 'event_id' => $new_event_id, 7196 'status' => 'matched_exact', 7197 'reason_code' => 'shared_fingerprint_exact_match', 7198 'reason' => 'A different source supplied the same explicit fingerprint and identical normalized event dimensions. This contribution was retained for audit but not added again.', 7199 'matched_event_id' => (int) $matched_event['id'], 7200 'evidence' => $evidence, 7201 'algorithm_version' => '1.0.0', 7202 'created_at' => $now, 7203 'updated_at' => $now, 7204 ]); 7205 $history_inserted = $reconciliation_inserted === 1 ? statpl_db_insert($this->view_source_reconciliation_history_table, [ 7206 'event_id' => $new_event_id, 7207 'previous_status' => null, 7208 'new_status' => 'matched_exact', 7209 'reason_code' => 'shared_fingerprint_exact_match', 7210 'reason' => 'Exact cross-source match using an adapter-provided shared fingerprint and identical normalized dimensions.', 7211 'matched_event_id' => (int) $matched_event['id'], 7212 'evidence' => $evidence, 7213 'algorithm_version' => '1.0.0', 7214 'created_at' => $now, 7215 ]) : false; 7216 if ($reconciliation_inserted !== 1 || $history_inserted !== 1) { 7217 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7218 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7219 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7220 } 7221 $failed++; 7222 $failure = $this->record_generic_historical_view_failure( 7223 $source_id, 7224 $origin_key, 7225 $record_key, 7226 $product_id, 7227 'Could not persist cross-source reconciliation evidence: ' . $wpdb->last_error, 7228 $batch_start_checkpoint, 7229 3 7230 , $origin_lock); 7231 if (empty($failure['persisted'])) { 7232 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7233 $checkpoint_persistence_failed = true; 7234 break; 7235 } 7236 if (!$failure['blocked']) { 7237 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7238 } 7239 continue; 7240 } 7241 } 7242 if ($possible_overlap_event) { 7243 $adapter_dimension_evidence_consistency = $this->build_generic_adapter_dimension_evidence_consistency( 7244 $candidate_conflict_assessment, 7245 $adapter_overlap_evidence 7246 ); 7247 // Validate that the primary reference, ordered candidate set and 7248 // adapter evidence describe the same candidate identities before 7249 // persisting the reconciliation record. 7250 $primary_candidate_id = (int) $possible_overlap_event['id']; 7251 $candidate_ids = array_values(array_unique(array_map( 7252 static function ($candidate) { 7253 return isset($candidate['id']) ? (int) $candidate['id'] : 0; 7254 }, 7255 !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event] 7256 ))); 7257 $candidate_ids = array_values(array_filter($candidate_ids, static function ($id) { 7258 return $id > 0; 7259 })); 7260 $candidate_list_primary_consistent = in_array($primary_candidate_id, $candidate_ids, true) 7261 && !empty($candidate_ids) 7262 && $candidate_ids[0] === $primary_candidate_id; 7263 7264 $adapter_candidate_ids = array_values(array_map( 7265 static function ($entry) { 7266 return isset($entry['candidate_event_id']) ? (int) $entry['candidate_event_id'] : 0; 7267 }, 7268 $adapter_overlap_evidence 7269 )); 7270 $adapter_evidence_primary_consistent = empty($adapter_overlap_evidence) 7271 || ( 7272 isset($adapter_overlap_evidence[0]['is_primary_candidate']) 7273 && $adapter_overlap_evidence[0]['is_primary_candidate'] === true 7274 && $adapter_candidate_ids[0] === $primary_candidate_id 7275 ); 7276 $adapter_evidence_order_consistent = true; 7277 foreach ($adapter_overlap_evidence as $index => $entry) { 7278 if ( 7279 !isset($entry['candidate_rank'], $entry['candidate_event_id']) 7280 || (int) $entry['candidate_rank'] !== $index + 1 7281 || (int) $entry['candidate_event_id'] !== (int) ($candidate_ids[$index] ?? 0) 7282 ) { 7283 $adapter_evidence_order_consistent = false; 7284 break; 7285 } 7286 } 7287 7288 $evidence = wp_json_encode([ 7289 'match_type' => $possible_overlap_reason_code, 7290 'source_id' => $source_id, 7291 'matched_source_id' => (int) $possible_overlap_event['source_id'], 7292 'matched_event_id' => (int) $possible_overlap_event['id'], 7293 'current' => [ 7294 'product_id' => $product_id, 7295 'record_type' => $record_type, 7296 'observed_views' => $views, 7297 'period_start' => $period_start, 7298 'period_end' => $period_end, 7299 ], 7300 'candidates_truncated' => $possible_overlap_candidates_truncated, 7301 'candidate_limit' => 25, 7302 'candidate_count_observed' => $possible_overlap_candidate_count_observed, 7303 'candidate_count_is_lower_bound' => $possible_overlap_candidate_count_is_lower_bound, 7304 'candidate_set_assessment' => $this->build_generic_candidate_set_assessment( 7305 !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event], 7306 $possible_overlap_candidates_truncated, 7307 $possible_overlap_candidate_count_observed 7308 ), 7309 'candidate_conflict_assessment' => $candidate_conflict_assessment = $this->build_generic_candidate_conflict_assessment( 7310 [ 7311 'product_id' => $product_id, 7312 'record_type' => $record_type, 7313 'observed_views' => $views, 7314 'period_start' => $period_start, 7315 'period_end' => $period_end, 7316 ], 7317 !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event] 7318 ), 7319 'candidate_evidence_decision' => $candidate_evidence_decision = $this->build_generic_candidate_evidence_decision( 7320 $candidate_conflict_assessment, 7321 $candidate_set_assessment = $this->build_generic_candidate_set_assessment( 7322 !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event], 7323 $possible_overlap_candidates_truncated, 7324 $possible_overlap_candidate_count_observed 7325 ), 7326 $possible_overlap_candidates_truncated 7327 ), 7328 'candidate_audit_consistency' => $this->build_generic_candidate_audit_consistency( 7329 $candidate_set_assessment, 7330 $candidate_conflict_assessment, 7331 $candidate_evidence_decision 7332 ), 7333 'candidate_reference_consistency' => $this->build_generic_candidate_reference_consistency( 7334 $candidate_set_assessment, 7335 $candidate_conflict_assessment 7336 ), 7337 'candidate_discovery_signal_consistency' => $this->build_generic_candidate_discovery_signal_consistency( 7338 $candidate_set_assessment, 7339 $candidate_conflict_assessment 7340 ), 7341 'adapter_discovery_evidence_consistency' => $adapter_discovery_evidence_consistency, 7342 'adapter_dimension_evidence_consistency' => $adapter_dimension_evidence_consistency, 7343 'candidate_consistency' => [ 7344 'primary_in_candidate_list' => $candidate_list_primary_consistent, 7345 'adapter_evidence_primary_consistent' => $adapter_evidence_primary_consistent, 7346 'adapter_evidence_order_consistent' => $adapter_evidence_order_consistent, 7347 'adapter_discovery_evidence_consistent' => !empty($adapter_discovery_evidence_consistency['consistent']), 7348 'adapter_dimension_evidence_consistent' => !empty($adapter_dimension_evidence_consistency['consistent']), 7349 'candidate_count' => count($candidate_ids), 7350 'adapter_evidence_candidate_count' => count($adapter_candidate_ids), 7351 'validation_policy' => 'audit_only_no_automatic_count_suppression', 7352 ], 7353 'candidate_reconciliation_chain' => $this->build_generic_candidate_reconciliation_chain_assessment( 7354 $candidate_set_assessment, 7355 $candidate_conflict_assessment, 7356 $candidate_evidence_decision, 7357 $this->build_generic_candidate_audit_consistency( 7358 $candidate_set_assessment, 7359 $candidate_conflict_assessment, 7360 $candidate_evidence_decision 7361 ), 7362 [ 7363 'primary_in_candidate_list' => $candidate_list_primary_consistent, 7364 'adapter_evidence_primary_consistent' => $adapter_evidence_primary_consistent, 7365 'adapter_evidence_order_consistent' => $adapter_evidence_order_consistent, 7366 'adapter_discovery_evidence_consistent' => !empty($adapter_discovery_evidence_consistency['consistent']), 7367 'adapter_dimension_evidence_consistent' => !empty($adapter_dimension_evidence_consistency['consistent']), 7368 'candidate_count' => count($candidate_ids), 7369 'adapter_evidence_candidate_count' => count($adapter_candidate_ids), 7370 ], 7371 $this->build_generic_candidate_reference_consistency( 7372 $candidate_set_assessment, 7373 $candidate_conflict_assessment 7374 ), 7375 $this->build_generic_candidate_discovery_signal_consistency( 7376 $candidate_set_assessment, 7377 $candidate_conflict_assessment 7378 ) 7379 ), 7380 'primary_candidate' => [ 7381 'event_id' => (int) $possible_overlap_event['id'], 7382 'source_id' => (int) $possible_overlap_event['source_id'], 7383 'source_key' => isset($possible_overlap_event['candidate_source_key']) ? (string) $possible_overlap_event['candidate_source_key'] : null, 7384 'adapter_key' => isset($possible_overlap_event['candidate_adapter_key']) ? (string) $possible_overlap_event['candidate_adapter_key'] : null, 7385 'discovered_by' => isset($possible_overlap_event['_discovered_by']) ? $possible_overlap_event['_discovered_by'] : [], 7386 'selection_policy' => 'highest_discovery_evidence_then_lowest_event_id', 7387 ], 7388 'fingerprint_candidates_observed' => $possible_overlap_fingerprint_candidates_observed, 7389 'temporal_candidates_observed' => $possible_overlap_temporal_candidates_observed, 7390 'candidates' => array_map(static function ($candidate) { 7391 return [ 7392 'event_id' => (int) $candidate['id'], 7393 'discovered_by' => isset($candidate['_discovered_by']) ? $candidate['_discovered_by'] : [], 7394 'source_id' => (int) $candidate['source_id'], 7395 'source_key' => isset($candidate['candidate_source_key']) ? (string) $candidate['candidate_source_key'] : null, 7396 'adapter_key' => isset($candidate['candidate_adapter_key']) ? (string) $candidate['candidate_adapter_key'] : null, 7397 'product_id' => (int) $candidate['product_id'], 7398 'record_type' => (string) $candidate['record_type'], 7399 'observed_views' => (int) $candidate['observed_views'], 7400 'applied_views' => (int) $candidate['applied_views'], 7401 'period_start' => $candidate['period_start'], 7402 'period_end' => $candidate['period_end'], 7403 ]; 7404 }, !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event]), 7405 'cross_source_assessment' => $cross_source_assessment, 7406 'partial_overlap_profiles' => $partial_overlap_profiles, 7407 'product_source_synthesis' => $product_source_synthesis, 7408 'product_reconciliation_summary' => $product_reconciliation_summary, 7409 'source_fingerprint' => $source_fingerprint !== '' ? $source_fingerprint : null, 7410 'adapter_overlap_evidence' => $adapter_overlap_evidence, 7411 'counting_policy' => 'preserve_both_pending_source_specific_evidence', 7412 ]); 7413 $reconciliation_inserted = statpl_db_insert($this->view_source_reconciliation_table, [ 7414 'event_id' => $new_event_id, 7415 'status' => 'possible_overlap', 7416 'reason_code' => $possible_overlap_reason_code, 7417 'reason' => 'A different source contains one or more potentially overlapping records. All matching candidates were retained as evidence; the contribution was preserved and counted because no automatic suppression was justified without stronger source-specific evidence.', 7418 'matched_event_id' => (int) $possible_overlap_event['id'], 7419 'evidence' => $evidence, 7420 'algorithm_version' => '1.6.0', 7421 'created_at' => $now, 7422 'updated_at' => $now, 7423 ]); 7424 $history_inserted = $reconciliation_inserted === 1 ? statpl_db_insert($this->view_source_reconciliation_history_table, [ 7425 'event_id' => $new_event_id, 7426 'previous_status' => null, 7427 'new_status' => 'possible_overlap', 7428 'reason_code' => $possible_overlap_reason_code, 7429 'reason' => 'Potential partial overlap recorded for review. Both source contributions remain counted until source-specific evidence justifies adjustment.', 7430 'matched_event_id' => (int) $possible_overlap_event['id'], 7431 'evidence' => $evidence, 7432 'algorithm_version' => '1.6.0', 7433 'created_at' => $now, 7434 ]) : false; 7435 if ($reconciliation_inserted !== 1 || $history_inserted !== 1) { 7436 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7437 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7438 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7439 } 7440 $failed++; 7441 $failure = $this->record_generic_historical_view_failure( 7442 $source_id, 7443 $origin_key, 7444 $record_key, 7445 $product_id, 7446 'Could not persist possible-overlap reconciliation evidence: ' . $wpdb->last_error, 7447 $batch_start_checkpoint, 7448 3 7449 , $origin_lock); 7450 if (empty($failure['persisted'])) { 7451 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7452 $checkpoint_persistence_failed = true; 7453 break; 7454 } 7455 if (!$failure['blocked']) { 7456 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7457 } 7458 continue; 7459 } 7460 } 7461 if ($applied_views > 0) { 7462 $total_result = statpl_db_query(statpl_db_prepare( 7463 "INSERT INTO {$this->views_table} (product_id, total_views) VALUES (%d, %d) 7464 ON DUPLICATE KEY UPDATE total_views = total_views + VALUES(total_views)", 7465 $product_id, 7466 $applied_views 7467 )); 7468 if ($total_result === false) { 7469 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7470 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7471 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7472 } 7473 $failed++; 7474 $failure = $this->record_generic_historical_view_failure( 7475 $source_id, 7476 $origin_key, 7477 $record_key, 7478 $product_id, 7479 'Could not update product view totals: ' . $wpdb->last_error, 7480 $batch_start_checkpoint, 7481 3 7482 , $origin_lock); 7483 if (empty($failure['persisted'])) { 7484 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7485 $checkpoint_persistence_failed = true; 7486 break; 7487 } 7488 if (!$failure['blocked']) { 7489 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7490 } 7491 continue; 7492 } 7493 } 7494 $origin_session_current = $this->generic_advisory_lock_session_is_current($origin_lock); 7495 $fingerprint_session_current = !is_array($reconciliation_lock_state) 7496 || empty($reconciliation_lock_state['acquired']) 7497 || $this->generic_advisory_lock_session_is_current($reconciliation_lock_state); 7498 if (!$origin_session_current || !$fingerprint_session_current) { 7499 // The transaction belongs to the origin-lock connection. A lost 7500 // advisory lock does not itself mean the DB connection changed. 7501 // Never send ROLLBACK to a transparently reconnected session. 7502 if ($transaction_open && !$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7503 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7504 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7505 } 7506 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7507 return [ 7508 'status' => 'transaction_lock_session_lost', 7509 'processed' => $processed, 7510 'duplicates' => $duplicates, 7511 'failed' => $failed, 7512 ]; 7513 } 7514 7515 if (statpl_db_query('COMMIT') === false) { 7516 $commit_error = (string) $wpdb->last_error; 7517 7518 // A failed COMMIT acknowledgement is ambiguous when WordPress has 7519 // transparently reconnected: the server may have committed the 7520 // transaction before the client lost its connection. Do not issue 7521 // ledger writes on a replacement session or classify the record 7522 // as failed. Replaying the same record is safe because the source 7523 // event's unique key and transactional totals update are idempotent. 7524 $origin_session_after_commit = $this->generic_advisory_lock_session_is_current($origin_lock); 7525 $fingerprint_session_after_commit = !is_array($reconciliation_lock_state) 7526 || empty($reconciliation_lock_state['acquired']) 7527 || $this->generic_advisory_lock_session_is_current($reconciliation_lock_state); 7528 if (!$origin_session_after_commit || !$fingerprint_session_after_commit) { 7529 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7530 return [ 7531 'status' => 'commit_outcome_unknown', 7532 'processed' => $processed, 7533 'duplicates' => $duplicates, 7534 'failed' => $failed, 7535 ]; 7536 } 7537 7538 // The original session is still alive, so the failed COMMIT can 7539 // be rolled back and recorded as a normal retryable row failure. 7540 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7541 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7542 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7543 } 7544 $failed++; 7545 $failure = $this->record_generic_historical_view_failure( 7546 $source_id, 7547 $origin_key, 7548 $record_key, 7549 $product_id, 7550 'Could not commit imported source event: ' . $commit_error, 7551 $batch_start_checkpoint, 7552 3 7553 , $origin_lock); 7554 if (empty($failure['persisted'])) { 7555 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7556 $checkpoint_persistence_failed = true; 7557 break; 7558 } 7559 if (!$failure['blocked']) { 7560 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7561 } 7562 continue; 7563 } 7564 $transaction_open = false; 7565 $processed++; 7566 } elseif ($inserted === false && stripos((string) $wpdb->last_error, 'duplicate') !== false) { 7567 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7568 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7569 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7570 } 7571 // A repeated source_record_key is idempotent only when the stored 7572 // normalized payload is identical. A changed payload under the same 7573 // key signals an adapter/source inconsistency and must not be silently 7574 // accepted as an ordinary duplicate. 7575 $existing_event = statpl_db_get_row(statpl_db_prepare( 7576 "SELECT id, product_id, record_type, observed_views, period_start, period_end, source_fingerprint, status 7577 FROM {$this->view_source_events_table} 7578 WHERE source_id = %d AND source_record_hash = %s 7579 LIMIT 1", 7580 $source_id, 7581 $record_hash 7582 ), ARRAY_A); 7583 if (!empty($wpdb->last_error) || !is_array($existing_event)) { 7584 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7585 return ['status' => 'duplicate_verification_failed', 'processed' => $processed, 'duplicates' => $duplicates]; 7586 } 7587 $existing_period_start = $existing_event['period_start'] !== null ? (string) $existing_event['period_start'] : null; 7588 $existing_period_end = $existing_event['period_end'] !== null ? (string) $existing_event['period_end'] : null; 7589 $existing_fingerprint = $existing_event['source_fingerprint'] !== null ? (string) $existing_event['source_fingerprint'] : ''; 7590 $same_existing_payload = (int) $existing_event['product_id'] === $product_id 7591 && (string) $existing_event['record_type'] === $record_type 7592 && (int) $existing_event['observed_views'] === $views 7593 && $existing_period_start === $period_start 7594 && $existing_period_end === $period_end 7595 && $existing_fingerprint === $source_fingerprint; 7596 if ($same_existing_payload) { 7597 // A retry must not rewrite reconciliation evidence or append 7598 // another history row. Verify the already committed audit record 7599 // before accepting the source event as a complete duplicate. 7600 $existing_event_id = isset($existing_event['id']) ? (int) $existing_event['id'] : 0; 7601 $existing_event_status = isset($existing_event['status']) ? (string) $existing_event['status'] : ''; 7602 if (in_array($existing_event_status, ['possible_overlap', 'reconciled'], true)) { 7603 $existing_audit = statpl_db_get_row(statpl_db_prepare( 7604 "SELECT status, reason_code, matched_event_id, evidence, algorithm_version 7605 FROM {$this->view_source_reconciliation_table} 7606 WHERE event_id = %d 7607 LIMIT 1", 7608 $existing_event_id 7609 ), ARRAY_A); 7610 if (!empty($wpdb->last_error) || !is_array($existing_audit)) { 7611 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7612 return [ 7613 'status' => 'duplicate_audit_missing_or_unreadable', 7614 'processed' => $processed, 7615 'duplicates' => $duplicates, 7616 ]; 7617 } 7618 $existing_audit_evidence = !empty($existing_audit['evidence']) 7619 ? json_decode((string) $existing_audit['evidence'], true) 7620 : null; 7621 $audit_matches_event = is_array($existing_audit_evidence) 7622 && isset($existing_audit_evidence['source_id']) 7623 && (int) $existing_audit_evidence['source_id'] === (int) $source_id; 7624 if ($existing_event_status === 'possible_overlap') { 7625 $audit_matches_event = $audit_matches_event 7626 && isset($existing_audit['matched_event_id'], $existing_audit_evidence['primary_candidate']['event_id']) 7627 && (int) $existing_audit['matched_event_id'] === (int) $existing_audit_evidence['primary_candidate']['event_id'] 7628 && (int) $existing_audit_evidence['primary_candidate']['event_id'] > 0; 7629 } elseif ($existing_event_status === 'reconciled') { 7630 $audit_matches_event = $audit_matches_event 7631 && (string) $existing_audit['status'] === 'matched_exact' 7632 && isset($existing_audit['matched_event_id']) 7633 && (int) $existing_audit['matched_event_id'] > 0; 7634 } 7635 if (!$audit_matches_event) { 7636 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7637 return [ 7638 'status' => 'duplicate_audit_integrity_mismatch', 7639 'processed' => $processed, 7640 'duplicates' => $duplicates, 7641 ]; 7642 } 7643 7644 // The current reconciliation row and its latest history 7645 // snapshot must agree. History is verified, never synthesized 7646 // or repaired during a duplicate retry. 7647 $latest_history = statpl_db_get_row(statpl_db_prepare( 7648 "SELECT new_status, reason_code, matched_event_id, evidence, algorithm_version 7649 FROM {$this->view_source_reconciliation_history_table} 7650 WHERE event_id = %d 7651 ORDER BY id DESC 7652 LIMIT 1", 7653 $existing_event_id 7654 ), ARRAY_A); 7655 if (!empty($wpdb->last_error) || !is_array($latest_history)) { 7656 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7657 return [ 7658 'status' => 'duplicate_audit_history_missing_or_unreadable', 7659 'processed' => $processed, 7660 'duplicates' => $duplicates, 7661 ]; 7662 } 7663 7664 $history_matches_audit = (string) $latest_history['new_status'] === (string) $existing_audit['status'] 7665 && (string) $latest_history['reason_code'] === (string) $existing_audit['reason_code'] 7666 && (int) ($latest_history['matched_event_id'] ?? 0) === (int) ($existing_audit['matched_event_id'] ?? 0) 7667 && (string) ($latest_history['evidence'] ?? '') === (string) ($existing_audit['evidence'] ?? '') 7668 && (string) $latest_history['algorithm_version'] === (string) $existing_audit['algorithm_version']; 7669 7670 // Validate the recorded transition chain in ascending history 7671 // order. The first row is treated as the chain anchor because 7672 // older installations may not have retained its predecessor. 7673 $history_chain = statpl_db_get_results(statpl_db_prepare( 7674 "SELECT id, previous_status, new_status 7675 FROM {$this->view_source_reconciliation_history_table} 7676 WHERE event_id = %d 7677 ORDER BY id ASC", 7678 $existing_event_id 7679 ), ARRAY_A); 7680 if (!empty($wpdb->last_error) || !is_array($history_chain) || empty($history_chain)) { 7681 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7682 return [ 7683 'status' => 'duplicate_audit_history_chain_unreadable', 7684 'processed' => $processed, 7685 'duplicates' => $duplicates, 7686 ]; 7687 } 7688 7689 $history_chain_consistent = true; 7690 $previous_new_status = null; 7691 $previous_history_id = 0; 7692 foreach ($history_chain as $history_entry) { 7693 $history_id = isset($history_entry['id']) ? (int) $history_entry['id'] : 0; 7694 $new_status = isset($history_entry['new_status']) ? (string) $history_entry['new_status'] : ''; 7695 $previous_status = isset($history_entry['previous_status']) && $history_entry['previous_status'] !== null 7696 ? (string) $history_entry['previous_status'] 7697 : null; 7698 7699 if ($history_id <= $previous_history_id || $new_status === '') { 7700 $history_chain_consistent = false; 7701 break; 7702 } 7703 if ($previous_new_status !== null && $previous_status !== $previous_new_status) { 7704 $history_chain_consistent = false; 7705 break; 7706 } 7707 $previous_new_status = $new_status; 7708 $previous_history_id = $history_id; 7709 } 7710 7711 if (!$history_chain_consistent) { 7712 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7713 return [ 7714 'status' => 'duplicate_audit_history_transition_chain_mismatch', 7715 'processed' => $processed, 7716 'duplicates' => $duplicates, 7717 ]; 7718 } 7719 7720 if (!$history_matches_audit) { 7721 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7722 return [ 7723 'status' => 'duplicate_audit_history_integrity_mismatch', 7724 'processed' => $processed, 7725 'duplicates' => $duplicates, 7726 ]; 7727 } 7728 } 7729 $duplicates++; 7730 } else { 7731 $failed++; 7732 $failure = $this->record_generic_historical_view_failure( 7733 $source_id, 7734 $origin_key, 7735 $record_key, 7736 $product_id, 7737 'The adapter returned different normalized data for an existing source record key. The original imported event was preserved.', 7738 $batch_start_checkpoint, 7739 3 7740 , $origin_lock); 7741 if (empty($failure['persisted'])) { 7742 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7743 $checkpoint_persistence_failed = true; 7744 break; 7745 } 7746 if (!$failure['blocked']) { 7747 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7748 } 7749 continue; 7750 } 7751 } else { 7752 $error = (string) $wpdb->last_error; 7753 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7754 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7755 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7756 } 7757 $failed++; 7758 $failure = $this->record_generic_historical_view_failure( 7759 $source_id, 7760 $origin_key, 7761 $record_key, 7762 $product_id, 7763 'Could not persist source event: ' . $error, 7764 $batch_start_checkpoint, 7765 3 7766 , $origin_lock); 7767 if (empty($failure['persisted'])) { 7768 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7769 $checkpoint_persistence_failed = true; 7770 break; 7771 } 7772 if (!$failure['blocked']) { 7773 $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key); 7774 } 7775 continue; 7776 } 7777 } finally { 7778 if ($transaction_open) { 7779 // A changed or unverifiable session means the original transaction 7780 // outcome cannot be inspected here. Do not issue transaction control 7781 // on a replacement connection; ensure the record is retried instead. 7782 if (!$this->generic_db_session_matches_lock($origin_lock)) { 7783 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7784 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7785 } 7786 if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) { 7787 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7788 return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed]; 7789 } 7790 } 7791 if (is_array($reconciliation_lock_state) && !empty($reconciliation_lock_state['acquired'])) { 7792 $this->release_generic_advisory_lock($reconciliation_lock_state, 'cross_source_fingerprint'); 7793 } 7794 } 7795 7796 $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, $record_key, $origin_lock); 7797 if ($failure_cleared && is_array($record) && !empty($record['source_record_key']) && (string) $record['source_record_key'] !== $record_key) { 7798 $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, $record['source_record_key'], $origin_lock, null); 7799 } elseif ($failure_cleared && (!is_array($record) || empty($record['source_record_key']))) { 7800 $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, wp_json_encode($record), $origin_lock, null); 7801 } 7802 if (!$failure_cleared) { 7803 // The event may already be committed. Idempotency makes replay 7804 // safe, but the cursor must not move beyond this record until 7805 // its stale failure-ledger entries have been cleared. 7806 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7807 $checkpoint_persistence_failed = true; 7808 break; 7809 } 7810 $next_checkpoint['cursor'] = $cursor; 7811 $next_checkpoint['processed'] = isset($next_checkpoint['processed']) ? (int) $next_checkpoint['processed'] + 1 : 1; 7812 unset($normalization_error); 7813 } 7814 7815 if ($checkpoint_persistence_failed) { 7816 // Do not persist the adapter's batch-level next_checkpoint when a 7817 // row could not be durably represented in the failure ledger. The 7818 // next main-import attempt must revisit this batch and this row. 7819 return [ 7820 'status' => 'failure_ledger_persist_failed', 7821 'processed' => $processed, 7822 'duplicates' => $duplicates, 7823 'failed' => $failed, 7824 'skipped' => $skipped, 7825 ]; 7826 } 7827 7828 // A connection can be transparently re-established after a mid-batch DB outage. 7829 // Never persist checkpoint/status progress unless this request still owns the 7830 // original origin lock session that serialized the batch. 7831 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 7832 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7833 return [ 7834 'status' => 'checkpoint_blocked_origin_lock_session_lost', 7835 'processed' => $processed, 7836 'duplicates' => $duplicates, 7837 'failed' => $failed, 7838 'skipped' => $skipped, 7839 ]; 7840 } 7841 7842 // Individual bad records have been durably placed in the failure ledger. 7843 // Continue advancing the main batch checkpoint; a separate replay worker 7844 // revisits the saved batch checkpoint without blocking later records. 7845 if ($retry_checkpoint_override !== null) { 7846 return [ 7847 'status' => $failed ? 'retry_replay_partial' : 'retry_replay_completed', 7848 'processed' => $processed, 7849 'duplicates' => $duplicates, 7850 'failed' => $failed, 7851 ]; 7852 } 7853 7854 if (isset($batch['next_checkpoint'])) { 7855 $next_checkpoint['cursor'] = $batch['next_checkpoint']; 7856 } 7857 $has_more = !empty($batch['has_more']); 7858 $failure_counts = statpl_db_get_row(statpl_db_prepare( 7859 "SELECT SUM(CASE WHEN status = 'retry' THEN 1 ELSE 0 END) AS pending, 7860 SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked 7861 FROM {$this->view_source_failures_table} WHERE source_id = %d AND origin_key = %s", 7862 $source_id, 7863 $origin_key 7864 ), ARRAY_A); 7865 if (!empty($wpdb->last_error) || !is_array($failure_counts)) { 7866 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7867 return ['status' => 'failure_ledger_read_failed', 'processed' => $processed, 'duplicates' => $duplicates]; 7868 } 7869 $pending_failures = (int) $failure_counts['pending']; 7870 $blocked_failures = (int) $failure_counts['blocked']; 7871 $origin_status = $has_more ? 'importing' : (($pending_failures > 0 || $blocked_failures > 0) ? ($blocked_failures > 0 ? 'needs_attention' : 'retry_wait') : 'completed'); 7872 if (!$this->generic_advisory_lock_session_is_current($origin_lock)) { 7873 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7874 return ['status' => 'checkpoint_blocked_origin_lock_session_lost', 'processed' => $processed, 'duplicates' => $duplicates]; 7875 } 7876 7877 $updates = [ 7878 'checkpoint_data' => $next_checkpoint, 7879 'last_error' => ($pending_failures > 0 || $blocked_failures > 0) ? 'One or more historical records remain unresolved; the main import continues independently.' : null, 7880 'status' => $origin_status, 7881 ]; 7882 if (!$has_more && $pending_failures === 0 && $blocked_failures === 0) { 7883 $updates['completed_at'] = current_time('mysql'); 7884 } else { 7885 $updates['completed_at'] = null; 7886 } 7887 $saved = $this->update_view_source_origin_state($source_id, $origin_key, $updates, $origin_lock); 7888 if (empty($saved['status']) || $saved['status'] !== 'updated') { 7889 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7890 return ['status' => 'checkpoint_write_failed', 'processed' => $processed, 'duplicates' => $duplicates]; 7891 } 7892 7893 // A successful statpl_db_query() confirms that MySQL accepted the UPDATE, 7894 // but a worker must not report durable progress until the exact state is 7895 // readable again. This also handles ambiguous acknowledgements and 7896 // storage-layer anomalies without advancing the scheduler to the next 7897 // batch on an unverified checkpoint. 7898 $persisted_state = $this->get_view_source_origin_state($source_id, $origin_key); 7899 $checkpoint_is_persisted = isset($persisted_state['status']) 7900 && $persisted_state['status'] === 'found' 7901 && isset($persisted_state['checkpoint']) 7902 && wp_json_encode($persisted_state['checkpoint']) === wp_json_encode($next_checkpoint) 7903 && isset($persisted_state['origin_status']) 7904 && $persisted_state['origin_status'] === $origin_status; 7905 $completion_is_persisted = array_key_exists('completed_at', $updates) 7906 && ((array_key_exists('completed_at', $persisted_state) && $updates['completed_at'] === null && $persisted_state['completed_at'] === null) 7907 || (array_key_exists('completed_at', $persisted_state) && $updates['completed_at'] !== null && $persisted_state['completed_at'] === $updates['completed_at'])); 7908 if (!$checkpoint_is_persisted || !$completion_is_persisted || !$this->generic_advisory_lock_session_is_current($origin_lock)) { 7909 $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key); 7910 return [ 7911 'status' => 'checkpoint_write_unverified', 7912 'processed' => $processed, 7913 'duplicates' => $duplicates, 7914 ]; 7915 } 7916 7917 if ($has_more) { 7918 $this->schedule_generic_historical_view_import_batch(5, $source_id, $origin_key); 7919 } 7920 if ($pending_failures > 0) { 7921 $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key); 7922 } 7923 return [ 7924 'status' => $has_more ? 'batch_completed' : ($pending_failures > 0 ? 'completed_with_pending_retries' : ($blocked_failures > 0 ? 'completed_with_unresolved_records' : 'completed')), 7925 'processed' => $processed, 7926 'duplicates' => $duplicates, 7927 'checkpoint' => $next_checkpoint, 7928 ]; 7929 } 7930 7931 private function register_view_source($source_key, $display_name, $adapter_version, $granularity, $status = 'detected', $adapter_key = null) { 7932 global $wpdb; 7933 7934 $source_key = (string) $source_key; 7935 $display_name = sanitize_text_field($display_name); 7936 $adapter_version = sanitize_text_field($adapter_version); 7937 $granularity = (string) $granularity; 7938 $status = (string) $status; 7939 $adapter_key = $adapter_key === null ? null : sanitize_key((string) $adapter_key); 7940 7941 if ( 7942 ($adapter_key !== null && ($adapter_key === '' || strlen($adapter_key) > 100)) 7943 || 7944 !preg_match('/^[a-z0-9_-]{1,100}$/D', $source_key) 7945 || $display_name === '' 7946 || $adapter_version === '' 7947 || !preg_match('/^[a-z0-9_-]{1,20}$/D', $granularity) 7948 || !preg_match('/^[a-z0-9_-]{1,20}$/D', $status) 7949 || strlen($display_name) > 191 7950 || strlen($adapter_version) > 32 7951 ) { 7952 return [ 7953 'status' => 'invalid_input', 7954 'source_id' => 0, 7955 ]; 7956 } 7957 7958 $now = current_time('mysql'); 7959 7960 $result = statpl_db_query(statpl_db_prepare( 7961 "INSERT INTO {$this->view_sources_table} 7962 (source_key, adapter_key, display_name, adapter_version, granularity, status, created_at, updated_at) 7963 VALUES (%s, %s, %s, %s, %s, %s, %s, %s) 7964 ON DUPLICATE KEY UPDATE 7965 display_name = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(display_name), display_name), 7966 adapter_version = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(adapter_version), adapter_version), 7967 granularity = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(granularity), granularity), 7968 updated_at = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(updated_at), updated_at)", 7969 $source_key, 7970 $adapter_key, 7971 $display_name, 7972 $adapter_version, 7973 $granularity, 7974 $status, 7975 $now, 7976 $now 7977 )); 7978 7979 if ($result === false || $wpdb->last_error !== '') { 7980 return [ 7981 'status' => 'database_error', 7982 'source_id' => 0, 7983 'error' => $wpdb->last_error, 7984 ]; 7985 } 7986 7987 $source_id = statpl_db_get_var( 7988 statpl_db_prepare( 7989 "SELECT id FROM {$this->view_sources_table} WHERE source_key = %s", 7990 $source_key 7991 ) 7992 ); 7993 7994 if (!empty($wpdb->last_error) || !$source_id) { 7995 return [ 7996 'status' => 'database_error', 7997 'source_id' => 0, 7998 'error' => $wpdb->last_error !== '' 7999 ? $wpdb->last_error 8000 : 'Registered source ID could not be retrieved.', 8001 ]; 8002 } 8003 8004 if ($adapter_key !== null) { 8005 $ownership_result = statpl_db_query( 8006 statpl_db_prepare( 8007 "UPDATE {$this->view_sources_table} 8008 SET adapter_key = %s 8009 WHERE id = %d AND (adapter_key IS NULL OR adapter_key = %s)", 8010 $adapter_key, 8011 (int) $source_id, 8012 $adapter_key 8013 ) 8014 ); 8015 if ($ownership_result === false || $wpdb->last_error !== '') { 8016 return [ 8017 'status' => 'database_error', 8018 'source_id' => 0, 8019 'error' => $wpdb->last_error, 8020 ]; 8021 } 8022 8023 $stored_adapter_key = statpl_db_get_var(statpl_db_prepare( 8024 "SELECT adapter_key FROM {$this->view_sources_table} WHERE id = %d", 8025 (int) $source_id 8026 )); 8027 if ($wpdb->last_error !== '') { 8028 return [ 8029 'status' => 'database_error', 8030 'source_id' => 0, 8031 'error' => $wpdb->last_error, 8032 ]; 8033 } 8034 if ((string) $stored_adapter_key !== $adapter_key) { 8035 return [ 8036 'status' => 'adapter_mismatch', 8037 'source_id' => 0, 8038 ]; 8039 } 8040 } 8041 8042 return [ 8043 'status' => 'registered', 8044 'source_id' => (int) $source_id, 8045 ]; 8046 } 8047 8048 private function register_view_source_origin($source_id, $origin_key, $adapter_key, $capabilities = [], $detection_status = 'unknown') { 8049 global $wpdb; 8050 8051 $source_id = absint($source_id); 8052 $origin_key = (string) $origin_key; 8053 $adapter_key = (string) $adapter_key; 8054 $detection_status = (string) $detection_status; 8055 8056 if ( 8057 !$source_id 8058 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 8059 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $adapter_key) 8060 || !in_array($detection_status, ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true) 8061 || !is_array($capabilities) 8062 ) { 8063 return [ 8064 'status' => 'invalid_input', 8065 'source_id' => $source_id, 8066 'origin_key' => $origin_key, 8067 ]; 8068 } 8069 8070 $source_exists = statpl_db_get_var( 8071 statpl_db_prepare( 8072 "SELECT id FROM {$this->view_sources_table} WHERE id = %d", 8073 $source_id 8074 ) 8075 ); 8076 8077 if (!empty($wpdb->last_error)) { 8078 return [ 8079 'status' => 'database_error', 8080 'source_id' => $source_id, 8081 'origin_key' => $origin_key, 8082 'error' => $wpdb->last_error, 8083 ]; 8084 } 8085 8086 if (!$source_exists) { 8087 return [ 8088 'status' => 'source_not_found', 8089 'source_id' => $source_id, 8090 'origin_key' => $origin_key, 8091 ]; 8092 } 8093 8094 // An adapter identity is part of the immutable meaning of an origin's 8095 // snapshot and checkpoint. Never silently replace it after initialization. 8096 $existing_origin = statpl_db_get_row( 8097 statpl_db_prepare( 8098 "SELECT adapter_key, snapshot_data, checkpoint_data, status 8099 FROM {$this->view_source_origins_table} 8100 WHERE source_id = %d AND origin_key = %s", 8101 $source_id, 8102 $origin_key 8103 ), 8104 ARRAY_A 8105 ); 8106 8107 if (!empty($wpdb->last_error)) { 8108 return [ 8109 'status' => 'database_error', 8110 'source_id' => $source_id, 8111 'origin_key' => $origin_key, 8112 'error' => $wpdb->last_error, 8113 ]; 8114 } 8115 8116 $existing_source_events = 0; 8117 $existing_origin_failures = 0; 8118 if (is_array($existing_origin) && isset($existing_origin['adapter_key']) && (string) $existing_origin['adapter_key'] !== $adapter_key) { 8119 $existing_source_events = (int) statpl_db_get_var(statpl_db_prepare( 8120 "SELECT COUNT(*) FROM {$this->view_source_events_table} WHERE source_id = %d", 8121 $source_id 8122 )); 8123 if (!empty($wpdb->last_error)) { 8124 return [ 8125 'status' => 'database_error', 8126 'source_id' => $source_id, 8127 'origin_key' => $origin_key, 8128 'error' => $wpdb->last_error, 8129 ]; 8130 } 8131 $existing_origin_failures = (int) statpl_db_get_var(statpl_db_prepare( 8132 "SELECT COUNT(*) FROM {$this->view_source_failures_table} WHERE source_id = %d AND origin_key = %s", 8133 $source_id, 8134 $origin_key 8135 )); 8136 if (!empty($wpdb->last_error)) { 8137 return [ 8138 'status' => 'database_error', 8139 'source_id' => $source_id, 8140 'origin_key' => $origin_key, 8141 'error' => $wpdb->last_error, 8142 ]; 8143 } 8144 } 8145 8146 if ( 8147 is_array($existing_origin) 8148 && isset($existing_origin['adapter_key']) 8149 && (string) $existing_origin['adapter_key'] !== $adapter_key 8150 && ( 8151 !empty($existing_origin['snapshot_data']) 8152 || !empty($existing_origin['checkpoint_data']) 8153 || !in_array($existing_origin['status'], ['pending', 'unavailable'], true) 8154 || $existing_source_events > 0 8155 || $existing_origin_failures > 0 8156 ) 8157 ) { 8158 $message = sprintf( 8159 'Historical adapter identity changed from "%1$s" to "%2$s" after this origin was initialized. Existing historical data and checkpoints were preserved; the replacement adapter was not allowed to reinterpret them.', 8160 sanitize_key((string) $existing_origin['adapter_key']), 8161 sanitize_key($adapter_key) 8162 ); 8163 // A replacement adapter cannot safely inherit the old adapter's 8164 // snapshot or checkpoint. Keep the origin automatically unavailable 8165 // (rather than requiring administrator intervention), while normal 8166 // discovery continues to look for the original adapter. If that 8167 // original identity returns, the ordinary registration path can resume 8168 // from the preserved checkpoint without reinterpreting historical data. 8169 statpl_db_update( 8170 $this->view_source_origins_table, 8171 [ 8172 'status' => 'unavailable', 8173 'last_error' => $message, 8174 'updated_at' => current_time('mysql'), 8175 ], 8176 [ 8177 'source_id' => $source_id, 8178 'origin_key' => $origin_key, 8179 ], 8180 ['%s', '%s', '%s'], 8181 ['%d', '%s'] 8182 ); 8183 if (!empty($wpdb->last_error)) { 8184 return [ 8185 'status' => 'database_error', 8186 'source_id' => $source_id, 8187 'origin_key' => $origin_key, 8188 'error' => $wpdb->last_error, 8189 ]; 8190 } 8191 return [ 8192 'status' => 'adapter_mismatch', 8193 'source_id' => $source_id, 8194 'origin_key' => $origin_key, 8195 ]; 8196 } 8197 8198 $capabilities_json = wp_json_encode($capabilities); 8199 8200 if (!is_string($capabilities_json)) { 8201 return [ 8202 'status' => 'invalid_capabilities', 8203 'source_id' => $source_id, 8204 'origin_key' => $origin_key, 8205 ]; 8206 } 8207 8208 $now = current_time('mysql'); 8209 8210 $result = statpl_db_query( 8211 statpl_db_prepare( 8212 "INSERT INTO {$this->view_source_origins_table} 8213 (source_id, origin_key, adapter_key, capabilities, detection_status, status, updated_at) 8214 VALUES (%d, %s, %s, %s, %s, %s, %s) 8215 ON DUPLICATE KEY UPDATE 8216 adapter_key = VALUES(adapter_key), 8217 capabilities = VALUES(capabilities), 8218 detection_status = %s, 8219 status = CASE 8220 WHEN VALUES(detection_status) <> 'available' AND status NOT IN ('completed', 'needs_attention') THEN 'unavailable' 8221 WHEN VALUES(detection_status) = 'available' AND status = 'unavailable' THEN 'pending' 8222 ELSE status 8223 END, 8224 updated_at = %s", 8225 $source_id, 8226 $origin_key, 8227 $adapter_key, 8228 $capabilities_json, 8229 $detection_status, 8230 $detection_status === 'available' ? 'pending' : 'unavailable', 8231 $now, 8232 $detection_status, 8233 $now 8234 ) 8235 ); 8236 8237 if ($result === false || $wpdb->last_error !== '') { 8238 return [ 8239 'status' => 'database_error', 8240 'source_id' => $source_id, 8241 'origin_key' => $origin_key, 8242 'error' => $wpdb->last_error, 8243 ]; 8244 } 8245 8246 return [ 8247 'status' => 'registered', 8248 'source_id' => $source_id, 8249 'origin_key' => $origin_key, 8250 ]; 8251 } 8252 8253 private function get_view_source_origin_state($source_id, $origin_key) { 8254 global $wpdb; 8255 8256 $source_id = absint($source_id); 8257 $origin_key = is_string($origin_key) ? trim($origin_key) : ''; 8258 8259 if ( 8260 !$source_id 8261 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 8262 ) { 8263 return [ 8264 'status' => 'invalid_input', 8265 ]; 8266 } 8267 8268 $row = statpl_db_get_row( 8269 statpl_db_prepare( 8270 "SELECT 8271 source_id, 8272 origin_key, 8273 adapter_key, 8274 capabilities, 8275 snapshot_data, 8276 checkpoint_data, 8277 detection_status, 8278 status, 8279 last_error, 8280 started_at, 8281 completed_at, 8282 updated_at 8283 FROM {$this->view_source_origins_table} 8284 WHERE source_id = %d 8285 AND origin_key = %s", 8286 $source_id, 8287 $origin_key 8288 ), 8289 ARRAY_A 8290 ); 8291 8292 if (!empty($wpdb->last_error)) { 8293 return [ 8294 'status' => 'database_error', 8295 'error' => $wpdb->last_error, 8296 ]; 8297 } 8298 8299 if (!$row) { 8300 return [ 8301 'status' => 'not_found', 8302 'source_id' => $source_id, 8303 'origin_key' => $origin_key, 8304 ]; 8305 } 8306 8307 $capabilities = null; 8308 if ($row['capabilities'] !== null && $row['capabilities'] !== '') { 8309 $capabilities = json_decode($row['capabilities'], true); 8310 if (json_last_error() !== JSON_ERROR_NONE) { 8311 return [ 8312 'status' => 'invalid_stored_data', 8313 'source_id' => $source_id, 8314 'origin_key' => $origin_key, 8315 'field' => 'capabilities', 8316 ]; 8317 } 8318 } 8319 8320 $snapshot = null; 8321 if ($row['snapshot_data'] !== null && $row['snapshot_data'] !== '') { 8322 $snapshot = json_decode($row['snapshot_data'], true); 8323 if (json_last_error() !== JSON_ERROR_NONE) { 8324 return [ 8325 'status' => 'invalid_stored_data', 8326 'source_id' => $source_id, 8327 'origin_key' => $origin_key, 8328 'field' => 'snapshot_data', 8329 ]; 8330 } 8331 } 8332 8333 $checkpoint = null; 8334 if ($row['checkpoint_data'] !== null && $row['checkpoint_data'] !== '') { 8335 $checkpoint = json_decode($row['checkpoint_data'], true); 8336 if (json_last_error() !== JSON_ERROR_NONE) { 8337 return [ 8338 'status' => 'invalid_stored_data', 8339 'source_id' => $source_id, 8340 'origin_key' => $origin_key, 8341 'field' => 'checkpoint_data', 8342 ]; 8343 } 8344 } 8345 8346 return [ 8347 'status' => 'found', 8348 'source_id' => (int) $row['source_id'], 8349 'origin_key' => $row['origin_key'], 8350 'adapter_key' => $row['adapter_key'], 8351 'capabilities' => $capabilities, 8352 'snapshot' => $snapshot, 8353 'checkpoint' => $checkpoint, 8354 'detection_status' => $row['detection_status'], 8355 'origin_status' => $row['status'], 8356 'last_error' => $row['last_error'], 8357 'started_at' => $row['started_at'], 8358 'completed_at' => $row['completed_at'], 8359 'updated_at' => $row['updated_at'], 8360 ]; 8361 } 8362 8363 private function update_view_source_origin_state($source_id, $origin_key, $updates, $lock_context = null) { 8364 global $wpdb; 8365 8366 $source_id = absint($source_id); 8367 $origin_key = is_string($origin_key) ? trim($origin_key) : ''; 8368 8369 if ( 8370 !$source_id 8371 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 8372 || !is_array($updates) 8373 || empty($updates) 8374 ) { 8375 return [ 8376 'status' => 'invalid_input', 8377 ]; 8378 } 8379 8380 $allowed_fields = [ 8381 'snapshot_data', 8382 'checkpoint_data', 8383 'status', 8384 'last_error', 8385 'started_at', 8386 'completed_at', 8387 ]; 8388 8389 $columns = []; 8390 $values = []; 8391 8392 foreach ($updates as $field => $value) { 8393 if (!in_array($field, $allowed_fields, true)) { 8394 return [ 8395 'status' => 'invalid_field', 8396 'field' => $field, 8397 ]; 8398 } 8399 8400 if (in_array($field, ['snapshot_data', 'checkpoint_data'], true)) { 8401 if ($value !== null && !is_array($value)) { 8402 return [ 8403 'status' => 'invalid_input', 8404 'field' => $field, 8405 ]; 8406 } 8407 8408 $encoded = $value === null ? null : wp_json_encode($value); 8409 8410 if ($value !== null && !is_string($encoded)) { 8411 return [ 8412 'status' => 'encoding_error', 8413 'field' => $field, 8414 ]; 8415 } 8416 8417 $columns[] = "`{$field}` = %s"; 8418 $values[] = $encoded; 8419 continue; 8420 } 8421 8422 if ($field === 'status') { 8423 if ( 8424 !is_string($value) 8425 || !preg_match('/^[a-z0-9_-]{1,20}$/D', $value) 8426 ) { 8427 return [ 8428 'status' => 'invalid_input', 8429 'field' => $field, 8430 ]; 8431 } 8432 8433 $columns[] = "`{$field}` = %s"; 8434 $values[] = $value; 8435 continue; 8436 } 8437 8438 if ($field === 'last_error') { 8439 if ($value !== null && !is_string($value)) { 8440 return [ 8441 'status' => 'invalid_input', 8442 'field' => $field, 8443 ]; 8444 } 8445 8446 $columns[] = "`{$field}` = %s"; 8447 $values[] = $value; 8448 continue; 8449 } 8450 8451 if ($value !== null && ( 8452 !is_string($value) 8453 || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value) 8454 )) { 8455 return [ 8456 'status' => 'invalid_input', 8457 'field' => $field, 8458 ]; 8459 } 8460 8461 $columns[] = "`{$field}` = %s"; 8462 $values[] = $value; 8463 } 8464 8465 $columns[] = '`updated_at` = %s'; 8466 $values[] = current_time('mysql'); 8467 8468 $values[] = $source_id; 8469 $values[] = $origin_key; 8470 8471 $lock_name = ''; 8472 if ($lock_context !== null) { 8473 if (!$this->generic_advisory_lock_session_is_current($lock_context) || empty($lock_context['lock_name'])) { 8474 return ['status' => 'lock_session_lost']; 8475 } 8476 $lock_name = (string) $lock_context['lock_name']; 8477 } 8478 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The optional lock predicate and its matching lock-name argument are both added only when lock_context is present. 8479 $lock_predicate = $lock_context !== null ? ' AND IS_USED_LOCK(%s) = CONNECTION_ID()' : ''; 8480 if ($lock_context !== null) { 8481 $values[] = $lock_name; 8482 } 8483 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SET placeholders and values are appended together from the validated origin-state field allowlist; source_id/origin_key and the optional lock placeholder are appended to the values in matching order. 8484 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 8485 $result = statpl_db_query(/* phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder fragments and their values are assembled together from the same fixed allowlists; optional lock predicates and matching values are added together. */ statpl_db_prepare( 8486 "UPDATE {$this->view_source_origins_table} 8487 SET " . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $columns) . " 8488 WHERE source_id = %d AND origin_key = %s" . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $lock_predicate, 8489 ...$values 8490 )); 8491 8492 if ($result === false || $wpdb->last_error !== '') { 8493 return [ 8494 'status' => 'database_error', 8495 'error' => $wpdb->last_error, 8496 ]; 8497 } 8498 8499 if ($result === 0) { 8500 if ($lock_context !== null && !$this->generic_advisory_lock_session_is_current($lock_context)) { 8501 return ['status' => 'lock_session_lost']; 8502 } 8503 $exists = statpl_db_get_var( 8504 statpl_db_prepare( 8505 "SELECT 1 FROM {$this->view_source_origins_table} 8506 WHERE source_id = %d AND origin_key = %s", 8507 $source_id, 8508 $origin_key 8509 ) 8510 ); 8511 8512 if (!empty($wpdb->last_error)) { 8513 return [ 8514 'status' => 'database_error', 8515 'error' => $wpdb->last_error, 8516 ]; 8517 } 8518 8519 // The existence read can itself trigger a transparent wpdb reconnect. 8520 // Revalidate the original lock session after that read before treating 8521 // a zero-row UPDATE as a successful no-op. Otherwise a replacement 8522 // connection could make an unperformed state transition look complete. 8523 if ($lock_context !== null && !$this->generic_advisory_lock_session_is_current($lock_context)) { 8524 return ['status' => 'lock_session_lost']; 8525 } 8526 8527 if (!$exists) { 8528 return [ 8529 'status' => 'not_found', 8530 'source_id' => $source_id, 8531 'origin_key' => $origin_key, 8532 ]; 8533 } 8534 } 8535 8536 return [ 8537 'status' => 'updated', 8538 'source_id' => $source_id, 8539 'origin_key' => $origin_key, 8540 ]; 8541 } 8542 8543 private function save_view_source_origin_snapshot_if_empty($source_id, $origin_key, $snapshot) { 8544 global $wpdb; 8545 8546 $source_id = absint($source_id); 8547 $origin_key = is_string($origin_key) ? trim($origin_key) : ''; 8548 8549 if ( 8550 !$source_id 8551 || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key) 8552 || !is_array($snapshot) 8553 || empty($snapshot) 8554 ) { 8555 return [ 8556 'status' => 'invalid_input', 8557 ]; 8558 } 8559 8560 $encoded = wp_json_encode($snapshot); 8561 8562 if (!is_string($encoded)) { 8563 return [ 8564 'status' => 'encoding_error', 8565 ]; 8566 } 8567 8568 $result = statpl_db_query( 8569 statpl_db_prepare( 8570 "UPDATE {$this->view_source_origins_table} 8571 SET snapshot_data = %s, updated_at = %s 8572 WHERE source_id = %d 8573 AND origin_key = %s 8574 AND snapshot_data IS NULL", 8575 $encoded, 8576 current_time('mysql'), 8577 $source_id, 8578 $origin_key 8579 ) 8580 ); 8581 8582 if ($result === false || $wpdb->last_error !== '') { 8583 return [ 8584 'status' => 'database_error', 8585 'error' => $wpdb->last_error, 8586 ]; 8587 } 8588 8589 if ($result === 1) { 8590 return [ 8591 'status' => 'updated', 8592 'source_id' => $source_id, 8593 'origin_key' => $origin_key, 8594 ]; 8595 } 8596 8597 $existing = $this->get_view_source_origin_state($source_id, $origin_key); 8598 8599 if ($existing['status'] !== 'found') { 8600 return $existing; 8601 } 8602 8603 if ($existing['snapshot'] !== null) { 8604 return [ 8605 'status' => 'already_initialized', 8606 'source_id' => $source_id, 8607 'origin_key' => $origin_key, 8608 'snapshot' => $existing['snapshot'], 8609 ]; 8610 } 8611 8612 return [ 8613 'status' => 'database_error', 8614 'source_id' => $source_id, 8615 'origin_key' => $origin_key, 8616 ]; 8617 } 8618 8619 private function initialize_view_source_origin_snapshot($source_id, $origin_key) { 8620 global $wpdb; 8621 8622 $state = $this->get_view_source_origin_state($source_id, $origin_key); 8623 8624 if ($state['status'] !== 'found') { 8625 return $state; 8626 } 8627 8628 if (!empty($state['snapshot'])) { 8629 return [ 8630 'status' => 'already_initialized', 8631 'source_id' => (int) $source_id, 8632 'origin_key' => $origin_key, 8633 'snapshot' => $state['snapshot'], 8634 ]; 8635 } 8636 8637 if ($state['detection_status'] !== 'available') { 8638 return [ 8639 'status' => 'origin_unavailable', 8640 'source_id' => (int) $source_id, 8641 'origin_key' => $origin_key, 8642 'detection_status' => $state['detection_status'], 8643 ]; 8644 } 8645 8646 $origin_tables = [ 8647 'slim_stats' => $wpdb->prefix . 'slim_stats', 8648 'slim_stats_archive' => $wpdb->prefix . 'slim_stats_archive', 8649 ]; 8650 8651 if (!isset($origin_tables[$origin_key])) { 8652 return [ 8653 'status' => 'unsupported_origin', 8654 'source_id' => (int) $source_id, 8655 'origin_key' => $origin_key, 8656 ]; 8657 } 8658 8659 $table = $origin_tables[$origin_key]; 8660 8661 $max_id = statpl_db_get_var( 8662 "SELECT MAX(id) FROM `{$table}`" 8663 ); 8664 8665 if (!empty($wpdb->last_error)) { 8666 return [ 8667 'status' => 'database_error', 8668 'source_id' => (int) $source_id, 8669 'origin_key' => $origin_key, 8670 'error' => $wpdb->last_error, 8671 ]; 8672 } 8673 8674 $snapshot = [ 8675 'max_id' => $max_id === null ? 0 : (int) $max_id, 8676 'captured_at' => current_time('mysql'), 8677 ]; 8678 8679 $saved = $this->save_view_source_origin_snapshot_if_empty( 8680 $source_id, 8681 $origin_key, 8682 $snapshot 8683 ); 8684 8685 if ($saved['status'] === 'already_initialized') { 8686 return $saved; 8687 } 8688 8689 if ($saved['status'] !== 'updated') { 8690 return $saved; 8691 } 8692 8693 return [ 8694 'status' => 'initialized', 8695 'source_id' => (int) $source_id, 8696 'origin_key' => $origin_key, 8697 'snapshot' => $snapshot, 8698 ]; 8699 } 8700 8701 private function save_view_source_origin_snapshot($source_id, $origin_key, $snapshot) { 8702 if (!is_array($snapshot) || empty($snapshot)) { 8703 return [ 8704 'status' => 'invalid_snapshot', 8705 ]; 8706 } 8707 8708 return $this->update_view_source_origin_state( 8709 $source_id, 8710 $origin_key, 8711 [ 8712 'snapshot_data' => $snapshot, 8713 ] 8714 ); 8715 } 8716 8717 private function save_view_source_origin_checkpoint($source_id, $origin_key, $checkpoint) { 8718 if (!is_array($checkpoint) || empty($checkpoint)) { 8719 return [ 8720 'status' => 'invalid_checkpoint', 8721 ]; 8722 } 8723 8724 return $this->update_view_source_origin_state( 8725 $source_id, 8726 $origin_key, 8727 [ 8728 'checkpoint_data' => $checkpoint, 8729 ] 8730 ); 8731 } 8732 8733 private function detect_slimstat_view_origins() { 8734 global $wpdb; 8735 8736 $origins = [ 8737 'slim_stats' => $wpdb->prefix . 'slim_stats', 8738 'slim_stats_archive' => $wpdb->prefix . 'slim_stats_archive', 8739 ]; 8740 8741 $required_columns = [ 8742 'id', 8743 'content_type', 8744 'content_id', 8745 'dt', 8746 ]; 8747 8748 $results = []; 8749 8750 foreach ($origins as $origin_key => $table) { 8751 $exists = statpl_db_get_var( 8752 statpl_db_prepare( 8753 'SHOW TABLES LIKE %s', 8754 $wpdb->esc_like($table) 8755 ) 8756 ); 8757 8758 if (!empty($wpdb->last_error)) { 8759 $results[$origin_key] = [ 8760 'status' => 'database_error', 8761 'error' => $wpdb->last_error, 8762 ]; 8763 continue; 8764 } 8765 8766 if ($exists !== $table) { 8767 $results[$origin_key] = [ 8768 'status' => 'missing_table', 8769 ]; 8770 continue; 8771 } 8772 8773 $columns = statpl_db_get_col( 8774 "SHOW COLUMNS FROM `{$table}`", 8775 0 8776 ); 8777 8778 if (!empty($wpdb->last_error)) { 8779 $results[$origin_key] = [ 8780 'status' => 'database_error', 8781 'error' => $wpdb->last_error, 8782 ]; 8783 continue; 8784 } 8785 8786 $missing_columns = array_values( 8787 array_diff($required_columns, $columns) 8788 ); 8789 8790 if (!empty($missing_columns)) { 8791 $results[$origin_key] = [ 8792 'status' => 'incompatible_schema', 8793 'missing_columns' => $missing_columns, 8794 ]; 8795 continue; 8796 } 8797 8798 $results[$origin_key] = [ 8799 'status' => 'available', 8800 'capabilities' => [ 8801 'historical_views' => true, 8802 'record_id' => true, 8803 'product_reference' => true, 8804 'event_timestamp' => true, 8805 ], 8806 ]; 8807 } 8808 8809 return $results; 8810 } 8811 8812 private function initialize_slimstat_view_source_origins() { 8813 $registration = $this->register_slimstat_view_source(); 8814 8815 if ( 8816 !in_array($registration['status'], ['registered', 'partial'], true) 8817 || empty($registration['source']['source_id']) 8818 ) { 8819 return [ 8820 'status' => 'registration_failed', 8821 'registration' => $registration, 8822 'origins' => [], 8823 ]; 8824 } 8825 8826 $source_id = (int) $registration['source']['source_id']; 8827 $origins = []; 8828 $initialization_failed = false; 8829 $available_origins = 0; 8830 8831 foreach ($registration['origins'] as $origin_key => $origin_data) { 8832 $origin_registration = isset($origin_data['registration']) 8833 ? $origin_data['registration'] 8834 : []; 8835 8836 if (($origin_registration['status'] ?? '') !== 'registered') { 8837 $origins[$origin_key] = [ 8838 'status' => 'registration_failed', 8839 'registration' => $origin_registration, 8840 ]; 8841 $initialization_failed = true; 8842 continue; 8843 } 8844 8845 $detection_status = $origin_data['detection']['status'] ?? 'unknown'; 8846 8847 if ($detection_status !== 'available') { 8848 $origins[$origin_key] = [ 8849 'status' => 'skipped', 8850 'reason' => $detection_status, 8851 ]; 8852 continue; 8853 } 8854 8855 $available_origins++; 8856 8857 $result = $this->initialize_view_source_origin_snapshot( 8858 $source_id, 8859 $origin_key 8860 ); 8861 8862 $origins[$origin_key] = $result; 8863 8864 if (!in_array( 8865 $result['status'], 8866 ['initialized', 'already_initialized'], 8867 true 8868 )) { 8869 $initialization_failed = true; 8870 } 8871 } 8872 8873 if ($available_origins === 0 && !$initialization_failed) { 8874 $status = 'no_available_origins'; 8875 } else { 8876 $status = $initialization_failed ? 'partial' : 'initialized'; 8877 } 8878 8879 return [ 8880 'status' => $status, 8881 'source_id' => $source_id, 8882 'registration' => $registration, 8883 'origins' => $origins, 8884 ]; 8885 } 8886 8887 private function register_slimstat_view_source() { 8888 $source = $this->register_view_source( 8889 'slimstat', 8890 'SlimStat', 8891 '1.0.0', 8892 'event', 8893 'registered' 8894 ); 8895 8896 if ($source['status'] !== 'registered' || empty($source['source_id'])) { 8897 return [ 8898 'status' => 'source_registration_failed', 8899 'source' => $source, 8900 'origins' => [], 8901 ]; 8902 } 8903 8904 $source_id = (int) $source['source_id']; 8905 $detected_origins = $this->detect_slimstat_view_origins(); 8906 $origins = []; 8907 $registration_failed = false; 8908 8909 foreach ($detected_origins as $origin_key => $detection) { 8910 $capabilities = isset($detection['capabilities']) 8911 ? $detection['capabilities'] 8912 : []; 8913 8914 $registration = $this->register_view_source_origin( 8915 $source_id, 8916 $origin_key, 8917 'slimstat', 8918 $capabilities, 8919 in_array($detection['status'], ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true) 8920 ? $detection['status'] 8921 : 'unknown' 8922 ); 8923 8924 $origins[$origin_key] = [ 8925 'detection' => $detection, 8926 'registration' => $registration, 8927 ]; 8928 8929 if ($registration['status'] !== 'registered') { 8930 $registration_failed = true; 8931 } 8932 } 8933 8934 return [ 8935 'status' => $registration_failed ? 'partial' : 'registered', 8936 'source' => $source, 8937 'origins' => $origins, 8938 ]; 8939 } 8940 8941 private function detect_slimstat_source() { 8942 global $wpdb; 8943 8944 // Check whether SlimStat is installed and loaded. 8945 if (!class_exists('wp_slimstat', false)) { 8946 return [ 8947 'status' => 'not_installed', 8948 ]; 8949 } 8950 8951 // SlimStat source tables. 8952 $tables = [ 8953 'active' => $wpdb->prefix . 'slim_stats', 8954 'archive' => $wpdb->prefix . 'slim_stats_archive', 8955 ]; 8956 8957 $missing_tables = []; 8958 8959 // Check that both tables exist. 8960 foreach ($tables as $key => $table) { 8961 $exists = statpl_db_get_var( 8962 statpl_db_prepare( 8963 'SHOW TABLES LIKE %s', 8964 $wpdb->esc_like($table) 8965 ) 8966 ); 8967 8968 if (!empty($wpdb->last_error)) { 8969 return [ 8970 'status' => 'database_error', 8971 ]; 8972 } 8973 8974 if ($exists !== $table) { 8975 $missing_tables[] = $table; 8976 } 8977 } 8978 8979 if (!empty($missing_tables)) { 8980 return [ 8981 'status' => 'missing_tables', 8982 'missing' => $missing_tables, 8983 ]; 8984 } 8985 8986 // Required columns for historical product-view import. 8987 $required_columns = [ 8988 'id', 8989 'content_type', 8990 'content_id', 8991 'dt', 8992 ]; 8993 8994 $incompatible_tables = []; 8995 8996 // Check the required schema in both tables. 8997 foreach ($tables as $key => $table) { 8998 $columns = statpl_db_get_col( 8999 "SHOW COLUMNS FROM `{$table}`", 9000 0 9001 ); 9002 9003 if (!empty($wpdb->last_error)) { 9004 return [ 9005 'status' => 'database_error', 9006 ]; 9007 } 9008 9009 $missing_columns = array_diff( 9010 $required_columns, 9011 $columns 9012 ); 9013 9014 if (!empty($missing_columns)) { 9015 $incompatible_tables[$table] = array_values( 9016 $missing_columns 9017 ); 9018 } 9019 } 9020 9021 if (!empty($incompatible_tables)) { 9022 return [ 9023 'status' => 'incompatible_schema', 9024 'missing_columns' => $incompatible_tables, 9025 ]; 9026 } 9027 9028 return [ 9029 'status' => 'available', 9030 'tables' => $tables, 9031 ]; 9032 } 9033 private function get_slimstat_view_snapshot() { 9034 global $wpdb; 9035 9036 $source = $this->detect_slimstat_source(); 9037 9038 if ($source['status'] !== 'available') { 9039 return $source; 9040 } 9041 9042 $snapshot = []; 9043 9044 foreach ($source['tables'] as $key => $table) { 9045 $max_id = statpl_db_get_var( 9046 statpl_db_prepare( 9047 "SELECT MAX(id) FROM `{$table}` WHERE content_type = %s", 9048 'cpt:product' 9049 ) 9050 ); 9051 9052 if (!empty($wpdb->last_error)) { 9053 return [ 9054 'status' => 'database_error', 9055 ]; 9056 } 9057 9058 $snapshot[$key] = (int) $max_id; 9059 } 9060 9061 return [ 9062 'status' => 'available', 9063 'snapshot' => $snapshot, 9064 ]; 9065 } 9066 private function initialize_slimstat_view_import() { 9067 global $wpdb; 9068 9069 $existing = statpl_db_get_var( 9070 statpl_db_prepare( 9071 "SELECT source FROM {$this->view_import_state_table} WHERE source = %s", 9072 'slimstat' 9073 ) 9074 ); 9075 9076 if (!empty($wpdb->last_error)) { 9077 return [ 9078 'status' => 'database_error', 9079 ]; 9080 } 9081 9082 if ($existing === 'slimstat') { 9083 return [ 9084 'status' => 'already_initialized', 9085 ]; 9086 } 9087 9088 $result = $this->get_slimstat_view_snapshot(); 9089 9090 if ($result['status'] !== 'available') { 9091 return $result; 9092 } 9093 9094 $inserted = statpl_db_query( 9095 statpl_db_prepare( 9096 "INSERT IGNORE INTO {$this->view_import_state_table} 9097 (source, snapshot_max_id, snapshot_active_max_id, snapshot_archive_max_id, last_processed_id, status, updated_at) 9098 VALUES (%s, %d, %d, %d, %d, %s, %s)", 9099 'slimstat', 9100 max($result['snapshot']['active'], $result['snapshot']['archive']), 9101 $result['snapshot']['active'], 9102 $result['snapshot']['archive'], 9103 0, 9104 'pending', 9105 current_time('mysql') 9106 ) 9107 ); 9108 9109 if (false === $inserted) { 9110 return [ 9111 'status' => 'database_error', 9112 ]; 9113 } 9114 9115 if (0 === $inserted) { 9116 $existing = statpl_db_get_var( 9117 statpl_db_prepare( 9118 "SELECT source FROM {$this->view_import_state_table} WHERE source = %s", 9119 'slimstat' 9120 ) 9121 ); 9122 9123 if (!empty($wpdb->last_error)) { 9124 return [ 9125 'status' => 'database_error', 9126 ]; 9127 } 9128 9129 if ($existing === 'slimstat') { 9130 return [ 9131 'status' => 'already_initialized', 9132 ]; 9133 } 9134 9135 return [ 9136 'status' => 'insert_not_confirmed', 9137 ]; 9138 } 9139 9140 return [ 9141 'status' => 'initialized', 9142 'snapshot' => $result['snapshot'], 9143 ]; 9144 } 9145 9146 /** 9147 * Retrieve the next historical SlimStat product-view batch. 9148 * This method is read-only: it never changes the checkpoint or import tables. 9149 */ 9150 private function get_slimstat_view_import_batch($last_processed_id, $batch_size = 50) { 9151 global $wpdb; 9152 9153 $state = statpl_db_get_row( 9154 statpl_db_prepare( 9155 "SELECT snapshot_active_max_id, snapshot_archive_max_id 9156 FROM {$this->view_import_state_table} 9157 WHERE source = %s", 9158 'slimstat' 9159 ), 9160 ARRAY_A 9161 ); 9162 9163 if (!empty($wpdb->last_error)) { 9164 return [ 9165 'status' => 'database_error', 9166 'rows' => [], 9167 ]; 9168 } 9169 9170 if (!is_array($state)) { 9171 return [ 9172 'status' => 'state_missing', 9173 'rows' => [], 9174 ]; 9175 } 9176 9177 $active_max_id = (int) $state['snapshot_active_max_id']; 9178 $archive_max_id = (int) $state['snapshot_archive_max_id']; 9179 $last_processed_id = max(0, (int) $last_processed_id); 9180 $batch_size = max(1, min(500, (int) $batch_size)); 9181 9182 $active_table = $wpdb->prefix . 'slim_stats'; 9183 $archive_table = $wpdb->prefix . 'slim_stats_archive'; 9184 9185 $sql = " 9186 SELECT id, product_id, dt, source 9187 FROM ( 9188 SELECT 9189 s.id AS id, 9190 s.content_id AS product_id, 9191 s.dt AS dt, 9192 'active' AS source 9193 FROM `{$active_table}` s 9194 WHERE s.content_type = %s 9195 AND s.id > %d 9196 AND s.id <= %d 9197 9198 UNION ALL 9199 9200 SELECT 9201 a.id AS id, 9202 a.content_id AS product_id, 9203 a.dt AS dt, 9204 'archive' AS source 9205 FROM `{$archive_table}` a 9206 WHERE a.content_type = %s 9207 AND a.id > %d 9208 AND a.id <= %d 9209 AND NOT EXISTS ( 9210 SELECT 1 9211 FROM `{$active_table}` s 9212 WHERE s.id = a.id 9213 AND s.id <= %d 9214 ) 9215 ) AS combined 9216 ORDER BY id ASC 9217 LIMIT %d 9218 "; 9219 9220 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is a fixed SQL template with value placeholders; execution below passes it through statpl_db_prepare() with the complete value list. 9221 $rows = statpl_db_get_results( 9222 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 9223 statpl_db_prepare( 9224 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $sql, 9225 'cpt:product', 9226 $last_processed_id, 9227 $active_max_id, 9228 'cpt:product', 9229 $last_processed_id, 9230 $archive_max_id, 9231 $active_max_id, 9232 $batch_size 9233 ), 9234 ARRAY_A 9235 ); 9236 9237 if (!empty($wpdb->last_error)) { 9238 return [ 9239 'status' => 'database_error', 9240 'rows' => [], 9241 ]; 9242 } 9243 9244 return [ 9245 'status' => 'available', 9246 'rows' => is_array($rows) ? $rows : [], 9247 ]; 9248 } 9249 9250 /** 9251 * Import one historical SlimStat product-view event idempotently. 9252 * 9253 * The event ledger is the unique source of truth. A guarded multi-table 9254 * update applies the metrics and marks the event in one atomic statement. 9255 * 9256 * The caller is responsible for checkpoint progression and retry handling. 9257 * 9258 * @param array $row Historical SlimStat source row. 9259 * @return string imported|duplicate|failed 9260 */ 9261 private function import_slimstat_view_event($row) { 9262 global $wpdb; 9263 9264 if ( 9265 !is_array($row) 9266 || !isset($row['id'], $row['product_id']) 9267 ) { 9268 return 'failed'; 9269 } 9270 9271 $source_view_id = (int) $row['id']; 9272 $product_id = (int) $row['product_id']; 9273 9274 if ($source_view_id <= 0 || $product_id <= 0) { 9275 return 'failed'; 9276 } 9277 9278 $now = current_time('mysql'); 9279 9280 // Insert the event as unapplied. Repeating this insert is harmless. 9281 $ledger_inserted = statpl_db_query( 9282 statpl_db_prepare( 9283 "INSERT INTO {$this->view_import_events_table} 9284 (source, source_view_id, product_id, imported_at, counts_applied) 9285 VALUES (%s, %d, %d, %s, 0) 9286 ON DUPLICATE KEY UPDATE 9287 source_view_id = VALUES(source_view_id)", 9288 'slimstat', 9289 $source_view_id, 9290 $product_id, 9291 $now 9292 ) 9293 ); 9294 9295 if (false === $ledger_inserted) { 9296 return 'failed'; 9297 } 9298 9299 // Verify the existing event identity; never reassign it to another product. 9300 $event = statpl_db_get_row( 9301 statpl_db_prepare( 9302 "SELECT product_id, counts_applied 9303 FROM {$this->view_import_events_table} 9304 WHERE source = %s AND source_view_id = %d", 9305 'slimstat', 9306 $source_view_id 9307 ), 9308 ARRAY_A 9309 ); 9310 9311 if ( 9312 !is_array($event) 9313 || !isset($event['product_id'], $event['counts_applied']) 9314 || (int) $event['product_id'] !== $product_id 9315 ) { 9316 return 'failed'; 9317 } 9318 9319 if (1 === (int) $event['counts_applied']) { 9320 return 'duplicate'; 9321 } 9322 9323 // Ensure both metric rows exist. These writes are safe to repeat. 9324 $total_ready = statpl_db_query( 9325 statpl_db_prepare( 9326 "INSERT INTO {$this->views_table} 9327 (product_id, total_views) 9328 VALUES (%d, 0) 9329 ON DUPLICATE KEY UPDATE product_id = VALUES(product_id)", 9330 $product_id 9331 ) 9332 ); 9333 9334 if (false === $total_ready) { 9335 return 'failed'; 9336 } 9337 9338 $aggregate_ready = statpl_db_query( 9339 statpl_db_prepare( 9340 "INSERT INTO {$this->view_imports_table} 9341 (source, product_id, imported_views, imported_at) 9342 VALUES (%s, %d, 0, %s) 9343 ON DUPLICATE KEY UPDATE product_id = VALUES(product_id)", 9344 'slimstat', 9345 $product_id, 9346 $now 9347 ) 9348 ); 9349 9350 if (false === $aggregate_ready) { 9351 return 'failed'; 9352 } 9353 9354 /* 9355 * Apply all three changes atomically. 9356 * Replaying this statement cannot increment the metrics twice. 9357 */ 9358 $metrics_updated = statpl_db_query( 9359 statpl_db_prepare( 9360 "UPDATE {$this->views_table} t 9361 INNER JOIN {$this->view_imports_table} i 9362 ON i.source = %s AND i.product_id = t.product_id 9363 INNER JOIN {$this->view_import_events_table} e 9364 ON e.source = %s 9365 AND e.source_view_id = %d 9366 AND e.product_id = t.product_id 9367 SET 9368 t.total_views = t.total_views + 1, 9369 i.imported_views = i.imported_views + 1, 9370 i.imported_at = %s, 9371 e.counts_applied = 1 9372 WHERE t.product_id = %d 9373 AND e.counts_applied = 0", 9374 'slimstat', 9375 'slimstat', 9376 $source_view_id, 9377 $now, 9378 $product_id 9379 ) 9380 ); 9381 9382 if (false === $metrics_updated) { 9383 return 'failed'; 9384 } 9385 9386 if (3 === $metrics_updated) { 9387 delete_transient('statpl_global_summary_v2'); 9388 return 'imported'; 9389 } 9390 9391 // Verify an ambiguous or concurrent result using the durable marker. 9392 if (0 === $metrics_updated) { 9393 $event = statpl_db_get_row( 9394 statpl_db_prepare( 9395 "SELECT product_id, counts_applied 9396 FROM {$this->view_import_events_table} 9397 WHERE source = %s AND source_view_id = %d", 9398 'slimstat', 9399 $source_view_id 9400 ), 9401 ARRAY_A 9402 ); 9403 9404 if ( 9405 is_array($event) 9406 && isset($event['product_id'], $event['counts_applied']) 9407 && (int) $event['product_id'] === $product_id 9408 && 1 === (int) $event['counts_applied'] 9409 ) { 9410 delete_transient('statpl_global_summary_v2'); 9411 return 'duplicate'; 9412 } 9413 } 9414 9415 return 'failed'; 9416 } 9417 9418 9419 /** Persist an initial SlimStat import failure without counting a replay as another attempt. */ 9420 private function record_slimstat_view_import_failure($row, $error) { 9421 global $wpdb; 9422 9423 $source_view_id = isset($row['id']) ? absint($row['id']) : 0; 9424 $product_id = isset($row['product_id']) ? absint($row['product_id']) : 0; 9425 9426 if ($source_view_id < 1) { 9427 return false; 9428 } 9429 9430 $error = substr(wp_strip_all_tags((string) $error), 0, 4000); 9431 if ($error === '') { 9432 $error = 'SlimStat historical view could not be imported.'; 9433 } 9434 9435 $result = statpl_db_query( 9436 statpl_db_prepare( 9437 "INSERT INTO {$this->view_import_failures_table} 9438 (source, source_view_id, product_id, attempts, last_error, status, updated_at) 9439 VALUES (%s, %d, %d, 1, %s, 'retry', %s) 9440 ON DUPLICATE KEY UPDATE last_error = VALUES(last_error)", 9441 'slimstat', 9442 $source_view_id, 9443 $product_id, 9444 $error, 9445 current_time('mysql') 9446 ) 9447 ); 9448 9449 if (false === $result) { 9450 return false; 9451 } 9452 $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . $source_view_id), 'retry', 1, 'record_import_failed'); 9453 9454 $failure = statpl_db_get_row( 9455 statpl_db_prepare( 9456 "SELECT product_id 9457 FROM {$this->view_import_failures_table} 9458 WHERE source = %s AND source_view_id = %d", 9459 'slimstat', 9460 $source_view_id 9461 ), 9462 ARRAY_A 9463 ); 9464 9465 return empty($wpdb->last_error) 9466 && is_array($failure) 9467 && isset($failure['product_id']) 9468 && (int) $failure['product_id'] === $product_id; 9469 } 9470 9471 /** Process independently due SlimStat retries. */ 9472 private function process_slimstat_view_import_retries($lock = null) { 9473 global $wpdb; 9474 9475 // Retry processing is part of the same serialized worker as the main 9476 // batch. Verify ownership per event, not merely before and after the 9477 // entire retry loop, because a transparent reconnect can occur midway. 9478 if (!$this->slimstat_import_lock_is_current($lock)) { 9479 return false; 9480 } 9481 9482 $now = current_time('mysql'); 9483 9484 $failures = statpl_db_get_results( 9485 statpl_db_prepare( 9486 "SELECT id, source_view_id, product_id, attempts 9487 FROM {$this->view_import_failures_table} 9488 WHERE source = %s 9489 AND status = 'retry' 9490 AND ( 9491 (attempts = 1 AND updated_at <= DATE_SUB(%s, INTERVAL 1 MINUTE)) 9492 OR 9493 (attempts = 2 AND updated_at <= DATE_SUB(%s, INTERVAL 2 MINUTE)) 9494 ) 9495 ORDER BY updated_at ASC 9496 LIMIT 50", 9497 'slimstat', 9498 $now, 9499 $now 9500 ), 9501 ARRAY_A 9502 ); 9503 9504 if (!empty($wpdb->last_error) || !is_array($failures)) { 9505 return false; 9506 } 9507 9508 foreach ($failures as $failure) { 9509 if (!$this->slimstat_import_lock_is_current($lock)) { 9510 return false; 9511 } 9512 9513 $result = $this->import_slimstat_view_event([ 9514 'id' => (int) $failure['source_view_id'], 9515 'product_id' => (int) $failure['product_id'], 9516 ]); 9517 9518 // The event may have committed immediately before a DB reconnect. 9519 // Its counts_applied marker makes replay safe, but no retry-ledger 9520 // mutation may continue on a replacement session without the lock. 9521 if (!$this->slimstat_import_lock_is_current($lock)) { 9522 return false; 9523 } 9524 9525 if (in_array($result, ['imported', 'duplicate'], true)) { 9526 $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $failure['source_view_id']), 'imported', (int) $failure['attempts'], ''); 9527 $deleted = statpl_db_delete( 9528 $this->view_import_failures_table, 9529 [ 9530 'id' => (int) $failure['id'], 9531 'source' => 'slimstat', 9532 ], 9533 ['%d', '%s'] 9534 ); 9535 9536 if (false === $deleted || !$this->slimstat_import_lock_is_current($lock)) { 9537 return false; 9538 } 9539 9540 continue; 9541 } 9542 9543 $error = !empty($wpdb->last_error) 9544 ? $wpdb->last_error 9545 : 'SlimStat historical view retry failed.'; 9546 9547 $updated = statpl_db_query( 9548 statpl_db_prepare( 9549 "UPDATE {$this->view_import_failures_table} 9550 SET 9551 status = IF(attempts >= 2, 'failed', 'retry'), 9552 attempts = attempts + 1, 9553 last_error = %s, 9554 updated_at = %s 9555 WHERE id = %d 9556 AND source = %s 9557 AND status = 'retry' 9558 AND attempts < 3", 9559 substr(wp_strip_all_tags((string) $error), 0, 4000), 9560 current_time('mysql'), 9561 (int) $failure['id'], 9562 'slimstat' 9563 ) 9564 ); 9565 9566 if (1 !== $updated || !$this->slimstat_import_lock_is_current($lock)) { 9567 return false; 9568 } 9569 $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $failure['source_view_id']), ((int) $failure['attempts'] >= 2 ? 'failed' : 'retry'), (int) $failure['attempts'] + 1, 'record_import_failed'); 9570 } 9571 9572 return $this->slimstat_import_lock_is_current($lock); 9573 } 9574 9575 /** Return the delay until the earliest pending SlimStat retry. */ 9576 private function get_slimstat_view_import_retry_delay() { 9577 global $wpdb; 9578 9579 $delay = statpl_db_get_var( 9580 statpl_db_prepare( 9581 "SELECT GREATEST( 9582 5, 9583 TIMESTAMPDIFF( 9584 SECOND, 9585 %s, 9586 CASE 9587 WHEN attempts = 1 THEN DATE_ADD(updated_at, INTERVAL 1 MINUTE) 9588 ELSE DATE_ADD(updated_at, INTERVAL 2 MINUTE) 9589 END 9590 ) 9591 ) 9592 FROM {$this->view_import_failures_table} 9593 WHERE source = %s AND status = 'retry' 9594 ORDER BY 9595 CASE 9596 WHEN attempts = 1 THEN DATE_ADD(updated_at, INTERVAL 1 MINUTE) 9597 ELSE DATE_ADD(updated_at, INTERVAL 2 MINUTE) 9598 END ASC 9599 LIMIT 1", 9600 current_time('mysql'), 9601 'slimstat' 9602 ) 9603 ); 9604 9605 if (!empty($wpdb->last_error)) { 9606 return 60; 9607 } 9608 9609 return null === $delay ? 5 : max(5, (int) $delay); 9610 } 9611 9612 /** Advance the SlimStat checkpoint only after the current row is durably resolved. */ 9613 private function update_slimstat_view_import_checkpoint($expected_id, $next_id, $lock) { 9614 global $wpdb; 9615 9616 $expected_id = max(0, (int) $expected_id); 9617 $next_id = max(0, (int) $next_id); 9618 9619 if ($next_id <= $expected_id) { 9620 return $this->slimstat_import_lock_is_current($lock); 9621 } 9622 if (!$this->slimstat_import_lock_is_current($lock)) { 9623 return false; 9624 } 9625 9626 // Enforce ownership inside the checkpoint UPDATE itself. This closes 9627 // the check/write race if wpdb reconnects between separate statements. 9628 $updated = statpl_db_query( 9629 statpl_db_prepare( 9630 "UPDATE {$this->view_import_state_table} 9631 SET last_processed_id = %d, updated_at = %s 9632 WHERE source = %s AND last_processed_id = %d 9633 AND CONNECTION_ID() = %s 9634 AND IS_USED_LOCK(%s) = CONNECTION_ID()", 9635 $next_id, 9636 current_time('mysql'), 9637 'slimstat', 9638 $expected_id, 9639 (string) $lock['connection_id'], 9640 (string) $lock['lock_name'] 9641 ) 9642 ); 9643 9644 return 1 === $updated && $this->slimstat_import_lock_is_current($lock); 9645 } 9646 9647 /** Capture and verify the exact MySQL session that owns the SlimStat import lock. */ 9648 private function acquire_slimstat_import_lock($lock_name) { 9649 global $wpdb; 9650 try { 9651 $row = statpl_db_get_row(statpl_db_prepare( 9652 'SELECT CONNECTION_ID() AS connection_id, GET_LOCK(%s, 0) AS lock_acquired', 9653 (string) $lock_name 9654 ), ARRAY_A); 9655 } catch (Throwable $exception) { 9656 return ['acquired' => false, 'error' => $exception->getMessage()]; 9657 } 9658 if (!is_array($row) || !empty($wpdb->last_error) 9659 || (string) ($row['lock_acquired'] ?? '') !== '1' 9660 || empty($row['connection_id'])) { 9661 return ['acquired' => false, 'error' => (string) $wpdb->last_error]; 9662 } 9663 return [ 9664 'acquired' => true, 9665 'connection_id' => (string) $row['connection_id'], 9666 'lock_name' => (string) $lock_name, 9667 ]; 9668 } 9669 9670 /** Verify both connection identity and actual ownership of the SlimStat lock. */ 9671 private function slimstat_import_lock_is_current($lock) { 9672 global $wpdb; 9673 if (!is_array($lock) || empty($lock['acquired']) 9674 || empty($lock['connection_id']) || empty($lock['lock_name'])) { 9675 return false; 9676 } 9677 try { 9678 $row = statpl_db_get_row(statpl_db_prepare( 9679 'SELECT CONNECTION_ID() AS connection_id, IS_USED_LOCK(%s) AS lock_owner', 9680 (string) $lock['lock_name'] 9681 ), ARRAY_A); 9682 } catch (Throwable $exception) { 9683 return false; 9684 } 9685 return is_array($row) && empty($wpdb->last_error) 9686 && (string) ($row['connection_id'] ?? '') === (string) $lock['connection_id'] 9687 && (string) ($row['lock_owner'] ?? '') === (string) $lock['connection_id']; 9688 } 9689 9690 /** Release the SlimStat lock only through the original lock-owning session. */ 9691 private function release_slimstat_import_lock($lock) { 9692 global $wpdb; 9693 if (!$this->slimstat_import_lock_is_current($lock)) { 9694 if (defined('WP_DEBUG') && WP_DEBUG) { 9695 $this->debug_log('Statora Plus: SlimStat advisory lock session changed before release.'); 9696 } 9697 return false; 9698 } 9699 try { 9700 $released = statpl_db_get_var(statpl_db_prepare( 9701 'SELECT RELEASE_LOCK(%s)', (string) $lock['lock_name'] 9702 )); 9703 return (string) $released === '1' && empty($wpdb->last_error); 9704 } catch (Throwable $exception) { 9705 if (defined('WP_DEBUG') && WP_DEBUG) { 9706 $this->debug_log('Statora Plus: SlimStat advisory lock release failed: ' . $exception->getMessage()); 9707 } 9708 return false; 9709 } 9710 } 9711 9712 /** Execute one protected SlimStat historical import batch. */ 9713 public function run_slimstat_view_import_batch() { 9714 global $wpdb; 9715 9716 // MySQL advisory lock names must remain within the server's 64-character limit. 9717 $lock_name = 'statpl_view_import_' . substr(md5($wpdb->prefix), 0, 32); 9718 9719 $slimstat_lock = $this->acquire_slimstat_import_lock($lock_name); 9720 9721 if (empty($slimstat_lock['acquired'])) { 9722 $this->schedule_slimstat_view_import_batch( 9723 !empty($wpdb->last_error) ? 60 : 15 9724 ); 9725 return; 9726 } 9727 9728 try { 9729 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9730 $this->schedule_slimstat_view_import_batch(60); 9731 return; 9732 } 9733 $state = statpl_db_get_row( 9734 statpl_db_prepare( 9735 "SELECT snapshot_max_id, last_processed_id, status 9736 FROM {$this->view_import_state_table} 9737 WHERE source = %s", 9738 'slimstat' 9739 ), 9740 ARRAY_A 9741 ); 9742 9743 if (!empty($wpdb->last_error)) { 9744 $this->schedule_slimstat_view_import_batch(60); 9745 return; 9746 } 9747 9748 if (!is_array($state)) { 9749 return; 9750 } 9751 9752 if (!in_array($state['status'], ['pending', 'running'], true)) { 9753 return; 9754 } 9755 9756 $state_updated = statpl_db_query( 9757 statpl_db_prepare( 9758 "UPDATE {$this->view_import_state_table} 9759 SET status = 'running', updated_at = %s 9760 WHERE source = %s AND status IN ('pending', 'running') 9761 AND CONNECTION_ID() = %s 9762 AND IS_USED_LOCK(%s) = CONNECTION_ID()", 9763 current_time('mysql'), 9764 'slimstat', 9765 (string) $slimstat_lock['connection_id'], 9766 (string) $slimstat_lock['lock_name'] 9767 ) 9768 ); 9769 9770 if (false === $state_updated) { 9771 $this->schedule_slimstat_view_import_batch(60); 9772 return; 9773 } 9774 9775 // Retry previously failed rows independently of the sequential source batch. 9776 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9777 $this->schedule_slimstat_view_import_batch(60); 9778 return; 9779 } 9780 if (!$this->process_slimstat_view_import_retries($slimstat_lock)) { 9781 $this->schedule_slimstat_view_import_batch(60); 9782 return; 9783 } 9784 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9785 // A retry may have committed its event before a transparent DB 9786 // reconnect. Its durable event marker makes replay safe, but this 9787 // worker must not advance the sequential checkpoint on a new session. 9788 $this->schedule_slimstat_view_import_batch(60); 9789 return; 9790 } 9791 9792 $checkpoint = (int) $state['last_processed_id']; 9793 $snapshot_max_id = (int) $state['snapshot_max_id']; 9794 9795 $batch = $this->get_slimstat_view_import_batch($checkpoint, 50); 9796 9797 if ( 9798 !is_array($batch) 9799 || !isset($batch['status'], $batch['rows']) 9800 || 'available' !== $batch['status'] 9801 || !is_array($batch['rows']) 9802 ) { 9803 $this->schedule_slimstat_view_import_batch(60); 9804 return; 9805 } 9806 9807 foreach ($batch['rows'] as $row) { 9808 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9809 $this->schedule_slimstat_view_import_batch(60); 9810 return; 9811 } 9812 // A checkpoint replay must not consume another import attempt. 9813 $existing_failure = statpl_db_get_row( 9814 statpl_db_prepare( 9815 "SELECT product_id, status 9816 FROM {$this->view_import_failures_table} 9817 WHERE source = %s AND source_view_id = %d", 9818 'slimstat', 9819 (int) $row['id'] 9820 ), 9821 ARRAY_A 9822 ); 9823 9824 if (!empty($wpdb->last_error)) { 9825 $this->schedule_slimstat_view_import_batch(60); 9826 return; 9827 } 9828 9829 if (is_array($existing_failure)) { 9830 if ((int) $existing_failure['product_id'] !== (int) $row['product_id']) { 9831 $this->debug_log( 9832 'Statora Plus: SlimStat failure ledger product ID mismatch for source event ' 9833 . (int) $row['id'] 9834 ); 9835 $this->schedule_slimstat_view_import_batch(60); 9836 return; 9837 } 9838 9839 if (!in_array($existing_failure['status'], ['retry', 'failed'], true)) { 9840 $this->schedule_slimstat_view_import_batch(60); 9841 return; 9842 } 9843 9844 // The failure is already durable; wait for its independent retry. 9845 $next_id = (int) $row['id']; 9846 9847 if (!$this->slimstat_import_lock_is_current($slimstat_lock) 9848 || !$this->update_slimstat_view_import_checkpoint($checkpoint, $next_id, $slimstat_lock)) { 9849 $this->schedule_slimstat_view_import_batch(60); 9850 return; 9851 } 9852 9853 $checkpoint = $next_id; 9854 continue; 9855 } 9856 9857 $result = $this->import_slimstat_view_event($row); 9858 9859 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9860 // The event helper may have completed its atomic metric update 9861 // just before a connection loss. Do not move the checkpoint; 9862 // replay is protected by counts_applied and the unique event key. 9863 $this->schedule_slimstat_view_import_batch(60); 9864 return; 9865 } 9866 9867 if (in_array($result, ['imported', 'duplicate'], true)) { 9868 $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $row['id']), 'imported', is_array($existing_failure) ? (int) ($existing_failure['attempts'] ?? 0) : 0, ''); 9869 // A previous retry record is no longer needed after confirmed success. 9870 $deleted = statpl_db_delete( 9871 $this->view_import_failures_table, 9872 [ 9873 'source' => 'slimstat', 9874 'source_view_id' => (int) $row['id'], 9875 ], 9876 ['%s', '%d'] 9877 ); 9878 9879 if (false === $deleted) { 9880 $this->schedule_slimstat_view_import_batch(60); 9881 return; 9882 } 9883 } else { 9884 // Capture the helper's SQL error before executing another database query. 9885 $error = !empty($wpdb->last_error) 9886 ? $wpdb->last_error 9887 : 'SlimStat historical view could not be imported.'; 9888 9889 if (!$this->record_slimstat_view_import_failure($row, $error)) { 9890 $this->schedule_slimstat_view_import_batch(60); 9891 return; 9892 } 9893 } 9894 9895 $next_id = (int) $row['id']; 9896 9897 if (!$this->slimstat_import_lock_is_current($slimstat_lock) 9898 || !$this->update_slimstat_view_import_checkpoint($checkpoint, $next_id, $slimstat_lock)) { 9899 $this->schedule_slimstat_view_import_batch(60); 9900 return; 9901 } 9902 9903 $checkpoint = $next_id; 9904 } 9905 9906 if (!empty($batch['rows'])) { 9907 $this->schedule_slimstat_view_import_batch(5); 9908 return; 9909 } 9910 9911 // An empty successful source query means the snapshot has been exhausted. 9912 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9913 $this->schedule_slimstat_view_import_batch(60); 9914 return; 9915 } 9916 if ($checkpoint < $snapshot_max_id) { 9917 if (!$this->update_slimstat_view_import_checkpoint($checkpoint, $snapshot_max_id, $slimstat_lock)) { 9918 $this->schedule_slimstat_view_import_batch(60); 9919 return; 9920 } 9921 9922 $checkpoint = $snapshot_max_id; 9923 } 9924 9925 $failure_counts = statpl_db_get_row( 9926 statpl_db_prepare( 9927 "SELECT 9928 SUM(CASE WHEN status = 'retry' THEN 1 ELSE 0 END) AS pending_retries, 9929 SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS terminal_failures 9930 FROM {$this->view_import_failures_table} 9931 WHERE source = %s", 9932 'slimstat' 9933 ), 9934 ARRAY_A 9935 ); 9936 9937 if (!empty($wpdb->last_error) || !is_array($failure_counts)) { 9938 $this->schedule_slimstat_view_import_batch(60); 9939 return; 9940 } 9941 9942 $pending_retries = (int) $failure_counts['pending_retries']; 9943 $terminal_failures = (int) $failure_counts['terminal_failures']; 9944 9945 if ($pending_retries > 0) { 9946 $this->schedule_slimstat_view_import_batch( 9947 $this->get_slimstat_view_import_retry_delay() 9948 ); 9949 return; 9950 } 9951 9952 if (!$this->slimstat_import_lock_is_current($slimstat_lock)) { 9953 $this->schedule_slimstat_view_import_batch(60); 9954 return; 9955 } 9956 $final_status = $terminal_failures > 0 ? 'error' : 'complete'; 9957 9958 $finalized = statpl_db_query( 9959 statpl_db_prepare( 9960 "UPDATE {$this->view_import_state_table} 9961 SET status = %s, updated_at = %s 9962 WHERE source = %s AND status IN ('pending', 'running') 9963 AND CONNECTION_ID() = %s 9964 AND IS_USED_LOCK(%s) = CONNECTION_ID()", 9965 $final_status, 9966 current_time('mysql'), 9967 'slimstat', 9968 (string) $slimstat_lock['connection_id'], 9969 (string) $slimstat_lock['lock_name'] 9970 ) 9971 ); 9972 9973 if (1 !== $finalized || !$this->slimstat_import_lock_is_current($slimstat_lock)) { 9974 $this->schedule_slimstat_view_import_batch(60); 9975 } 9976 } finally { 9977 // Release only through the original connection. If WordPress transparently 9978 // reconnected, MySQL has already released the old session's advisory lock. 9979 $this->release_slimstat_import_lock($slimstat_lock); 9980 } 9981 } 9982 9983 /** Schedule one independent SlimStat view import batch. */ 9984 public function schedule_slimstat_view_import_batch($delay = 10) { 9985 global $wpdb; 9986 9987 $status = statpl_db_get_var( 9988 statpl_db_prepare( 9989 "SELECT status FROM {$this->view_import_state_table} WHERE source = %s", 9990 'slimstat' 9991 ) 9992 ); 9993 9994 if (!empty($wpdb->last_error) || !in_array($status, ['pending', 'running'], true)) { 9995 return; 9996 } 9997 9998 $delay = max(5, (int) $delay); 9999 10000 if (function_exists('as_schedule_single_action') && !did_action('action_scheduler_init')) { 10001 add_action('action_scheduler_init', [$this, 'schedule_slimstat_view_import_batch'], 10, 0); 10002 return; 10003 } 10004 10005 if ( 10006 did_action('action_scheduler_init') 10007 && class_exists('ActionScheduler') 10008 && class_exists('ActionScheduler_Store') 10009 && null !== ActionScheduler::store()->query_action( 10010 [ 10011 'hook' => 'statpl_view_import_batch', 10012 'status' => ActionScheduler_Store::STATUS_PENDING, 10013 'group' => 'statpl-plus', 10014 'args' => [], 10015 'orderby' => 'none', 10016 ] 10017 ) 10018 ) { 10019 return; 10020 } 10021 10022 if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) { 10023 $action_id = as_schedule_single_action( 10024 time() + $delay, 10025 'statpl_view_import_batch', 10026 [], 10027 'statpl-plus' 10028 ); 10029 10030 if ($action_id) { 10031 return; 10032 } 10033 } 10034 10035 if (!wp_next_scheduled('statpl_view_import_batch')) { 10036 wp_schedule_single_event(time() + $delay, 'statpl_view_import_batch'); 10037 } 10038 } 10039 580 10040 /** Schedule one import batch with a delay to avoid overlapping batch execution. */ 581 10041 public function schedule_import_batch($delay = 10) { … … 645 10105 $args = array_merge([$since,$since,$since], $ids); 646 10106 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared with all variable values; table name is a class property built from the WordPress table prefix and fixed suffix; event counts must be current. 647 $rows = $wpdb->get_results($wpdb->prepare($sql, $args), ARRAY_A);10107 $rows = statpl_db_get_results(statpl_db_prepare($sql, $args), ARRAY_A); 648 10108 foreach ($rows as $r) { 649 10109 $id = (int)$r['product_id']; if (!isset($out[$id])) continue; … … 662 10122 $event_args = array_merge([$event_since, $event_since], $ids); 663 10123 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared with all variable values; table name is a class property built from the WordPress table prefix and fixed suffix; event counts must be current. 664 $event_rows = $wpdb->get_results($wpdb->prepare($event_sql, $event_args), ARRAY_A);10124 $event_rows = statpl_db_get_results(statpl_db_prepare($event_sql, $event_args), ARRAY_A); 665 10125 foreach ($event_rows as $event_row) { 666 10126 $id = (int)$event_row['product_id']; … … 686 10146 $summary = [ 687 10147 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This dashboard aggregate is cached by global_summary_metrics() for 60 seconds and invalidated when relevant data changes. 688 'products' => (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status IN ('publish','private','draft','pending')"),10148 'products' => (int) statpl_db_get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status IN ('publish','private','draft','pending')"), 689 10149 'views' => 0, 'views24' => 0, 'orders' => 0, 'orders24' => 0, 690 10150 'units' => 0.0, 'units24' => 0.0, 'revenue' => 0.0, 'revenue24' => 0.0, … … 694 10154 // All product views are recorded and aggregated by Statora Plus. 695 10155 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix; the dashboard aggregate is cached for 60 seconds and view recording invalidates that cache. 696 $summary['views'] = (int) $wpdb->get_var("SELECT COALESCE(SUM(total_views),0) FROM {$this->views_table}");10156 $summary['views'] = (int) statpl_db_get_var("SELECT COALESCE(SUM(total_views),0) FROM {$this->views_table}"); 697 10157 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the date value is prepared. 698 $summary['views24'] = (int) $wpdb->get_var(699 $wpdb->prepare(10158 $summary['views24'] = (int) statpl_db_get_var( 10159 statpl_db_prepare( 700 10160 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the date value is prepared. 701 10161 "SELECT COUNT(*) FROM {$this->events_table} WHERE viewed_at >= %s", … … 707 10167 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL identifiers cannot be bound as value placeholders; the identifier is internal, not user-controlled. 708 10168 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This aggregate is cached by global_summary_metrics() for 60 seconds and invalidated on relevant changes. 709 $sales = $wpdb->get_row($wpdb->prepare("SELECT10169 $sales = statpl_db_get_row(statpl_db_prepare("SELECT 710 10170 COALESCE(SUM(CASE WHEN statpl_snapshot.order_status='completed' AND statpl_snapshot.completed_at IS NOT NULL THEN statpl_snapshot.net_quantity ELSE 0 END),0) AS units_total, 711 10171 COALESCE(SUM(CASE WHEN statpl_snapshot.order_status='completed' AND statpl_snapshot.completed_at >= %s THEN statpl_snapshot.net_quantity ELSE 0 END),0) AS units24, … … 723 10183 // Product-level detail is calculated separately from rows with actual product IDs. 724 10184 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; no user-supplied SQL values are used. 725 $event_totals = $wpdb->get_row(10185 $event_totals = statpl_db_get_row( 726 10186 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; this aggregate has no user-supplied SQL values. 727 10187 "SELECT COUNT(DISTINCT CASE WHEN event_type='cancelled' AND product_id=0 THEN order_id END) AS cancelled_total, COUNT(DISTINCT CASE WHEN event_type='refund' AND product_id=0 AND event_ref_id > 0 THEN event_ref_id END) AS refunds_total FROM {$this->order_events_table}", … … 734 10194 // Every normal order is represented once per product in the snapshot; count order IDs only once. 735 10195 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix.; Table identifier is internally generated from the WordPress prefix and a fixed suffix.; This aggregate is part of the 60-second cached dashboard summary. 736 $summary['total_orders'] = (int) $wpdb->get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table}");10196 $summary['total_orders'] = (int) statpl_db_get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table}"); 737 10197 // Clean orders are currently processing/completed and retain some non-refunded product quantity/value. 738 10198 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix.; Table identifier is internally generated from the WordPress prefix and a fixed suffix.; This aggregate is part of the 60-second cached dashboard summary. 739 $summary['orders'] = (int) $wpdb->get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0)");10199 $summary['orders'] = (int) statpl_db_get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0)"); 740 10200 // Today's clean orders are based on order creation date, not payment/completion date. 741 10201 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary. 742 $summary['orders24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND created_at >= %s AND (net_quantity > 0 OR net_revenue > 0)", $since));10202 $summary['orders24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND created_at >= %s AND (net_quantity > 0 OR net_revenue > 0)", $since)); 743 10203 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary. 744 $summary['cancelled24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='cancelled' AND event_at >= %s", $since));10204 $summary['cancelled24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='cancelled' AND event_at >= %s", $since)); 745 10205 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary. 746 $summary['refunds24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT event_ref_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='refund' AND event_ref_id > 0 AND event_at >= %s", $since));10206 $summary['refunds24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT event_ref_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='refund' AND event_ref_id > 0 AND event_at >= %s", $since)); 747 10207 $summary['revenue'] = round($summary['revenue'], wc_get_price_decimals()); 748 10208 $summary['revenue24'] = round($summary['revenue24'], wc_get_price_decimals()); … … 829 10289 SELECT product_id, COUNT(*) AS views_today 830 10290 FROM {$this->events_table} 831 WHERE viewed_at >= " . $wpdb->prepare('%s', $since_mysql) . "10291 WHERE viewed_at >= " . statpl_db_prepare('%s', $since_mysql) . " 832 10292 GROUP BY product_id 833 10293 ) recent ON recent.product_id = totals.product_id … … 837 10297 SELECT product_id, COUNT(*) AS views_today 838 10298 FROM {$this->events_table} 839 WHERE viewed_at >= " . $wpdb->prepare('%s', $since_mysql) . "10299 WHERE viewed_at >= " . statpl_db_prepare('%s', $since_mysql) . " 840 10300 GROUP BY product_id 841 10301 ) statpl_recent_views ON statpl_recent_views.product_id = p.ID"; … … 849 10309 SELECT product_id, 850 10310 COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders, 851 COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND created_at >= " . $wpdb->prepare('%s', $since) . " AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders24,10311 COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND created_at >= " . statpl_db_prepare('%s', $since) . " AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders24, 852 10312 SUM(CASE WHEN order_status='completed' AND completed_at IS NOT NULL THEN net_quantity ELSE 0 END) AS units, 853 SUM(CASE WHEN order_status='completed' AND completed_at >= " . $wpdb->prepare('%s', $since) . " THEN net_quantity ELSE 0 END) AS units24,10313 SUM(CASE WHEN order_status='completed' AND completed_at >= " . statpl_db_prepare('%s', $since) . " THEN net_quantity ELSE 0 END) AS units24, 854 10314 SUM(CASE WHEN order_status='completed' AND completed_at IS NOT NULL THEN net_revenue ELSE 0 END) AS revenue, 855 SUM(CASE WHEN order_status='completed' AND completed_at >= " . $wpdb->prepare('%s', $since) . " THEN net_revenue ELSE 0 END) AS revenue2410315 SUM(CASE WHEN order_status='completed' AND completed_at >= " . statpl_db_prepare('%s', $since) . " THEN net_revenue ELSE 0 END) AS revenue24 856 10316 FROM {$this->order_items_table} GROUP BY product_id 857 10317 ) statpl_orders ON statpl_orders.product_id = p.ID … … 859 10319 SELECT product_id, 860 10320 COUNT(DISTINCT CASE WHEN event_type='cancelled' THEN order_id END) AS cancelled, 861 COUNT(DISTINCT CASE WHEN event_type='cancelled' AND event_at >= " . $wpdb->prepare('%s', $since) . " THEN order_id END) AS cancelled24,10321 COUNT(DISTINCT CASE WHEN event_type='cancelled' AND event_at >= " . statpl_db_prepare('%s', $since) . " THEN order_id END) AS cancelled24, 862 10322 COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 THEN event_ref_id END) AS refunds, 863 COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 AND event_at >= " . $wpdb->prepare('%s', $since) . " THEN event_ref_id END) AS refunds2410323 COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 AND event_at >= " . statpl_db_prepare('%s', $since) . " THEN event_ref_id END) AS refunds24 864 10324 FROM {$this->order_events_table} GROUP BY product_id 865 10325 ) statpl_events ON statpl_events.product_id = p.ID"; … … 869 10329 // $join and $where are assembled only from fixed SQL fragments and prepared placeholders; user input is passed in $args. 870 10330 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query fragments are plugin-controlled; all user-supplied values are bound through prepare(); product filters are evaluated live. 871 $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $args));10331 $total = (int) statpl_db_get_var(statpl_db_prepare($count_sql, $args)); 872 10332 $order_column = $order_columns[$orderby] ?? 'p.post_title'; 873 10333 $pages_for_query = max(1, (int) ceil($total / $per_page)); … … 878 10338 // $order_column is selected from the internal allowlist and $order is normalized before query construction. 879 10339 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query fragments are plugin-controlled; all user-supplied values are bound through prepare(); product filters are evaluated live. 880 $product_ids = array_map('intval', $wpdb->get_col($wpdb->prepare($id_sql, $id_args)));10340 $product_ids = array_map('intval', statpl_db_get_col(statpl_db_prepare($id_sql, $id_args))); 881 10341 } else { 882 10342 $base_args = ['post_type'=>'product','post_status'=>$statuses,'posts_per_page'=>-1,'fields'=>'ids','orderby'=>'ID','order'=>'ASC']; … … 891 10351 if (false === $sku_ids) { 892 10352 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Prepared partial-SKU lookup is required; results are cached for 60 seconds. 893 $sku_ids = $wpdb->get_col(894 $wpdb->prepare(10353 $sku_ids = statpl_db_get_col( 10354 statpl_db_prepare( 895 10355 "SELECT DISTINCT p.ID 896 10356 FROM {$wpdb->posts} p … … 928 10388 $st = $stats_by_product[$product_id] ?? ['orders'=>0,'orders24'=>0,'units'=>0,'units24'=>0,'revenue'=>0.0,'revenue24'=>0.0,'cancelled'=>0,'cancelled24'=>0,'refunds'=>0,'refunds24'=>0]; 929 10389 $stock_summary = $this->stock_summary_for_product($product); 10390 $product_status = get_post_status($product_id); 10391 $visibility_labels = [ 10392 'private' => 'Κρυφό', 10393 'draft' => 'Πρόχειρο', 10394 'pending' => 'Σε αναμονή', 10395 ]; 930 10396 $rows[] = ['id'=>$product_id,'title'=>$product->get_name(),'date'=>get_post_field('post_date',$product_id),'sku'=>$product->get_sku(), 931 'views'=>(int)$views['total'],'views24'=>(int)$views['today'],'source'=>$views['source'],'stock_managed'=>$stock_summary['managed'],'stock'=>$stock_summary['quantity'],'edit_url'=>get_edit_post_link($product_id, 'raw')] + $st; 10397 'product_visibility'=>$visibility_labels[$product_status] ?? '', 10398 'views'=>(int)$views['total'],'views24'=>(int)$views['today'],'source'=>$views['source'],'stock_managed'=>$stock_summary['managed'],'stock'=>$stock_summary['quantity'],'stock_in_stock'=>$stock_summary['in_stock'],'edit_url'=>get_edit_post_link($product_id, 'raw')] + $st; 932 10399 } 933 10400 if (!$db_paged) { … … 945 10412 $summary = $this->global_summary_metrics(); 946 10413 947 echo '<div class="wrap statpl-metrics-wrap"><h1>Statora Plus</h1> <p class="description">Στατιστικά προϊόντων WooCommerce σε μία συνοπτική εικόνα.</p>';10414 echo '<div class="wrap statpl-metrics-wrap"><h1>Statora Plus</h1>'; 948 10415 $overview_url = add_query_arg(['page'=>'statpl-product-metrics','view'=>'overview'], admin_url('admin.php')); 949 10416 $products_url = add_query_arg(['page'=>'statpl-product-metrics','view'=>'products'], admin_url('admin.php')); 950 10417 echo '<nav class="nav-tab-wrapper"><a class="nav-tab '.($view === 'overview' ? 'nav-tab-active' : '').'" href="'.esc_url($overview_url).'">Συνολική εικόνα</a><a class="nav-tab '.($view === 'products' ? 'nav-tab-active' : '').'" href="'.esc_url($products_url).'">Στατιστικά ανά προϊόν</a></nav>'; 951 10418 952 if (get_option('statpl_import_complete')) { 953 echo '<div class="notice notice-success inline"><p>Η αρχική εισαγωγή ιστορικού έχει επισημανθεί ως ολοκληρωμένη.</p></div>'; 954 } else { 955 $import_page = max(1, (int) get_option('statpl_import_page', 1)); 956 echo '<div class="notice notice-info inline"><p>Η αρχική εισαγωγή ιστορικού εκτελείται στο παρασκήνιο. Επεξεργασία παρτίδας: '.esc_html((string)$import_page).'. Τα στατιστικά ενδέχεται να είναι ελλιπή μέχρι να ολοκληρωθεί.</p></div>'; 957 } 10419 $this->render_historical_import_notice(); 958 10420 if ($view === 'overview') { 959 10421 echo '<h2>Συνολικά στατιστικά καταστήματος</h2>'; … … 1006 10468 if (!$visible_rows) echo '<tr><td colspan="14">Δεν βρέθηκαν προϊόντα.</td></tr>'; 1007 10469 foreach ($visible_rows as $r) { 1008 echo '<tr><td><a class="statpl-product-title" href="'.esc_url($r['edit_url']).'">'.esc_html($r['title']).'</a></td>'; 1009 echo '<td>'.($r['stock_managed'] ? esc_html(number_format_i18n($r['stock'])) : '<span class="statpl-stock-not-tracked">Δεν παρακολουθείται</span>').'</td>'; 10470 echo '<tr><td><a class="statpl-product-title" href="'.esc_url($r['edit_url']).'">'.esc_html($r['title']).'</a>'; 10471 if (!empty($r['product_visibility'])) { 10472 echo ' <span class="statpl-product-visibility">'.esc_html($r['product_visibility']).'</span>'; 10473 } 10474 echo '</td>'; 10475 if ($r['stock_managed']) { 10476 $stock_quantity = (float) $r['stock']; 10477 if ($stock_quantity > 0) { 10478 echo '<td>'.esc_html(number_format_i18n($stock_quantity)).'</td>'; 10479 } elseif (!empty($r['stock_in_stock'])) { 10480 // Zero quantity with backorders allowed remains purchasable. 10481 echo '<td>0</td>'; 10482 } else { 10483 echo '<td>Εξαντλημένο</td>'; 10484 } 10485 } else { 10486 echo '<td><span class="statpl-stock-not-tracked">Δεν γίνεται διαχείριση αποθέματος</span></td>'; 10487 } 1010 10488 echo '<td>'.esc_html(number_format_i18n($r['views'])).'</td><td>'.esc_html(number_format_i18n($r['views24'])).'</td>'; 1011 10489 foreach (['orders','orders24'] as $k) echo '<td>'.esc_html(number_format_i18n($r[$k])).'</td>'; … … 1022 10500 } 1023 10501 STATPL_Core::instance(); 10502 10503 10504 /** 10505 * Page View Count adapter. 10506 * 10507 * Reads only the provider's immutable, per-product daily rows from pvc_daily. 10508 * The current day is intentionally excluded because Page View Count updates 10509 * that row in place; importing a mutable count would violate the immutable 10510 * source snapshot contract. Lifetime totals in pvc_total are deliberately not 10511 * imported because they overlap the daily rows and have no date boundaries. 10512 */ 10513 if (!class_exists('STATPL_Page_View_Count_Historical_Adapter')) { 10514 final class STATPL_Page_View_Count_Historical_Adapter implements STATPL_Historical_View_Adapter { 10515 const ADAPTER_KEY = 'page_view_count'; 10516 const SOURCE_KEY = 'page_view_count'; 10517 const ADAPTER_VERSION = '1.0.0'; 10518 const ORIGIN_KEY = 'daily_product_views'; 10519 10520 public function get_adapter_key() { 10521 return self::ADAPTER_KEY; 10522 } 10523 10524 public function get_source_key() { 10525 return self::SOURCE_KEY; 10526 } 10527 10528 public function get_display_name() { 10529 return 'Page Views Count'; 10530 } 10531 10532 public function get_adapter_version() { 10533 return self::ADAPTER_VERSION; 10534 } 10535 10536 private function table_name() { 10537 global $wpdb; 10538 return $wpdb->prefix . 'pvc_daily'; 10539 } 10540 10541 private function quoted_table_name() { 10542 $table = $this->table_name(); 10543 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 10544 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 10545 } 10546 return '`' . $table . '`'; 10547 } 10548 10549 private function inspect_table() { 10550 global $wpdb; 10551 $table = $this->table_name(); 10552 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SHOW TABLES LIKE has one %s placeholder and receives exactly one escaped table-name value. 10553 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 10554 if (!empty($wpdb->last_error)) { 10555 return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; 10556 } 10557 if ($exists !== $table) { 10558 return ['status' => 'missing_table']; 10559 } 10560 10561 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SHOW COLUMNS contains no value placeholders; the table identifier is validated and quoted by quoted_table_name(). 10562 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10563 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 10564 if (!empty($wpdb->last_error) || !is_array($columns)) { 10565 return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; 10566 } 10567 $names = []; 10568 foreach ($columns as $column) { 10569 if (isset($column['Field'])) { 10570 $names[] = (string) $column['Field']; 10571 } 10572 } 10573 $required = ['id', 'time', 'postnum', 'postcount']; 10574 if (array_diff($required, $names)) { 10575 return ['status' => 'incompatible_schema']; 10576 } 10577 return ['status' => 'available']; 10578 } 10579 10580 public function should_register_adapter() { 10581 global $wpdb; 10582 $inspection = $this->inspect_table(); 10583 if ($inspection['status'] !== 'missing_table') { return true; } 10584 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 10585 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 10586 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 10587 } 10588 $registry_table = '`' . $registry_name . '`'; 10589 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 10590 if (!empty($wpdb->last_error)) { return true; } 10591 if ($registry_exists !== $registry_name) { return false; } 10592 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 10593 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10594 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 10595 return !empty($wpdb->last_error) || !empty($source_id); 10596 } 10597 10598 public function discover_origins() { 10599 $inspection = $this->inspect_table(); 10600 if ($inspection['status'] === 'missing_table') { 10601 // A previously registered source can be marked unavailable by the 10602 // core's authoritative inventory reconciliation if its table was 10603 // removed. A never-installed source is not registered needlessly. 10604 return []; 10605 } 10606 return [[ 10607 'origin_key' => self::ORIGIN_KEY, 10608 'capabilities' => [ 10609 'product_views' => true, 10610 'granularity' => 'daily_aggregate', 10611 'immutable_past_days' => true, 10612 ], 10613 'detection_status' => $inspection['status'], 10614 ]]; 10615 } 10616 10617 public function get_origin_snapshot($origin_key) { 10618 global $wpdb; 10619 if ($origin_key !== self::ORIGIN_KEY) { 10620 return []; 10621 } 10622 $inspection = $this->inspect_table(); 10623 if ($inspection['status'] !== 'available') { 10624 throw new RuntimeException('Page View Count source table is not available or has an incompatible schema.'); 10625 } 10626 10627 $through_date = current_datetime()->modify('-1 day')->format('Y-m-d'); 10628 $posts_table_name = $wpdb->posts; 10629 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 10630 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 10631 } 10632 $posts_table = '`' . $posts_table_name . '`'; 10633 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10634 $max_id = statpl_db_get_var(statpl_db_prepare( 10635 'SELECT MAX(d.id) 10636 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS d 10637 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p 10638 ON p.ID = CAST(d.postnum AS UNSIGNED) 10639 AND p.post_type = %s 10640 WHERE d.postnum REGEXP %s AND d.time <= %s', 10641 'product', 10642 '^[0-9]+$', 10643 $through_date 10644 )); 10645 if (!empty($wpdb->last_error)) { 10646 throw new RuntimeException('Page View Count snapshot query failed.'); 10647 } 10648 10649 return [ 10650 'boundary' => [ 10651 'max_id' => max(0, (int) $max_id), 10652 'through_date' => $through_date, 10653 ], 10654 'initial_checkpoint' => null, 10655 'captured_at' => current_time('mysql'), 10656 ]; 10657 } 10658 10659 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 10660 global $wpdb; 10661 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 10662 throw new RuntimeException('Invalid Page View Count origin or snapshot.'); 10663 } 10664 $inspection = $this->inspect_table(); 10665 if ($inspection['status'] !== 'available') { 10666 throw new RuntimeException('Page View Count source table became unavailable or incompatible.'); 10667 } 10668 10669 $boundary = $snapshot['boundary']; 10670 $max_id = isset($boundary['max_id']) ? absint($boundary['max_id']) : -1; 10671 $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : ''; 10672 $date = DateTime::createFromFormat('!Y-m-d', $through_date); 10673 if ($max_id < 0 || !$date || $date->format('Y-m-d') !== $through_date) { 10674 throw new RuntimeException('Invalid Page View Count snapshot boundary.'); 10675 } 10676 10677 $cursor_id = 0; 10678 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 10679 $cursor_value = $checkpoint['cursor']; 10680 if ($cursor_value !== null) { 10681 $cursor_id = absint($cursor_value); 10682 } 10683 } elseif (is_numeric($checkpoint)) { 10684 $cursor_id = absint($checkpoint); 10685 } 10686 10687 $limit = max(1, min(100, absint($limit))); 10688 $posts_table_name = $wpdb->posts; 10689 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 10690 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 10691 } 10692 $posts_table = '`' . $posts_table_name . '`'; 10693 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10694 $rows = statpl_db_get_results(statpl_db_prepare( 10695 'SELECT MAX(d.id) AS id, d.time, d.postnum, SUM(d.postcount) AS postcount 10696 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS d 10697 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p 10698 ON p.ID = CAST(d.postnum AS UNSIGNED) 10699 AND p.post_type = %s 10700 WHERE d.postnum REGEXP %s 10701 AND d.id <= %d 10702 AND d.time <= %s 10703 GROUP BY d.time, d.postnum 10704 HAVING MAX(d.id) > %d 10705 ORDER BY MAX(d.id) ASC 10706 LIMIT %d', 10707 'product', 10708 '^[0-9]+$', 10709 $max_id, 10710 $through_date, 10711 $cursor_id, 10712 $limit + 1 10713 ), ARRAY_A); 10714 if (!empty($wpdb->last_error) || !is_array($rows)) { 10715 throw new RuntimeException('Page View Count batch query failed.'); 10716 } 10717 10718 $has_more = count($rows) > $limit; 10719 if ($has_more) { 10720 $rows = array_slice($rows, 0, $limit); 10721 } 10722 $next_checkpoint = $cursor_id; 10723 if ($rows) { 10724 $last = end($rows); 10725 $next_checkpoint = absint($last['id']); 10726 } 10727 10728 return [ 10729 'rows' => array_values($rows), 10730 'has_more' => $has_more, 10731 'next_checkpoint' => $next_checkpoint, 10732 'boundary_applied' => true, 10733 ]; 10734 } 10735 10736 public function normalize_record($origin_key, $record) { 10737 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { 10738 return null; 10739 } 10740 $record_id = isset($record['id']) ? absint($record['id']) : 0; 10741 $product_raw = isset($record['postnum']) ? (string) $record['postnum'] : ''; 10742 $views_raw = $record['postcount'] ?? null; 10743 $date = isset($record['time']) ? (string) $record['time'] : ''; 10744 if (!$record_id || !preg_match('/^[1-9][0-9]*$/D', $product_raw) || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { 10745 return null; 10746 } 10747 $parsed_date = DateTime::createFromFormat('!Y-m-d', $date); 10748 $errors = DateTime::getLastErrors(); 10749 if (!$parsed_date || $parsed_date->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { 10750 return null; 10751 } 10752 10753 $product_id = (int) $product_raw; 10754 $views = (int) $views_raw; 10755 $period_start = $date . ' 00:00:00'; 10756 $period_end = $date . ' 23:59:59'; 10757 10758 return [ 10759 'product_id' => $product_id, 10760 'source_record_key' => 'pvc_daily_' . $product_id . '_' . $date, 10761 'views' => $views, 10762 'record_type' => 'aggregate', 10763 'period_start' => $period_start, 10764 'period_end' => $period_end, 10765 'cursor' => $record_id, 10766 ]; 10767 } 10768 } 10769 } 10770 10771 add_filter('statpl_historical_view_adapters', static function ($adapters) { 10772 if (!is_array($adapters)) { 10773 return $adapters; 10774 } 10775 $adapter = new STATPL_Page_View_Count_Historical_Adapter(); 10776 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 10777 return $adapters; 10778 }, 30); 10779 10780 /** 10781 * WP Statistics adapter. 10782 * 10783 * Imports daily rows from statistics_pages where WP Statistics has explicitly 10784 * classified the viewed resource as a WooCommerce product. The historical 10785 * summary table is not read here: it no longer preserves a trustworthy date 10786 * interval after detailed rows have been aggregated. 10787 */ 10788 if (!class_exists('STATPL_WP_Statistics_Historical_Adapter')) { 10789 final class STATPL_WP_Statistics_Historical_Adapter implements STATPL_Historical_View_Adapter { 10790 const ADAPTER_KEY = 'wp_statistics_pages'; 10791 const SOURCE_KEY = 'wp_statistics'; 10792 const ADAPTER_VERSION = '1.0.0'; 10793 const ORIGIN_KEY = 'product_daily_pages'; 10794 10795 public function get_adapter_key() { return self::ADAPTER_KEY; } 10796 public function get_source_key() { return self::SOURCE_KEY; } 10797 public function get_display_name() { return 'WP Statistics'; } 10798 public function get_adapter_version() { return self::ADAPTER_VERSION; } 10799 10800 private function table_name() { 10801 global $wpdb; 10802 return $wpdb->prefix . 'statistics_pages'; 10803 } 10804 10805 private function quoted_table_name() { 10806 $table = $this->table_name(); 10807 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 10808 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 10809 } 10810 return '`' . $table . '`'; 10811 } 10812 10813 private function inspect_table() { 10814 global $wpdb; 10815 $table = $this->table_name(); 10816 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 10817 if (!empty($wpdb->last_error)) { 10818 return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; 10819 } 10820 if ($exists !== $table) { 10821 return ['status' => 'missing_table']; 10822 } 10823 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10824 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 10825 if (!empty($wpdb->last_error) || !is_array($columns)) { 10826 return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; 10827 } 10828 $names = []; 10829 foreach ($columns as $column) { 10830 if (isset($column['Field'])) { $names[] = (string) $column['Field']; } 10831 } 10832 if (array_diff(['page_id', 'uri', 'type', 'date', 'count', 'id'], $names)) { 10833 return ['status' => 'incompatible_schema']; 10834 } 10835 return ['status' => 'available']; 10836 } 10837 10838 public function should_register_adapter() { 10839 global $wpdb; 10840 $inspection = $this->inspect_table(); 10841 if ($inspection['status'] !== 'missing_table') { return true; } 10842 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 10843 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 10844 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 10845 } 10846 $registry_table = '`' . $registry_name . '`'; 10847 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 10848 if (!empty($wpdb->last_error)) { return true; } 10849 if ($registry_exists !== $registry_name) { return false; } 10850 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 10851 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10852 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 10853 return !empty($wpdb->last_error) || !empty($source_id); 10854 } 10855 10856 public function discover_origins() { 10857 $inspection = $this->inspect_table(); 10858 if ($inspection['status'] === 'missing_table') { return []; } 10859 return [[ 10860 'origin_key' => self::ORIGIN_KEY, 10861 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true], 10862 'detection_status' => $inspection['status'], 10863 ]]; 10864 } 10865 10866 public function get_origin_snapshot($origin_key) { 10867 global $wpdb; 10868 if ($origin_key !== self::ORIGIN_KEY) { return []; } 10869 if ($this->inspect_table()['status'] !== 'available') { 10870 throw new RuntimeException('WP Statistics pages table is unavailable or incompatible.'); 10871 } 10872 $through_date = current_datetime()->modify('-1 day')->format('Y-m-d'); 10873 $posts_table_name = $wpdb->posts; 10874 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 10875 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 10876 } 10877 $posts_table = '`' . $posts_table_name . '`'; 10878 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10879 $max_id = statpl_db_get_var(statpl_db_prepare( 10880 'SELECT MAX(s.page_id) 10881 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 10882 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.id AND p.post_type = %s 10883 WHERE s.type = %s AND s.date <= %s', 10884 'product', 'product', $through_date 10885 )); 10886 if (!empty($wpdb->last_error)) { 10887 throw new RuntimeException('WP Statistics snapshot query failed.'); 10888 } 10889 return [ 10890 'boundary' => ['max_page_id' => max(0, (int) $max_id), 'through_date' => $through_date], 10891 'initial_checkpoint' => null, 10892 'captured_at' => current_time('mysql'), 10893 ]; 10894 } 10895 10896 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 10897 global $wpdb; 10898 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 10899 throw new RuntimeException('Invalid WP Statistics origin or snapshot.'); 10900 } 10901 if ($this->inspect_table()['status'] !== 'available') { 10902 throw new RuntimeException('WP Statistics pages table became unavailable or incompatible.'); 10903 } 10904 $boundary = $snapshot['boundary']; 10905 $max_id = isset($boundary['max_page_id']) ? absint($boundary['max_page_id']) : -1; 10906 $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : ''; 10907 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 10908 if ($max_id < 0 || !$parsed || $parsed->format('Y-m-d') !== $through_date) { 10909 throw new RuntimeException('Invalid WP Statistics snapshot boundary.'); 10910 } 10911 $cursor_id = 0; 10912 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 10913 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 10914 } elseif (is_numeric($checkpoint)) { 10915 $cursor_id = absint($checkpoint); 10916 } 10917 $limit = max(1, min(100, absint($limit))); 10918 $posts_table_name = $wpdb->posts; 10919 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 10920 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 10921 } 10922 $posts_table = '`' . $posts_table_name . '`'; 10923 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 10924 $rows = statpl_db_get_results(statpl_db_prepare( 10925 'SELECT MAX(s.page_id) AS page_id, s.date, SUM(s.count) AS count, s.id 10926 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 10927 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.id AND p.post_type = %s 10928 WHERE s.type = %s AND s.page_id <= %d AND s.date <= %s 10929 GROUP BY s.date, s.id 10930 HAVING MAX(s.page_id) > %d 10931 ORDER BY MAX(s.page_id) ASC 10932 LIMIT %d', 10933 'product', 'product', $max_id, $through_date, $cursor_id, $limit + 1 10934 ), ARRAY_A); 10935 if (!empty($wpdb->last_error) || !is_array($rows)) { 10936 throw new RuntimeException('WP Statistics batch query failed.'); 10937 } 10938 $has_more = count($rows) > $limit; 10939 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 10940 $next = $cursor_id; 10941 if ($rows) { $last = end($rows); $next = absint($last['page_id']); } 10942 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 10943 } 10944 10945 public function normalize_record($origin_key, $record) { 10946 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 10947 $record_id = isset($record['page_id']) ? absint($record['page_id']) : 0; 10948 $product_id = isset($record['id']) ? absint($record['id']) : 0; 10949 $views_raw = $record['count'] ?? null; 10950 $date = isset($record['date']) ? (string) $record['date'] : ''; 10951 if (!$record_id || !$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 10952 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 10953 $errors = DateTime::getLastErrors(); 10954 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 10955 $views = (int) $views_raw; 10956 $period_start = $date . ' 00:00:00'; 10957 $period_end = $date . ' 23:59:59'; 10958 return [ 10959 'product_id' => $product_id, 10960 'source_record_key' => 'wp_statistics_product_' . $product_id . '_' . $date, 10961 'views' => $views, 10962 'record_type' => 'aggregate', 10963 'period_start' => $period_start, 10964 'period_end' => $period_end, 10965 'cursor' => $record_id, 10966 ]; 10967 } 10968 } 10969 } 10970 10971 /** 10972 * Post Views Counter adapter. 10973 * 10974 * Reads only type=0 daily rows from wp_post_views. Week, month, year and total 10975 * rows are deliberately excluded because they are overlapping rollups of the 10976 * same views. Past-day rows are used so the source counts are stable while the 10977 * importer runs; today's row is still mutable and is left to live tracking. 10978 */ 10979 if (!class_exists('STATPL_Post_Views_Counter_Historical_Adapter')) { 10980 final class STATPL_Post_Views_Counter_Historical_Adapter implements STATPL_Historical_View_Adapter { 10981 const ADAPTER_KEY = 'post_views_counter'; 10982 const SOURCE_KEY = 'post_views_counter'; 10983 const ADAPTER_VERSION = '1.0.0'; 10984 const ORIGIN_KEY = 'daily_product_views'; 10985 10986 public function get_adapter_key() { return self::ADAPTER_KEY; } 10987 public function get_source_key() { return self::SOURCE_KEY; } 10988 public function get_display_name() { return 'Post Views Counter'; } 10989 public function get_adapter_version() { return self::ADAPTER_VERSION; } 10990 10991 private function table_name() { 10992 global $wpdb; 10993 return $wpdb->prefix . 'post_views'; 10994 } 10995 10996 private function quoted_table_name() { 10997 $table = $this->table_name(); 10998 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 10999 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11000 } 11001 return '`' . $table . '`'; 11002 } 11003 11004 private function inspect_table() { 11005 global $wpdb; 11006 $table = $this->table_name(); 11007 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11008 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11009 if ($exists !== $table) { return ['status' => 'missing_table']; } 11010 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11011 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11012 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11013 $names = []; 11014 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = (string) $column['Field']; } } 11015 if (array_diff(['id', 'type', 'period', 'count'], $names)) { return ['status' => 'incompatible_schema']; } 11016 return ['status' => 'available']; 11017 } 11018 11019 public function should_register_adapter() { 11020 global $wpdb; 11021 $inspection = $this->inspect_table(); 11022 if ($inspection['status'] !== 'missing_table') { return true; } 11023 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11024 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11025 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11026 } 11027 $registry_table = '`' . $registry_name . '`'; 11028 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11029 if (!empty($wpdb->last_error)) { return true; } 11030 if ($registry_exists !== $registry_name) { return false; } 11031 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11032 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11033 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11034 return !empty($wpdb->last_error) || !empty($source_id); 11035 } 11036 11037 public function discover_origins() { 11038 $inspection = $this->inspect_table(); 11039 if ($inspection['status'] === 'missing_table') { return []; } 11040 return [[ 11041 'origin_key' => self::ORIGIN_KEY, 11042 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true], 11043 'detection_status' => $inspection['status'], 11044 ]]; 11045 } 11046 11047 public function get_origin_snapshot($origin_key) { 11048 global $wpdb; 11049 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11050 if ($this->inspect_table()['status'] !== 'available') { 11051 throw new RuntimeException('Post Views Counter table is unavailable or incompatible.'); 11052 } 11053 $through_date = current_datetime()->modify('-1 day')->format('Ymd'); 11054 $posts_table_name = $wpdb->posts; 11055 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11056 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11057 } 11058 $posts_table = '`' . $posts_table_name . '`'; 11059 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The query has four fixed value placeholders and receives four values; the two table identifiers are independently validated SQL identifiers. 11060 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11061 $max_period = statpl_db_get_var(statpl_db_prepare( 11062 'SELECT MAX(v.period) 11063 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS v 11064 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.id AND p.post_type = %s 11065 WHERE v.type = %d AND v.period REGEXP %s AND v.period <= %s', 11066 'product', 0, '^[0-9]{8}$', $through_date 11067 )); 11068 if (!empty($wpdb->last_error)) { 11069 throw new RuntimeException('Post Views Counter snapshot query failed.'); 11070 } 11071 if (!is_string($max_period) || !preg_match('/^[0-9]{8}$/D', $max_period)) { $max_period = ''; } 11072 return [ 11073 'boundary' => ['max_period' => $max_period], 11074 'initial_checkpoint' => null, 11075 'captured_at' => current_time('mysql'), 11076 ]; 11077 } 11078 11079 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11080 global $wpdb; 11081 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11082 throw new RuntimeException('Invalid Post Views Counter origin or snapshot.'); 11083 } 11084 if ($this->inspect_table()['status'] !== 'available') { 11085 throw new RuntimeException('Post Views Counter table became unavailable or incompatible.'); 11086 } 11087 $max_period = isset($snapshot['boundary']['max_period']) ? (string) $snapshot['boundary']['max_period'] : ''; 11088 if ($max_period !== '' && !preg_match('/^[0-9]{8}$/D', $max_period)) { throw new RuntimeException('Invalid Post Views Counter snapshot boundary.'); } 11089 $cursor_period = ''; 11090 $cursor_id = 0; 11091 if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) { 11092 $cursor_period = isset($checkpoint['cursor']['period']) ? (string) $checkpoint['cursor']['period'] : ''; 11093 $cursor_id = isset($checkpoint['cursor']['id']) ? absint($checkpoint['cursor']['id']) : 0; 11094 } 11095 $limit = max(1, min(100, absint($limit))); 11096 $posts_table_name = $wpdb->posts; 11097 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11098 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11099 } 11100 $posts_table = '`' . $posts_table_name . '`'; 11101 if ($max_period === '') { $rows = []; } 11102 else { 11103 $cursor_sql = ''; 11104 $args = ['product', 0, '^[0-9]{8}$', $max_period]; 11105 if ($cursor_period !== '') { 11106 if (!preg_match('/^[0-9]{8}$/D', $cursor_period)) { throw new RuntimeException('Invalid Post Views Counter cursor.'); } 11107 $cursor_sql = ' AND (v.period > %s OR (v.period = %s AND v.id > %d))'; 11108 $args[] = $cursor_period; 11109 $args[] = $cursor_period; 11110 $args[] = $cursor_id; 11111 } 11112 $batch_sql = 'SELECT v.id, v.period, v.count 11113 FROM ' . $this->quoted_table_name() . ' AS v 11114 INNER JOIN ' . $posts_table . ' AS p ON p.ID = v.id AND p.post_type = %s 11115 WHERE v.type = %d AND v.period REGEXP %s AND v.period <= %s' . $cursor_sql . ' 11116 ORDER BY v.period ASC, v.id ASC 11117 LIMIT %d'; 11118 if ($cursor_period !== '') { 11119 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11120 $rows = statpl_db_get_results(statpl_db_prepare( 11121 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11122 'product', 0, '^[0-9]{8}$', $max_period, 11123 $cursor_period, $cursor_period, $cursor_id, $limit + 1 11124 ), ARRAY_A); 11125 } else { 11126 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11127 $rows = statpl_db_get_results(statpl_db_prepare( 11128 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11129 'product', 0, '^[0-9]{8}$', $max_period, $limit + 1 11130 ), ARRAY_A); 11131 } 11132 } 11133 if (!empty($wpdb->last_error) || !is_array($rows)) { 11134 throw new RuntimeException('Post Views Counter batch query failed.'); 11135 } 11136 $has_more = count($rows) > $limit; 11137 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11138 $next = ($cursor_period !== '') ? ['period' => $cursor_period, 'id' => $cursor_id] : null; 11139 if ($rows) { $last = end($rows); $next = ['period' => (string) $last['period'], 'id' => absint($last['id'])]; } 11140 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11141 } 11142 11143 public function normalize_record($origin_key, $record) { 11144 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11145 $product_id = isset($record['id']) ? absint($record['id']) : 0; 11146 $period = isset($record['period']) ? (string) $record['period'] : ''; 11147 $views_raw = $record['count'] ?? null; 11148 if (!$product_id || !preg_match('/^[0-9]{8}$/D', $period) || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 11149 $parsed = DateTime::createFromFormat('!Ymd', $period); 11150 $errors = DateTime::getLastErrors(); 11151 if (!$parsed || $parsed->format('Ymd') !== $period || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 11152 $date = $parsed->format('Y-m-d'); 11153 $views = (int) $views_raw; 11154 $period_start = $date . ' 00:00:00'; 11155 $period_end = $date . ' 23:59:59'; 11156 return [ 11157 'product_id' => $product_id, 11158 'source_record_key' => 'post_views_counter_day_' . $period . '_' . $product_id, 11159 'views' => $views, 11160 'record_type' => 'aggregate', 11161 'period_start' => $period_start, 11162 'period_end' => $period_end, 11163 'cursor' => ['period' => $period, 'id' => $product_id], 11164 ]; 11165 } 11166 } 11167 } 11168 11169 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11170 if (!is_array($adapters)) { return $adapters; } 11171 $wp_statistics = new STATPL_WP_Statistics_Historical_Adapter(); 11172 if ($wp_statistics->should_register_adapter()) { $adapters[] = $wp_statistics; } 11173 $post_views_counter = new STATPL_Post_Views_Counter_Historical_Adapter(); 11174 if ($post_views_counter->should_register_adapter()) { $adapters[] = $post_views_counter; } 11175 return $adapters; 11176 }, 31); 11177 11178 /** 11179 * Statify adapter. 11180 * 11181 * Statify stores one immutable row per page view, but its schema contains only 11182 * a calendar date and target URL (not a timestamp or WordPress post ID). The 11183 * adapter resolves same-site targets to published WooCommerce products and 11184 * explicitly skips non-product rows. It does not issue cross-source fingerprints: 11185 * a date-only row cannot be safely matched one-to-one against another provider's 11186 * event stream without risking suppression of legitimate repeat views. 11187 */ 11188 if (!class_exists('STATPL_Statify_Historical_Adapter')) { 11189 final class STATPL_Statify_Historical_Adapter implements STATPL_Historical_View_Adapter { 11190 const ADAPTER_KEY = 'statify'; 11191 const SOURCE_KEY = 'statify'; 11192 const ADAPTER_VERSION = '1.0.0'; 11193 const ORIGIN_KEY = 'page_view_events'; 11194 11195 public function get_adapter_key() { return self::ADAPTER_KEY; } 11196 public function get_source_key() { return self::SOURCE_KEY; } 11197 public function get_display_name() { return 'Statify'; } 11198 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11199 11200 private function table_name() { 11201 global $wpdb; 11202 return $wpdb->prefix . 'statify'; 11203 } 11204 11205 private function quoted_table_name() { 11206 $table = $this->table_name(); 11207 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11208 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11209 } 11210 return '`' . $table . '`'; 11211 } 11212 11213 private function inspect_table() { 11214 global $wpdb; 11215 $table = $this->table_name(); 11216 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11217 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11218 if ($exists !== $table) { return ['status' => 'missing_table']; } 11219 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11220 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11221 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11222 $names = []; 11223 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = (string) $column['Field']; } } 11224 if (array_diff(['id', 'created', 'referrer', 'target'], $names)) { return ['status' => 'incompatible_schema']; } 11225 return ['status' => 'available']; 11226 } 11227 11228 public function should_register_adapter() { 11229 global $wpdb; 11230 $inspection = $this->inspect_table(); 11231 if ($inspection['status'] !== 'missing_table') { return true; } 11232 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11233 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11234 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11235 } 11236 $registry_table = '`' . $registry_name . '`'; 11237 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11238 if (!empty($wpdb->last_error)) { return true; } 11239 if ($registry_exists !== $registry_name) { return false; } 11240 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11241 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11242 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11243 return !empty($wpdb->last_error) || !empty($source_id); 11244 } 11245 11246 public function discover_origins() { 11247 $inspection = $this->inspect_table(); 11248 if ($inspection['status'] === 'missing_table') { return []; } 11249 return [[ 11250 'origin_key' => self::ORIGIN_KEY, 11251 'capabilities' => ['product_views' => true, 'granularity' => 'event_date_only', 'target_requires_product_resolution' => true], 11252 'detection_status' => $inspection['status'], 11253 ]]; 11254 } 11255 11256 public function get_origin_snapshot($origin_key) { 11257 global $wpdb; 11258 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11259 if ($this->inspect_table()['status'] !== 'available') { 11260 throw new RuntimeException('Statify table is unavailable or incompatible.'); 11261 } 11262 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11263 $max_id = statpl_db_get_var('SELECT MAX(id) FROM ' . $this->quoted_table_name()); 11264 if (!empty($wpdb->last_error)) { 11265 throw new RuntimeException('Statify snapshot query failed.'); 11266 } 11267 return [ 11268 'boundary' => ['max_id' => max(0, (int) $max_id)], 11269 'initial_checkpoint' => null, 11270 'captured_at' => current_time('mysql'), 11271 ]; 11272 } 11273 11274 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11275 global $wpdb; 11276 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11277 throw new RuntimeException('Invalid Statify origin or snapshot.'); 11278 } 11279 if ($this->inspect_table()['status'] !== 'available') { 11280 throw new RuntimeException('Statify table became unavailable or incompatible.'); 11281 } 11282 $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1; 11283 if ($max_id < 0) { throw new RuntimeException('Invalid Statify snapshot boundary.'); } 11284 $cursor_id = 0; 11285 if (is_array($checkpoint) && array_key_exists('cursor', $checkpoint)) { 11286 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 11287 } elseif (is_numeric($checkpoint)) { 11288 $cursor_id = absint($checkpoint); 11289 } 11290 $limit = max(1, min(100, absint($limit))); 11291 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11292 $rows = statpl_db_get_results(statpl_db_prepare( 11293 'SELECT id, created, referrer, target 11294 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' 11295 WHERE id > %d AND id <= %d 11296 ORDER BY id ASC 11297 LIMIT %d', 11298 $cursor_id, 11299 $max_id, 11300 $limit + 1 11301 ), ARRAY_A); 11302 if (!empty($wpdb->last_error) || !is_array($rows)) { 11303 throw new RuntimeException('Statify batch query failed.'); 11304 } 11305 $has_more = count($rows) > $limit; 11306 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11307 $next = $cursor_id; 11308 if ($rows) { $last = end($rows); $next = absint($last['id']); } 11309 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11310 } 11311 11312 private function resolve_product_id($target) { 11313 if (!is_string($target) || trim($target) === '' || !function_exists('url_to_postid')) { return 0; } 11314 $target = trim($target); 11315 if (preg_match('#^https?://#i', $target)) { 11316 $url = $target; 11317 } elseif (strpos($target, '//') === 0) { 11318 return 0; 11319 } else { 11320 $url = home_url('/' . ltrim($target, '/')); 11321 } 11322 $target_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); 11323 $home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); 11324 if ($target_host === '' || $home_host === '' || $target_host !== $home_host) { return 0; } 11325 $product_id = absint(url_to_postid($url)); 11326 if (!$product_id || get_post_type($product_id) !== 'product') { return 0; } 11327 return $product_id; 11328 } 11329 11330 public function normalize_record($origin_key, $record) { 11331 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11332 $record_id = isset($record['id']) ? absint($record['id']) : 0; 11333 $record_key = $record_id ? 'statify_view_' . $record_id : ''; 11334 $cursor = $record_id; 11335 if (!$record_id) { return null; } 11336 $date = isset($record['created']) ? (string) $record['created'] : ''; 11337 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 11338 $errors = DateTime::getLastErrors(); 11339 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { 11340 return null; 11341 } 11342 $product_id = $this->resolve_product_id(isset($record['target']) ? (string) $record['target'] : ''); 11343 if (!$product_id) { 11344 return ['skip' => true, 'source_record_key' => $record_key, 'cursor' => $cursor]; 11345 } 11346 $referrer = isset($record['referrer']) ? (string) $record['referrer'] : ''; 11347 return [ 11348 'product_id' => $product_id, 11349 'source_record_key' => $record_key, 11350 'views' => 1, 11351 'record_type' => 'event', 11352 'period_start' => $date . ' 00:00:00', 11353 'period_end' => $date . ' 23:59:59', 11354 'cursor' => $cursor, 11355 'source_referrer_hash' => hash('sha256', $referrer), 11356 ]; 11357 } 11358 11359 public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) { 11360 return [ 11361 'source' => 'statify', 11362 'date_precision' => 'calendar_day_only', 11363 'referrer_sha256' => isset($normalized_record['source_referrer_hash']) ? $normalized_record['source_referrer_hash'] : null, 11364 'overlap_assessment' => 'possible_but_not_proven', 11365 'automatic_suppression' => false, 11366 'reason' => 'Statify has no per-view timestamp or stable cross-provider event identifier; a one-to-one match would risk suppressing legitimate repeat views.', 11367 ]; 11368 } 11369 } 11370 } 11371 11372 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11373 if (!is_array($adapters)) { return $adapters; } 11374 $adapter = new STATPL_Statify_Historical_Adapter(); 11375 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 11376 return $adapters; 11377 }, 32); 11378 11379 /** 11380 * WP Popular Posts adapter. 11381 * 11382 * Imports the per-post/per-day summary table only. The all-time data table is 11383 * deliberately excluded because it overlaps the daily summary and carries no 11384 * date range. WPP's configurable log retention determines how much daily 11385 * history remains available at the time Statora captures its snapshot. 11386 */ 11387 if (!class_exists('STATPL_WP_Popular_Posts_Historical_Adapter')) { 11388 final class STATPL_WP_Popular_Posts_Historical_Adapter implements STATPL_Historical_View_Adapter { 11389 const ADAPTER_KEY = 'wp_popular_posts'; 11390 const SOURCE_KEY = 'wp_popular_posts'; 11391 const ADAPTER_VERSION = '1.0.0'; 11392 const ORIGIN_KEY = 'product_daily_summary'; 11393 11394 public function get_adapter_key() { return self::ADAPTER_KEY; } 11395 public function get_source_key() { return self::SOURCE_KEY; } 11396 public function get_display_name() { return 'WP Popular Posts'; } 11397 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11398 11399 private function table_name() { 11400 global $wpdb; 11401 return $wpdb->prefix . 'popularpostssummary'; 11402 } 11403 11404 private function quoted_table_name() { 11405 $table = $this->table_name(); 11406 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11407 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11408 } 11409 return '`' . $table . '`'; 11410 } 11411 11412 private function inspect_table() { 11413 global $wpdb; 11414 $table = $this->table_name(); 11415 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11416 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11417 if ($exists !== $table) { return ['status' => 'missing_table']; } 11418 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11419 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11420 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11421 $names = []; 11422 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 11423 if (array_diff(['id', 'postid', 'pageviews', 'view_date', 'last_viewed'], $names)) { return ['status' => 'incompatible_schema']; } 11424 return ['status' => 'available']; 11425 } 11426 11427 public function should_register_adapter() { 11428 global $wpdb; 11429 $inspection = $this->inspect_table(); 11430 if ($inspection['status'] !== 'missing_table') { return true; } 11431 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11432 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11433 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11434 } 11435 $registry_table = '`' . $registry_name . '`'; 11436 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11437 if (!empty($wpdb->last_error)) { return true; } 11438 if ($registry_exists !== $registry_name) { return false; } 11439 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11440 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11441 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11442 return !empty($wpdb->last_error) || !empty($source_id); 11443 } 11444 11445 public function discover_origins() { 11446 $inspection = $this->inspect_table(); 11447 if ($inspection['status'] === 'missing_table') { return []; } 11448 return [[ 11449 'origin_key' => self::ORIGIN_KEY, 11450 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true], 11451 'detection_status' => $inspection['status'], 11452 ]]; 11453 } 11454 11455 public function get_origin_snapshot($origin_key) { 11456 global $wpdb; 11457 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11458 if ($this->inspect_table()['status'] !== 'available') { 11459 throw new RuntimeException('WP Popular Posts summary table is unavailable or incompatible.'); 11460 } 11461 $through_date = current_datetime()->modify('-1 day')->format('Y-m-d'); 11462 $posts_table_name = $wpdb->posts; 11463 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11464 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11465 } 11466 $posts_table = '`' . $posts_table_name . '`'; 11467 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11468 $max_id = statpl_db_get_var(statpl_db_prepare( 11469 'SELECT MAX(s.ID) 11470 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 11471 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.postid AND p.post_type = %s 11472 WHERE s.view_date <= %s', 11473 'product', $through_date 11474 )); 11475 if (!empty($wpdb->last_error)) { 11476 throw new RuntimeException('WP Popular Posts snapshot query failed.'); 11477 } 11478 return [ 11479 'boundary' => ['max_id' => max(0, (int) $max_id), 'through_date' => $through_date], 11480 'initial_checkpoint' => null, 11481 'captured_at' => current_time('mysql'), 11482 ]; 11483 } 11484 11485 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11486 global $wpdb; 11487 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11488 throw new RuntimeException('Invalid WP Popular Posts origin or snapshot.'); 11489 } 11490 if ($this->inspect_table()['status'] !== 'available') { 11491 throw new RuntimeException('WP Popular Posts summary table became unavailable or incompatible.'); 11492 } 11493 $boundary = $snapshot['boundary']; 11494 $max_id = isset($boundary['max_id']) ? absint($boundary['max_id']) : -1; 11495 $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : ''; 11496 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 11497 if ($max_id < 0 || !$parsed || $parsed->format('Y-m-d') !== $through_date) { 11498 throw new RuntimeException('Invalid WP Popular Posts snapshot boundary.'); 11499 } 11500 $cursor_id = 0; 11501 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 11502 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 11503 } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); } 11504 $limit = max(1, min(100, absint($limit))); 11505 $posts_table_name = $wpdb->posts; 11506 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11507 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11508 } 11509 $posts_table = '`' . $posts_table_name . '`'; 11510 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11511 $rows = statpl_db_get_results(statpl_db_prepare( 11512 'SELECT s.ID AS id, s.postid, s.pageviews, s.view_date 11513 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 11514 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.postid AND p.post_type = %s 11515 WHERE s.ID > %d AND s.ID <= %d AND s.view_date <= %s 11516 ORDER BY s.ID ASC 11517 LIMIT %d', 11518 'product', $cursor_id, $max_id, $through_date, $limit + 1 11519 ), ARRAY_A); 11520 if (!empty($wpdb->last_error) || !is_array($rows)) { 11521 throw new RuntimeException('WP Popular Posts batch query failed.'); 11522 } 11523 $has_more = count($rows) > $limit; 11524 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11525 $next = $cursor_id; 11526 if ($rows) { $last = end($rows); $next = absint($last['id']); } 11527 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11528 } 11529 11530 public function normalize_record($origin_key, $record) { 11531 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11532 $record_id = isset($record['id']) ? absint($record['id']) : 0; 11533 $product_id = isset($record['postid']) ? absint($record['postid']) : 0; 11534 $views_raw = $record['pageviews'] ?? null; 11535 $date = isset($record['view_date']) ? (string) $record['view_date'] : ''; 11536 if (!$record_id || !$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 11537 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 11538 $errors = DateTime::getLastErrors(); 11539 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 11540 $views = (int) $views_raw; 11541 $period_start = $date . ' 00:00:00'; 11542 $period_end = $date . ' 23:59:59'; 11543 return [ 11544 'product_id' => $product_id, 11545 'source_record_key' => 'wp_popular_posts_' . $product_id . '_' . $date, 11546 'views' => $views, 11547 'record_type' => 'aggregate', 11548 'period_start' => $period_start, 11549 'period_end' => $period_end, 11550 'cursor' => $record_id, 11551 ]; 11552 } 11553 } 11554 } 11555 11556 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11557 if (!is_array($adapters)) { return $adapters; } 11558 $adapter = new STATPL_WP_Popular_Posts_Historical_Adapter(); 11559 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 11560 return $adapters; 11561 }, 33); 11562 11563 /** 11564 * WebberZone Top 10 adapter. 11565 * 11566 * Imports only the per-post hourly rows from top_ten_daily, grouped into local 11567 * calendar-day totals. The all-time table, visits funnel and visits log are not 11568 * imported in parallel: the funnel/log feed the same daily and lifetime counts. 11569 * A two-day safety lag allows Top 10's background aggregation cron to drain 11570 * completed days before the snapshot is taken. 11571 */ 11572 if (!class_exists('STATPL_Top_Ten_Historical_Adapter')) { 11573 final class STATPL_Top_Ten_Historical_Adapter implements STATPL_Historical_View_Adapter { 11574 const ADAPTER_KEY = 'webberzone_top_10'; 11575 const SOURCE_KEY = 'webberzone_top_10'; 11576 const ADAPTER_VERSION = '1.0.0'; 11577 const ORIGIN_KEY = 'product_daily_counts'; 11578 11579 public function get_adapter_key() { return self::ADAPTER_KEY; } 11580 public function get_source_key() { return self::SOURCE_KEY; } 11581 public function get_display_name() { return 'WebberZone Top 10'; } 11582 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11583 11584 private function table_name() { 11585 global $wpdb; 11586 return $wpdb->base_prefix . 'top_ten_daily'; 11587 } 11588 11589 private function quoted_table_name() { 11590 $table = $this->table_name(); 11591 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11592 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11593 } 11594 return '`' . $table . '`'; 11595 } 11596 11597 private function inspect_table() { 11598 global $wpdb; 11599 $table = $this->table_name(); 11600 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11601 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11602 if ($exists !== $table) { return ['status' => 'missing_table']; } 11603 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11604 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11605 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11606 $names = []; 11607 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 11608 if (array_diff(['postnumber', 'cntaccess', 'dp_date', 'blog_id'], $names)) { return ['status' => 'incompatible_schema']; } 11609 return ['status' => 'available', 'has_feed_column' => in_array('feedcnt', $names, true)]; 11610 } 11611 11612 public function should_register_adapter() { 11613 global $wpdb; 11614 $inspection = $this->inspect_table(); 11615 if ($inspection['status'] !== 'missing_table') { return true; } 11616 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11617 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11618 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11619 } 11620 $registry_table = '`' . $registry_name . '`'; 11621 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11622 if (!empty($wpdb->last_error)) { return true; } 11623 if ($registry_exists !== $registry_name) { return false; } 11624 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11625 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11626 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11627 return !empty($wpdb->last_error) || !empty($source_id); 11628 } 11629 11630 public function discover_origins() { 11631 $inspection = $this->inspect_table(); 11632 if ($inspection['status'] === 'missing_table') { return []; } 11633 return [[ 11634 'origin_key' => self::ORIGIN_KEY, 11635 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'source_storage_granularity' => 'hourly', 'immutable_past_days' => true], 11636 'detection_status' => $inspection['status'], 11637 ]]; 11638 } 11639 11640 public function get_origin_snapshot($origin_key) { 11641 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11642 if ($this->inspect_table()['status'] !== 'available') { 11643 throw new RuntimeException('Top 10 daily table is unavailable or incompatible.'); 11644 } 11645 $through_date = current_datetime()->modify('-2 days')->format('Y-m-d'); 11646 return [ 11647 'boundary' => ['through_date' => $through_date, 'blog_id' => absint(get_current_blog_id())], 11648 'initial_checkpoint' => null, 11649 'captured_at' => current_time('mysql'), 11650 ]; 11651 } 11652 11653 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11654 global $wpdb; 11655 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11656 throw new RuntimeException('Invalid Top 10 origin or snapshot.'); 11657 } 11658 if ($this->inspect_table()['status'] !== 'available') { 11659 throw new RuntimeException('Top 10 daily table became unavailable or incompatible.'); 11660 } 11661 $through_date = isset($snapshot['boundary']['through_date']) ? (string) $snapshot['boundary']['through_date'] : ''; 11662 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 11663 $blog_id = isset($snapshot['boundary']['blog_id']) ? absint($snapshot['boundary']['blog_id']) : 0; 11664 if (!$parsed || $parsed->format('Y-m-d') !== $through_date || !$blog_id) { 11665 throw new RuntimeException('Invalid Top 10 snapshot boundary.'); 11666 } 11667 $cursor_date = ''; 11668 $cursor_product = 0; 11669 if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) { 11670 $cursor_date = isset($checkpoint['cursor']['date']) ? (string) $checkpoint['cursor']['date'] : ''; 11671 $cursor_product = isset($checkpoint['cursor']['product_id']) ? absint($checkpoint['cursor']['product_id']) : 0; 11672 } 11673 if ($cursor_date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $cursor_date) || !DateTime::createFromFormat('!Y-m-d', $cursor_date))) { 11674 throw new RuntimeException('Invalid Top 10 cursor date.'); 11675 } 11676 $limit = max(1, min(100, absint($limit))); 11677 $posts_table_name = $wpdb->posts; 11678 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11679 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11680 } 11681 $posts_table = '`' . $posts_table_name . '`'; 11682 $inspection = $this->inspect_table(); 11683 $view_sum_sql = !empty($inspection['has_feed_column']) 11684 ? 'SUM(GREATEST(t.cntaccess - t.feedcnt, 0))' 11685 : 'SUM(t.cntaccess)'; 11686 if (!in_array($view_sum_sql, ['SUM(GREATEST(t.cntaccess - t.feedcnt, 0))', 'SUM(t.cntaccess)'], true)) { 11687 throw new RuntimeException('Unsupported Top 10 view aggregation expression.'); 11688 } 11689 $cursor_sql = ''; 11690 $args = ['product', $blog_id, $through_date]; 11691 if ($cursor_date !== '') { 11692 $cursor_sql = ' HAVING (view_date > %s OR (view_date = %s AND product_id > %d))'; 11693 $args[] = $cursor_date; 11694 $args[] = $cursor_date; 11695 $args[] = $cursor_product; 11696 } 11697 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $view_sum_sql is selected from two fixed aggregate expressions and checked against the same strict allowlist; all runtime values remain prepared placeholders. 11698 $batch_sql = 'SELECT DATE(t.dp_date) AS view_date, t.postnumber AS product_id, ' . $view_sum_sql . ' AS views 11699 FROM ' . $this->quoted_table_name() . ' AS t 11700 INNER JOIN ' . $posts_table . ' AS p ON p.ID = t.postnumber AND p.post_type = %s 11701 WHERE t.blog_id = %d AND t.dp_date < DATE_ADD(%s, INTERVAL 1 DAY) 11702 GROUP BY DATE(t.dp_date), t.postnumber' . $cursor_sql . ' 11703 ORDER BY view_date ASC, product_id ASC 11704 LIMIT %d'; 11705 if ($cursor_date !== '') { 11706 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11707 $rows = statpl_db_get_results(statpl_db_prepare( 11708 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11709 'product', $blog_id, $through_date, 11710 $cursor_date, $cursor_date, $cursor_product, $limit + 1 11711 ), ARRAY_A); 11712 } else { 11713 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11714 $rows = statpl_db_get_results(statpl_db_prepare( 11715 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11716 'product', $blog_id, $through_date, $limit + 1 11717 ), ARRAY_A); 11718 } 11719 if (!empty($wpdb->last_error) || !is_array($rows)) { 11720 throw new RuntimeException('Top 10 batch query failed.'); 11721 } 11722 $has_more = count($rows) > $limit; 11723 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11724 $next = ($cursor_date !== '') ? ['date' => $cursor_date, 'product_id' => $cursor_product] : null; 11725 if ($rows) { $last = end($rows); $next = ['date' => (string) $last['view_date'], 'product_id' => absint($last['product_id'])]; } 11726 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11727 } 11728 11729 public function normalize_record($origin_key, $record) { 11730 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11731 $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0; 11732 $date = isset($record['view_date']) ? (string) $record['view_date'] : ''; 11733 $views_raw = $record['views'] ?? null; 11734 if (!$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 11735 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 11736 $errors = DateTime::getLastErrors(); 11737 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 11738 $views = (int) $views_raw; 11739 $period_start = $date . ' 00:00:00'; 11740 $period_end = $date . ' 23:59:59'; 11741 return [ 11742 'product_id' => $product_id, 11743 'source_record_key' => 'top_10_' . $product_id . '_' . $date, 11744 'views' => $views, 11745 'record_type' => 'aggregate', 11746 'period_start' => $period_start, 11747 'period_end' => $period_end, 11748 'cursor' => ['date' => $date, 'product_id' => $product_id], 11749 ]; 11750 } 11751 } 11752 } 11753 11754 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11755 if (!is_array($adapters)) { return $adapters; } 11756 $adapter = new STATPL_Top_Ten_Historical_Adapter(); 11757 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 11758 return $adapters; 11759 }, 34); 11760 11761 /** 11762 * Koko Analytics adapter. 11763 * 11764 * Reads the per-page daily statistics table. Current Koko schemas use path_id 11765 * and post_id; older schemas used id directly as the WordPress post ID. Rows 11766 * are grouped by product/date because one product can be represented by more 11767 * than one canonicalized path. Only completed days are imported. 11768 */ 11769 if (!class_exists('STATPL_Koko_Analytics_Historical_Adapter')) { 11770 final class STATPL_Koko_Analytics_Historical_Adapter implements STATPL_Historical_View_Adapter { 11771 const ADAPTER_KEY = 'koko_analytics'; 11772 const SOURCE_KEY = 'koko_analytics'; 11773 const ADAPTER_VERSION = '1.0.0'; 11774 const ORIGIN_KEY = 'product_daily_stats'; 11775 11776 public function get_adapter_key() { return self::ADAPTER_KEY; } 11777 public function get_source_key() { return self::SOURCE_KEY; } 11778 public function get_display_name() { return 'Koko Analytics'; } 11779 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11780 11781 private function table_name() { 11782 global $wpdb; 11783 return $wpdb->prefix . 'koko_analytics_post_stats'; 11784 } 11785 11786 private function quoted_table_name() { 11787 $table = $this->table_name(); 11788 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11789 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11790 } 11791 return '`' . $table . '`'; 11792 } 11793 11794 private function inspect_table() { 11795 global $wpdb; 11796 $table = $this->table_name(); 11797 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 11798 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11799 if ($exists !== $table) { return ['status' => 'missing_table']; } 11800 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11801 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 11802 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 11803 $names = []; 11804 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 11805 if (!in_array('date', $names, true) || !in_array('pageviews', $names, true)) { return ['status' => 'incompatible_schema']; } 11806 if (in_array('post_id', $names, true) && in_array('path_id', $names, true)) { 11807 return ['status' => 'available', 'schema_mode' => 'path_id']; 11808 } 11809 if (in_array('id', $names, true)) { 11810 return ['status' => 'available', 'schema_mode' => 'legacy_post_id']; 11811 } 11812 return ['status' => 'incompatible_schema']; 11813 } 11814 11815 public function should_register_adapter() { 11816 global $wpdb; 11817 $inspection = $this->inspect_table(); 11818 if ($inspection['status'] !== 'missing_table') { return true; } 11819 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 11820 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 11821 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 11822 } 11823 $registry_table = '`' . $registry_name . '`'; 11824 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 11825 if (!empty($wpdb->last_error)) { return true; } 11826 if ($registry_exists !== $registry_name) { return false; } 11827 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 11828 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11829 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 11830 return !empty($wpdb->last_error) || !empty($source_id); 11831 } 11832 11833 public function discover_origins() { 11834 $inspection = $this->inspect_table(); 11835 if ($inspection['status'] === 'missing_table') { return []; } 11836 return [[ 11837 'origin_key' => self::ORIGIN_KEY, 11838 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'schema_mode' => isset($inspection['schema_mode']) ? $inspection['schema_mode'] : 'unknown'], 11839 'detection_status' => $inspection['status'], 11840 ]]; 11841 } 11842 11843 public function get_origin_snapshot($origin_key) { 11844 if ($origin_key !== self::ORIGIN_KEY) { return []; } 11845 $inspection = $this->inspect_table(); 11846 if ($inspection['status'] !== 'available') { 11847 throw new RuntimeException('Koko Analytics post statistics table is unavailable or incompatible.'); 11848 } 11849 return [ 11850 'boundary' => [ 11851 'through_date' => current_datetime()->modify('-1 day')->format('Y-m-d'), 11852 'schema_mode' => $inspection['schema_mode'], 11853 ], 11854 'initial_checkpoint' => null, 11855 'captured_at' => current_time('mysql'), 11856 ]; 11857 } 11858 11859 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 11860 global $wpdb; 11861 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 11862 throw new RuntimeException('Invalid Koko Analytics origin or snapshot.'); 11863 } 11864 $inspection = $this->inspect_table(); 11865 if ($inspection['status'] !== 'available') { 11866 throw new RuntimeException('Koko Analytics post statistics table became unavailable or incompatible.'); 11867 } 11868 $boundary = $snapshot['boundary']; 11869 $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : ''; 11870 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 11871 $schema_mode = isset($boundary['schema_mode']) ? (string) $boundary['schema_mode'] : ''; 11872 if (!$parsed || $parsed->format('Y-m-d') !== $through_date || $schema_mode !== $inspection['schema_mode']) { 11873 throw new RuntimeException('Invalid or changed Koko Analytics snapshot boundary.'); 11874 } 11875 $cursor_date = ''; 11876 $cursor_product = 0; 11877 if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) { 11878 $cursor_date = isset($checkpoint['cursor']['date']) ? (string) $checkpoint['cursor']['date'] : ''; 11879 $cursor_product = isset($checkpoint['cursor']['product_id']) ? absint($checkpoint['cursor']['product_id']) : 0; 11880 } 11881 if ($cursor_date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $cursor_date) || !DateTime::createFromFormat('!Y-m-d', $cursor_date))) { 11882 throw new RuntimeException('Invalid Koko Analytics cursor date.'); 11883 } 11884 $limit = max(1, min(100, absint($limit))); 11885 $posts_table_name = $wpdb->posts; 11886 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 11887 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 11888 } 11889 $posts_table = '`' . $posts_table_name . '`'; 11890 if (!in_array($schema_mode, ['path_id', 'id'], true)) { 11891 throw new RuntimeException('Unsupported Koko Analytics schema mode.'); 11892 } 11893 $post_id_column = $schema_mode === 'path_id' ? 's.post_id' : 's.id'; 11894 if (!in_array($post_id_column, ['s.post_id', 's.id'], true)) { 11895 throw new RuntimeException('Unsupported Koko Analytics product identifier column.'); 11896 } 11897 $cursor_sql = ''; 11898 $args = ['product', $through_date]; 11899 if ($cursor_date !== '') { 11900 $cursor_sql = ' HAVING (view_date > %s OR (view_date = %s AND product_id > %d))'; 11901 $args[] = $cursor_date; 11902 $args[] = $cursor_date; 11903 $args[] = $cursor_product; 11904 } 11905 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $post_id_column is selected from the fixed allowlist s.post_id/s.id after schema-mode validation; runtime values remain prepared placeholders. 11906 $batch_sql = 'SELECT s.date AS view_date, ' . $post_id_column . ' AS product_id, SUM(s.pageviews) AS views 11907 FROM ' . $this->quoted_table_name() . ' AS s 11908 INNER JOIN ' . $posts_table . ' AS p ON p.ID = ' . $post_id_column . ' AND p.post_type = %s 11909 WHERE s.date <= %s 11910 GROUP BY s.date, ' . $post_id_column . $cursor_sql . ' 11911 ORDER BY view_date ASC, product_id ASC 11912 LIMIT %d'; 11913 if ($cursor_date !== '') { 11914 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11915 $rows = statpl_db_get_results(statpl_db_prepare( 11916 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11917 'product', $through_date, 11918 $cursor_date, $cursor_date, $cursor_product, $limit + 1 11919 ), ARRAY_A); 11920 } else { 11921 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 11922 $rows = statpl_db_get_results(statpl_db_prepare( 11923 /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql, 11924 'product', $through_date, $limit + 1 11925 ), ARRAY_A); 11926 } 11927 if (!empty($wpdb->last_error) || !is_array($rows)) { 11928 throw new RuntimeException('Koko Analytics batch query failed.'); 11929 } 11930 $has_more = count($rows) > $limit; 11931 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 11932 $next = ($cursor_date !== '') ? ['date' => $cursor_date, 'product_id' => $cursor_product] : null; 11933 if ($rows) { $last = end($rows); $next = ['date' => (string) $last['view_date'], 'product_id' => absint($last['product_id'])]; } 11934 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 11935 } 11936 11937 public function normalize_record($origin_key, $record) { 11938 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 11939 $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0; 11940 $date = isset($record['view_date']) ? (string) $record['view_date'] : ''; 11941 $views_raw = $record['views'] ?? null; 11942 if (!$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 11943 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 11944 $errors = DateTime::getLastErrors(); 11945 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 11946 $views = (int) $views_raw; 11947 $period_start = $date . ' 00:00:00'; 11948 $period_end = $date . ' 23:59:59'; 11949 return [ 11950 'product_id' => $product_id, 11951 'source_record_key' => 'koko_analytics_' . $product_id . '_' . $date, 11952 'views' => $views, 11953 'record_type' => 'aggregate', 11954 'period_start' => $period_start, 11955 'period_end' => $period_end, 11956 'cursor' => ['date' => $date, 'product_id' => $product_id], 11957 ]; 11958 } 11959 } 11960 } 11961 11962 add_filter('statpl_historical_view_adapters', static function ($adapters) { 11963 if (!is_array($adapters)) { return $adapters; } 11964 $adapter = new STATPL_Koko_Analytics_Historical_Adapter(); 11965 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 11966 return $adapters; 11967 }, 35); 11968 11969 /** 11970 * Burst Statistics adapter. 11971 * 11972 * Imports raw product pageview rows from burst_statistics. Unlike aggregate 11973 * providers, Burst stores a WordPress post ID and a Unix timestamp per hit, so 11974 * product resolution is direct and each source row has a stable ID. Visitor 11975 * identifiers and session data are intentionally not read or imported. 11976 */ 11977 if (!class_exists('STATPL_Burst_Statistics_Historical_Adapter')) { 11978 final class STATPL_Burst_Statistics_Historical_Adapter implements STATPL_Historical_View_Adapter { 11979 const ADAPTER_KEY = 'burst_statistics'; 11980 const SOURCE_KEY = 'burst_statistics'; 11981 const ADAPTER_VERSION = '1.0.0'; 11982 const ORIGIN_KEY = 'product_pageview_events'; 11983 11984 public function get_adapter_key() { return self::ADAPTER_KEY; } 11985 public function get_source_key() { return self::SOURCE_KEY; } 11986 public function get_display_name() { return 'Burst Statistics'; } 11987 public function get_adapter_version() { return self::ADAPTER_VERSION; } 11988 11989 private function table_name() { 11990 global $wpdb; 11991 return $wpdb->prefix . 'burst_statistics'; 11992 } 11993 11994 private function quoted_table_name() { 11995 $table = $this->table_name(); 11996 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 11997 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 11998 } 11999 return '`' . $table . '`'; 12000 } 12001 12002 private function inspect_table() { 12003 global $wpdb; 12004 $table = $this->table_name(); 12005 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 12006 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12007 if ($exists !== $table) { return ['status' => 'missing_table']; } 12008 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12009 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A); 12010 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12011 $names = []; 12012 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 12013 if (array_diff(['id', 'page_id', 'page_type', 'time'], $names)) { return ['status' => 'incompatible_schema']; } 12014 return ['status' => 'available']; 12015 } 12016 12017 public function should_register_adapter() { 12018 global $wpdb; 12019 $inspection = $this->inspect_table(); 12020 if ($inspection['status'] !== 'missing_table') { return true; } 12021 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 12022 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 12023 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 12024 } 12025 $registry_table = '`' . $registry_name . '`'; 12026 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 12027 if (!empty($wpdb->last_error)) { return true; } 12028 if ($registry_exists !== $registry_name) { return false; } 12029 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 12030 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12031 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 12032 return !empty($wpdb->last_error) || !empty($source_id); 12033 } 12034 12035 public function discover_origins() { 12036 $inspection = $this->inspect_table(); 12037 if ($inspection['status'] === 'missing_table') { return []; } 12038 return [[ 12039 'origin_key' => self::ORIGIN_KEY, 12040 'capabilities' => ['product_views' => true, 'granularity' => 'event', 'timestamp_precision' => 'seconds', 'product_id_direct' => true], 12041 'detection_status' => $inspection['status'], 12042 ]]; 12043 } 12044 12045 public function get_origin_snapshot($origin_key) { 12046 global $wpdb; 12047 if ($origin_key !== self::ORIGIN_KEY) { return []; } 12048 if ($this->inspect_table()['status'] !== 'available') { 12049 throw new RuntimeException('Burst Statistics table is unavailable or incompatible.'); 12050 } 12051 $posts_table_name = $wpdb->posts; 12052 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 12053 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 12054 } 12055 $posts_table = '`' . $posts_table_name . '`'; 12056 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12057 $max_id = statpl_db_get_var(statpl_db_prepare( 12058 'SELECT MAX(s.ID) 12059 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 12060 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.page_id AND p.post_type = %s 12061 WHERE s.page_id > 0 AND s.page_type = %s', 12062 'product', 'product' 12063 )); 12064 if (!empty($wpdb->last_error)) { 12065 throw new RuntimeException('Burst Statistics snapshot query failed.'); 12066 } 12067 return [ 12068 'boundary' => ['max_id' => max(0, (int) $max_id)], 12069 'initial_checkpoint' => null, 12070 'captured_at' => current_time('mysql'), 12071 ]; 12072 } 12073 12074 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 12075 global $wpdb; 12076 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 12077 throw new RuntimeException('Invalid Burst Statistics origin or snapshot.'); 12078 } 12079 if ($this->inspect_table()['status'] !== 'available') { 12080 throw new RuntimeException('Burst Statistics table became unavailable or incompatible.'); 12081 } 12082 $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1; 12083 if ($max_id < 0) { throw new RuntimeException('Invalid Burst Statistics snapshot boundary.'); } 12084 $cursor_id = 0; 12085 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 12086 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 12087 } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); } 12088 $limit = max(1, min(100, absint($limit))); 12089 $posts_table_name = $wpdb->posts; 12090 if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) { 12091 throw new InvalidArgumentException('Invalid WordPress posts table identifier.'); 12092 } 12093 $posts_table = '`' . $posts_table_name . '`'; 12094 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12095 $rows = statpl_db_get_results(statpl_db_prepare( 12096 'SELECT s.ID AS id, s.page_id, s.time 12097 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s 12098 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.page_id AND p.post_type = %s 12099 WHERE s.page_type = %s AND s.page_id > 0 AND s.ID > %d AND s.ID <= %d 12100 ORDER BY s.ID ASC 12101 LIMIT %d', 12102 'product', 'product', $cursor_id, $max_id, $limit + 1 12103 ), ARRAY_A); 12104 if (!empty($wpdb->last_error) || !is_array($rows)) { 12105 throw new RuntimeException('Burst Statistics batch query failed.'); 12106 } 12107 $has_more = count($rows) > $limit; 12108 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 12109 $next = $cursor_id; 12110 if ($rows) { $last = end($rows); $next = absint($last['id']); } 12111 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 12112 } 12113 12114 public function normalize_record($origin_key, $record) { 12115 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 12116 $record_id = isset($record['id']) ? absint($record['id']) : 0; 12117 $product_id = isset($record['page_id']) ? absint($record['page_id']) : 0; 12118 $timestamp = isset($record['time']) ? absint($record['time']) : 0; 12119 if (!$record_id || !$product_id || !$timestamp || !function_exists('wp_date')) { return null; } 12120 $datetime = wp_date('Y-m-d H:i:s', $timestamp); 12121 if (!is_string($datetime) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $datetime)) { return null; } 12122 return [ 12123 'product_id' => $product_id, 12124 'source_record_key' => 'burst_statistic_' . $record_id, 12125 'views' => 1, 12126 'record_type' => 'event', 12127 'period_start' => $datetime, 12128 'period_end' => $datetime, 12129 'cursor' => $record_id, 12130 ]; 12131 } 12132 12133 public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) { 12134 return [ 12135 'source' => 'burst_statistics', 12136 'timestamp_precision' => 'seconds', 12137 'overlap_assessment' => 'possible_but_not_proven', 12138 'automatic_suppression' => false, 12139 'reason' => 'Burst supplies an exact local event timestamp and product ID, but other providers do not share its stable pageview ID or visitor identity.', 12140 ]; 12141 } 12142 } 12143 } 12144 12145 add_filter('statpl_historical_view_adapters', static function ($adapters) { 12146 if (!is_array($adapters)) { return $adapters; } 12147 $adapter = new STATPL_Burst_Statistics_Historical_Adapter(); 12148 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 12149 return $adapters; 12150 }, 36); 12151 12152 /** 12153 * Independent Analytics adapter. 12154 * 12155 * Imports one row per view from independent_analytics_views and resolves the 12156 * resource through independent_analytics_resources.singular_id/post_type. 12157 * Independent Analytics writes viewed_at in UTC. Only the timestamp and the 12158 * product ID are imported; visitor/session identifiers are intentionally not 12159 * accessed. 12160 */ 12161 if (!class_exists('STATPL_Independent_Analytics_Historical_Adapter')) { 12162 final class STATPL_Independent_Analytics_Historical_Adapter implements STATPL_Historical_View_Adapter { 12163 const ADAPTER_KEY = 'independent_analytics'; 12164 const SOURCE_KEY = 'independent_analytics'; 12165 const ADAPTER_VERSION = '1.0.0'; 12166 const ORIGIN_KEY = 'product_view_events'; 12167 12168 public function get_adapter_key() { return self::ADAPTER_KEY; } 12169 public function get_source_key() { return self::SOURCE_KEY; } 12170 public function get_display_name() { return 'Independent Analytics'; } 12171 public function get_adapter_version() { return self::ADAPTER_VERSION; } 12172 12173 private function views_table() { 12174 global $wpdb; 12175 return $wpdb->prefix . 'independent_analytics_views'; 12176 } 12177 12178 private function resources_table() { 12179 global $wpdb; 12180 return $wpdb->prefix . 'independent_analytics_resources'; 12181 } 12182 12183 private function quote_table($table) { 12184 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 12185 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 12186 } 12187 return '`' . $table . '`'; 12188 } 12189 12190 private function inspect_tables() { 12191 global $wpdb; 12192 $found_any = false; 12193 $missing_any = false; 12194 foreach ([$this->views_table() => ['id', 'resource_id', 'viewed_at'], $this->resources_table() => ['id', 'singular_id', 'post_type']] as $table => $required) { 12195 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 12196 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12197 if ($exists !== $table) { 12198 $missing_any = true; 12199 continue; 12200 } 12201 $found_any = true; 12202 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12203 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quote_table($table), ARRAY_A); 12204 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12205 $names = []; 12206 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 12207 if (array_diff($required, $names)) { return ['status' => 'incompatible_schema']; } 12208 } 12209 if (!$found_any) { return ['status' => 'missing_table']; } 12210 if ($missing_any) { return ['status' => 'incompatible_schema']; } 12211 return ['status' => 'available']; 12212 } 12213 12214 public function should_register_adapter() { 12215 global $wpdb; 12216 $inspection = $this->inspect_tables(); 12217 if ($inspection['status'] !== 'missing_table') { return true; } 12218 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 12219 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 12220 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 12221 } 12222 $registry_table = '`' . $registry_name . '`'; 12223 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 12224 if (!empty($wpdb->last_error)) { return true; } 12225 if ($registry_exists !== $registry_name) { return false; } 12226 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 12227 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12228 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 12229 return !empty($wpdb->last_error) || !empty($source_id); 12230 } 12231 12232 public function discover_origins() { 12233 $inspection = $this->inspect_tables(); 12234 if ($inspection['status'] === 'missing_table') { return []; } 12235 return [[ 12236 'origin_key' => self::ORIGIN_KEY, 12237 'capabilities' => ['product_views' => true, 'granularity' => 'event', 'timestamp_precision' => 'seconds', 'product_id_direct' => true], 12238 'detection_status' => $inspection['status'], 12239 ]]; 12240 } 12241 12242 public function get_origin_snapshot($origin_key) { 12243 global $wpdb; 12244 if ($origin_key !== self::ORIGIN_KEY) { return []; } 12245 if ($this->inspect_tables()['status'] !== 'available') { 12246 throw new RuntimeException('Independent Analytics tables are unavailable or incompatible.'); 12247 } 12248 $posts_table = $this->quote_table($wpdb->posts); 12249 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12250 $max_id = statpl_db_get_var(statpl_db_prepare( 12251 'SELECT MAX(v.id) 12252 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->views_table()) . ' AS v 12253 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->resources_table()) . ' AS r ON r.id = v.resource_id 12254 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = r.singular_id AND p.post_type = %s 12255 WHERE r.post_type = %s AND r.singular_id > 0', 12256 'product', 'product' 12257 )); 12258 if (!empty($wpdb->last_error)) { 12259 throw new RuntimeException('Independent Analytics snapshot query failed.'); 12260 } 12261 return [ 12262 'boundary' => ['max_id' => max(0, (int) $max_id)], 12263 'initial_checkpoint' => null, 12264 'captured_at' => current_time('mysql'), 12265 ]; 12266 } 12267 12268 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 12269 global $wpdb; 12270 if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 12271 throw new RuntimeException('Invalid Independent Analytics origin or snapshot.'); 12272 } 12273 if ($this->inspect_tables()['status'] !== 'available') { 12274 throw new RuntimeException('Independent Analytics tables became unavailable or incompatible.'); 12275 } 12276 $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1; 12277 if ($max_id < 0) { throw new RuntimeException('Invalid Independent Analytics snapshot boundary.'); } 12278 $cursor_id = 0; 12279 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { 12280 $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); 12281 } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); } 12282 $limit = max(1, min(100, absint($limit))); 12283 $posts_table = $this->quote_table($wpdb->posts); 12284 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12285 $rows = statpl_db_get_results(statpl_db_prepare( 12286 'SELECT v.id, r.singular_id AS product_id, v.viewed_at 12287 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->views_table()) . ' AS v 12288 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->resources_table()) . ' AS r ON r.id = v.resource_id 12289 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = r.singular_id AND p.post_type = %s 12290 WHERE r.post_type = %s AND r.singular_id > 0 AND v.id > %d AND v.id <= %d 12291 ORDER BY v.id ASC 12292 LIMIT %d', 12293 'product', 'product', $cursor_id, $max_id, $limit + 1 12294 ), ARRAY_A); 12295 if (!empty($wpdb->last_error) || !is_array($rows)) { 12296 throw new RuntimeException('Independent Analytics batch query failed.'); 12297 } 12298 $has_more = count($rows) > $limit; 12299 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 12300 $next = $cursor_id; 12301 if ($rows) { $last = end($rows); $next = absint($last['id']); } 12302 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 12303 } 12304 12305 public function normalize_record($origin_key, $record) { 12306 if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; } 12307 $record_id = isset($record['id']) ? absint($record['id']) : 0; 12308 $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0; 12309 $viewed_at = isset($record['viewed_at']) ? trim((string) $record['viewed_at']) : ''; 12310 if (!$record_id || !$product_id || $viewed_at === '') { return null; } 12311 $timestamp = strtotime(str_replace('T', ' ', $viewed_at) . ' UTC'); 12312 if (!$timestamp || !function_exists('wp_date')) { return null; } 12313 $datetime = wp_date('Y-m-d H:i:s', $timestamp); 12314 if (!is_string($datetime) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $datetime)) { return null; } 12315 return [ 12316 'product_id' => $product_id, 12317 'source_record_key' => 'independent_analytics_view_' . $record_id, 12318 'views' => 1, 12319 'record_type' => 'event', 12320 'period_start' => $datetime, 12321 'period_end' => $datetime, 12322 'cursor' => $record_id, 12323 ]; 12324 } 12325 12326 public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) { 12327 return [ 12328 'source' => 'independent_analytics', 12329 'timestamp_precision' => 'seconds', 12330 'overlap_assessment' => 'possible_but_not_proven', 12331 'automatic_suppression' => false, 12332 'reason' => 'Independent Analytics provides a per-view timestamp and product ID, but no stable event identifier shared with other providers.', 12333 ]; 12334 } 12335 } 12336 } 12337 12338 add_filter('statpl_historical_view_adapters', static function ($adapters) { 12339 if (!is_array($adapters)) { return $adapters; } 12340 $adapter = new STATPL_Independent_Analytics_Historical_Adapter(); 12341 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 12342 return $adapters; 12343 }, 37); 12344 12345 /** 12346 * PulseViews adapter. 12347 * 12348 * Uses disjoint date windows: completed daily aggregates through two days ago, 12349 * then non-bot raw events after that boundary. Weekly/monthly rollups and the 12350 * lifetime totals cache are never imported, preventing overlap. PulseViews 12351 * prunes raw events after 90 days but retains its daily aggregate history. 12352 */ 12353 if (!class_exists('STATPL_PulseViews_Historical_Adapter')) { 12354 final class STATPL_PulseViews_Historical_Adapter implements STATPL_Historical_View_Adapter { 12355 const ADAPTER_KEY = 'pulseviews'; 12356 const SOURCE_KEY = 'pulseviews'; 12357 const ADAPTER_VERSION = '1.0.0'; 12358 const DAILY_ORIGIN = 'daily_aggregates'; 12359 const RAW_ORIGIN = 'recent_raw_views'; 12360 private $snapshot_through_date = null; 12361 private $snapshot_captured_at = null; 12362 12363 private function get_snapshot_through_date() { 12364 if ($this->snapshot_through_date === null) { 12365 $this->snapshot_through_date = current_datetime()->modify('-2 days')->format('Y-m-d'); 12366 } 12367 return $this->snapshot_through_date; 12368 } 12369 12370 private function get_snapshot_captured_at() { 12371 if ($this->snapshot_captured_at === null) { 12372 $this->snapshot_captured_at = current_time('mysql'); 12373 } 12374 return $this->snapshot_captured_at; 12375 } 12376 12377 public function get_adapter_key() { return self::ADAPTER_KEY; } 12378 public function get_source_key() { return self::SOURCE_KEY; } 12379 public function get_display_name() { return 'PulseViews'; } 12380 public function get_adapter_version() { return self::ADAPTER_VERSION; } 12381 12382 private function table_name($kind) { 12383 global $wpdb; 12384 if (!in_array($kind, ['daily', 'raw'], true)) { 12385 throw new InvalidArgumentException('Unsupported PulseViews table kind.'); 12386 } 12387 return $wpdb->prefix . ($kind === 'daily' ? 'pulseviews_aggregates' : 'pulseviews_views'); 12388 } 12389 12390 private function quote_table($table) { 12391 if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) { 12392 throw new InvalidArgumentException('Invalid historical analytics table identifier.'); 12393 } 12394 return '`' . $table . '`'; 12395 } 12396 12397 private function inspect_table($kind) { 12398 global $wpdb; 12399 $table = $this->table_name($kind); 12400 $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); 12401 if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12402 if ($exists !== $table) { return ['status' => 'missing_table']; } 12403 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12404 $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quote_table($table), ARRAY_A); 12405 if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; } 12406 $names = []; 12407 foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } } 12408 $required = $kind === 'daily' 12409 ? ['id', 'post_id', 'period_date', 'period_type', 'view_count'] 12410 : ['id', 'post_id', 'viewed_at', 'is_bot']; 12411 if (array_diff($required, $names)) { return ['status' => 'incompatible_schema']; } 12412 return ['status' => 'available']; 12413 } 12414 12415 public function should_register_adapter() { 12416 global $wpdb; 12417 $daily = $this->inspect_table('daily'); 12418 $raw = $this->inspect_table('raw'); 12419 if ($daily['status'] !== 'missing_table' || $raw['status'] !== 'missing_table') { return true; } 12420 $registry_name = $wpdb->prefix . 'statpl_view_sources'; 12421 if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) { 12422 throw new InvalidArgumentException('Invalid Statora source registry table identifier.'); 12423 } 12424 $registry_table = '`' . $registry_name . '`'; 12425 $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name))); 12426 if (!empty($wpdb->last_error)) { return true; } 12427 if ($registry_exists !== $registry_name) { return false; } 12428 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value. 12429 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12430 $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY)); 12431 return !empty($wpdb->last_error) || !empty($source_id); 12432 } 12433 12434 public function discover_origins() { 12435 $origins = []; 12436 $daily = $this->inspect_table('daily'); 12437 if ($daily['status'] !== 'missing_table') { 12438 $origins[] = [ 12439 'origin_key' => self::DAILY_ORIGIN, 12440 'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'window' => 'through_two_days_ago'], 12441 'detection_status' => $daily['status'], 12442 ]; 12443 } 12444 $raw = $this->inspect_table('raw'); 12445 if ($raw['status'] !== 'missing_table') { 12446 $origins[] = [ 12447 'origin_key' => self::RAW_ORIGIN, 12448 'capabilities' => ['product_views' => true, 'granularity' => 'event', 'window' => 'after_daily_aggregate_boundary', 'bot_filtered' => true], 12449 'detection_status' => $raw['status'], 12450 ]; 12451 } 12452 return $origins; 12453 } 12454 12455 public function get_origin_snapshot($origin_key) { 12456 global $wpdb; 12457 $through_date = $this->get_snapshot_through_date(); 12458 $posts_table = $this->quote_table($wpdb->posts); 12459 if ($origin_key === self::DAILY_ORIGIN) { 12460 if ($this->inspect_table('daily')['status'] !== 'available') { 12461 throw new RuntimeException('PulseViews daily aggregate table is unavailable or incompatible.'); 12462 } 12463 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12464 $max_id = statpl_db_get_var(statpl_db_prepare( 12465 'SELECT MAX(a.id) 12466 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('daily')) . ' AS a 12467 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = a.post_id AND p.post_type = %s 12468 WHERE a.period_type = %s AND a.period_date <= %s', 12469 'product', 'day', $through_date 12470 )); 12471 if (!empty($wpdb->last_error)) { throw new RuntimeException('PulseViews daily snapshot query failed.'); } 12472 return ['boundary' => ['max_id' => max(0, (int) $max_id), 'through_date' => $through_date], 'initial_checkpoint' => null, 'captured_at' => current_time('mysql')]; 12473 } 12474 if ($origin_key === self::RAW_ORIGIN) { 12475 if ($this->inspect_table('raw')['status'] !== 'available') { 12476 throw new RuntimeException('PulseViews raw views table is unavailable or incompatible.'); 12477 } 12478 $after_datetime = $through_date . ' 23:59:59'; 12479 $captured_at = $this->get_snapshot_captured_at(); 12480 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12481 $max_id = statpl_db_get_var(statpl_db_prepare( 12482 'SELECT MAX(v.id) 12483 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('raw')) . ' AS v 12484 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.post_id AND p.post_type = %s 12485 WHERE v.is_bot = 0 AND v.viewed_at > %s AND v.viewed_at <= %s', 12486 'product', $after_datetime, $captured_at 12487 )); 12488 if (!empty($wpdb->last_error)) { throw new RuntimeException('PulseViews raw snapshot query failed.'); } 12489 return ['boundary' => ['max_id' => max(0, (int) $max_id), 'after_datetime' => $after_datetime, 'captured_at' => $captured_at], 'initial_checkpoint' => null, 'captured_at' => $captured_at]; 12490 } 12491 return []; 12492 } 12493 12494 public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) { 12495 global $wpdb; 12496 if (!is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) { 12497 throw new RuntimeException('Invalid PulseViews snapshot.'); 12498 } 12499 $cursor_id = 0; 12500 if (is_array($checkpoint) && isset($checkpoint['cursor'])) { $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); } 12501 elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); } 12502 $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1; 12503 if ($max_id < 0) { throw new RuntimeException('Invalid PulseViews snapshot boundary.'); } 12504 $limit = max(1, min(100, absint($limit))); 12505 $posts_table = $this->quote_table($wpdb->posts); 12506 if ($origin_key === self::DAILY_ORIGIN) { 12507 if ($this->inspect_table('daily')['status'] !== 'available') { throw new RuntimeException('PulseViews daily aggregate table became unavailable.'); } 12508 $through_date = isset($snapshot['boundary']['through_date']) ? (string) $snapshot['boundary']['through_date'] : ''; 12509 $parsed = DateTime::createFromFormat('!Y-m-d', $through_date); 12510 if (!$parsed || $parsed->format('Y-m-d') !== $through_date) { throw new RuntimeException('Invalid PulseViews daily boundary.'); } 12511 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12512 $rows = statpl_db_get_results(statpl_db_prepare( 12513 'SELECT a.id, a.post_id, a.period_date, a.view_count 12514 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('daily')) . ' AS a 12515 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = a.post_id AND p.post_type = %s 12516 WHERE a.period_type = %s AND a.id > %d AND a.id <= %d AND a.period_date <= %s 12517 ORDER BY a.id ASC 12518 LIMIT %d', 12519 'product', 'day', $cursor_id, $max_id, $through_date, $limit + 1 12520 ), ARRAY_A); 12521 } elseif ($origin_key === self::RAW_ORIGIN) { 12522 if ($this->inspect_table('raw')['status'] !== 'available') { throw new RuntimeException('PulseViews raw views table became unavailable.'); } 12523 $after_datetime = isset($snapshot['boundary']['after_datetime']) ? (string) $snapshot['boundary']['after_datetime'] : ''; 12524 $captured_at = isset($snapshot['boundary']['captured_at']) ? (string) $snapshot['boundary']['captured_at'] : ''; 12525 if ($after_datetime === '' || $captured_at === '') { throw new RuntimeException('Invalid PulseViews raw boundary.'); } 12526 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders. 12527 $rows = statpl_db_get_results(statpl_db_prepare( 12528 'SELECT v.id, v.post_id, v.viewed_at 12529 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('raw')) . ' AS v 12530 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.post_id AND p.post_type = %s 12531 WHERE v.is_bot = 0 AND v.id > %d AND v.id <= %d AND v.viewed_at > %s AND v.viewed_at <= %s 12532 ORDER BY v.id ASC 12533 LIMIT %d', 12534 'product', $cursor_id, $max_id, $after_datetime, $captured_at, $limit + 1 12535 ), ARRAY_A); 12536 } else { 12537 throw new RuntimeException('Unknown PulseViews origin.'); 12538 } 12539 if (!empty($wpdb->last_error) || !is_array($rows)) { throw new RuntimeException('PulseViews batch query failed.'); } 12540 $has_more = count($rows) > $limit; 12541 if ($has_more) { $rows = array_slice($rows, 0, $limit); } 12542 $next = $cursor_id; 12543 if ($rows) { $last = end($rows); $next = absint($last['id']); } 12544 return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true]; 12545 } 12546 12547 public function normalize_record($origin_key, $record) { 12548 if (!is_array($record)) { return null; } 12549 $record_id = isset($record['id']) ? absint($record['id']) : 0; 12550 $product_id = isset($record['post_id']) ? absint($record['post_id']) : 0; 12551 if (!$record_id || !$product_id) { return null; } 12552 if ($origin_key === self::DAILY_ORIGIN) { 12553 $date = isset($record['period_date']) ? (string) $record['period_date'] : ''; 12554 $views_raw = $record['view_count'] ?? null; 12555 if (!preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; } 12556 $parsed = DateTime::createFromFormat('!Y-m-d', $date); 12557 $errors = DateTime::getLastErrors(); 12558 if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 12559 $views = (int) $views_raw; 12560 $period_start = $date . ' 00:00:00'; 12561 $period_end = $date . ' 23:59:59'; 12562 return ['product_id' => $product_id, 'source_record_key' => 'pulseviews_day_' . $product_id . '_' . $date, 'views' => $views, 'record_type' => 'aggregate', 'period_start' => $period_start, 'period_end' => $period_end, 'cursor' => $record_id]; 12563 } 12564 if ($origin_key === self::RAW_ORIGIN) { 12565 $viewed_at = isset($record['viewed_at']) ? (string) $record['viewed_at'] : ''; 12566 $parsed = DateTime::createFromFormat('!Y-m-d H:i:s', $viewed_at); 12567 $errors = DateTime::getLastErrors(); 12568 if (!$parsed || $parsed->format('Y-m-d H:i:s') !== $viewed_at || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; } 12569 return ['product_id' => $product_id, 'source_record_key' => 'pulseviews_view_' . $record_id, 'views' => 1, 'record_type' => 'event', 'period_start' => $viewed_at, 'period_end' => $viewed_at, 'cursor' => $record_id]; 12570 } 12571 return null; 12572 } 12573 12574 public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) { 12575 return [ 12576 'source' => 'pulseviews', 12577 'origin' => $origin_key, 12578 'overlap_assessment' => 'possible_but_not_proven', 12579 'automatic_suppression' => false, 12580 'reason' => 'PulseViews event rows have local timestamps but no stable cross-provider event identifier; daily aggregates are matched only on exact product/date/count dimensions.', 12581 ]; 12582 } 12583 } 12584 } 12585 12586 add_filter('statpl_historical_view_adapters', static function ($adapters) { 12587 if (!is_array($adapters)) { return $adapters; } 12588 $adapter = new STATPL_PulseViews_Historical_Adapter(); 12589 if ($adapter->should_register_adapter()) { $adapters[] = $adapter; } 12590 return $adapters; 12591 }, 38);
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)