From b213a08e721bf426a2bd7741991df9f50e44ba2c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:54:48 -0400 Subject: [PATCH 1/6] fix: isolate native multisite post roots --- ...class-wp-markdown-native-query-runtime.php | 11 ++++++----- tests/probe-native-multisite-wordpress.php | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 64f2d1d..260931c 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -207,7 +207,7 @@ public static function register_core_table( } if ( 'posts' === $suffix ) { $posts = self::posts_schema(); - $registry->register( $prefix . 'posts', $posts, new WP_Markdown_Native_Post_Provider( $provider_content_root, $posts, self::shared_storage( $provider_content_root ), $provider_state_root ) ); + $registry->register( $prefix . 'posts', $posts, new WP_Markdown_Native_Post_Provider( $provider_content_root, $posts, self::shared_storage( $provider_content_root, $multisite && $prefix === $base_prefix ), $provider_state_root ) ); return true; } $bespoke = array( @@ -269,7 +269,7 @@ public static function runtime( new WP_Markdown_Native_Post_Mutation_Runtime( $registry, $parser, - self::shared_storage( $content_root ?? $state_root ), + self::shared_storage( $content_root ?? $state_root, $multisite && $prefix === $resolved_base ), $transactions ), advisory_locks: $advisory_locks ?? new WP_Markdown_Native_Advisory_Locks( $state_root ) @@ -367,10 +367,11 @@ private static function holds_canonical_site( string $state_root, string $conten * a read that remembers what it parsed must be told when a write changes * a file underneath it. */ - private static function shared_storage( string $content_root ): WP_Markdown_Storage { - $key = rtrim( $content_root, '/\\' ); + private static function shared_storage( string $content_root, bool $network_root = false ): WP_Markdown_Storage { + $key = ( $network_root ? 'network:' : 'site:' ) . rtrim( $content_root, '/\\' ); if ( ! isset( self::$storages[ $key ] ) ) { - self::$storages[ $key ] = new WP_Markdown_Storage( $content_root ); + // The network root owns sites/{blog_id}; it is not a post-type tree. + self::$storages[ $key ] = new WP_Markdown_Storage( $content_root, $network_root ? array( 'sites' ) : array() ); } return self::$storages[ $key ]; } diff --git a/tests/probe-native-multisite-wordpress.php b/tests/probe-native-multisite-wordpress.php index 830993f..e3e30ee 100644 --- a/tests/probe-native-multisite-wordpress.php +++ b/tests/probe-native-multisite-wordpress.php @@ -86,8 +86,27 @@ function mdi_native_multisite_probe_tables(): array { mdi_native_multisite_probe_assert( get_post( $post_id ) instanceof WP_Post, 'site_post', $checks ); mdi_native_multisite_probe_assert( in_array( $site_prefix . 'options', $site_tables, true ) && in_array( $site_prefix . 'posts', $site_tables, true ), 'site_show_tables', $checks ); mdi_native_multisite_probe_assert( false === $wpdb->query( "SELECT option_value FROM {$wpdb->base_prefix}options WHERE option_name = 'mdi_network_option'" ), 'cross_prefix_site_table_rejected', $checks ); + $foreign_id = wp_insert_attachment( + array( + 'import_id' => 987654321, + 'post_title' => 'MDI foreign attachment', + 'post_status' => 'inherit', + 'post_mime_type' => 'image/png', + ), + 'foreign-logo.png', + 0, + true + ); + if ( is_wp_error( $foreign_id ) ) { + throw new RuntimeException( $foreign_id->get_error_message() ); + } + $foreign_id = (int) $foreign_id; + mdi_native_multisite_probe_assert( get_post( $foreign_id ) instanceof WP_Post, 'foreign_attachment_exists_on_site', $checks ); restore_current_blog(); + $foreign_post = get_post( $foreign_id ); + mdi_native_multisite_probe_assert( null === $foreign_post, 'foreign_attachment_is_not_visible_after_restore', $checks ); + $base_tables = mdi_native_multisite_probe_tables(); $network_transaction = array( 'begin' => $wpdb->query( 'START TRANSACTION' ), From 04c85bf78c1d1c41043cb18e9fd0a62d421b2bac Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 09:04:48 -0400 Subject: [PATCH 2/6] fix: ignore foreign multisite post catalogues --- inc/native/class-wp-markdown-native-post-catalogue.php | 6 ++++-- inc/native/class-wp-markdown-native-query-runtime.php | 3 ++- inc/native/class-wp-markdown-native-table-providers.php | 5 +++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/inc/native/class-wp-markdown-native-post-catalogue.php b/inc/native/class-wp-markdown-native-post-catalogue.php index f3b6190..93610a9 100644 --- a/inc/native/class-wp-markdown-native-post-catalogue.php +++ b/inc/native/class-wp-markdown-native-post-catalogue.php @@ -32,7 +32,9 @@ final class WP_Markdown_Native_Post_Catalogue { public function __construct( private readonly string $content_root, - private readonly string $state_root + private readonly string $state_root, + /** @var string[] */ + private readonly array $excluded_roots = array() ) {} /** @@ -165,7 +167,7 @@ private function hydrate( mixed $saved ): ?array { return null; } $relative = str_replace( '\\', '/', $saved['path'] ); - if ( '' === $relative || str_starts_with( $relative, '/' ) || in_array( '..', explode( '/', $relative ), true ) ) { + if ( '' === $relative || str_starts_with( $relative, '/' ) || in_array( '..', explode( '/', $relative ), true ) || in_array( strtok( $relative, '/' ), $this->excluded_roots, true ) ) { return null; } $path = rtrim( $this->content_root, '/\\' ) . DIRECTORY_SEPARATOR . str_replace( '/', DIRECTORY_SEPARATOR, $relative ); diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 260931c..a8ce0ac 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -207,7 +207,8 @@ public static function register_core_table( } if ( 'posts' === $suffix ) { $posts = self::posts_schema(); - $registry->register( $prefix . 'posts', $posts, new WP_Markdown_Native_Post_Provider( $provider_content_root, $posts, self::shared_storage( $provider_content_root, $multisite && $prefix === $base_prefix ), $provider_state_root ) ); + $network_root = $multisite && $prefix === $base_prefix; + $registry->register( $prefix . 'posts', $posts, new WP_Markdown_Native_Post_Provider( $provider_content_root, $posts, self::shared_storage( $provider_content_root, $network_root ), $provider_state_root, $network_root ) ); return true; } $bespoke = array( diff --git a/inc/native/class-wp-markdown-native-table-providers.php b/inc/native/class-wp-markdown-native-table-providers.php index 9b27749..ce5c7e6 100644 --- a/inc/native/class-wp-markdown-native-table-providers.php +++ b/inc/native/class-wp-markdown-native-table-providers.php @@ -240,13 +240,14 @@ public function __construct( string $content_root, WP_Markdown_Native_Table_Schema $schema, ?WP_Markdown_Storage $storage = null, - ?string $state_root = null + ?string $state_root = null, + bool $network_root = false ) { parent::__construct( $content_root, $schema ); $this->storage = $storage ?? new WP_Markdown_Storage( $content_root ); // Writing a canonical file makes anything remembered about the corpus // stale, so the parse is dropped the moment one changes. - $this->catalogue = new WP_Markdown_Native_Post_Catalogue( $content_root, $state_root ?? $content_root ); + $this->catalogue = new WP_Markdown_Native_Post_Catalogue( $content_root, $state_root ?? $content_root, $network_root ? array( 'sites' ) : array() ); $this->storage->add_file_mutation_observer( function (): void { $this->catalogue->forget(); } ); From 4e710d922f4eedbcb379299ec74c1af91883d8ea Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 15:11:04 +0000 Subject: [PATCH 3/6] fix(native): detect network installation topology --- ...class-wp-markdown-native-query-runtime.php | 2 +- .../smoke-native-install-network-topology.php | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/smoke-native-install-network-topology.php diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 64f2d1d..66fb24c 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -656,7 +656,7 @@ public function __construct( string $state_root, private string $base_prefix, st private string $content_root; public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { - $multisite = ( defined( 'MULTISITE' ) && MULTISITE ) || ( function_exists( 'is_multisite' ) && is_multisite() ); + $multisite = ( defined( 'WP_INSTALLING_NETWORK' ) && WP_INSTALLING_NETWORK ) || ( defined( 'MULTISITE' ) && MULTISITE ) || ( function_exists( 'is_multisite' ) && is_multisite() ); if ( ! $multisite ) { return $this->prefix_runtime->execute( $request ); } diff --git a/tests/smoke-native-install-network-topology.php b/tests/smoke-native-install-network-topology.php new file mode 100644 index 0000000..c668134 --- /dev/null +++ b/tests/smoke-native-install-network-topology.php @@ -0,0 +1,41 @@ + 'wptests_' ); +$runtime = new WP_Markdown_Native_WordPress_Query_Runtime( $root, 'wptests_', $root ); +$created = $runtime->execute( + new WP_Markdown_Query_Request( + 'CREATE TABLE wptests_blogs ( blog_id bigint(20) NOT NULL auto_increment, site_id bigint(20) NOT NULL default 0, domain varchar(200) NOT NULL default \'\', path varchar(100) NOT NULL default \'\', registered datetime NOT NULL default \'1970-01-01 00:00:00\', last_updated datetime NOT NULL default \'1970-01-01 00:00:00\', public tinyint(2) NOT NULL default 1, archived tinyint(2) NOT NULL default 0, mature tinyint(2) NOT NULL default 0, spam tinyint(2) NOT NULL default 0, deleted tinyint(2) NOT NULL default 0, lang_id int(11) NOT NULL default 0, PRIMARY KEY (blog_id), KEY domain (domain(50),path(5)), KEY lang_id (lang_id) )', + 'wptests_' + ) +); +$inserted = $runtime->execute( + new WP_Markdown_Query_Request( + "INSERT INTO wptests_blogs (blog_id, site_id, domain, path, registered, last_updated, public, archived, mature, spam, deleted, lang_id) VALUES (1, 1, 'example.com', '/', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 1, 0, 0, 0, 0, 0)", + 'wptests_' + ) +); +$blog = $runtime->execute( new WP_Markdown_Query_Request( 'SELECT blog_id FROM wptests_blogs WHERE blog_id = 1', 'wptests_' ) ); +$checks = array( + 'network installation creates the global blogs schema before multisite bootstrap' => true === $created->return_value(), + 'network installation persists the base blog row' => 1 === $inserted->return_value() && '1' === ( $blog->wpdb_state()['last_result'][0]->blog_id ?? null ), +); +$failed = false; +foreach ( $checks as $label => $passed ) { + fwrite( $passed ? STDOUT : STDERR, ( $passed ? 'PASS' : 'FAIL' ) . ": {$label}\n" ); + $failed = $failed || ! $passed; +} + +foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ) as $entry ) { + $entry->isDir() ? rmdir( $entry->getPathname() ) : unlink( $entry->getPathname() ); +} +rmdir( $root ); +exit( $failed ? 1 : 0 ); From 9364e25b67a817bb6f0628ea3d73a148637799ea Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:22:42 -0400 Subject: [PATCH 4/6] fix: retain global tables during site initialization --- inc/native/class-wp-markdown-native-query-runtime.php | 6 ++++++ tests/smoke-native-generated-core-query.php | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index a8ce0ac..a41a324 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -163,6 +163,12 @@ public static function registry( foreach ( array_keys( WP_Markdown_Native_Schema_Catalog::definitions( $multisite ) ) as $suffix ) { self::register_core_table( $registry, $state_root, $content_root, $prefix, $base_prefix, $multisite, (string) $suffix, $global_state_root, $global_content_root ); } + } elseif ( $multisite && self::holds_canonical_site( $global_state_root, $global_content_root ) ) { + // A new site's local root is empty while wp_initialize_site() still + // needs established network tables such as sitemeta. + foreach ( array( 'blogs', 'blogmeta', 'registration_log', 'site', 'sitemeta', 'signups' ) as $suffix ) { + self::register_core_table( $registry, $state_root, $content_root, $prefix, $base_prefix, true, $suffix, $global_state_root, $global_content_root ); + } } self::register_persisted_plugin_tables( $registry, $state_root, $prefix, $multisite ); return $registry; diff --git a/tests/smoke-native-generated-core-query.php b/tests/smoke-native-generated-core-query.php index 247a460..d965343 100644 --- a/tests/smoke-native-generated-core-query.php +++ b/tests/smoke-native-generated-core-query.php @@ -60,6 +60,9 @@ } $multisite = WP_Markdown_Native_Runtime_Factory::runtime( $root, 'wp_2_', 'wp_', true ); +$new_site_root = $root . '/sites/2'; +mkdir( $new_site_root, 0755, true ); +$new_site_runtime = WP_Markdown_Native_Runtime_Factory::runtime( $new_site_root, 'wp_2_', 'wp_', true, $new_site_root, $root, $root ); $network_tables = array( 'blogs' => 'blog_id', 'blogmeta' => 'meta_id', @@ -75,6 +78,7 @@ } $site_termmeta = $multisite->execute( new WP_Markdown_Query_Request( 'SELECT meta_id FROM wp_2_termmeta LIMIT 0', 'wp_2_' ) ); $wrong_network_prefix = $multisite->execute( new WP_Markdown_Query_Request( 'SELECT blog_id FROM wp_2_blogs LIMIT 0', 'wp_2_' ) ); +$new_site_network_option = $new_site_runtime->execute( new WP_Markdown_Query_Request( "SELECT meta_value FROM wp_sitemeta WHERE meta_key = 'ms_files_rewriting' AND site_id = 1", 'wp_2_' ) ); $checks = array( 'generated commentmeta schema executes the retained conjunctive blocker' => 'retained' === ( $commentmeta->wpdb_state()['last_result'][0]->meta_value ?? null ), @@ -90,6 +94,7 @@ 'eligible multisite globals use base_prefix while site tables use active prefix' => $network_registered && 0 === $site_termmeta->return_value() && false === $wrong_network_prefix->return_value(), + 'an empty new-site root retains established network-global core tables' => 0 === $new_site_network_option->return_value(), ); $failed = false; @@ -102,6 +107,8 @@ @unlink( $root . '/_tables/terms.json' ); @unlink( $root . '/_tables/term_relationships.json' ); @rmdir( $root . '/_tables' ); +@rmdir( $new_site_root ); +@rmdir( $root . '/sites' ); @rmdir( $root . '/_options' ); @rmdir( $root ); exit( $failed ? 1 : 0 ); From 2d831ec10fd5f07846f9da8d7aa4f0a61fdc5e53 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:52:58 -0400 Subject: [PATCH 5/6] fix: keep network post lookups site-local --- inc/native/class-wp-markdown-native-table-providers.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/native/class-wp-markdown-native-table-providers.php b/inc/native/class-wp-markdown-native-table-providers.php index 4d5e6da..cb9b927 100644 --- a/inc/native/class-wp-markdown-native-table-providers.php +++ b/inc/native/class-wp-markdown-native-table-providers.php @@ -247,7 +247,7 @@ public function __construct( WP_Markdown_Native_Table_Schema $schema, ?WP_Markdown_Storage $storage = null, ?string $state_root = null, - bool $network_root = false + private bool $network_root = false ) { parent::__construct( $content_root, $schema ); $this->storage = $storage ?? new WP_Markdown_Storage( $content_root ); @@ -352,7 +352,7 @@ private function located_candidates( WP_Markdown_Native_Table_Access $access ): foreach ( $predicate->values() as $value ) { $id = (int) $value; $file = $this->catalogue->file_for( $id ); - if ( null === $file ) { + if ( null === $file && ! $this->network_root ) { $file = $this->storage->indexed_post_file( $id ); } if ( null === $file ) { From aba971006dd87011e9e699bf45e54cf3c8542e68 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 12:54:22 -0400 Subject: [PATCH 6/6] fix: retain excluded multisite catalogue roots --- inc/native/class-wp-markdown-native-post-catalogue.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/native/class-wp-markdown-native-post-catalogue.php b/inc/native/class-wp-markdown-native-post-catalogue.php index d308bac..96a09dd 100644 --- a/inc/native/class-wp-markdown-native-post-catalogue.php +++ b/inc/native/class-wp-markdown-native-post-catalogue.php @@ -46,7 +46,7 @@ public function __construct( public function remember( WP_Markdown_File_Witness $witness, array $file, object $post, array $row, bool $path_is_canonical = false ): void { $this->load(); $path = (string) ( $file['absolute'] ?? '' ); - if ( ! $path_is_canonical && null === $this->relative_path( $path ) ) { + if ( ( ! $path_is_canonical || array() !== $this->excluded_roots ) && null === $this->relative_path( $path ) ) { return; } $this->entries[ $path ] = array( 'witness' => $witness, 'file' => $file, 'post' => $post, 'row' => $row );