| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS sync store component. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\Sync |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Sync; |
| 9 |
|
| 10 |
// phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim. |
| 11 |
|
| 12 |
/** |
| 13 |
* THE canonical record revision (one function, G1) — a stable content hash of a |
| 14 |
* record's wc/v3 serialization, used for optimistic-concurrency on writes and as |
| 15 |
* the `baseRevision` a client sends back on an update. Distinct from the |
| 16 |
* change-signal's internal change-DETECTION hash (class-change-log): this is the |
| 17 |
* record's content revision exposed to the client. |
| 18 |
* |
| 19 |
* Stable by construction so it changes ONLY when the record's representation does: |
| 20 |
* - volatile/derived REST fields are stripped — `related_ids` comes back in random |
| 21 |
* order every GET, `_links` is request-derived — shared with the change-log via |
| 22 |
* the `woocommerce_pos_sync_revision_excluded_fields` filter; |
| 23 |
* - keys are sorted recursively, so wc/v3 (or a filter) re-ordering properties is |
| 24 |
* not seen as a change. |
| 25 |
* |
| 26 |
* IMPORTANT: always compute over the BARE wc/v3 serialization — never a payload that |
| 27 |
* has had `_woocommerce_pos_uuid` re-injected — because the conflict check re-reads |
| 28 |
* the record via wc/v3 (which omits that protected meta), so the two must hash the |
| 29 |
* same bytes. |
| 30 |
*/ |
| 31 |
class Revision { |
| 32 |
|
| 33 |
/** |
| 34 |
* #423 step 1b: stamp each proxied record's CANONICAL revision as a |
| 35 |
* top-level `_rxdb_revision` (transport metadata, like `_rxdb_digest`) so |
| 36 |
* the client lane builders stop synthesizing date/id stand-ins that can |
| 37 |
* never match a push-side recompute. Registered at priority 9 — BEFORE |
| 38 |
* the uuid/digest stampers (priority 10) augment the payload — so the |
| 39 |
* stamped bytes equal what the write path's revision_for computes from a |
| 40 |
* bare wc/v3 re-read. Orders additionally identity-strip (HPOS exposes |
| 41 |
* the protected uuid meta on reads). |
| 42 |
*/ |
| 43 |
public static function register_proxy_stamps(): void { |
| 44 |
add_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'stamp_proxy_revisions' ), 9, 3 ); |
| 45 |
} |
| 46 |
|
| 47 |
/** |
| 48 |
* Remove the canonical revision stamper. |
| 49 |
*/ |
| 50 |
public static function unregister_proxy_stamps(): void { |
| 51 |
remove_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'stamp_proxy_revisions' ), 9 ); |
| 52 |
} |
| 53 |
|
| 54 |
public static function stamp_proxy_revisions( $data, $resource = '', $request = null ) { |
| 55 |
if ( ! is_array( $data ) ) { |
| 56 |
return $data; |
| 57 |
} |
| 58 |
$row = Collections::by_proxy_slug( (string) $resource ); |
| 59 |
if ( null === $row ) { |
| 60 |
return $data; |
| 61 |
} |
| 62 |
$is_order = 'order' === $row['object_type']; |
| 63 |
foreach ( $data as $index => $record ) { |
| 64 |
if ( ! is_array( $record ) ) { |
| 65 |
continue; |
| 66 |
} |
| 67 |
$data[ $index ]['_rxdb_revision'] = $is_order |
| 68 |
? Order_Serializer::canonical_revision( $record ) |
| 69 |
: self::compute( $record ); |
| 70 |
} |
| 71 |
return $data; |
| 72 |
} |
| 73 |
|
| 74 |
public static function compute( array $data ): string { |
| 75 |
return 'sha256:' . hash( 'sha256', (string) wp_json_encode( self::canonicalize( $data ) ) ); |
| 76 |
} |
| 77 |
|
| 78 |
public static function canonicalize( array $data ): array { |
| 79 |
$excluded = apply_filters( 'woocommerce_pos_sync_revision_excluded_fields', array( 'related_ids', '_links', 'links' ) ); |
| 80 |
foreach ( (array) $excluded as $field ) { |
| 81 |
unset( $data[ $field ] ); |
| 82 |
} |
| 83 |
return self::sort_keys_recursive( $data ); |
| 84 |
} |
| 85 |
|
| 86 |
private static function sort_keys_recursive( array $data ): array { |
| 87 |
ksort( $data ); |
| 88 |
foreach ( $data as $key => $value ) { |
| 89 |
if ( is_array( $value ) ) { |
| 90 |
$data[ $key ] = self::sort_keys_recursive( $value ); |
| 91 |
// Taxonomy collections are sets; other lists retain semantic order. |
| 92 |
if ( ! in_array( $key, array( 'categories', 'tags', 'brands' ), true ) || count( $value ) < 2 || array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) { |
| 93 |
continue; |
| 94 |
} |
| 95 |
foreach ( $value as $term ) { |
| 96 |
$id = is_array( $term ) ? ( $term['id'] ?? null ) : ( is_object( $term ) ? ( $term->id ?? null ) : null ); |
| 97 |
if ( ! is_int( $id ) ) { |
| 98 |
continue 2; |
| 99 |
} |
| 100 |
} |
| 101 |
usort( |
| 102 |
$data[ $key ], |
| 103 |
static function ( $left, $right ): int { |
| 104 |
$left_id = is_array( $left ) ? $left['id'] : $left->id; |
| 105 |
$right_id = is_array( $right ) ? $right['id'] : $right->id; |
| 106 |
return $left_id <=> $right_id; |
| 107 |
} |
| 108 |
); |
| 109 |
} |
| 110 |
} |
| 111 |
return $data; |
| 112 |
} |
| 113 |
} |
| 114 |
|