- migrate_entity (drift-check --fix), migrate_group and migrate_group_batch
write flat rows outside the Hook Bus: bump the routed count version
(once per call / batch; not on dry runs; post rows only)
- dev36: a vendor search kept counting 21515 of 21522 matches after a fix
- Verify: composer test (704), test:integration (598), phpcs, stan
- PR: PR-129
- Copy route: drop WordPress's GROUP BY when the flat row is the only join,
and read posts.ID tie-breaks as the flat post_id (own direction, once)
- Post status sync: index (post_status, post_date, post_id) replaces the
two-column idx_post_status_date (added / dropped by post-status-sync)
- dev36, 235k of 1.3M listings in a price range: main query 1.7 s -> 3 ms
- Verify: composer test (704), test:integration (597), phpcs, stan
- PR: PR-128
- migrate_entity() (drift-check --fix, per-row backfill) REPLACEs the whole
flat row and left out the post_status / post_date copies, so a rebuilt row
had NULL status. One such row turns the status/date copy route off for the
whole group: on dev36 a single row rebuilt by the stage-3 drift fix made the
date-ordered price search 1.47 s instead of driving from the flat index
- post rows now carry TMDO_Post_Status_Sync::insert_columns(); the bulk pivot
backfill (ON DUPLICATE KEY UPDATE) was not affected
- Verify: PostStatusSyncTest (existing and new rows keep their copies),
integration 593, unit 704, phpcs, stan; dev36 post-status-sync + front-end
- PR: PR-127 (found in 2.4 profiling)
- HivePress runs it on every listing search page from its own
pre_get_posts: get_posts() with the search's meta query (still unrouted,
including its sort group), meta_key hp_featured and orderby rand. The
router declined both shapes, so the lookup stayed native: 2.26–2.38 s of a
2.5 s page at 1.3M listings on dev36
- a sort group (key NOT EXISTS OR key EXISTS) that no orderby uses matches
every post and is dropped; meta_key without meta_value and without a
meta_value sort routes as EXISTS (WP_Query turns it into that clause)
- Verify: PostQueryRouterTest (featured lookup routed, meta_key with a value
untouched), integration 592, unit 704, phpcs, stan; dev36 front-end
- PR: PR-122 (follow-up)
- WP_Meta_Query adds a value condition only when 'value' is set, so a clause
like array( 'key' => 'hp_featured' ) only tests that the key exists. The
router read it as "= ''", declined it, and kept the whole query native
- HivePress runs such a query on every listing search page (featured-listing
lookup in Attribute::set_search_query(): hp_featured plus the search's own
filters, ORDER BY RAND()); at 1.3M listings on dev36 it took 2.26 s of a
2.5 s page, routed or not
- Verify: PostQueryRouterTest (key-only clause routes as IS NOT NULL),
integration 590, unit 704, phpcs, stan; dev36 front-end
- PR: PR-122
- a group index may end with the entity ID column (post_id, ...)
- flat-first rewrites the posts.ID tie-breaks once, in the sort's direction:
with a (column, post_id) index MySQL returns rows in order instead of
sorting the whole match. dev36 (1.3M listings), price range sorted by price:
850 ms filesort -> 2 ms. Ties are ordered by ID the same way as the column
(natively ID DESC); pagination stays stable, result sets are unchanged
- Verify: EntityRegistryIndexesTest, PostQueryRouterFoundRowsTest (ASC
tie-break, single tie-break), unit 704, integration 589, phpcs, stan;
dev36 with the HivePress indexes (PR-119)
- PR: PR-118
- 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)
- 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)
- 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)
- 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)
- 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)
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- show_in_rest defaulted to true, so GET /wpdo/v1/listings and /listings/{id} (public by design) returned every registered hot / cold field to anonymous visitors — e.g. infocards tmeetic_ig_access_token_enc / _token_expiry / _user_id and WooCommerce _purchase_note (confirmed on dev36)
- Fields are private unless registered with show_in_rest => true; protected (underscore) and credential-like keys (token, secret, password, api key, private key, credential, salt, nonce) are never exposed
- Zone A list path no longer returns whole rows when nothing is public, and filters / orderby accept public columns only
- Version 1.1.1; CHANGELOG, readme
- Verify: unit 690 (+3), integration 525 (+3 private-column tests, RED on 1.1.0), live suite rest 2/2 (RED on 1.1.0), all live 49/49, phpcs 0, stan OK
- A filtered search ordered by a flat column is rewritten to SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts, with posts.ID tie-breaks (ours and plugin-appended) read as the equal flat post_id, so MariaDB sorts one table instead of looking every post up and sorting in a temporary table
- Only when the routed conditions already require a flat row on the sort group, without the copy route or a selective post filter; filter tmdo_router_flat_first turns it off; split_the_query compares with the same rewrite
- Verify: tests/live/run.php router (13/13); integration 522; dev36 page SQL Q07 263→147 ms, Q15 250→142 ms; fe.sh hourly_rate__desc 850→367 ms, ids == native
- PR: PR-062c
- Deleting a post / user / term / comment drops its flat row in the adapter delete hook, then WordPress deletes each meta row; sync_from_native upserted NULL for each and brought the row back (512 orphan rows on dev36). With no native value it now only clears the column on an existing row
- Verify: tests/live/run.php resync (25/25, entity deletes in dual_write and shadow_read leave no flat row); mut.php T8 flat_row NO-ROW; integration 516
- PR: PR-057b
- posts_request only drops SQL_CALC_FOUND_ROWS, but WordPress then treated the request as filtered and selected full post rows through a temporary table; split_the_query now keeps the ID-first split for routed searches when nothing else changed the request, and posts_request_ids drops SQL_CALC_FOUND_ROWS from the split ID query
- Verify: tests/live/run.php router (12/12, IDs-first request, found_posts == native); dev36 fe.sh price 589→340 ms, price__asc 961→427 ms, rating__desc 1724→872 ms, hourly_rate__desc 855→884 ms (was 1206)
- PR: PR-062b
- Sort shape (relation OR of NOT EXISTS + EXISTS on one key, named in orderby): drop it from meta_query, LEFT JOIN the flat table and order by its column, ties by ID
- Named routed clause as the sole orderby, and meta_key + meta_value_num (meta_value on string columns) with IS NOT NULL; stay native when the column would not order like CAST(meta_value AS type), orderby has several keys, or meta_value filters
- Verify: tests/live/run.php router (11/11); fe.sh price__asc / hourly_rate__desc / rating__desc ids == native
- PR: PR-062
- posts_join joins the flat table of every routed group (a wp_core key next to a post type group no longer fails with "Unknown column")
- Flat tables are one row per post (UNIQUE post_id): remove the posts_groupby hook; skip the INNER JOIN status/date copy route for NOT EXISTS, which must match posts without a flat row
- Verify: tests/live/run.php router (9/9); qbench Q05 475 == native, no SQL error; price filter EXPLAIN has no Using temporary
- PR: PR-061
- Leave the query to WordPress on OR, nested groups with registered keys, several post types, orderby on a named routed clause, and compare/type pairs whose flat-column semantics differ from native (untyped numeric ranges, CHAR on decimals, NUMERIC on text)
- Wrap LIKE values as %esc_like%; hook pre_get_posts at priority 1000 so plugin clauses (HivePress) are seen
- Verify: wp eval-file tests/live/run.php router (7/7); qbench Q04/Q06/Q08/Q14/Q16 == native on dev36
- PR: PR-060
- TMDO_Native_Restorer::run() gains a policy: overwrite (flat wins; the
default in aeav_only, where writes only reached flat and native is
stale) or only_missing (native wins, flat only fills gaps; the default in
every other mode, where native is the source of truth and may be newer —
on dev36 an aeav_only round trip overwrote WooCommerce stock 42 with 0).
CLI: --overwrite / --only-missing.
- Native values are read first and each owned key is classified as
missing / differs / same / default_skipped; unchanged values are no
longer rewritten. A flat value equal to its field default is not
written for an entity without a native row (dev36: 1,368 invented
session_count / account_flags rows). --dry-run reports the counts.
- Verify: NativeRestorer* 17 OK (5 new: policy by mode, forced
overwrite, only-missing in aeav_only, dry-run counts, no invented
defaults); unit 687 OK, integration 490 OK, phpcs (phpcs.xml) 0,
stan OK; dev36 `bridge-restore-native user --dry-run`: policy
only_missing, activity 120 defaults skipped
- PR: PR-058
- dual_write / shadow_read no longer write flat in the update_/add_/
delete_{type}_metadata pre-filters, before WP decides whether native
changes at all. added_/updated_/deleted_{type}_meta (priority 1) call
TMDO_Hook_Bus::sync_from_native(), which copies the entity's first native
row (plain SQL, NULL when gone) into flat. Flat now follows native when WP
skips (same value), refuses (non-matching prev_value, add $unique on an
existing key) or deletes (also delete_all: touched IDs are collected from
the meta IDs in delete_{type}_meta), and a flat value left stale by a
direct SQL write heals on the next write.
- update_metadata_by_mid key changes: the old key is remembered in
update_{type}_metadata_by_mid and re-synced too.
- wpdo_after_write / wpdo_after_delete fire from the sync with the same
arguments; aeav_only keeps the pre-filter short-circuit unchanged.
- $internal_ops lock key includes the entity type (user 5 ≠ post 5).
- Live suite resync: 4 entities × 2 modes × 3 scenarios (old code: 16 of
24 FAIL); integration: pre-filter leaves flat to the sync, delete_all
re-syncs every touched user.
- Verify: live resync + shadow_write + serialized + zero_side_effect 33/33
PASS; unit 687 OK, integration 485 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-057
- TMDO_Zone_Router::delete_post() deleted from wp_wpdo_hot_/cold_{post_type}
whenever fields were registered for that zone, so every post delete on a
HivePress site (cold fields registered, module idle, table never created)
logged a "table doesn't exist" error. Hot and cold deletes now also need
the table to exist; module state is not checked, so a table left by a
rolled-back module still loses the rows of deleted posts.
- Live suite zero_side_effect: saving / updating / deleting an hp_listing
issues no query on missing zone tables (covers PR-054 and this PR).
- Verify: ZoneRouterTest 22 OK; live zero_side_effect + shadow_write +
serialized 9/9 PASS (old code: zero_side_effect FAIL); unit 687 OK,
integration 484 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-054b
- In shadow_read, TMDO_Hook_Bus::intercept_get() returned the flat value.
update_metadata() reads the old value through that filter after the Hook
Bus has upserted the new one, so WP saw old === new and never wrote native
meta: every update in shadow_read was lost on demotion (dev36 2026-09-26).
shadow_read now returns $check (native) and compares flat on a sample of
reads (filter tmdo_shadow_compare_sample_rate, default 0.1). aeav_only is
unchanged; the post query router still routes in shadow_read.
- Live suite shadow_write: xfail markers removed, HivePress listing case
added (new int key hp_rating_count); fixtures gain post() and a
post_type-scoped field()/flat().
- Verify: live shadow_write + serialized 8/8 PASS (old code: 4 FAIL);
unit 685 OK, integration 484 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-056
- TMDO_Type_Caster: text / textarea values that are arrays or objects are
stored with maybe_serialize() (was (string) → the literal "Array", which
bridge-restore-native then wrote back over native meta); from_db()
unserializes them with TMDO_Safe_Unserialize. text columns are
VARCHAR(255): longer values are stored NULL (native keeps them) and
logged as text_overflow.
- Add `wp tmdo repair-array-literals [--entity] [--confirm]`
(TMDO_Array_Literal_Repair): rewrites flat "Array" values from native
meta and lists native rows that are "Array" themselves (backup needed).
- Verify: TypeCasterSerializeTest 13 OK, ArrayLiteralRepairTest 3 OK;
unit 685 OK, integration 483 OK, phpcs (phpcs.xml) 0, stan OK;
live `serialized` 3 PASS; dev36 repair dry-run lists the value corrupted
by the 2026-09-26 round trip (user #1 community-events-location)
- PR: PR-055
- TMDO_Cache_Layer::prefetch() / warm_post() (and so on_save_post /
on_loop_start) queried wp_wpdo_cold_{post_type} whenever cold fields
were registered, even with the cold module idle and the table never
created: every HivePress / WC save logged "table doesn't exist" errors.
They now return unless the module is write-active and the table exists.
- CacheLayerTest activates the module in setUp; new cases assert zero cold
queries while idle or when the table is missing.
- Verify: CacheLayerTest 12 OK; unit 671 OK, integration 480 OK,
phpcs (phpcs.xml) 0, stan OK; dev36 live: seeding listings/products/
vendors with cold_hp_listing idle → 0 wpdo_cold errors (was hundreds)
- PR: PR-054
- Add TMDO_Promotion_Guard: HivePress models read every meta key with an
empty-key get_*_meta() and WooCommerce does the same for products (and
writes stock/sales with direct SQL), so on those sites native meta must
stay authoritative. Promotion to aeav_only is refused in
TMDO_Mode_Manager::set() (covers REST, CLI, phases, auto-promoter) for
post/user/term/comment with HivePress and post/user with WooCommerce;
TMDO_Post_Migration::cleanup() and the cleanup phase refuse too.
Demotion stays open; filter tmdo_promotion_guard_allow overrides.
- Admin notice lists guarded entities already in aeav_only with the
restore-native command (no forced demotion on read).
- Verify: PromotionGuardTest 8 OK; unit 668 OK, integration 480 OK,
phpcs (phpcs.xml) 0, stan OK; dev36 live: 4 promotions + post-cleanup
refused, wpdo_bridge_modes unchanged
- PR: PR-053
- CI PHPStan (level 6, baseline for pre-existing debt) failed on 35 errors in
code added on hardening/2.0: missing iterable value types (PHPDoc only), a
redundant method_exists() in uninstall.php, and the new WP-CLI commands
registered as array('Class', 'instanceMethod') — now array(new Class(), ...)
- Local runs had been masked by a PHPStan tmpDir shared with the act CI runner
(cached container pointing at a removed phar); use a private tmpDir
- Verify: phpstan (private tmpDir) no errors, phpcs clean on changed files,
phpunit Unit 658 OK; dev22 (running this branch) CLI post-status-sync /
bridge-restore-native dry runs OK
- retire() read native meta with wpdb::get_col(), which (via get_var())
returns null for an empty string, so a flat '' written back as '' was
counted as a mismatch and blocked the retirement. It now reads raw rows
with get_results().
- The integration test wpdb stub's get_var() / get_col() now read '' as
null like the real wpdb, so tests see WordPress behaviour.
- Found writing back the sso group on dev21 / dev22 (picture_url = '').
- Verify: core unit 658 / integration 480 green, phpcs clean
- PR: PR-042 follow-up
- missing_integration_addons() no longer asks for the SSO AddOn when
2meet-hub-core / 2meet-spoke-sso are active: they register their own
tables and the `sso` values belong in usermeta (benchmark: no
measurable gain from the flat table for by-user-id access).
- `sso` joins the deprecated AddOns; for hub / spoke / sso the notice says
they go without replacement and to run
`wp tmdo bridge-restore-native user --group=sso --retire` first.
- Verify: core unit 658 / integration 479 green, phpcs clean
- PR: PR-043