PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Sync / Visibility_Observer.php

Visibility_Observer.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.16, at includes/Sync/Visibility_Observer.php

296 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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