* * @see http://wcpos.com * @package WCPOS\WooCommercePOS */ namespace WCPOS\WooCommercePOS; use WCPOS\WooCommercePOS\Sync\Pos_Uuid; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Queries are prepared before execution. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only table names and an array_fill-built %s placeholder list are interpolated. // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Update script with file-scoped variables. global $wpdb; // The trailing separator is part of the escaped literal, so the plain // `_woocommerce_pos_uuid` key itself cannot match. The users JOIN skips // orphaned rows of deleted users — nothing reads them and promoting them // would recreate garbage. Non-numeric suffixes are filtered out below. $wcpos_legacy_rows = $wpdb->get_results( $wpdb->prepare( "SELECT m.user_id, m.meta_key, m.meta_value FROM {$wpdb->usermeta} m INNER JOIN {$wpdb->users} u ON u.ID = m.user_id WHERE m.meta_key LIKE %s ORDER BY m.user_id ASC, m.umeta_id ASC", $wpdb->esc_like( Pos_Uuid::META_KEY . '_' ) . '%' ) ); if ( '' !== $wpdb->last_error ) { if ( \function_exists( 'wc_get_logger' ) ) { wc_get_logger()->error( 'WCPOS 1.10.0 migration: legacy uuid discovery query failed, no rows were migrated: ' . $wpdb->last_error, array( 'source' => 'woocommerce-pos' ) ); } return; } $wcpos_legacy_key_pattern = '/^' . preg_quote( Pos_Uuid::META_KEY, '/' ) . '_(\d+)$/'; // Group as [user_id][blog_id] => value. Duplicate rows for one key keep the // FIRST (lowest umeta_id) value unless a later row is the only valid uuid. $wcpos_legacy_by_user = array(); foreach ( (array) $wcpos_legacy_rows as $wcpos_row ) { if ( ! preg_match( $wcpos_legacy_key_pattern, (string) $wcpos_row->meta_key, $wcpos_matches ) ) { continue; } $wcpos_user_id = (int) $wcpos_row->user_id; $wcpos_blog_id = (int) $wcpos_matches[1]; $wcpos_value = (string) $wcpos_row->meta_value; if ( ! isset( $wcpos_legacy_by_user[ $wcpos_user_id ][ $wcpos_blog_id ] ) || ( ! Pos_Uuid::is_uuid( $wcpos_legacy_by_user[ $wcpos_user_id ][ $wcpos_blog_id ] ) && Pos_Uuid::is_uuid( $wcpos_value ) ) ) { $wcpos_legacy_by_user[ $wcpos_user_id ][ $wcpos_blog_id ] = $wcpos_value; } } // Prefetch which candidate values are already owned as a LIVE user's plain // key (one chunked query instead of one query per candidate — meta_value is // unindexed, so per-candidate lookups would range-scan every customer row). // Deleted users' stale rows are excluded, mirroring uuid_owned_by_other_user. $wcpos_candidate_values = array(); foreach ( $wcpos_legacy_by_user as $wcpos_blog_values ) { foreach ( $wcpos_blog_values as $wcpos_value ) { if ( Pos_Uuid::is_uuid( $wcpos_value ) ) { $wcpos_candidate_values[ $wcpos_value ] = true; } } } $wcpos_owners_by_value = array(); foreach ( array_chunk( array_keys( $wcpos_candidate_values ), 500 ) as $wcpos_chunk ) { $wcpos_placeholders = implode( ',', array_fill( 0, \count( $wcpos_chunk ), '%s' ) ); $wcpos_owner_rows = $wpdb->get_results( $wpdb->prepare( "SELECT m.meta_value, m.user_id FROM {$wpdb->usermeta} m INNER JOIN {$wpdb->users} u ON u.ID = m.user_id WHERE m.meta_key = %s AND m.meta_value IN ( {$wcpos_placeholders} )", array_merge( array( Pos_Uuid::META_KEY ), $wcpos_chunk ) ) ); if ( '' !== $wpdb->last_error ) { if ( \function_exists( 'wc_get_logger' ) ) { wc_get_logger()->error( 'WCPOS 1.10.0 migration: uuid ownership prefetch query failed, no rows were migrated: ' . $wpdb->last_error, array( 'source' => 'woocommerce-pos' ) ); } return; } foreach ( (array) $wcpos_owner_rows as $wcpos_owner_row ) { $wcpos_owners_by_value[ strtolower( (string) $wcpos_owner_row->meta_value ) ][] = (int) $wcpos_owner_row->user_id; } } $wcpos_promoted = 0; $wcpos_collisions = 0; $wcpos_failed = 0; $wcpos_cleaned = 0; foreach ( $wcpos_legacy_by_user as $wcpos_user_id => $wcpos_blog_values ) { // All plain rows, matching Pos_Uuid::read_valid_uuid_from_meta — a valid // uuid in ANY row wins even when an invalid duplicate sits in front of it. $wcpos_plain_values = get_user_meta( $wcpos_user_id, Pos_Uuid::META_KEY ); $wcpos_has_valid_plain = false; foreach ( (array) $wcpos_plain_values as $wcpos_plain_value ) { if ( Pos_Uuid::is_uuid( $wcpos_plain_value ) ) { $wcpos_has_valid_plain = true; break; } } $wcpos_write_failed = false; if ( ! $wcpos_has_valid_plain ) { ksort( $wcpos_blog_values ); // Ascending blog id, numerically. foreach ( $wcpos_blog_values as $wcpos_legacy_value ) { if ( ! Pos_Uuid::is_uuid( $wcpos_legacy_value ) ) { continue; } // Owned by a DIFFERENT live user (cloned/imported row): fall through // to the next blog's value rather than serve a duplicate RxDB key. // Users iterate in ascending id order and promotions land in this // map as they happen, so shared values converge deterministically. $wcpos_collides = false; foreach ( $wcpos_owners_by_value[ strtolower( $wcpos_legacy_value ) ] ?? array() as $wcpos_owner_id ) { if ( $wcpos_owner_id !== $wcpos_user_id ) { $wcpos_collides = true; break; } } if ( $wcpos_collides ) { ++$wcpos_collisions; continue; } $wcpos_owned_value = $wcpos_legacy_value; if ( array() === (array) $wcpos_plain_values ) { // Unique add: if #1450's lazy adoption raced us and already wrote // a plain uuid, the add fails and THAT value stands. $wcpos_written = false !== add_user_meta( $wcpos_user_id, Pos_Uuid::META_KEY, $wcpos_legacy_value, true ); } else { // Invalid-only plain rows: overwrite, as the lazy path would. $wcpos_written = false !== update_user_meta( $wcpos_user_id, Pos_Uuid::META_KEY, $wcpos_legacy_value ); } if ( ! $wcpos_written ) { // Re-read before declaring failure — a concurrent writer winning // the unique add is success, just not OUR value. wp_cache_delete( $wcpos_user_id, 'user_meta' ); foreach ( (array) get_user_meta( $wcpos_user_id, Pos_Uuid::META_KEY ) as $wcpos_recheck_value ) { if ( Pos_Uuid::is_uuid( $wcpos_recheck_value ) ) { $wcpos_written = true; $wcpos_owned_value = $wcpos_recheck_value; break; } } } if ( $wcpos_written ) { ++$wcpos_promoted; $wcpos_owners_by_value[ strtolower( $wcpos_owned_value ) ][] = $wcpos_user_id; } else { // Genuine write failure (meta filter veto, DB error). The ladder // is one-shot, so keep this user's legacy rows for the lazy path. $wcpos_write_failed = true; } break; } } if ( $wcpos_write_failed ) { ++$wcpos_failed; continue; } foreach ( array_keys( $wcpos_blog_values ) as $wcpos_blog_id ) { delete_user_meta( $wcpos_user_id, Pos_Uuid::META_KEY . '_' . $wcpos_blog_id ); } ++$wcpos_cleaned; } if ( array() !== $wcpos_legacy_by_user && \function_exists( 'wc_get_logger' ) ) { $wcpos_logger = wc_get_logger(); $wcpos_message = \sprintf( 'WCPOS 1.10.0 migration: promoted %d legacy per-blog user uuid(s) to the network key, %d collision fallback(s), cleaned legacy rows for %d user(s), %d write failure(s).', $wcpos_promoted, $wcpos_collisions, $wcpos_cleaned, $wcpos_failed ); if ( $wcpos_failed > 0 ) { $wcpos_logger->error( $wcpos_message . ' Failed users keep their legacy rows for lazy adoption.', array( 'source' => 'woocommerce-pos' ) ); } else { $wcpos_logger->info( $wcpos_message, array( 'source' => 'woocommerce-pos' ) ); } } /* * Seed the activation latches for stores that already exist. * * `pos_app_opened` (#793 Phase 4) reports the site's first open once, guarded * by an option that only new installs are supposed to be missing. Every store * upgrading to 1.10.0 is missing it by definition, so without this their next * POS open would be reported as first-ever, inventing an activation spike out * of the existing user base. * * Seeded unconditionally: this site predates the latch, so its true first open * is unknowable, and an unknown first is better than a wrong one. */ add_option( Services\Lifecycle_Events::FIRST_OPEN_OPTION, Services\Lifecycle_Events::LATCH_VALUE, '', true ); // phpcs:enable