chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -0,0 +1,437 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform shadow verifier: must read raw meta to verify zone correctness
/**
* TMDO_Post_Shadow_Verifier — Sample-and-compare flat vs wp_postmeta (v2.10.3).
*
* Companion to TMDO_Post_Migration. Used during shadow_read mode to verify
* the flat tables stay in sync with wp_postmeta. Each sample picks a random
* post + key, fetches both values (flat row vs wp_postmeta row), and counts
* matches / diffs / missing rows. Divergent values are written to the
* shared `wpdo_shadow_diffs` table via `TMDO_Shadow_Diff_Logger` (which is
* already entity_type-aware — pass 'post' to differentiate from user diffs).
*
* Run pattern:
* - Manual: `wp wpdo post-shadow-report`
* - Cron: wpdo_post_shadow_verify event registered when post mode is
* shadow_read; auto-unregistered when mode changes to anything else
*
* 🔒 Frozen contract: never touches user-side flat tables, never modifies
* wp_postmeta or wp_posts (read-only verifier).
*
* @package WP_Data_Optimizer
* @since 2.10.3
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Verifier is read-only.
/**
* Sample-and-compare verifier for post entity flat tables.
*/
final class TMDO_Post_Shadow_Verifier {
/**
* Cron event hook fired hourly when post mode is shadow_read.
*/
public const CRON_HOOK = 'wpdo_post_shadow_verify';
/**
* Default sample size per cron tick (capped to available post count).
*/
public const DEFAULT_SAMPLE_SIZE = 100;
/**
* Run a sample-compare pass.
*
* Picks $sample_size random posts of $post_type, fetches their flat row
* + their wp_postmeta values for each $keys entry, and counts matches.
* Divergences are recorded via TMDO_Shadow_Diff_Logger when the class
* is available.
*
* @param string $post_type WP post_type to sample.
* @param string $group_name Entity group name (e.g. 'wc_product').
* @param string $flat_table Fully qualified flat table name.
* @param string[] $keys Meta keys to compare (each post checks all).
* @param int $sample_size Number of posts to sample (capped to DB count).
* @return array{
* sampled:int,
* matched:int,
* diffs:int,
* missing_flat:int,
* missing_postmeta:int,
* group:string,
* post_type:string,
* }
* @throws InvalidArgumentException When inputs invalid.
*/
public static function sample_compare(
string $post_type,
string $group_name,
string $flat_table,
array $keys,
int $sample_size = self::DEFAULT_SAMPLE_SIZE
): array {
if ( $sample_size <= 0 ) {
$msg = 'Sample size must be > 0';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
if ( empty( $keys ) ) {
$msg = 'Keys array cannot be empty';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
global $wpdb;
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s ORDER BY RAND() LIMIT %d",
$post_type,
$sample_size
)
);
$sampled = count( $post_ids );
$matched = 0;
$diffs = 0;
$missing_flat = 0;
$missing_postmeta = 0;
if ( 0 === $sampled ) {
return array(
'sampled' => 0,
'matched' => 0,
'diffs' => 0,
'missing_flat' => 0,
'missing_postmeta' => 0,
'group' => $group_name,
'post_type' => $post_type,
);
}
foreach ( $post_ids as $post_id ) {
$post_id = (int) $post_id;
foreach ( $keys as $key ) {
$col = self::sanitize_column( $key );
$flat_val = $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$col}` FROM `{$flat_table}` WHERE post_id = %d LIMIT 1",
$post_id
)
);
$pm_val = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
$post_id,
$key
)
);
$flat_present = null !== $flat_val && '' !== $flat_val;
$pm_present = null !== $pm_val && '' !== $pm_val;
if ( ! $flat_present && ! $pm_present ) {
// Both empty — neither side has the value, count as match
// (key truly absent for this post).
++$matched;
continue;
}
if ( ! $flat_present && $pm_present ) {
++$missing_flat;
self::log_diff( $post_id, $key, (string) $pm_val, '(missing)' );
continue;
}
if ( $flat_present && ! $pm_present ) {
++$missing_postmeta;
self::log_diff( $post_id, $key, '(missing)', (string) $flat_val );
continue;
}
// Loose equality — flat may have widened types (e.g. tinyint→bigint)
// or full-precision decimals (10,2 → 18,6).
if ( self::values_loose_equal( $pm_val, $flat_val ) ) {
++$matched;
} else {
++$diffs;
self::log_diff( $post_id, $key, (string) $pm_val, (string) $flat_val );
}
}
}
return array(
'sampled' => $sampled,
'matched' => $matched,
'diffs' => $diffs,
'missing_flat' => $missing_flat,
'missing_postmeta' => $missing_postmeta,
'group' => $group_name,
'post_type' => $post_type,
);
}
/**
* Cron handler: runs sample_compare for each registered group when post
* mode is shadow_read. No-op otherwise.
*
* @return void
*/
public static function cron_tick(): void {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return;
}
if ( 'shadow_read' !== TMDO_Mode_Manager::get( 'post' ) ) {
return;
}
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
global $wpdb;
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
$keys = TMDO_Entity_Registry::get_group_keys( 'post', $group );
if ( empty( $keys ) ) {
continue;
}
$post_type = self::group_post_type( $group );
if ( null === $post_type ) {
continue; // wp_core spans all types, skip in cron tick.
}
$flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
try {
self::sample_compare( $post_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
} catch ( \Throwable $e ) {
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::error(
'post_shadow_verifier',
'cron_tick',
$e->getMessage()
);
}
}
}
}
/**
* Recent diff records for entity_type='post'.
*
* @param int $limit Max rows.
* @return array
*/
public static function recent_diffs( int $limit = 100 ): array {
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
return array();
}
return TMDO_Shadow_Diff_Logger::recent( $limit, 'post' );
}
/**
* Aggregate diff stats for entity_type='post' over recent N hours.
*
* @param int $hours Window in hours (default 24).
* @return array{total:int,by_key:array<string,int>}
*/
public static function diff_stats( int $hours = 24 ): array {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return array(
'total' => 0,
'by_key' => array(),
);
}
$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );
$total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s",
$since
)
);
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s GROUP BY meta_key ORDER BY n DESC",
$since
),
ARRAY_A
);
$by_key = array();
foreach ( (array) $rows as $row ) {
$by_key[ (string) $row['meta_key'] ] = (int) $row['n'];
}
return array(
'total' => $total,
'by_key' => $by_key,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
*
* @param string $key Meta key.
* @return string
*/
private static function sanitize_column( string $key ): string {
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
return TMDO_Schema_Manager::sanitize_column_name( $key );
}
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
}
/**
* Map entity group → primary post_type (null = cross-cutting like wp_core).
*
* @param string $group Entity group name.
* @return string|null
*/
private static function group_post_type( string $group ): ?string {
switch ( $group ) {
case 'attachment':
return 'attachment';
case 'wc_product':
return 'product';
case 'hp_listing_core':
return 'hp_listing';
case 'hp_request_core':
return 'hp_request';
case 'hp_vendor_core':
return 'hp_vendor';
case 'nav_menu_item':
return 'nav_menu_item';
case 'wp_core':
default:
return null;
}
}
/**
* Loose equality for verifier — handles widened types (decimal precision,
* tinyint→bigint, etc.) and serialization-format differences without
* false-positive divergence reports.
*
* Layers (fail-fast on first match):
* 1. Identical strings
* 2. Numeric loose match (handles "50" vs "50.000000")
* 3. v2.10.5: Decoded match for serialized vs JSON values
* (postmeta uses PHP serialize, flat tables use wp_json_encode for
* json-typed fields — same logical data, different storage format)
*
* @param mixed $a Side A value.
* @param mixed $b Side B value.
* @return bool
*/
private static function values_loose_equal( $a, $b ): bool {
// Both null/empty already filtered out by caller.
$as = (string) $a;
$bs = (string) $b;
if ( $as === $bs ) {
return true;
}
// Numeric loose match (handles "50" vs "50.000000").
if ( is_numeric( $as ) && is_numeric( $bs ) ) {
return (float) $as === (float) $bs;
}
// v2.10.5: try decoded comparison for serialized/JSON values.
$a_decoded = self::decode_value( $as );
$b_decoded = self::decode_value( $bs );
if ( ( null !== $a_decoded || null !== $b_decoded ) && $a_decoded === $b_decoded ) {
return true;
}
return false;
}
/**
* Best-effort decode of a string value: try PHP unserialize (object-safe),
* then JSON. Returns the decoded structure, or null when neither succeeds
* (so the caller can short-circuit instead of false-positive matching
* against a literal "null" string).
*
* Returns null in three cases:
* - Both decoders failed (input is plain non-encoded string)
* - unserialize returned NULL legitimately (input was 'N;')
* - Empty string
*
* The caller distinguishes these by combining with an identical-string
* fast-path that runs first; non-encoded strings hit equality before this
* decoder runs.
*
* @param string $s Raw string value.
* @return mixed|null Decoded value, or null on failure.
*/
private static function decode_value( string $s ) {
if ( '' === $s ) {
return null;
}
// PHP serialize: 'a:N:{...}' / 'O:N:"...":...' / 'i:N;' / 's:N:"..."' / 'b:0|1;' / 'N;' / 'd:N;'.
if ( preg_match( '/^[aOidsbN]:/', $s ) || 'N;' === $s ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors,Generic.PHP.NoSilencedErrors,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection; @ swallows malformed-payload notices.
$v = @unserialize( $s, array( 'allowed_classes' => false ) );
if ( false !== $v || 'b:0;' === $s ) {
return $v;
}
}
// JSON: arrays/objects start with [ or {.
$first = $s[0] ?? '';
if ( '[' === $first || '{' === $first ) {
$v = json_decode( $s, true );
if ( null !== $v ) {
return $v;
}
}
return null;
}
/**
* Record a divergence via the shared shadow diff logger.
*
* @param int $post_id Post ID.
* @param string $key Meta key.
* @param string $pm_val Postmeta value (or '(missing)').
* @param string $flat_val Flat value (or '(missing)').
* @return void
*/
private static function log_diff( int $post_id, string $key, string $pm_val, string $flat_val ): void {
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
return;
}
// Use the public record() method if the logger exposes one; otherwise
// fall through silently. v2.5.x exposes compare_and_log() which does
// internal compare; for verifier we already know they diverged so we
// write directly to the table.
global $wpdb;
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return;
}
$wpdb->insert(
$table,
array(
'entity_type' => 'post',
'entity_id' => $post_id,
'meta_key' => $key,
'postmeta_value' => substr( $pm_val, 0, 1000 ),
'zone_value' => substr( $flat_val, 0, 1000 ),
'diff_hash' => md5( $pm_val . '|' . $flat_val ),
'ts' => gmdate( 'Y-m-d H:i:s' ),
)
);
}
}