| @@ -21,197 +21,269 @@ | ||
| 21 | 21 | * Init class. |
| 22 | 22 | */ |
| 23 | 23 | class Init { |
| 24 | 24 | /** |
| 25 | - * Constructor — the plugin's entire `plugins_loaded` hook wiring. | |
| 25 | + * Observer awaiting the constructor's non-hook seed step. | |
| 26 | 26 | * |
| 27 | - * Reached from {@see Activator::init()}, which runs on `plugins_loaded` at the | |
| 28 | - * default priority 10. Everything that must exist before `init` fires — most | |
| 29 | - * importantly the `determine_current_user` pair — has to be registered here. | |
| 27 | + * @var Sync\Visibility_Observer|null | |
| 28 | + */ | |
| 29 | + private $visibility_observer; | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * Install the ordered wiring declared by {@see hook_rows()}. | |
| 30 | 33 | * |
| 31 | - * NOT PURE WIRING. Constructing this class also, in statement order: | |
| 32 | - * `require_once`s `wcpos-functions.php` and `wcpos-store-functions.php`; | |
| 33 | - * registers the `wc_pos_user_uuid_locks` global cache group; READS the sync | |
| 34 | - * schema-latch option; WRITES options through | |
| 35 | - * `Config_Fingerprint::maybe_cleanup_legacy_options()` (one-time, latched on | |
| 36 | - * its own version option); and SCHEDULES a daily cron event through | |
| 37 | - * `Sync_Journal_Purge::register_hooks()`. Anything that constructs `Init` — | |
| 38 | - * a test included — inherits all of that. | |
| 39 | - * | |
| 40 | - * ## How to read the ordering table | |
| 41 | - * | |
| 42 | - * A priority number decides ordering on its own, wherever in this method it | |
| 43 | - * happens to be written. Statement order is load-bearing ONLY when two | |
| 44 | - * callbacks share a hook AND a priority: WordPress then runs them in | |
| 45 | - * registration order. Exactly one such pair exists here, and it is marked | |
| 46 | - * ORDER-CRITICAL (STATEMENT ORDER) below — do not move it. | |
| 47 | - * | |
| 48 | - * Priorities that live in the callee (`Meta_Normalizer` at 5, `Revision` at 9, | |
| 49 | - * the proxy stampers at 10) are listed at the value they actually register, | |
| 50 | - * not at the position of the call in this method. Reordering those statements | |
| 51 | - * changes nothing; changing those numbers changes everything. | |
| 52 | - * | |
| 53 | - * "Why" is recovered from `git log -S` / `git blame` where a reason was | |
| 54 | - * recorded. Where none was, the entry says **unknown** rather than guessing. | |
| 55 | - * | |
| 56 | - * ## Ordering table | |
| 57 | - * | |
| 58 | - * | # | Hook | Callback | Pri | Order | Why that priority | | |
| 59 | - * |---|------|----------|-----|-------|-------------------| | |
| 60 | - * | 1 | `activated_plugin`, `upgrader_process_complete`, `admin_enqueue_scripts`, `admin_notices`, `rest_api_init` | `Admin\Consent` (5 callbacks) | 10 | irrelevant | Default. What matters is that `Consent` is built during `plugins_loaded`, so its lifecycle hooks exist before an activation/update request fires them. | | |
| 61 | - * | 2 | `woocommerce_pos_rest_api_controllers` | `Sync\Api::register_controllers` | 10 | irrelevant | Default; sole callback. | | |
| 62 | - * | 3 | `wcpos_integrity_digest_rebuild` | `Sync\Integrity_Digest::run_scheduled_rebuild` | 10 | irrelevant | Default; sole callback. Registered OUTSIDE the schema latch, so an already-scheduled rebuild still has a callback while the latch is down. | | |
| 63 | - * | 4 | `woocommerce_pos_sync_proxy_response`, `..._serialized_product`, `..._serialized_order` | `Sync\Meta_Normalizer::normalize` | **5** | **ORDER-CRITICAL** | Must precede `Revision` at 9 so the stamped revision bytes equal what the write path recomputes from a bare `wc/v3` re-read. See `Sync\Augmentation_Pipeline` class docblock and `Sync\Meta_Normalizer::register_hooks()`. Kept out of the pipeline because it also serves the ORDER lane. | | |
| 64 | - * | 5 | `woocommerce_pos_sync_serialized_order` | `Sync\Pos_Uuid::stamp_serialized_record` | 10 | order-critical (by number) | After `Meta_Normalizer` at 5, in step with the product lane's stampers. | | |
| 65 | - * | 6 | `woocommerce_pos_sync_order_pull_payloads` | `Sync\Integrity_Digest::stamp_proxy_order_digests` | 10 | irrelevant | Default; sole callback on that filter. | | |
| 66 | - * | 7 | `woocommerce_pos_sync_proxy_response` | `Sync\Revision::stamp_proxy_revisions` (via `Augmentation_Pipeline::install()`) | **9** | **ORDER-CRITICAL** | Between `Meta_Normalizer` (5) and the uuid/digest stampers (10). Revision must hash the normalized-but-not-yet-augmented payload. | | |
| 67 | - * | 8 | `woocommerce_pos_sync_proxy_response`, `..._serialized_product` | `Proxy_Uuid_Stamper`, `Integrity_Digest` digest stampers, pipeline projections | 10 | order-critical (by number) | Preserved verbatim from the hand-wiring the pipeline replaced, so third-party code hooking either public filter still runs where it always did. | | |
| 68 | - * | 9 | `woocommerce_before_product_object_save`, `woocommerce_before_product_variation_object_save` | `Sync\Pos_Uuid::stamp_on_save` | 10 | irrelevant | Default. The HOOK is the design (before the data store writes, so the uuid lands in the same save); the number is not. Registered unconditionally — identity is core, not an observer. | | |
| 69 | - * | 10 | 32 catalogue/customer/order hooks | `Sync\Sync_Journal` (33 callbacks) | 10 | irrelevant | Default throughout. | | |
| 70 | - * | 10b | `delete_option` plus `pre_update_option_*`, `update_option_*`, `add_option_*`, `delete_option_*` for the two `Pos_Visibility::source_options()` | `Sync\Visibility_Observer` (9 callbacks) | 10 | irrelevant | Default. Appends the journal row for a record entering or leaving the POS servable set — the transition the sequence-log stream relies on, since it drops a hidden record's update rows. `delete_option` is the generic PRE-delete action (the per-option form fires after) and is gated on the option name inside the callback. Registered after `Sync_Journal` only because it writes through it; the constructor also runs the observer's one-time tombstone seed. | | |
| 71 | - * | 11 | `wcpos_sync_journal_purge` | `Sync\Sync_Journal_Purge::run_purge` | 10 | irrelevant | Cron callback; sole listener. This call also SCHEDULES the daily event. | | |
| 72 | - * | 12 | 21 catalogue/customer/order hooks (a subset of row 10's) | `Sync\Integrity_Digest` | 10 | unknown | Default. Shares every one of its hooks with `Sync_Journal` at the same priority, so the journal always runs first — no code found that depends on that, but nothing pins it either. | | |
| 73 | - * | 13 | `init` | `Init::init` | 10 | **ORDER-CRITICAL, CROSS-PLUGIN** | Default. **Pro registers its own `init` at 20** (`woocommerce-pos-pro/includes/Init.php:32`) so free's services exist first. Raising free's number silently breaks Pro; nothing on either side tests it. | | |
| 74 | - * | 14 | `rest_api_init` | `Init::init_rest_api` | **20** | **ORDER-CRITICAL, CROSS-PLUGIN** | Free's own reason: unknown — the number dates to the initial commit (8f2b9eac, 2021-03-16). It is load-bearing anyway: **Pro registers `rest_api_init` at 9**, commented "Before the free version" (`woocommerce-pos-pro/includes/Init.php:33`). Untested on both sides. | | |
| 75 | - * | 15 | `query_vars` | `Init::query_vars` | 10 | irrelevant | Default; appends one var. | | |
| 76 | - * | 16 | `pre_update_option_woocommerce_pos_pro_settings_license` | `Init::remove_license_transient` | 10 | irrelevant | Default. The reentrancy guard, not the priority, is what makes it safe (f33b8d655). | | |
| 77 | - * | 17 | ~~`rest_pre_serve_request`~~ | *(removed)* | — | — | Init no longer publishes any part of the REST wire contract. This registration and its handler moved to `Rest_Cors::register_hooks()`, which registers at **20** — after core's `rest_send_cors_headers` at 10 — so WCPOS is the last writer on the lanes it owns. The old `5` had no recorded reason; the new number does. | | |
| 78 | - * | 18 | `send_headers` | `Init::send_headers` | 99 | unknown | Introduced by 62da70551 ("fix WPSEO integration"). The commit records no reason for the number beyond running late. | | |
| 79 | - * | 19 | `send_headers` | `Init::remove_x_frame_options` | **9999** | **ORDER-CRITICAL** | Must run AFTER security plugins have set `X-Frame-Options`, because it works by `header_remove()` (80ee545a5). A smaller number lets the plugin set the header again afterwards. | | |
| 80 | - * | 20 | `determine_current_user` | `Services\Core_Order_Audit_Guard::record_prior_authentication` | **20** | **ORDER-CRITICAL (STATEMENT ORDER)** | See below. | | |
| 81 | - * | 21 | `rest_pre_dispatch` | `Services\Core_Order_Audit_Guard::rest_pre_dispatch` | 10 | irrelevant | Default; reads what row 20 recorded. | | |
| 82 | - * | 22 | `woocommerce_update_coupon` | `Sync\Coupon_Modified_Date::touch` | 10 | irrelevant | Default. `Sync_Journal::record_coupon_updated` shares the hook and priority (row 10) and is registered first, but the journal timestamps rows with the wall clock, not the coupon's `post_modified`, so neither ordering changes an outcome. | | |
| 83 | - * | 23 | `determine_current_user` | `Init::determine_current_user_early` | **20** | **ORDER-CRITICAL (STATEMENT ORDER)** | See below. | | |
| 84 | - * | 24 | `admin_init` | `Services\Lifecycle_Events::flush_pending`, `::maybe_schedule_refresh` | 10 | irrelevant | Default. `admin_init` because both need a fully booted admin request: one sends install/upgrade events recorded before the plugin was loaded enough to send them, the other schedules row 25. Both check consent first and cost nothing on a site that opted out. | | |
| 85 | - * | 25 | `wcpos_analytics_group_refresh` | `Services\Lifecycle_Events::refresh_group_properties` | 10 | irrelevant | Default; sole listener. Unlike row 11, this call does NOT schedule the event — scheduling lives in row 24 so that withdrawing consent unschedules it. | | |
| 86 | - * | |
| 87 | - * ## The one pair where statement order is the whole mechanism | |
| 88 | - * | |
| 89 | - * Rows 20 and 23 share `determine_current_user` AND priority 20, so insertion | |
| 90 | - * order — and nothing else — decides which runs first. 20 puts both after | |
| 91 | - * WordPress core's own handlers, which `default-filters.php` registers before | |
| 92 | - * any plugin loads: `wp_validate_auth_cookie` at 10, then | |
| 93 | - * `wp_validate_logged_in_cookie` and `wp_validate_application_password`, both | |
| 94 | - * at 20 and therefore both ahead of these two. | |
| 95 | - * | |
| 96 | - * The guard must run FIRST. It records into `pre_wcpos_user_id` whichever user | |
| 97 | - * some EARLIER filter had already authenticated; a non-zero value means the | |
| 98 | - * request proved itself with a cookie or application password, so | |
| 99 | - * `Core_Order_Audit_Guard::is_wcpos_jwt_authenticated()` returns false and the | |
| 100 | - * request keeps its normal power over order meta. | |
| 101 | - * | |
| 102 | - * Swap the two statements and the guard records the user WCPOS's own JWT filter | |
| 103 | - * just authenticated. `pre_wcpos_user_id` is then non-zero on every | |
| 104 | - * token-authenticated request, `is_wcpos_jwt_authenticated()` returns false for | |
| 105 | - * all of them, and forged `_pos_*` audit meta on `/wc/v3/orders` is accepted. | |
| 106 | - * It fails OPEN, silently, on a route no smoke test touches. | |
| 107 | - * | |
| 108 | - * Pinned by `tests/includes/Test_Init_Hook_Wiring.php`, which asserts the two | |
| 109 | - * callbacks' ARRAY POSITIONS inside `callbacks[20]` — asserting priorities | |
| 110 | - * would pass on the broken order. | |
| 34 | + * Non-hook setup stays here, interleaved at its original registration boundaries. | |
| 111 | 35 | */ |
| 112 | 36 | public function __construct() { |
| 113 | - // global helper functions. | |
| 114 | 37 | require_once PLUGIN_PATH . 'includes/wcpos-functions.php'; |
| 115 | 38 | require_once PLUGIN_PATH . 'includes/wcpos-store-functions.php'; |
| 116 | 39 | wp_cache_add_global_groups( 'wc_pos_user_uuid_locks' ); |
| 117 | 40 | |
| 118 | - // Tracking consent pop-up + callout. Registered here (during | |
| 119 | - // plugins_loaded) so its lifecycle hooks (activated_plugin, | |
| 120 | - // upgrader_process_complete) are in place before those actions | |
| 121 | - // fire on a plugin activation or update request. | |
| 122 | - new Consent(); | |
| 123 | - add_filter( 'woocommerce_pos_rest_api_controllers', array( \WCPOS\WooCommercePOS\Sync\Api::class, 'register_controllers' ) ); | |
| 124 | - add_action( \WCPOS\WooCommercePOS\Sync\Integrity_Digest::REBUILD_HOOK, array( \WCPOS\WooCommercePOS\Sync\Integrity_Digest::class, 'run_scheduled_rebuild' ) ); | |
| 125 | - // Gate on the schema latch, not a live Health probe: the latch is only | |
| 126 | - // set AFTER install verified every table (latch-after-verify), so a | |
| 127 | - // per-request SHOW TABLES sweep buys nothing — and a table lost after | |
| 128 | - // latching is already survivable (observer writes fail open and the | |
| 129 | - // REST health gate 503s the sync endpoints). | |
| 130 | - $sync_schema_latched = \WCPOS\WooCommercePOS\Sync\Api::SCHEMA_VERSION === get_option( \WCPOS\WooCommercePOS\Sync\Api::SCHEMA_OPTION, null ); | |
| 131 | - if ( $sync_schema_latched ) { | |
| 132 | - // Normalize structured meta at priority 5, before revision stamps at 9 | |
| 133 | - // and UUID, digest, and variable-price stamps at priority 10. Kept out | |
| 134 | - // of the augmentation pipeline because it also serves the order lane. | |
| 135 | - \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::register_hooks(); | |
| 136 | - add_filter( 'woocommerce_pos_sync_serialized_order', array( \WCPOS\WooCommercePOS\Sync\Pos_Uuid::class, 'stamp_serialized_record' ), 10, 3 ); | |
| 137 | - // ONE seam for both product read lanes: the batch catalog proxy and the | |
| 138 | - // per-object serializer. Every stamper is declared once inside; both | |
| 139 | - // public filter names stay live as projections of it. The order pull | |
| 140 | - // lane's digest stamper is wired there too — it was hand-added here, | |
| 141 | - // under this same latch, which made the pipeline's single-wiring-site | |
| 142 | - // claim untrue. | |
| 143 | - \WCPOS\WooCommercePOS\Sync\Augmentation_Pipeline::install(); | |
| 41 | + $rows = $this->hook_rows( true ); | |
| 42 | + Hook_Manifest::validate( $rows ); | |
| 43 | + Hook_Manifest::install( wp_list_filter( $rows, array( 'phase' => 'pre-latch' ) ) ); | |
| 44 | + $sync_latched = Sync\Api::SCHEMA_VERSION === get_option( Sync\Api::SCHEMA_OPTION, null ); | |
| 45 | + foreach ( $rows as $row ) { | |
| 46 | + if ( 'pre-latch' === $row['phase'] || ( 'sync-latched' === $row['phase'] && ! $sync_latched ) ) { | |
| 47 | + continue; | |
| 48 | + } | |
| 49 | + if ( array( $this, 'init' ) === $row['callback'] ) { | |
| 50 | + ( new Sync\Config_Fingerprint() )->maybe_cleanup_legacy_options(); | |
| 51 | + } | |
| 52 | + Hook_Manifest::install( array( $row ) ); | |
| 53 | + if ( null !== $this->visibility_observer ) { | |
| 54 | + $this->visibility_observer->maybe_seed_hidden_tombstones(); | |
| 55 | + $this->visibility_observer = null; | |
| 56 | + } | |
| 144 | 57 | } |
| 58 | + } | |
| 145 | 59 | |
| 146 | - // Identity is core, not an observer benchmark variable: every product is | |
| 147 | - // born with a UUID even before the schema latch is healthy. The before-save | |
| 148 | - // hook writes it in the same save. | |
| 149 | - \WCPOS\WooCommercePOS\Sync\Pos_Uuid::register_hooks(); | |
| 60 | + /** | |
| 61 | + * Declare bootstrap wiring in registration order, without installing it. | |
| 62 | + * | |
| 63 | + * Null hooks invoke registrars immediately; their internal priorities/arity stay | |
| 64 | + * in register_hooks(). Phases keep the schema read after pre-latch hooks; | |
| 65 | + * sync-latched rows are post-read hooks omitted when the latch is down. | |
| 66 | + * The guard registrar MUST precede the JWT row: both register at priority 20, | |
| 67 | + * after core cookie/application-password handlers. Reversing them attributes | |
| 68 | + * JWT identity to prior authentication and fails open on /wc/v3/orders. | |
| 69 | + * | |
| 70 | + * @param bool $sync_latched Whether the verified sync schema latch is set. | |
| 71 | + * @return array Ordered rows consumed by Hook_Manifest::install(). | |
| 72 | + */ | |
| 73 | + public function hook_rows( bool $sync_latched ): array { | |
| 74 | + $rows = array( | |
| 75 | + array( | |
| 76 | + 'hook' => null, | |
| 77 | + 'callback' => static function (): void { | |
| 78 | + new Consent(); | |
| 79 | + }, | |
| 80 | + 'priority' => 10, | |
| 81 | + 'args' => 0, | |
| 82 | + 'reason' => 'Default 10; lifecycle hooks must exist during plugins_loaded, before activation/update actions.', | |
| 83 | + 'phase' => 'pre-latch', | |
| 84 | + ), | |
| 85 | + array( | |
| 86 | + 'hook' => 'woocommerce_pos_rest_api_controllers', | |
| 87 | + 'callback' => array( Sync\Api::class, 'register_controllers' ), | |
| 88 | + 'priority' => 10, | |
| 89 | + 'args' => 1, | |
| 90 | + 'reason' => 'Default; sole callback. Response registrars stay inside this filter to retain REST activation timing.', | |
| 91 | + 'phase' => 'pre-latch', | |
| 92 | + ), | |
| 93 | + array( | |
| 94 | + 'hook' => Sync\Integrity_Digest::REBUILD_HOOK, | |
| 95 | + 'callback' => array( Sync\Integrity_Digest::class, 'run_scheduled_rebuild' ), | |
| 96 | + 'priority' => 10, | |
| 97 | + 'args' => 1, | |
| 98 | + 'reason' => 'Default; sole callback. Unlatched so an already-scheduled rebuild still has a listener.', | |
| 99 | + 'phase' => 'pre-latch', | |
| 100 | + ), | |
| 101 | + array( | |
| 102 | + 'hook' => null, | |
| 103 | + 'callback' => array( Sync\Meta_Normalizer::class, 'register_hooks' ), | |
| 104 | + 'priority' => 10, | |
| 105 | + 'args' => 0, | |
| 106 | + 'reason' => 'Priority 5 before revision 9 and augmentation 10, so revisions match bare wc/v3 rereads; also serves orders.', | |
| 107 | + 'phase' => 'sync-latched', | |
| 108 | + ), | |
| 109 | + array( | |
| 110 | + 'hook' => 'woocommerce_pos_sync_serialized_order', | |
| 111 | + 'callback' => array( Sync\Pos_Uuid::class, 'stamp_serialized_record' ), | |
| 112 | + 'priority' => 10, | |
| 113 | + 'args' => 3, | |
| 114 | + 'reason' => 'After normalization at 5, in step with product stampers at 10.', | |
| 115 | + 'phase' => 'sync-latched', | |
| 116 | + ), | |
| 117 | + array( | |
| 118 | + 'hook' => null, | |
| 119 | + 'callback' => array( Sync\Augmentation_Pipeline::class, 'install' ), | |
| 120 | + 'priority' => 10, | |
| 121 | + 'args' => 0, | |
| 122 | + 'reason' => 'Revision 9 hashes normalized, unaugmented bytes; UUID/digest/projections at 10 preserve extension order, including order-pull digests.', | |
| 123 | + 'phase' => 'sync-latched', | |
| 124 | + ), | |
| 125 | + array( | |
| 126 | + 'hook' => null, | |
| 127 | + 'callback' => array( Sync\Pos_Uuid::class, 'register_hooks' ), | |
| 128 | + 'priority' => 10, | |
| 129 | + 'args' => 0, | |
| 130 | + 'reason' => 'Default 10; identity is unconditional: before-save UUIDs land in the same write and native restores re-prove ownership (ADR 0038).', | |
| 131 | + 'phase' => 'post-latch', | |
| 132 | + ), | |
| 133 | + array( | |
| 134 | + 'hook' => null, | |
| 135 | + 'callback' => static function (): void { | |
| 136 | + ( new Sync\Sync_Journal() )->register_hooks(); | |
| 137 | + }, | |
| 138 | + 'priority' => 10, | |
| 139 | + 'args' => 0, | |
| 140 | + 'reason' => 'Default 10; dirty order updates coalesce until shutdown at PHP_INT_MAX, after WooCommerce customer 10/session 20 saves.', | |
| 141 | + 'phase' => 'sync-latched', | |
| 142 | + ), | |
| 143 | + array( | |
| 144 | + 'hook' => null, | |
| 145 | + 'callback' => function (): void { | |
| 146 | + $this->visibility_observer = new Sync\Visibility_Observer(); | |
| 147 | + $this->visibility_observer->register_hooks(); | |
| 148 | + }, | |
| 149 | + 'priority' => 10, | |
| 150 | + 'args' => 0, | |
| 151 | + 'reason' => 'Default 10 after journal; records servable-set transitions, using generic pre-delete_option; Init then seeds tombstones.', | |
| 152 | + 'phase' => 'sync-latched', | |
| 153 | + ), | |
| 154 | + array( | |
| 155 | + 'hook' => null, | |
| 156 | + 'callback' => static function (): void { | |
| 157 | + ( new Sync\Sync_Journal_Purge() )->register_hooks(); | |
| 158 | + }, | |
| 159 | + 'priority' => 10, | |
| 160 | + 'args' => 0, | |
| 161 | + 'reason' => 'Default 10; sole cron listener; the registrar also schedules the daily purge.', | |
| 162 | + 'phase' => 'sync-latched', | |
| 163 | + ), | |
| 164 | + array( | |
| 165 | + 'hook' => null, | |
| 166 | + 'callback' => static function (): void { | |
| 167 | + ( new Sync\Integrity_Digest() )->register_hooks(); | |
| 168 | + }, | |
| 169 | + 'priority' => 10, | |
| 170 | + 'args' => 0, | |
| 171 | + 'reason' => 'Default 10, shutdown PHP_INT_MAX; journal registers first on shared hooks (reason unknown); dirty digests coalesce until flush.', | |
| 172 | + 'phase' => 'sync-latched', | |
| 173 | + ), | |
| 174 | + array( | |
| 175 | + 'hook' => 'init', | |
| 176 | + 'callback' => array( $this, 'init' ), | |
| 177 | + 'priority' => 10, | |
| 178 | + 'args' => 1, | |
| 179 | + 'reason' => 'Default 10; free services must exist before Pro init at 20.', | |
| 180 | + 'phase' => 'post-latch', | |
| 181 | + ), | |
| 182 | + array( | |
| 183 | + 'hook' => 'rest_api_init', | |
| 184 | + 'callback' => array( $this, 'init_rest_api' ), | |
| 185 | + 'priority' => 20, | |
| 186 | + 'args' => 1, | |
| 187 | + 'reason' => 'Original reason unknown (8f2b9eac); Pro deliberately registers before free at 9.', | |
| 188 | + 'phase' => 'post-latch', | |
| 189 | + ), | |
| 190 | + array( | |
| 191 | + 'hook' => 'query_vars', | |
| 192 | + 'callback' => array( $this, 'query_vars' ), | |
| 193 | + 'priority' => 10, | |
| 194 | + 'args' => 1, | |
| 195 | + 'reason' => 'Default; appends one variable.', | |
| 196 | + 'phase' => 'post-latch', | |
| 197 | + ), | |
| 198 | + array( | |
| 199 | + 'hook' => 'pre_update_option_woocommerce_pos_pro_settings_license', | |
| 200 | + 'callback' => array( self::class, 'remove_license_transient' ), | |
| 201 | + 'priority' => 10, | |
| 202 | + 'args' => 2, | |
| 203 | + 'reason' => 'Default; the reentrancy guard, not priority, makes legacy Pro license cache invalidation safe (f33b8d655).', | |
| 204 | + 'phase' => 'post-latch', | |
| 205 | + ), | |
| 206 | + array( | |
| 207 | + 'hook' => null, | |
| 208 | + 'callback' => array( Rest_Cors::class, 'register_hooks' ), | |
| 209 | + 'priority' => 10, | |
| 210 | + 'args' => 0, | |
| 211 | + 'reason' => 'Unconditional for unmarked preflights/relay; serve at 20 after core CORS at 10 so WCPOS is the last writer.', | |
| 212 | + 'phase' => 'post-latch', | |
| 213 | + ), | |
| 214 | + array( | |
| 215 | + 'hook' => 'send_headers', | |
| 216 | + 'callback' => array( $this, 'send_headers' ), | |
| 217 | + 'priority' => 99, | |
| 218 | + 'args' => 1, | |
| 219 | + 'reason' => 'Unknown beyond running late for WPSEO integration (62da70551).', | |
| 220 | + 'phase' => 'post-latch', | |
| 221 | + ), | |
| 222 | + array( | |
| 223 | + 'hook' => 'send_headers', | |
| 224 | + 'callback' => array( $this, 'remove_x_frame_options' ), | |
| 225 | + 'priority' => 9999, | |
| 226 | + 'args' => 1, | |
| 227 | + 'reason' => 'Must remove X-Frame-Options AFTER security plugins set it (80ee545a5).', | |
| 228 | + 'phase' => 'post-latch', | |
| 229 | + ), | |
| 230 | + array( | |
| 231 | + 'hook' => null, | |
| 232 | + 'callback' => static function (): void { | |
| 233 | + ( new Services\Core_Order_Audit_Guard() )->register_hooks(); | |
| 234 | + }, | |
| 235 | + 'priority' => 10, | |
| 236 | + 'args' => 0, | |
| 237 | + 'reason' => 'Auth provenance at 20 MUST register before JWT at 20, after core cookie/password auth; rest_pre_dispatch reads it at 10.', | |
| 238 | + 'phase' => 'post-latch', | |
| 239 | + ), | |
| 240 | + array( | |
| 241 | + 'hook' => null, | |
| 242 | + 'callback' => array( Sync\Coupon_Modified_Date::class, 'register_hooks' ), | |
| 243 | + 'priority' => 10, | |
| 244 | + 'args' => 0, | |
| 245 | + 'reason' => 'Default 10; unconditional meta-only coupon edit timestamps for date-based replication; journal uses wall clock, not post_modified.', | |
| 246 | + 'phase' => 'post-latch', | |
| 247 | + ), | |
| 248 | + array( | |
| 249 | + 'hook' => 'determine_current_user', | |
| 250 | + 'callback' => array( $this, 'determine_current_user_early' ), | |
| 251 | + 'priority' => 20, | |
| 252 | + 'args' => 1, | |
| 253 | + 'reason' => 'At 20 AFTER the audit guard and core cookie/password handlers; register before init, regardless of the request marker.', | |
| 254 | + 'phase' => 'post-latch', | |
| 255 | + ), | |
| 256 | + array( | |
| 257 | + 'hook' => null, | |
| 258 | + 'callback' => static function (): void { | |
| 259 | + ( new Services\Lifecycle_Events() )->register_hooks(); | |
| 260 | + }, | |
| 261 | + 'priority' => 10, | |
| 262 | + 'args' => 0, | |
| 263 | + 'reason' => 'Default 10; append after auth pair; admin_init flushes pending events and consent-gates refresh scheduling, not the cron listener.', | |
| 264 | + 'phase' => 'post-latch', | |
| 265 | + ), | |
| 266 | + array( | |
| 267 | + 'hook' => null, | |
| 268 | + 'callback' => static function (): void { | |
| 269 | + Services\Error_Reporter::instance()->register_hooks(); | |
| 270 | + }, | |
| 271 | + 'priority' => 10, | |
| 272 | + 'args' => 0, | |
| 273 | + 'reason' => 'Append after lifecycle; REST priority 999 reports the final response status, gated by consent (#1811).', | |
| 274 | + 'phase' => 'post-latch', | |
| 275 | + ), | |
| 276 | + ); | |
| 150 | 277 | |
| 151 | - if ( $sync_schema_latched ) { | |
| 152 | - ( new \WCPOS\WooCommercePOS\Sync\Sync_Journal() )->register_hooks(); | |
| 153 | - $visibility_observer = new \WCPOS\WooCommercePOS\Sync\Visibility_Observer(); | |
| 154 | - $visibility_observer->register_hooks(); | |
| 155 | - $visibility_observer->maybe_seed_hidden_tombstones(); | |
| 156 | - ( new \WCPOS\WooCommercePOS\Sync\Sync_Journal_Purge() )->register_hooks(); | |
| 157 | - ( new \WCPOS\WooCommercePOS\Sync\Integrity_Digest() )->register_hooks(); | |
| 158 | - } | |
| 159 | - | |
| 160 | - ( new \WCPOS\WooCommercePOS\Sync\Config_Fingerprint() )->maybe_cleanup_legacy_options(); | |
| 161 | - | |
| 162 | - // Init hooks. | |
| 163 | - add_action( 'init', array( $this, 'init' ) ); | |
| 164 | - add_action( 'rest_api_init', array( $this, 'init_rest_api' ), 20 ); | |
| 165 | - add_filter( 'query_vars', array( $this, 'query_vars' ) ); | |
| 166 | - | |
| 167 | - // Remove this once Pro settings have been moved to the new settings service. | |
| 168 | - add_filter( 'pre_update_option_woocommerce_pos_pro_settings_license', array( self::class, 'remove_license_transient' ), 10, 2 ); | |
| 169 | - | |
| 170 | - // The REST wire contract — CORS and shared-cache defeat — has a single | |
| 171 | - // owner. Registered unconditionally, from here rather than from the | |
| 172 | - // X-WCPOS-gated API class, because preflights carry no marker and the | |
| 173 | - // relay's consent route is served without constructing API. | |
| 174 | - Rest_Cors::register_hooks(); | |
| 175 | - | |
| 176 | - // Non-REST API discoverability: the HEAD probe against the homepage. | |
| 177 | - add_action( 'send_headers', array( $this, 'send_headers' ), 99, 1 ); | |
| 178 | - add_action( 'send_headers', array( $this, 'remove_x_frame_options' ), 9999, 1 ); | |
| 179 | - | |
| 180 | - /* | |
| 181 | - * Add the global JWT authentication filter and its core-route audit guard. | |
| 182 | - * | |
| 183 | - * Hook order: plugins_loaded -> init (determine_current_user) -> rest_api_init | |
| 184 | - * | |
| 185 | - * This filter runs at priority 20, after WordPress core's cookie auth handlers. | |
| 186 | - * It must be registered here (during plugins_loaded) because determine_current_user | |
| 187 | - * fires during 'init', which is BEFORE rest_api_init where our API class loads. | |
| 188 | - * Because it authenticates WCPOS Bearer tokens on EVERY | |
| 189 | - * REST request (marked or not), the audit-meta guard for core routes | |
| 190 | - * must be registered just as unconditionally — never from the | |
| 191 | - * X-WCPOS-gated API class, whose marker an attacker simply omits. | |
| 192 | - * Registering it first lets its priority-20 provenance filter run after | |
| 193 | - * core's cookie handlers but before WCPOS's JWT filter. | |
| 194 | - */ | |
| 195 | - ( new Services\Core_Order_Audit_Guard() )->register_hooks(); | |
| 196 | - | |
| 197 | - // Coupon post-date touch. Unconditional and lane-agnostic on purpose: a | |
| 198 | - // meta-only coupon edit (amount, discount_type, usage limits) never moves | |
| 199 | - // post_modified, and the client's catalogue replication is date-based | |
| 200 | - // (?modified_after, filtered by WooCommerce on post_modified_gmt), so an | |
| 201 | - // untouched coupon is invisible to every other till. That is true whether | |
| 202 | - // the edit came from the POS, wp-admin, WP-CLI or another plugin — so this | |
| 203 | - // sits outside the schema latch above because it does not use the v2 sync | |
| 204 | - // tables. | |
| 205 | - \WCPOS\WooCommercePOS\Sync\Coupon_Modified_Date::register_hooks(); | |
| 206 | - | |
| 207 | - add_filter( 'determine_current_user', array( $this, 'determine_current_user_early' ), 20 ); | |
| 208 | - | |
| 209 | - // Install lifecycle reporting. Registered last: it adds no filter that | |
| 210 | - // anything else orders against, and appending keeps the ordering table | |
| 211 | - // above in statement order. Deliberately NOT before the pair above — | |
| 212 | - // rows 20 and 23 are decided by insertion order alone. | |
| 213 | - ( new Services\Lifecycle_Events() )->register_hooks(); | |
| 278 | + return array_values( | |
| 279 | + array_filter( | |
| 280 | + $rows, | |
| 281 | + static function ( array $row ) use ( $sync_latched ): bool { | |
| 282 | + return $sync_latched || 'sync-latched' !== $row['phase']; | |
| 283 | + } | |
| 284 | + ) | |
| 285 | + ); | |
| 214 | 286 | } |
| 215 | 287 | |
| 216 | 288 | /** |
| 217 | 289 | * Clear cached data that depends on the Pro license. |
| @@ -436,41 +508,160 @@ | ||
| 436 | 508 | } |
| 437 | 509 | } |
| 438 | 510 | |
| 439 | 511 | /** |
| 440 | - * Common initializations. | |
| 512 | + * Groups constructed so far in this request (test seam; see constructed_groups()). | |
| 513 | + * | |
| 514 | + * @var array<string, bool> | |
| 441 | 515 | */ |
| 516 | + private static array $constructed = array(); | |
| 517 | + | |
| 518 | + /** | |
| 519 | + * Common initializations, by request lane. | |
| 520 | + * | |
| 521 | + * Every request gets the services whose hooks WooCommerce consults on a | |
| 522 | + * plain shopper page BEFORE any order exists: translations, the product | |
| 523 | + * visibility filters, the order statuses and the read-side order filters | |
| 524 | + * (My Account renders POS orders), the gateway registration (WooCommerce | |
| 525 | + * builds its gateway list on cart pages) and the reserved-stock filter | |
| 526 | + * (POS drafts must reduce online availability at add-to-cart time). | |
| 527 | + * | |
| 528 | + * Everything else is constructed only on the lanes that use it, and the | |
| 529 | + * order-event services additionally on the first order write of ANY request | |
| 530 | + * ({@see ensure_order_services()}), so the lane classifier is an | |
| 531 | + * optimisation rather than a correctness gate. Measured 2026-09-03: a | |
| 532 | + * storefront page loaded ~80 plugin files and 22 objects for hooks that | |
| 533 | + * never fire there (see .claude/research/2026-09-03-lazy-service-construction-spec.md). | |
| 534 | + */ | |
| 442 | 535 | private function init_common(): void { |
| 536 | + self::$constructed['always'] = true; | |
| 537 | + | |
| 443 | 538 | // init the Services. |
| 444 | 539 | SettingsService::instance(); |
| 445 | 540 | AuthService::instance(); |
| 446 | - Extensions::instance(); | |
| 447 | - Receipt_Snapshot_Store::instance(); | |
| 448 | 541 | |
| 449 | - // init other functionality needed by both frontend and admin. | |
| 542 | + // Needed on every lane, including a plain storefront page. | |
| 450 | 543 | new i18n(); |
| 451 | 544 | new Gateways(); |
| 452 | 545 | new Products(); |
| 453 | 546 | new Orders(); |
| 454 | - new Emails(); | |
| 455 | - new Templates(); | |
| 456 | 547 | Services\Stock_Validator::instance(); |
| 548 | + Services\Order_Write_Intent::register(); | |
| 549 | + | |
| 550 | + if ( Services\Request_Lane::is_storefront() ) { | |
| 551 | + // Order-event services arrive on the first order write, if any. | |
| 552 | + self::arm_order_services(); | |
| 553 | + return; | |
| 554 | + } | |
| 555 | + | |
| 556 | + self::ensure_order_services(); | |
| 557 | + self::construct_pos_services(); | |
| 558 | + } | |
| 559 | + | |
| 560 | + /** | |
| 561 | + * Services only POS, admin, REST, cron and CLI requests use. | |
| 562 | + */ | |
| 563 | + private static function construct_pos_services(): void { | |
| 564 | + if ( isset( self::$constructed['pos'] ) ) { | |
| 565 | + return; | |
| 566 | + } | |
| 567 | + self::$constructed['pos'] = true; | |
| 568 | + Extensions::instance(); | |
| 457 | 569 | new Services\Decimal_Quantities(); |
| 458 | 570 | new Services\Customer_Meta_Parity(); |
| 571 | + } | |
| 572 | + | |
| 573 | + /** | |
| 574 | + * Hook the order-event services to the first order write of the request. | |
| 575 | + * | |
| 576 | + * Every WooCommerce order write — create, update, status transition, | |
| 577 | + * `payment_complete()`, refund — goes through `WC_Abstract_Order::save()`, | |
| 578 | + * which fires `woocommerce_before_order_object_save` before the data store | |
| 579 | + * writes and before `woocommerce_new_order` / `woocommerce_order_status_changed` | |
| 580 | + * / `woocommerce_payment_complete` fire. Priority 0 there means every | |
| 581 | + * observer exists before any order is written — on a webhook, a cron | |
| 582 | + * spawned from a page view, a third-party plugin creating an order on | |
| 583 | + * `template_redirect`, or a lane the classifier got wrong. Nothing in the | |
| 584 | + * order group listens to trash or delete, so those need no arming. | |
| 585 | + */ | |
| 586 | + private static function arm_order_services(): void { | |
| 587 | + add_action( 'woocommerce_before_order_object_save', array( self::class, 'ensure_order_services' ), 0, 0 ); | |
| 588 | + } | |
| 589 | + | |
| 590 | + /** | |
| 591 | + * Construct the order-event services exactly once per request. | |
| 592 | + * | |
| 593 | + * Idempotent and safe to call after `init`; each service handles its own | |
| 594 | + * late registration. Fires `woocommerce_pos_order_services_ready` once so | |
| 595 | + * Pro and extensions can construct their own order-event services at the | |
| 596 | + * same moment on every lane. | |
| 597 | + */ | |
| 598 | + public static function ensure_order_services(): void { | |
| 599 | + if ( isset( self::$constructed['order'] ) ) { | |
| 600 | + return; | |
| 601 | + } | |
| 602 | + self::$constructed['order'] = true; | |
| 603 | + | |
| 604 | + Receipt_Snapshot_Store::instance(); | |
| 605 | + new Emails(); | |
| 606 | + new Templates(); | |
| 459 | 607 | new Services\Print_Job_Service(); |
| 460 | 608 | new Services\Cloud_Print_Trigger_Service(); |
| 461 | 609 | new Services\Cloud_Print_Submit_Service(); |
| 462 | 610 | new Services\Cloud_Print_Relay_Service(); |
| 611 | + | |
| 612 | + /** | |
| 613 | + * Fires once per request when the POS order-event services exist: | |
| 614 | + * eagerly on POS, admin, REST, cron and CLI requests (from this | |
| 615 | + * plugin's `init` callback at priority 10), and on a storefront | |
| 616 | + * request the moment the first order is about to be written. | |
| 617 | + * | |
| 618 | + * Because the eager firing happens at `init` priority 10, a listener | |
| 619 | + * added later than that (for example from another plugin's `init` | |
| 620 | + * callback at priority 20) must check `did_action()` first and | |
| 621 | + * construct immediately when the action has already fired. | |
| 622 | + * | |
| 623 | + * @since 1.10.8 | |
| 624 | + */ | |
| 625 | + do_action( 'woocommerce_pos_order_services_ready' ); | |
| 463 | 626 | } |
| 464 | 627 | |
| 465 | 628 | /** |
| 629 | + * Which service groups this request has constructed: 'always', 'order', 'pos'. | |
| 630 | + * | |
| 631 | + * @internal Test seam. | |
| 632 | + * | |
| 633 | + * @return string[] | |
| 634 | + */ | |
| 635 | + public static function constructed_groups(): array { | |
| 636 | + return array_keys( self::$constructed ); | |
| 637 | + } | |
| 638 | + | |
| 639 | + /** | |
| 640 | + * Forget which groups were constructed. Tests only. | |
| 641 | + * | |
| 642 | + * @internal | |
| 643 | + */ | |
| 644 | + public static function reset_request_state(): void { | |
| 645 | + self::$constructed = array(); | |
| 646 | + Services\Request_Lane::reset(); | |
| 647 | + } | |
| 648 | + | |
| 649 | + /** | |
| 466 | 650 | * Frontend specific initializations. |
| 467 | 651 | */ |
| 468 | 652 | private function init_frontend(): void { |
| 469 | - if ( ! is_admin() ) { | |
| 653 | + if ( is_admin() ) { | |
| 654 | + return; | |
| 655 | + } | |
| 656 | + // The public receipt shortcode and the My Account receipt action are | |
| 657 | + // storefront features; they construct the template services when used. | |
| 658 | + new Storefront_Receipts(); | |
| 659 | + if ( ! Services\Request_Lane::is_storefront() ) { | |
| 660 | + // The POS routes (rewrite rules, checkout context, order-pay and | |
| 661 | + // coupon forms) only matter on requests the classifier saw as POS. | |
| 470 | 662 | new Template_Router(); |
| 471 | 663 | new Form_Handler(); |
| 472 | - new Storefront_Receipts(); | |
| 473 | 664 | } |
| 474 | 665 | } |
| 475 | 666 | |
| 476 | 667 | /** |
| @@ -505,7 +696,17 @@ | ||
| 505 | 696 | } |
| 506 | 697 | |
| 507 | 698 | // wePOS alters the WooCommerce REST API, breaking the expected schema |
| 508 | 699 | // It's very bad form on their part, but we need to work around it. |
| 509 | - new Integrations\WePOS(); | |
| 700 | + // Its only hook is admin_init (a conflict notice), so admin lane only. | |
| 701 | + if ( is_admin() ) { | |
| 702 | + new Integrations\WePOS(); | |
| 703 | + } | |
| 704 | + | |
| 705 | + // WooCommerce Tax - https://wordpress.org/plugins/woocommerce-services/ | |
| 706 | + // Its class exists whenever the plugin is active, but its callbacks are | |
| 707 | + // only hooked when automated taxes are on and the store country is | |
| 708 | + // supported, so the integration looks them up on the hooks at | |
| 709 | + // recalculation time instead of gating on the class here. | |
| 710 | + new Integrations\WooCommerce_Tax(); | |
| 510 | 711 | } |
| 511 | 712 | } |