| @@ -10,8 +10,14 @@ | ||
| 10 | 10 | |
| 11 | 11 | namespace WCPOS\WooCommercePOS; |
| 12 | 12 | |
| 13 | 13 | use WCPOS\WooCommercePOS\Admin\Consent; |
| 14 | +use WCPOS\WooCommercePOS\Services\Lifecycle_Events; | |
| 15 | +use WCPOS\WooCommercePOS\Sync\Api as Sync_Api; | |
| 16 | +use WCPOS\WooCommercePOS\Sync\Health as Sync_Health; | |
| 17 | +use WCPOS\WooCommercePOS\Sync\Integrity_Digest; | |
| 18 | +use WCPOS\WooCommercePOS\Sync\Mutation_Store; | |
| 19 | +use WCPOS\WooCommercePOS\Sync\Sync_Journal; | |
| 14 | 20 | use const DOING_AJAX; |
| 15 | 21 | |
| 16 | 22 | /** |
| 17 | 23 | * Activator class. |
| @@ -90,37 +96,79 @@ | ||
| 90 | 96 | } |
| 91 | 97 | |
| 92 | 98 | /** |
| 93 | 99 | * Fired when the plugin is activated. |
| 100 | + * | |
| 101 | + * @param bool $install_sync_schema Whether to install the sync schema. | |
| 102 | + * @param bool $full_role_sync Whether to repair all default role capabilities. | |
| 94 | 103 | */ |
| 95 | - public function single_activate(): void { | |
| 104 | + public function single_activate( bool $install_sync_schema = true, bool $full_role_sync = true ): void { | |
| 105 | + $role_capabilities = self::role_capability_definition(); | |
| 106 | + $capability_names = $role_capabilities; | |
| 107 | + $capability_names['cashier'] = array_merge( array( 'access_woocommerce_pos' ), array_keys( $role_capabilities['cashier'] ) ); | |
| 108 | + $synced = get_option( 'woocommerce_pos_role_caps_synced', false ); | |
| 109 | + if ( ! $full_role_sync && false === $synced && get_option( 'woocommerce_pos_role_caps_fingerprint' ) === $this->role_caps_fingerprint() ) { | |
| 110 | + $synced = $capability_names; | |
| 111 | + } | |
| 112 | + // An upgrade grants only capabilities new to the definition since the | |
| 113 | + // last sync, so a capability the merchant removed on the Access screen | |
| 114 | + // stays removed. Explicit activation still repairs every default. | |
| 115 | + $granted = $capability_names; | |
| 116 | + if ( ! $full_role_sync && \is_array( $synced ) ) { | |
| 117 | + foreach ( $granted as $slug => $capabilities ) { | |
| 118 | + $already = isset( $synced[ $slug ] ) && \is_array( $synced[ $slug ] ) ? $synced[ $slug ] : array(); | |
| 119 | + $granted[ $slug ] = array_values( array_diff( $capabilities, $already ) ); | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + // Reseed the default template terms on the next request: (re)activation | |
| 124 | + // is the repair a merchant reaches for after deleting a term by hand. | |
| 125 | + // This also runs once per upgrade (version_check re-activates to sync | |
| 126 | + // role caps), so one post-upgrade request pays the ~18 seeding queries. | |
| 127 | + delete_option( Templates::DEFAULT_TERMS_OPTION ); | |
| 128 | + | |
| 129 | + // Second, merchant-reachable trigger for the autoload repair: db_upgrade() | |
| 130 | + // only runs when version_check() trips on an admin load that reaches | |
| 131 | + // woocommerce_init, and a miss there is permanent once bump_versions() ran. | |
| 132 | + self::autoload_request_latches(); | |
| 133 | + Admin\Permalink::ensure_default(); | |
| 134 | + | |
| 96 | 135 | // create POS specific roles. |
| 97 | - $this->create_pos_roles(); | |
| 136 | + $this->create_pos_roles( $granted['cashier'] ); | |
| 98 | 137 | |
| 99 | 138 | // add pos capabilities to non POS roles. |
| 100 | 139 | $this->add_pos_capability( |
| 101 | 140 | array( |
| 102 | - 'administrator' => array( | |
| 103 | - 'manage_woocommerce_pos', | |
| 104 | - 'access_woocommerce_pos', | |
| 105 | - 'edit_wcpos_store', | |
| 106 | - 'read_wcpos_store', | |
| 107 | - 'delete_wcpos_store', | |
| 108 | - 'edit_wcpos_stores', | |
| 109 | - 'edit_others_wcpos_stores', | |
| 110 | - 'publish_wcpos_stores', | |
| 111 | - 'read_private_wcpos_stores', | |
| 112 | - 'delete_wcpos_stores', | |
| 113 | - 'delete_private_wcpos_stores', | |
| 114 | - 'delete_published_wcpos_stores', | |
| 115 | - 'delete_others_wcpos_stores', | |
| 116 | - 'edit_private_wcpos_stores', | |
| 117 | - 'edit_published_wcpos_stores', | |
| 118 | - ), | |
| 119 | - 'shop_manager' => array( 'manage_woocommerce_pos', 'access_woocommerce_pos' ), | |
| 141 | + 'administrator' => $granted['administrator'], | |
| 142 | + 'shop_manager' => $granted['shop_manager'], | |
| 120 | 143 | ) |
| 121 | 144 | ); |
| 122 | 145 | |
| 146 | + $stored_roles = get_option( wp_roles()->role_key, array() ); | |
| 147 | + $roles_are_persisted = is_array( $stored_roles ); | |
| 148 | + if ( $roles_are_persisted ) { | |
| 149 | + foreach ( $granted as $slug => $capabilities ) { | |
| 150 | + if ( ! isset( $stored_roles[ $slug ] ) ) { | |
| 151 | + $roles_are_persisted = false; | |
| 152 | + break; | |
| 153 | + } | |
| 154 | + foreach ( $capabilities as $capability ) { | |
| 155 | + if ( empty( $stored_roles[ $slug ]['capabilities'][ $capability ] ) ) { | |
| 156 | + $roles_are_persisted = false; | |
| 157 | + break 2; | |
| 158 | + } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + } | |
| 162 | + | |
| 163 | + $obsolete_customer_create_cap = isset( $role_capabilities['cashier']['create_customers'] ) ? 'promote_users' : 'create_customers'; | |
| 164 | + if ( $roles_are_persisted && empty( $stored_roles['cashier']['capabilities'][ $obsolete_customer_create_cap ] ) ) { | |
| 165 | + // Snapshot first: a fingerprint that advanced past a failed snapshot | |
| 166 | + // write would never retry it. | |
| 167 | + update_option( 'woocommerce_pos_role_caps_synced', $capability_names, true ); | |
| 168 | + update_option( 'woocommerce_pos_role_caps_fingerprint', $this->role_caps_fingerprint(), true ); | |
| 169 | + } | |
| 170 | + | |
| 123 | 171 | // Flag the consent pop-up for the next admin page load. Done here |
| 124 | 172 | // because the `activated_plugin` action in Admin\Consent fires |
| 125 | 173 | // inside the activation request, at which point our plugin's |
| 126 | 174 | // `plugins_loaded` callback hasn't yet instantiated Init on a |
| @@ -135,11 +183,68 @@ | ||
| 135 | 183 | : 'undecided'; |
| 136 | 184 | if ( 'undecided' === $tracking_consent ) { |
| 137 | 185 | set_transient( Consent::MODAL_TRANSIENT, 1, Consent::MODAL_TRANSIENT_TTL ); |
| 138 | 186 | } |
| 187 | + | |
| 188 | + if ( $install_sync_schema ) { | |
| 189 | + $this->install_sync_schema(); | |
| 190 | + } | |
| 191 | + | |
| 192 | + // Record the install for analytics. Consent is still `undecided` at this | |
| 193 | + // point, so the event is held until the user answers the pop-up flagged | |
| 194 | + // above; Lifecycle_Events owns that deferral and reports at most once. | |
| 195 | + ( new Lifecycle_Events() )->record_install(); | |
| 139 | 196 | } |
| 140 | 197 | |
| 141 | 198 | /** |
| 199 | + * Install the sync store and latch its aggregate schema version after verification. | |
| 200 | + */ | |
| 201 | + public function install_sync_schema(): void { | |
| 202 | + $previous_schema = get_option( Sync_Api::SCHEMA_OPTION, null ); | |
| 203 | + | |
| 204 | + $journal = new Sync_Journal(); | |
| 205 | + $journal->install(); | |
| 206 | + ( new Integrity_Digest() )->install(); | |
| 207 | + ( new Mutation_Store() )->install(); | |
| 208 | + | |
| 209 | + if ( ! Sync_Health::is_healthy() ) { | |
| 210 | + if ( Sync_Api::SCHEMA_VERSION === $previous_schema ) { | |
| 211 | + delete_option( Sync_Api::SCHEMA_OPTION ); | |
| 212 | + } | |
| 213 | + return; | |
| 214 | + } | |
| 215 | + | |
| 216 | + // Schema 3 (#1379): the customer space widened from role=customer to ALL users. | |
| 217 | + // Upgrading installs carry role-departure tombstones in the persisted stream that | |
| 218 | + // would replay against now-live users; compensating updates supersede them (see | |
| 219 | + // Sync_Journal::append_customer_updates_for_all_users). The old latch stays until | |
| 220 | + // migration succeeds, so retries may append duplicate but harmless superseding | |
| 221 | + // updates. Fresh installs (no previous latch) have no stream to repair. | |
| 222 | + if ( | |
| 223 | + null !== $previous_schema | |
| 224 | + && version_compare( (string) $previous_schema, '3', '<' ) | |
| 225 | + && ! $journal->append_customer_updates_for_all_users() | |
| 226 | + ) { | |
| 227 | + return; | |
| 228 | + } | |
| 229 | + | |
| 230 | + // Autoloaded: the Init constructor reads this latch on every request. | |
| 231 | + // This flips an existing row only on WP 6.4+; older rows are flipped by | |
| 232 | + // autoload_request_latches() on upgrade. | |
| 233 | + update_option( Sync_Api::SCHEMA_OPTION, Sync_Api::SCHEMA_VERSION, true ); | |
| 234 | + | |
| 235 | + if ( null !== $previous_schema && version_compare( (string) $previous_schema, Sync_Api::SCHEMA_VERSION, '<' ) ) { | |
| 236 | + global $wpdb; | |
| 237 | + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_change_log" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name. | |
| 238 | + $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_order_index" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name. | |
| 239 | + // The legacy Change_Log_Purge class is gone; its recurring cron event | |
| 240 | + // would otherwise survive the upgrade and fire a hook with no handler | |
| 241 | + // forever. Literal hook name — the constant was removed with the class. | |
| 242 | + wp_clear_scheduled_hook( 'wcpos_change_log_purge' ); | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + /** | |
| 142 | 247 | * Fired when a new site is activated with a WPMU environment. |
| 143 | 248 | * |
| 144 | 249 | * @param int $blog_id Blog ID. |
| 145 | 250 | */ |
| @@ -223,10 +328,16 @@ | ||
| 223 | 328 | /** |
| 224 | 329 | * Check version number, runs every admin page load. |
| 225 | 330 | */ |
| 226 | 331 | private function version_check(): void { |
| 227 | - $old = (string) Services\Settings::get_db_version(); | |
| 228 | - if ( ! version_compare( $old, VERSION, '<' ) ) { | |
| 332 | + $old = (string) Services\Settings::get_db_version(); | |
| 333 | + $plugin_needs_upgrade = version_compare( $old, VERSION, '<' ); | |
| 334 | + $sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ); | |
| 335 | + | |
| 336 | + $role_caps_fingerprint = $this->role_caps_fingerprint(); | |
| 337 | + $role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $role_caps_fingerprint | |
| 338 | + || false === get_option( 'woocommerce_pos_role_caps_synced' ); | |
| 339 | + if ( ! $plugin_needs_upgrade && ! $sync_needs_upgrade && ! $role_caps_need_sync ) { | |
| 229 | 340 | return; |
| 230 | 341 | } |
| 231 | 342 | |
| 232 | 343 | if ( ! $this->acquire_db_upgrade_lock() ) { |
| @@ -232,28 +343,38 @@ | ||
| 232 | 343 | if ( ! $this->acquire_db_upgrade_lock() ) { |
| 233 | 344 | return; |
| 234 | 345 | } |
| 235 | 346 | |
| 236 | - $locked_old = (string) Services\Settings::get_db_version(); | |
| 237 | - if ( ! version_compare( $locked_old, VERSION, '<' ) ) { | |
| 347 | + $locked_old = (string) Services\Settings::get_db_version(); | |
| 348 | + $locked_plugin_needs_upgrade = version_compare( $locked_old, VERSION, '<' ); | |
| 349 | + $locked_sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ); | |
| 350 | + | |
| 351 | + $locked_role_caps_fingerprint = $this->role_caps_fingerprint(); | |
| 352 | + $locked_role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $locked_role_caps_fingerprint | |
| 353 | + || false === get_option( 'woocommerce_pos_role_caps_synced' ); | |
| 354 | + if ( ! $locked_plugin_needs_upgrade && ! $locked_sync_needs_upgrade && ! $locked_role_caps_need_sync ) { | |
| 238 | 355 | $this->release_db_upgrade_lock(); |
| 239 | 356 | return; |
| 240 | 357 | } |
| 241 | 358 | |
| 242 | - Services\Settings::bump_versions(); | |
| 359 | + if ( $locked_plugin_needs_upgrade ) { | |
| 360 | + Services\Settings::bump_versions(); | |
| 361 | + } | |
| 243 | 362 | |
| 244 | - // Re-run activation to sync role capabilities. add_role() and add_cap() | |
| 245 | - // are both idempotent, so this is safe. Without this, capabilities added | |
| 246 | - // in newer versions would never reach existing installs because add_role() | |
| 247 | - // is a no-op when the role already exists. | |
| 248 | - // Deferred to 'init' because create_pos_roles() calls __() which | |
| 249 | - // requires translations to be loaded (WordPress 6.7+). | |
| 250 | - add_action( | |
| 251 | - 'init', | |
| 252 | - function () { | |
| 253 | - $this->single_activate(); | |
| 254 | - } | |
| 255 | - ); | |
| 363 | + if ( $locked_plugin_needs_upgrade || $locked_role_caps_need_sync ) { | |
| 364 | + // Re-run activation to sync role capabilities. add_role() and add_cap() | |
| 365 | + // are both idempotent, so this is safe. Without this, capabilities added | |
| 366 | + // in newer versions would never reach existing installs because add_role() | |
| 367 | + // is a no-op when the role already exists. | |
| 368 | + // Deferred to 'init' because create_pos_roles() calls __() which | |
| 369 | + // requires translations to be loaded (WordPress 6.7+). | |
| 370 | + add_action( | |
| 371 | + 'init', | |
| 372 | + function () { | |
| 373 | + $this->single_activate( false, false ); | |
| 374 | + } | |
| 375 | + ); | |
| 376 | + } | |
| 256 | 377 | |
| 257 | 378 | $lock_released = false; |
| 258 | 379 | $release_lock = function () use ( &$lock_released ): void { |
| 259 | 380 | if ( $lock_released ) { |
| @@ -271,11 +392,24 @@ | ||
| 271 | 392 | // This prevents conflicts with plugins like WC Subscriptions that hook |
| 272 | 393 | // into before_delete_post and assume WC()->order_factory is available. |
| 273 | 394 | add_action( |
| 274 | 395 | 'woocommerce_init', |
| 275 | - function () use ( $locked_old, $release_lock ) { | |
| 396 | + function () use ( $locked_old, $locked_plugin_needs_upgrade, $locked_sync_needs_upgrade, $release_lock ) { | |
| 276 | 397 | try { |
| 277 | 398 | $this->db_upgrade( $locked_old, VERSION ); |
| 399 | + | |
| 400 | + // Report the upgrade only once the migration has actually | |
| 401 | + // completed — queueing it beside bump_versions() would claim a | |
| 402 | + // finished upgrade even when db_upgrade() threw or never ran. | |
| 403 | + // Still exactly-once: the version was bumped above, so the | |
| 404 | + // upgrade is not re-detected on the next request. | |
| 405 | + if ( $locked_plugin_needs_upgrade ) { | |
| 406 | + ( new Lifecycle_Events() )->record_upgrade( $locked_old, VERSION ); | |
| 407 | + } | |
| 408 | + if ( $locked_sync_needs_upgrade && Sync_Api::SCHEMA_VERSION === get_option( Sync_Api::SCHEMA_OPTION, null ) ) { | |
| 409 | + ( new Sync_Journal() )->register_hooks(); | |
| 410 | + ( new Integrity_Digest() )->register_hooks(); | |
| 411 | + } | |
| 278 | 412 | } finally { |
| 279 | 413 | $release_lock(); |
| 280 | 414 | remove_action( 'shutdown', $release_lock ); |
| 281 | 415 | } |
| @@ -334,11 +468,13 @@ | ||
| 334 | 468 | return $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static query, no user input |
| 335 | 469 | } |
| 336 | 470 | |
| 337 | 471 | /** |
| 338 | - * Add POS specific roles. | |
| 472 | + * Get the role capability definition. | |
| 473 | + * | |
| 474 | + * @return array<string, array<string, bool>|array<int, string>> Role capabilities keyed by role. | |
| 339 | 475 | */ |
| 340 | - private function create_pos_roles(): void { | |
| 476 | + private static function role_capability_definition(): array { | |
| 341 | 477 | // WC 9.9 replaced promote_users with create_customers for customer creation. |
| 342 | 478 | $customer_create_cap = \defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '9.9', '>=' ) // @phpstan-ignore-line |
| 343 | 479 | ? 'create_customers' |
| 344 | 480 | : 'promote_users'; |
| @@ -346,8 +482,14 @@ | ||
| 346 | 482 | // Cashier role. |
| 347 | 483 | $cashier_capabilities = array( |
| 348 | 484 | 'read' => true, |
| 349 | 485 | 'read_private_products' => true, |
| 486 | + 'publish_products' => true, | |
| 487 | + 'edit_product' => true, | |
| 488 | + 'edit_products' => true, | |
| 489 | + 'edit_published_products' => true, | |
| 490 | + 'edit_private_products' => true, | |
| 491 | + 'edit_others_products' => true, | |
| 350 | 492 | 'read_private_shop_orders' => true, |
| 351 | 493 | 'publish_shop_orders' => true, |
| 352 | 494 | 'edit_shop_orders' => true, |
| 353 | 495 | 'edit_others_shop_orders' => true, |
| @@ -354,24 +496,77 @@ | ||
| 354 | 496 | 'list_users' => true, |
| 355 | 497 | $customer_create_cap => true, |
| 356 | 498 | 'edit_users' => true, |
| 357 | 499 | 'read_private_shop_coupons' => true, |
| 500 | + 'publish_shop_coupons' => true, | |
| 501 | + 'edit_shop_coupons' => true, | |
| 502 | + 'edit_published_shop_coupons' => true, | |
| 503 | + 'edit_private_shop_coupons' => true, | |
| 504 | + 'edit_others_shop_coupons' => true, | |
| 358 | 505 | 'manage_product_terms' => true, |
| 359 | 506 | ); |
| 360 | 507 | |
| 508 | + return array( | |
| 509 | + 'cashier' => $cashier_capabilities, | |
| 510 | + 'administrator' => array( | |
| 511 | + 'manage_woocommerce_pos', | |
| 512 | + 'access_woocommerce_pos', | |
| 513 | + 'edit_wcpos_store', | |
| 514 | + 'read_wcpos_store', | |
| 515 | + 'delete_wcpos_store', | |
| 516 | + 'edit_wcpos_stores', | |
| 517 | + 'edit_others_wcpos_stores', | |
| 518 | + 'publish_wcpos_stores', | |
| 519 | + 'read_private_wcpos_stores', | |
| 520 | + 'delete_wcpos_stores', | |
| 521 | + 'delete_private_wcpos_stores', | |
| 522 | + 'delete_published_wcpos_stores', | |
| 523 | + 'delete_others_wcpos_stores', | |
| 524 | + 'edit_private_wcpos_stores', | |
| 525 | + 'edit_published_wcpos_stores', | |
| 526 | + ), | |
| 527 | + 'shop_manager' => array( 'manage_woocommerce_pos', 'access_woocommerce_pos' ), | |
| 528 | + ); | |
| 529 | + } | |
| 530 | + | |
| 531 | + /** | |
| 532 | + * Get the role-capabilities definition fingerprint. | |
| 533 | + */ | |
| 534 | + private function role_caps_fingerprint(): string { | |
| 535 | + return md5( wp_json_encode( self::role_capability_definition() ) ); | |
| 536 | + } | |
| 537 | + | |
| 538 | + /** | |
| 539 | + * Add POS specific roles. | |
| 540 | + * | |
| 541 | + * @param string[]|null $capabilities Capability names to sync onto an existing role, or null for | |
| 542 | + * every default. A missing role is always created with the full set. | |
| 543 | + */ | |
| 544 | + private function create_pos_roles( ?array $capabilities = null ): void { | |
| 545 | + $role_capabilities = self::role_capability_definition(); | |
| 546 | + $cashier_capabilities = $role_capabilities['cashier']; | |
| 547 | + | |
| 361 | 548 | add_role( |
| 362 | 549 | 'cashier', |
| 363 | 550 | /* translators: Plugin activation notice label. */ |
| 364 | 551 | __( 'Cashier', 'woocommerce-pos' ), |
| 365 | - $cashier_capabilities | |
| 552 | + // A missing role is created whole, access gate included, whatever | |
| 553 | + // subset an incremental upgrade asked to sync. | |
| 554 | + array_merge( array( 'access_woocommerce_pos' => true ), $cashier_capabilities ) | |
| 366 | 555 | ); |
| 367 | 556 | |
| 368 | - // Sync all capabilities to the existing role. add_role() is a no-op when | |
| 557 | + $obsolete_customer_create_cap = isset( $cashier_capabilities['create_customers'] ) ? 'promote_users' : 'create_customers'; | |
| 558 | + $cashier = get_role( 'cashier' ); | |
| 559 | + if ( $cashier ) { | |
| 560 | + $cashier->remove_cap( $obsolete_customer_create_cap ); | |
| 561 | + } | |
| 562 | + | |
| 563 | + // Sync the requested capabilities to the role. add_role() is a no-op when | |
| 369 | 564 | // the role already exists, so capabilities added in newer versions would |
| 370 | 565 | // never reach existing installs without this. |
| 371 | 566 | $this->add_pos_capability( |
| 372 | 567 | array( |
| 373 | - 'cashier' => array_merge( | |
| 568 | + 'cashier' => $capabilities ?? array_merge( | |
| 374 | 569 | array( 'access_woocommerce_pos' ), |
| 375 | 570 | array_keys( $cashier_capabilities ) |
| 376 | 571 | ), |
| 377 | 572 | ) |
| @@ -410,8 +605,9 @@ | ||
| 410 | 605 | '1.8.7' => 'updates/update-1.8.7.php', |
| 411 | 606 | '1.8.12' => 'updates/update-1.8.12.php', |
| 412 | 607 | '1.8.13' => 'updates/update-1.8.13.php', |
| 413 | 608 | '1.9.0' => 'updates/update-1.9.0.php', |
| 609 | + '1.10.0' => 'updates/update-1.10.0.php', | |
| 414 | 610 | ); |
| 415 | 611 | foreach ( $db_updates as $version => $updater ) { |
| 416 | 612 | if ( version_compare( $version, $old, '>' ) && |
| 417 | 613 | version_compare( $version, $current, '<=' ) ) { |
| @@ -417,8 +613,98 @@ | ||
| 417 | 613 | version_compare( $version, $current, '<=' ) ) { |
| 418 | 614 | include $updater; |
| 419 | 615 | } |
| 420 | 616 | } |
| 617 | + | |
| 618 | + if ( Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ) ) { | |
| 619 | + $this->install_sync_schema(); | |
| 620 | + } | |
| 621 | + | |
| 622 | + // Installs that predate 2026-09 wrote the per-request latches with | |
| 623 | + // autoload off; every upgrade re-asserts autoload so the flip is | |
| 624 | + // idempotent and needs no versioned update file. | |
| 625 | + self::autoload_request_latches(); | |
| 626 | + Admin\Permalink::ensure_default(); | |
| 627 | + } | |
| 628 | + | |
| 629 | + /** | |
| 630 | + * Every option row that is read on EVERY request and must therefore ride in | |
| 631 | + * alloptions: the three sync latches the Init constructor reads, the permalink | |
| 632 | + * slug Template_Router reads, and each registered settings section that | |
| 633 | + * declares {@see Services\Settings\Abstract_Section::autoload()} — the | |
| 634 | + * sections are the extension point, so Pro's and extensions' sections join | |
| 635 | + * the repair by declaring it, without touching this file. | |
| 636 | + * | |
| 637 | + * Needed because core's update_option() returns early on an unchanged value | |
| 638 | + * WITHOUT touching the autoload column, so a writer alone never repairs a row | |
| 639 | + * an older release wrote with autoload off. Without a persistent object cache | |
| 640 | + * each such row cost one `SELECT option_value` per page load (measured | |
| 641 | + * 2026-09-03 on dev-next and dev-free). | |
| 642 | + * | |
| 643 | + * @return string[] | |
| 644 | + */ | |
| 645 | + private static function request_option_names(): array { | |
| 646 | + $names = array( | |
| 647 | + Sync_Api::SCHEMA_OPTION, | |
| 648 | + \WCPOS\WooCommercePOS\Sync\Visibility_Observer::SEED_VERSION_OPTION, | |
| 649 | + \WCPOS\WooCommercePOS\Sync\Config_Fingerprint::CLEANUP_VERSION_OPTION, | |
| 650 | + Admin\Permalink::DB_KEY, | |
| 651 | + ); | |
| 652 | + foreach ( Services\Settings::instance()->sections()->all() as $section ) { | |
| 653 | + if ( $section instanceof Services\Settings\Abstract_Section && $section->autoload() ) { | |
| 654 | + $names[] = $section->autoload_option_name(); | |
| 655 | + } | |
| 656 | + } | |
| 657 | + return $names; | |
| 658 | + } | |
| 659 | + | |
| 660 | + /** | |
| 661 | + * Flip the per-request rows to autoload in place, and seed the settings | |
| 662 | + * sections that are absent. | |
| 663 | + * | |
| 664 | + * One UPDATE on the flag column: never delete-and-recreate, because | |
| 665 | + * `Sync_Api::SCHEMA_OPTION` gates the sync observers on every request and a | |
| 666 | + * request landing in that gap would run with journaling off. 'yes' is | |
| 667 | + * accepted by every core version (6.6+ maps it alongside 'on'). Idempotent: | |
| 668 | + * already-autoloaded rows match nothing. Latches that were never written | |
| 669 | + * stay absent (their absence is the signal). An autoloaded settings section | |
| 670 | + * that was never saved is seeded as an autoloaded row — an absent option is | |
| 671 | + * queried on every request too. General also persists its migrated consent | |
| 672 | + * so the legacy row does not remain on the read path; other defaults stay | |
| 673 | + * dynamic. | |
| 674 | + */ | |
| 675 | + public static function autoload_request_latches(): void { | |
| 676 | + global $wpdb; | |
| 677 | + // The key comes from the section itself (autoload_option_name()): Pro's | |
| 678 | + // License section stores under a Pro-prefixed key, so deriving it from | |
| 679 | + // id() flipped nothing for that row and seeded a stray free-prefixed one. | |
| 680 | + foreach ( Services\Settings::instance()->sections()->all() as $section ) { | |
| 681 | + if ( ! $section instanceof Services\Settings\Abstract_Section || ! $section->autoload() ) { | |
| 682 | + continue; | |
| 683 | + } | |
| 684 | + $option_name = $section->autoload_option_name(); | |
| 685 | + if ( false === get_option( $option_name ) ) { | |
| 686 | + $value = $section instanceof Services\Settings\General_Section | |
| 687 | + ? array( 'tracking_consent' => $section->raw_tracking_consent() ) | |
| 688 | + : array(); | |
| 689 | + add_option( $option_name, $value, '', true ); | |
| 690 | + } | |
| 691 | + } | |
| 692 | + $options = self::request_option_names(); | |
| 693 | + $placeholders = implode( ', ', array_fill( 0, \count( $options ), '%s' ) ); | |
| 694 | + $flipped = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the flag column is the target; caches are cleared below. | |
| 695 | + $wpdb->prepare( | |
| 696 | + "UPDATE {$wpdb->options} SET autoload = 'yes' WHERE option_name IN ({$placeholders}) AND autoload NOT IN ('yes', 'on', 'auto-on')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholders are generated for the prepared values. | |
| 697 | + $options | |
| 698 | + ) | |
| 699 | + ); | |
| 700 | + if ( ! $flipped ) { | |
| 701 | + return; | |
| 702 | + } | |
| 703 | + foreach ( $options as $option ) { | |
| 704 | + wp_cache_delete( $option, 'options' ); | |
| 705 | + } | |
| 706 | + wp_cache_delete( 'alloptions', 'options' ); | |
| 421 | 707 | } |
| 422 | 708 | |
| 423 | 709 | /** |
| 424 | 710 | * If \WCPOS\WooCommercePOSPro\ is installed, check the version is above MIN_PRO_VERSION. |