prefix (trusted); every value is bound via prepare() placeholders, verified by PHPStan. /** * Data access for adaf_snapshots. Before-state is stored as * base64(gzcompress(json)) so binary output is safe in LONGTEXT. */ final class SnapshotRepository { /** * Constructor. * * @param wpdb $wpdb WordPress database. */ public function __construct( private readonly wpdb $wpdb ) { } /** * Fully prefixed table name for the site WordPress is on now. Worked * out per query, so after switch_to_blog() it is that site's table * rather than the one this repository was created on (ADR-16, found * 2026-10-16). */ private function table(): string { return $this->wpdb->prefix . 'adaf_snapshots'; } /** * Store a before-state snapshot. * * @param int $ledger_id Linked ledger entry. * @param string $entity_type post|option|meta|term. * @param int $entity_id Target entity id. * @param array $before_state Pre-action state. * @param DateTimeImmutable $created_at Capture timestamp. */ public function create( int $ledger_id, string $entity_type, int $entity_id, array $before_state, DateTimeImmutable $created_at ): Snapshot { $this->wpdb->insert( $this->table(), array( 'ledger_id' => $ledger_id, 'entity_type' => $entity_type, 'entity_id' => $entity_id, 'before_state' => self::encode( $before_state ), 'created_at' => $created_at->format( 'Y-m-d H:i:s' ), ), array( '%d', '%s', '%d', '%s', '%s' ) ); return new Snapshot( (int) $this->wpdb->insert_id, $ledger_id, $entity_type, $entity_id, $before_state, $created_at ); } /** * Remove the snapshot belonging to a ledger entry. * * Used by retention: a snapshot exists to undo its entry, so once the * entry is gone the snapshot is rollback data pointing at nothing. * * @param int $ledger_id Ledger entry id. */ public function delete_by_ledger( int $ledger_id ): void { $this->wpdb->delete( $this->table(), array( 'ledger_id' => $ledger_id ), array( '%d' ) ); } /** * Find the snapshot linked to a ledger entry. * * @param int $ledger_id Ledger row id. */ public function find_by_ledger( int $ledger_id ): ?Snapshot { $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table()} WHERE ledger_id = %d", $ledger_id ), ARRAY_A ); return null === $row ? null : $this->hydrate( $row ); } /** * Compress and encode a state array for LONGTEXT storage. * * @param array $state State array. */ private static function encode( array $state ): string { $compressed = gzcompress( (string) wp_json_encode( Utf8::sanitize_deep( $state ) ), 6 ); return base64_encode( false === $compressed ? '' : $compressed ); } /** * Decode a stored blob back to the state array. * * @param string $blob Stored value. * @return array */ private static function decode( string $blob ): array { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding our own compressed snapshot storage format, not obfuscated code. $binary = base64_decode( $blob, true ); if ( false === $binary ) { return array(); } $json = gzuncompress( $binary ); if ( false === $json ) { return array(); } $state = json_decode( $json, true ); return is_array( $state ) ? $state : array(); } /** * Map a database row to the value object. * * @param array $row Raw row. */ private function hydrate( array $row ): Snapshot { return new Snapshot( (int) $row['id'], (int) $row['ledger_id'], (string) $row['entity_type'], (int) $row['entity_id'], self::decode( (string) $row['before_state'] ), new DateTimeImmutable( (string) $row['created_at'] ) ); } }