| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS POS-visibility change observer. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\Sync |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Sync; |
| 9 |
|
| 10 |
/** |
| 11 |
* Journals the moment a record enters or leaves the POS servable set. |
| 12 |
* |
| 13 |
* # Why this exists |
| 14 |
* |
| 15 |
* The catalogue change stream ({@see \WCPOS\WooCommercePOS\API\V2\Changes_Controller::sequence_log}) |
| 16 |
* drops update rows for records {@see Pos_Visibility} hides, because the catalog lane will never |
| 17 |
* serve them: announcing one costs every till a targeted pull that comes back empty, inflates the |
| 18 |
* replay backlog the client re-baselines on, and moves a head no till can act on. |
| 19 |
* |
| 20 |
* Dropping them is only safe because the TRANSITION is announced. A record that just became hidden |
| 21 |
* is still resident on every till, and the one message about it a client must still receive is |
| 22 |
* "drop this" — so hiding appends a TOMBSTONE and un-hiding appends an ordinary update row. The |
| 23 |
* stream never filters tombstones, so the removal always lands. |
| 24 |
* |
| 25 |
* To the stream, hiding a record is indistinguishable from trashing it and un-hiding it from |
| 26 |
* untrashing it, which is why both directions go through the journal's existing |
| 27 |
* `record_post_deleted()` / `record_post_untrashed()` handlers rather than re-deriving the object |
| 28 |
* type, the revision and the parent-product re-announcement. A client needs no new vocabulary: the |
| 29 |
* `deleted` row runs through the same local-work-protected delete path a real delete does. |
| 30 |
* |
| 31 |
* # Why it observes OPTIONS rather than the settings API |
| 32 |
* |
| 33 |
* `Visibility_Section::update_visibility_settings()` is only one of the writers. The POS app PATCHes |
| 34 |
* the whole section through the settings REST endpoint, and another plugin or wp-cli can call |
| 35 |
* `update_option()` directly. Every one of those paths funnels through `update_option()`, so |
| 36 |
* observing the options is what makes this self-healing in the same way |
| 37 |
* {@see Config_Fingerprint} recomputes from live options instead of trusting a hook counter. |
| 38 |
* |
| 39 |
* The diff is taken over the RESOLVED hidden set — `Pos_Visibility::hidden_ids()`, the same call the |
| 40 |
* stream filters on — not over the raw stored id lists. That is what makes the `pos_only_products` |
| 41 |
* feature toggle work: flipping it moves the entire hidden set without touching a single id list. |
| 42 |
* It also means an extension filtering the visibility settings is honoured here exactly as it is on |
| 43 |
* every read lane. |
| 44 |
*/ |
| 45 |
final class Visibility_Observer { |
| 46 |
/** |
| 47 |
* Bump when the seeded set changes shape and every install must re-announce it. |
| 48 |
* |
| 49 |
* History: |
| 50 |
* - 1 (1.10.1): first announcement, for records hidden before the observer existed. |
| 51 |
* - 2 (1.10.16): re-announcement. Between 1.10.1 and 1.10.14 the catalogue search |
| 52 |
* lane served hidden records when a search carried late `include` ids |
| 53 |
* (wcpos/woocommerce-pos#1990), so tills that searched during that window hold |
| 54 |
* hidden products the seed-1 tombstones predate and the stream never mentions |
| 55 |
* again. Re-seeding drops them through the ordinary changes pull on the next |
| 56 |
* tick; the client's existence audit would otherwise be the only path, and on a |
| 57 |
* host that reports sustained pressure that audit runs at a trickle |
| 58 |
* (wcpos/monorepo#2078). |
| 59 |
* |
| 60 |
* @var int |
| 61 |
*/ |
| 62 |
public const SEED_VERSION = 2; |
| 63 |
|
| 64 |
/** |
| 65 |
* The one-time seed latch. |
| 66 |
* |
| 67 |
* @var string |
| 68 |
*/ |
| 69 |
public const SEED_VERSION_OPTION = 'woocommerce_pos_sync_visibility_tombstone_seed'; |
| 70 |
|
| 71 |
/** |
| 72 |
* The journal rows are appended to. |
| 73 |
* |
| 74 |
* @var Sync_Journal |
| 75 |
*/ |
| 76 |
private Sync_Journal $journal; |
| 77 |
|
| 78 |
/** |
| 79 |
* The POS servable-set authority. |
| 80 |
* |
| 81 |
* @var Pos_Visibility |
| 82 |
*/ |
| 83 |
private Pos_Visibility $visibility; |
| 84 |
|
| 85 |
/** |
| 86 |
* The resolved hidden set as it stood before an in-flight option write, keyed by option name. |
| 87 |
* |
| 88 |
* Keyed per option because a no-op write fires `pre_update_option_{$option}` and then NO |
| 89 |
* `update_option_{$option}`; an unkeyed snapshot would be consumed by whichever option wrote |
| 90 |
* next and diffed against the wrong baseline. |
| 91 |
* |
| 92 |
* @var array<string, int[]> |
| 93 |
*/ |
| 94 |
private array $hidden_before = array(); |
| 95 |
|
| 96 |
/** |
| 97 |
* Constructor. |
| 98 |
* |
| 99 |
* @param null|Sync_Journal $journal Journal to append to. |
| 100 |
* @param null|Pos_Visibility $visibility Servable-set authority. |
| 101 |
*/ |
| 102 |
public function __construct( ?Sync_Journal $journal = null, ?Pos_Visibility $visibility = null ) { |
| 103 |
$this->journal = $journal ?? new Sync_Journal(); |
| 104 |
$this->visibility = $visibility ?? new Pos_Visibility(); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Watch every option that can move the POS servable set. |
| 109 |
*/ |
| 110 |
public function register_hooks(): void { |
| 111 |
// `delete_option` is the GENERIC pre-delete action — WordPress has no per-option form that |
| 112 |
// fires before the row is gone (`delete_option_{$option}` fires after). Registered once and |
| 113 |
// gated on the option name inside the callback. |
| 114 |
add_action( 'delete_option', array( $this, 'snapshot_before_delete' ), 10, 1 ); |
| 115 |
|
| 116 |
foreach ( Pos_Visibility::source_options() as $option ) { |
| 117 |
add_filter( "pre_update_option_{$option}", array( $this, 'snapshot_hidden_ids' ), 10, 3 ); |
| 118 |
add_action( "update_option_{$option}", array( $this, 'record_updated_option' ), 10, 3 ); |
| 119 |
add_action( "add_option_{$option}", array( $this, 'record_added_option' ), 10, 2 ); |
| 120 |
add_action( "delete_option_{$option}", array( $this, 'record_deleted_option' ), 10, 1 ); |
| 121 |
} |
| 122 |
} |
| 123 |
|
| 124 |
/** |
| 125 |
* Announce every record that was hidden before this observer existed. |
| 126 |
* |
| 127 |
* An install that already had records hidden transitioned them while nothing was watching, so no |
| 128 |
* tombstone was ever written for them. A till still holding one used to drop it on that record's |
| 129 |
* next edit — the update row, the empty pull, the client's shortfall prune — and the stream no |
| 130 |
* longer carries that update row. Without this pass the upgrade would strand exactly those |
| 131 |
* records until a tier 2 sweep. |
| 132 |
* |
| 133 |
* Latched by its own option rather than the sync schema version: no table changed, and bumping |
| 134 |
* the schema would re-run the unrelated customer compensation pass on every install. Two |
| 135 |
* concurrent requests can both seed before either latches; the duplicate rows are identical |
| 136 |
* tombstones at different sequences and a client applies them idempotently, which is the right |
| 137 |
* trade at this tier. |
| 138 |
*/ |
| 139 |
public function maybe_seed_hidden_tombstones(): void { |
| 140 |
if ( (int) get_option( self::SEED_VERSION_OPTION, 0 ) >= self::SEED_VERSION ) { |
| 141 |
return; |
| 142 |
} |
| 143 |
|
| 144 |
// Latch only once the rows are in: a transient write failure (missing table, dead |
| 145 |
// connection) that latched anyway would never be retried, and the copies this seed |
| 146 |
// exists to remove would stay on every till (Codex review, #1995). |
| 147 |
if ( ! $this->journal->append_catalogue_tombstones( $this->visibility->hidden_ids( Pos_Visibility::CATALOG ) ) ) { |
| 148 |
return; |
| 149 |
} |
| 150 |
|
| 151 |
// Latched even when the hidden set is empty — otherwise every request on a store that hides |
| 152 |
// nothing would resolve the set again forever. |
| 153 |
// Autoloaded: the Init constructor reads this latch on every request. |
| 154 |
// Existing rows from older releases are flipped by |
| 155 |
// Activator::autoload_request_latches() on upgrade. |
| 156 |
update_option( self::SEED_VERSION_OPTION, self::SEED_VERSION, true ); |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* Capture the hidden set before the write lands. |
| 161 |
* |
| 162 |
* Runs on `pre_update_option_{$option}`, which fires BEFORE the option row and its cache are |
| 163 |
* updated — so `hidden_ids()` here still resolves the pre-write state. A pass-through filter: |
| 164 |
* the value is returned untouched. |
| 165 |
* |
| 166 |
* @param mixed $value The value about to be written. |
| 167 |
* @param mixed $old_value The value being replaced. |
| 168 |
* @param string $option Option name. |
| 169 |
* |
| 170 |
* @return mixed |
| 171 |
*/ |
| 172 |
public function snapshot_hidden_ids( $value, $old_value = null, $option = '' ) { |
| 173 |
if ( \is_string( $option ) && '' !== $option ) { |
| 174 |
$this->hidden_before[ $option ] = $this->visibility->hidden_ids( Pos_Visibility::CATALOG ); |
| 175 |
} |
| 176 |
|
| 177 |
return $value; |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Journal the transitions an option update caused. |
| 182 |
* |
| 183 |
* @param mixed $old_value The replaced value. |
| 184 |
* @param mixed $value The written value. |
| 185 |
* @param string $option Option name. |
| 186 |
*/ |
| 187 |
public function record_updated_option( $old_value = null, $value = null, $option = '' ): void { |
| 188 |
$this->consume_snapshot( $option ); |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* Capture the hidden set before an option is deleted. |
| 193 |
* |
| 194 |
* Deleting either source moves the set exactly as writing it does: dropping the visibility option |
| 195 |
* reveals every id it listed, and dropping the General option takes the `pos_only_products` |
| 196 |
* feature down with it. Both are reachable from `Settings::delete_settings()`. |
| 197 |
* |
| 198 |
* @param string $option Option about to be deleted. |
| 199 |
*/ |
| 200 |
public function snapshot_before_delete( $option = '' ): void { |
| 201 |
if ( ! \is_string( $option ) || ! \in_array( $option, Pos_Visibility::source_options(), true ) ) { |
| 202 |
return; |
| 203 |
} |
| 204 |
|
| 205 |
$this->hidden_before[ $option ] = $this->visibility->hidden_ids( Pos_Visibility::CATALOG ); |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* Journal the transitions deleting the option caused. |
| 210 |
* |
| 211 |
* @param string $option Option name. |
| 212 |
*/ |
| 213 |
public function record_deleted_option( $option = '' ): void { |
| 214 |
$this->consume_snapshot( $option ); |
| 215 |
} |
| 216 |
|
| 217 |
/** |
| 218 |
* Journal the transitions adding the option caused. |
| 219 |
* |
| 220 |
* `add_option_{$option}` fires after the insert and has no pre-write counterpart, but it needs |
| 221 |
* none: with the option absent the hidden set is necessarily empty — an unconfigured visibility |
| 222 |
* option hides nothing, and an unconfigured general option leaves the `pos_only_products` |
| 223 |
* feature off, which reports an empty set whatever the id lists hold. |
| 224 |
* |
| 225 |
* @param string $option Option name. |
| 226 |
* @param mixed $value The inserted value. |
| 227 |
*/ |
| 228 |
public function record_added_option( $option = '', $value = null ): void { |
| 229 |
$this->record_transitions( array() ); |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* Diff against the snapshot this option's pre-write hook left, then discard it. |
| 234 |
* |
| 235 |
* The snapshot is consumed rather than merely read: a post-write hook that somehow arrives |
| 236 |
* without its own pre-write counterpart must not diff against a stale baseline. A missing |
| 237 |
* snapshot means the before state is unknown, and announcing the whole current set would be |
| 238 |
* worse than announcing nothing. |
| 239 |
* |
| 240 |
* @param mixed $option Option name from the hook. |
| 241 |
*/ |
| 242 |
private function consume_snapshot( $option ): void { |
| 243 |
if ( ! \is_string( $option ) || ! \array_key_exists( $option, $this->hidden_before ) ) { |
| 244 |
return; |
| 245 |
} |
| 246 |
|
| 247 |
$before = $this->hidden_before[ $option ]; |
| 248 |
unset( $this->hidden_before[ $option ] ); |
| 249 |
|
| 250 |
$this->record_transitions( $before ); |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* Append one journal row per record that entered or left the servable set. |
| 255 |
* |
| 256 |
* @param int[] $before The hidden set before the write. |
| 257 |
*/ |
| 258 |
private function record_transitions( array $before ): void { |
| 259 |
$after = $this->visibility->hidden_ids( Pos_Visibility::CATALOG ); |
| 260 |
|
| 261 |
foreach ( array_diff( $after, $before ) as $id ) { |
| 262 |
$this->record_transition( (int) $id, true ); |
| 263 |
} |
| 264 |
|
| 265 |
foreach ( array_diff( $before, $after ) as $id ) { |
| 266 |
$this->record_transition( (int) $id, false ); |
| 267 |
} |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* Record one record's servability change as the trash/untrash event it is. |
| 272 |
* |
| 273 |
* The post type is read from the post itself rather than from the id list the id came from: the |
| 274 |
* lists are merchant-supplied, and a stale or mistyped id must not make the journal announce a |
| 275 |
* change to whatever unrelated record now holds that number. An id with no post — deleted since |
| 276 |
* it was hidden — resolves to no type and is skipped. |
| 277 |
* |
| 278 |
* @param int $id Post id whose POS servability changed. |
| 279 |
* @param bool $hidden True when the record just left the servable set. |
| 280 |
*/ |
| 281 |
private function record_transition( int $id, bool $hidden ): void { |
| 282 |
$post_type = get_post_type( $id ); |
| 283 |
if ( 'product' !== $post_type && 'product_variation' !== $post_type ) { |
| 284 |
return; |
| 285 |
} |
| 286 |
|
| 287 |
if ( $hidden ) { |
| 288 |
$this->journal->record_post_deleted( $id ); |
| 289 |
|
| 290 |
return; |
| 291 |
} |
| 292 |
|
| 293 |
$this->journal->record_post_untrashed( $id ); |
| 294 |
} |
| 295 |
} |
| 296 |
|