hash( 'sha256', wp_json_encode( $v ) ), self::contracts() ); } /** * List the fixed capability suffixes managed by the migration engine. * * @return array Supported capability catalog used to detect newly introduced grants. */ private static function capabilities(): array { return array( 'view_events', 'export_events', 'manage_response', 'manage_settings', 'manage_alerts', 'purge_data', 'purge_network_data', 'view_sensitive_context' ); } /** * Bind the migration engine marker, component contracts and capability catalog. * * @return string SHA-256 target digest for this installed migration definition. */ public static function target(): string { return hash( 'sha256', 'migration-engine-v1:' . wp_json_encode( array( self::hashes(), self::capabilities() ) ) ); } /** * Discard request-local migration readiness snapshots. * * @return void */ public static function reset_cache(): void { self::$cache = array(); } /** * Identify the current network and site for request-local readiness caching. * * @return string Current network and site cache key. */ private static function key(): string { return Scope::network() . ':' . get_current_blog_id(); } /** * Read and validate the authoritative base-site migration checkpoint. * * @return array|\WP_Error Supported saved or initial pending state, or an explicit storage/schema error. */ private static function state(): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ $old = $wpdb->suppress_errors( true ); try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative base-site migration checkpoints; object-cache state is not persistence proof. $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s LIMIT 1', $wpdb->base_prefix . 'options', self::OPTION ) ); /** * Validate saved migration bytes before absence, size and JSON validation from the untyped wpdb adapter. * * @var mixed $raw */ $error = $wpdb->last_error; } finally { $wpdb->suppress_errors( $old ); } if ( '' !== $error ) { return new \WP_Error( 'migration_storage', 'Schema upgrade state is unavailable.' ); } if ( null === $raw ) { return array( 'target' => self::target(), 'state' => 'pending', 'phase' => null, 'components' => array(), 'changed_at' => 0, 'last_error' => null, 'introduced_caps' => null, ); } if ( ! is_string( $raw ) || strlen( $raw ) > 16384 ) { return new \WP_Error( 'migration_state', 'Schema upgrade state is invalid.' ); } $value = json_decode( $raw, true, 8 ); if ( ! is_array( $value ) || ! preg_match( '/\A[a-f0-9]{64}\z/D', $value['target'] ?? '' ) || ! in_array( $value['state'] ?? '', array( 'pending', 'running', 'failed', 'ready' ), true ) || ! is_array( $value['components'] ?? null ) || count( $value['components'] ) > 16 || ! is_int( $value['changed_at'] ?? null ) || ! array_key_exists( 'introduced_caps', $value ) ) { return new \WP_Error( 'migration_state', 'Schema upgrade state is invalid.' ); } foreach ( $value['components'] as $name => $hash ) { if ( ! is_string( $name ) || ! preg_match( '/\A[a-z_]{1,32}\z/D', $name ) || ! is_string( $hash ) || ! preg_match( '/\A[a-f0-9]{64}\z/D', $hash ) ) { return new \WP_Error( 'migration_state', 'Schema upgrade state is invalid.' ); } } if ( null !== $value['introduced_caps'] && ( ! is_array( $value['introduced_caps'] ) || array_diff( $value['introduced_caps'], self::capabilities() ) ) ) { return new \WP_Error( 'migration_state', 'Schema capability state is invalid.' ); } if ( isset( $value['cap_catalog'] ) && ( ! is_array( $value['cap_catalog'] ) || count( $value['cap_catalog'] ) > 32 || array_filter( $value['cap_catalog'], static fn( $cap ) => ! is_string( $cap ) || ! preg_match( '/\A[a-z_]{1,64}\z/D', $cap ) ) ) ) { return new \WP_Error( 'migration_state', 'Schema capability catalog is invalid.' ); } if ( ! in_array( $value['phase'] ?? null, array_merge( array( null, 'site' ), array_keys( self::hashes() ) ), true ) || ! in_array( $value['last_error'] ?? null, array( null, 'schema_phase_failed', 'site_initialization_failed', 'schedule_failed' ), true ) ) { return new \WP_Error( 'migration_state', 'Schema phase state is invalid.' ); } return $value; } /** * Write a bounded base-site migration checkpoint with its current change timestamp. * * @param array $state Bounded current migration checkpoint to persist. * * @return bool Whether encoding and the native checkpoint write succeeded. */ private static function save( array $state ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ $state['changed_at'] = time(); $json = wp_json_encode( $state ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomically persist the base-site migration checkpoint and expose actual SQL write failure. return is_string( $json ) && strlen( $json ) <= 16384 && false !== $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,%s,%s) ON DUPLICATE KEY UPDATE option_value=VALUES(option_value),autoload=VALUES(autoload)', $wpdb->base_prefix . 'options', self::OPTION, $json, 'off' ) ); } /** * Cache component and site readiness while retaining compatible request-engine progress. * * @return array Current raw checkpoint, ready components and site readiness/error facts. */ private static function snapshot(): array { $key = self::key(); if ( isset( self::$cache[ $key ] ) ) { return self::$cache[ $key ]; } $state = self::state(); $ready = array(); if ( ! is_wp_error( $state ) ) { foreach ( self::hashes() as $name => $hash ) { if ( ( $state['components'][ $name ] ?? null ) === $hash ) { $ready[] = $name; } } } // A matching supported request version was written only after all six tables were verified. // Preserve that compatible response engine during an unrelated upgrade. if ( ! in_array( 'requests', $ready, true ) && ! is_wp_error( $state ) && ! isset( $state['components']['requests'] ) ) { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ $old = $wpdb->suppress_errors( true ); try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read the persisted custom response schema version; cached readiness could enable an incompatible engine. $version = $wpdb->get_var( $wpdb->prepare( 'SELECT schema_version FROM %i WHERE network_id=%d AND site_id=%d', Requests::table( 'response_state' ), Scope::network(), get_current_blog_id() ) ); $ok = '' === $wpdb->last_error; } finally { $wpdb->suppress_errors( $old ); } if ( $ok && (string) Requests::VERSION === (string) $version && in_array( Requests::VERSION, array( 3, 4, 5 ), true ) ) { $ready[] = 'requests'; } } $site = get_option( self::SITE_OPTION, array() ); $site_ready = is_array( $site ) && ( $site['target'] ?? null ) === self::target() && 'ready' === ( $site['state'] ?? null ) && ( $site['network_id'] ?? null ) === Scope::network() && ( $site['multisite'] ?? null ) === is_multisite(); self::$cache[ $key ] = array( 'raw' => $state, 'ready' => $ready, 'site_ready' => $site_ready, 'site_error' => is_array( $site ) ? $site['last_error'] ?? null : null, ); return self::$cache[ $key ]; } /** * Check component readiness or complete current-site readiness outside active migration work. * * @param string $component Supported migration or diagnostic component name. * * @return bool Whether the requested supported component or whole target is ready. */ public static function ready( string $component = 'all' ): bool { if ( self::$working ) { return false; } $data = self::snapshot(); if ( 'all' === $component ) { return ! is_wp_error( $data['raw'] ) && 'ready' === $data['raw']['state'] && $data['site_ready'] && ! array_diff( array_keys( self::hashes() ), $data['ready'] ); } return in_array( $component, $data['ready'], true ); } /** * Project current server-scope migration progress into bounded operational fields. * * Sanitized current server-scope health; callers enforce their UI/API permissions. * * @return array Target, phase, readiness and finite error metadata for caller-authorized display. */ public static function status(): array { $data = self::snapshot(); $raw = $data['raw']; $ready = self::ready(); return array( 'target' => self::target(), 'state' => is_wp_error( $raw ) ? 'unavailable' : ( $ready ? 'ready' : ( 'ready' === $raw['state'] ? 'pending' : $raw['state'] ) ), 'phase' => is_wp_error( $raw ) ? null : $raw['phase'] ?? ( $data['site_ready'] ? null : 'site' ), 'last_error' => is_wp_error( $raw ) ? $raw->get_error_code() : $raw['last_error'] ?? $data['site_error'] ?? null, 'changed_at' => is_wp_error( $raw ) ? 0 : $raw['changed_at'], 'ready_components' => $data['ready'], 'pending_components' => array_values( array_diff( array_keys( self::hashes() ), $data['ready'] ) ), 'site_ready' => $data['site_ready'], 'network_id' => Scope::network(), 'site_id' => (int) get_current_blog_id(), ); } /** * Check the native site or network capabilities required to operate schema upgrades. * * @return bool Whether the current principal can initiate schema upgrade work. */ public static function allowed(): bool { return is_multisite() ? Scope::network_authorized() && current_user_can( 'manage_network_plugins' ) && current_user_can( 'manage_network_options' ) : current_user_can( 'activate_plugins' ) && current_user_can( 'manage_options' ); } /** * Ensure a near-term migration invocation and verify its persisted schedule. * * @return bool Whether the migration hook is scheduled after the attempt. */ public static function schedule(): bool { if ( ! wp_next_scheduled( self::HOOK ) ) { $result = wp_schedule_single_event( time() + 10, self::HOOK, array(), true ); if ( is_wp_error( $result ) || ! $result ) { return false; } } return Lifecycle::scheduled( self::HOOK ); } /** * Persist a visible site-initialization failure and request a later migration attempt. * * Fixed, visible site failure; never make the underlying site-creation hook fatal. * * @return void */ public static function defer_site(): void { $before = get_option( self::SITE_OPTION, array() ); $value = is_array( $before ) ? $before : array(); $value = array_merge( $value, array( 'target' => self::target(), 'state' => 'failed', 'network_id' => Scope::network(), 'multisite' => is_multisite(), 'last_error' => 'site_initialization_failed', 'existing_before' => (bool) ( $value['existing_before'] ?? false ), ) ); update_option( self::SITE_OPTION, $value, false ); self::schedule(); self::reset_cache(); } /** * Write a site checkpoint and verify its actual persisted value. * * @param string $name Fixed site option name owned by the lifecycle/migration code. * @param mixed $value Supplied native value to validate, persist or render for this operation. * * @return bool Whether the native persisted checkpoint exactly matches the requested value. */ private static function write_site_option( string $name, mixed $value ): bool { update_option( $name, $value, false ); $actual = Lifecycle::persisted_option( $name ); return ! is_wp_error( $actual ) && $actual === $value; } /** * Run one migration quantum through a separate session and restore caller globals afterward. * * Uses a fresh session: even DDL implicit commits cannot commit caller work. * * @return array|\WP_Error Resumable phase result, or an authority, adapter, storage or migration error. */ public static function work(): array|\WP_Error { if ( ! self::$activation && ! self::allowed() && current_action() !== self::HOOK && ! wp_doing_cron() ) { return new \WP_Error( 'forbidden', 'Schema upgrade authority is required.' ); } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ $original = $wpdb; $roles = $GLOBALS['wp_roles'] ?? null; $connection = null; $die = null; $die_hooks = array( 'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler', 'wp_die_xmlrpc_handler', 'wp_die_xml_handler' ); if ( 'wpdb' !== get_class( $original ) ) { return new \WP_Error( 'migration_adapter', 'The database drop-in needs a verified independent migration adapter.' ); } self::$working = true; self::reset_cache(); try { $connection = new WorkerConnection( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); if ( ! $connection->ready ) { return new \WP_Error( 'migration_storage', 'Independent schema connection unavailable.' ); } $connection->set_prefix( $original->base_prefix ); $connection->set_blog_id( get_current_blog_id(), Scope::network() ); $connection->suppress_errors( true ); foreach ( array( 'SET SESSION autocommit=1', 'SET SESSION innodb_lock_wait_timeout=2', 'SET SESSION lock_wait_timeout=2' ) as $sql ) { if ( false === $connection->query( $sql ) ) { return new \WP_Error( 'migration_storage', 'Independent schema session could not be configured.' ); } } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily select the independently configured migration connection so schema work cannot commit the caller transaction; finally restores the caller. $wpdb = $connection; $die = static fn() => static function () { throw new \RuntimeException( 'installer_failed' ); }; // phpcs:ignore Generic.CodeAnalysis.EmptyPHPStatement.SemicolonWithoutCodeDetected -- Required terminator of the arrow function assignment returning a closure; removing it is a PHP syntax error. foreach ( $die_hooks as $hook ) { add_filter( $hook, $die, PHP_INT_MAX ); } return self::locked_work(); } catch ( \Throwable $error ) { return new \WP_Error( 'migration_failed', 'The schema upgrade could not finish; retry from its last saved phase.' ); } finally { if ( $die ) { foreach ( $die_hooks as $hook ) { remove_filter( $hook, $die, PHP_INT_MAX ); } } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured caller database connection in migration finally before closing the private connection. $wpdb = $original; if ( $roles ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the native role object captured before migration work so temporary role initialization does not leak into the caller. $GLOBALS['wp_roles'] = $roles; } if ( $connection ) { $connection->close(); } self::$working = false; self::reset_cache(); } } /** * Advance one unverified schema phase or site batch under the installation migration mutex. * * @return array|\WP_Error Saved progress and pending-site status, or an explicit checkpoint/phase error. * * @throws \RuntimeException When the initial site provenance checkpoint cannot be verified by remember_site(). */ private static function locked_work(): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ $lock = 'lai_migrate_' . substr( hash( 'sha256', DB_NAME . ':' . $wpdb->base_prefix ), 0, 32 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Acquire the migration connection lock; a cached result cannot serialize schema changes. if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,1)', $lock ) ) ) { return new \WP_Error( 'migration_busy', 'Another schema upgrade is running. Retry later.' ); } try { $state = self::state(); if ( is_wp_error( $state ) ) { return $state; } if ( self::$repair_network ) { if ( ! self::write_network_progress( Scope::network(), array( 'target' => self::target(), 'cursor' => 0, 'done' => false, 'repair' => true, ) ) ) { return new \WP_Error( 'migration_storage', 'Network activation checkpoint failed.' ); } self::$repair_network = false; } if ( self::target() !== $state['target'] ) { $state['introduced_caps'] = isset( $state['cap_catalog'] ) ? array_values( array_diff( self::capabilities(), $state['cap_catalog'] ) ) : null; } $state['target'] = self::target(); if ( null === $state['introduced_caps'] ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual prior table presence before choosing migration capability grants; stale metadata is unsafe. $tables = $wpdb->get_col( $wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (%s,%s,%s)', Store::table(), Requests::table( 'response_state' ), Alerts::table() ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'migration_storage', 'Previous schema availability could not be read.' ); } $state['introduced_caps'] = ! in_array( Store::table(), $tables, true ) ? self::capabilities() : array_merge( array( 'purge_data', 'purge_network_data', 'view_sensitive_context' ), ! in_array( Requests::table( 'response_state' ), $tables, true ) ? array( 'manage_response', 'manage_settings' ) : array(), ! in_array( Alerts::table(), $tables, true ) ? array( 'manage_alerts' ) : array() ); } $state['cap_catalog'] = self::capabilities(); self::remember_site(); foreach ( self::hashes() as $name => $hash ) { if ( ( $state['components'][ $name ] ?? null ) === $hash ) { continue; } $state['state'] = 'running'; $state['phase'] = $name; $state['last_error'] = null; if ( ! self::save( $state ) ) { return new \WP_Error( 'migration_storage', 'Upgrade checkpoint could not be saved.' ); } if ( ! self::schedule() ) { $state['state'] = 'failed'; $state['last_error'] = 'schedule_failed'; self::save( $state ); return new \WP_Error( 'migration_schedule', 'Upgrade retry could not be scheduled. Retry from Schema upgrade.' ); } try { $class = array( 'events' => Store::class, 'auth' => AuthCollectors::class, 'requests' => Requests::class, 'exports' => Exports::class, 'alerts' => Alerts::class, 'network' => NetworkPrivacy::class, 'automatic' => RequestsAutomaticSchema::class, )[ $name ]; $class::activate(); $verified = self::verify( $name ); if ( is_wp_error( $verified ) ) { throw new \RuntimeException( 'verification_failed' ); } $state['components'][ $name ] = $hash; $state['state'] = 'pending'; $state['phase'] = null; if ( ! self::save( $state ) ) { return new \WP_Error( 'migration_storage', 'Verified phase could not be checkpointed; retry is safe.' ); } self::schedule(); return array( 'state' => 'pending', 'completed_phase' => $name, 'target' => self::target(), ); } catch ( \Throwable $error ) { $state['state'] = 'failed'; $state['last_error'] = 'schema_phase_failed'; self::save( $state ); self::schedule(); return new \WP_Error( 'schema_phase_failed', 'Schema phase did not finish. Saved data is retained; retry the upgrade.' ); } } // All phases are verified before site initialization can mark completion. $site = self::initialize_site( $state['introduced_caps'], self::$activation ); $network = is_wp_error( $site ) ? $site : self::network_batch( $state['introduced_caps'] ); if ( is_wp_error( $network ) ) { $state['state'] = 'failed'; $state['phase'] = 'site'; $state['last_error'] = 'site_initialization_failed'; self::save( $state ); self::schedule(); return $network; } $state['state'] = 'ready'; $state['phase'] = null; $state['last_error'] = null; if ( ! self::save( $state ) ) { return new \WP_Error( 'migration_storage', 'Upgrade completion could not be saved.' ); } if ( ! $network ) { self::schedule(); } return array( 'state' => 'ready', 'target' => self::target(), 'network_sites_pending' => ! $network, ); } finally { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the migration connection lock in finally; cached results cannot release database locks. $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } /** * Persist the initial site provenance needed to distinguish fresh and existing installs. * * @return void * * @throws \RuntimeException When the initial site provenance checkpoint cannot be verified. */ private static function remember_site(): void { $site = get_option( self::SITE_OPTION, null ); if ( ! is_array( $site ) || ! array_key_exists( 'existing_before', $site ) ) { $value = array( 'target' => self::target(), 'state' => 'pending', 'network_id' => Scope::network(), 'existing_before' => (int) get_option( 'logged_ai_schema_version', 0 ) > 0, ); if ( ! self::write_site_option( self::SITE_OPTION, $value ) ) { throw new \RuntimeException( 'site_checkpoint_failed' ); } } } /** * Initialize or repair site facilities without restoring deliberately removed capabilities. * * @param array $introduced Capability suffixes newly introduced by this migration target. * @param bool $repair Whether activation requests repair of site facilities after a completed target. * * @return bool|\WP_Error True after verified site completion, or a lifecycle/checkpoint error. */ private static function initialize_site( array $introduced, bool $repair = false ): bool|\WP_Error { self::remember_site(); $before = Lifecycle::persisted_option( self::SITE_OPTION ); if ( is_wp_error( $before ) || ! is_array( $before ) ) { return new \WP_Error( 'migration_storage', 'Site checkpoint unavailable.' ); } $complete = ( $before['target'] ?? null ) === self::target() && 'ready' === ( $before['state'] ?? null ) && ( $before['network_id'] ?? null ) === Scope::network() && ( $before['multisite'] ?? null ) === is_multisite(); if ( $complete && ! $repair ) { return true; } if ( $complete ) { $pending = $before; $pending['state'] = 'pending'; $pending['caps_target'] = self::target(); if ( ! self::write_site_option( self::SITE_OPTION, $pending ) ) { return new \WP_Error( 'migration_storage', 'Site repair checkpoint could not be saved.' ); } } // A completed target never restores capabilities deliberately removed later. $caps = $complete || ( $before['caps_target'] ?? null ) === self::target() ? array() : ( $before['existing_before'] ? $introduced : self::capabilities() ); $result = Lifecycle::initialize_site( $caps ); if ( is_wp_error( $result ) ) { return $result; } $after = array( 'target' => self::target(), 'state' => 'ready', 'network_id' => Scope::network(), 'existing_before' => true, 'multisite' => is_multisite(), 'caps_target' => self::target(), ); if ( ! self::write_site_option( self::SITE_OPTION, $after ) ) { return new \WP_Error( 'migration_storage', 'Site upgrade completion could not be saved.' ); } return true; } /** * Initialize at most ten current-network sites from the saved migration cursor. * * @param array $introduced Capability suffixes newly introduced by this migration target. * * @return bool|\WP_Error Whether the selected network batch sequence is complete, or an initialization error. */ private static function network_batch( array $introduced ): bool|\WP_Error { if ( ! is_multisite() ) { return true; } $network = Scope::network(); $active = get_network_option( $network, 'active_sitewide_plugins', array() ); if ( ! self::$network_activation && ! isset( $active[ plugin_basename( LOGGED_AI_FILE ) ] ) ) { return true; } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ $progress = get_network_option( $network, self::NETWORK_OPTION, array() ); $cursor = ( $progress['target'] ?? null ) === self::target() ? (int) ( $progress['cursor'] ?? 0 ) : 0; if ( ( $progress['target'] ?? null ) === self::target() && true === ( $progress['done'] ?? false ) ) { return true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read a bounded authoritative network-site cursor batch; cached membership could skip site initialization. $sites = $wpdb->get_col( $wpdb->prepare( 'SELECT blog_id FROM %i WHERE site_id=%d AND blog_id>%d AND deleted=0 ORDER BY blog_id LIMIT 10', $wpdb->blogs, $network, $cursor ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'migration_storage', 'Network site upgrade batch unavailable.' ); } foreach ( $sites as $site ) { switch_to_blog( (int) $site ); try { $result = self::initialize_site( $introduced, (bool) ( $progress['repair'] ?? false ) ); } finally { restore_current_blog(); } if ( is_wp_error( $result ) ) { return $result; } $cursor = (int) $site; } $next = array( 'target' => self::target(), 'cursor' => $cursor, 'done' => count( $sites ) < 10, 'repair' => (bool) ( $progress['repair'] ?? false ), ); if ( ! self::write_network_progress( $network, $next ) ) { return new \WP_Error( 'migration_storage', 'Network upgrade cursor could not be saved.' ); } return $next['done']; } /** * Save a network migration cursor and verify its persisted metadata. * * @param int $network Selected network identifier whose progress is being recorded. * @param array $value Bounded current-network migration cursor, target and completion metadata. * * @return bool Whether the exact progress record was read back from native storage. */ private static function write_network_progress( int $network, array $value ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ update_network_option( $network, self::NETWORK_OPTION, $value ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify the network checkpoint actually persisted; the option cache is not write confirmation. $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT meta_value FROM %i WHERE site_id=%d AND meta_key=%s ORDER BY meta_id LIMIT 1', $wpdb->sitemeta, $network, self::NETWORK_OPTION ) ); return '' === $wpdb->last_error && maybe_unserialize( $raw ) === $value; } /** * Verify actual storage engines, columns and complete required indexes for a component. * * Version progress is accepted only after actual type/index/engine verification. * * @param string $component Supported migration or diagnostic component name. * * @return bool|\WP_Error True after all component tables match, or a concrete schema error. */ public static function verify( string $component ): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ $contracts = self::contracts(); if ( ! isset( $contracts[ $component ] ) ) { return new \WP_Error( 'schema_component', 'Unknown schema component.' ); } foreach ( $contracts[ $component ] as $suffix => $schema ) { $table = $wpdb->base_prefix . 'logged_ai_' . $suffix; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual engine metadata before accepting a migration phase; cached schema is insufficient. $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( '' !== $wpdb->last_error || 'INNODB' !== strtoupper( (string) $engine ) ) { return new \WP_Error( 'schema_engine', 'Required schema storage is unavailable.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Verify actual column definitions before accepting a migration phase; cached schema is insufficient. The backtick-quoted identifier is native validated base_prefix plus a suffix from the fixed shipping MigrationsSchema contract. No caller/request value enters SQL syntax. $columns = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A ); /** * Validate column metadata before comparing the required schema from the untyped wpdb adapter. * * @var mixed $columns */ if ( '' !== $wpdb->last_error || ! is_array( $columns ) ) { return new \WP_Error( 'schema_columns', 'Required schema columns are unavailable.' ); } $actual = array_column( $columns, null, 'Field' ); foreach ( $schema['columns'] as $name => $definition ) { $expected = is_string( $definition ) ? array( 'type' => $definition ) : $definition; $column = $actual[ $name ] ?? null; $type = $column ? preg_replace( '/\b(bigint|int|smallint|tinyint)\([0-9]+\)/', '$1', strtolower( $column['Type'] ) ) : ''; if ( ! $column || strtolower( $expected['type'] ) !== $type || ( isset( $expected['null'] ) && ( 'YES' === $column['Null'] ) !== $expected['null'] ) || ( isset( $expected['auto'] ) && str_contains( $column['Extra'], 'auto_increment' ) !== $expected['auto'] ) ) { return new \WP_Error( 'schema_columns', 'Required schema column definition differs.' ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Verify actual indexes before accepting a migration phase; cached schema is insufficient. The backtick-quoted table is the same native prefix plus fixed contract suffix; SHOW INDEX syntax and the inspected contract are fixed. $indexes = $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ); /** * Validate index metadata before comparing the required schema from the untyped wpdb adapter. * * @var mixed $indexes */ if ( '' !== $wpdb->last_error || ! is_array( $indexes ) ) { return new \WP_Error( 'schema_indexes', 'Required schema indexes are unavailable.' ); } $by = array(); foreach ( $indexes as $index ) { $by[ $index['Key_name'] ]['unique'] = 0 === (int) $index['Non_unique']; $by[ $index['Key_name'] ]['columns'][ (int) $index['Seq_in_index'] ] = $index['Column_name']; if ( null !== $index['Sub_part'] ) { $by[ $index['Key_name'] ]['partial'] = true; } } foreach ( $schema['indexes'] as $name => $expected ) { $index = $by[ $name ] ?? null; if ( $index ) { ksort( $index['columns'] ); } $parts = $index ? array_values( $index['columns'] ) : array(); if ( ! $index || isset( $index['partial'] ) || $index['unique'] !== $expected['unique'] || $parts !== $expected['columns'] ) { return new \WP_Error( 'schema_indexes', 'Required schema index definition differs.' ); } } } return true; } /** * Run bounded activation migration quanta and schedule remaining resumable work. * * @param bool $network_wide Whether this activation applies across the current WordPress network. * * @return void * * @throws \RuntimeException When an activation migration quantum returns an error. */ public static function activate( bool $network_wide = false ): void { self::$activation = true; self::$network_activation = $network_wide; self::$repair_network = is_multisite() && $network_wide; $until = microtime( true ) + 15; try { for ( $i = 0; $i < 8; ++$i ) { if ( microtime( true ) >= $until ) { break; } $result = self::work(); if ( is_wp_error( $result ) ) { throw new \RuntimeException( $result->get_error_message() ); } if ( 'ready' === ( $result['state'] ?? '' ) ) { break; } } } finally { self::$activation = false; self::$network_activation = false; self::$repair_network = false; self::reset_cache(); } if ( ! self::ready() ) { self::schedule(); } } /** * Register migration scheduling, notices, upgrade controls and scoped CLI handlers. * * @return void */ public static function boot(): void { add_action( self::HOOK, static function () { self::work(); } ); add_action( 'init', static function () { if ( ( wp_doing_cron() || ( is_admin() && self::allowed() ) ) && ! self::ready() ) { self::schedule(); } }, 999 ); add_action( 'admin_notices', array( self::class, 'notice' ) ); add_action( 'network_admin_notices', array( self::class, 'notice' ) ); add_action( 'admin_menu', static function () { if ( ! self::ready( 'events' ) ) { add_menu_page( __( 'Logged.ai schema upgrade', 'logged-ai' ), __( 'Logged.ai', 'logged-ai' ), 'manage_options', 'logged-ai', array( self::class, 'page' ), 'dashicons-list-view', 80 ); } add_submenu_page( 'logged-ai', __( 'Schema upgrade', 'logged-ai' ), __( 'Schema upgrade', 'logged-ai' ), 'manage_options', 'logged-ai-upgrade', array( self::class, 'page' ) ); }, 5 ); add_action( 'network_admin_menu', static function () { add_submenu_page( 'plugins.php', __( 'Logged.ai schema upgrade', 'logged-ai' ), __( 'Logged.ai schema upgrade', 'logged-ai' ), 'manage_network_plugins', 'logged-ai-upgrade', array( self::class, 'page' ) ); } ); add_action( 'admin_post_logged_ai_migrate', static function () { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Require the exact POST protocol token; changing slash or text syntax must not broaden the method gate. if ( ! self::allowed() || 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) || ! AdminInput::nonce_shape() ) { wp_die( esc_html__( 'Schema upgrade denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'logged_ai_migrate' ); $result = self::work(); if ( is_wp_error( $result ) ) { wp_die( esc_html( PresentationTranslations::text( $result->get_error_message() ) ), '', array( 'response' => 503, 'back_link' => true, ) ); } wp_safe_redirect( admin_url( 'admin.php?page=logged-ai-upgrade' ) ); exit; } ); if ( defined( 'WP_CLI' ) && WP_CLI ) { \WP_CLI::add_command( 'logged-ai schema', static function ( $args, $assoc ) { if ( ! self::allowed() || $assoc || ! in_array( $args[0] ?? '', array( 'status', 'upgrade' ), true ) ) { \WP_CLI::error( __( 'Use logged-ai schema status|upgrade --user= --url=. One phase per upgrade command.', 'logged-ai' ) ); } $result = 'upgrade' === $args[0] ? self::work() : self::status(); if ( is_wp_error( $result ) ) { \WP_CLI::error( PresentationTranslations::text( $result->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); } ); } } /** * Render a fixed pending-schema warning for an authorized administration viewer. * * @return void */ public static function notice(): void { if ( self::ready() || ! current_user_can( 'manage_options' ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; // The native schema screen already explains its pending state and recovery controls. if ( $screen && false !== has_action( $screen->base, array( self::class, 'page' ) ) ) { return; } echo '

' . esc_html__( 'Logged.ai schema upgrade is pending or unavailable. Capture and background work that require the new schema are paused; compatible request policies continue. Open Schema upgrade for progress and recovery.', 'logged-ai' ) . '

'; } /** * Render current migration progress and an authorized next-phase control. * * @return void */ public static function page(): void { if ( ! current_user_can( 'manage_options' ) && ! self::allowed() ) { wp_die( esc_html__( 'Schema status denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } nocache_headers(); $status = self::status(); echo '

' . esc_html__( 'Logged.ai schema upgrade', 'logged-ai' ) . '

' . esc_html( 'ready' === $status['state'] ? __( 'Database structure is up to date.', 'logged-ai' ) : __( 'Database upgrade needs attention.', 'logged-ai' ) ) . '

' . esc_html( 'ready' === $status['state'] ? __( 'This site meets the installed plugin’s database requirements. No upgrade action is needed.', 'logged-ai' ) : __( 'Upgrade work is pending or its state could not be verified. Capture and background work that need the unfinished schema remain paused; compatible request policies continue. Review the technical details before continuing.', 'logged-ai' ) ) . '

'; echo '
' . esc_html__( 'Technical upgrade details', 'logged-ai' ) . '

' . esc_html__( 'Upgrades use verified additive phases on a separate database session. They do not commit a WordPress business transaction. A code rollback is not a database restore. Activity missed while an incompatible phase is pending cannot be reconstructed.', 'logged-ai' ) . '

' . esc_html( wp_json_encode( $status, JSON_PRETTY_PRINT ) ) . '
'; if ( self::allowed() && ! self::ready() ) { echo '
'; wp_nonce_field( 'logged_ai_migrate' ); echo ( '' . '
' . '

' . esc_html__( 'Each submission runs one resumable phase. Scheduled work can resume it when WordPress cron runs.', 'logged-ai' ) . '

' ); } echo '
'; } }