$batch, 'offset' => $offset, 'fields' => 'ids', ) ); foreach ( $site_ids as $blog_id ) { switch_to_blog( (int) $blog_id ); try { self::install(); } finally { // v2.14.1: ensure blog context restored even if install() throws. restore_current_blog(); } } $offset += $batch; } while ( count( $site_ids ) === $batch ); // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- count() used in do-while condition, loop body does not modify $site_ids, so caching is unnecessary. } /** * Auto-install tables when a new site is created in Multisite. * * @param \WP_Site $site The newly created site. */ public static function on_new_site( \WP_Site $site ): void { if ( ! is_plugin_active_for_network( plugin_basename( TMDO_FILE ) ) ) { return; } switch_to_blog( (int) $site->blog_id ); try { self::install(); } finally { // v2.14.1: ensure blog context restored even if install() throws. restore_current_blog(); } } /** * Drop WPDO tables when a Multisite site is being deleted (v2.14.0). * * Fires on `wp_uninitialize_site` (the recommended hook for plugins to * clean up site-scoped data; runs BEFORE WordPress drops the core * wp_N_* tables). Without this hook, deleting a site leaves up to ~36 * orphan `wp_N_wpdo_*` tables permanently consuming DB space. * * Only runs when the plugin is network-active (per-site activations * are scoped to that single site, no cross-site cleanup needed). * * @param \WP_Site|int $site Site or site ID being deleted. * @return void * @since 2.14.0 */ public static function on_site_delete( $site ): void { if ( ! is_multisite() ) { return; } if ( function_exists( 'is_plugin_active_for_network' ) && ! is_plugin_active_for_network( plugin_basename( TMDO_FILE ) ) ) { return; } $blog_id = $site instanceof \WP_Site ? (int) $site->blog_id : (int) $site; if ( $blog_id < 1 ) { return; } switch_to_blog( $blog_id ); try { $counts = self::drop_all_tables_for_current_blog(); if ( class_exists( 'TMDO_Logger' ) ) { TMDO_Logger::info( 'multisite_site_delete_cleanup', array( 'blog_id' => $blog_id, 'counts' => $counts, ) ); } } finally { restore_current_blog(); } } /** * Install tables for the current blog. * * Idempotent. Safely re-runs on every activation / version bump. * v2.0.0: also installs the entity / audit / shadow_diffs / site_metrics * tables via install_v2_tables() — kept here so plain `wp plugin activate` * gets a complete v2 schema without needing the explicit V2_Upgrader run. */ public static function install(): void { self::run_dbdelta(); if ( TMDO_IS_MYSQL ) { self::run_mysql_indexes(); } if ( TMDO_IS_SQLITE ) { TMDO_SQLite_Compat::patch_all(); } // v2.0.0 entity + audit + shadow_diffs + site_metrics + uni_options. // Idempotent — only creates tables that don't already exist. self::install_v2_tables(); update_option( 'wpdo_db_version', self::SCHEMA_VERSION ); // Register wpdo_features with autoload=no so it doesn't inflate every page load. if ( false === get_option( 'wpdo_features' ) ) { add_option( 'wpdo_features', array(), '', 'no' ); } // v2.5.4: one-time migration — enable Hook Bus + activate user/term/comment bridge. self::maybe_autoactivate_entity_bridge(); // v2.15.0: one-time crypto migration — re-encrypt v1 CBC ciphertext as // v2 GCM. Idempotent best-effort: skipped if no v1 ciphertext present // or if the migration flag is already set. self::maybe_migrate_crypto_v1_to_v2(); } /** * One-time migration: enable Hook Bus and set user/term/comment to dual_write. * * Runs once per site (guarded by wpdo_entity_bridge_autoactivated_v2 flag). * Safe: dual_write still writes native EAV — zero data-loss risk. * * @return void */ private static function maybe_autoactivate_entity_bridge(): void { if ( get_option( 'wpdo_entity_bridge_autoactivated_v2' ) ) { return; } // Enable Hook Bus. update_option( 'wpdo_hook_bus_enabled', '1', false ); // Upgrade user/term/comment from disabled → dual_write. $modes = get_option( 'wpdo_bridge_modes', array() ); if ( ! is_array( $modes ) ) { $modes = array(); } foreach ( array( 'user', 'term', 'comment' ) as $type ) { if ( ! isset( $modes[ $type ] ) || 'disabled' === $modes[ $type ] ) { $modes[ $type ] = 'dual_write'; } } // post entity is intentionally left alone — it uses the legacy Feature_Flags FSM. update_option( 'wpdo_bridge_modes', $modes, false ); update_option( 'wpdo_entity_bridge_autoactivated_v2', '1', false ); } /** * One-time best-effort crypto migration v1 (CBC) → v2 (GCM) (v2.15.0). * * Idempotent: guarded by the `wpdo_crypto_migrated_v2` flag option. Re-runs * are no-ops. Errors are logged but do not block plugin activation — v1 * blobs remain readable so notifications continue to work. * * @return void */ private static function maybe_migrate_crypto_v1_to_v2(): void { if ( get_option( 'wpdo_crypto_migrated_v2' ) ) { return; } if ( ! class_exists( 'TMDO_Crypto' ) ) { return; } $counts = TMDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); if ( $counts['migrated'] > 0 || $counts['failed'] > 0 ) { if ( class_exists( 'TMDO_Logger' ) ) { TMDO_Logger::info( 'crypto_migrate_v1_to_v2', $counts ); } } // Mark as migrated even if 0 v1 ciphertexts were found, so we don't // re-scan wp_options on every activation. The flag itself uses the // option name pattern but isn't an encrypted secret. update_option( 'wpdo_crypto_migrated_v2', '1', false ); } /** * Called on plugins_loaded to upgrade when SCHEMA_VERSION changes. */ public static function maybe_upgrade(): void { if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) { self::install(); } } // ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ───────────────── // These tables are installed only when v2.0.0 upgrade runs (PR-7). // install_v2_tables() is idempotent; safe to call multiple times. /** * Install v2.0.0 tables — entity tables (user/term/comment), audit log, * shadow_diffs, site_metrics, options manager backing table. * * Idempotent. Called from TMDO_V2_Upgrader::upgrade_to_v2() in PR-7. * * @internal Public so PR-7 upgrader and integration tests can invoke directly. * @return void */ public static function install_v2_tables(): void { global $wpdb; // Allow integration tests to provide a stub dbDelta() without WP being bootstrapped. if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } $charset = $wpdb->get_charset_collate(); $p = $wpdb->prefix; $sqls = array(); // ── wpdo_audit (structured op-aware audit log) ──────────────────── $sqls[] = "CREATE TABLE {$p}wpdo_audit ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ts datetime NOT NULL DEFAULT '0000-00-00 00:00:00', user_id bigint(20) unsigned NOT NULL DEFAULT 0, entity_type varchar(20) NOT NULL DEFAULT '', entity_id bigint(20) unsigned NOT NULL DEFAULT 0, group_name varchar(50) NOT NULL DEFAULT '', meta_key varchar(255) NOT NULL DEFAULT '', action varchar(20) NOT NULL DEFAULT '', op varchar(20) NOT NULL DEFAULT '', value_before longtext, value_after longtext, source varchar(20) NOT NULL DEFAULT '', trace_id varchar(36) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY idx_entity (entity_type, entity_id), KEY idx_meta_key (meta_key(191)), KEY idx_ts (ts), KEY idx_trace (trace_id) ) {$charset};"; // ── wpdo_shadow_diffs (verify-stage divergence log) ─────────────── $sqls[] = "CREATE TABLE {$p}wpdo_shadow_diffs ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ts datetime NOT NULL DEFAULT '0000-00-00 00:00:00', entity_type varchar(20) NOT NULL DEFAULT '', entity_id bigint(20) unsigned NOT NULL DEFAULT 0, meta_key varchar(255) NOT NULL DEFAULT '', postmeta_value longtext, zone_value longtext, diff_hash varchar(40) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY idx_entity (entity_type, entity_id), KEY idx_meta_key (meta_key(191)), KEY idx_diff_hash (diff_hash), KEY idx_ts (ts) ) {$charset};"; // ── wpdo_site_metrics (Part C.3 site-wide EAV health) ───────────── $sqls[] = "CREATE TABLE {$p}wpdo_site_metrics ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, collected_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', metric_key varchar(100) NOT NULL DEFAULT '', metric_value bigint(20) NOT NULL DEFAULT 0, context longtext, PRIMARY KEY (id), KEY idx_metric_key (metric_key), KEY idx_collected_at (collected_at) ) {$charset};"; // ── wpdo_uni_options (autoload-optimized options) ───────────────── $sqls[] = "CREATE TABLE {$p}wpdo_uni_options ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, option_name varchar(191) NOT NULL DEFAULT '', option_value longtext NOT NULL, autoload varchar(20) NOT NULL DEFAULT 'no', provider varchar(50) NOT NULL DEFAULT '', updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), UNIQUE KEY ui_option_name (option_name), KEY idx_provider (provider) ) {$charset};"; // ── wpdo_registry_meta (v2.5.1 fix — engine schema_manager metadata catalog) ─ // Stores schema hash + field_definitions per (entity_type, group_name). // Previously referenced by `TMDO_Schema_Manager::store_schema_metadata` / // `get_stored_schema_hash` via `$wpdb->replace()` but never created → produced // continuous WordPress database errors on every wp-cli init. Schema derived // from the columns those callers write. $sqls[] = "CREATE TABLE {$p}wpdo_registry_meta ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, entity_type varchar(20) NOT NULL DEFAULT '', group_name varchar(40) NOT NULL DEFAULT '', schema_hash varchar(64) NOT NULL DEFAULT '', field_definitions longtext, updated_at datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (id), UNIQUE KEY ui_entity_group (entity_type, group_name), KEY idx_schema_hash (schema_hash) ) {$charset};"; // ── wpdo_snapshots (v2.2.0 M1 — backup/restore catalog with hybrid storage) ─ // Catalog rows for every backup snapshot. Small payloads (≤5MB) live in // `inline_blob`; larger snapshots are stored as gzipped SQL dumps under // wp-content/uploads/wpdo-backups/.sql.gz with sha256 verify. // `trigger` records why the snapshot was created (manual / pre_fsm_transition / // pre_v2_upgrade / scheduled / pre_uninstall). `scope` JSON declares which // entities/modules were dumped. `expires_at` powers the daily prune cron. $sqls[] = "CREATE TABLE {$p}wpdo_snapshots ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, snapshot_id varchar(64) NOT NULL DEFAULT '', trigger_type varchar(40) NOT NULL DEFAULT 'manual', scope longtext, size_bytes bigint(20) unsigned NOT NULL DEFAULT 0, row_count bigint(20) unsigned NOT NULL DEFAULT 0, storage varchar(20) NOT NULL DEFAULT 'file', file_path varchar(500) DEFAULT NULL, file_sha256 varchar(64) DEFAULT NULL, inline_blob longblob, fsm_states longtext, notes varchar(500) DEFAULT NULL, created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', expires_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY ui_snapshot_id (snapshot_id), KEY idx_trigger_created (trigger_type, created_at), KEY idx_expires (expires_at) ) {$charset};"; // ── wpdo_wc_commissions (v2.1.0 WC integration — vendor commission) ─ // Replaces legacy `hpct_wc_orders` from HPCT era. HPOS-aware via // wc_get_order() abstraction in TMDO_WC_Orders_Interceptor. $sqls[] = "CREATE TABLE {$p}wpdo_wc_commissions ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, wc_order_id bigint(20) unsigned NOT NULL DEFAULT 0, vendor_id bigint(20) unsigned NOT NULL DEFAULT 0, listing_id bigint(20) unsigned NOT NULL DEFAULT 0, subtotal decimal(15,4) NOT NULL DEFAULT 0, commission decimal(15,4) NOT NULL DEFAULT 0, vendor_payout decimal(15,4) NOT NULL DEFAULT 0, commission_rate decimal(5,2) NOT NULL DEFAULT 0, status varchar(40) NOT NULL DEFAULT 'pending', hpos_enabled tinyint(1) NOT NULL DEFAULT 0, payout_at datetime DEFAULT NULL, created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), UNIQUE KEY ui_order_vendor (wc_order_id, vendor_id), KEY idx_vendor (vendor_id), KEY idx_status (status), KEY idx_payout_at (payout_at) ) {$charset};"; // ── wpdo_migration_status (entity migration engine checkpoint) ──────── // Bug fix: TMDO_Entity_Migration_Engine::migrate_group() reads/writes this // table at every batch. Missing in original schema → first backfill fails. $sqls[] = "CREATE TABLE {$p}wpdo_migration_status ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, entity_type varchar(20) NOT NULL DEFAULT '', group_name varchar(40) NOT NULL DEFAULT '', last_id bigint(20) unsigned NOT NULL DEFAULT 0, total_migrated bigint(20) unsigned NOT NULL DEFAULT 0, status varchar(20) NOT NULL DEFAULT 'idle', started_at datetime NULL DEFAULT NULL, completed_at datetime NULL DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY ui_entity_group (entity_type, group_name) ) {$charset};"; // ── wpdo_user_points_ledger (append-only points journal) ────────────── // One row per transaction. idx_user_created is a covering index — // WHERE user_id=? ORDER BY created_at DESC LIMIT N never touches data pages. $sqls[] = "CREATE TABLE {$p}wpdo_user_points_ledger ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id bigint(20) unsigned NOT NULL DEFAULT 0, delta int(11) NOT NULL DEFAULT 0, balance_after bigint(20) NOT NULL DEFAULT 0, reason varchar(60) NOT NULL DEFAULT '', ref_id bigint(20) DEFAULT NULL, ref_type varchar(30) DEFAULT NULL, created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), KEY idx_user_created (user_id, created_at), KEY idx_user_reason (user_id, reason), KEY idx_created (created_at) ) {$charset};"; // ── v2.12.4 Phase 4: Term + Comment misc bucket (catch-all flat) ─────── // Last-resort storage for unregistered term/comment meta keys, so // wp_termmeta / wp_commentmeta can become DROPpable in v3.0.0. // Composite PK (entity_id, meta_key) means each (entity, key) pair // has exactly one canonical row — UPSERT semantics on write. $sqls[] = "CREATE TABLE {$p}wpdo_term_misc ( term_id bigint(20) unsigned NOT NULL, meta_key varchar(191) NOT NULL, meta_value longtext, updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (term_id, meta_key), KEY meta_key (meta_key) ) {$charset};"; $sqls[] = "CREATE TABLE {$p}wpdo_comment_misc ( comment_id bigint(20) unsigned NOT NULL, meta_key varchar(191) NOT NULL, meta_value longtext, updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (comment_id, meta_key), KEY meta_key (meta_key) ) {$charset};"; foreach ( $sqls as $sql ) { dbDelta( $sql ); } if ( TMDO_IS_SQLITE ) { TMDO_SQLite_Compat::patch_table( $p . 'wpdo_audit', self::v2_audit_sqlite_cols() ); TMDO_SQLite_Compat::patch_table( $p . 'wpdo_shadow_diffs', self::v2_shadow_diffs_sqlite_cols() ); } // Composite indexes for member flat tables — MySQL-only, idempotent. if ( TMDO_IS_MYSQL ) { self::install_member_indexes(); } } /** * Add composite indexes to wp_wpdo_user_membership after Schema Manager * creates the flat table from Entity Registry field definitions. * * Three indexes enable the key 10M-scale query patterns: * idx_level_expires → WHERE level='gold' AND expires_at < NOW() * idx_expires_level → ORDER BY expires_at (scan all expiring this month) * idx_points_bal → ORDER BY points_balance DESC (leaderboard) * * Safe to call multiple times — skips existing indexes. * * @return void */ private static function install_member_indexes(): void { global $wpdb; $table = $wpdb->prefix . 'wpdo_user_membership'; // Skip if Schema Manager hasn't created the table yet. $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( ! $exists ) { return; } $desired = array( 'idx_level_expires' => 'ADD INDEX `idx_level_expires` (membership_level, membership_expires_at)', 'idx_expires_level' => 'ADD INDEX `idx_expires_level` (membership_expires_at, membership_level)', 'idx_points_bal' => 'ADD INDEX `idx_points_bal` (points_balance)', ); foreach ( $desired as $idx_name => $add_sql ) { $exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s', $table, $idx_name ) ); if ( ! $exists ) { $wpdb->query( "ALTER TABLE `{$table}` {$add_sql}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- table/index names are safe string literals with no user input } } } /** * SQLite column metadata for wpdo_audit (used by SQLite_Compat patcher). * * @return array */ private static function v2_audit_sqlite_cols(): array { return array( array( 'name' => 'id', 'type' => 'bigint', 'nullable' => false, 'default' => null, ), array( 'name' => 'ts', 'type' => 'datetime', 'nullable' => false, 'default' => '0000-00-00 00:00:00', ), array( 'name' => 'entity_type', 'type' => 'varchar', 'nullable' => false, 'default' => '', ), array( 'name' => 'entity_id', 'type' => 'bigint', 'nullable' => false, 'default' => '0', ), array( 'name' => 'op', 'type' => 'varchar', 'nullable' => false, 'default' => '', ), array( 'name' => 'trace_id', 'type' => 'varchar', 'nullable' => false, 'default' => '', ), ); } /** * SQLite column metadata for wpdo_shadow_diffs. * * @return array */ private static function v2_shadow_diffs_sqlite_cols(): array { return array( array( 'name' => 'id', 'type' => 'bigint', 'nullable' => false, 'default' => null, ), array( 'name' => 'entity_type', 'type' => 'varchar', 'nullable' => false, 'default' => '', ), array( 'name' => 'entity_id', 'type' => 'bigint', 'nullable' => false, 'default' => '0', ), array( 'name' => 'diff_hash', 'type' => 'varchar', 'nullable' => false, 'default' => '', ), ); } /** * Verify v2 tables exist. Used by upgrader pre-flight + tests. * * @return array Table name => exists. */ public static function v2_tables_status(): array { global $wpdb; $p = $wpdb->prefix; $tables = array( $p . 'wpdo_audit', $p . 'wpdo_shadow_diffs', $p . 'wpdo_site_metrics', $p . 'wpdo_uni_options', ); $status = array(); foreach ( $tables as $table ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); $status[ $table ] = $exists; } return $status; } /** * Called on plugin deactivation. Non-destructive. */ public static function deactivate(): void { wp_clear_scheduled_hook( 'wpdo_warm_cleanup' ); wp_clear_scheduled_hook( 'wpdo_archive_sweep' ); // v2.14.0: clear all plugin cron hooks (was incomplete pre-v2.14.0). wp_clear_scheduled_hook( 'wpdo_errors_gc' ); wp_clear_scheduled_hook( 'wpdo_flush_views' ); wp_clear_scheduled_hook( 'wpdo_remove_uae_plugin_dir' ); wp_clear_scheduled_hook( 'wpdo_daily_health_check' ); wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' ); wp_clear_scheduled_hook( 'wpdo_fsm_automator_run' ); wp_clear_scheduled_hook( 'wpdo_collect_site_metrics' ); wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' ); wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' ); wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' ); wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_comment_stress_test_batch' ); } // ── v2.14.0: Multisite-aware site cleanup ───────────────────────────────── /** * Drop all WPDO tables and clear options + cron for the *current* blog. * * Idempotent. Used by: * - `uninstall.php` (single-site delete-and-uninstall path) * - `on_site_delete()` / `on_uninitialize_site()` (multisite per-site cleanup) * - `uninstall.php` network branch via `switch_to_blog` loop * * Does NOT take pre-uninstall snapshot — that's the caller's responsibility * (snapshot only makes sense at uninstall, not at site-deletion). * * Validates each table name against `^[a-zA-Z0-9_]+$` + the `wpdo_` prefix * to prevent accidental DROP of unrelated tables if registry is poisoned. * * @return array{tables_dropped:int,options_deleted:int,crons_cleared:int} * @since 2.14.0 */ public static function drop_all_tables_for_current_blog(): array { global $wpdb; $tables_dropped = 0; $options_deleted = 0; $crons_cleared = 0; // ── 1) Static system + entity flat tables ───────────────────────────── $tables = array( $wpdb->prefix . 'wpdo_migrations', $wpdb->prefix . 'wpdo_errors', $wpdb->prefix . 'wpdo_benchmarks', $wpdb->prefix . 'wpdo_warm', $wpdb->prefix . 'wpdo_archive', $wpdb->prefix . 'wpdo_audit', $wpdb->prefix . 'wpdo_shadow_diffs', $wpdb->prefix . 'wpdo_site_metrics', $wpdb->prefix . 'wpdo_registry_meta', $wpdb->prefix . 'wpdo_uni_options', $wpdb->prefix . 'wpdo_wc_commissions', $wpdb->prefix . 'wpdo_snapshots', $wpdb->prefix . 'wpdo_migration_status', $wpdb->prefix . 'wpdo_user_points_ledger', $wpdb->prefix . 'wpdo_user_membership', $wpdb->prefix . 'wpdo_user_activity', $wpdb->prefix . 'wpdo_user_profile', $wpdb->prefix . 'wpdo_user_sso', $wpdb->prefix . 'wpdo_user_core_profile', $wpdb->prefix . 'wpdo_user_social', $wpdb->prefix . 'wpdo_user_commerce', $wpdb->prefix . 'wpdo_user_hp_user', $wpdb->prefix . 'wpdo_user_admin_prefs', $wpdb->prefix . 'wpdo_post_wp_core', $wpdb->prefix . 'wpdo_post_attachment', $wpdb->prefix . 'wpdo_post_wc_product', $wpdb->prefix . 'wpdo_post_hp_listing_core', $wpdb->prefix . 'wpdo_post_hp_request_core', $wpdb->prefix . 'wpdo_post_hp_vendor_core', $wpdb->prefix . 'wpdo_post_nav_menu_item', $wpdb->prefix . 'wpdo_hot_hp_listing', $wpdb->prefix . 'wpdo_term_hp_taxonomy', $wpdb->prefix . 'wpdo_comment_hp_review', $wpdb->prefix . 'wpdo_term_misc', $wpdb->prefix . 'wpdo_comment_misc', ); // ── 2) Dynamic zone + entity flat tables (per-site discovery) ───────── $hot_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_hot_' ) . '%'; $cold_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_cold_' ) . '%'; $entity_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_user_' ) . '%'; $post_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%'; $term_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_term_' ) . '%'; $comment_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_comment_' ) . '%'; if ( class_exists( 'WP_SQLite_Driver' ) ) { $dynamic = $wpdb->get_col( $wpdb->prepare( "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s)", $hot_like, $cold_like, $entity_like, $post_ent_like, $term_ent_like, $comment_ent_like ) ); } else { $dynamic = $wpdb->get_col( $wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)', $hot_like, $cold_like, $entity_like, $post_ent_like, $term_ent_like, $comment_ent_like ) ); } $tables = array_unique( array_merge( $tables, $dynamic ?: array() ) ); // ── 3) Validate + filter existing-only + DROP ───────────────────────── // Pre-check existence so the returned count reflects ACTUAL drops, not // `DROP IF EXISTS` attempts. `$dynamic` is already pre-filtered (came // from information_schema), but the static list may contain non-existent // tables that would inflate the counter. $prefix = $wpdb->prefix . 'wpdo_'; $valid_static = array(); foreach ( $tables as $table ) { if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) { continue; } $valid_static[] = $table; } if ( ! empty( $valid_static ) ) { // Single information_schema lookup to confirm which actually exist. $placeholders = implode( ',', array_fill( 0, count( $valid_static ), '%s' ) ); if ( class_exists( 'WP_SQLite_Driver' ) ) { $existing = $wpdb->get_col( $wpdb->prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ...$valid_static ) ); } else { $existing = $wpdb->get_col( $wpdb->prepare( "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ...$valid_static ) ); } $existing = (array) $existing; } else { $existing = array(); } foreach ( $existing as $table ) { // Re-validate before raw interpolation as a defence-in-depth measure. if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) { continue; } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); ++$tables_dropped; } // ── 4) Clear options ────────────────────────────────────────────────── $known_options = array( 'wpdo_db_version', 'wpdo_features', 'wpdo_features_shadow', 'wpdo_hpct_imported', 'wpdo_hook_bus_enabled', 'wpdo_v2_features_backup', 'wpdo_v2_upgrade_status', 'wpdo_v2_upgrade_error', 'wpdo_v2_upgraded_at', 'wpdo_health_alert', 'wpdo_rl_stats', 'wpdo_setup_wizard_completed', 'wpdo_first_run_at', // Stress test state. 'wpdo_post_stress_test_state', 'wpdo_user_stress_test_state', 'wpdo_term_stress_test_state', 'wpdo_comment_stress_test_state', ); foreach ( $known_options as $opt ) { if ( delete_option( $opt ) ) { ++$options_deleted; } } // Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0 // or by 3rd-party hooks). Validates name pattern before delete to avoid // accidental option removal. $residual = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'wpdo_' ) . '%' ) ); foreach ( (array) $residual as $opt ) { if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) { ++$options_deleted; } } // ── 5) Clear cron events ────────────────────────────────────────────── $crons = array( 'wpdo_warm_cleanup', 'wpdo_archive_sweep', 'wpdo_errors_gc', 'wpdo_flush_views', 'wpdo_remove_uae_plugin_dir', 'wpdo_daily_health_check', 'wpdo_snapshot_prune_daily', 'wpdo_fsm_automator_run', 'wpdo_collect_site_metrics', 'wpdo_health_snapshot_monthly', 'wpdo_post_shadow_verify', 'wpdo_term_comment_shadow_verify', 'wpdo_post_stress_test_batch', 'wpdo_user_stress_test_batch', 'wpdo_term_stress_test_batch', 'wpdo_comment_stress_test_batch', ); foreach ( $crons as $hook ) { if ( false !== wp_next_scheduled( $hook ) ) { wp_clear_scheduled_hook( $hook ); ++$crons_cleared; } } return array( 'tables_dropped' => $tables_dropped, 'options_deleted' => $options_deleted, 'crons_cleared' => $crons_cleared, ); } // ── Dynamic Zone Table Creation ────────────────────────────────────── /** * Ensure the Zone A (hot) table has all columns declared by Schema_Registry. * * V2.1.2 critical fix: when a partner plugin registers new hot-zone fields * AFTER the initial table creation, those columns never make it to the DB * → WPDO silently falls back to postmeta → zero advertised speedup. * * This method: * 1. Reads current columns from the existing table (SHOW COLUMNS / pragma) * 2. Diffs against $expected_columns (from Schema_Registry) * 3. ALTER TABLE ADD COLUMN for any missing columns (idempotent) * 4. Returns the list of columns that were added (for logging / doctor) * * Safe to call on every page load — diff is fast (~0.5ms), ALTER only fires * on actual drift. * * @param string $post_type Post type slug. * @param array $expected_columns Schema_Registry's declared columns: [name => sql_type]. * @return array Names of columns added (empty when no drift). * * @since 2.1.2 */ public static function ensure_hot_columns( string $post_type, array $expected_columns ): array { global $wpdb; $table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type ); // Skip if table doesn't exist — create_hot_table will handle it. if ( TMDO_IS_SQLITE ) { $exists = $wpdb->get_var( $wpdb->prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name=%s", $table ) ); } else { $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table ) ); } if ( ! $exists ) { return array(); } // Read existing columns. $existing = array(); if ( TMDO_IS_SQLITE ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results( "PRAGMA table_info(`{$table}`)", ARRAY_A ); foreach ( (array) $rows as $row ) { $existing[ $row['name'] ] = true; } } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A ); foreach ( (array) $rows as $row ) { $existing[ $row['Field'] ] = true; } } // Diff: which expected columns are missing from the DB? $added = array(); foreach ( $expected_columns as $col_name => $col_type ) { $safe_name = sanitize_key( $col_name ); if ( isset( $existing[ $safe_name ] ) ) { continue; } // MySQL/MariaDB ALTER TABLE ADD COLUMN. SQLite supports the same syntax. // Suppress PHP warnings during the ALTER — concurrent races on cold deploy // (N php-fpm workers each detecting drift simultaneously) cause the loser // to error with "Duplicate column name". We tolerate that case (the column // IS now present) but surface real failures (disk full, permission denied). $prev_show = $wpdb->show_errors ?? false; if ( method_exists( $wpdb, 'hide_errors' ) ) { $wpdb->hide_errors(); } self::validate_col_type( $col_type, $safe_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared $ok = $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$safe_name}` {$col_type}" ); if ( $prev_show && method_exists( $wpdb, 'show_errors' ) ) { $wpdb->show_errors(); } if ( false !== $ok ) { $added[] = $safe_name; continue; } // v2.1.3 race tolerance: "Duplicate column name" (MySQL errno 1060) means // another worker won the race. The column IS present now — confirm and treat // as success. Real failures (errno != 1060, e.g. 1142 access denied, 1114 // table full) still get logged as errors with SQLSTATE/errno for ops. $err = (string) ( $wpdb->last_error ?? '' ); $is_duplicate = stripos( $err, 'Duplicate column' ) !== false || stripos( $err, 'duplicate column' ) !== false || stripos( $err, '1060' ) !== false; // MySQL errno. if ( $is_duplicate ) { $added[] = $safe_name; // race winner already added it; we're consistent. continue; } TMDO_Logger::error( 'installer', 'ensure_hot_columns', "Failed to ALTER TABLE {$table} ADD {$safe_name}: {$err}" ); } // Re-add covering indexes to pick up any newly indexed columns. if ( ! empty( $added ) && TMDO_IS_MYSQL ) { self::add_covering_indexes( $post_type, $expected_columns ); } return $added; } /** * Create a Zone A (hot) table for a specific post type. * * @param string $post_type Post type slug (e.g. 'hp_listing'). * @param array $columns Column definitions from Schema Registry. Format: [ 'column_name' => 'column_type_sql', ... ]. * @return void */ public static function create_hot_table( string $post_type, array $columns ): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $table_name = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type ); $col_defs = " id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n"; $col_defs .= " post_id bigint(20) unsigned NOT NULL DEFAULT 0,\n"; $index_defs = array(); $sqlite_cols = array( array( 'name' => 'id', 'type' => 'bigint', 'nullable' => false, 'default' => null, ), array( 'name' => 'post_id', 'type' => 'bigint', 'nullable' => false, 'default' => '0', ), ); foreach ( $columns as $col_name => $col_type ) { $safe_name = sanitize_key( $col_name ); self::validate_col_type( $col_type, $safe_name ); $col_defs .= " {$safe_name} {$col_type},\n"; $base_type = strtolower( strtok( $col_type, '(' ) ); $sqlite_cols[] = array( 'name' => $safe_name, 'type' => $base_type, 'nullable' => str_contains( strtolower( $col_type ), 'null' ) && ! str_contains( strtolower( $col_type ), 'not null' ), 'default' => '0', ); } $col_defs .= " updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',\n"; $sqlite_cols[] = array( 'name' => 'updated_at', 'type' => 'datetime', 'nullable' => false, 'default' => '0000-00-00 00:00:00', ); $indexes = " PRIMARY KEY (id),\n UNIQUE KEY ui_post_id (post_id)"; foreach ( $index_defs as $idx ) { $indexes .= ",\n {$idx}"; } $sql = "CREATE TABLE {$table_name} (\n{$col_defs}{$indexes}\n) {$charset};"; dbDelta( $sql ); if ( TMDO_IS_SQLITE ) { TMDO_SQLite_Compat::patch_table( $table_name, $sqlite_cols ); } // Add covering indexes after table creation (MySQL only). if ( TMDO_IS_MYSQL ) { self::add_covering_indexes( $post_type, $columns ); } } /** * Add covering indexes to an existing Zone A (hot) table. * * Index strategy: * - DECIMAL/FLOAT columns → single-column idx for range + ORDER BY * - TINYINT + DECIMAL → compound (flag, sort) for filtered sorts * - BIGINT *_time + DECIMAL → compound (time, sort) for expiry + price * - TINYINT matching *_featured + BIGINT *_featured_time → compound (flag, time) * * Safe to call multiple times — skips already-existing indexes. * No-op on SQLite. * * @param string $post_type Post type slug. * @param array $columns Column name => SQL type from Schema Registry. */ public static function add_covering_indexes( string $post_type, array $columns ): void { if ( TMDO_IS_SQLITE ) { return; } global $wpdb; $table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type ); // Categorise columns by SQL type. $decimal_cols = array(); // decimal/float/double → good for range + ORDER BY. $tinyint_cols = array(); // tinyint → boolean flags. $bigint_time = array(); // bigint with _time suffix → timestamps. foreach ( $columns as $col_name => $col_type ) { $safe = sanitize_key( $col_name ); $base = strtolower( strtok( $col_type, '( ' ) ); if ( in_array( $base, array( 'decimal', 'float', 'double' ), true ) ) { $decimal_cols[] = $safe; } elseif ( 'tinyint' === $base ) { $tinyint_cols[] = $safe; } elseif ( 'bigint' === $base && str_ends_with( $safe, '_time' ) ) { $bigint_time[] = $safe; } } // Build desired index map: name => ADD INDEX SQL fragment. $desired = array(); // 1. Single-column index on every decimal column. foreach ( $decimal_cols as $col ) { $desired[ "idx_{$col}" ] = "ADD INDEX `idx_{$col}` (`{$col}`)"; } // 2. Compound (tinyint_flag, decimal_sort) — improves "WHERE flag=1 ORDER BY price". foreach ( $tinyint_cols as $flag ) { foreach ( $decimal_cols as $sort ) { $name = "idx_{$flag}_{$sort}"; $desired[ $name ] = "ADD INDEX `{$name}` (`{$flag}`, `{$sort}`)"; } } // 3. Compound (bigint_time, decimal_sort) — improves "WHERE exp_time > ? ORDER BY price". foreach ( $bigint_time as $time_col ) { foreach ( $decimal_cols as $sort ) { $name = "idx_{$time_col}_{$sort}"; $desired[ $name ] = "ADD INDEX `{$name}` (`{$time_col}`, `{$sort}`)"; } // 4. Compound (matching_tinyint_flag, bigint_time) — "WHERE featured=1 ORDER BY featured_time". // Matches pattern: tinyint column name is prefix of time column (hp_featured → hp_featured_time). foreach ( $tinyint_cols as $flag ) { if ( str_starts_with( $time_col, $flag . '_' ) ) { $name = "idx_{$flag}_{$time_col}"; $desired[ $name ] = "ADD INDEX `{$name}` (`{$flag}`, `{$time_col}`)"; } } } if ( empty( $desired ) ) { return; } // Apply only missing indexes. foreach ( $desired as $idx_name => $add_sql ) { $exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s', $table, $idx_name ) ); if ( ! $exists ) { $wpdb->query( "ALTER TABLE `{$table}` {$add_sql}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- table/index names are validated via sanitize_key(); add_sql contains no user input. } } } /** * Create a Zone C (cold) table for a specific post type. * * @param string $post_type Post type slug (e.g. 'hp_vendor'). */ public static function create_cold_table( string $post_type ): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $table_name = $wpdb->prefix . 'wpdo_cold_' . sanitize_key( $post_type ); $sql = "CREATE TABLE {$table_name} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_id bigint(20) unsigned NOT NULL DEFAULT 0, data longtext NOT NULL, updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), UNIQUE KEY ui_post_id (post_id) ) {$charset};"; dbDelta( $sql ); if ( TMDO_IS_SQLITE ) { TMDO_SQLite_Compat::patch_table( $table_name, array( array( 'name' => 'id', 'type' => 'bigint', 'nullable' => false, 'default' => null, ), array( 'name' => 'post_id', 'type' => 'bigint', 'nullable' => false, 'default' => '0', ), array( 'name' => 'data', 'type' => 'longtext', 'nullable' => false, 'default' => null, ), array( 'name' => 'updated_at', 'type' => 'datetime', 'nullable' => false, 'default' => '0000-00-00 00:00:00', ), ) ); } } // ── Layer 1: dbDelta ───────────────────────────────────────────────── /** * Runs dbDelta to create or upgrade all WPDO tables. * * @return void */ private static function run_dbdelta(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $p = $wpdb->prefix; $sqls = array(); // ── wpdo_migrations ────────────────────────────────────────────── $sqls[] = "CREATE TABLE {$p}wpdo_migrations ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, module varchar(50) NOT NULL DEFAULT '', zone varchar(10) NOT NULL DEFAULT '', state varchar(20) NOT NULL DEFAULT 'idle', total_rows bigint(20) unsigned NOT NULL DEFAULT 0, processed_rows bigint(20) unsigned NOT NULL DEFAULT 0, last_offset bigint(20) unsigned NOT NULL DEFAULT 0, error_count int(11) NOT NULL DEFAULT 0, started_at datetime NULL DEFAULT NULL, completed_at datetime NULL DEFAULT NULL, created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), KEY idx_module (module) ) {$charset};"; // ── wpdo_errors ────────────────────────────────────────────────── $sqls[] = "CREATE TABLE {$p}wpdo_errors ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, module varchar(50) NOT NULL DEFAULT '', zone varchar(10) NOT NULL DEFAULT '', hook varchar(255) NOT NULL DEFAULT '', message longtext NOT NULL, context longtext, created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), KEY idx_module (module), KEY idx_created_at (created_at) ) {$charset};"; // ── wpdo_benchmarks ────────────────────────────────────────────── $sqls[] = "CREATE TABLE {$p}wpdo_benchmarks ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, module varchar(50) NOT NULL DEFAULT '', zone varchar(10) NOT NULL DEFAULT '', query_type varchar(50) NOT NULL DEFAULT '', native_ms decimal(10,3) NOT NULL DEFAULT 0, custom_ms decimal(10,3) NOT NULL DEFAULT 0, sample_size int(11) NOT NULL DEFAULT 0, created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), KEY idx_module (module) ) {$charset};"; // ── wpdo_warm (Zone B) ─────────────────────────────────────────── $sqls[] = "CREATE TABLE {$p}wpdo_warm ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_id bigint(20) unsigned NOT NULL DEFAULT 0, meta_key varchar(255) NOT NULL DEFAULT '', meta_value longtext, expires_at datetime NULL DEFAULT NULL, created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), KEY idx_post_meta (post_id, meta_key(191)), KEY idx_expires_at (expires_at) ) {$charset};"; // ── wpdo_archive (Zone D) ──────────────────────────────────────── $sqls[] = "CREATE TABLE {$p}wpdo_archive ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_id bigint(20) unsigned NOT NULL DEFAULT 0, post_type varchar(20) NOT NULL DEFAULT '', meta_key varchar(255) NOT NULL DEFAULT '', meta_value longtext, compressed tinyint(1) NOT NULL DEFAULT 0, archived_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_post_id (post_id), KEY idx_post_type_date (post_type, archived_at) ) {$charset};"; foreach ( $sqls as $sql ) { dbDelta( $sql ); } } // ── Layer 2: MySQL-only composite indexes ──────────────────────────── /** * Adds MySQL-only composite indexes if they do not already exist. * * @return void */ private static function run_mysql_indexes(): void { global $wpdb; $p = $wpdb->prefix; $indexes = array( "{$p}wpdo_migrations" => array( 'ui_module' => "ALTER TABLE `{$p}wpdo_migrations` ADD UNIQUE KEY `ui_module` (module)", ), "{$p}wpdo_warm" => array( 'ui_post_meta' => "ALTER TABLE `{$p}wpdo_warm` ADD UNIQUE KEY `ui_post_meta` (post_id, meta_key(191))", ), ); foreach ( $indexes as $table => $defs ) { foreach ( $defs as $key_name => $sql ) { $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s', $table, $key_name ) ); if ( ! $exists ) { $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } } } } }