Compare commits

43 Commits

Author SHA1 Message Date
wpdev e4e49e70ff test(2meet-data-optimizer): live tools no longer flush the object cache
- mut.php called wp_cache_flush() after each write and qbench.php
  wp_cache_flush_group() before each run; with Redis Object Cache's selective
  flush both are one server-side Lua scan over the site's keys, which blocked
  the shared Redis for 5–20 s on dev36 (3 M keys) and broke other sites'
  requests (BUSY / read errors)
- mut.php clears the touched post or user caches plus the runtime cache;
  qbench.php bumps the routed count version (posts' last_changed before 1.3.0)
- tests/ is not packaged; plugin code does not flush
2026-09-27 19:25:56 +08:00
wpdev f929165b7b Merge pull request 'hardening 2.3: 2meet-data-optimizer 1.3.0 (flat-table DDL outside web requests, routed count cache, audit fixes)' (#7) from hardening/2.3 into main
Tests / PHP Lint (push) Successful in 11s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 14s
Tests / Unit Tests (push) Successful in 43s
Tests / PHPStan (push) Failing after 14m14s
Tests / PHPCS (push) Failing after 14m16s
Tests / Integration Tests (push) Failing after 15m11s
2026-09-27 18:19:17 +08:00
wpdev 947e130386 docs(2meet-data-optimizer): add PR-116 and PR-117 to the 1.3.0 release notes
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 24s
Tests / PHP Lint (pull_request) Successful in 40s
Tests / Unit Tests (pull_request) Successful in 41s
Tests / Integration Tests (pull_request) Successful in 1m23s
Tests / PHPCS (pull_request) Successful in 29s
Tests / PHPStan (pull_request) Successful in 1m14s
- routed count cache: placeholder escape and count version group
- PR: PR-101 (follow-up)
2026-09-27 17:58:07 +08:00
wpdev ca2cbdab65 perf(2meet-data-optimizer): keep cached routed counts across unrelated post meta writes
- cached counts followed posts' last_changed, which WordPress moves on any
  post meta write; themes write meta on page views (on dev36 Blocksy saved a
  dynamic-CSS descriptor for each listing a crawler visited), so the cache
  was invalidated every few seconds and keyword/filter searches recounted
- counts now follow their own last_changed group (tmdo_posts), bumped on
  clean_post_cache (post saved or deleted; hooked in every mode) and on flat
  writes through the Hook Bus, plus terms' last_changed; a routed search reads
  no unmanaged meta
- Verify: PostQueryRouterFoundRowsTest (unrelated meta write keeps the count,
  save or flat write recounts), integration 588, unit 703, phpcs, stan;
  dev36 front-end
- PR: PR-117 (2.3 acceptance)
2026-09-27 17:46:54 +08:00
wpdev 7851f150fa perf(2meet-data-optimizer): key the routed count cache on the SQL without wpdb's placeholder escape
- keyword searches carry wpdb's placeholder escape for "%" ({<hash>}), which
  is new in every request, so md5(count SQL) never matched across requests
  and every keyword search recounted (the 2.2 "keyword + filter routed slower
  than native" finding). The key now uses remove_placeholder_escape()
- found in the 2.3 acceptance: dev36 keyword + price routed 6.2 s vs native
  4.9 s; PR-091's in-process check reused one process, one escape
- Verify: PostQueryRouterFoundRowsTest (two escapes, one COUNT), integration
  588, unit 703, phpcs, stan; dev36 front-end
- PR: PR-116 (2.3 acceptance)
2026-09-27 17:39:36 +08:00
wpdev 1835c8776a docs(2meet-data-optimizer): add PR-111, PR-113 and PR-114 to the 1.3.0 release notes
- deferred-upgrade hardening from the acceptance reviews, notice() audit
  trail, known cold-cache version window
- PR: PR-101 (follow-up)
2026-09-27 17:34:19 +08:00
wpdev 1d70d13f45 fix(2meet-data-optimizer): close the gaps in deferred table upgrades found in the 2.3 review
- a deferred group whose stored definition matches again (reverted, or
  upgraded by another process) goes through end_deferral() instead of staying
  native for good (review F2)
- the tmdo_ensure_group_indexes handler upgrades deferred tables itself: with
  ALTERNATE_WP_CRON the event runs after init in a visitor's request (F3)
- a new group table created while the entity writes to flat stays native
  until the drift fix has filled it from native meta; an opted-in HivePress
  attribute no longer routes searches to an empty table (F6)
- direct_read() returns native values for a deferred group (F10); the Hook
  Bus ignores deferral in aeav_only, as the router does
- dbDelta failures: the definition is recorded only when every column exists
  (schema.upgrade_failed otherwise), so a failed ALTER is retried and the
  group is not released onto missing columns
- test bootstrap: get_metadata() reads the real meta table
- Verify: SchemaUpgradeDeferralTest (6), Hook Bus direct read, integration
  587, unit 703, phpcs, stan
- PR: PR-114 (2.3 acceptance correctness review)
2026-09-27 17:30:36 +08:00
wpdev e26aadc4ec fix(2meet-data-optimizer): keep the audit trail of mode changes and snapshot deletions
- TMDO_Logger::notice() is always written to the error log (it had no
  callers); bridge_mode_changed and snapshot_deleted use it. Since PR-095
  info() is silent without WP_DEBUG_LOG, which left no record of who switched
  a mode or deleted a snapshot (security review L1)
- Verify: LoggerTest (notice always logged, info gated), unit 703,
  integration 585, phpcs, stan
- PR: PR-113 (2.3 acceptance security review)
2026-09-27 17:25:56 +08:00
wpdev 29adb05567 fix(2meet-data-optimizer): alter an existing flat table in one process at a time; keep a deferred group native until its drift fix
- security review H1: anonymous /wp-cron.php requests pass ddl_allowed(),
  and dbDelta on an existing table had no lock, so concurrent requests each
  ran the ALTER on a large table. The upgrade now runs under the same MySQL
  GET_LOCK as the index DDL (skipped when another process holds it) and
  re-checks the stored hash once locked; this covers aeav_only too. The table
  build moves unchanged to write_table(); lock()/unlock() are shared
- security review M1: after a deferred upgrade the group stays native until
  tmdo_drift_resync has rebuilt the rows written meanwhile
  (TMDO_Schema_Manager::resynced() from run_scheduled_fix()); with no flat
  writes (disabled) it is released at once
- Verify: SchemaUpgradeDeferralTest (lock held elsewhere, resync release),
  integration 585, unit 702, phpcs, stan
- PR: PR-111 (2.3 acceptance security review)
2026-09-27 17:22:57 +08:00
wpdev 0aec4b9bb6 docs(2meet-data-optimizer): add PR-106 to PR-110 to the 1.3.0 release notes
- Changed: deferred table upgrades (PR-107), index lock on the primary
  (PR-108), cold cache flush by version (PR-110); Fixed: dropped tables in
  post status sync (PR-106)
- Verify: composer test
- PR: PR-101 (follow-up)
2026-09-27 17:12:54 +08:00
wpdev eb1c8b78f4 perf(2meet-data-optimizer): flush the cold cache group without a server-side scan
- TMDO_Cache_Layer::flush_group() bumps the post type's cold cache version
  (option wpdo_cold_cache_version) instead of wp_cache_flush_group(), which
  Redis Object Cache runs as one server-side scan that blocks Redis on a large
  group; entries under older keys expire by their TTL
- TMDO_Zone_Cold::cache_key() builds every cold blob key; version 0 keeps the
  current keys, so upgrading invalidates nothing
- Verify: CacheLayerTest (new keys, no scan, other post types untouched),
  unit 702, integration 583, phpcs, stan
- PR: PR-110
2026-09-27 17:12:14 +08:00
wpdev 06a3594417 fix(2meet-data-optimizer): take the index lock on the primary under read/write splitting
- when $wpdb has send_reads_to_masters() (HyperDB, LudicrousDB), it is called
  before SELECT GET_LOCK, which a replica would take without locking out the
  ALTER on the primary
- Verify: GroupCompositeIndexTest (call order through a wpdb proxy),
  integration 583, unit 701, phpcs, stan
- PR: PR-108
2026-09-27 17:09:10 +08:00
wpdev e18030b206 fix(2meet-data-optimizer): upgrade existing flat tables from WP-CLI or WP-Cron only
- a changed group definition no longer runs dbDelta on an existing table
  inside a web request (seconds to minutes on a large table, and every
  concurrent request tried it): the group is recorded in tmdo_schema_deferred
  and tmdo_ensure_group_indexes is scheduled, whose request upgrades it
- meanwhile the group stays native: Hook Bus field lookups return no field and
  the router treats it as drifting; afterwards a drift fix
  (tmdo_drift_resync) rebuilds the writes that skipped the flat table
- new tables are still created at once; aeav_only upgrades at once (the flat
  table is the only copy); doctor lists deferred groups
- Verify: SchemaUpgradeDeferralTest, Hook Bus and router tests, integration
  582, unit 701, phpcs, stan; dev36 live cycle in the 2.3 acceptance
- PR: PR-107
2026-09-27 17:07:48 +08:00
wpdev 5968bcd95d fix(2meet-data-optimizer): forget post status columns of groups whose flat table is gone
- on_transition() skips a recorded group whose table no longer exists and
  removes it from tmdo_post_status_columns; every post status change used to
  UPDATE the missing table and print a database error (seen on dev36 after a
  throwaway group's table was dropped)
- Verify: PostStatusSyncTest (dropped table), integration 578, unit 701, phpcs, stan
- PR: PR-106
2026-09-27 16:59:08 +08:00
wpdev 303781e68b docs(2meet-data-optimizer): 1.3.0 release notes
- version 1.3.0; CHANGELOG and readme.txt for PR-090a, 091, 093 (verified,
  no change), 094–098, with the post-upgrade steps (first CLI / cron run adds
  the HivePress indexes; INFO lines need WP_DEBUG_LOG)
- Verify: composer test (701)
- PR: PR-101
2026-09-27 16:46:55 +08:00
wpdev d46913de41 chore(2meet-data-optimizer): prefix the global variables in uninstall.php
- $bootstrap_files, $bootstrap_ok, $f, $result, $snapshot_id and $dir become
  $wpdo_* like the file's other globals (WordPress.org Plugin Check)
- Verify: PrefixAllGlobals sniff 0, phpcs, UninstallGuardTest and
  InstallerCleanupTest, unit 701
- PR: PR-098
2026-09-27 16:41:08 +08:00
wpdev 1b50df7cd0 refactor(2meet-data-optimizer): handle admin form posts before the page renders
- the main page's form handlers move unchanged from render_page() to
  handle_post(), hooked on load-{page}: inside render_page() the admin
  header was already sent, so their wp_safe_redirect() calls could not work
- the setup wizard's step 4 snapshot post moves to handle_snapshot_post() on
  its load hook; a failure redirects with snap_failed=1 for the notice
- Verify: unit 701, integration 577, phpcs, stan; dev36 load hook handles a
  posted reset and redirects before output
- PR: PR-097
2026-09-27 16:39:56 +08:00
wpdev a268470b60 fix(2meet-data-optimizer): treat contact and address keys as private in the public REST filter
- rest_exposable() also withholds keys named like an email, phone, address or
  IP, even when a field declares show_in_rest (defence in depth)
- Verify: RestApiTest (private and look-alike public keys), unit 701,
  integration 577, phpcs, stan; dev36 live rest suite
- PR: PR-096
2026-09-27 16:36:51 +08:00
wpdev 1bdd51d9a3 fix(2meet-data-optimizer): log info and notice events only when debug logging is on
- TMDO_Logger::info()/notice() write to the PHP error log only with
  WP_DEBUG_LOG (filter tmdo_log_info); every CLI run and production request
  printed [WPDO][INFO] lines
- debug() keeps its WP_DEBUG gate; warning() and error() are unchanged
- Verify: LoggerTest (2 new), unit 700, integration 577, phpcs, stan
- PR: PR-095
2026-09-27 16:35:13 +08:00
wpdev 22b46ad175 perf(2meet-data-optimizer): stop writing an option for every rate-limited view
- blocked POST /listings/{id}/view requests update wpdo_rl_stats 1 in 10,
  each sample adding 10: anonymous traffic no longer turns 1:1 into DB writes,
  and the admin top list stays representative (unbiased estimate)
- Verify: RestApiTest counts option writes (about 20 for 200 blocked requests),
  unit 698, integration 577, phpcs, stan
- PR: PR-094
2026-09-27 16:33:16 +08:00
wpdev b187c1c4d3 perf(2meet-data-optimizer): count routed searches from a cached base count minus the excluded posts
- a routed search with post__not_in (HivePress sends a random set per page)
  caches the COUNT without the exclusion and subtracts a live primary-key
  count of the excluded posts that match; the total stays exact
- the excluded count drops STRAIGHT_JOIN so MySQL starts from posts.ID
- Verify: PostQueryRouterFoundRowsTest, unit 698, integration 577, phpcs, stan;
  dev36 live router suite and keyword + price search
- PR: PR-091
2026-09-27 16:19:11 +08:00
wpdev ec8d69a6f4 fix(2meet-data-optimizer): add declared flat-table indexes from WP-CLI or WP-Cron only, one process at a time
- ensure_group_indexes() runs the ALTER only under WP-CLI or WP-Cron (filter
  tmdo_schema_ddl_allowed); web requests, including /wp-admin/ before
  auth_redirect() and admin-post.php, schedule tmdo_ensure_group_indexes instead
- MySQL GET_LOCK keeps it to one process (add_option() is INSERT ... ON
  DUPLICATE KEY UPDATE and cannot serve as a lock); released by MySQL if the
  process dies
- the applied signature is recorded only when every ALTER succeeds; failures
  log schema.index_failed and stay in pending_indexes(), which doctor reports
- new tables get their declared cidx_ indexes in CREATE TABLE
- test stub wpdb::query()/insert() return false on SQL errors like wpdb
- Verify: GroupCompositeIndexTest 7/7, unit 698, integration 576, phpcs, stan
- PR: PR-090a
2026-09-27 16:07:23 +08:00
wpdev 28ac6e3cad Merge pull request 'hardening 2.2: 2meet-data-optimizer 1.2.0 (drift detection, scope, CLI and diagnostics)' (#6) from hardening/2.2 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 40s
Tests / PHP Lint (push) Successful in 55s
Tests / Integration Tests (push) Successful in 1m42s
Tests / PHPCS (push) Successful in 33s
Tests / PHPStan (push) Successful in 46s
2026-09-27 10:56:47 +08:00
wpdev 582ff13a11 test(2meet-data-optimizer): compare site-wide rating sorts by sort key, not id
Tests / PHP Lint (pull_request) Successful in 16s
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 16s
Tests / Unit Tests (pull_request) Successful in 45s
Tests / PHPCS (pull_request) Successful in 35s
Tests / Integration Tests (pull_request) Successful in 1m38s
Tests / PHPStan (pull_request) Successful in 1m18s
- The PR-079 live router cases ran the HivePress rating sort on the whole site with an unqualified DECIMAL: native rounds 4.5 to 5 and orders its many ties by chance, so the id order passed or failed with the query plan. They now send DECIMAL(10,1), as the HivePress Rating field does, and compare each position's native sort key and found_posts; the flat-first / GROUP BY assertions stay.
- Verify: live router 18/18 twice; all suites 56/56
- PR: PR-089b
2026-09-27 10:20:30 +08:00
wpdev edf9e6bc93 fix(2meet-data-optimizer): doctor ignores drift records of groups no longer registered
- A group's last drift record outlived its registration (wc_product and user hot on dev36, any add-on deactivated): doctor counted it and could warn forever with a --fix hint that cannot reach it. Only records of backfillable groups are read; the router and clean_since already go by the registry.
- Verify: DoctorTest (RED before); dev36 doctor user 9 → 8 groups
- PR: PR-089a
2026-09-27 10:07:02 +08:00
wpdev fab07655a2 docs(2meet-data-optimizer): 1.2.0 release notes
- Bump to 1.2.0; CHANGELOG / readme entries for PR-066–080, with the post-upgrade ops commands
- ADR-004 Release 2.2 design (scope, drift check / fix / router / resync) and CONTEXT.md drift paragraph
- Verify: composer test, integration, phpcs 0
- PR: PR-086
2026-09-27 09:52:22 +08:00
wpdev 07955192d4 chore(2meet-data-optimizer): clean HivePress transients left in wp_options
- `wp tmdo cleanup-hp-transients --options [--dry-run|--confirm] [--batch=<n>]` removes the rows the HivePress AddOn wrote to wp_options (`_transient_wpdo_hp_pm_*` and their timeouts) before AddOn 0.3.0 kept them in the object cache, deleting in batches (default 5000). It works without the AddOn active; the namespace is repeated in core for that.
- Verify: CleanupHpTransientsTest (RED before; only the AddOn's rows go, look-alike names stay); dev36 --dry-run counts 2,314,750 rows in 3.2 s. The --confirm run belongs after the AddOn change is deployed.
- PR: PR-080
2026-09-27 09:06:19 +08:00
wpdev 84f6e32913 perf(2meet-data-optimizer): route sort-only searches from the flat table when every post has a row
- A sort-only search (HivePress _sort, no routed filter) LEFT JOINs the flat table to keep posts without a row; it now drives from the flat table (STRAIGHT_JOIN, INNER JOIN) when every post of the type has a flat row. The router gives a new post of a type with its own groups an empty row (wp_insert_post); coverage is one anti-join per group, cached 15 min and per post-mode change (forget_coverage()).
- A flat-first search drops the GROUP BY posts.ID WordPress adds for any tax query when the flat row is the only join (WooCommerce's product_visibility NOT IN on searches is a subquery); a joining tax query keeps it.
- Verify: live router suite (5 new cases, RED before); dev36 rating__desc page, ids identical: native ~3.3 s, 2.1 routing ~1.5–1.9 s, now ~0.51–0.85 s (main query 440 → 46 ms, COUNT 400 → 160 ms). p50 ≤ 300 ms is still not met: page rendering and HivePress's own get_posts() in pre_get_posts (suppress_filters, ~100 ms) remain.
- PR: PR-079
2026-09-27 08:31:47 +08:00
wpdev ad7acc40b7 chore(2meet-data-optimizer): address the 2.1 audit notes
- The four public REST routes say why they are public (viewable posts only, show_in_rest fields only, per_page cap, one view per IP per post per hour).
- Admin ternary echoes of literal strings are escaped for their context: esc_attr() in attributes, esc_html() for text; the object-cache badge is an if/else and aria-current is always printed ("false" off the current tab).
- Exceptions in the migration engine and the health cron go to TMDO_Logger instead of error_log().
- Verify: audit greps (ternary echoes 23 → 0, error_log outside the logger 0); dev36 renders 8 admin tabs with the change, no PHP notice, one aria-current="page" each.
- PR: PR-078
2026-09-27 01:19:22 +08:00
wpdev f48df1a33f refactor(2meet-data-optimizer): stop registering the wc_product post group
- WooCommerce reads product meta directly and writes stock by SQL, so a flat copy drifted (R-2.1 V1 T4); product meta now stays in native only (plan decision 2). TMDO_Post_Fields registers wp_core, attachment and nav_menu_item. Existing wp_wpdo_post_wc_product tables are left in place and still dropped on uninstall; the orphan restore registers nothing for them while native meta holds the keys; `wp tmdo bridge-restore-native post --group=wc_product --retire` archives them.
- Tests that route products register the former group themselves (tmdo_test_register_wc_product_group(), same name and table); live router Q06 runs on wp_core's _wp_page_template instead of _sku. The post stress tester already skips flat tables that do not exist.
- Verify: PostFieldsRegistrationTest (RED before); dev36: wc_product absent from the registry and not restored as an orphan, a CLI order took stock 452 → 450 in native with no DB error (reverted), drift post 10 groups 0.
- PR: PR-077
2026-09-27 01:11:52 +08:00
wpdev f15473527c fix(2meet-data-optimizer): stop false alarms in doctor, term-comment-diagnose and bridge-status
- doctor: entity flat tables come from the registry (TMDO_CLI::check_entity_tables()) instead of a hard-coded user list naming the never-registered sso group, which failed doctor on every site; it also reports Promotion Guard violations and the recorded drift status per entity (report_guard_and_drift()).
- term-comment-diagnose: filter status comes from each filter's is_enabled() (the garbage filter and misc bucket default to off but were shown enabled); a filter whose add-on is not active shows "not installed".
- bridge-status: flat_rows counts rows holding a value (wp_core keeps an all-NULL row per post: 15306% on dev36) and eav_ids of a post-type group counts only its post types.
- Tests: the WP_CLI stubs record their output in $GLOBALS['_wp_cli_output'].
- Verify: DoctorTest, CliFilterStatusTest, CliBridgeStatusTest (RED before); dev36 doctor exit 1 → 0, wp_core 15306% → 100%.
- PR: PR-076
2026-09-27 01:00:13 +08:00
wpdev 9d1a7c5591 feat(2meet-data-optimizer): throttle shadow_read diffs per key and promote on clean drift checks
- The shadow diff logger records at most 20 rows per entity type + key per hour across all objects, on top of the existing per-object 5-minute window: a key stale on every object (WooCommerce total_sales, written by direct SQL) no longer logs a row per product read.
- The auto-promoter's third condition is now TMDO_Drift_Check::clean_since(): every backfillable group of the entity checked within 2 days (sampled or full) with no drift; no record, no promotion. The shadow diff count no longer gates it (the filter's 4th argument is passed as 0). The Promotion Guard still refuses HivePress / WooCommerce sites.
- Reason classification was dropped (decision 2026-09-27): it needs a schema change and drift-check already reports miss / diff / extra per key.
- Verify: ShadowDiffThrottleTest, AutoPromoterDriftTest (RED before); dev36 simulated user shadow_read: clean_since yes, promotion still refused by the guard, mode unchanged.
- PR: PR-075
2026-09-27 00:48:18 +08:00
wpdev a26500206a feat(2meet-data-optimizer): backfill writes made while the Hook Bus was off
- When an entity goes from disabled to dual_write / shadow_read, or the Hook Bus flag is switched on, a single WP-Cron event (tmdo_drift_resync, one per entity, 60 s later) runs drift-check --fix for the entities that keep native meta, so values written while flat was bypassed land in flat. aeav_only is never touched.
- A full fix instead of the planned post_modified_gmt increment (decision 2026-09-27): meta-only writes such as WooCommerce's direct stock SQL do not bump post_modified. No wpdo_bridge_off_since.
- The resync hook is cleared on deactivate / uninstall (literal name: uninstall.php does not load TMDO_Drift_Check).
- Verify: DriftCheckTest resync cases (RED before); dev36 end to end with real WP-Cron: write while user disabled → miss 1 → bridge-mode user dual_write schedules tmdo_drift_resync [user] → run → drift 0.
- PR: PR-074
2026-09-27 00:41:43 +08:00
wpdev 1b21ee9add fix(2meet-data-optimizer): drift checks only where native meta is authoritative
- In aeav_only flat is the source of truth and native meta is stale by design, so drift there means nothing: drift-check --fix refuses (it would overwrite current flat values with stale native ones), the daily sample skips such entities, and the router keeps routing a drifting group instead of falling back to stale native meta.
- Found before PR-074 on hardening/2.2 (PR-071–073 not released; dev21 still runs aeav_only).
- Verify: DriftCheckTest / PostQueryRouterDriftTest aeav_only cases (RED before); dev36 fix() in a simulated aeav_only refused.
- PR: PR-073b
2026-09-27 00:34:03 +08:00
wpdev 6c782282c7 feat(2meet-data-optimizer): sample drift daily and record the result
- TMDO_Drift_Check::cron_tick() runs on the existing wpdo_daily_health_check event (no new schedule to add or clear): every entity type whose flat tables are written (dual_write or higher) is checked on 500 random entities per group, and the outcome replaces the recorded status, so a group found drifting leaves the router that day.
- Verify: DriftCheckTest cron cases (RED: method / hook missing); dev36 with all four entities in dual_write: one tick over 22 groups in 10 s, 0 drift, status option restored.
- PR: PR-073
2026-09-27 00:28:00 +08:00
wpdev ddefdc16d4 fix(2meet-data-optimizer): keep searches native while a group is known to drift
- The post query router routes no clause of a group whose last drift-check recorded miss / diff / extra; all or nothing, so such a query stays fully native. A group never checked keeps routing (decision 2026-09-27: no slowdown after upgrading; PR-073 samples daily).
- The check sits in route_clause(), which every routed clause, HivePress sort shape and meta_key sort goes through.
- Verify: PostQueryRouterDriftTest (drifting group and drifting sort key RED before); dev36 hp_listing price search in shadow_read: clean → flat, drift → native, unchecked → flat, found 90347 each.
- PR: PR-072
2026-09-27 00:22:47 +08:00
wpdev 3a347269c8 feat(2meet-data-optimizer): repair drifted flat rows from native with drift-check --fix
- TMDO_Drift_Check::fix() deletes flat rows of deleted entities and, for post groups, of posts outside the group's post types; rebuilds every other drifted entity's row from native through the new TMDO_Entity_Migration_Engine::migrate_entity() (keys without native rows become NULL, PR-066); keeps a row whose entity has no native value for the group but clears its columns (no NULL-only inserts, no deleted rows the router may join on); invalidates the row cache of each touched entity; then checks again. Native meta is never written.
- `wp tmdo drift-check <entity> --fix` (not combinable with --sample).
- Verify: DriftCheckTest fix cases (RED: method missing); dev36 --fix: post 42 rows rebuilt, 1 stray row (post 312, hp_booking in hp_draft_flags) deleted, all four entities 0 drift after, checker miss/extra 0.
- PR: PR-071
2026-09-27 00:07:33 +08:00
wpdev 02045c6f07 feat(2meet-data-optimizer): report flat-vs-native drift with wp tmdo drift-check
- New TMDO_Drift_Check compares every backfillable group of post / user / term / comment with native meta by value (first native row → safe unserialize → Type_Caster::to_db): miss / diff / extra per key, set-based SQL with BINARY exact matches, PHP comparison only for the remaining candidates. Serialized-vs-JSON and 50 vs 50.000000 are no longer drift; over-long text stored as NULL is not a miss; extra covers rows of other post types. Outcome per group goes to the wpdo_drift_status option; nothing else is written.
- `wp tmdo drift-check <entity> [--group] [--sample=<n>] [--format]`. A new class instead of extending the post-only shadow verifier, which queries row by row and writes shadow_diffs.
- Verify: DriftCheckTest; live suite drift; dev36 post miss=533 diff=2 extra=1 in 23 s (checker's 45 json false diffs gone), user/term/comment 0.
- PR: PR-070
2026-09-26 23:56:23 +08:00
wpdev 382786cb9d feat(2meet-data-optimizer): set an entity bridge mode from the CLI under the Promotion Guard
- New `wp tmdo bridge-mode <entity> <mode>` goes through TMDO_Mode_Manager::set(): unsafe jumps and aeav_only promotions refused by the Promotion Guard exit with the reason and leave the mode unchanged. Named apart from the Feature Flags `mode-set`.
- member-sso-sync and member-force-logout warn that they are deprecated: the user sso group they work on is no longer registered.
- Verify: CliBridgeModeTest (RED: method missing); dev36 bridge-mode user aeav_only refused (unsafe jump), term dual_write → shadow_read → aeav_only refused by the HivePress guard → back to disabled, wpdo_bridge_modes identical to before.
- PR: PR-069
2026-09-26 23:36:55 +08:00
wpdev 2022cf1d08 feat(2meet-data-optimizer): backfill every registered user group with member-backfill --all
- member-backfill / member-audit take their groups from the registry (TMDO_Entity_Migration_Engine::backfillable_groups(), restored orphans excluded) instead of a hard-coded list that still named the never-registered `sso`; `--all` backfills every user group (R-2.1 had to use wp eval). TMDO_Post_Migration::backfillable_groups() now delegates to it.
- member-cutover checks the Mode_Manager result with is_wp_error(): a guarded or invalid transition used to crash with a fatal (WP_Error used as an array).
- Verify: CliMemberBackfillTest (--all and the orphan filter RED before; the --group case cannot fail under the non-throwing WP_CLI stub); dev36 `member-backfill --all --reset` 9 groups, 0 errors, checker user miss/diff/extra 0; `member-cutover --confirm` exits 1 with the Guard message, mode unchanged.
- PR: PR-068
2026-09-26 23:30:28 +08:00
wpdev 7807eae957 refactor(2meet-data-optimizer): list post groups for backfill from the registry
- Admin "backfill all" and `wp tmdo post-migrate-group` take any registered post group via TMDO_Post_Migration::backfillable_groups() instead of a hard-coded list of 7 (dev36 registers 11; the 4 HP add-on groups were never backfilled). Restored orphan groups are excluded and rejected — their values live only in the flat table.
- Groups with json fields (attachment, nav_menu_item) go row by row through the migration engine instead of leaving the json columns NULL in the bulk pivot.
- Verify: PostBackfillJsonTest (3 RED on hardening/2.2); dev36 post-migrate-group attachment / nav_menu_item / hp_draft_flags: json columns semantically equal native (24/24, 21/21), checker unchanged.
- PR: PR-067
2026-09-26 22:54:27 +08:00
wpdev 13ea341ef8 fix(2meet-data-optimizer): do not invent default rows when migrating an entity group
- migrate_group() REPLACEs the whole flat row; columns without a native row fell back to their DDL default (session_count / account_flags DEFAULT 0), so every backfilled user got invented zeros. Keys with no native row are now written as NULL; required NOT NULL columns keep their default.
- Covers the cron batch path (migrate_group_batch) and clears stale flat values on re-run.
- Verify: EntityMigrationEngineDefaultsTest (3 RED on main); dev36 user/activity backfill: checker extra 123 → 0 for session_count and account_flags, miss/diff unchanged.
- PR: PR-066
2026-09-26 22:46:01 +08:00
wpdev 957f5c4ab0 Merge pull request 'hotfix 1.1.1: 2meet-data-optimizer — keep private fields out of the public listings REST endpoints' (#5) from hotfix/1.1.1 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 19s
Tests / PHP Lint (push) Successful in 50s
Tests / Unit Tests (push) Successful in 1m6s
Tests / Integration Tests (push) Successful in 1m42s
Tests / PHPCS (push) Successful in 41s
Tests / PHPStan (push) Successful in 1m4s
2026-09-26 22:25:23 +08:00
73 changed files with 4063 additions and 437 deletions
+3 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.1.1
* Version: 1.3.0
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.1.1' );
define( 'TMDO_VERSION', '1.3.0' );
define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
@@ -268,6 +268,7 @@ add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'b
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
+144
View File
@@ -7,6 +7,150 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3)
**性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。
搭配 HivePress AddOn 0.4.0、WooCommerce AddOn 0.3.0。來源:dev36 規模壓測(90 萬 listing)
與系列稽核(2026-09-27)。
### Changed
- **group 宣告的複合索引只在 WP-CLI 或 WP-Cron 加上**:網頁請求(包括 auth_redirect 之前的
`/wp-admin/` 與 `admin-post.php`)改排單次事件 `tmdo_ensure_group_indexes`;以 MySQL
`GET_LOCK` 保證同一時間只有一個程序執行 ALTER;每個 ALTER 都成功才記錄已套用,失敗記
`schema.index_failed` 並保留在 `doctor` 的 `[INDEX]` 待辦;新表在 CREATE TABLE 時就帶上
索引。filter:`tmdo_schema_ddl_allowed`。dev36 90 萬列加兩個索引約 4.7 s。(PR-090a)
- **路由搜尋的 found rows**:帶 `post__not_in` 的查詢(HivePress 每頁送一組隨機排除清單)
改為「不含排除條件的 COUNT(快取)」減去「排除清單中符合條件的篇數(主鍵查詢)」,結果
精確,快取不再被打散。(PR-091)快取 key 另外去掉 wpdb 替 `%` 產生的 placeholder escape
(每個請求不同,關鍵字搜尋原本永遠不命中,也就是 2.2 驗收「關鍵字 + 篩選 routed 較慢」的
原因)(PR-116);計數改跟隨自有的 `tmdo_posts` last_changed(文章儲存/刪除、經 Hook Bus
的扁平寫入)與 terms 的 last_changed,不再跟隨 posts 的 last_changed——任何 post meta 寫入
都會推動它,dev36 的 Blocksy 佈景在爬蟲瀏覽每篇 listing 時都寫一筆 meta,快取幾秒就失效
(PR-117)。dev36 關鍵字 + 價格 routed 9.8 s → 首次後約 5.5 s(原生 5.0–6.2 s,剩餘為
WordPress 核心的 LIKE 全表掃描)。
- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入
(每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094)
- `TMDO_Logger::info()` 只在 `WP_DEBUG_LOG` 時寫 error_log(filter `tmdo_log_info`);
`notice()` 一律寫入,模式切換(`bridge_mode_changed`)與快照刪除改用它,保留操作紀錄;
`debug()`、`warning()`、`error()` 不變。(PR-095、PR-113)
- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告
`show_in_rest`。(PR-096)
- **既有扁平表的 schema 升級只在 WP-CLI 或 WP-Cron 執行**:網頁請求遇到定義變更時記入
`tmdo_schema_deferred` 並排 `tmdo_ensure_group_indexes`;升級前該 group 的 key 走原生
(Hook Bus 不攔、router 不路由),升級後排 `tmdo_drift_resync` 補回期間跳過的扁平寫入。
新表照常立即建立;aeav_only 仍立即升級(扁平表是唯一副本);`doctor` 顯示 `[SCHEMA]`。(PR-107)
驗收審查後補強:既有表的升級與索引共用同一把 `GET_LOCK`(匿名 `/wp-cron.php` 也會通過
`ddl_allowed()`),取得鎖後重新比對 hash;延後的 group 要等 drift fix 完成才改回扁平表;
寫入扁平表的 entity 新建 group 表時同樣先走原生,由 drift fix 從原生填滿(新宣告的
HivePress 屬性不會把搜尋導到空表);定義還原時解除延後;cron 事件本身也會升級延後的表
(`ALTERNATE_WP_CRON`);dbDelta 之後每個欄位都存在才記錄定義(否則 `schema.upgrade_failed`
並重試);延後期間 `direct_read()` 回傳原生值。(PR-111、PR-114)
- 讀寫分離(HyperDB、LudicrousDB)時,取索引鎖前先 `send_reads_to_masters()`,
`GET_LOCK` 才會在 primary 上。(PR-108)
- Cold 快取的「清除」改為遞增版本號(`wpdo_cold_cache_version`),不再呼叫
`wp_cache_flush_group()`(Redis Object Cache 以伺服器端腳本掃描整個 group,大 group 會卡住
Redis);舊 key 依 TTL 過期。(PR-110)
### Fixed
- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()`
可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097)
- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098)
- 扁平表已被刪除的 group 不再留在 `tmdo_post_status_columns`:每次文章狀態變更都 UPDATE
不存在的表並印出 DB error。(PR-106)
### Security(已驗證,無程式變更)
- 快照目錄在 nginx 下的外洩風險(稽核 Medium):快照檔名含 64-bit 隨機值、遷移備份檔名含
12 字元隨機 job id,目錄有 index.php 擋列表;`doctor` 的 HTTP 探測與 nginx 後台提示已存在。
dev31 探測全為 403。不採用「目錄名加隨機後綴」:各站 nginx deny 規則比對 `wpdo-backups/`,
改名反而讓新快照脫離保護。(PR-093)
### 升級後
- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER
(90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。
- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。
- 已知限制:cold 快取版本遞增的瞬間,在遞增前就開始的長請求寫入後只會清掉舊版 key,新版 key
最多保留舊值到 TTL(預設 1 小時);cold module 預設 idle。
---
## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2)
**性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配
HivePress AddOn 0.3.0、WooCommerce AddOn 0.2.0。
### Added
- **`wp tmdo drift-check <entity> [--group] [--sample=<n>] [--format]`**(`TMDO_Drift_Check`):
逐 key 比對扁平表與原生 meta 的值(第一筆原生 row → 安全 unserialize →
`Type_Caster::to_db`),回報 miss / diff / extra。序列化 vs JSON、`50` vs `50.000000`
不算漂移;超過 255 字元存 NULL 的 text 不算 miss。結果寫入 `wpdo_drift_status`。(PR-070)
- **`drift-check --fix`**:刪除已刪實體與非本 group post type 的扁平列,其餘漂移列經
`TMDO_Entity_Migration_Engine::migrate_entity()` 從原生重建,清除列快取後再檢查一次。
不寫原生;aeav_only(扁平才是真實資料)一律拒絕。(PR-071、PR-073b)
- **每日抽樣**:`wpdo_daily_health_check` 對 dual_write 以上、非 aeav_only 的 entity 每個
group 抽 500 筆,結果取代既有紀錄。(PR-073)
- **Hook Bus 關閉期間的寫入補回**:entity 從 disabled 進入 dual_write / shadow_read,或
Hook Bus flag 由關轉開時,60 秒後以單次 WP-Cron `tmdo_drift_resync` 執行全量
`drift-check --fix`(不做 `post_modified_gmt` 增量:WooCommerce 直接 SQL 改庫存不會更新
post_modified)。停用 / 解除安裝時清除排程。(PR-074)
- **`wp tmdo bridge-mode <entity> <mode>`**:經 `TMDO_Mode_Manager::set()`,受
Promotion Guard 約束;被拒時 exit 1、模式不變。(PR-069)
- **`wp tmdo member-backfill --all`**:回填所有已註冊的 user group。(PR-068)
- **`wp tmdo cleanup-hp-transients --options [--dry-run|--confirm] [--batch=<n>]`**:
刪除 HivePress AddOn 0.3.0 之前寫進 wp_options 的 `_transient_wpdo_hp_pm_*`(含 timeout),
分批刪除(預設 5000),AddOn 未啟用也可執行。dev36 有 2,314,750 列。(PR-080)
### Changed
- **Post query router 避開漂移中的 group**:上次 drift-check 有 miss / diff / extra 的
group 不路由,整個查詢維持原生(全有或全無);從未檢查的 group 照常路由;aeav_only
不受影響。(PR-072、PR-073b)
- **純排序搜尋 flat-first**:沒有路由篩選的 HivePress `_sort` 搜尋,在該 post type 每篇都有
扁平列時改由扁平表驅動(`STRAIGHT_JOIN` + INNER JOIN);Router 在 `wp_insert_post`
替新文章建空列,覆蓋率以 anti-join 檢查並快取 15 分鐘(隨 post 模式變更失效)。
flat-first 且扁平表是唯一 JOIN 時拿掉 WordPress 為 tax query 加的 `GROUP BY posts.ID`。
dev36 `rating__desc`:原生約 3.3 s、1.1.0 約 1.5–1.9 s、現在約 0.51–0.85 s。(PR-079)
- **Auto-promoter** 的第三個條件改為 `TMDO_Drift_Check::clean_since()`:2 天內每個 group 都
檢查過(抽樣或全量)且沒有漂移;沒有紀錄就不升級。(PR-075)
- **shadow_read diff 節流**:每個 entity type + key 每小時最多記 20 列(疊加既有的
每物件 5 分鐘視窗)。(PR-075)
- **回填 group 改讀 registry**:admin「全部回填」、`post-migrate-group`、`member-backfill`、
`member-audit` 取所有已註冊 group(排除已還原的孤兒 group),不再是寫死清單;含 json
欄位的 group(attachment、nav_menu_item)逐列經 migration engine。(PR-067、PR-068)
- **不再註冊 `post/wc_product`**:WooCommerce 直接讀 product meta、以 SQL 寫庫存,扁平副本
必然漂移(ADR-004 決策 2)。既有的 `wp_wpdo_post_wc_product` 表保留,以
`wp tmdo bridge-restore-native post --group=wc_product --retire` 封存;解除安裝仍會刪除。(PR-077)
- `member-sso-sync`、`member-force-logout` 標示為 deprecated(`sso` group 已不註冊)。(PR-069)
### Fixed
- Entity 回填替沒有原生 row 的 key 寫入 DDL 預設值(`session_count` / `account_flags` = 0),
每個 user 都被捏造出 0;現在寫 NULL,required NOT NULL 欄位保留預設值。(PR-066)
- `member-cutover` 在轉換被拒時 fatal(把 `WP_Error` 當陣列)。(PR-068)
- `doctor` 依 registry 檢查 entity 表(不再因從未註冊的 `sso` 表而每站失敗),並顯示
Promotion Guard 違規與 drift 狀態;`term-comment-diagnose` 依各 filter 的 `is_enabled()`
顯示狀態;`bridge-status` 只計有值的扁平列、eav_ids 限定 group 的 post type
(dev36 wp_core 15306% → 100%)。(PR-076)
- 2.1 稽核:公開 REST route 註明公開理由;admin 23 處三元 echo 依情境 `esc_attr` /
`esc_html`;migration engine 與 health cron 的例外改記 `TMDO_Logger`。(PR-078)
### Docs
- ADR-004「設計(Release 2.2)」、`CONTEXT.md` Entity Bridge 的漂移段落。(PR-086)
### 升級後
- HivePress 站:部署 AddOn 0.3.0 後執行 `wp tmdo cleanup-hp-transients --options --confirm`。
- 曾註冊 wc_product 的站:`wp tmdo bridge-restore-native post --group=wc_product --retire`。
- 已進入 dual_write 以上的站:`wp tmdo drift-check <entity>` 建立第一筆紀錄(未檢查的
group 照常路由)。
---
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。
+7
View File
@@ -158,6 +158,13 @@ semantics, LIKE as `%esc_like%`. Meta ordering (the HivePress `_sort` NOT EXISTS
shape, a named clause, `meta_key` + `meta_value_num`) is routed only as the sole
`orderby` on a column that orders like native.
**Drift** (v1.2.0). `TMDO_Drift_Check` compares flat values with native meta per key
(miss / diff / extra) and records the outcome per group in `wpdo_drift_status`;
`--fix` rebuilds flat rows from native. The router routes no clause of a group recorded
as drifting (never checked → routes). A daily sample, and a full fix 60 s after an
entity leaves `disabled` or the Hook Bus is switched back on (`tmdo_drift_resync`), keep
the record current. None of this applies in `aeav_only`, where flat is the source of truth.
---
## Migration Phase (Strategy pattern)
+31 -14
View File
@@ -89,13 +89,16 @@ class TMDO_Admin {
* Register the admin menu page under Tools.
*/
public static function register_menu(): void {
add_management_page(
$hook_suffix = add_management_page(
__( 'WP Data Optimizer', '2meet-data-optimizer' ),
__( 'WP Data Optimizer', '2meet-data-optimizer' ),
'manage_options',
self::MENU_SLUG,
array( __CLASS__, 'render_page' )
);
if ( $hook_suffix ) {
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_post' ) );
}
}
/**
@@ -326,9 +329,10 @@ class TMDO_Admin {
}
/**
* Render the admin page.
* Handle the page's form posts on load-{page}, before any output, so their
* redirects work (inside render_page() the admin header was already sent).
*/
public static function render_page(): void {
public static function handle_post(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
@@ -556,11 +560,11 @@ class TMDO_Admin {
exit;
}
// v2.10.0: Post Migration Wizard — backfill all 7 groups.
// v2.10.0: Post Migration Wizard — backfill every registered post group.
if ( isset( $_POST['wpdo_post_backfill_all'] ) && check_admin_referer( 'wpdo_post_backfill_all' ) && class_exists( 'TMDO_Post_Migration' ) ) {
$total_migrated = 0;
$errors = array();
foreach ( array( 'wp_core', 'attachment', 'wc_product', 'hp_listing_core', 'hp_request_core', 'hp_vendor_core', 'nav_menu_item' ) as $group ) {
foreach ( TMDO_Post_Migration::backfillable_groups() as $group ) {
try {
$result = TMDO_Post_Migration::backfill_group( $group );
$total_migrated += (int) $result['migrated'];
@@ -752,6 +756,15 @@ class TMDO_Admin {
);
exit;
}
}
/**
* Render the admin page.
*/
public static function render_page(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$tabs = array(
'dashboard' => __( '儀表板', '2meet-data-optimizer' ),
@@ -774,7 +787,7 @@ class TMDO_Admin {
'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ),
);
$tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) );
$tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- tab navigation, no state change.
// v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry.
if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) {
$tab = 'entity-bridge';
@@ -846,8 +859,8 @@ class TMDO_Admin {
<span class="wpdo-tab-group-label" aria-hidden="true"><?php echo esc_html( $group['label'] ); ?></span>
<?php foreach ( $group_tabs as $slug ) : ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', $slug, admin_url( 'tools.php?page=' . self::MENU_SLUG ) ) ); ?>"
class="nav-tab <?php echo $tab === $slug ? 'nav-tab-active' : ''; ?>"
<?php echo $tab === $slug ? 'aria-current="page"' : ''; ?>>
class="nav-tab <?php echo esc_attr( $tab === $slug ? 'nav-tab-active' : '' ); ?>"
aria-current="<?php echo esc_attr( $tab === $slug ? 'page' : 'false' ); ?>">
<?php echo esc_html( $tabs[ $slug ] ); ?>
<?php if ( isset( $needs_dot[ $slug ] ) ) : ?>
<span class="wpdo-tab-dot" title="
@@ -1099,7 +1112,7 @@ class TMDO_Admin {
<tbody>
<tr>
<th><?php esc_html_e( '資料庫引擎', '2meet-data-optimizer' ); ?></th>
<td><code><?php echo TMDO_IS_MYSQL ? 'MySQL' : 'SQLite'; ?></code></td>
<td><code><?php echo esc_html( TMDO_IS_MYSQL ? 'MySQL' : 'SQLite' ); ?></code></td>
</tr>
<tr>
<th><?php esc_html_e( '外掛版本', '2meet-data-optimizer' ); ?></th>
@@ -1116,7 +1129,11 @@ class TMDO_Admin {
<tr>
<th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th>
<td>
<?php echo wp_using_ext_object_cache() ? '<span class="wpdo-badge wpdo-badge-ok">External</span>' : '<span class="wpdo-badge">Built-in</span>'; ?>
<?php if ( wp_using_ext_object_cache() ) : ?>
<span class="wpdo-badge wpdo-badge-ok">External</span>
<?php else : ?>
<span class="wpdo-badge">Built-in</span>
<?php endif; ?>
<?php if ( $cache['flush_support'] ) : ?>
<span class="wpdo-badge wpdo-badge-ok">Flush Support</span>
<?php endif; ?>
@@ -1726,13 +1743,13 @@ class TMDO_Admin {
↑ <?php esc_html_e( '升級模式', '2meet-data-optimizer' ); ?>
</button>
<button type="button"
class="button wpdo-btn-demote <?php echo $prev_mode ? '' : 'wpdo-eb-btn-dim'; ?>"
class="button wpdo-btn-demote <?php echo esc_attr( $prev_mode ? '' : 'wpdo-eb-btn-dim' ); ?>"
data-wpdo-action="demote"
data-entity-type="<?php echo esc_attr( $etype ); ?>"
data-prev-mode="<?php echo esc_attr( $prev_mode ?? '' ); ?>"
<?php disabled( ! $prev_mode ); ?>
title="<?php echo esc_attr( $prev_mode ? '降級到 ' . $prev_mode : '已在最低模式' ); ?>"
class="<?php echo $prev_mode ? '' : 'wpdo-eb-btn-dim'; ?>">
class="<?php echo esc_attr( $prev_mode ? '' : 'wpdo-eb-btn-dim' ); ?>">
↓ <?php esc_html_e( '降級模式', '2meet-data-optimizer' ); ?>
</button>
</div>
@@ -2037,7 +2054,7 @@ class TMDO_Admin {
</div>
<!-- Progress section (live) -->
<div id="wpdo-st-progress-card" class="wpdo-eb-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
<div id="wpdo-st-progress-card" class="wpdo-eb-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-bar-section">
<div class="wpdo-bar-header">
@@ -2071,7 +2088,7 @@ class TMDO_Admin {
</div>
<!-- Benchmark report -->
<div id="wpdo-st-bench-card" class="wpdo-eb-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
<div id="wpdo-st-bench-card" class="wpdo-eb-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-st-bench-content">
<?php if ( ! empty( $state['benchmark'] ) ) : ?>
+1 -1
View File
@@ -246,7 +246,7 @@ class TMDO_Dashboard_Widget {
'%2$s'
);
?>
<p class="wpdo-widget-notice <?php echo $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok'; ?>">
<p class="wpdo-widget-notice <?php echo esc_attr( $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok' ); ?>">
📦
<?php
if ( $total_eav > 0 ) {
+30 -15
View File
@@ -106,7 +106,7 @@ class TMDO_Setup_Wizard {
* @return void
*/
public static function register_menu_page(): void {
add_submenu_page(
$hook_suffix = add_submenu_page(
'tools.php',
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
@@ -114,6 +114,33 @@ class TMDO_Setup_Wizard {
'wpdo-setup-wizard',
array( __CLASS__, 'render_page' )
);
if ( $hook_suffix ) {
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_snapshot_post' ) );
}
}
/**
* Step 4 baseline snapshot (POST + nonce, prevents Referer leak — v3.3.x
* P1-6b hardening), handled on load-{page} so the redirect runs before any
* output.
*
* @return void
*/
public static function handle_snapshot_post(): void {
if ( ! isset( $_POST['wpdo_take_snapshot'] ) || ! TMDO_Capability::current_user_can_admin() || ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
return;
}
check_admin_referer( self::NONCE_NAME );
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&' . ( empty( $result['ok'] ) ? 'snap_failed=1' : 'snap_done=1' ) ) );
exit;
}
/**
@@ -356,20 +383,8 @@ class TMDO_Setup_Wizard {
</p>
</form>
<?php
// Take snapshot if action requested (POST + nonce, prevents Referer leak — v3.3.x P1-6b hardening).
if ( isset( $_POST['wpdo_take_snapshot'] ) && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) {
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
if ( ! empty( $result['ok'] ) ) {
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
exit;
}
// The snapshot itself is taken in handle_snapshot_post(), before any output.
if ( isset( $_GET['snap_failed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
}
?>
+2 -2
View File
@@ -211,7 +211,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
</div>
<!-- Progress card (live) -->
<div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
<div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-bar-section">
<div class="wpdo-bar-header">
@@ -249,7 +249,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
</div>
<!-- Benchmark report -->
<div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
<div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-cst-bench-content"></div>
</div>
+3 -3
View File
@@ -99,7 +99,7 @@ $failed = 'failed' === $state;
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
<?php echo $active || $completed ? 'hidden' : ''; ?>>
<?php echo esc_attr( $active || $completed ? 'hidden' : '' ); ?>>
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
<div class="wpdo-mw-nothing-todo">
@@ -170,7 +170,7 @@ $failed = 'failed' === $state;
<!-- Live progress panel (running state) ───────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
<?php echo $active || $failed ? '' : 'hidden'; ?>>
<?php echo esc_attr( $active || $failed ? '' : 'hidden' ); ?>>
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
@@ -210,7 +210,7 @@ $failed = 'failed' === $state;
<!-- Completed state ───────────────────────────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
<?php echo $completed ? '' : 'hidden'; ?>>
<?php echo esc_attr( $completed ? '' : 'hidden' ); ?>>
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
+1 -1
View File
@@ -218,7 +218,7 @@ $wpdo_format_msg = static function ( string $code ): string {
<form method="post">
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
<button type="submit" class="button button-primary <?php echo (int) $garbage['total'] > 0 ? '' : 'disabled'; ?>"
<button type="submit" class="button button-primary <?php echo esc_attr( (int) $garbage['total'] > 0 ? '' : 'disabled' ); ?>"
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
</button>
+6 -6
View File
@@ -161,22 +161,22 @@ $post_type_options = array(
</tr>
</thead>
<tbody>
<tr class="<?php echo 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
<tr class="<?php echo esc_attr( 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
<td><code>disabled</code></td>
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
</tr>
<tr class="<?php echo 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
<tr class="<?php echo esc_attr( 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
<td><code>dual_write</code></td>
<td>wp_postmeta 5 / flat 0 → 1:5</td>
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
</tr>
<tr class="<?php echo 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
<tr class="<?php echo esc_attr( 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
<td><code>shadow_read</code></td>
<td>wp_postmeta 5 / flat 0 → 1:5</td>
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
</tr>
<tr class="<?php echo 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : ''; ?>">
<tr class="<?php echo esc_attr( 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : '' ); ?>">
<td><code>aeav_only</code></td>
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
@@ -291,7 +291,7 @@ $post_type_options = array(
</div>
<!-- Progress section (live) -->
<div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
<div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-bar-section">
<div class="wpdo-bar-header">
@@ -329,7 +329,7 @@ $post_type_options = array(
</div>
<!-- Benchmark report -->
<div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
<div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-pst-bench-content"></div>
</div>
+2 -2
View File
@@ -207,7 +207,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
</div>
<!-- Progress card (live) -->
<div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
<div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-bar-section">
<div class="wpdo-bar-header">
@@ -245,7 +245,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
</div>
<!-- Benchmark report -->
<div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
<div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-tst-bench-content"></div>
</div>
+39 -29
View File
@@ -9,8 +9,8 @@
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
* wp wpdo member-cutover — Switch user mode to aeav_only.
* wp wpdo member-points-check — Show balance + recent ledger for a user.
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
* wp wpdo member-sso-sync — (deprecated) Sync Hub membership claims to flat table.
* wp wpdo member-force-logout — (deprecated) Set token_expires_at to past → re-auth.
*
* @package WP_Data_Optimizer
* @since 2.5.5
@@ -35,24 +35,6 @@ if ( ! class_exists( 'WP_CLI' ) ) {
*/
class TMDO_CLI_Member {
/**
* Valid group names managed by this CLI.
*
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
* the legacy WP/WC/HP wp_usermeta keys.
*/
private const VALID_GROUPS = array(
'membership',
'activity',
'profile',
'sso',
'core_profile',
'social',
'commerce',
'hp_user',
'admin_prefs',
);
// ── member-audit ─────────────────────────────────────────────────────────
/**
@@ -73,7 +55,7 @@ class TMDO_CLI_Member {
WP_CLI::log( '' );
$items = array();
foreach ( self::VALID_GROUPS as $group ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'user' ) as $group ) {
$table = $wpdb->prefix . 'wpdo_user_' . $group;
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
@@ -111,7 +93,7 @@ class TMDO_CLI_Member {
// ── member-backfill ───────────────────────────────────────────────────────
/**
* Backfill wp_usermeta rows into the specified user flat-table group.
* Backfill wp_usermeta rows into one user flat-table group, or all of them.
*
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
@@ -119,7 +101,10 @@ class TMDO_CLI_Member {
* ## OPTIONS
*
* [--group=<group>]
* : Which group to backfill: membership, activity, profile, sso. Default: membership.
* : Registered user group to backfill (e.g. membership, activity, core_profile). Default: membership.
*
* [--all]
* : Backfill every registered user group (restored orphan groups excluded).
*
* [--batch-size=<n>]
* : Rows per batch (default 500, max 2000).
@@ -134,21 +119,38 @@ class TMDO_CLI_Member {
*
* wp wpdo member-backfill --group=membership --dry-run
* wp wpdo member-backfill --group=membership
* wp wpdo member-backfill --group=sso --reset
* wp wpdo member-backfill --all
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_backfill( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? 'membership' );
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
$dry_run = isset( $assoc_args['dry-run'] );
$reset = isset( $assoc_args['reset'] );
$valid = TMDO_Entity_Migration_Engine::backfillable_groups( 'user' );
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) );
if ( isset( $assoc_args['all'] ) ) {
if ( isset( $assoc_args['group'] ) ) {
WP_CLI::error( 'Use either --group or --all, not both.' );
}
$groups = $valid;
} else {
$groups = array( (string) ( $assoc_args['group'] ?? 'membership' ) );
if ( ! in_array( $groups[0], $valid, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid ) );
}
}
foreach ( $groups as $group ) {
$this->backfill_group( $group, $batch_size, $dry_run, $reset );
}
}
/**
* Backfill one user group and report the result.
*/
private function backfill_group( string $group, int $batch_size, bool $dry_run, bool $reset ): void {
if ( $reset ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
WP_CLI::log( "Checkpoint cleared for user/{$group}." );
@@ -298,8 +300,8 @@ class TMDO_CLI_Member {
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
if ( isset( $result['error'] ) ) {
WP_CLI::error( 'Cutover failed: ' . $result['error'] );
if ( is_wp_error( $result ) ) {
WP_CLI::error( 'Cutover failed: ' . $result->get_error_message() );
}
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
@@ -356,6 +358,9 @@ class TMDO_CLI_Member {
/**
* Sync Hub membership claims into the user flat table.
*
* Deprecated since 1.2.0: the user `sso` group is no longer registered, so
* its flat table is never created on new installs. Will be removed.
*
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
* from usermeta and writes them into the sso group flat table.
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
@@ -381,6 +386,7 @@ class TMDO_CLI_Member {
* @param array $assoc_args Named arguments.
*/
public function member_sso_sync( $args, $assoc_args ): void {
WP_CLI::warning( 'member-sso-sync is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
global $wpdb;
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
@@ -466,6 +472,9 @@ class TMDO_CLI_Member {
/**
* Force re-authentication by setting token_expires_at to a past datetime.
*
* Deprecated since 1.2.0: works on the user `sso` flat table, which is no
* longer registered. Will be removed.
*
* The Spoke silent-refresh logic detects an expired token_expires_at and
* redirects the user to re-authenticate against the Hub. If the Hub has
* the account blocked, the user is logged out from all Spokes.
@@ -490,6 +499,7 @@ class TMDO_CLI_Member {
* @param array $assoc_args Named arguments.
*/
public function member_force_logout( $args, $assoc_args ): void {
WP_CLI::warning( 'member-force-logout is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
}
+55 -15
View File
@@ -198,29 +198,20 @@ class TMDO_CLI_Post {
/**
* Backfill one post entity group from wp_postmeta into its flat table
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE).
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). Groups with
* JSON-typed fields are backfilled row by row instead.
*
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the
* row-by-row backfill phase in v2.9.4+).
* Idempotent — safe to re-run.
*
* ## 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
* ---
* : Registered post entity group name (e.g. wp_core, attachment,
* hp_listing_core). Required.
*
* ## EXAMPLES
*
* wp wpdo post-migrate-group --group=wc_product
* wp wpdo post-migrate-group --group=hp_listing_core
* wp wpdo post-migrate-group --group=hp_listing_core
*
* @param array $args Positional arguments (unused).
@@ -439,6 +430,10 @@ class TMDO_CLI_Post {
* Safe to run with the filter enabled; the filter prevents new postmeta
* writes from re-bloating the table.
*
* With --options it removes instead the rows the filter wrote to wp_options
* (`_transient_wpdo_hp_pm_*`) before 1.2.0 / AddOn 0.3.0 kept them in the
* object cache; works without the AddOn active.
*
* ## OPTIONS
*
* [--dry-run]
@@ -447,10 +442,17 @@ class TMDO_CLI_Post {
* [--confirm]
* : Required to actually DELETE rows.
*
* [--options]
* : Clean the AddOn's rows in wp_options instead of wp_postmeta.
*
* [--batch=<n>]
* : Rows per DELETE with --options. Default 5000.
*
* ## EXAMPLES
*
* wp wpdo cleanup-hp-transients --dry-run
* wp wpdo cleanup-hp-transients --confirm
* wp wpdo cleanup-hp-transients --options --dry-run
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
@@ -470,6 +472,11 @@ class TMDO_CLI_Post {
);
}
if ( isset( $assoc_args['options'] ) ) {
self::cleanup_hp_transient_options( $dry_run, max( 1, (int) ( $assoc_args['batch'] ?? 5000 ) ) );
return;
}
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
}
@@ -503,6 +510,39 @@ class TMDO_CLI_Post {
);
}
/**
* Count or delete the HivePress AddOn's transient rows in wp_options, in
* batches (dev36 held 1.15 M of them).
*
* @param bool $dry_run Count only.
* @param int $batch Rows per DELETE.
* @return void
*/
private static function cleanup_hp_transient_options( bool $dry_run, int $batch ): void {
global $wpdb;
// TMDO_Hivepress_Transient_Filter::TRANSLATED_NAMESPACE (AddOn), repeated
// here so the leftovers can be removed with the AddOn gone.
$ns = $wpdb->esc_like( 'wpdo_hp_pm_' );
$where = $wpdb->prepare( 'option_name LIKE %s OR option_name LIKE %s', '\_transient\_' . $ns . '%', '\_transient\_timeout\_' . $ns . '%' );
if ( $dry_run ) {
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE {$where}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
WP_CLI::success( sprintf( '[dry-run] %d HivePress transient row(s) in wp_options would be deleted. Re-run with --confirm.', $count ) );
return;
}
$deleted = 0;
do {
$rows = (int) $wpdb->query( "DELETE FROM {$wpdb->options} WHERE {$where} LIMIT {$batch}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$deleted += $rows;
} while ( $rows === $batch );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info( 'cleanup_hp_transient_options', array( 'deleted' => $deleted ) );
}
WP_CLI::success( sprintf( 'Deleted %d HivePress transient row(s) from wp_options.', $deleted ) );
}
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
/**
+13 -9
View File
@@ -442,9 +442,9 @@ class TMDO_CLI_Term_Comment {
WP_CLI::log( '' );
WP_CLI::log( '── Filter chain status ──' );
self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' );
self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' );
self::print_filter_status( 'wpdo_term_comment_misc_bucket_enabled', 'Misc bucket (Phase 4)' );
self::print_filter_status( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter (Phase 1)' );
self::print_filter_status( 'TMDO_WC_Term_Count_Filter', 'WC term count filter (Phase 3)' );
self::print_filter_status( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket (Phase 4)' );
WP_CLI::log( '' );
WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
@@ -504,15 +504,19 @@ class TMDO_CLI_Term_Comment {
}
/**
* Print a filter's enabled/disabled status.
* Print a filter's enabled/disabled status, as the filter itself decides it
* (each has its own default).
*
* @param string $option_key Option storing the toggle value.
* @param string $label Human-readable name.
* @param string $filter_class Filter class with a static is_enabled().
* @param string $label Human-readable name.
* @return void
*/
private static function print_filter_status( string $option_key, string $label ): void {
$enabled = (bool) get_option( $option_key, '1' );
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) );
private static function print_filter_status( string $filter_class, string $label ): void {
if ( ! class_exists( $filter_class ) ) {
WP_CLI::log( sprintf( '%-32s%s', $label . ':', '— not installed' ) );
return;
}
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $filter_class::is_enabled() ? '✓ enabled' : '✗ disabled' ) );
}
// ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
+142 -6
View File
@@ -91,22 +91,29 @@ class TMDO_CLI_V2 {
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table )
);
$flat_count = $flat_exists
// Flat rows holding at least one value (wp_core keeps an all-NULL row per post).
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
$has_value = implode( ' OR ', array_map( static fn( $k ) => '`' . TMDO_Schema_Manager::sanitize_column_name( $k ) . '` IS NOT NULL', $managed_keys ) );
$flat_count = $flat_exists && '' !== $has_value
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" )
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}` WHERE {$has_value}" )
: 0;
// EAV source: count distinct entity IDs that have at least one managed key.
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
$eav_table = $eav_tables[ $entity_type ] ?? '';
$eav_count = 0;
$eav_table = $eav_tables[ $entity_type ] ?? '';
$eav_count = 0;
if ( '' !== $eav_table && ! empty( $managed_keys ) ) {
$id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id";
$placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) );
// $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$sql = "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$eav_table}` WHERE `meta_key` IN ({$placeholders})";
$sql = "SELECT COUNT(DISTINCT m.`{$id_col}`) FROM `{$eav_table}` m WHERE m.`meta_key` IN ({$placeholders})";
// A group of some post types only: other posts holding its keys are not its rows.
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( 'post', $group_name ) : array();
if ( $post_types ) {
$sql .= " AND m.post_id IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "') )";
}
$eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
@@ -152,6 +159,131 @@ class TMDO_CLI_V2 {
WP_CLI::success( "Hook Bus: {$state}" );
}
/**
* Set an entity's bridge mode. Goes through TMDO_Mode_Manager::set(): unsafe
* jumps and promotions refused by the Promotion Guard (aeav_only on
* HivePress / WooCommerce sites) fail and leave the mode unchanged.
*
* ## OPTIONS
*
* <entity>
* : post | user | term | comment
*
* <mode>
* : disabled | dual_write | shadow_read | aeav_only
*
* ## EXAMPLES
*
* wp tmdo bridge-mode user dual_write
* wp tmdo bridge-mode post disabled
*
* @param array<int, string> $args Entity type and mode.
* @param array<string, string> $assoc_args Unused.
*/
public function bridge_mode( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$entity = (string) ( $args[0] ?? '' );
$mode = (string) ( $args[1] ?? '' );
$from = TMDO_Mode_Manager::get( $entity );
$result = TMDO_Mode_Manager::set( $entity, $mode );
if ( is_wp_error( $result ) ) {
WP_CLI::error( $result->get_error_message() );
}
WP_CLI::success( "{$entity}: {$from} → {$mode}" );
}
/**
* Compare an entity's flat tables with native meta, optionally repairing them.
*
* Reports per key how many values are missing from flat (miss), differ
* (diff) or exist only in flat (extra), and records the outcome per group
* in the wpdo_drift_status option. Read-only unless --fix is given.
*
* ## OPTIONS
*
* <entity>
* : post | user | term | comment
*
* [--group=<group>]
* : Only this group. Default: every backfillable group.
*
* [--sample=<n>]
* : Check n random entities per group instead of all of them.
*
* [--fix]
* : Rebuild drifted flat rows from native meta (native is never written),
* delete rows of deleted entities / other post types, then check again.
* Not combinable with --sample.
*
* [--format=<format>]
* : table or json. Default: table.
*
* ## EXAMPLES
*
* wp tmdo drift-check post
* wp tmdo drift-check user --group=activity
* wp tmdo drift-check post --sample=500
* wp tmdo drift-check post --fix
*
* @param array<int, string> $args Entity type.
* @param array<string, string> $assoc_args Options.
*/
public function drift_check( $args, $assoc_args ): void {
$entity = (string) ( $args[0] ?? '' );
$group = (string) ( $assoc_args['group'] ?? '' );
$sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) );
$fix = isset( $assoc_args['fix'] );
if ( $fix && $sample ) {
WP_CLI::error( '--fix repairs every entity; it cannot be combined with --sample.' );
return;
}
try {
if ( $fix ) {
$result = TMDO_Drift_Check::fix( $entity, $group );
$report = $result['after'];
WP_CLI::log( sprintf( 'Fixed: %d row(s) rebuilt from native, %d cleared, %d stray row(s) deleted.', $result['fixed']['rebuilt'], $result['fixed']['cleared'], $result['fixed']['deleted_rows'] ) );
} else {
$report = TMDO_Drift_Check::run( $entity, $group, $sample );
}
} catch ( InvalidArgumentException | RuntimeException $e ) {
WP_CLI::error( $e->getMessage() );
return;
}
if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) {
WP_CLI::line( (string) wp_json_encode( $report ) );
return;
}
$rows = array();
foreach ( $report['groups'] as $group => $result ) {
foreach ( $result['keys'] as $key => $counts ) {
if ( $counts['miss'] + $counts['diff'] + $counts['extra'] > 0 ) {
$rows[] = array_merge(
array(
'group' => $group,
'key' => $key,
),
$counts
);
}
}
}
if ( $rows ) {
\WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'key', 'miss', 'diff', 'extra' ) );
}
$total = $report['total'];
$summary = sprintf( '%s: %d group(s)%s — miss=%d diff=%d extra=%d', $entity, count( $report['groups'] ), $sample ? " (sample {$sample})" : '', $total['miss'], $total['diff'], $total['extra'] );
if ( $total['miss'] + $total['diff'] + $total['extra'] > 0 ) {
WP_CLI::warning( $summary );
return;
}
WP_CLI::success( $summary );
}
/**
* Write an entity's flat-table values back to native meta — the way out of
* aeav_only. Resumable; re-run after an interruption.
@@ -918,6 +1050,10 @@ WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status'
WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'wpdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
WP_CLI::add_command( 'tmdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
WP_CLI::add_command( 'wpdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
WP_CLI::add_command( 'tmdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
+91 -23
View File
@@ -94,6 +94,92 @@ class TMDO_CLI {
WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
}
/**
* Report the flat table of every backfillable group of every entity type,
* plus the installer's migration status and points ledger tables.
*
* @return bool Every table exists.
*/
public static function check_entity_tables(): bool {
global $wpdb;
$tables = array( $wpdb->prefix . 'wpdo_migration_status', $wpdb->prefix . 'wpdo_user_points_ledger' );
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
foreach ( TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ) as $group ) {
$tables[] = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
}
}
$ok = true;
foreach ( $tables as $t ) {
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
if ( $exists ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
} else {
WP_CLI::warning( " [MISSING] {$t} — run `wp wpdo install` to create." );
$ok = false;
}
}
return $ok;
}
/**
* Report Promotion Guard violations and the recorded drift status of every
* entity that writes flat tables or has been checked (informational).
*/
public static function report_guard_and_drift(): void {
WP_CLI::log( '' );
$violations = TMDO_Promotion_Guard::violations();
if ( empty( $violations ) ) {
WP_CLI::log( ' [OK] Promotion Guard: no violation' );
}
foreach ( $violations as $entity_type => $integrations ) {
WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." );
}
$deferred = array_keys( (array) get_option( TMDO_Schema_Manager::DEFERRED_OPTION, array() ) );
if ( $deferred ) {
WP_CLI::warning( ' [SCHEMA] groups using native meta until their table upgrade and the drift resync after it (tmdo_drift_resync) are done: ' . implode( ', ', $deferred ) . '.' );
}
$pending = TMDO_Schema_Manager::pending_indexes();
if ( $pending ) {
WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' );
}
$status = get_option( TMDO_Drift_Check::OPTION, array() );
$status = is_array( $status ) ? $status : array();
WP_CLI::log( 'Drift (last `wp tmdo drift-check` or daily sample):' );
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
// Records of groups no longer registered can be neither re-checked nor fixed.
$last = array_intersect_key( (array) ( $status[ $entity_type ] ?? array() ), array_flip( $groups ) );
if ( empty( $groups ) || ( empty( $last ) && ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) ) {
continue;
}
if ( empty( $last ) ) {
WP_CLI::log( " {$entity_type}: never checked — run `wp tmdo drift-check {$entity_type}`" );
continue;
}
$drifting = array();
foreach ( $last as $group => $s ) {
if ( (int) $s['miss'] + (int) $s['diff'] + (int) $s['extra'] > 0 ) {
$drifting[] = sprintf( '%s miss=%d diff=%d extra=%d', $group, $s['miss'], $s['diff'], $s['extra'] );
}
}
$oldest = min( array_column( $last, 'checked_at' ) );
if ( $drifting ) {
WP_CLI::warning( " {$entity_type}: " . implode( '; ', $drifting ) . " (checked {$oldest} UTC or later) — `wp tmdo drift-check {$entity_type} --fix`" );
} else {
WP_CLI::log( sprintf( ' [OK] %s: %d group(s) clean (oldest check %s UTC)', $entity_type, count( $last ), $oldest ) );
}
}
}
/**
* Run health check on all tables.
*
@@ -195,30 +281,12 @@ class TMDO_CLI {
WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
}
// Check user entity flat tables (v2.5.5+).
$user_entity_tables = array(
$wpdb->prefix . 'wpdo_user_membership',
$wpdb->prefix . 'wpdo_user_activity',
$wpdb->prefix . 'wpdo_user_profile',
$wpdb->prefix . 'wpdo_user_sso',
$wpdb->prefix . 'wpdo_user_points_ledger',
$wpdb->prefix . 'wpdo_migration_status',
);
foreach ( $user_entity_tables as $t ) {
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
if ( $exists ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
} else {
WP_CLI::warning( " [MISSING] {$t} — run \`wp wpdo install\` to create." );
$all_ok = false;
}
// Entity flat tables, from the registry (was a hard-coded user list that
// named the never-registered sso group).
if ( ! self::check_entity_tables() ) {
$all_ok = false;
}
self::report_guard_and_drift();
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
+21 -1
View File
@@ -77,6 +77,26 @@
HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。
## 設計(Release 2.2)
### 範圍收斂
- core 不再註冊 `post/wc_product`;WooCommerce AddOn 0.2.0 不再註冊 product / order / subscription 的 zone 欄位與 user `hot` 群組。WooCommerce 的 meta 只存在原生。既有表以 `bridge-restore-native post --group=wc_product --retire` 封存。
### 漂移偵測與修復(`TMDO_Drift_Check`)
- `wp tmdo drift-check <entity>` 以值比對扁平與原生(第一筆原生 row → 安全 unserialize → `Type_Caster::to_db`),逐 key 回報 miss / diff / extra,結果寫入 `wpdo_drift_status`。
- `--fix` 從原生重建漂移列,刪除已刪實體與非本 group post type 的列,不寫原生。
- **aeav_only 不適用**:那時扁平才是真實資料,原生本來就過期。`--fix` 拒絕、每日抽樣略過、Router 不因漂移退回原生。
- **Router**:上次檢查有漂移的 group 不路由,整個查詢維持原生;從未檢查的 group 照常路由(決策 2026-09-27:升級後不變慢)。
- **每日抽樣**:`wpdo_daily_health_check`,每 group 500 筆。
- **Hook Bus 關閉期間的寫入**:entity 從 disabled 進入 dual_write / shadow_read、或 Hook Bus flag 由關轉開時,60 秒後 `tmdo_drift_resync` 執行全量 `--fix`。不用 `post_modified_gmt` 增量,因為 WooCommerce 直接 SQL 寫 meta 不會更新它。
- **Auto-promoter** 以 `clean_since()`(2 天內乾淨的檢查)取代 shadow diff 數量;Promotion Guard 仍擋 HP / WC 站。
### 查詢
- 純排序搜尋(HivePress `_sort`、沒有路由篩選)在該 post type 每篇都有扁平列時改由扁平表驅動;Router 在 `wp_insert_post` 替新文章建空列,覆蓋率快取 15 分鐘。
## 後果
**好處**
@@ -89,7 +109,7 @@ HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `opti
- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。
- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 的 `drift-check` 與重新啟用時的增量 backfill(E01 / E02)處理;在那之前,大量匯入後要重新 backfill。
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 起由 `drift-check`(每日抽樣、Router 避開漂移中的 group)與重新啟用時的全量 `--fix` 處理;兩次抽樣之間的漂移仍可能短暫回傳過期結果,大量匯入後可手動執行 `drift-check <entity> --fix`。
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。
- HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。
+12 -12
View File
@@ -49,7 +49,7 @@ class TMDO_Cache_Layer {
// Check which IDs are already cached.
$uncached = array();
foreach ( $post_ids as $pid ) {
$cached = wp_cache_get( "cold_{$pid}", $group );
$cached = wp_cache_get( TMDO_Zone_Cold::cache_key( (int) $pid, $post_type ), $group );
if ( false === $cached ) {
$uncached[] = (int) $pid;
}
@@ -94,7 +94,7 @@ class TMDO_Cache_Layer {
// Cache all results (including empty arrays for posts with no cold data).
foreach ( $uncached as $pid ) {
$data = $fetched[ $pid ] ?? array();
wp_cache_set( "cold_{$pid}", $data, $group, $ttl );
wp_cache_set( TMDO_Zone_Cold::cache_key( $pid, $post_type ), $data, $group, $ttl );
}
}
@@ -114,7 +114,7 @@ class TMDO_Cache_Layer {
// Force a fresh read from DB (bypasses cache).
$group = 'wpdo_cold_' . sanitize_key( $post_type );
wp_cache_delete( "cold_{$post_id}", $group );
wp_cache_delete( TMDO_Zone_Cold::cache_key( $post_id, $post_type ), $group );
// Re-read will populate cache.
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
@@ -136,18 +136,18 @@ class TMDO_Cache_Layer {
/**
* Flush all cold cache for a post type.
*
* Useful after bulk imports or schema changes.
* Useful after bulk imports or schema changes. Bumps the post type's cache
* version (new keys from now on) instead of wp_cache_flush_group(), which
* Redis Object Cache runs as one server-side scan that blocks Redis on a
* large group; older entries expire by their TTL.
*
* @param string $post_type Post type to flush.
*/
public static function flush_group( string $post_type ): void {
$group = 'wpdo_cold_' . sanitize_key( $post_type );
if ( function_exists( 'wp_cache_flush_group' ) ) {
wp_cache_flush_group( $group );
}
// If wp_cache_flush_group is not available (older WP or no object cache),
// individual entries will expire via TTL.
$versions = get_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, array() );
$versions = is_array( $versions ) ? $versions : array();
$versions[ $post_type ] = (int) ( $versions[ $post_type ] ?? 0 ) + 1;
update_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, $versions, true );
}
/**
@@ -183,7 +183,7 @@ class TMDO_Cache_Layer {
return array(
'groups' => $groups,
'prefetch_support' => true,
'flush_support' => function_exists( 'wp_cache_flush_group' ),
'flush_support' => true,
);
}
+16
View File
@@ -261,6 +261,9 @@ class TMDO_Core {
$post_router = new TMDO_Post_Query_Router();
$post_router->register_hooks();
}
if ( class_exists( 'TMDO_Post_Query_Router' ) ) {
add_action( 'clean_post_cache', array( 'TMDO_Post_Query_Router', 'bump_count_version' ) );
}
// Zone B: Warm cleanup cron handler.
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
@@ -296,6 +299,19 @@ class TMDO_Core {
// v2.3.0 M6: daily health probe cron.
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
// 1.2.0: daily flat-vs-native drift sample (read by the post query router).
add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );
// 1.2.0: rebuild flat from native after writes bypassed it (entity was
// disabled, or the Hook Bus was off).
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );
add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );
add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );
add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );
// 1.3.0: declared indexes a visitor request left for WP-Cron.
add_action( TMDO_Schema_Manager::INDEXES_HOOK, array( 'TMDO_Schema_Manager', 'ensure_pending_indexes' ) );
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
+4
View File
@@ -788,6 +788,8 @@ class TMDO_Installer {
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( 'tmdo_drift_resync' );
wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' );
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' );
@@ -991,6 +993,8 @@ class TMDO_Installer {
'wpdo_health_snapshot_monthly',
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'tmdo_drift_resync',
'tmdo_ensure_group_indexes',
'wpdo_post_stress_test_batch',
'wpdo_user_stress_test_batch',
'wpdo_term_stress_test_batch',
+24 -7
View File
@@ -48,7 +48,7 @@ class TMDO_Logger {
/**
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
*
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved
* Writes only to PHP error_log, and only when debug logging is on; does NOT touch wpdo_errors (which is reserved
* for actual errors). Mode changes / cache flushes / audit prunes call here
* many times per request — keeping them out of the DB error table.
*
@@ -57,19 +57,36 @@ class TMDO_Logger {
* @return void
*/
public static function info( string $event, array $context = array() ): void {
// Routine events: only with WP_DEBUG_LOG (filter `tmdo_log_info`), or every
// CLI run and production request would flood the log.
if ( ! apply_filters( 'tmdo_log_info', defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
return;
}
self::write_info( $event, $context );
}
/**
* Write an INFO line to the PHP error log.
*
* @param string $event Event name.
* @param array<string, mixed> $context Structured context.
* @return void
*/
private static function write_info( string $event, array $context ): void {
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
}
/**
* Log a NOTICE-level event — alias of info() for callers that want a stronger
* level intent. Same routing (error_log only).
* Log a NOTICE-level event: always written to the PHP error log (unlike
* info()), for the audit trail of operator actions such as mode changes
* and snapshot deletions.
*
* @param string $event Event name.
* @param array $context Structured context.
* @param string $event Event name.
* @param array<string, mixed> $context Structured context.
* @return void
*/
public static function notice( string $event, array $context = array() ): void {
self::info( $event, $context );
error_log( sprintf( '[WPDO][NOTICE][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
}
/**
@@ -97,7 +114,7 @@ class TMDO_Logger {
*/
public static function debug( string $event, array $context = array() ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
self::info( $event, $context );
self::write_info( $event, $context );
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ final class TMDO_Post_Shadow_Verifier {
* is available.
*
* @param string $post_type WP post_type to sample.
* @param string $group_name Entity group name (e.g. 'wc_product').
* @param string $group_name Entity group name (e.g. 'hp_listing_core').
* @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).
+21 -7
View File
@@ -23,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
class TMDO_REST_API {
/** Blocked views are counted 1 in this many (wpdo_rl_stats). */
private const RL_STATS_SAMPLE = 10;
const NAMESPACE = 'wpdo/v1';
/**
@@ -37,6 +40,8 @@ class TMDO_REST_API {
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_listings' ),
// Public by design: viewable post types and published posts only, fields
// declared show_in_rest only (1.1.1), per_page capped (wpdo_rest_max_per_page).
'permission_callback' => '__return_true',
'args' => $this->listings_args(),
)
@@ -48,6 +53,8 @@ class TMDO_REST_API {
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_listing' ),
// Public by design: the post must be viewable to the request (assert_post_viewable_or_error:
// publicly viewable or read_post); fields declared show_in_rest only (1.1.1).
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
@@ -65,6 +72,7 @@ class TMDO_REST_API {
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_stats' ),
// Public by design: view / engagement counters of a post viewable to the request.
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
@@ -82,6 +90,8 @@ class TMDO_REST_API {
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'post_view' ),
// Public by design: anonymous visitors count views; viewable posts only, one view
// per IP per post per hour (rate limited).
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
@@ -718,14 +728,18 @@ class TMDO_REST_API {
if ( $cookie_blocked || $ip_blocked ) {
// Track rate-limit hits per post for admin visibility, capped at 100 entries.
$rl_stats = get_option( 'wpdo_rl_stats', array() );
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1;
if ( count( $rl_stats ) > 100 ) {
// Keep only the top-100 posts by hit count to bound option size.
arsort( $rl_stats );
$rl_stats = array_slice( $rl_stats, 0, 100, true );
// Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so
// anonymous requests cannot turn every blocked view into an option write.
if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) {
$rl_stats = get_option( 'wpdo_rl_stats', array() );
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE;
if ( count( $rl_stats ) > 100 ) {
// Keep only the top-100 posts by hit count to bound option size.
arsort( $rl_stats );
$rl_stats = array_slice( $rl_stats, 0, 100, true );
}
update_option( 'wpdo_rl_stats', $rl_stats, false );
}
update_option( 'wpdo_rl_stats', $rl_stats, false );
return new WP_REST_Response(
array(
+5 -2
View File
@@ -282,7 +282,8 @@ class TMDO_Schema_Registry {
* Whether a field may appear in the public listings endpoints: declared
* show_in_rest, not a protected meta key (leading underscore — WordPress
* hides those from public output, e.g. _purchase_note), and not named like a
* credential (tokens, secrets, passwords, API keys), whatever it declares.
* credential (tokens, secrets, passwords, API keys) or a contact / location
* detail (email, phone, address, IP), whatever it declares.
*
* @param array<string, mixed> $field Registered field.
* @return bool
@@ -291,7 +292,9 @@ class TMDO_Schema_Registry {
$key = (string) ( $field['meta_key'] ?? '' );
return ! empty( $field['show_in_rest'] )
&& 0 !== strpos( $key, '_' )
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) );
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) )
// Contact and location details (2.3 PR-096): private even when declared public.
&& 1 !== preg_match( '/e_?mail|phone|address|(^|_)ip(_|$|\s)/i', $key . ' ' . ( $field['column'] ?? '' ) );
}
/**
@@ -0,0 +1,533 @@
<?php
/**
* TMDO_Drift_Check — read-only flat-vs-native comparison for every entity type.
*
* For each backfillable group and key, the flat column is compared with what
* native meta would put there (first native row → safe unserialize →
* TMDO_Type_Caster::to_db()):
*
* miss — native holds a non-empty value, the flat column is NULL or has no row
* diff — both hold a value and the values differ (numbers, JSON and
* serialized values are compared by value, not by formatting)
* extra — the flat column holds a value, native has no row for the key
* (a required column holding its default counts as absent); rows of
* another post type or of a deleted entity count too
*
* run() writes nothing except the per-group outcome in the wpdo_drift_status
* option. fix() rebuilds the drifted rows from native (native is never written).
*
* @package WP_Data_Optimizer
* @since 1.2.0
*/
// phpcs:ignore WPDO.AntiEAV -- drift checker: must read raw meta to compare it with the flat tables
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Read-only checker: table and column names come from the registry / adapters, meta keys and ids go through prepare() or intval.
/**
* Flat-vs-native drift checker.
*/
final class TMDO_Drift_Check {
/**
* Option holding the last outcome per entity / group.
*/
public const OPTION = 'wpdo_drift_status';
/**
* Entities per group sampled by the daily check.
*/
public const CRON_SAMPLE = 500;
/**
* Single WP-Cron event rebuilding an entity's flat tables from native after
* writes bypassed them. Argument: the entity type.
*/
public const RESYNC_HOOK = 'tmdo_drift_resync';
/**
* Seconds before the scheduled rebuild, so the switch that triggered it
* finishes first.
*/
private const RESYNC_DELAY = 60;
/**
* MySQL regex for a plain decimal number, used to skip rows that obviously match.
* Exact matches compare BINARY: meta and flat tables may differ in collation,
* and a case-insensitive one would hide 'b' vs 'B'.
*/
private const SQL_NUMERIC = '^-?[0-9]+(\\\\.[0-9]+)?$';
/**
* Compare one entity type's flat tables with native meta.
*
* @param string $entity_type post | user | term | comment.
* @param string $group One group, or '' for every backfillable group.
* @param int $sample 0 checks every entity; N checks N random entities per group.
* @return array{entity:string, sample:int, groups:array<string, array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>}>, total:array{miss:int, diff:int, extra:int}}
* @throws InvalidArgumentException Unknown entity type or group.
*/
public static function run( string $entity_type, string $group = '', int $sample = 0 ): array {
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
$report = array(
'entity' => $entity_type,
'sample' => $sample,
'groups' => array(),
'total' => array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
),
);
$status = get_option( self::OPTION, array() );
$status = is_array( $status ) ? $status : array();
foreach ( $groups as $name ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
$result = self::check_group( $adapter, $entity_type, $name, $table, $sample );
unset( $result['ids'] );
$report['groups'][ $name ] = $result;
foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) {
$report['total'][ $kind ] += $result[ $kind ];
}
$status[ $entity_type ][ $name ] = array(
'miss' => $result['miss'],
'diff' => $result['diff'],
'extra' => $result['extra'],
'sample' => $sample,
'checked_at' => gmdate( 'Y-m-d H:i:s' ),
);
}
update_option( self::OPTION, $status );
return $report;
}
/**
* Every backfillable group of the entity was checked within $max_age
* seconds (sampled or full) and none drifted. No record counts as unclean.
*
* @param string $entity_type Entity type.
* @param int $max_age Seconds.
*/
public static function clean_since( string $entity_type, int $max_age ): bool {
$status = get_option( self::OPTION, array() );
$status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array();
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
if ( empty( $groups ) ) {
return false;
}
foreach ( $groups as $group ) {
$last = $status[ $group ] ?? null;
if ( ! is_array( $last )
|| (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0
|| (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) {
return false;
}
}
return true;
}
/**
* Daily check (wpdo_daily_health_check): sample every entity type written
* to both native and flat (dual_write, shadow_read). In aeav_only flat is
* the source of truth and native is stale by design, so drift means nothing
* there. The outcome replaces the recorded status, so a group found
* drifting leaves the router at once.
*/
public static function cron_tick(): void {
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) || ! TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
continue;
}
try {
self::run( $entity_type, '', self::CRON_SAMPLE );
} catch ( InvalidArgumentException $e ) {
continue; // No adapter for this entity type on this site.
}
}
}
/**
* Entities to rebuild after a mode change: one whose flat tables were not
* written (disabled) is written next to native again, and values written
* meanwhile are missing from flat.
*
* @param string $entity_type Entity type.
* @param string $new_mode New mode.
* @param string $old_mode Previous mode.
* @return string[]
*/
public static function resync_after_mode_change( string $entity_type, string $new_mode, string $old_mode ): array {
$keeps_native = array( TMDO_Mode_Manager::MODE_DUAL_WRITE, TMDO_Mode_Manager::MODE_SHADOW_READ );
return TMDO_Mode_Manager::MODE_DISABLED === $old_mode && in_array( $new_mode, $keeps_native, true ) ? array( $entity_type ) : array();
}
/**
* Entities to rebuild after the Hook Bus flag changes: no write reached
* flat while it was off. Every entity written to both native and flat
* (aeav_only keeps flat as the source of truth).
*
* @param mixed $old_value Previous flag value.
* @param mixed $value New flag value.
* @return string[]
*/
public static function resync_after_hook_bus_change( $old_value, $value ): array {
if ( self::flag_on( $old_value ) || ! self::flag_on( $value ) ) {
return array();
}
$entities = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
$entities[] = $entity_type;
}
}
return $entities;
}
/**
* `wpdo_bridge_mode_changed` handler.
*
* @param string $entity_type Entity type.
* @param string $new_mode New mode.
* @param string $old_mode Previous mode.
*/
public static function on_mode_changed( string $entity_type, string $new_mode, string $old_mode ): void {
self::schedule_resync( self::resync_after_mode_change( $entity_type, $new_mode, $old_mode ) );
}
/**
* `update_option_wpdo_hook_bus_enabled` handler.
*
* @param mixed $old_value Previous flag value.
* @param mixed $value New flag value.
*/
public static function on_hook_bus_toggled( $old_value, $value ): void {
self::schedule_resync( self::resync_after_hook_bus_change( $old_value, $value ) );
}
/**
* `add_option_wpdo_hook_bus_enabled` handler: the flag is stored for the first time.
*
* @param string $option Option name.
* @param mixed $value Flag value.
*/
public static function on_hook_bus_added( $option, $value ): void {
unset( $option );
self::schedule_resync( self::resync_after_hook_bus_change( '0', $value ) );
}
/**
* RESYNC_HOOK handler. An entity that has meanwhile moved to aeav_only is
* left alone (fix() refuses).
*
* @param string $entity_type Entity type.
*/
public static function run_scheduled_fix( string $entity_type ): void {
try {
$result = self::fix( $entity_type );
TMDO_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again.
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
} catch ( InvalidArgumentException | RuntimeException $e ) {
TMDO_Logger::info(
'drift_resync_skipped',
array(
'entity' => $entity_type,
'reason' => $e->getMessage(),
)
);
}
}
/**
* Schedule one rebuild per entity type (not twice while one is pending).
*
* @param string[] $entities Entity types.
*/
private static function schedule_resync( array $entities ): void {
foreach ( $entities as $entity_type ) {
if ( ! wp_next_scheduled( self::RESYNC_HOOK, array( $entity_type ) ) ) {
wp_schedule_single_event( time() + self::RESYNC_DELAY, self::RESYNC_HOOK, array( $entity_type ) );
}
}
}
/**
* Whether a stored Hook Bus flag value means "on" (as TMDO_Hook_Bus_Bridge reads it).
*
* @param mixed $value Flag value.
*/
private static function flag_on( $value ): bool {
return '1' === (string) $value || true === $value;
}
/**
* Rebuild drifted flat rows from native meta, then check again.
*
* Rows of deleted entities (or, for post groups, of posts outside the
* group's post types) are deleted. Every other entity with drift in any key
* gets its whole row rebuilt from native; when native holds none of the
* group's keys the row is kept with its columns cleared. Row caches of the
* touched entities are invalidated. Native meta is never written.
*
* @param string $entity_type post | user | term | comment.
* @param string $group One group, or '' for every backfillable group.
* @return array{fixed:array{rebuilt:int, cleared:int, deleted_rows:int}, after:array<string, mixed>}
* @throws RuntimeException In aeav_only, where flat is the source of truth
* (and InvalidArgumentException via resolve() for an
* unknown entity type or group).
*/
public static function fix( string $entity_type, string $group = '' ): array {
global $wpdb;
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ) {
throw new RuntimeException( esc_html( $entity_type ) . ' is in aeav_only: flat is the source of truth and native meta is stale, so it cannot be rebuilt from native. Write flat back first (wp tmdo bridge-restore-native).' );
}
$id = $adapter->get_entity_id_column();
$fixed = array(
'rebuilt' => 0,
'cleared' => 0,
'deleted_rows' => 0,
);
foreach ( $groups as $name ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
foreach ( self::stray_ids( $adapter, $entity_type, $name, $table ) as $eid ) {
$fixed['deleted_rows'] += (int) $wpdb->delete( $table, array( $id => $eid ), array( '%d' ) );
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
}
foreach ( self::check_group( $adapter, $entity_type, $name, $table, 0 )['ids'] as $eid ) {
if ( TMDO_Entity_Migration_Engine::migrate_entity( $entity_type, $name, $eid ) ) {
++$fixed['rebuilt'];
} else {
self::clear_row( $entity_type, $name, $table, $id, $eid );
++$fixed['cleared'];
}
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
}
}
return array(
'fixed' => $fixed,
'after' => self::run( $entity_type, $group ),
);
}
/**
* Adapter and group list for a run.
*
* @param string $entity_type Entity type.
* @param string $group One group, or '' for every backfillable group.
* @return array{0:TMDO_Entity_Adapter_Interface, 1:string[]}
* @throws InvalidArgumentException Unknown entity type or group.
*/
private static function resolve( string $entity_type, string $group ): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) );
}
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
if ( '' !== $group ) {
if ( ! in_array( $group, $groups, true ) ) {
throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) );
}
$groups = array( $group );
}
return array( $adapter, $groups );
}
/**
* Compare every key of one group.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @param int $sample 0, or the number of random entities.
* @return array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>, ids:int[]}
*/
private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array {
global $wpdb;
$meta = $adapter->get_native_meta_table();
$id = $adapter->get_entity_id_column();
$mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
$scope = self::scope_sql( $adapter, $entity_type, $group, $sample );
$result = array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
'keys' => array(),
'ids' => array(),
);
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$key = (string) $field['key'];
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$type = $field['type'] ?? 'text';
// First native row per entity — what a single get_*_meta() read returns.
$native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key )
. str_replace( '{id}', "m.`{$id}`", $scope );
// Text beyond the column width is stored as NULL by design (Type_Caster::fit_text).
$fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : '';
$miss = array_map( 'intval', $wpdb->get_col( "SELECT n.eid FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ) );
$candidates = $wpdb->get_results(
"SELECT n.eid, n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid
WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v
OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )",
ARRAY_A
);
$diff = array();
foreach ( (array) $candidates as $row ) {
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
$diff[] = (int) $row['eid'];
}
}
$absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key );
if ( ! empty( $field['required'] ) ) {
$default = (string) TMDO_Type_Caster::to_db( null, $field );
$absent .= $wpdb->prepare(
" AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )",
$default,
$default,
$default
);
}
// Every flat row counts, including rows of other post types or deleted
// entities; only sample mode narrows the rows.
$extra = array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ) );
$counts = array(
'miss' => count( $miss ),
'diff' => count( $diff ),
'extra' => count( $extra ),
);
$result['keys'][ $key ] = $counts;
$result['miss'] += $counts['miss'];
$result['diff'] += $counts['diff'];
$result['extra'] += $counts['extra'];
$result['ids'] = array_merge( $result['ids'], $miss, $diff, $extra );
}
$result['ids'] = array_values( array_unique( $result['ids'] ) );
return $result;
}
/**
* Flat rows whose entity no longer exists or, for post groups, whose post
* is outside the group's post types.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @return int[]
*/
private static function stray_ids( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table ): array {
global $wpdb;
$id = $adapter->get_entity_id_column();
$primary = $adapter->get_primary_table();
$pk = $adapter->get_primary_id_column();
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
$type_sql = $post_types ? " AND p.post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" : '';
return array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f LEFT JOIN `{$primary}` p ON p.`{$pk}` = f.`{$id}`{$type_sql} WHERE p.`{$pk}` IS NULL" ) );
}
/**
* Clear a row's columns: native holds none of the group's keys. The row is
* kept (NULL-only rows are not deleted, as the router may join on them);
* required columns go back to their default.
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @param string $id Entity id column.
* @param int $eid Entity id.
*/
private static function clear_row( string $entity_type, string $group, string $table, string $id, int $eid ): void {
global $wpdb;
$data = array();
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$data[ TMDO_Schema_Manager::sanitize_column_name( (string) $field['key'] ) ] = TMDO_Type_Caster::to_db( null, $field );
}
$wpdb->update( $table, $data, array( $id => $eid ) );
}
/**
* SQL restricting the checked entities: the group's post types, and in
* sample mode a random set of entity ids. `{id}` stands for the id column.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param int $sample 0, or the number of random entities.
*/
private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string {
global $wpdb;
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
$type_sql = $post_types
? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')"
: '';
if ( $sample <= 0 ) {
return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : '';
}
$primary = $adapter->get_primary_table();
$pk = $adapter->get_primary_id_column();
$ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample );
return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')';
}
/**
* Whether a flat value is what native meta would store.
*
* @param string $native Raw native meta_value.
* @param string $flat Flat column value.
* @param array<string, mixed> $field Registered field definition.
*/
private static function same( string $native, string $flat, array $field ): bool {
$type = $field['type'] ?? 'text';
$value = TMDO_Safe_Unserialize::run( $native );
if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) {
return false; // Belongs in flat as NULL, yet this row holds a value.
}
$expected = TMDO_Type_Caster::to_db( $value, $field );
if ( null === $expected ) {
return false;
}
if ( 'json' === $type ) {
// Key order and int-vs-float formatting are not drift.
return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
}
if ( is_numeric( $expected ) && is_numeric( $flat ) ) {
return abs( (float) $expected - (float) $flat ) < 0.000001;
}
return (string) $expected === $flat;
}
}
@@ -104,7 +104,7 @@ class TMDO_Health_Cron {
}
} catch ( Throwable $e ) {
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
TMDO_Logger::error( 'health_cron', 'module_detector', $e->getMessage() );
}
}
+11 -8
View File
@@ -6,7 +6,8 @@
* 判斷條件(皆滿足才觸發):
* 1. 當前 mode = shadow_read
* 2. 進入 shadow_read 距今 ≥ `min_days`
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
* 3. 該 entity 每個群組最近一次 drift-check(2 天內,抽樣或全量)皆無漂移
* (1.2.0 起取代 shadow_diffs 筆數;無紀錄不升級)
*
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
* 若 diff 出現,規則永不觸發;人工介入即可。
@@ -35,6 +36,9 @@ final class TMDO_Auto_Promoter {
public const CRON_HOOK = 'wpdo_auto_promote_check';
/** A drift check older than this (seconds) does not count for promotion. */
public const DRIFT_MAX_AGE = 2 * DAY_IN_SECONDS;
public static function init(): void {
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
@@ -82,7 +86,7 @@ final class TMDO_Auto_Promoter {
/**
* 評估單一 entity 是否可升級。
*
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed'
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'drift_not_clean' | 'vetoed' | 'error'
*/
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
$current = TMDO_Mode_Manager::get( $type );
@@ -95,14 +99,13 @@ final class TMDO_Auto_Promoter {
return 'not_ready';
}
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
$count = (int) ( $diff_counts[ $type ] ?? 0 );
if ( $count > 0 ) {
return 'has_diff';
if ( ! TMDO_Drift_Check::clean_since( $type, self::DRIFT_MAX_AGE ) ) {
return 'drift_not_clean';
}
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count );
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)。第 4 參數原為
// shadow diff 筆數;1.2.0 起改看 drift,固定傳 0。
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, 0 );
if ( ! $should ) {
return 'vetoed';
}
@@ -144,13 +144,14 @@ final class TMDO_Entity_Migration_Engine {
}
} catch ( \Throwable $e ) {
++$stats['errors'];
error_log(
sprintf(
'[UAE Migration] Error %s/%s ID=%d: %s',
$entity_type,
$group_name,
$entity_id,
$e->getMessage()
TMDO_Logger::error(
'migration_engine',
'migrate_group',
$e->getMessage(),
array(
'entity_type' => $entity_type,
'group' => $group_name,
'entity_id' => $entity_id,
)
);
}
@@ -182,6 +183,28 @@ final class TMDO_Entity_Migration_Engine {
return $stats;
}
/**
* Rebuild one entity's row of a group from native meta.
*
* @return bool False when native holds none of the group's keys (nothing written).
*/
public static function migrate_entity( string $entity_type, string $group_name, int $entity_id ): bool {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( ! $adapter || empty( $group_fields ) ) {
return false;
}
return self::migrate_single_entity(
$entity_type,
$group_name,
$entity_id,
array_column( $group_fields, 'key' ),
array_column( $group_fields, null, 'key' ),
$adapter,
false
);
}
/**
* 遷移單一 entity 的所有 meta
*/
@@ -243,6 +266,18 @@ final class TMDO_Entity_Migration_Engine {
return false;
}
// REPLACE rewrites the whole row: a column left out falls back to its DDL
// default (e.g. session_count DEFAULT 0), inventing a value the object never
// had. Keys without a native row are written as NULL; required columns are
// NOT NULL, so they keep their default (treated as "absent" by the diff logger).
foreach ( $field_map as $key => $field_def ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
if ( ! array_key_exists( $col, $data ) && empty( $field_def['required'] ) ) {
$data[ $col ] = null;
$formats[] = '%s';
}
}
if ( $dry_run ) {
return true;
}
@@ -349,7 +384,16 @@ final class TMDO_Entity_Migration_Engine {
++$batch_migrated;
}
} catch ( \Throwable $e ) {
error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) );
TMDO_Logger::error(
'migration_engine',
'migrate_group_batch',
$e->getMessage(),
array(
'entity_type' => $entity_type,
'group' => $group_name,
'entity_id' => $entity_id,
)
);
}
$last_id = $entity_id;
}
@@ -374,6 +418,23 @@ final class TMDO_Entity_Migration_Engine {
);
}
/**
* Registered groups of an entity type that can be backfilled from native
* meta. Orphan groups restored from wpdo_registry_meta are left out: their
* values live only in the flat table, a backfill would wipe them.
*
* @return string[]
*/
public static function backfillable_groups( string $entity_type ): array {
$orphans = array();
foreach ( TMDO_Entity_Registry::get_restored_groups() as $restored ) {
if ( $entity_type === $restored['entity_type'] ) {
$orphans[] = $restored['group'];
}
}
return array_values( array_diff( TMDO_Entity_Registry::get_groups_for_type( $entity_type ), $orphans ) );
}
// ─────────────────────────────────────────────────────────
// 斷點管理
// ─────────────────────────────────────────────────────────
+24 -6
View File
@@ -912,6 +912,7 @@ final class TMDO_Hook_Bus {
private static function touch_posts_last_changed( string $type ): void {
if ( 'post' === $type ) {
wp_cache_set_posts_last_changed();
TMDO_Post_Query_Router::bump_count_version();
}
}
@@ -948,10 +949,23 @@ final class TMDO_Hook_Bus {
*/
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( null === $field_def || 'post' !== $type ) {
return $field_def;
if ( null !== $field_def && 'post' === $type ) {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
}
return TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
// A group whose table upgrade waits for WP-CLI / WP-Cron stays native
// (not in aeav_only, where native is stale; the router agrees).
return null !== $field_def && self::deferred( $type, (string) $field_def['group'] ) ? null : $field_def;
}
/**
* Whether a group is deferred and so read and written natively.
*
* @param string $type Entity type.
* @param string $group Group name.
* @return bool
*/
private static function deferred( string $type, string $group ): bool {
return TMDO_Schema_Manager::is_deferred( $type, $group ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $type );
}
/**
@@ -969,10 +983,13 @@ final class TMDO_Hook_Bus {
// 回傳所有群組的所有欄位
$result = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
$row = self::get_or_load_row( $type, $entity_id, $group );
$deferred = self::deferred( $type, $group );
$row = $deferred ? array() : self::get_or_load_row( $type, $entity_id, $group );
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
$result[ $field['key'] ] = $deferred
? get_metadata( $type, $entity_id, $field['key'], true )
: TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
}
}
return $result;
@@ -980,7 +997,8 @@ final class TMDO_Hook_Bus {
$field_def = self::field_for( $type, $entity_id, $key );
if ( ! $field_def ) {
return null;
// A deferred group's value is the native one.
return null !== TMDO_Entity_Registry::get_field( $type, $key ) ? get_metadata( $type, $entity_id, $key, true ) : null;
}
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
+1 -1
View File
@@ -159,7 +159,7 @@ final class TMDO_Mode_Manager {
$entered[ $entity_type ] = time();
update_option( self::OPT_ENTERED_AT, $entered, false );
TMDO_Logger::info(
TMDO_Logger::notice(
'bridge_mode_changed',
array(
'entity_type' => $entity_type,
@@ -65,6 +65,10 @@ final class TMDO_Post_Status_Sync {
unset( $new_status, $old_status );
foreach ( self::groups_with_columns() as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
self::forget( $group ); // The table was dropped (retired or uninstalled group).
continue;
}
$wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
@@ -220,6 +224,20 @@ final class TMDO_Post_Status_Sync {
return is_array( $states ) ? array_keys( $states ) : array();
}
/**
* Drop a group from the recorded states.
*
* @param string $group Group name.
* @return void
*/
private static function forget( string $group ): void {
$states = get_option( self::OPTION, array() );
if ( is_array( $states ) && isset( $states[ $group ] ) ) {
unset( $states[ $group ] );
update_option( self::OPTION, $states, true );
}
}
/**
* Record a group's state.
*
+323 -13
View File
@@ -23,6 +23,12 @@ final class TMDO_Schema_Manager {
/** Option: "entity/group" → md5 of the composite index definition already applied. */
public const INDEXES_OPTION = 'tmdo_entity_indexes';
/** Single cron event that adds declared indexes a visitor request had to leave out. */
public const INDEXES_HOOK = 'tmdo_ensure_group_indexes';
/** Option: "entity/group" → time an existing table's upgrade was left to WP-CLI / WP-Cron. */
public const DEFERRED_OPTION = 'tmdo_schema_deferred';
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
private static array $table_exists_cache = array();
@@ -77,13 +83,13 @@ final class TMDO_Schema_Manager {
string $group_name,
array $field_definitions
): bool {
global $wpdb;
// Schema 版本比對:若未變動則跳過
$schema_hash = self::calculate_schema_hash( $field_definitions );
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
if ( $stored_hash === $schema_hash ) {
// Upgraded meanwhile, or the definition went back to the stored one.
self::end_deferral( $entity_type, $group_name );
return true;
}
@@ -92,7 +98,55 @@ final class TMDO_Schema_Manager {
return false;
}
$table = self::get_table_name( $entity_type, $group_name );
$table = self::get_table_name( $entity_type, $group_name );
if ( self::upgrade_waits( $entity_type, $table ) ) {
self::defer_upgrade( "{$entity_type}/{$group_name}" );
return false;
}
if ( ! self::table_exists( $table ) ) {
if ( ! self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ) ) {
return false;
}
// A new table starts empty while native meta may already hold the
// group's values: native until the drift fix has filled it in.
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ) ) {
self::record_deferred( "{$entity_type}/{$group_name}" );
self::end_deferral( $entity_type, $group_name );
}
return true;
}
// An existing table is altered by one process at a time (anonymous
// /wp-cron.php requests also pass ddl_allowed()); a process that waited
// finds the upgrade done.
if ( ! self::lock() ) {
return false;
}
try {
if ( self::get_stored_schema_hash( $entity_type, $group_name ) === $schema_hash ) {
return true;
}
return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash );
} finally {
self::unlock();
}
}
/**
* Create or alter the table (dbDelta) and record its definition.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @param array<int, array<string, mixed>> $field_definitions Normalized fields.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $table Table name.
* @param string $schema_hash Definition hash.
* @return bool
*/
private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool {
global $wpdb;
$existed = self::table_exists( $table );
$charset = $wpdb->get_charset_collate();
$id_col = $adapter->get_entity_id_column();
@@ -144,6 +198,11 @@ final class TMDO_Schema_Manager {
}
}
// A new table gets its declared composite indexes while it is empty.
foreach ( TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ) as $name => $keys ) {
$sql_indexes[] = 'KEY `cidx_' . self::sanitize_column_name( (string) $name ) . '` (' . implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ) . ')';
}
$columns_sql = implode( ",\n ", $sql_columns );
$indexes_sql = implode( ",\n ", $sql_indexes );
@@ -157,8 +216,27 @@ final class TMDO_Schema_Manager {
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
self::$table_exists_cache[ $table ] = true;
// dbDelta reports no failure: record the definition only when every column is there.
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" );
$missing = array_diff( array_map( static fn( $field ) => self::sanitize_column_name( (string) $field['key'] ), $field_definitions ), $columns );
if ( $missing ) {
unset( self::$table_exists_cache[ $table ] );
TMDO_Logger::warning(
'schema.upgrade_failed',
array(
'table' => $table,
'missing' => array_values( $missing ),
'error' => $wpdb->last_error,
)
);
return false;
}
// 記錄 Schema 版本與定義
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
if ( $existed ) {
self::end_deferral( $entity_type, $group_name );
}
/**
* Action: 表建立/升級完成
@@ -176,6 +254,12 @@ final class TMDO_Schema_Manager {
* applied definition is remembered per group (one autoloaded option), so
* requests only run SHOW INDEX / ALTER when the declaration changes.
*
* On a large table the ALTER takes seconds, so it runs only from WP-CLI or
* WP-Cron (ddl_allowed(); other requests schedule INDEXES_HOOK) and in one
* process at a time (MySQL GET_LOCK on index_lock_name()). Until every
* ALTER succeeds the group stays in pending_indexes() and the table works
* without the index.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
@@ -193,23 +277,249 @@ final class TMDO_Schema_Manager {
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
return;
}
if ( ! self::ddl_allowed() ) {
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK );
}
return;
}
$table = self::get_table_name( $entity_type, $group_name );
if ( ! self::table_exists( $table ) ) {
return;
}
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
foreach ( $declared as $name => $keys ) {
$index = 'cidx_' . self::sanitize_column_name( $name );
if ( in_array( $index, $existing, true ) ) {
continue;
}
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" );
if ( ! self::lock() ) {
return; // Another process is adding indexes.
}
try {
// SHOW INDEX (not the option, cached for this request) tells what another process already added.
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
$failed = false;
foreach ( $declared as $name => $keys ) {
$index = 'cidx_' . self::sanitize_column_name( $name );
if ( in_array( $index, $existing, true ) ) {
continue;
}
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
if ( false === $wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ) ) {
$failed = true;
TMDO_Logger::warning(
'schema.index_failed',
array(
'table' => $table,
'index' => $index,
'error' => $wpdb->last_error,
)
);
}
}
$applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, true );
if ( ! $failed ) {
$applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, true );
}
} finally {
self::unlock();
}
}
/**
* INDEXES_HOOK handler: the table upgrades and indexes a web request left.
* The cron request's init usually did them already; not with
* ALTERNATE_WP_CRON, where the event runs inside a visitor's request.
*
* @return void
*/
public static function ensure_pending_indexes(): void {
foreach ( array_keys( (array) get_option( self::DEFERRED_OPTION, array() ) ) as $id ) {
list( $entity_type, $group_name ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
$fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( $fields ) {
self::create_or_upgrade_table( $entity_type, $group_name, $fields );
}
}
foreach ( self::pending_indexes() as $id ) {
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
self::ensure_group_indexes( $entity_type, $group_name );
}
}
/**
* Whether the group's table upgrade is waiting for WP-CLI / WP-Cron; its
* keys then stay native (Hook Bus, query router).
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return bool
*/
public static function is_deferred( string $entity_type, string $group_name ): bool {
$deferred = get_option( self::DEFERRED_OPTION, array() );
return is_array( $deferred ) && isset( $deferred[ "{$entity_type}/{$group_name}" ] );
}
/**
* Whether an existing table's upgrade waits for WP-CLI / WP-Cron: altering
* a large table takes seconds to minutes, as for indexes. Until then the
* group reads and writes native meta (is_deferred()). Not in aeav_only,
* where the flat table is the only copy: that upgrade runs at once.
*
* @param string $entity_type Entity type.
* @param string $table Flat table name.
* @return bool
*/
public static function upgrade_waits( string $entity_type, string $table ): bool {
return self::table_exists( $table )
&& ! self::ddl_allowed()
&& TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type );
}
/**
* Record a deferred table upgrade and have WP-Cron run it.
*
* @param string $id "entity/group".
* @return void
*/
private static function defer_upgrade( string $id ): void {
self::record_deferred( $id );
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade.
}
}
/**
* Record a group as deferred (native until resynced()).
*
* @param string $id "entity/group".
* @return void
*/
private static function record_deferred( string $id ): void {
$deferred = get_option( self::DEFERRED_OPTION, array() );
$deferred = is_array( $deferred ) ? $deferred : array();
if ( ! isset( $deferred[ $id ] ) ) {
$deferred[ $id ] = time();
update_option( self::DEFERRED_OPTION, $deferred, true );
}
}
/**
* A deferred upgrade is done. Writes made meanwhile skipped the flat table,
* so the group stays native until a drift fix (tmdo_drift_resync, a minute
* later) has rebuilt it from native and calls resynced(). With no flat
* writes (disabled), nothing is behind.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
*/
private static function end_deferral( string $entity_type, string $group_name ): void {
if ( ! self::is_deferred( $entity_type, $group_name ) ) {
return;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) {
$deferred = (array) get_option( self::DEFERRED_OPTION, array() );
unset( $deferred[ "{$entity_type}/{$group_name}" ] );
update_option( self::DEFERRED_OPTION, $deferred, true );
return;
}
if ( ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) {
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) );
}
}
/**
* A drift fix of the entity succeeded: its deferred groups whose table is
* upgraded read and write the flat table again.
*
* @param string $entity_type Entity type.
* @return void
*/
public static function resynced( string $entity_type ): void {
$deferred = get_option( self::DEFERRED_OPTION, array() );
if ( ! is_array( $deferred ) || ! $deferred ) {
return;
}
foreach ( array_keys( $deferred ) as $id ) {
list( $type, $group ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
if ( $type === $entity_type && self::get_stored_schema_hash( $type, $group ) === self::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
unset( $deferred[ $id ] );
}
}
update_option( self::DEFERRED_OPTION, $deferred, true );
}
/**
* Take the schema lock without waiting.
*
* add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two
* processes both "acquire" an option lock; GET_LOCK is atomic and is
* released by MySQL if this process dies. Re-entrant per connection.
*
* @return bool False when another process holds it.
*/
private static function lock(): bool {
global $wpdb;
if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) {
// HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica,
// where it locks nothing against the ALTER on the primary.
$wpdb->send_reads_to_masters();
}
return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', self::index_lock_name() ) );
}
/**
* Release the schema lock.
*
* @return void
*/
private static function unlock(): void {
global $wpdb;
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', self::index_lock_name() ) );
}
/**
* Key of the MySQL lock held while indexes are added. Lock names are
* server-wide, so SQL hashes it with DATABASE() to keep sites apart.
*
* @return string
*/
public static function index_lock_name(): string {
global $wpdb;
return $wpdb->prefix . self::INDEXES_OPTION;
}
/**
* Whether this request may alter flat tables: WP-CLI or WP-Cron only.
* Front-end, REST, admin-ajax, admin-post and even /wp-admin/ requests
* (init runs before auth_redirect()) can be a visitor's, who would wait
* for the ALTER. Filter: `tmdo_schema_ddl_allowed`.
*
* @return bool
*/
public static function ddl_allowed(): bool {
$allowed = ( defined( 'WP_CLI' ) && WP_CLI )
|| ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() );
return (bool) apply_filters( 'tmdo_schema_ddl_allowed', $allowed );
}
/**
* Registered groups whose declared indexes are not on their table yet.
*
* @return string[] "entity/group"
*/
public static function pending_indexes(): array {
$applied = get_option( self::INDEXES_OPTION, array() );
$applied = is_array( $applied ) ? $applied : array();
$pending = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group_name ) {
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
if ( $declared && ( $applied[ "{$entity_type}/{$group_name}" ] ?? '' ) !== md5( (string) wp_json_encode( $declared ) )
&& self::table_exists( self::get_table_name( $entity_type, $group_name ) ) ) {
$pending[] = "{$entity_type}/{$group_name}";
}
}
}
return $pending;
}
/**
@@ -31,6 +31,14 @@ final class TMDO_Shadow_Diff_Logger {
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
public const RATELIMIT_WINDOW = 300;
/**
* Rows per entity type + key per KEY_WINDOW, across all objects: a key
* stale on every object (WooCommerce total_sales, written by direct SQL)
* must not log a row for each object read.
*/
public const KEY_LIMIT = 20;
public const KEY_WINDOW = 3600;
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'wpdo_shadow_diffs';
@@ -69,7 +77,7 @@ final class TMDO_Shadow_Diff_Logger {
}
// 不一致 → 檢查 rate limit
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) {
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) || self::key_over_limit( $entity_type, $meta_key ) ) {
return;
}
@@ -183,6 +191,21 @@ final class TMDO_Shadow_Diff_Logger {
return false;
}
/**
* Count this diff against the key's KEY_LIMIT; true once the limit is
* reached within the current KEY_WINDOW (the window starts at its first row).
*/
private static function key_over_limit( string $entity_type, string $meta_key ): bool {
$key = 'wpdo_sd_kl_' . md5( "{$entity_type}:{$meta_key}" );
$state = get_transient( $key );
$state = is_array( $state ) ? $state : array( 0, time() );
if ( $state[0] >= self::KEY_LIMIT ) {
return true;
}
set_transient( $key, array( $state[0] + 1, $state[1] ), max( 1, $state[1] + self::KEY_WINDOW - time() ) );
return false;
}
/**
* 寫入 diff row
*
+1 -1
View File
@@ -84,7 +84,7 @@ final class TMDO_Type_Caster {
}
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
private const TEXT_MAX_CHARS = 255;
public const TEXT_MAX_CHARS = 255;
/**
* Keep a text value only if it fits its VARCHAR(255) column; otherwise
@@ -2,19 +2,18 @@
/**
* Post entity field registration for WP Data Optimizer (v2.9.1).
*
* Registers four post entity groups designed to absorb the bulk of
* Registers three post entity groups designed to absorb the bulk of
* wp_postmeta rows that previously bypassed any flat-table strategy.
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
*
* Groups → flat tables (post_type targets):
* wp_core → wp_wpdo_post_wp_core (cross post_type)
* attachment → wp_wpdo_post_attachment (attachment)
* wc_product → wp_wpdo_post_wc_product (product)
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
*
* Pattern mirrors TMDO_Member_Fields::register(). Called from
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
* Schema_Manager materializes the four flat tables in init:1
* Schema_Manager materializes the three flat tables in init:1
* via process_pending_migrations() (idempotent via schema_hash compare).
*
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
@@ -39,7 +38,10 @@ if ( ! defined( 'ABSPATH' ) ) {
}
/**
* Registers four post entity groups.
* Registers three post entity groups. WooCommerce product meta is not one of
* them (1.2.0): WooCommerce reads it directly and writes stock by SQL, so
* native meta stays its only copy. Existing wp_wpdo_post_wc_product tables
* are left alone (see `wp tmdo bridge-restore-native post --group=wc_product --retire`).
*/
final class TMDO_Post_Fields {
@@ -49,7 +51,6 @@ final class TMDO_Post_Fields {
*/
private const GROUP_POST_TYPES = array(
'attachment' => 'attachment',
'wc_product' => 'product',
'nav_menu_item' => 'nav_menu_item',
);
@@ -104,7 +105,6 @@ final class TMDO_Post_Fields {
self::register_wp_core_group();
self::register_attachment_group();
self::register_wc_product_group();
self::register_nav_menu_item_group();
}
@@ -176,131 +176,6 @@ final class TMDO_Post_Fields {
);
}
/**
* WooCommerce product core meta keys (post_type=product).
* 19 keys — covers ~94% of wp_postmeta rows for products on dev10.
*
* @return void
*/
private static function register_wc_product_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'wc_product',
array(
array(
'key' => '_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Effective price',
),
array(
'key' => '_regular_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Regular price',
),
array(
'key' => '_sale_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Sale price',
),
array(
'key' => '_stock',
'type' => 'integer',
'searchable' => true,
'label' => 'Stock quantity',
),
array(
'key' => '_stock_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'instock', 'outofstock', 'onbackorder' ),
'label' => 'Stock status',
),
array(
'key' => '_sku',
'type' => 'text',
'searchable' => true,
'label' => 'Product SKU',
),
array(
'key' => '_manage_stock',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Manage stock?',
),
array(
'key' => '_backorders',
'type' => 'enum',
'options' => array( 'yes', 'no', 'notify' ),
'label' => 'Allow backorders?',
),
array(
'key' => '_sold_individually',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Sold individually?',
),
array(
'key' => '_virtual',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Virtual product?',
),
array(
'key' => '_downloadable',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Downloadable?',
),
array(
'key' => '_tax_class',
'type' => 'text',
'label' => 'Tax class slug',
),
array(
'key' => '_tax_status',
'type' => 'enum',
'options' => array( 'taxable', 'shipping', 'none' ),
'label' => 'Tax status',
),
array(
'key' => '_download_limit',
'type' => 'integer',
'label' => 'Download limit',
),
array(
'key' => '_download_expiry',
'type' => 'integer',
'label' => 'Download expiry days',
),
array(
'key' => '_product_version',
'type' => 'text',
'label' => 'WC version product was created on',
),
array(
'key' => '_wc_average_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
array(
'key' => '_wc_review_count',
'type' => 'integer',
'label' => 'Review count',
),
array(
'key' => 'total_sales',
'type' => 'integer',
'searchable' => true,
'label' => 'Total sales count',
),
)
);
}
/**
* Nav menu item meta keys (post_type=nav_menu_item).
*
@@ -99,11 +99,12 @@ final class TMDO_Post_Migration {
/**
* Bulk SQL pivot for one group: select managed keys from wp_postmeta,
* filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT
* into the group's flat table.
* into the group's flat table. Groups with json fields go row by row
* through backfill_group_json() instead.
*
* @param string $group_name Entity group name (e.g. 'wc_product').
* @param string $group_name Entity group name (e.g. 'hp_listing_core').
* @return array{migrated:int,group:string,post_type:string|null}
* @throws InvalidArgumentException When group is not registered.
* @throws InvalidArgumentException When group is not registered, or is a restored orphan group.
* @throws RuntimeException When the pivot SQL fails.
*/
public static function backfill_group( string $group_name ): array {
@@ -115,8 +116,30 @@ final class TMDO_Post_Migration {
'Unknown post entity group: ' . esc_html( $group_name )
);
}
if ( ! in_array( $group_name, self::backfillable_groups(), true ) ) {
throw new InvalidArgumentException(
'Orphan post group, its values live only in the flat table: ' . esc_html( $group_name )
);
}
$post_type = self::group_post_type( $group_name );
// json values need PHP-level unserialize → json encode, which the bulk
// pivot below cannot do: backfill such groups row by row instead.
if ( in_array( 'json', array_column( $fields, 'type' ), true ) ) {
$result = self::backfill_group_json( $group_name, array( 'resume' => false ) );
if ( ! empty( $result['error'] ) || ! empty( $result['errors'] ) ) {
throw new RuntimeException(
'backfill_group failed for ' . esc_html( $group_name ) . ': ' . esc_html( (string) ( $result['error'] ?? $result['errors'] . ' row error(s)' ) )
);
}
return array(
'migrated' => (int) $result['migrated'],
'group' => $group_name,
'post_type' => $post_type,
);
}
$post_type = self::group_post_type( $group_name );
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
// Introspect target table columns so we only pivot fields that actually
@@ -131,16 +154,10 @@ final class TMDO_Post_Migration {
}
// Build column list and CASE expressions.
// Skip json/textarea types from the bulk pivot — those need row-by-row
// unserialize handling (deferred to v2.9.4 backfill_unserialize phase).
$columns = array();
$cases = array();
$update_parts = array();
foreach ( $fields as $field ) {
$type = $field['type'] ?? 'text';
if ( 'json' === $type ) {
continue;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
if ( ! isset( $existing_cols[ $col ] ) ) {
continue; // Column not present in this table — skip silently.
@@ -536,6 +553,17 @@ final class TMDO_Post_Migration {
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Registered post groups that can be backfilled from wp_postmeta. Orphan
* groups restored from wpdo_registry_meta are left out: their values live
* only in the flat table.
*
* @return string[]
*/
public static function backfillable_groups(): array {
return TMDO_Entity_Migration_Engine::backfillable_groups( self::ENTITY_TYPE );
}
/**
* All meta_keys registered for any post entity group.
*
+174 -16
View File
@@ -31,8 +31,11 @@
*
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
* match inside the page query); found_posts_query answers from a COUNT of the
* same query, kept in the object cache until posts' last_changed moves (core
* post saves, and flat writes via TMDO_Hook_Bus).
* same query, kept in the object cache until a post is saved or deleted, a
* flat write goes through TMDO_Hook_Bus (bump_count_version()) or terms'
* last_changed moves. Not posts' last_changed: WordPress moves it on any post
* meta write, which themes do on page views (Blocksy's dynamic CSS), and a
* routed search reads no unmanaged meta.
*
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
* only entity_type='post'.
@@ -69,6 +72,12 @@ class TMDO_Post_Query_Router {
/** Object cache group for found-rows counts. */
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
/** The last_changed group the cached counts follow (bump_count_version()). */
public const COUNT_VERSION_GROUP = 'tmdo_posts';
/** Seconds a "every post of the type has a flat row" answer is reused. */
private const COVERAGE_TTL = 900;
/** Comparison operators the router can express on a flat column. */
private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
@@ -95,6 +104,30 @@ class TMDO_Post_Query_Router {
add_filter( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 );
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
add_action( 'wp_insert_post', array( $this, 'add_flat_rows' ), 10, 3 );
}
/**
* A new post of a type with its own groups gets an empty row in each, so
* that "every post of the type has a flat row" keeps holding and sort-only
* searches can drive from the flat table (see flat_first()).
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post.
* @param bool $update Whether an existing post was updated.
* @return void
*/
public function add_flat_rows( $post_id, $post, $update ): void {
if ( $update || ! $post instanceof \WP_Post ) {
return;
}
global $wpdb;
foreach ( TMDO_Post_Fields::groups_for_post_type( $post->post_type ) as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`) VALUES (%d)", (int) $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
}
/**
@@ -345,7 +378,23 @@ class TMDO_Post_Query_Router {
if ( ! $counted ) {
return $request;
}
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
global $wpdb;
$matches = (string) preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
$not_in = " AND {$wpdb->posts}.ID NOT IN (";
// post__not_in (HivePress sends a random set per page) would give every
// search its own cache key: count without it (cached) minus the excluded
// posts that match (primary-key lookups, so no forced flat-first order).
if ( 1 === substr_count( $matches, $not_in ) && preg_match( '/' . preg_quote( $not_in, '/' ) . '([\d,]+)\)/', $matches, $m ) ) {
$excluded = str_replace( $m[0], " AND {$wpdb->posts}.ID IN ({$m[1]})", (string) preg_replace( '/^SELECT STRAIGHT_JOIN /', 'SELECT ', $matches ) );
$query->set(
self::COUNT_VAR,
array(
'SELECT COUNT(*) FROM ( ' . str_replace( $m[0], '', $matches ) . ' ) AS tmdo_found',
"SELECT COUNT(*) FROM ( {$excluded} ) AS tmdo_found",
)
);
return $request;
}
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
return $request;
}
@@ -387,13 +436,21 @@ class TMDO_Post_Query_Router {
if ( empty( $count_sql ) ) {
return (string) ( $sql ?? '' );
}
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
global $wpdb;
list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' );
// Keyword searches carry wpdb's placeholder escape for "%", which is new
// in every request: without it the key would never match again.
$stable = method_exists( $wpdb, 'remove_placeholder_escape' ) ? $wpdb->remove_placeholder_escape( $count_sql ) : $count_sql;
$key = md5( $stable ) . ':' . wp_cache_get_last_changed( self::COUNT_VERSION_GROUP ) . ':' . wp_cache_get_last_changed( 'terms' );
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
if ( false === $total ) {
global $wpdb;
$total = (int) $wpdb->get_var( $count_sql );
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
}
if ( '' !== $excluded_sql ) {
$total = max( 0, (int) $total - (int) $wpdb->get_var( $excluded_sql ) );
}
return 'SELECT ' . (int) $total;
}
@@ -401,6 +458,17 @@ class TMDO_Post_Query_Router {
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* A post was saved or deleted, or a managed key written: cached routed
* counts are stale. Hooked on clean_post_cache whatever the mode, so a
* count cached before the router was last active is not reused.
*
* @return void
*/
public static function bump_count_version(): void {
wp_cache_set_last_changed( self::COUNT_VERSION_GROUP );
}
/**
* Whether the router is mode-active (post mode is shadow_read or aeav_only).
*
@@ -422,6 +490,57 @@ class TMDO_Post_Query_Router {
self::$copies_usable = array();
}
/**
* Every post of the type has a row in the group's flat table. One
* anti-join per group, reused for COVERAGE_TTL and per post-mode change.
* A post inserted by direct SQL (no wp_insert_post) is seen at the next
* check at the latest; the `tmdo_router_flat_first` filter turns the
* flat-first route off.
*
* @param string $post_type Post type.
* @param string $group Entity group name.
* @return bool
*/
private static function covers_all( string $post_type, string $group ): bool {
$key = self::coverage_key( $post_type, $group );
$hit = get_transient( $key );
if ( false === $hit ) {
global $wpdb;
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
$missing = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} p LEFT JOIN `{$table}` f ON f.post_id = p.ID WHERE p.post_type = %s AND f.post_id IS NULL LIMIT 1", $post_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$hit = null === $missing ? 'yes' : 'no';
set_transient( $key, $hit, self::COVERAGE_TTL );
}
return 'yes' === $hit;
}
/**
* Forget every cached coverage answer (tests, or after bulk writes that
* bypass wp_insert_post).
*
* @return void
*/
public static function forget_coverage(): void {
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
foreach ( TMDO_Entity_Registry::get_group_post_types( 'post', $group ) as $post_type ) {
delete_transient( self::coverage_key( $post_type, $group ) );
}
}
}
/**
* Transient name of a coverage answer; a post-mode change starts a new one.
*
* @param string $post_type Post type.
* @param string $group Entity group name.
* @return string
*/
private static function coverage_key( string $post_type, string $group ): string {
$entered = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
$since = is_array( $entered ) ? (int) ( $entered['post'] ?? 0 ) : 0;
return 'tmdo_cov_' . md5( "{$post_type}:{$group}:{$since}" );
}
/**
* The status / date copy route for a routed query, or [] when it does not apply.
*
@@ -523,9 +642,30 @@ class TMDO_Post_Query_Router {
return false;
}
/**
* The last `wp tmdo drift-check` found the group out of step with native
* meta. A group never checked keeps routing, and so does every group in
* aeav_only, where native is stale by design and no fallback.
*
* @param string $group Entity group name.
* @return bool
*/
private static function drifting( string $group ): bool {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) {
return false;
}
if ( TMDO_Schema_Manager::is_deferred( 'post', $group ) ) {
return true; // Table upgrade waiting for WP-CLI / WP-Cron.
}
$status = get_option( TMDO_Drift_Check::OPTION, array() );
$last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null;
return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0;
}
/**
* The routed form of a clause, or null when the flat column would not
* compare the way WordPress compares the meta_value string.
* compare the way WordPress compares the meta_value string, or when the
* group is known to drift from native meta.
*
* Native casts meta_value by the clause type (CHAR when absent); flat
* columns are typed. Routed: EXISTS / NOT EXISTS; string columns with
@@ -560,7 +700,7 @@ class TMDO_Post_Query_Router {
&& in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true )
&& array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v );
}
if ( ! $ok ) {
if ( ! $ok || self::drifting( (string) $field['group'] ) ) {
return null;
}
@@ -603,14 +743,25 @@ class TMDO_Post_Query_Router {
private static function flat_first( string $request, \WP_Query $query ): string {
$routed = $query->get( self::QUERY_VAR );
$sort = $query->get( self::SORT_VAR );
if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
if ( empty( $sort ) || ! is_array( $sort ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
return $request;
}
$post_type = (string) array_key_first( $routed );
$groups = array_unique( array_column( $routed[ $post_type ], 'group' ) );
$compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) );
if ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
return $request;
if ( empty( $routed ) || ! is_array( $routed ) ) {
// Sort only: the LEFT JOIN keeps posts without a flat row, so the
// flat table may drive only when every post of the type has one.
$post_type = (string) $sort['post_type'];
$group = (string) $sort['group'];
if ( ! self::covers_all( $post_type, $group ) ) {
return $request;
}
} else {
$post_type = (string) array_key_first( $routed );
$groups = array_unique( array_column( $routed[ $post_type ], 'group' ) );
$compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) );
if ( 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
return $request;
}
$group = (string) $groups[0];
}
foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) {
if ( ! empty( $query->get( $var ) ) ) {
@@ -622,8 +773,8 @@ class TMDO_Post_Query_Router {
}
global $wpdb;
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] );
$alias = self::table_alias( $post_type, $groups[0] );
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group );
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
if ( 1 !== substr_count( $request, $join ) ) {
@@ -639,6 +790,13 @@ class TMDO_Post_Query_Router {
if ( 1 !== $replaced ) {
return $request;
}
// WordPress groups by posts.ID for any tax or meta query. With the flat row
// (one per post) as the only join there is nothing to collapse, and the
// GROUP BY makes MySQL sort the whole match in a temporary table
// (WooCommerce's product_visibility NOT IN on searches is a subquery).
if ( 1 === preg_match_all( '/\bJOIN\b/i', (string) $flat_first ) ) {
$flat_first = preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', (string) $flat_first, 1 );
}
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
$flat_first = preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
@@ -310,7 +310,7 @@ class TMDO_Snapshot_Manager {
}
$table = $wpdb->prefix . self::TABLE_SLUG;
$deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB
TMDO_Logger::info( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
TMDO_Logger::notice( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
return false !== $deleted;
}
+23 -5
View File
@@ -25,6 +25,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
class TMDO_Zone_Cold {
/** Option: post type → cold cache version (TMDO_Cache_Layer::flush_group()). */
public const CACHE_VERSION_OPTION = 'wpdo_cold_cache_version';
/**
* Get the cold table name for a post type.
*
@@ -45,6 +48,21 @@ class TMDO_Zone_Cold {
return 'wpdo_cold_' . sanitize_key( $post_type );
}
/**
* Object-cache key of a post's cold blob. It carries the post type's cache
* version once TMDO_Cache_Layer::flush_group() has bumped it (none before,
* so existing entries keep their keys).
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return string
*/
public static function cache_key( int $post_id, string $post_type ): string {
$versions = get_option( self::CACHE_VERSION_OPTION, array() );
$version = is_array( $versions ) ? (int) ( $versions[ $post_type ] ?? 0 ) : 0;
return $version ? "cold_{$post_id}_v{$version}" : "cold_{$post_id}";
}
/**
* Ensure the cold table exists for a post type.
*
@@ -92,7 +110,7 @@ class TMDO_Zone_Cold {
*/
public static function get_blob( int $post_id, string $post_type ): array {
$group = self::cache_group( $post_type );
$cache_key = "cold_{$post_id}";
$cache_key = self::cache_key( $post_id, $post_type );
$cached = wp_cache_get( $cache_key, $group );
if ( false !== $cached ) {
@@ -202,7 +220,7 @@ class TMDO_Zone_Cold {
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
}
/**
@@ -215,7 +233,7 @@ class TMDO_Zone_Cold {
public static function delete( int $post_id, string $post_type ): void {
global $wpdb;
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
}
// ── Private helpers ───────────────────────────────────────────────────
@@ -264,7 +282,7 @@ class TMDO_Zone_Cold {
);
// Invalidate object cache.
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
}
/**
@@ -305,6 +323,6 @@ class TMDO_Zone_Cold {
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
}
}
+25 -1
View File
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.1
Stable tag: 1.1.1
Stable tag: 1.3.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -107,6 +107,30 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
== Changelog ==
= 1.3.0 =
* Declared flat-table indexes are added only from WP-CLI or WP-Cron, one process at a time (MySQL GET_LOCK); web requests schedule `tmdo_ensure_group_indexes`, and doctor lists indexes not added yet
* Routed searches with post__not_in count from a cached base count minus the excluded posts; the cache key ignores wpdb's per-request placeholder escape and follows post saves and managed-key writes only, not every post meta write (HivePress keyword + price search no longer slower than native)
* Rate-limited view requests sample their statistics (1 in 10) instead of writing an option each time
* Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`)
* The public listings REST output withholds email, phone, address and IP fields even when declared public
* Admin form posts are handled before the page renders; uninstall.php globals are prefixed
* Existing flat tables are upgraded from WP-CLI or WP-Cron only; a group waiting for its upgrade uses native meta, then a drift fix rebuilds it
* The index lock goes to the primary under read/write splitting; clearing the cold cache bumps a version instead of scanning the object cache group
* Groups whose flat table was dropped no longer cause database errors on post status changes
* Schema upgrades share one MySQL lock with index DDL; new or upgraded groups stay native until a drift fix has filled them; mode changes and snapshot deletions are always logged
= 1.2.0 =
* `wp tmdo drift-check <entity> [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused
* The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current
* Switching an entity from disabled to dual_write / shadow_read, or turning the Hook Bus back on, schedules a full drift fix 60 s later (`tmdo_drift_resync`)
* Auto-promotion requires a clean drift check within 2 days; shadow_read diffs are capped at 20 per key per hour
* `wp tmdo bridge-mode <entity> <mode>` (under the Promotion Guard); `member-backfill --all`; post / user backfill groups come from the registry, json groups included
* WooCommerce: the `post/wc_product` group is no longer registered — product meta stays native (retire existing tables with `bridge-restore-native post --group=wc_product --retire`)
* Sort-only searches drive from the flat table when every post of the type has a flat row (HivePress rating sort ~3.3 s → ~0.5–0.85 s on 100k listings)
* `wp tmdo cleanup-hp-transients --options` removes HivePress AddOn transients left in wp_options
* Fixed: entity backfill invented default values (0) for keys with no native row; doctor, term-comment-diagnose and bridge-status false alarms; member-cutover fatal on a refused transition
* Admin output escaping and logging clean-ups from the 2.1 audit
= 1.1.1 =
* Security: the public GET /wpdo/v1/listings endpoints returned every registered field (show_in_rest defaulted to true), including private values such as integration tokens and WooCommerce purchase notes. Fields are now private unless registered with show_in_rest => true, and protected (underscore) or credential-like keys are never exposed
* Security: the listings endpoint no longer returns whole rows when no field is public, and only filters / sorts on public columns
+7 -4
View File
@@ -506,10 +506,12 @@ if ( ! function_exists( 'register_rest_route' ) ) {
// WP_CLI stub (needed before loading CLI classes).
if ( ! class_exists( 'WP_CLI' ) ) {
class WP_CLI {
public static function log( string $msg ): void {}
public static function warning( string $msg ): void {}
public static function success( string $msg ): void {}
public static function error( string $msg ): void {}
// Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it.
public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function warning( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Warning: ' . $msg; }
public static function success( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Success: ' . $msg; }
public static function error( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Error: ' . $msg; }
public static function add_command( string $name, $class ): void {}
}
}
@@ -622,6 +624,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo cleanup-hp-transients --options` (2.2 PR-080): the HivePress AddOn
* moved HivePress's per-post transients out of wp_postmeta into wp_options
* (`_transient_wpdo_hp_pm_*`), where they piled up — 1.15 M rows on dev36. Since
* PR-081 they live in the object cache when there is one; this removes the
* rows left behind. Works without the AddOn loaded (as in core CI).
*/
class CleanupHpTransientsTest extends TestCase {
private const OPTIONS = 'wp_itest_options';
public static function setUpBeforeClass(): void {
require_once WPDO_PLUGIN_DIR . 'cli/class-tmdo-cli-post.php';
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' );
$wpdb->query( 'CREATE TABLE `' . self::OPTIONS . "` ( option_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, option_name VARCHAR(191) NOT NULL DEFAULT '', option_value LONGTEXT NOT NULL, autoload VARCHAR(20) NOT NULL DEFAULT 'yes', PRIMARY KEY (option_id), UNIQUE KEY option_name (option_name) ) ENGINE=InnoDB" );
for ( $i = 1; $i <= 5; $i++ ) {
$wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_wpdo_hp_pm_{$i}_abc", 'option_value' => 'v', 'autoload' => 'no' ) );
$wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_timeout_wpdo_hp_pm_{$i}_abc", 'option_value' => '1', 'autoload' => 'no' ) );
}
foreach ( array( 'siteurl', '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'wpdo_hp_pm_1_abc' ) as $name ) {
$wpdb->insert( self::OPTIONS, array( 'option_name' => $name, 'option_value' => 'keep' ) );
}
$GLOBALS['_wp_cli_output'] = array();
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' );
}
private function remaining(): array {
global $wpdb;
return $wpdb->get_col( 'SELECT option_name FROM `' . self::OPTIONS . '` ORDER BY option_name' );
}
public function test_dry_run_counts_the_rows_and_deletes_nothing(): void {
( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'dry-run' => true ) );
$this->assertCount( 14, $this->remaining() );
$this->assertStringContainsString( '10 HivePress transient row(s) in wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) );
}
public function test_confirm_deletes_only_the_hivepress_rows_in_batches(): void {
( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'confirm' => true, 'batch' => '3' ) );
$this->assertSame( array( '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'siteurl', 'wpdo_hp_pm_1_abc' ), $this->remaining() );
$this->assertStringContainsString( 'Deleted 10 HivePress transient row(s) from wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) );
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo bridge-status` progress (2.2 PR-076): eav_ids of a post group that
* belongs to some post types counted every post holding one of its keys, so a
* fully backfilled group showed less than 100%.
*
* flat_rows counted rows without any value too (wp_core holds an all-NULL row
* for every post): dev36 showed wp_core at 15306%.
*
* Fixture: group bs_grp for hp_listing only; bs_price on post 1 (hp_listing)
* and post 2 (a plain post); flat rows for post 1 (a value) and post 3
* (hp_listing, all NULL).
*/
class CliBridgeStatusTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_bs_grp';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-post.php',
'cli/class-tmdo-cli-v2.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
if ( ! function_exists( 'WP_CLI\Utils\format_items' ) ) {
eval( 'namespace WP_CLI\Utils; function format_items( $format, $items, $fields ) { $GLOBALS["_wp_cli_items"] = $items; }' ); // phpcs:ignore Squiz.PHP.Eval -- test-only WP-CLI stub.
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( 'CREATE TABLE `' . self::POSTS . "` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_type VARCHAR(20) NOT NULL DEFAULT 'post', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::POSTMETA . '` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::FLAT . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, bs_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->insert( self::POSTS, array( 'ID' => 1, 'post_type' => 'hp_listing' ) );
$wpdb->insert( self::POSTS, array( 'ID' => 2, 'post_type' => 'post' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => 'bs_price', 'meta_value' => '10' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => 2, 'meta_key' => 'bs_price', 'meta_value' => '20' ) );
$wpdb->insert( self::FLAT, array( 'post_id' => 1, 'bs_price' => 10 ) );
$wpdb->insert( self::POSTS, array( 'ID' => 3, 'post_type' => 'hp_listing' ) );
$wpdb->insert( self::FLAT, array( 'post_id' => 3 ) );
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'post' => 'dual_write' ) );
$GLOBALS['_wp_cli_items'] = array();
$GLOBALS['_wp_cli_output'] = array();
TMDO_Mode_Manager::reset_cache();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'bs_grp', array( array( 'key' => 'bs_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
TMDO_Mode_Manager::reset_cache();
}
private static function drop(): void {
global $wpdb;
foreach ( array( self::POSTS, self::POSTMETA, self::FLAT ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
public function test_counts_only_flat_rows_that_hold_a_value_and_posts_of_the_groups_types(): void {
( new TMDO_CLI_V2() )->bridge_status( array(), array() );
$row = array_values( array_filter( $GLOBALS['_wp_cli_items'], static fn( $r ) => 'bs_grp' === $r['group'] ) )[0];
$this->assertSame( 1, $row['flat_rows'] );
$this->assertSame( 1, $row['eav_ids'] );
$this->assertSame( '100%', $row['progress_%'] );
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo member-backfill` takes its groups from the registry (2.2 PR-068).
*
* Before: a hard-coded VALID_GROUPS list (with the never-registered `sso`)
* rejected groups registered by add-ons, and there was no way to backfill
* every user group at once — R-2.1 had to fall back to `wp eval`.
*
* Fixture (user entity): groups cb_one (cb_a) and cb_two (cb_b).
*/
class CliMemberBackfillTest extends TestCase {
private const ONE = 'wp_itest_wpdo_user_cb_one';
private const TWO = 'wp_itest_wpdo_user_cb_two';
private const STATUS = 'wp_itest_wpdo_migration_status';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'cli/class-tmdo-cli-member.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::ONE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::TWO . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_b VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_a', 'meta_value' => 'one' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_b', 'meta_value' => 'two' ) );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'cb_one', array( array( 'key' => 'cb_a', 'type' => 'text' ) ) );
WPDO_Entity_Registry::register_group( 'user', 'cb_two', array( array( 'key' => 'cb_b', 'type' => 'text' ) ) );
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::ONE, self::TWO, self::STATUS ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function flat( string $table, string $col ): ?string {
global $wpdb;
return $wpdb->get_var( "SELECT `{$col}` FROM `{$table}` WHERE user_id = 1" );
}
public function test_all_backfills_every_registered_user_group(): void {
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'all' => true ) );
$this->assertSame( 'one', $this->flat( self::ONE, 'cb_a' ) );
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
}
public function test_group_accepts_any_registered_user_group(): void {
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'group' => 'cb_two' ) );
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
$this->assertNull( $this->flat( self::ONE, 'cb_a' ), 'only the requested group' );
}
public function test_backfillable_groups_leave_out_restored_orphans(): void {
WPDO_Entity_Registry::restore_group( 'user', 'cb_orphan', array( array( 'key' => 'cb_orphan_key', 'type' => 'text' ) ) );
$this->assertSame( array( 'cb_one', 'cb_two' ), TMDO_Entity_Migration_Engine::backfillable_groups( 'user' ) );
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo doctor` false alarms (2.2 PR-076).
*
* The user flat tables were a hard-coded list including wpdo_user_sso, a group
* never registered: doctor reported it MISSING and failed on every site. The
* tables now come from the registry; doctor also reports Promotion Guard
* violations and the recorded drift status.
*/
class DoctorTest extends TestCase {
private const HAVE = 'wp_itest_wpdo_user_dt_have';
private const CORE = array( 'wp_itest_wpdo_migration_status', 'wp_itest_wpdo_user_points_ledger' );
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-promotion-guard.php',
'cli/class-tmdo-cli.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) {
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINE=InnoDB" );
}
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cli_output'] = array();
TMDO_Mode_Manager::reset_cache();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'dt_have', array( array( 'key' => 'dt_a', 'type' => 'text' ) ) );
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
TMDO_Mode_Manager::reset_cache();
}
private static function drop(): void {
global $wpdb;
foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function cli_output(): string {
return implode( "\n", $GLOBALS['_wp_cli_output'] ?? array() );
}
public function test_entity_tables_come_from_the_registry(): void {
$ok = TMDO_CLI::check_entity_tables();
$this->assertTrue( $ok, $this->cli_output() );
$this->assertStringContainsString( '[OK] ' . self::HAVE, $this->cli_output() );
$this->assertStringNotContainsString( 'wpdo_user_sso', $this->cli_output() );
}
public function test_a_registered_group_without_its_table_is_reported(): void {
WPDO_Entity_Registry::register_group( 'user', 'dt_missing', array( array( 'key' => 'dt_b', 'type' => 'text' ) ) );
$ok = TMDO_CLI::check_entity_tables();
$this->assertFalse( $ok );
$this->assertStringContainsString( '[MISSING] wp_itest_wpdo_user_dt_missing', $this->cli_output() );
}
public function test_reports_drifting_groups(): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array(
'user' => array(
'dt_have' => array(
'miss' => 1,
'diff' => 2,
'extra' => 0,
'sample' => 0,
'checked_at' => '2026-09-27 00:00:00',
),
),
);
TMDO_Mode_Manager::reset_cache();
TMDO_CLI::report_guard_and_drift();
$this->assertStringContainsString( 'dt_have miss=1 diff=2 extra=0', $this->cli_output() );
$this->assertStringContainsString( 'wp tmdo drift-check user --fix', $this->cli_output() );
}
public function test_ignores_the_record_of_a_group_no_longer_registered(): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array(
'user' => array(
'dt_have' => array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
'sample' => 0,
'checked_at' => '2026-09-27 00:00:00',
),
'dt_gone' => array(
'miss' => 3,
'diff' => 0,
'extra' => 0,
'sample' => 0,
'checked_at' => '2026-09-20 00:00:00',
),
),
);
TMDO_Mode_Manager::reset_cache();
TMDO_CLI::report_guard_and_drift();
$this->assertStringContainsString( '[OK] user: 1 group(s) clean (oldest check 2026-09-27 00:00:00 UTC)', $this->cli_output() );
$this->assertStringNotContainsString( 'dt_gone', $this->cli_output() );
}
public function test_reports_an_entity_never_checked(): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
TMDO_Mode_Manager::reset_cache();
TMDO_CLI::report_guard_and_drift();
$this->assertStringContainsString( 'user: never checked', $this->cli_output() );
$this->assertStringContainsString( 'Promotion Guard: no violation', $this->cli_output() );
}
}
+319
View File
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Drift_Check (2.2 PR-070): read-only flat-vs-native comparison.
*
* Fixture (user entity, group dc_grp):
* user 1 every key equal in value, differently formatted
* (50 vs 50.000000, serialized array vs JSON, required flag holding
* its default with no native row) → clean
* user 2 name 'b' vs 'B', cnt 5 vs 6 → diff ×2
* user 3 native name, flat row with name NULL → miss
* user 4 no native rows, flat cnt 9 → extra
* user 5 native name '', flat NULL → clean
* user 6 native name longer than a text column, flat NULL → clean
* user 7 native cnt, no flat row → miss
*/
class DriftCheckTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_dc_grp';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/diagnostic/class-tmdo-drift-check.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->users}` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_login VARCHAR(60) NOT NULL DEFAULT '', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, num DECIMAL(18,6) DEFAULT NULL, cnt BIGINT(20) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, prefs TEXT DEFAULT NULL, meta_json LONGTEXT DEFAULT NULL, flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
for ( $id = 1; $id <= 7; $id++ ) {
$wpdb->insert( $wpdb->users, array( 'ID' => $id, 'user_login' => "u{$id}" ) );
}
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
$this->native( 1, array( 'num' => '50', 'cnt' => '3', 'name' => 'a', 'prefs' => $prefs, 'meta_json' => serialize( array( 'w' => 640, 'tags' => array( 'x' ) ) ) ) );
$this->flat( array( 'user_id' => 1, 'num' => '50.000000', 'cnt' => 3, 'name' => 'a', 'prefs' => $prefs, 'meta_json' => '{"w":640,"tags":["x"]}' ) );
$this->native( 2, array( 'name' => 'b', 'cnt' => '5' ) );
$this->flat( array( 'user_id' => 2, 'name' => 'B', 'cnt' => 6 ) );
$this->native( 3, array( 'name' => 'c' ) );
$this->flat( array( 'user_id' => 3, 'cnt' => null ) );
$this->flat( array( 'user_id' => 4, 'cnt' => 9 ) );
$this->native( 5, array( 'name' => '' ) );
$this->flat( array( 'user_id' => 5 ) );
$this->native( 6, array( 'name' => str_repeat( 'x', 300 ) ) );
$this->flat( array( 'user_id' => 6 ) );
$this->native( 7, array( 'cnt' => '7' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'dc_grp',
array(
array( 'key' => 'num', 'type' => 'decimal' ),
array( 'key' => 'cnt', 'type' => 'integer' ),
array( 'key' => 'name', 'type' => 'text' ),
array( 'key' => 'prefs', 'type' => 'textarea' ),
array( 'key' => 'meta_json', 'type' => 'json' ),
array( 'key' => 'flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
)
);
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->users, $wpdb->usermeta, self::TABLE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function native( int $user_id, array $values ): void {
global $wpdb;
foreach ( $values as $key => $value ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
}
}
private function flat( array $row ): void {
global $wpdb;
$wpdb->insert( self::TABLE, $row );
}
private function snapshot(): string {
global $wpdb;
return md5( wp_json_encode( array( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ), $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
}
public function test_counts_miss_diff_and_extra_per_key(): void {
$report = TMDO_Drift_Check::run( 'user' );
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
$keys = $report['groups']['dc_grp']['keys'];
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 0 ), $keys['name'] );
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 1 ), $keys['cnt'] );
foreach ( array( 'num', 'prefs', 'meta_json', 'flag' ) as $key ) {
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $keys[ $key ], "{$key}: equal by value" );
}
}
public function test_records_the_outcome_per_group(): void {
TMDO_Drift_Check::run( 'user', 'dc_grp' );
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
$this->assertSame( 2, $status['miss'] );
$this->assertSame( 2, $status['diff'] );
$this->assertSame( 1, $status['extra'] );
$this->assertSame( 0, $status['sample'] );
$this->assertNotEmpty( $status['checked_at'] );
}
public function test_a_sample_covering_every_user_matches_the_full_check(): void {
$report = TMDO_Drift_Check::run( 'user', '', 100 );
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
$this->assertSame( 100, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['sample'] );
}
public function test_never_writes_native_or_flat(): void {
$before = $this->snapshot();
TMDO_Drift_Check::run( 'user' );
$this->assertSame( $before, $this->snapshot() );
}
// ── fix() (2.2 PR-071) ──────────────────────────────────────────────────
private function flat_row( int $user_id ): ?array {
global $wpdb;
return $wpdb->get_row( $wpdb->prepare( 'SELECT num, cnt, name, flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
}
public function test_fix_rebuilds_drifted_rows_from_native_and_leaves_no_drift(): void {
$result = TMDO_Drift_Check::fix( 'user' );
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $result['after']['total'] );
$this->assertSame( 'b', $this->flat_row( 2 )['name'] );
$this->assertSame( '5', $this->flat_row( 2 )['cnt'] );
$this->assertSame( 'c', $this->flat_row( 3 )['name'] );
$this->assertSame( array( 'num' => null, 'cnt' => '7', 'name' => null, 'flag' => '0' ), $this->flat_row( 7 ), 'new row: no invented defaults' );
$this->assertSame( array( 'num' => null, 'cnt' => null, 'name' => null, 'flag' => '0' ), $this->flat_row( 4 ), 'no native values: row kept, columns cleared' );
$this->assertSame( 0, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['diff'] );
}
public function test_fix_deletes_rows_of_entities_that_no_longer_exist(): void {
$this->flat( array( 'user_id' => 99, 'name' => 'ghost' ) );
$result = TMDO_Drift_Check::fix( 'user' );
$this->assertSame( 1, $result['fixed']['deleted_rows'] );
$this->assertNull( $this->flat_row( 99 ) );
$this->assertNotNull( $this->flat_row( 1 ), 'rows of existing users stay' );
}
public function test_fix_never_writes_native_and_invalidates_the_row_cache(): void {
global $wpdb;
$native = md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) );
TMDO_Cache_Orchestrator::set_row( 'user', 2, 'dc_grp', array( 'name' => 'B' ) );
TMDO_Drift_Check::fix( 'user' );
$this->assertSame( $native, md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ) );
$this->assertFalse( TMDO_Cache_Orchestrator::get_row( 'user', 2, 'dc_grp' ) );
}
// ── cron_tick() (2.2 PR-073) ────────────────────────────────────────────
private static function set_modes( ?array $modes ): void {
$cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, $modes );
}
public function test_the_daily_tick_samples_entities_that_write_to_flat(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::cron_tick();
} finally {
self::set_modes( null );
}
$status = get_option( TMDO_Drift_Check::OPTION );
$this->assertSame( TMDO_Drift_Check::CRON_SAMPLE, $status['user']['dc_grp']['sample'] );
$this->assertSame( 2, $status['user']['dc_grp']['diff'], 'the sample covers all 7 fixture users' );
$this->assertArrayNotHasKey( 'post', $status );
}
public function test_the_daily_tick_skips_entities_that_do_not_write_to_flat(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::cron_tick();
} finally {
self::set_modes( null );
}
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
}
public function test_the_daily_tick_skips_aeav_only_where_flat_is_the_source_of_truth(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::cron_tick();
} finally {
self::set_modes( null );
}
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
}
public function test_fix_refuses_aeav_only_and_leaves_flat_alone(): void {
global $wpdb;
$flat = md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) );
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::fix( 'user' );
$this->fail( 'fix() must refuse aeav_only' );
} catch ( RuntimeException $e ) {
$this->assertStringContainsString( 'aeav_only', $e->getMessage() );
} finally {
self::set_modes( null );
}
$this->assertSame( $flat, md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
}
// ── resync after writes bypassed flat (2.2 PR-074) ──────────────────────
public function test_turning_writes_on_schedules_a_fix_for_that_entity(): void {
$this->assertSame( array( 'user' ), TMDO_Drift_Check::resync_after_mode_change( 'user', 'dual_write', 'disabled' ) );
}
public function test_other_mode_changes_schedule_nothing(): void {
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'shadow_read', 'dual_write' ) );
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'aeav_only', 'shadow_read' ) );
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'disabled', 'dual_write' ) );
}
public function test_turning_the_hook_bus_on_schedules_a_fix_for_entities_that_keep_native(): void {
self::set_modes( array( 'post' => 'aeav_only', 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) );
try {
$on = TMDO_Drift_Check::resync_after_hook_bus_change( '0', '1' );
$off = TMDO_Drift_Check::resync_after_hook_bus_change( '1', '0' );
} finally {
self::set_modes( null );
}
$this->assertSame( array( 'user', 'term' ), $on );
$this->assertSame( array(), $off );
}
public function test_the_scheduled_fix_repairs_the_entity(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::run_scheduled_fix( 'user' );
} finally {
self::set_modes( null );
}
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
$this->assertSame( 0, $status['miss'] + $status['diff'] + $status['extra'] );
}
public function test_the_scheduled_fix_leaves_aeav_only_alone(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::run_scheduled_fix( 'user' );
} finally {
self::set_modes( null );
}
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
$this->assertSame( 'B', $this->flat_row( 2 )['name'], 'flat untouched' );
}
public function test_the_resync_triggers_are_hooked(): void {
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
foreach ( array(
"add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );",
"add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );",
"add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );",
"add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );",
) as $line ) {
$this->assertStringContainsString( $line, $source );
}
// Literal: uninstall.php does not load TMDO_Drift_Check.
$installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' );
$this->assertSame( 2, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'cleared on deactivate and uninstall' );
}
public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void {
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
$this->assertStringContainsString( "add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );", $source );
}
public function test_rejects_an_unknown_group(): void {
$this->expectException( InvalidArgumentException::class );
TMDO_Drift_Check::run( 'user', 'no_such_group' );
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Entity_Migration_Engine::migrate_group() must not invent values for keys
* the object has no native row for.
*
* Bug (dev36 R-2.1, 2026-09-26): the engine REPLACEs only the columns it found
* native rows for, so every other column fell back to its DDL default —
* user activity.session_count / account_flags (nullable, DEFAULT 0) became 0
* for every backfilled user, and the checker's "extra" count went 60 → 123.
*
* Fixture (user entity, group md_act):
* login_count integer, default 0, nullable
* session_count integer, default 0, nullable
* note text
* req_flag integer, required (NOT NULL DEFAULT 0)
*/
class EntityMigrationEngineDefaultsTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_md_act';
private const STATUS = 'wp_itest_wpdo_migration_status';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
'includes/adapters/class-tmdo-adapter-user.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, login_count BIGINT(20) DEFAULT 0, session_count BIGINT(20) DEFAULT 0, note VARCHAR(255) DEFAULT NULL, req_flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'md_act',
array(
array( 'key' => 'login_count', 'type' => 'integer', 'default' => 0 ),
array( 'key' => 'session_count', 'type' => 'integer', 'default' => 0 ),
array( 'key' => 'note', 'type' => 'text' ),
array( 'key' => 'req_flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
)
);
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::TABLE, self::STATUS ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function native( int $user_id, string $key, string $value ): void {
global $wpdb;
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
}
/** @return array<string, string|null> */
private function flat_row( int $user_id ): array {
global $wpdb;
return (array) $wpdb->get_row( $wpdb->prepare( 'SELECT login_count, session_count, note, req_flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
}
private function migrate(): array {
return TMDO_Entity_Migration_Engine::migrate_group( 'user', 'md_act', array( 'resume' => false, 'sleep_ms' => 0 ) );
}
public function test_keys_without_a_native_row_stay_null_instead_of_the_column_default(): void {
$this->native( 1, 'login_count', '7' );
$report = $this->migrate();
$this->assertSame( 1, $report['migrated'] );
$row = $this->flat_row( 1 );
$this->assertSame( '7', $row['login_count'] );
$this->assertNull( $row['session_count'], 'no native session_count — must not become the DEFAULT 0' );
$this->assertNull( $row['note'] );
}
public function test_a_native_value_equal_to_the_default_is_still_written(): void {
$this->native( 1, 'session_count', '0' );
$this->migrate();
$this->assertSame( '0', $this->flat_row( 1 )['session_count'] );
}
public function test_a_stale_flat_value_is_cleared_when_the_native_row_is_gone(): void {
global $wpdb;
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'login_count' => 3, 'session_count' => 5, 'note' => 'old' ) );
$this->native( 1, 'login_count', '4' );
$this->migrate();
$row = $this->flat_row( 1 );
$this->assertSame( '4', $row['login_count'] );
$this->assertNull( $row['session_count'] );
$this->assertNull( $row['note'] );
}
public function test_a_required_column_without_a_native_row_keeps_its_default(): void {
$this->native( 1, 'login_count', '1' );
$report = $this->migrate();
$this->assertSame( 0, $report['errors'] );
$this->assertSame( '0', $this->flat_row( 1 )['req_flag'], 'NOT NULL column cannot hold NULL — its default is the documented stand-in' );
}
public function test_the_cron_batch_path_does_not_invent_defaults_either(): void {
$this->native( 1, 'login_count', '2' );
$result = TMDO_Entity_Migration_Engine::migrate_group_batch( 'user', 'md_act' );
$this->assertSame( 1, $result['migrated'] );
$this->assertNull( $this->flat_row( 1 )['session_count'] );
}
}
@@ -31,6 +31,10 @@ class GroupCompositeIndexTest extends TestCase {
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
$GLOBALS['_wp_options'] = array();
// Index DDL runs from WP-CLI / WP-Cron only (2.3 PR-090a); these tests act as one.
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$GLOBALS['_wp_scheduled_single'] = array();
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
@@ -53,6 +57,120 @@ class GroupCompositeIndexTest extends TestCase {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
unset( $GLOBALS['_wp_is_admin'], $GLOBALS['_wp_scheduled_single'] );
}
public function test_a_web_request_adds_no_index_and_leaves_it_to_wp_cron(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
foreach ( array( false, true ) as $is_admin ) { // /wp-admin/ runs init before auth_redirect().
$GLOBALS['_wp_is_admin'] = $is_admin;
$this->assertFalse( TMDO_Schema_Manager::ddl_allowed() );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'no ALTER in a web request' );
}
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // The cron run.
TMDO_Schema_Manager::ensure_pending_indexes();
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
}
public function test_the_ddl_decision_can_be_overridden(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => false );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes() );
}
public function test_a_failed_alter_is_not_recorded_as_applied(): void {
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD COLUMN ci_note TEXT DEFAULT NULL' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group(
'post',
'ci_core',
array(
array( 'key' => 'ci_flag', 'type' => 'integer' ),
array( 'key' => 'ci_note', 'type' => 'textarea' ),
),
array( 'indexes' => array( 'flag_note' => array( 'ci_flag', 'ci_note' ) ) ) // TEXT without a prefix length: MySQL 1170.
);
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_note', $this->indexes() );
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ), 'not recorded, so it is retried' );
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
}
public function test_a_lock_held_by_another_process_skips_the_ddl_until_it_is_released(): void {
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$this->assertSame( '1', (string) $other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" )->fetch_row()[0] );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'another process holds the lock' );
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
global $wpdb;
$this->assertSame( '1', (string) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_FREE_LOCK(MD5(CONCAT(DATABASE(), %s)))', TMDO_Schema_Manager::index_lock_name() ) ), 'lock released' );
}
/**
* Under read/write splitting (HyperDB, LudicrousDB) a SELECT may go to a
* replica, where GET_LOCK would lock nothing: reads go to the primary first.
*/
public function test_the_lock_is_taken_on_the_primary_under_read_write_splitting(): void {
global $wpdb;
$real = $wpdb;
$wpdb = new class( $real ) {
/** @var string[] */
public array $calls = array();
public function __construct( private object $db ) {}
public function send_reads_to_masters(): void {
$this->calls[] = 'send_reads_to_masters';
}
public function __call( string $name, array $args ) {
if ( 'get_var' === $name && str_contains( (string) $args[0], 'GET_LOCK' ) ) {
$this->calls[] = 'GET_LOCK';
}
return $this->db->$name( ...$args );
}
public function __get( string $name ) {
return $this->db->$name;
}
};
try {
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$calls = $wpdb->calls;
} finally {
$wpdb = $real;
}
$this->assertSame( array( 'send_reads_to_masters', 'GET_LOCK' ), $calls );
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
}
/** @return array<string, string[]> index name → ordered columns */
@@ -227,6 +227,30 @@ class HookBusIntegrationTest extends TestCase {
);
}
/**
* A group whose table upgrade waits for WP-CLI / WP-Cron (2.3 PR-107)
* stays native: its table may lack the new columns.
*/
public function test_a_group_waiting_for_its_table_upgrade_stays_native(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/core_profile' => time() ) );
try {
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ) );
WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' );
$this->assertNull( $this->read_flat_first_name( 4 ) );
$this->assertSame( 'Dora', WPDO_Hook_Bus::direct_read( 'user', 4, 'first_name' ), 'direct reads return the native value' );
}
);
} finally {
delete_option( TMDO_Schema_Manager::DEFERRED_OPTION );
}
}
/**
* delete_all passes object ID 0: the delete_ action records the touched
* users from the meta IDs, the deleted_ action re-syncs each to NULL.
@@ -215,6 +215,38 @@ class PostBackfillJsonTest extends TestCase {
$this->assertSame( 0, $result['errors'] ?? -1 );
}
// ── backfill_group() / backfillable_groups() (2.2 PR-067) ───────────────
public function test_backfill_group_fills_json_columns_instead_of_skipping_them(): void {
$this->seed_attachment( 1, array( 'width' => 640 ), 'Alt text' );
$result = WPDO_Post_Migration::backfill_group( 'attachment' );
$this->assertSame( 'attachment', $result['group'] );
global $wpdb;
$row = $wpdb->get_row( 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A );
$this->assertSame( array( 'width' => 640 ), json_decode( (string) $row['_wp_attachment_metadata'], true ), 'json field must not be left NULL by the bulk pivot' );
$this->assertSame( 'Alt text', $row['_wp_attachment_image_alt'] );
}
public function test_backfillable_groups_are_the_registered_post_groups(): void {
WPDO_Entity_Registry::register_group( 'post', 'itest_extra', array( array( 'key' => 'itest_extra_key', 'type' => 'text' ) ) );
$groups = WPDO_Post_Migration::backfillable_groups();
$this->assertSame( WPDO_Entity_Registry::get_groups_for_type( 'post' ), $groups );
$this->assertContains( 'itest_extra', $groups, 'a group registered by an add-on must be offered for backfill' );
}
public function test_restored_orphan_groups_are_never_backfilled(): void {
WPDO_Entity_Registry::restore_group( 'post', 'itest_orphan', array( array( 'key' => 'itest_orphan_key', 'type' => 'text' ) ) );
$this->assertContains( 'itest_orphan', WPDO_Entity_Registry::get_groups_for_type( 'post' ) );
$this->assertNotContains( 'itest_orphan', WPDO_Post_Migration::backfillable_groups(), 'orphan data lives only in the flat table' );
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::backfill_group( 'itest_orphan' );
}
public function test_returns_error_for_unknown_group(): void {
$result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
@@ -129,6 +129,7 @@ class PostEntityLifecycleTest extends TestCase {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
}
public static function tearDownAfterClass(): void {
+1
View File
@@ -93,6 +93,7 @@ class PostMigrationTest extends TestCase {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
}
public static function tearDownAfterClass(): void {
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* The post query router leaves a query native while the last drift-check
* found a routed clause's group out of step with native meta (2.2 PR-072).
* All or nothing: one drifting group keeps the whole query native. A group
* never checked keeps routing (decision 2026-09-27).
*/
class PostQueryRouterDriftTest extends TestCase {
public static function setUpBeforeClass(): void {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
}
public static function tearDownAfterClass(): void {
self::set_modes( null );
WPDO_Entity_Registry::init();
}
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
self::set_modes(
array(
'post' => 'shadow_read',
'user' => 'disabled',
'term' => 'disabled',
'comment' => 'disabled',
)
);
}
private static function set_modes( ?array $modes ): void {
$cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, $modes );
}
private function drift( string $group, int $diff ): void {
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['post'][ $group ] = array(
'miss' => 0,
'diff' => $diff,
'extra' => 0,
'sample' => 0,
'checked_at' => '2026-09-27 00:00:00',
);
}
private function price_query( array $extra = array() ): WP_Query {
$query = new WP_Query();
foreach ( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
),
) + $extra as $k => $v ) {
$query->set( $k, $v );
}
return $query;
}
public function test_a_drifting_group_keeps_the_query_native(): void {
$this->drift( 'wc_product', 3 );
$query = $this->price_query();
$original = $query->get( 'meta_query' );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertSame( $original, $query->get( 'meta_query' ) );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
}
public function test_a_clean_group_is_routed(): void {
$this->drift( 'wc_product', 0 );
$query = $this->price_query();
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
}
public function test_a_group_never_checked_is_routed(): void {
$query = $this->price_query();
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
}
public function test_drift_in_another_group_does_not_matter(): void {
$this->drift( 'hp_listing_core', 5 );
$query = $this->price_query();
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
}
public function test_aeav_only_ignores_drift_because_native_is_stale_by_design(): void {
self::set_modes(
array(
'post' => 'aeav_only',
'user' => 'disabled',
'term' => 'disabled',
'comment' => 'disabled',
)
);
$this->drift( 'wc_product', 3 );
$query = $this->price_query();
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
}
public function test_sorting_by_a_drifting_key_stays_native(): void {
$this->drift( 'wc_product', 1 );
$query = new WP_Query();
$query->set( 'post_type', 'product' );
$query->set( 'meta_key', '_price' );
$query->set( 'orderby', 'meta_value_num' );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertSame( '_price', $query->get( 'meta_key' ) );
$this->assertSame( '', $query->get( 'wpdo_post_sort' ) );
}
}
@@ -90,6 +90,17 @@ class PostQueryRouterFoundRowsTest extends TestCase {
return array( $router, $query, $request );
}
public function test_a_group_waiting_for_its_table_upgrade_is_not_routed(): void {
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'shadow_read', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'post/fr_core' => time() ) );
list( , $query ) = $this->routed();
$this->assertEmpty( $query->get( 'wpdo_post_clauses' ), 'kept native' );
}
public function test_routed_request_drops_sql_calc_found_rows(): void {
list( $router, $query, $request ) = $this->routed();
@@ -117,13 +128,85 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' );
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' );
wp_cache_set_posts_last_changed();
wp_cache_set_posts_last_changed(); // Any post meta write, e.g. a theme's CSS descriptor on a page view.
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'still cached' );
TMDO_Post_Query_Router::bump_count_version(); // A post saved, or a managed key written.
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
}
/**
* HivePress excludes a different random set of posts on each search page;
* the total is the cached count without the exclusion minus a live count
* of the excluded posts that match (by primary key).
*/
public function test_excluded_posts_do_not_split_the_cached_total(): void {
global $wpdb;
$count = function ( string $not_in ) use ( $wpdb ): string {
list( $router, $query, $request ) = $this->routed();
$request = str_replace( ' GROUP BY', " AND {$wpdb->posts}.ID NOT IN ({$not_in}) GROUP BY", $request );
$router->posts_request( $request, $query );
return (string) $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) );
};
$this->assertSame( '1', $count( '3' ), 'prices 20, 30 match; 3 excluded' );
$this->assertSame( '0', $count( '2,3' ) );
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 3' ); // Not seen until posts change.
$this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' );
$this->assertSame( '1', $count( '2' ) );
TMDO_Post_Query_Router::bump_count_version();
$this->assertSame( '0', $count( '2' ), 'recounted' );
}
/**
* Keyword searches carry wpdb's placeholder escape for "%", which changes
* with every request: the cache key uses the SQL without it, or no search
* with a keyword ever hits the cached count.
*/
public function test_the_cached_count_survives_a_new_placeholder_escape(): void {
global $wpdb;
$real = $wpdb;
$wpdb = new class( $real ) {
public int $counts = 0;
public function __construct( private object $db ) {}
public function remove_placeholder_escape( string $sql ): string {
return (string) preg_replace( '/\{[0-9a-f]{8}\}/', '%', $sql );
}
public function __call( string $name, array $args ) {
if ( 'get_var' === $name && str_contains( (string) $args[0], 'COUNT(*)' ) ) {
++$this->counts;
}
return $this->db->$name( ...$args );
}
public function __get( string $name ) {
return $this->db->$name;
}
};
try {
foreach ( array( '{1a2b3c4d}', '{9f8e7d6c}' ) as $escape ) { // Two requests.
list( $router, $query, $request ) = $this->routed();
$request = str_replace( ' GROUP BY', " AND '{$escape}' = '{$escape}' GROUP BY", $request );
$router->posts_request( $request, $query );
$this->assertSame( '2', $real->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) );
}
$counts = $wpdb->counts;
} finally {
$wpdb = $real;
}
$this->assertSame( 1, $counts, 'counted once' );
}
public function test_flat_writes_and_deletes_move_posts_last_changed(): void {
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
$before = wp_cache_get_last_changed( 'posts' );
$version = wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP );
WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' );
$after_write = wp_cache_get_last_changed( 'posts' );
@@ -132,6 +215,7 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$this->assertNotSame( $before, $after_write );
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
$this->assertNotSame( $version, wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP ), 'routed counts follow flat writes' );
}
// ── flat-first: a filtered search ordered by a flat column ─────────────
@@ -20,6 +20,7 @@ class PostQueryRouterOrderTest extends TestCase {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
}
protected function setUp(): void {
@@ -39,6 +39,7 @@ class PostQueryRouterTest extends TestCase {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
}
public static function tearDownAfterClass(): void {
+17
View File
@@ -134,6 +134,23 @@ class PostStatusSyncTest extends TestCase {
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' );
}
public function test_a_group_whose_table_was_dropped_is_forgotten(): void {
global $wpdb;
TMDO_Post_Status_Sync::run();
$wpdb->query( 'DROP TABLE `' . self::B . '`' );
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
$wpdb->last_error = '';
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
$this->assertSame( '', (string) $wpdb->last_error, 'no UPDATE of a missing table' );
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_b' ), 'forgotten' );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] );
}
public function test_transition_leaves_tables_without_the_columns_alone(): void {
global $wpdb;
$wpdb->last_error = '';
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* An existing flat table whose definition changed is upgraded from WP-CLI /
* WP-Cron only (2.3 PR-107); web requests record the group as deferred.
*/
class SchemaUpgradeDeferralTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_sd_core';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/adapters/class-tmdo-adapter-user.php',
) as $file ) {
require_once $base . $file;
}
WPDO_Installer::install_v2_tables();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, sd_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
$wpdb->insert( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core', 'schema_hash' => 'old', 'field_definitions' => '[]' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_scheduled_single'] = array();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); // A web request.
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
self::set_user_mode( 'dual_write' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'sd_core', array( array( 'key' => 'sd_a', 'type' => 'text' ), array( 'key' => 'sd_b', 'type' => 'integer' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
WPDO_Entity_Registry::init();
self::set_user_mode( null );
unset( $GLOBALS['_wp_scheduled_single'] );
}
private static function set_user_mode( ?string $mode ): void {
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$ref->setAccessible( true );
$ref->setValue( null, null === $mode ? null : array( 'post' => 'disabled', 'user' => $mode, 'term' => 'disabled', 'comment' => 'disabled' ) );
}
private function upgrade(): bool {
return TMDO_Schema_Manager::create_or_upgrade_table( 'user', 'sd_core', WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
}
public function test_a_web_request_leaves_the_upgrade_to_wp_cron_and_the_group_native(): void {
global $wpdb;
$this->assertFalse( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
$this->assertSame( array( 'id', 'user_id', 'sd_a' ), $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::TABLE . '`' ), 'no ALTER' );
$this->assertSame( 'old', TMDO_Schema_Manager::get_stored_schema_hash( 'user', 'sd_core' ) );
}
public function test_only_existing_tables_outside_aeav_only_and_outside_cli_or_cron_wait(): void {
$this->assertTrue( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', 'wp_itest_wpdo_user_sd_missing' ), 'a new table is created at once' );
self::set_user_mode( 'aeav_only' );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ), 'aeav_only: the flat table is the only copy' );
self::set_user_mode( 'dual_write' );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // WP-CLI / WP-Cron.
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
/**
* An existing table is altered by one process at a time: anonymous
* /wp-cron.php requests pass ddl_allowed() too (security review H1).
*/
public function test_an_upgrade_is_skipped_while_another_process_holds_the_schema_lock(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" );
try {
$this->assertFalse( $this->upgrade(), 'returns before dbDelta (unavailable here)' );
} finally {
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
}
/**
* After the upgrade the group stays native until the drift fix has
* rebuilt the rows written meanwhile (security review M1).
*/
public function test_a_drift_fix_releases_only_upgraded_groups_of_its_entity(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
update_option(
TMDO_Schema_Manager::DEFERRED_OPTION,
array(
'user/sd_core' => 1,
'user/sd_later' => 1, // Not upgraded yet (no stored definition).
'post/sd_core' => 1, // Another entity.
)
);
TMDO_Schema_Manager::resynced( 'user' );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'stored hash still old: not upgraded' );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
TMDO_Schema_Manager::resynced( 'user' );
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) );
}
/**
* The definition went back to the stored one (or another process upgraded
* the table) while the group was deferred (correctness review F2).
*/
public function test_a_deferred_group_whose_definition_matches_again_is_resynced_then_released(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
$this->assertTrue( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'dual_write: native until the drift fix' );
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
self::set_user_mode( 'disabled' );
$this->upgrade();
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'no flat writes: nothing to rebuild' );
}
/**
* The cron event upgrades deferred groups itself: with ALTERNATE_WP_CRON
* it runs inside a visitor's request, after init (correctness review F3).
*/
public function test_the_cron_event_handles_deferred_groups(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
TMDO_Schema_Manager::ensure_pending_indexes();
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Shadow_Diff_Logger throttling (2.2 PR-075).
*
* The per-object window (one row per entity + key per 5 minutes) did not stop
* a key that is stale on every object — WooCommerce's total_sales, written by
* direct SQL — from logging a row for each product read. A key now records at
* most KEY_LIMIT rows per KEY_WINDOW across all objects.
*/
class ShadowDiffThrottleTest extends TestCase {
private const DIFFS = 'wp_itest_wpdo_shadow_diffs';
private const ERRORS = 'wp_itest_wpdo_errors';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-shadow-diff-logger.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::DIFFS . "` ( id BIGINT 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 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) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::ERRORS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, module VARCHAR(100) NOT NULL DEFAULT '', zone VARCHAR(20) NOT NULL DEFAULT '', hook VARCHAR(255) NOT NULL DEFAULT '', message TEXT NOT NULL, context LONGTEXT, created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id) ) ENGINE=InnoDB" );
for ( $id = 1; $id <= 30; $id++ ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'total_sales', 'meta_value' => '5' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'other_key', 'meta_value' => '5' ) );
}
$GLOBALS['_wp_transients'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::DIFFS, self::ERRORS ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function logged( string $key ): int {
global $wpdb;
return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM `' . self::DIFFS . '` WHERE meta_key = %s', $key ) );
}
private function stale_read( int $user_id, string $key ): void {
TMDO_Shadow_Diff_Logger::compare_and_log( 'user', $user_id, $key, 4, array( 'type' => 'integer' ) );
}
public function test_a_key_stale_on_every_object_logs_at_most_the_key_limit(): void {
for ( $id = 1; $id <= 30; $id++ ) {
$this->stale_read( $id, 'total_sales' );
}
$this->assertSame( TMDO_Shadow_Diff_Logger::KEY_LIMIT, $this->logged( 'total_sales' ) );
}
public function test_the_limit_is_per_key(): void {
for ( $id = 1; $id <= 30; $id++ ) {
$this->stale_read( $id, 'total_sales' );
}
$this->stale_read( 1, 'other_key' );
$this->assertSame( 1, $this->logged( 'other_key' ) );
}
public function test_the_same_object_and_key_still_log_once_per_window(): void {
$this->stale_read( 1, 'total_sales' );
$this->stale_read( 1, 'total_sales' );
$this->assertSame( 1, $this->logged( 'total_sales' ) );
}
}
+74 -7
View File
@@ -168,7 +168,12 @@ $wpdb = new class( $_mysqli ) {
fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'",
array_values( $data )
) );
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
try {
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
} catch ( mysqli_sql_exception $e ) { // Like wpdb::insert(): an SQL error returns false.
$this->last_error = $e->getMessage();
return false;
}
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; }
$this->last_error = (string) $this->db->error;
return false;
@@ -200,7 +205,12 @@ $wpdb = new class( $_mysqli ) {
public function flush(): void {}
public function query( string $sql ): int|bool {
$result = $this->db->query( $sql );
try {
$result = $this->db->query( $sql );
} catch ( mysqli_sql_exception $e ) { // Like wpdb::query(): an SQL error returns false.
$this->last_error = $e->getMessage();
return false;
}
if ( $result instanceof mysqli_result ) { $result->free(); return true; }
if ( false === $result ) { return false; }
return $this->db->affected_rows;
@@ -289,9 +299,22 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
if ( ! function_exists( 'get_metadata' ) ) {
// Like get_metadata(): the first stored value of a key (single), read from the real meta table.
function get_metadata( string $type, $object_id, string $key = '', bool $single = false ) {
global $wpdb;
$table = $wpdb->{$type . 'meta'};
$id = 'user' === $type ? 'umeta_id' : 'meta_id';
$value = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$type}_id` = %d AND meta_key = %s ORDER BY `{$id}` LIMIT 1", $object_id, $key ) );
if ( $single ) {
return null === $value ? '' : maybe_unserialize( $value );
}
return null === $value ? array() : array( maybe_unserialize( $value ) );
}
}
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
@@ -411,6 +434,7 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) {
$GLOBALS['_wp_cache'] = [];
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
if ( ! function_exists( 'wp_cache_set_last_changed' ) ) { function wp_cache_set_last_changed( $group ) { $GLOBALS['_wp_last_changed'][ $group ] = (string) hrtime( true ); return $GLOBALS['_wp_last_changed'][ $group ]; } }
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
@@ -517,10 +541,12 @@ if ( ! function_exists( 'wp_insert_comment' ) ) {
if ( ! class_exists( 'WP_CLI' ) ) {
class WP_CLI {
public static function log( string $msg ): void {}
public static function warning( string $msg ): void {}
public static function success( string $msg ): void {}
public static function error( string $msg ): void {}
// Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it.
public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function warning( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Warning: ' . $msg; }
public static function success( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Success: ' . $msg; }
public static function error( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Error: ' . $msg; }
public static function add_command( string $name, $class ): void {}
}
}
@@ -586,6 +612,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
@@ -646,3 +673,43 @@ if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
}
class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
}
/**
* The WooCommerce product group core registered before 1.2.0 (PR-077 moved
* product meta back to native only). Tests that use products as a routed
* post type register it themselves; the table name wp_itest_wpdo_post_wc_product
* is unchanged.
*/
function tmdo_test_register_wc_product_group(): void {
$enum = static fn( string $key, array $options ): array => array(
'key' => $key,
'type' => 'enum',
'options' => $options,
);
TMDO_Entity_Registry::register_group(
'post',
'wc_product',
array(
array( 'key' => '_price', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => '_regular_price', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => '_sale_price', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => '_stock', 'type' => 'integer', 'searchable' => true ),
array( 'key' => '_stock_status', 'type' => 'enum', 'searchable' => true, 'options' => array( 'instock', 'outofstock', 'onbackorder' ) ),
array( 'key' => '_sku', 'type' => 'text', 'searchable' => true ),
$enum( '_manage_stock', array( 'yes', 'no' ) ),
$enum( '_backorders', array( 'yes', 'no', 'notify' ) ),
$enum( '_sold_individually', array( 'yes', 'no' ) ),
$enum( '_virtual', array( 'yes', 'no' ) ),
$enum( '_downloadable', array( 'yes', 'no' ) ),
array( 'key' => '_tax_class', 'type' => 'text' ),
$enum( '_tax_status', array( 'taxable', 'shipping', 'none' ) ),
array( 'key' => '_download_limit', 'type' => 'integer' ),
array( 'key' => '_download_expiry', 'type' => 'integer' ),
array( 'key' => '_product_version', 'type' => 'text' ),
array( 'key' => '_wc_average_rating', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => '_wc_review_count', 'type' => 'integer' ),
array( 'key' => 'total_sales', 'type' => 'integer', 'searchable' => true ),
),
array( 'post_types' => array( 'product' ) )
);
}
+1
View File
@@ -41,6 +41,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY |
| `rest` | anonymous GET of the public listings endpoints returns no protected or credential-like field (1.1.1) |
| `drift` | `TMDO_Drift_Check`: json groups show no false diff; a planted flat mismatch / flat-only value counts exactly once (1.2.0) |
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
## Tools (`tools/`)
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* Suite: TMDO_Drift_Check on a real site (2.2 PR-070).
*
* - json groups (attachment / nav_menu_item) compare by value: a serialized
* native array and its JSON flat copy are not drift.
* - a flat value planted behind the checker's back shows up as exactly one
* diff / one extra, nothing else moves.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Run the drift check for one group and put the site's recorded status back.
*
* @param string $entity Entity type.
* @param string $group Group.
* @return array<string, mixed> Report.
*/
function tmdo_live_drift( string $entity, string $group ): array {
$status = get_option( TMDO_Drift_Check::OPTION, null );
try {
return TMDO_Drift_Check::run( $entity, $group );
} finally {
null === $status ? delete_option( TMDO_Drift_Check::OPTION ) : update_option( TMDO_Drift_Check::OPTION, $status );
}
}
TMDO_Live::test(
'drift-check reports no diff for json groups whose values match native',
static function () {
$checked = 0;
foreach ( array( 'attachment', 'nav_menu_item' ) as $group ) {
if ( ! in_array( $group, TMDO_Entity_Migration_Engine::backfillable_groups( 'post' ), true )
|| ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( 'post', $group ) ) ) {
continue;
}
$report = tmdo_live_drift( 'post', $group );
tmdo_live_assert_same( 0, $report['total']['diff'], "post/{$group} diff" );
++$checked;
}
if ( 0 === $checked ) {
throw new TMDO_Live_Skip( 'no attachment / nav_menu_item flat table on this site' );
}
}
);
TMDO_Live::test(
'drift-check counts a planted flat mismatch once and a flat-only value once',
static function () {
global $wpdb;
TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) );
$field = TMDO_Live_Fixtures::field( 'user', 'login_count' );
$group = $field['group'];
$table = TMDO_Schema_Manager::get_table_name( 'user', $group );
$before = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
$mismatch = TMDO_Live_Fixtures::user();
$flat_only = TMDO_Live_Fixtures::user();
add_user_meta( $mismatch, 'login_count', '5' );
try {
$wpdb->insert( $table, array( 'user_id' => $mismatch, 'login_count' => 6 ) );
$wpdb->insert( $table, array( 'user_id' => $flat_only, 'login_count' => 9 ) );
$after = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
} finally {
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE user_id IN (%d, %d)", $mismatch, $flat_only ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
tmdo_live_assert_same( $before['diff'] + 1, $after['diff'], 'login_count diff +1' );
tmdo_live_assert_same( $before['extra'] + 1, $after['extra'], 'login_count extra +1' );
tmdo_live_assert_same( $before['miss'], $after['miss'], 'login_count miss unchanged' );
}
);
+165 -7
View File
@@ -247,21 +247,22 @@ TMDO_Live::test(
TMDO_Live::test(
'Q06 LIKE matches substrings and escapes wildcards, routed',
static function () {
TMDO_Live_Fixtures::field( 'post', '_sku', 'product' );
// A text key of wp_core (WooCommerce's _sku left the flat tables in 1.2.0).
TMDO_Live_Fixtures::field( 'post', '_wp_page_template' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
$p = tmdo_live_router_posts(
'product',
'post',
array(
'P1' => array( '_sku' => 'tmdo-live-B1-1' ),
'P2' => array( '_sku' => 'tmdo-live-B1-10' ),
'P3' => array( '_sku' => 'tmdo-live-B2-1' ),
'P1' => array( '_wp_page_template' => 'tmdo-live-B1-1' ),
'P2' => array( '_wp_page_template' => 'tmdo-live-B1-10' ),
'P3' => array( '_wp_page_template' => 'tmdo-live-B2-1' ),
)
);
foreach ( array( 'B1-1', 'B1_1' ) as $needle ) {
tmdo_live_router_assert_native(
array(
'post_type' => 'product',
'meta_query' => array( array( 'key' => '_sku', 'value' => $needle, 'compare' => 'LIKE' ) ),
'post_type' => 'post',
'meta_query' => array( array( 'key' => '_wp_page_template', 'value' => $needle, 'compare' => 'LIKE' ) ),
),
array_values( $p ),
true
@@ -423,3 +424,160 @@ TMDO_Live::test(
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
}
);
/**
* HivePress-style sort-only search (rating__order), fields ids, first page.
* DECIMAL(10,1): what the HivePress Rating field (decimals 1) sends.
*
* @param string $order ASC | DESC.
* @return array<string, mixed> WP_Query args.
*/
function tmdo_live_router_rating_sort( string $order ): array {
return array(
'post_type' => 'hp_listing',
'post_status' => 'publish',
'posts_per_page' => 10,
'fields' => 'ids',
'suppress_filters' => false,
'meta_query' => array(
array(
'relation' => 'OR',
'rating__order' => array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'NOT EXISTS' ),
array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'EXISTS' ),
),
),
'orderby' => 'rating__order',
'order' => $order,
);
}
/**
* The native sort key of each post, in order (null without a rating). On the
* whole site many listings share a rating and native orders ties by chance,
* so a page is compared by its keys, not its ids.
*
* @param array<int|string> $ids Post ids.
* @return array<int, string|null>
*/
function tmdo_live_router_rating_keys( array $ids ): array {
global $wpdb;
$ids = array_map( 'intval', $ids );
if ( empty( $ids ) ) {
return array();
}
$rows = $wpdb->get_results(
"SELECT post_id, CAST(meta_value AS DECIMAL(10,1)) AS k FROM {$wpdb->postmeta} WHERE meta_key = 'hp_rating' AND post_id IN (" . implode( ',', $ids ) . ')',
OBJECT_K
);
return array_map( static fn( $id ) => isset( $rows[ $id ] ) ? (string) $rows[ $id ]->k : null, $ids );
}
/**
* Native and routed runs of the same query.
*
* @param array<string, mixed> $args Query args.
* @return array{0:WP_Query, 1:WP_Query}
*/
function tmdo_live_router_native_and_routed( array $args ): array {
$native = new WP_Query( $args );
$router = new TMDO_Post_Query_Router();
$router->register_hooks();
try {
$routed = new WP_Query( $args );
} finally {
tmdo_live_router_unhook( $router );
}
return array( $native, $routed );
}
TMDO_Live::test(
'a sort-only search drives from the flat table when every post of the type has a flat row (2.2 PR-079)',
static function () {
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
TMDO_Post_Query_Router::forget_coverage();
foreach ( array( 'DESC', 'ASC' ) as $order ) {
list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( $order ) );
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), "{$order} sort keys" );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, "{$order} found_posts" );
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), "{$order} flat-first: " . $routed->request );
}
}
);
TMDO_Live::test(
'a sort-only search keeps the LEFT JOIN while a post of the type has no flat row (2.2 PR-079)',
static function () {
global $wpdb;
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
$bare = TMDO_Live_Fixtures::post( 'hp_listing' );
$wpdb->delete( TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ), array( 'post_id' => $bare ) );
TMDO_Post_Query_Router::forget_coverage();
try {
list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( 'DESC' ) );
} finally {
TMDO_Post_Query_Router::forget_coverage();
}
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
tmdo_live_assert_true( false === strpos( $routed->request, 'STRAIGHT_JOIN' ), 'not flat-first: ' . $routed->request );
}
);
TMDO_Live::test(
'a post created while the router is active gets its empty flat row (2.2 PR-079)',
static function () {
global $wpdb;
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
$router = new TMDO_Post_Query_Router();
$router->register_hooks();
try {
$id = TMDO_Live_Fixtures::post( 'hp_listing' );
} finally {
tmdo_live_router_unhook( $router );
}
$table = TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' );
tmdo_live_assert_same( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d", $id ) ), 'flat row created' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
);
TMDO_Live::test(
'a flat-first search drops the GROUP BY WordPress adds for a subquery-only tax query (2.2 PR-079)',
static function () {
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
TMDO_Post_Query_Router::forget_coverage();
// Like WooCommerce's product_visibility exclusion on front-end searches: NOT IN
// is a subquery, yet WordPress groups by posts.ID for any tax query.
$args = tmdo_live_router_rating_sort( 'DESC' ) + array(
'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => array( 999999999 ), 'operator' => 'NOT IN' ) ),
);
list( $native, $routed ) = tmdo_live_router_native_and_routed( $args );
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
tmdo_live_assert_true( false === stripos( $routed->request, 'GROUP BY' ), 'no GROUP BY: ' . $routed->request );
}
);
TMDO_Live::test(
'a flat-first search keeps the GROUP BY when a tax query joins term relationships (2.2 PR-079)',
static function () {
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
TMDO_Post_Query_Router::forget_coverage();
$terms = get_terms( array( 'taxonomy' => 'hp_listing_category', 'number' => 2, 'hide_empty' => true, 'fields' => 'ids' ) );
if ( count( (array) $terms ) < 2 ) {
throw new TMDO_Live_Skip( 'needs two used hp_listing_category terms' );
}
$args = tmdo_live_router_rating_sort( 'DESC' ) + array(
'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => $terms, 'operator' => 'IN' ) ),
);
list( $native, $routed ) = tmdo_live_router_native_and_routed( $args );
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
tmdo_live_assert_true( false !== stripos( $routed->request, 'GROUP BY' ), 'GROUP BY kept: ' . $routed->request );
}
);
+5 -4
View File
@@ -7,7 +7,8 @@ $mode = TMDO_Mode_Manager::get( 'post' ) . '/' . TMDO_Mode_Manager::get( 'user'
function nat( $t, $id, $k ) { global $wpdb; $mt = $t === 'user' ? $wpdb->usermeta : $wpdb->postmeta; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM $mt WHERE $c=%d AND meta_key=%s", $id, $k ) ); return $v ? implode( '|', $v ) : '∅'; }
function flat( $t, $g, $id, $k ) { global $wpdb; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_row( $wpdb->prepare( "SELECT `$k` v FROM {$wpdb->prefix}wpdo_{$t}_{$g} WHERE $c=%d", $id ) ); return $v ? ( $v->v ?? 'NULL' ) : 'NO-ROW'; }
function row( $name, $vals ) { printf( "%-44s %s\n", $name, implode( ' ', array_map( fn( $k, $v ) => "$k=" . ( is_scalar( $v ) || is_null( $v ) ? var_export( $v, true ) : json_encode( $v ) ), array_keys( $vals ), $vals ) ) ); }
function clear_caches( $id ) { wp_cache_flush(); clean_post_cache( $id ); }
// Never wp_cache_flush(): with Redis Object Cache selective flush it is one server-side scan that blocks Redis for every site.
function clear_caches( $id ) { clean_post_cache( $id ); wp_cache_flush_runtime(); }
echo "== mode post/user = $mode\n";
// T1: HivePress model update of a listing price.
@@ -42,12 +43,12 @@ row( "T5 delete_post_meta hp_featured", array( 'native' => nat( 'post', $lid, 'h
// T6: user first_name via wp_update_user.
$uid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE 's36v_B1p1_%' ORDER BY ID LIMIT 1" );
$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); wp_cache_flush();
$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); clean_user_cache( $uid ); wp_cache_flush_runtime();
$u = get_userdata( $uid );
row( "T6 user $uid first_name:=$fn", array( 'native' => nat( 'user', $uid, 'first_name' ), 'flat' => flat( 'user', 'core_profile', $uid, 'first_name' ), 'get_meta' => get_user_meta( $uid, 'first_name', true ), 'WP_User' => $u->first_name, 'wc_customer' => ( new WC_Customer( $uid ) )->get_first_name() ) );
// T7: WC customer billing city via CRUD.
$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); wp_cache_flush();
$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); clean_user_cache( $uid ); wp_cache_flush_runtime();
row( "T7 WC_Customer billing_city:=$city", array( 'native' => nat( 'user', $uid, 'billing_city' ), 'flat' => flat( 'user', 'commerce', $uid, 'billing_city' ), 'get_meta' => get_user_meta( $uid, 'billing_city', true ), 'wc_customer' => ( new WC_Customer( $uid ) )->get_billing_city() ) );
// T8: delete a listing → flat row?
@@ -57,5 +58,5 @@ row( "T8 wp_delete_post $d", array( 'posts' => (int) $wpdb->get_var( "SELECT COU
// T9: HP review → listing rating recompute.
$before = get_post_meta( $lid, 'hp_rating_count', true );
$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); wp_cache_flush();
$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); clean_post_cache( $lid ); wp_cache_flush_runtime();
row( "T9 add review (count was $before)", array( 'native' => nat( 'post', $lid, 'hp_rating_count' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_rating_count' ), 'hp_model' => Models\Listing::query()->get_by_id( $lid )->get_rating_count() ) );
+6 -1
View File
@@ -40,7 +40,12 @@ foreach ( $cases as $name => $qa ) {
$err = '';
$sql = '';
for ( $i = 0; $i < $iters; $i++ ) {
wp_cache_flush_group( 'tmdo_found_rows' );
// Fresh routed counts without wp_cache_flush_group() (a Redis-blocking server-side scan).
if ( method_exists( 'TMDO_Post_Query_Router', 'bump_count_version' ) ) {
TMDO_Post_Query_Router::bump_count_version();
} else {
wp_cache_set_last_changed( 'posts' ); // Core < 1.3.0 keyed the counts on posts' last_changed.
}
$wpdb->last_error = '';
$t = microtime( true );
$q = new WP_Query( $qa + array( 'fields' => 'ids', 'cache_results' => false, 'suppress_filters' => false ) );
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Auto_Promoter promotes shadow_read → aeav_only only when the last drift
* check of every group of the entity found nothing, within the last two days
* (2.2 PR-075; decision 2026-09-27). No record means no promotion.
*/
class AutoPromoterDriftTest extends TestCase {
private const TEN_DAYS_AGO = 864000;
public static function setUpBeforeClass(): void {
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
}
protected function setUp(): void {
ini_set( 'error_log', '/dev/null' );
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'shadow_read' ) );
$GLOBALS['_wp_filter_callbacks'] = array();
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
TMDO_Mode_Manager::reset_cache();
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
TMDO_Entity_Registry::register_group( 'user', 'ap_one', array( array( 'key' => 'ap_a', 'type' => 'text' ) ) );
TMDO_Entity_Registry::register_group( 'user', 'ap_two', array( array( 'key' => 'ap_b', 'type' => 'text' ) ) );
}
protected function tearDown(): void {
TMDO_Entity_Registry::init();
TMDO_Mode_Manager::reset_cache();
}
private function drift( string $group, int $diff, int $age = 0 ): void {
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['user'][ $group ] = array(
'miss' => 0,
'diff' => $diff,
'extra' => 0,
'sample' => TMDO_Drift_Check::CRON_SAMPLE,
'checked_at' => gmdate( 'Y-m-d H:i:s', time() - $age ),
);
}
private function check(): string {
return TMDO_Auto_Promoter::check_entity( 'user', 7, array( 'user' => time() - self::TEN_DAYS_AGO ) );
}
public function test_promotes_when_every_group_checked_clean_recently(): void {
$this->drift( 'ap_one', 0 );
$this->drift( 'ap_two', 0 );
$this->assertSame( 'promoted', $this->check() );
$this->assertSame( 'aeav_only', TMDO_Mode_Manager::get( 'user' ) );
}
public function test_drift_in_one_group_blocks_promotion(): void {
$this->drift( 'ap_one', 0 );
$this->drift( 'ap_two', 1 );
$this->assertSame( 'drift_not_clean', $this->check() );
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
}
public function test_a_group_never_checked_blocks_promotion(): void {
$this->drift( 'ap_one', 0 );
$this->assertSame( 'drift_not_clean', $this->check() );
}
public function test_a_check_older_than_two_days_blocks_promotion(): void {
$this->drift( 'ap_one', 0 );
$this->drift( 'ap_two', 0, 3 * DAY_IN_SECONDS );
$this->assertSame( 'drift_not_clean', $this->check() );
}
}
+19
View File
@@ -197,6 +197,25 @@ class CacheLayerTest extends TestCase {
$this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null );
}
/**
* Flushing bumps the post type's cache version instead of asking the
* object cache to scan the group (a server-side script on Redis that
* blocks it on a large group); older entries expire by their TTL.
*/
public function test_flush_group_moves_to_new_keys_without_a_group_flush(): void {
$group = 'wpdo_cold_hp_listing';
$old_key = WPDO_Zone_Cold::cache_key( 1, 'hp_listing' );
$GLOBALS['_wp_cache'][ $group ][ $old_key ] = array( 'stale' => true );
WPDO_Cache_Layer::flush_group( 'hp_listing' );
$new_key = WPDO_Zone_Cold::cache_key( 1, 'hp_listing' );
$this->assertNotSame( $old_key, $new_key );
$this->assertFalse( wp_cache_get( $new_key, $group ) );
$this->assertSame( 'cold_2', WPDO_Zone_Cold::cache_key( 2, 'other_type' ), 'other post types keep their keys' );
$this->assertArrayHasKey( $old_key, $GLOBALS['_wp_cache'][ $group ], 'nothing scanned or deleted' );
}
// ── get_stats() ──────────────────────────────────────────────────────────
public function test_get_stats_includes_required_keys(): void {
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo bridge-mode <entity> <mode>` (2.2 PR-069): the CLI way to change an
* entity's bridge mode. It must go through TMDO_Mode_Manager::set(), so unsafe
* jumps and promotions refused by the Promotion Guard never reach the option.
* (The Guard matrix itself is covered by PromotionGuardTest.)
*/
class CliBridgeModeTest extends TestCase {
protected function setUp(): void {
ini_set( 'error_log', '/dev/null' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_filter_callbacks'] = array();
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
TMDO_Mode_Manager::reset_cache();
}
private function modes( array $modes ): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
TMDO_Mode_Manager::reset_cache();
}
public function test_sets_the_mode_of_one_entity(): void {
$this->modes( array( 'user' => 'disabled', 'post' => 'dual_write' ) );
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'dual_write' ), array() );
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'post' ), 'other entities untouched' );
}
public function test_an_unsafe_jump_is_refused(): void {
$this->modes( array( 'user' => 'disabled' ) );
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'aeav_only' ), array() );
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'user' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_a_promotion_refused_by_the_guard_leaves_the_mode_unchanged(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'user' => 'shadow_read' ) );
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'aeav_only' ), array() );
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
$this->assertSame( array( 'user' => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'] );
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo term-comment-diagnose` filter chain status (2.2 PR-076).
*
* It read each filter's option with a default of '1', while the garbage filter
* and misc bucket default to off: a site that never touched them was told they
* were enabled. The status now comes from each filter's own is_enabled().
*/
class CliFilterStatusTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cli_output'] = array();
}
private function filter_line( string $class, string $label ): string {
$method = new ReflectionMethod( TMDO_CLI_Term_Comment::class, 'print_filter_status' );
$method->setAccessible( true );
$method->invoke( null, $class, $label );
return (string) end( $GLOBALS['_wp_cli_output'] );
}
public function test_filters_off_by_default_show_disabled(): void {
$this->assertStringContainsString( 'disabled', $this->filter_line( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter' ) );
$this->assertStringContainsString( 'disabled', $this->filter_line( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket' ) );
}
public function test_an_enabled_filter_shows_enabled(): void {
$GLOBALS['_wp_options'][ TMDO_Term_Comment_Garbage_Filter::OPT_ENABLED ] = '1';
$this->assertStringContainsString( '✓ enabled', $this->filter_line( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter' ) );
}
public function test_a_filter_whose_add_on_is_not_active_says_so(): void {
$this->assertStringContainsString( 'not installed', $this->filter_line( 'TMDO_No_Such_Filter', 'WC term count filter' ) );
}
}
+37
View File
@@ -130,4 +130,41 @@ class LoggerTest extends TestCase {
$deleted = WPDO_Logger::purge( 30 );
$this->assertSame( 5, $deleted );
}
/** error_log() output of $fn. */
private function logged( callable $fn ): string {
$file = tempnam( sys_get_temp_dir(), 'tmdo-log' );
$old = ini_set( 'error_log', $file );
try {
$fn();
} finally {
ini_set( 'error_log', (string) $old );
}
$out = (string) file_get_contents( $file );
unlink( $file );
return $out;
}
public function test_info_stays_out_of_the_log_unless_debug_logging_is_on(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
$this->assertSame( '', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) );
add_filter( 'tmdo_log_info', static fn() => true );
try {
$this->assertStringContainsString( '[WPDO][INFO][cache_flushed]', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) );
} finally {
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
}
}
public function test_notices_are_always_logged_for_the_audit_trail(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
$this->assertStringContainsString( '[WPDO][NOTICE][bridge_mode_changed]', $this->logged( static fn() => WPDO_Logger::notice( 'bridge_mode_changed', array( 'to' => 'dual_write' ) ) ) );
}
public function test_warnings_are_always_logged(): void {
$this->assertStringContainsString( '[WPDO][WARN][odd_return]', $this->logged( static fn() => WPDO_Logger::warning( 'odd_return' ) ) );
}
}
@@ -33,13 +33,12 @@ class PostFieldsRegistrationTest extends TestCase {
$this->assertTrue( true );
}
public function test_all_four_groups_are_registered(): void {
public function test_the_core_groups_are_registered(): void {
WPDO_Post_Fields::register_entity_fields();
$expected = array(
'wp_core',
'attachment',
'wc_product',
'nav_menu_item',
);
@@ -92,38 +91,14 @@ class PostFieldsRegistrationTest extends TestCase {
$this->assertSame( 'json', $field['type'] );
}
// ── wc_product group ─────────────────────────────────────────────────────
// ── WooCommerce products stay in native meta (2.2 PR-077) ───────────────
public function test_wc_product_group_has_19_keys(): void {
public function test_core_registers_no_woocommerce_product_group(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'wc_product' );
$this->assertCount( 19, $keys, 'wc_product group should register exactly 19 keys.' );
}
public function test_wc_product_critical_keys_present(): void {
WPDO_Post_Fields::register_entity_fields();
$keys = $this->get_field_keys( 'wc_product' );
foreach ( array( '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', '_sku' ) as $key ) {
$this->assertContains( $key, $keys, "wc_product missing critical key: {$key}" );
}
}
public function test_wc_product_price_is_searchable_decimal(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'wc_product', '_price' );
$this->assertNotNull( $field );
$this->assertSame( 'decimal', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
}
public function test_wc_product_stock_status_is_enum_searchable(): void {
WPDO_Post_Fields::register_entity_fields();
$field = $this->find_field( 'wc_product', '_stock_status' );
$this->assertNotNull( $field );
$this->assertSame( 'enum', $field['type'] );
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
$this->assertContains( 'instock', $field['options'] );
$this->assertContains( 'outofstock', $field['options'] );
$this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'post', 'wc_product' ), 'WooCommerce reads and writes product meta directly (stock by SQL)' );
$this->assertNull( WPDO_Entity_Registry::get_field( 'post', '_price', 'product' ) );
$this->assertSame( array(), WPDO_Post_Fields::groups_for_post_type( 'product' ) );
}
// ── HivePress groups moved to the HivePress AddOn ───────────────────────
@@ -139,7 +114,6 @@ class PostFieldsRegistrationTest extends TestCase {
public function test_core_groups_map_to_their_post_types(): void {
$this->assertSame( 'attachment', WPDO_Post_Fields::post_type_for_group( 'attachment' ) );
$this->assertSame( 'product', WPDO_Post_Fields::post_type_for_group( 'wc_product' ) );
$this->assertSame( 'nav_menu_item', WPDO_Post_Fields::post_type_for_group( 'nav_menu_item' ) );
$this->assertNull( WPDO_Post_Fields::post_type_for_group( 'wp_core' ), 'wp_core spans every post type' );
}
@@ -149,7 +123,6 @@ class PostFieldsRegistrationTest extends TestCase {
$this->assertSame( 'x_type', WPDO_Post_Fields::post_type_for_group( 'x_core' ) );
$this->assertSame( array( 'x_core' ), WPDO_Post_Fields::groups_for_post_type( 'x_type' ) );
$this->assertSame( array( 'wc_product' ), WPDO_Post_Fields::groups_for_post_type( 'product' ) );
$this->assertNull( WPDO_Post_Fields::post_type_for_group( 'hp_listing_core' ), 'unregistered HivePress group is unknown to core' );
}
+30 -4
View File
@@ -164,6 +164,21 @@ class RestApiTest extends TestCase {
$this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) );
}
public function test_contact_and_address_keys_stay_private_even_when_declared_public(): void {
$private = array( 'hp_email', 'hp_contact_e_mail', 'hp_phone_number', 'hp_address', 'billing_address_1', 'hp_ip', 'hp_client_ip_addr' );
$public = array( 'hp_price', 'hp_description', 'hp_tip_amount', 'hp_shipping_days' );
$fields = array();
$values = array();
foreach ( array_merge( $private, $public ) as $key ) {
$fields[] = array( 'meta_key' => $key, 'zone' => 'cold', 'show_in_rest' => true );
$values[ $key ] = 'v';
}
$data = $this->listing_with( $fields, $values );
$this->assertSame( array_merge( array( 'id', 'post_type' ), $public ), array_keys( $data ) );
}
public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void {
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
@@ -377,7 +392,11 @@ class RestApiTest extends TestCase {
$this->assertSame( 'too_many_requests', $response->get_data()['code'] );
}
public function test_post_view_429_increments_rate_limit_stats(): void {
/**
* Blocked requests are counted 1 in 10 (each sample adds 10), so anonymous
* traffic cannot turn every blocked request into an option write.
*/
public function test_post_view_429_samples_rate_limit_stats(): void {
$GLOBALS['_wp_post_types'][40] = 'hp_listing';
$_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block.
$nonce = wp_create_nonce( 'wp_rest' );
@@ -385,10 +404,17 @@ class RestApiTest extends TestCase {
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' );
$req->set_param( 'id', 40 );
$req->set_header( 'X-WP-Nonce', $nonce );
$this->api->post_view( $req );
$writes = 0;
for ( $i = 0; $i < 200; $i++ ) {
$before = get_option( 'wpdo_rl_stats', [] );
$this->assertSame( 429, $this->api->post_view( $req )->get_status() );
$writes += get_option( 'wpdo_rl_stats', [] ) === $before ? 0 : 1;
}
$stats = get_option( 'wpdo_rl_stats', [] );
$this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) );
$count = (int) ( get_option( 'wpdo_rl_stats', [] )['40'] ?? 0 );
$this->assertSame( 10 * $writes, $count, 'each sample adds 10' );
$this->assertGreaterThan( 0, $writes );
$this->assertLessThan( 60, $writes, 'about 20 writes for 200 blocked requests, not 200' );
}
// ── get_status: rate_limit_stats ─────────────────────────────────────────
+15 -15
View File
@@ -30,7 +30,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
global $wpdb;
// ── Load shared cleanup logic + snapshot classes ─────────────────────────
$bootstrap_files = array(
$wpdo_bootstrap_files = array(
__DIR__ . '/includes/class-tmdo-logger.php',
__DIR__ . '/includes/class-tmdo-feature-flags.php',
__DIR__ . '/includes/class-tmdo-installer.php',
@@ -39,13 +39,13 @@ $bootstrap_files = array(
__DIR__ . '/includes/snapshots/class-tmdo-snapshot-reader.php',
__DIR__ . '/includes/snapshots/class-tmdo-snapshot-pruner.php',
);
$bootstrap_ok = true;
foreach ( $bootstrap_files as $f ) {
if ( ! is_file( $f ) ) {
$bootstrap_ok = false;
$wpdo_bootstrap_ok = true;
foreach ( $wpdo_bootstrap_files as $wpdo_bootstrap_file ) {
if ( ! is_file( $wpdo_bootstrap_file ) ) {
$wpdo_bootstrap_ok = false;
continue;
}
require_once $f;
require_once $wpdo_bootstrap_file;
}
// ── Sites to check and clean (0 = single site, no blog switching) ────────
@@ -77,7 +77,7 @@ $wpdo_for_each_site = static function ( callable $callback ) use ( $wpdo_site_id
};
// ── Refuse while any site holds data that exists only in wpdo_* tables ───
if ( ! $bootstrap_ok ) {
if ( ! $wpdo_bootstrap_ok ) {
wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) );
}
@@ -103,7 +103,7 @@ if ( $wpdo_blocked ) {
// wpdo_snapshots rows (which is per-site so per-site snapshots survive the
// site-level table DROP that happens later). Best-effort.
if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
$result = TMDO_Snapshot_Manager::create(
$wpdo_snapshot = TMDO_Snapshot_Manager::create(
'pre_uninstall',
array(),
array(
@@ -112,18 +112,18 @@ if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
'inline_threshold_bytes' => 0,
)
);
if ( ! empty( $result['ok'] ) ) {
$snapshot_id = $result['snapshot_id'];
$dir = TMDO_Snapshot_Manager::backup_dir();
if ( is_dir( $dir ) ) {
if ( ! empty( $wpdo_snapshot['ok'] ) ) {
$wpdo_snapshot_id = $wpdo_snapshot['snapshot_id'];
$wpdo_backup_dir = TMDO_Snapshot_Manager::backup_dir();
if ( is_dir( $wpdo_backup_dir ) ) {
file_put_contents(
trailingslashit( $dir ) . 'README-pre-uninstall.txt',
"WPDO pre-uninstall snapshot: {$snapshot_id}\n"
trailingslashit( $wpdo_backup_dir ) . 'README-pre-uninstall.txt',
"WPDO pre-uninstall snapshot: {$wpdo_snapshot_id}\n"
. 'Created: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n\n"
. "To restore after re-installing wp-data-optimizer:\n"
. " 1. Re-install the plugin (it will re-create wp_wpdo_snapshots).\n"
. " 2. Manually re-insert this snapshot's catalog row, or use wp wpdo snapshot list to confirm.\n"
. " 3. Run: wp wpdo snapshot restore {$snapshot_id} --apply\n"
. " 3. Run: wp wpdo snapshot restore {$wpdo_snapshot_id} --apply\n"
);
}
}