Files
2meet-data-optimizer/cli/class-tmdo-cli-post.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

855 lines
27 KiB
PHP

<?php
declare(strict_types=1);
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
/**
* TMDO_CLI_Post — Post entity CLI subcommands (v2.9.0+).
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo postmeta-cleanup — Clean wp_postmeta garbage (transients,
* _wp_old_date, stale _edit_lock).
*
* Mirrors TMDO_CLI_Member's design: thin wrapper around core class
* (TMDO_Postmeta_Cleaner). Future post-entity subcommands (post-audit,
* post-backfill, post-cutover) will live in this same file.
*
* @package WP_Data_Optimizer
* @since 2.9.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital -- WP_CLI callbacks accept ($args, $assoc_args) by contract; many subcommands only need one.
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
/**
* Post entity CLI subcommands. Loaded only in WP_CLI context.
*/
class TMDO_CLI_Post {
// ── postmeta-cleanup ──────────────────────────────────────────────────────
/**
* Clean wp_postmeta garbage rows (transients, _wp_old_date, stale _edit_lock).
*
* Phase 0 of v2.9.0 Post Entity migration. Runs before any Entity Bridge
* work so subsequent ratio measurements reflect real data, not garbage.
*
* SAFETY: by default this command refuses to run. Pass --dry-run to
* preview row counts without deleting, or --confirm to actually delete.
*
* ## OPTIONS
*
* [--target=<target>]
* : Which garbage class to address. Default: all.
* ---
* default: all
* options:
* - all
* - transients
* - wp_old_date
* - edit_locks
* ---
*
* [--dry-run]
* : Show row counts without deleting.
*
* [--confirm]
* : Required to actually DELETE rows. Mutually exclusive with --dry-run.
*
* ## EXAMPLES
*
* wp wpdo postmeta-cleanup --dry-run
* wp wpdo postmeta-cleanup --target=transients --dry-run
* wp wpdo postmeta-cleanup --confirm
* wp wpdo postmeta-cleanup --target=edit_locks --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function postmeta_cleanup( $args, $assoc_args ): void {
$target = (string) ( $assoc_args['target'] ?? TMDO_Postmeta_Cleaner::TARGET_ALL );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( ! in_array( $target, TMDO_Postmeta_Cleaner::VALID_TARGETS, true ) ) {
WP_CLI::error(
'Invalid --target. Choose: ' . implode( ', ', TMDO_Postmeta_Cleaner::VALID_TARGETS )
);
}
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
// Default safety: refuse to run without an explicit choice.
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
'Example: wp wpdo postmeta-cleanup --dry-run'
);
}
if ( $dry_run ) {
$counts = TMDO_Postmeta_Cleaner::count_garbage( $target );
self::render_table( $counts, 'would delete' );
WP_CLI::success(
sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] )
);
return;
}
// $confirm path.
$deleted = TMDO_Postmeta_Cleaner::delete_garbage( $target );
self::render_table( $deleted, 'deleted' );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'postmeta_cleanup',
array(
'target' => $target,
'transients' => $deleted['transients'],
'wp_old_date' => $deleted['wp_old_date'],
'edit_locks' => $deleted['edit_locks'],
'total' => $deleted['total'],
)
);
}
WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_postmeta.', $deleted['total'] ) );
}
/**
* Render a table of bucket → count.
*
* @param array $counts Output from TMDO_Postmeta_Cleaner::count_garbage() / delete_garbage().
* @param string $verb Column header verb ('would delete' / 'deleted').
*/
private static function render_table( array $counts, string $verb ): void {
$rows = array(
array(
'bucket' => 'transients',
$verb => $counts['transients'],
'rule' => '_transient_% OR _transient_timeout_%',
),
array(
'bucket' => 'wp_old_date',
$verb => $counts['wp_old_date'],
'rule' => "meta_key = '_wp_old_date'",
),
array(
'bucket' => 'edit_locks',
$verb => $counts['edit_locks'],
'rule' => '_edit_lock older than 24h',
),
array(
'bucket' => 'TOTAL',
$verb => $counts['total'],
'rule' => '',
),
);
WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) );
}
// ── post-diagnose (v2.9.3) ────────────────────────────────────────────────
/**
* Show wp_posts:wp_postmeta ratio + per-group EAV residue / flat row count.
*
* Read-only command. Mirrors `wp wpdo member-audit` for the post entity.
*
* ## EXAMPLES
*
* wp wpdo post-diagnose
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments (unused).
*/
public function post_diagnose( $args, $assoc_args ): void {
$result = TMDO_Post_Migration::diagnose();
WP_CLI::log( sprintf( 'Posts: %s', number_format_i18n( $result['posts'] ) ) );
WP_CLI::log( sprintf( 'Postmeta: %s', number_format_i18n( $result['postmeta'] ) ) );
WP_CLI::log( sprintf( 'Ratio: 1:%s', $result['ratio'] ) );
WP_CLI::log( sprintf( 'Mode: %s', $result['mode'] ) );
WP_CLI::log( '' );
$rows = array();
foreach ( $result['groups'] as $name => $g ) {
$rows[] = array(
'group' => $name,
'post_type' => $g['post_type'] ?: '(any)',
'keys' => count( $g['keys'] ),
'eav_rows' => $g['eav_rows'],
'flat_rows' => $g['flat_rows'],
);
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'post_type', 'keys', 'eav_rows', 'flat_rows' ) );
}
// ── post-migrate-group (v2.9.3) ───────────────────────────────────────────
/**
* Backfill one post entity group from wp_postmeta into its flat table
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE).
*
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the
* row-by-row backfill phase in v2.9.4+).
*
* ## OPTIONS
*
* --group=<group>
* : Entity group name. Required.
* ---
* options:
* - wp_core
* - attachment
* - wc_product
* - hp_listing_core
* - hp_request_core
* - hp_vendor_core
* - nav_menu_item
* ---
*
* ## EXAMPLES
*
* wp wpdo post-migrate-group --group=wc_product
* wp wpdo post-migrate-group --group=hp_listing_core
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_migrate_group( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? '' );
if ( '' === $group ) {
WP_CLI::error( 'Missing required --group=<name>.' );
}
try {
$result = TMDO_Post_Migration::backfill_group( $group );
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_migrate_group',
array(
'group' => $result['group'],
'post_type' => $result['post_type'],
'migrated' => $result['migrated'],
)
);
}
WP_CLI::success(
sprintf(
'Backfilled %s (post_type=%s): %d post(s) migrated.',
$result['group'],
$result['post_type'] ?: '(any)',
$result['migrated']
)
);
}
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
/**
* DELETE managed-key wp_postmeta rows after cutover. Requires post mode
* to be aeav_only (the flat table is the authoritative source).
*
* SAFETY: requires --confirm.
*
* ## OPTIONS
*
* [--dry-run]
* : Preview row count only.
*
* [--confirm]
* : Required to actually DELETE rows.
*
* ## EXAMPLES
*
* wp wpdo post-cleanup --dry-run
* wp wpdo post-cleanup --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_cleanup( $args, $assoc_args ): void {
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
'Example: wp wpdo post-cleanup --dry-run'
);
}
$diagnose = TMDO_Post_Migration::diagnose();
$keys = TMDO_Post_Migration::get_managed_keys();
global $wpdb;
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$sql = "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$candidate = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$keys ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
if ( $dry_run ) {
WP_CLI::log( sprintf( 'Mode: %s', $diagnose['mode'] ) );
WP_CLI::log( sprintf( 'Managed keys: %d', count( $keys ) ) );
WP_CLI::success(
sprintf(
'[dry-run] %d wp_postmeta row(s) would be deleted. Re-run with --confirm.',
$candidate
)
);
return;
}
// $confirm path — TMDO_Post_Migration::cleanup() enforces mode=aeav_only.
try {
$result = TMDO_Post_Migration::cleanup();
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_cleanup',
array(
'deleted' => $result['deleted'],
)
);
}
WP_CLI::success( sprintf( 'Deleted %d wp_postmeta row(s).', $result['deleted'] ) );
}
// ── cleanup-hp-transients (v2.11.5) ───────────────────────────────────────
/**
* Purge legacy `_transient_hp_*` rows from wp_postmeta.
*
* HivePress (`hivepress/includes/components/class-cache.php`) writes
* per-post TTL caches as `_transient_<name>` / `_transient_timeout_<name>`
* postmeta rows. v2.11.5 ships `TMDO_Hivepress_Transient_Filter` to
* intercept new writes and reroute them to wp_options. This command does
* the one-time historical cleanup — DELETE-ing all such existing rows
* from wp_postmeta. After cleanup, HivePress re-fetches on demand.
*
* Safe to run with the filter enabled; the filter prevents new postmeta
* writes from re-bloating the table.
*
* ## OPTIONS
*
* [--dry-run]
* : Preview row count only.
*
* [--confirm]
* : Required to actually DELETE rows.
*
* ## EXAMPLES
*
* wp wpdo cleanup-hp-transients --dry-run
* wp wpdo cleanup-hp-transients --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function cleanup_hp_transients( $args, $assoc_args ): void {
unset( $args );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without explicit choice. Pass --dry-run or --confirm.\n" .
'Example: wp wpdo cleanup-hp-transients --dry-run'
);
}
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
}
$count = TMDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows();
if ( $dry_run ) {
WP_CLI::success(
sprintf(
'[dry-run] %d HivePress transient row(s) in wp_postmeta would be deleted. Re-run with --confirm.',
$count
)
);
return;
}
$deleted = TMDO_Hivepress_Transient_Filter::purge_legacy_postmeta_rows();
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'cleanup_hp_transients',
array( 'deleted' => $deleted )
);
}
WP_CLI::success(
sprintf(
'Deleted %d HivePress transient row(s) from wp_postmeta. Future writes auto-route to wp_options.',
$deleted
)
);
}
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
/**
* Non-destructive copy of a legacy `wpdo_hot_<post_type>` zone table
* into the new `wp_wpdo_post_<group>` flat table.
*
* The legacy table is left UNTOUCHED (safety net for v3.0.0 rollback).
* Idempotent — re-running is safe.
*
* Currently supports hp_listing only (the only post_type with a
* legacy hot table on production deployments). Other post_types skip
* the cutover and rely on direct wp_postmeta backfill.
*
* ## OPTIONS
*
* --post-type=<post_type>
* : Post type to cutover.
* ---
* default: hp_listing
* options:
* - hp_listing
* ---
*
* [--dry-run]
* : Preview row counts only.
*
* [--confirm]
* : Required to actually run the copy.
*
* ## EXAMPLES
*
* wp wpdo post-cutover-legacy --dry-run
* wp wpdo post-cutover-legacy --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_cutover_legacy( $args, $assoc_args ): void {
$post_type = (string) ( $assoc_args['post-type'] ?? 'hp_listing' );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to run.\n" .
'Example: wp wpdo post-cutover-legacy --dry-run'
);
}
// Map post_type → (hot_table, flat_table, group).
$mapping = array(
'hp_listing' => array(
'hot' => 'wpdo_hot_hp_listing',
'flat' => 'wpdo_post_hp_listing_core',
'group' => 'hp_listing_core',
),
);
if ( ! isset( $mapping[ $post_type ] ) ) {
WP_CLI::error( 'Unsupported --post-type: ' . $post_type );
}
global $wpdb;
$hot_table = $wpdb->prefix . $mapping[ $post_type ]['hot'];
$flat_table = $wpdb->prefix . $mapping[ $post_type ]['flat'];
// Pre-flight: verify hot table exists and report current state.
$hot_exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $hot_table )
);
if ( ! $hot_exists ) {
WP_CLI::warning( "Legacy hot table not present: {$hot_table}. Nothing to do." );
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" );
WP_CLI::log( sprintf( 'Hot (%s): %s rows', $hot_table, number_format_i18n( $hot_rows ) ) );
WP_CLI::log( sprintf( 'Flat (%s): %s rows', $flat_table, number_format_i18n( $flat_rows ) ) );
WP_CLI::log( '' );
if ( $dry_run ) {
WP_CLI::success(
sprintf(
'[dry-run] Would copy %s rows from hot → flat. Re-run with --confirm to apply.',
number_format_i18n( $hot_rows )
)
);
return;
}
// $confirm path.
try {
$result = TMDO_Post_Migration::copy_legacy_hot_table( $post_type, $hot_table, $flat_table );
$verify = TMDO_Post_Migration::verify_legacy_cutover( $hot_table, $flat_table );
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'post_cutover_legacy',
array(
'post_type' => $post_type,
'hot_table' => $hot_table,
'flat_table' => $flat_table,
'copied' => $result['copied'],
'common_columns' => $result['common_columns'],
'verify' => $verify,
)
);
}
WP_CLI::log( sprintf( 'Copied %d row(s). Common columns: %s', $result['copied'], implode( ', ', $result['common_columns'] ) ) );
WP_CLI::log(
sprintf(
'Verify: hot=%d flat=%d mismatched=%d ok=%s',
$verify['hot_rows'],
$verify['flat_rows'],
$verify['mismatched_rows'],
$verify['ok'] ? 'yes' : 'NO'
)
);
if ( ! $verify['ok'] ) {
WP_CLI::error( 'Verification failed — flat table missing rows. Legacy hot table left intact for retry.' );
}
WP_CLI::success(
sprintf(
'Legacy cutover complete (%s). Hot table preserved as safety net for v3.0.0 rollback.',
$post_type
)
);
}
// ── post-benchmark (v2.10.2) ──────────────────────────────────────────────
/**
* Compare query latency between wp_postmeta JOIN and flat-table JOIN
* for a single meta_key/value combination.
*
* Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better.
*
* Read-only — does not modify any table or change post mode. Safe to run
* in any post mode (the benchmark always queries both paths regardless).
*
* ## OPTIONS
*
* --post-type=<post_type>
* : Post type to filter by (e.g. product, hp_listing).
*
* --meta-key=<key>
* : Meta key to query (must be registered in entity registry for $post_type).
*
* [--compare=<op>]
* : Comparison operator. Default: =.
* ---
* default: =
* options:
* - "="
* - "!="
* - "<"
* - "<="
* - ">"
* - ">="
* - "LIKE"
* ---
*
* --value=<value>
* : Value to compare against.
*
* [--samples=<n>]
* : Number of times to run each query. Default: 50.
*
* ## EXAMPLES
*
* wp wpdo post-benchmark --post-type=hp_listing --meta-key=hp_price --compare=">=" --value=100
* wp wpdo post-benchmark --post-type=product --meta-key=_price --compare="=" --value=99 --samples=200
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_benchmark( $args, $assoc_args ): void {
$post_type = (string) ( $assoc_args['post-type'] ?? '' );
$meta_key = (string) ( $assoc_args['meta-key'] ?? '' );
$compare = (string) ( $assoc_args['compare'] ?? '=' );
$value = (string) ( $assoc_args['value'] ?? '' );
$samples = max( 1, (int) ( $assoc_args['samples'] ?? 50 ) );
if ( '' === $post_type || '' === $meta_key ) {
WP_CLI::error( 'Both --post-type and --meta-key are required.' );
}
// Look up the entity group for this meta_key to find the flat table.
$field = TMDO_Entity_Registry::get_field( 'post', $meta_key );
if ( ! $field ) {
WP_CLI::error( "Meta key '{$meta_key}' is not registered for entity_type='post'." );
}
global $wpdb;
$flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $field['group'] );
try {
$result = TMDO_Post_Migration::benchmark_query(
$post_type,
$meta_key,
$compare,
$value,
$flat_table,
$samples
);
} catch ( \Throwable $e ) {
WP_CLI::error( $e->getMessage() );
}
WP_CLI::log(
sprintf(
'Benchmark: %s.%s %s %s (samples=%d)',
$post_type,
$meta_key,
$compare,
$value,
$samples
)
);
WP_CLI::log( '' );
WP_CLI::log(
sprintf(
' postmeta JOIN: %.3f ms avg (matched %d rows)',
$result['postmeta_avg_ms'],
$result['postmeta_rows']
)
);
WP_CLI::log(
sprintf(
' flat JOIN: %.3f ms avg (matched %d rows)',
$result['flat_avg_ms'],
$result['flat_rows']
)
);
WP_CLI::log( '' );
WP_CLI::log( sprintf( ' → Speedup: %.2fx', $result['speedup'] ) );
// Persist to wpdo_benchmarks table.
$bench_table = $wpdb->prefix . 'wpdo_benchmarks';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->insert(
$bench_table,
array(
'module' => 'post_router_' . $post_type,
'zone' => 'flat',
'query_type' => 'meta_query_' . $meta_key,
'native_ms' => $result['postmeta_avg_ms'],
'custom_ms' => $result['flat_avg_ms'],
'sample_size' => $samples,
'created_at' => gmdate( 'Y-m-d H:i:s' ),
),
array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
);
WP_CLI::success( sprintf( 'Benchmark recorded to %s.', $bench_table ) );
}
// ── post-shadow-report (v2.10.3) ──────────────────────────────────────────
/**
* Show shadow_read divergence stats for entity_type='post'.
*
* When post mode is shadow_read, the TMDO_Post_Shadow_Verifier cron job
* runs hourly and writes any flat vs wp_postmeta divergences to
* wpdo_shadow_diffs. This command surfaces a 24h aggregate.
*
* Optionally runs an immediate sample-compare pass via --run-now.
*
* ## OPTIONS
*
* [--hours=<n>]
* : Aggregation window in hours. Default: 24.
*
* [--limit=<n>]
* : Max recent diff rows to display. Default: 10.
*
* [--run-now]
* : Execute one sample-compare tick immediately (alongside the report).
*
* ## EXAMPLES
*
* wp wpdo post-shadow-report
* wp wpdo post-shadow-report --hours=48 --limit=20
* wp wpdo post-shadow-report --run-now
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_shadow_report( $args, $assoc_args ): void {
$hours = max( 1, (int) ( $assoc_args['hours'] ?? 24 ) );
$limit = max( 1, (int) ( $assoc_args['limit'] ?? 10 ) );
$run_now = isset( $assoc_args['run-now'] );
$mode = TMDO_Mode_Manager::get( 'post' );
WP_CLI::log( sprintf( 'Post mode: %s', $mode ) );
WP_CLI::log( sprintf( 'Window: last %d hours', $hours ) );
WP_CLI::log( '' );
if ( $run_now ) {
WP_CLI::log( 'Running sample-compare for all groups...' );
$totals = array(
'sampled' => 0,
'matched' => 0,
'diffs' => 0,
'missing_flat' => 0,
'missing_postmeta' => 0,
);
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;
}
// Use the verifier's own group→post_type mapping reflectively.
$pt = self::group_post_type_for_cli( $group );
if ( null === $pt ) {
continue;
}
$flat = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
try {
$res = TMDO_Post_Shadow_Verifier::sample_compare( $pt, $group, $flat, $keys, 50 );
foreach ( $totals as $k => $_ ) {
$totals[ $k ] += (int) ( $res[ $k ] ?? 0 );
}
WP_CLI::log(
sprintf(
' %-20s sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d',
$group,
$res['sampled'],
$res['matched'],
$res['diffs'],
$res['missing_flat'],
$res['missing_postmeta']
)
);
} catch ( \Throwable $e ) {
WP_CLI::warning( " {$group}: " . $e->getMessage() );
}
}
WP_CLI::log( '' );
WP_CLI::log(
sprintf(
'Run-now totals: sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d',
$totals['sampled'],
$totals['matched'],
$totals['diffs'],
$totals['missing_flat'],
$totals['missing_postmeta']
)
);
WP_CLI::log( '' );
}
// 24h aggregate from wpdo_shadow_diffs.
$stats = TMDO_Post_Shadow_Verifier::diff_stats( $hours );
WP_CLI::log( sprintf( 'Total diffs (last %dh): %d', $hours, $stats['total'] ) );
if ( ! empty( $stats['by_key'] ) ) {
WP_CLI::log( '' );
$rows = array();
foreach ( $stats['by_key'] as $key => $count ) {
$rows[] = array(
'meta_key' => $key,
'diffs' => $count,
);
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'meta_key', 'diffs' ) );
}
// Recent diff rows.
if ( $stats['total'] > 0 ) {
$recent = TMDO_Post_Shadow_Verifier::recent_diffs( $limit );
if ( ! empty( $recent ) ) {
WP_CLI::log( '' );
WP_CLI::log( sprintf( 'Recent %d diff(s):', $limit ) );
WP_CLI\Utils\format_items( 'table', $recent, array( 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'ts' ) );
}
}
WP_CLI::success( 'Shadow report complete.' );
}
/**
* Helper: map entity group to its primary post_type for the run-now CLI path.
* Mirror of TMDO_Post_Shadow_Verifier's private mapping.
*
* @param string $group Entity group name.
* @return string|null
*/
private static function group_post_type_for_cli( 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;
}
}
}
// ── Register subcommands ──────────────────────────────────────────────────────
WP_CLI::add_command( 'wpdo postmeta-cleanup', array( 'TMDO_CLI_Post', 'postmeta_cleanup' ) );
WP_CLI::add_command( 'tmdo postmeta-cleanup', array( 'TMDO_CLI_Post', 'postmeta_cleanup' ) );
WP_CLI::add_command( 'wpdo post-diagnose', array( 'TMDO_CLI_Post', 'post_diagnose' ) );
WP_CLI::add_command( 'tmdo post-diagnose', array( 'TMDO_CLI_Post', 'post_diagnose' ) );
WP_CLI::add_command( 'wpdo post-migrate-group', array( 'TMDO_CLI_Post', 'post_migrate_group' ) );
WP_CLI::add_command( 'tmdo post-migrate-group', array( 'TMDO_CLI_Post', 'post_migrate_group' ) );
WP_CLI::add_command( 'wpdo post-cleanup', array( 'TMDO_CLI_Post', 'post_cleanup' ) );
WP_CLI::add_command( 'tmdo post-cleanup', array( 'TMDO_CLI_Post', 'post_cleanup' ) );
WP_CLI::add_command( 'wpdo post-cutover-legacy', array( 'TMDO_CLI_Post', 'post_cutover_legacy' ) );
WP_CLI::add_command( 'tmdo post-cutover-legacy', array( 'TMDO_CLI_Post', 'post_cutover_legacy' ) );
WP_CLI::add_command( 'wpdo cleanup-hp-transients', array( 'TMDO_CLI_Post', 'cleanup_hp_transients' ) );
WP_CLI::add_command( 'tmdo cleanup-hp-transients', array( 'TMDO_CLI_Post', 'cleanup_hp_transients' ) );
WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );