| @@ -98,20 +98,49 @@ | ||
| 98 | 98 | /** |
| 99 | 99 | * Fired when the plugin is activated. |
| 100 | 100 | * |
| 101 | 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. | |
| 102 | 103 | */ |
| 103 | - public function single_activate( bool $install_sync_schema = true ): void { | |
| 104 | - $role_capabilities = self::role_capability_definition(); | |
| 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 | + } | |
| 105 | 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 | + | |
| 106 | 135 | // create POS specific roles. |
| 107 | - $this->create_pos_roles(); | |
| 136 | + $this->create_pos_roles( $granted['cashier'] ); | |
| 108 | 137 | |
| 109 | 138 | // add pos capabilities to non POS roles. |
| 110 | 139 | $this->add_pos_capability( |
| 111 | 140 | array( |
| 112 | - 'administrator' => $role_capabilities['administrator'], | |
| 113 | - 'shop_manager' => $role_capabilities['shop_manager'], | |
| 141 | + 'administrator' => $granted['administrator'], | |
| 142 | + 'shop_manager' => $granted['shop_manager'], | |
| 114 | 143 | ) |
| 115 | 144 | ); |
| 116 | 145 | |
| 117 | 146 | $stored_roles = get_option( wp_roles()->role_key, array() ); |
| @@ -116,13 +145,14 @@ | ||
| 116 | 145 | |
| 117 | 146 | $stored_roles = get_option( wp_roles()->role_key, array() ); |
| 118 | 147 | $roles_are_persisted = is_array( $stored_roles ); |
| 119 | 148 | if ( $roles_are_persisted ) { |
| 120 | - foreach ( $role_capabilities as $slug => $capabilities ) { | |
| 121 | - $required_capabilities = 'cashier' === $slug | |
| 122 | - ? array_merge( array( 'access_woocommerce_pos' ), array_keys( $capabilities ) ) | |
| 123 | - : $capabilities; | |
| 124 | - foreach ( $required_capabilities as $capability ) { | |
| 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 ) { | |
| 125 | 155 | if ( empty( $stored_roles[ $slug ]['capabilities'][ $capability ] ) ) { |
| 126 | 156 | $roles_are_persisted = false; |
| 127 | 157 | break 2; |
| 128 | 158 | } |
| @@ -131,8 +161,11 @@ | ||
| 131 | 161 | } |
| 132 | 162 | |
| 133 | 163 | $obsolete_customer_create_cap = isset( $role_capabilities['cashier']['create_customers'] ) ? 'promote_users' : 'create_customers'; |
| 134 | 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 ); | |
| 135 | 168 | update_option( 'woocommerce_pos_role_caps_fingerprint', $this->role_caps_fingerprint(), true ); |
| 136 | 169 | } |
| 137 | 170 | |
| 138 | 171 | // Flag the consent pop-up for the next admin page load. Done here |
| @@ -193,9 +226,12 @@ | ||
| 193 | 226 | ) { |
| 194 | 227 | return; |
| 195 | 228 | } |
| 196 | 229 | |
| 197 | - update_option( Sync_Api::SCHEMA_OPTION, Sync_Api::SCHEMA_VERSION, false ); | |
| 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 ); | |
| 198 | 234 | |
| 199 | 235 | if ( null !== $previous_schema && version_compare( (string) $previous_schema, Sync_Api::SCHEMA_VERSION, '<' ) ) { |
| 200 | 236 | global $wpdb; |
| 201 | 237 | $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_change_log" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name. |
| @@ -297,9 +333,10 @@ | ||
| 297 | 333 | $plugin_needs_upgrade = version_compare( $old, VERSION, '<' ); |
| 298 | 334 | $sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ); |
| 299 | 335 | |
| 300 | 336 | $role_caps_fingerprint = $this->role_caps_fingerprint(); |
| 301 | - $role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $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' ); | |
| 302 | 339 | if ( ! $plugin_needs_upgrade && ! $sync_needs_upgrade && ! $role_caps_need_sync ) { |
| 303 | 340 | return; |
| 304 | 341 | } |
| 305 | 342 | |
| @@ -311,9 +348,10 @@ | ||
| 311 | 348 | $locked_plugin_needs_upgrade = version_compare( $locked_old, VERSION, '<' ); |
| 312 | 349 | $locked_sync_needs_upgrade = Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ); |
| 313 | 350 | |
| 314 | 351 | $locked_role_caps_fingerprint = $this->role_caps_fingerprint(); |
| 315 | - $locked_role_caps_need_sync = get_option( 'woocommerce_pos_role_caps_fingerprint' ) !== $locked_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' ); | |
| 316 | 354 | if ( ! $locked_plugin_needs_upgrade && ! $locked_sync_needs_upgrade && ! $locked_role_caps_need_sync ) { |
| 317 | 355 | $this->release_db_upgrade_lock(); |
| 318 | 356 | return; |
| 319 | 357 | } |
| @@ -331,9 +369,9 @@ | ||
| 331 | 369 | // requires translations to be loaded (WordPress 6.7+). |
| 332 | 370 | add_action( |
| 333 | 371 | 'init', |
| 334 | 372 | function () { |
| 335 | - $this->single_activate( false ); | |
| 373 | + $this->single_activate( false, false ); | |
| 336 | 374 | } |
| 337 | 375 | ); |
| 338 | 376 | } |
| 339 | 377 | |
| @@ -498,10 +536,13 @@ | ||
| 498 | 536 | } |
| 499 | 537 | |
| 500 | 538 | /** |
| 501 | 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. | |
| 502 | 543 | */ |
| 503 | - private function create_pos_roles(): void { | |
| 544 | + private function create_pos_roles( ?array $capabilities = null ): void { | |
| 504 | 545 | $role_capabilities = self::role_capability_definition(); |
| 505 | 546 | $cashier_capabilities = $role_capabilities['cashier']; |
| 506 | 547 | |
| 507 | 548 | add_role( |
| @@ -507,9 +548,11 @@ | ||
| 507 | 548 | add_role( |
| 508 | 549 | 'cashier', |
| 509 | 550 | /* translators: Plugin activation notice label. */ |
| 510 | 551 | __( 'Cashier', 'woocommerce-pos' ), |
| 511 | - $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 ) | |
| 512 | 555 | ); |
| 513 | 556 | |
| 514 | 557 | $obsolete_customer_create_cap = isset( $cashier_capabilities['create_customers'] ) ? 'promote_users' : 'create_customers'; |
| 515 | 558 | $cashier = get_role( 'cashier' ); |
| @@ -516,14 +559,14 @@ | ||
| 516 | 559 | if ( $cashier ) { |
| 517 | 560 | $cashier->remove_cap( $obsolete_customer_create_cap ); |
| 518 | 561 | } |
| 519 | 562 | |
| 520 | - // Sync all capabilities to the existing role. add_role() is a no-op when | |
| 563 | + // Sync the requested capabilities to the role. add_role() is a no-op when | |
| 521 | 564 | // the role already exists, so capabilities added in newer versions would |
| 522 | 565 | // never reach existing installs without this. |
| 523 | 566 | $this->add_pos_capability( |
| 524 | 567 | array( |
| 525 | - 'cashier' => array_merge( | |
| 568 | + 'cashier' => $capabilities ?? array_merge( | |
| 526 | 569 | array( 'access_woocommerce_pos' ), |
| 527 | 570 | array_keys( $cashier_capabilities ) |
| 528 | 571 | ), |
| 529 | 572 | ) |
| @@ -574,8 +617,94 @@ | ||
| 574 | 617 | |
| 575 | 618 | if ( Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ) ) { |
| 576 | 619 | $this->install_sync_schema(); |
| 577 | 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' ); | |
| 578 | 707 | } |
| 579 | 708 | |
| 580 | 709 | /** |
| 581 | 710 | * If \WCPOS\WooCommercePOSPro\ is installed, check the version is above MIN_PRO_VERSION. |