*/ private array $hidden_before = array(); /** * Constructor. * * @param null|Sync_Journal $journal Journal to append to. * @param null|Pos_Visibility $visibility Servable-set authority. */ public function __construct( ?Sync_Journal $journal = null, ?Pos_Visibility $visibility = null ) { $this->journal = $journal ?? new Sync_Journal(); $this->visibility = $visibility ?? new Pos_Visibility(); } /** * Watch every option that can move the POS servable set. */ public function register_hooks(): void { // `delete_option` is the GENERIC pre-delete action — WordPress has no per-option form that // fires before the row is gone (`delete_option_{$option}` fires after). Registered once and // gated on the option name inside the callback. add_action( 'delete_option', array( $this, 'snapshot_before_delete' ), 10, 1 ); foreach ( Pos_Visibility::source_options() as $option ) { add_filter( "pre_update_option_{$option}", array( $this, 'snapshot_hidden_ids' ), 10, 3 ); add_action( "update_option_{$option}", array( $this, 'record_updated_option' ), 10, 3 ); add_action( "add_option_{$option}", array( $this, 'record_added_option' ), 10, 2 ); add_action( "delete_option_{$option}", array( $this, 'record_deleted_option' ), 10, 1 ); } } /** * Announce every record that was hidden before this observer existed. * * An install that already had records hidden transitioned them while nothing was watching, so no * tombstone was ever written for them. A till still holding one used to drop it on that record's * next edit — the update row, the empty pull, the client's shortfall prune — and the stream no * longer carries that update row. Without this pass the upgrade would strand exactly those * records until a tier 2 sweep. * * Latched by its own option rather than the sync schema version: no table changed, and bumping * the schema would re-run the unrelated customer compensation pass on every install. Two * concurrent requests can both seed before either latches; the duplicate rows are identical * tombstones at different sequences and a client applies them idempotently, which is the right * trade at this tier. */ public function maybe_seed_hidden_tombstones(): void { if ( (int) get_option( self::SEED_VERSION_OPTION, 0 ) >= self::SEED_VERSION ) { return; } // Latch only once the rows are in: a transient write failure (missing table, dead // connection) that latched anyway would never be retried, and the copies this seed // exists to remove would stay on every till (Codex review, #1995). if ( ! $this->journal->append_catalogue_tombstones( $this->visibility->hidden_ids( Pos_Visibility::CATALOG ) ) ) { return; } // Latched even when the hidden set is empty — otherwise every request on a store that hides // nothing would resolve the set again forever. // Autoloaded: the Init constructor reads this latch on every request. // Existing rows from older releases are flipped by // Activator::autoload_request_latches() on upgrade. update_option( self::SEED_VERSION_OPTION, self::SEED_VERSION, true ); } /** * Capture the hidden set before the write lands. * * Runs on `pre_update_option_{$option}`, which fires BEFORE the option row and its cache are * updated — so `hidden_ids()` here still resolves the pre-write state. A pass-through filter: * the value is returned untouched. * * @param mixed $value The value about to be written. * @param mixed $old_value The value being replaced. * @param string $option Option name. * * @return mixed */ public function snapshot_hidden_ids( $value, $old_value = null, $option = '' ) { if ( \is_string( $option ) && '' !== $option ) { $this->hidden_before[ $option ] = $this->visibility->hidden_ids( Pos_Visibility::CATALOG ); } return $value; } /** * Journal the transitions an option update caused. * * @param mixed $old_value The replaced value. * @param mixed $value The written value. * @param string $option Option name. */ public function record_updated_option( $old_value = null, $value = null, $option = '' ): void { $this->consume_snapshot( $option ); } /** * Capture the hidden set before an option is deleted. * * Deleting either source moves the set exactly as writing it does: dropping the visibility option * reveals every id it listed, and dropping the General option takes the `pos_only_products` * feature down with it. Both are reachable from `Settings::delete_settings()`. * * @param string $option Option about to be deleted. */ public function snapshot_before_delete( $option = '' ): void { if ( ! \is_string( $option ) || ! \in_array( $option, Pos_Visibility::source_options(), true ) ) { return; } $this->hidden_before[ $option ] = $this->visibility->hidden_ids( Pos_Visibility::CATALOG ); } /** * Journal the transitions deleting the option caused. * * @param string $option Option name. */ public function record_deleted_option( $option = '' ): void { $this->consume_snapshot( $option ); } /** * Journal the transitions adding the option caused. * * `add_option_{$option}` fires after the insert and has no pre-write counterpart, but it needs * none: with the option absent the hidden set is necessarily empty — an unconfigured visibility * option hides nothing, and an unconfigured general option leaves the `pos_only_products` * feature off, which reports an empty set whatever the id lists hold. * * @param string $option Option name. * @param mixed $value The inserted value. */ public function record_added_option( $option = '', $value = null ): void { $this->record_transitions( array() ); } /** * Diff against the snapshot this option's pre-write hook left, then discard it. * * The snapshot is consumed rather than merely read: a post-write hook that somehow arrives * without its own pre-write counterpart must not diff against a stale baseline. A missing * snapshot means the before state is unknown, and announcing the whole current set would be * worse than announcing nothing. * * @param mixed $option Option name from the hook. */ private function consume_snapshot( $option ): void { if ( ! \is_string( $option ) || ! \array_key_exists( $option, $this->hidden_before ) ) { return; } $before = $this->hidden_before[ $option ]; unset( $this->hidden_before[ $option ] ); $this->record_transitions( $before ); } /** * Append one journal row per record that entered or left the servable set. * * @param int[] $before The hidden set before the write. */ private function record_transitions( array $before ): void { $after = $this->visibility->hidden_ids( Pos_Visibility::CATALOG ); foreach ( array_diff( $after, $before ) as $id ) { $this->record_transition( (int) $id, true ); } foreach ( array_diff( $before, $after ) as $id ) { $this->record_transition( (int) $id, false ); } } /** * Record one record's servability change as the trash/untrash event it is. * * The post type is read from the post itself rather than from the id list the id came from: the * lists are merchant-supplied, and a stale or mistyped id must not make the journal announce a * change to whatever unrelated record now holds that number. An id with no post — deleted since * it was hidden — resolves to no type and is skipped. * * @param int $id Post id whose POS servability changed. * @param bool $hidden True when the record just left the servable set. */ private function record_transition( int $id, bool $hidden ): void { $post_type = get_post_type( $id ); if ( 'product' !== $post_type && 'product_variation' !== $post_type ) { return; } if ( $hidden ) { $this->journal->record_post_deleted( $id ); return; } $this->journal->record_post_untrashed( $id ); } }