| @@ -102,8 +102,20 @@ | ||
| 102 | 102 | */ |
| 103 | 103 | public function single_activate( bool $install_sync_schema = true ): void { |
| 104 | 104 | $role_capabilities = self::role_capability_definition(); |
| 105 | 105 | |
| 106 | + // Reseed the default template terms on the next request: (re)activation | |
| 107 | + // is the repair a merchant reaches for after deleting a term by hand. | |
| 108 | + // This also runs once per upgrade (version_check re-activates to sync | |
| 109 | + // role caps), so one post-upgrade request pays the ~18 seeding queries. | |
| 110 | + delete_option( Templates::DEFAULT_TERMS_OPTION ); | |
| 111 | + | |
| 112 | + // Second, merchant-reachable trigger for the autoload repair: db_upgrade() | |
| 113 | + // only runs when version_check() trips on an admin load that reaches | |
| 114 | + // woocommerce_init, and a miss there is permanent once bump_versions() ran. | |
| 115 | + self::autoload_request_latches(); | |
| 116 | + Admin\Permalink::ensure_default(); | |
| 117 | + | |
| 106 | 118 | // create POS specific roles. |
| 107 | 119 | $this->create_pos_roles(); |
| 108 | 120 | |
| 109 | 121 | // add pos capabilities to non POS roles. |
| @@ -193,9 +205,12 @@ | ||
| 193 | 205 | ) { |
| 194 | 206 | return; |
| 195 | 207 | } |
| 196 | 208 | |
| 197 | - update_option( Sync_Api::SCHEMA_OPTION, Sync_Api::SCHEMA_VERSION, false ); | |
| 209 | + // Autoloaded: the Init constructor reads this latch on every request. | |
| 210 | + // This flips an existing row only on WP 6.4+; older rows are flipped by | |
| 211 | + // autoload_request_latches() on upgrade. | |
| 212 | + update_option( Sync_Api::SCHEMA_OPTION, Sync_Api::SCHEMA_VERSION, true ); | |
| 198 | 213 | |
| 199 | 214 | if ( null !== $previous_schema && version_compare( (string) $previous_schema, Sync_Api::SCHEMA_VERSION, '<' ) ) { |
| 200 | 215 | global $wpdb; |
| 201 | 216 | $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wcpos_sync_change_log" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Known legacy table name. |
| @@ -574,8 +589,94 @@ | ||
| 574 | 589 | |
| 575 | 590 | if ( Sync_Api::SCHEMA_VERSION !== get_option( Sync_Api::SCHEMA_OPTION, null ) ) { |
| 576 | 591 | $this->install_sync_schema(); |
| 577 | 592 | } |
| 593 | + | |
| 594 | + // Installs that predate 2026-09 wrote the per-request latches with | |
| 595 | + // autoload off; every upgrade re-asserts autoload so the flip is | |
| 596 | + // idempotent and needs no versioned update file. | |
| 597 | + self::autoload_request_latches(); | |
| 598 | + Admin\Permalink::ensure_default(); | |
| 599 | + } | |
| 600 | + | |
| 601 | + /** | |
| 602 | + * Every option row that is read on EVERY request and must therefore ride in | |
| 603 | + * alloptions: the three sync latches the Init constructor reads, the permalink | |
| 604 | + * slug Template_Router reads, and each registered settings section that | |
| 605 | + * declares {@see Services\Settings\Abstract_Section::autoload()} — the | |
| 606 | + * sections are the extension point, so Pro's and extensions' sections join | |
| 607 | + * the repair by declaring it, without touching this file. | |
| 608 | + * | |
| 609 | + * Needed because core's update_option() returns early on an unchanged value | |
| 610 | + * WITHOUT touching the autoload column, so a writer alone never repairs a row | |
| 611 | + * an older release wrote with autoload off. Without a persistent object cache | |
| 612 | + * each such row cost one `SELECT option_value` per page load (measured | |
| 613 | + * 2026-09-03 on dev-next and dev-free). | |
| 614 | + * | |
| 615 | + * @return string[] | |
| 616 | + */ | |
| 617 | + private static function request_option_names(): array { | |
| 618 | + $names = array( | |
| 619 | + Sync_Api::SCHEMA_OPTION, | |
| 620 | + \WCPOS\WooCommercePOS\Sync\Visibility_Observer::SEED_VERSION_OPTION, | |
| 621 | + \WCPOS\WooCommercePOS\Sync\Config_Fingerprint::CLEANUP_VERSION_OPTION, | |
| 622 | + Admin\Permalink::DB_KEY, | |
| 623 | + ); | |
| 624 | + foreach ( Services\Settings::instance()->sections()->all() as $section ) { | |
| 625 | + if ( $section instanceof Services\Settings\Abstract_Section && $section->autoload() ) { | |
| 626 | + $names[] = $section->autoload_option_name(); | |
| 627 | + } | |
| 628 | + } | |
| 629 | + return $names; | |
| 630 | + } | |
| 631 | + | |
| 632 | + /** | |
| 633 | + * Flip the per-request rows to autoload in place, and seed the settings | |
| 634 | + * sections that are absent. | |
| 635 | + * | |
| 636 | + * One UPDATE on the flag column: never delete-and-recreate, because | |
| 637 | + * `Sync_Api::SCHEMA_OPTION` gates the sync observers on every request and a | |
| 638 | + * request landing in that gap would run with journaling off. 'yes' is | |
| 639 | + * accepted by every core version (6.6+ maps it alongside 'on'). Idempotent: | |
| 640 | + * already-autoloaded rows match nothing. Latches that were never written | |
| 641 | + * stay absent (their absence is the signal). An autoloaded settings section | |
| 642 | + * that was never saved is seeded as an autoloaded row — an absent option is | |
| 643 | + * queried on every request too. General also persists its migrated consent | |
| 644 | + * so the legacy row does not remain on the read path; other defaults stay | |
| 645 | + * dynamic. | |
| 646 | + */ | |
| 647 | + public static function autoload_request_latches(): void { | |
| 648 | + global $wpdb; | |
| 649 | + // The key comes from the section itself (autoload_option_name()): Pro's | |
| 650 | + // License section stores under a Pro-prefixed key, so deriving it from | |
| 651 | + // id() flipped nothing for that row and seeded a stray free-prefixed one. | |
| 652 | + foreach ( Services\Settings::instance()->sections()->all() as $section ) { | |
| 653 | + if ( ! $section instanceof Services\Settings\Abstract_Section || ! $section->autoload() ) { | |
| 654 | + continue; | |
| 655 | + } | |
| 656 | + $option_name = $section->autoload_option_name(); | |
| 657 | + if ( false === get_option( $option_name ) ) { | |
| 658 | + $value = $section instanceof Services\Settings\General_Section | |
| 659 | + ? array( 'tracking_consent' => $section->raw_tracking_consent() ) | |
| 660 | + : array(); | |
| 661 | + add_option( $option_name, $value, '', true ); | |
| 662 | + } | |
| 663 | + } | |
| 664 | + $options = self::request_option_names(); | |
| 665 | + $placeholders = implode( ', ', array_fill( 0, \count( $options ), '%s' ) ); | |
| 666 | + $flipped = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the flag column is the target; caches are cleared below. | |
| 667 | + $wpdb->prepare( | |
| 668 | + "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. | |
| 669 | + $options | |
| 670 | + ) | |
| 671 | + ); | |
| 672 | + if ( ! $flipped ) { | |
| 673 | + return; | |
| 674 | + } | |
| 675 | + foreach ( $options as $option ) { | |
| 676 | + wp_cache_delete( $option, 'options' ); | |
| 677 | + } | |
| 678 | + wp_cache_delete( 'alloptions', 'options' ); | |
| 578 | 679 | } |
| 579 | 680 | |
| 580 | 681 | /** |
| 581 | 682 | * If \WCPOS\WooCommercePOSPro\ is installed, check the version is above MIN_PRO_VERSION. |