PluginProbe ʕ •ᴥ•ʔ
Robin Image Optimizer – Unlimited Image Optimization, WebP & AVIF / 2.0.6
Robin Image Optimizer – Unlimited Image Optimization, WebP & AVIF v2.0.6
2.0.7 2.0.6 2.0.5 trunk 1.3.7 1.4.0 1.4.1 1.4.2 1.4.6 1.5.0 1.5.3 1.5.6 1.5.8 1.6.5 1.6.6 1.6.9 1.7.0 1.7.4 1.8.1 1.8.2 1.9.0 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4
robin-image-optimizer / vendor / codeinwp / themeisle-sdk / src / Modules / Migrator.php
robin-image-optimizer / vendor / codeinwp / themeisle-sdk / src / Modules Last commit date
About_us.php 1 month ago Abstract_Migration.php 4 months ago Announcements.php 4 months ago Compatibilities.php 10 months ago Crash_reporter.php 1 month ago Dashboard_widget.php 1 month ago Featured_plugins.php 1 month ago Float_widget.php 10 months ago Licenser.php 4 months ago Logger.php 1 month ago Migrator.php 1 month ago Notification.php 10 months ago Promotions.php 1 month ago Recommendation.php 10 months ago Review.php 10 months ago Rollback.php 10 months ago Script_loader.php 10 months ago Translate.php 10 months ago Translations.php 10 months ago Uninstall_feedback.php 1 month ago Welcome.php 10 months ago
Migrator.php
398 lines
1 <?php
2 /**
3 * The migrator module for ThemeIsle SDK.
4 *
5 * @package ThemeIsleSDK
6 * @subpackage Modules
7 * @copyright Copyright (c) 2024, Themeisle
8 * @license http://opensource.org/licenses/gpl-3.0.php GNU Public License
9 * @since 3.3.50
10 */
11
12 namespace ThemeisleSDK\Modules;
13
14 use ThemeisleSDK\Common\Abstract_Module;
15 use ThemeisleSDK\Product;
16
17 // Exit if accessed directly.
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Migrator module for ThemeIsle SDK.
24 *
25 * Allows products to ship PHP migration files that run automatically on the
26 * first complete request for each version. Each product opts in by registering
27 * its migrations directory via the `{product_slug}_sdk_migrations_path` filter.
28 */
29 class Migrator extends Abstract_Module {
30 /**
31 * Option key suffix used to store the list of ran migrations.
32 */
33 const OPTION_SUFFIX = '_ran_migrations';
34
35 /**
36 * Option key suffix used to store the last fully migrated product version.
37 */
38 const VERSION_OPTION_SUFFIX = '_migrated_version';
39
40 /**
41 * Option/cache key suffix used while migrations are running.
42 */
43 const LOCK_SUFFIX = '_migration_lock';
44
45 /**
46 * Cache group used for migration locks.
47 */
48 const LOCK_CACHE_GROUP = 'themeisle_sdk_migrations';
49
50 /**
51 * Maximum lock lifetime in seconds.
52 */
53 const LOCK_TTL = 300;
54
55 /**
56 * Token identifying the lock owned by this instance.
57 *
58 * @var string
59 */
60 private $lock_token = '';
61
62 /**
63 * Serialized value used by the database lock.
64 *
65 * @var string
66 */
67 private $lock_value = '';
68
69 /**
70 * Lock backend used by this instance.
71 *
72 * @var string
73 */
74 private $lock_driver = '';
75
76 /**
77 * Check if we should load the module for this product.
78 *
79 * Always returns true — the actual path check happens lazily at wp_loaded.
80 *
81 * @param Product $product Product to load the module for.
82 *
83 * @return bool
84 */
85 public function can_load( $product ) {
86 return apply_filters( $product->get_slug() . '_sdk_enable_migrator', true );
87 }
88
89 /**
90 * Load module logic.
91 *
92 * @param Product $product Product to load.
93 *
94 * @return Migrator
95 */
96 public function load( $product ) {
97 $this->product = $product;
98 add_action( 'wp_loaded', array( $this, 'run_pending' ) );
99 add_action( 'themeisle_sdk_rollback_migration_' . $product->get_slug(), array( $this, 'rollback' ) );
100 return $this;
101 }
102
103 /**
104 * Discover and run any pending migrations for the product.
105 *
106 * Runs on the first complete request for each product version. The migrated
107 * version is recorded only after all pending migrations finish successfully,
108 * so interrupted or failed runs are retried on the next request.
109 *
110 * @return void
111 */
112 public function run_pending() {
113 $path = $this->get_migrations_path();
114
115 if ( empty( $path ) || ! is_dir( $path ) ) {
116 return;
117 }
118
119 $version_key = $this->product->get_key() . self::VERSION_OPTION_SUFFIX;
120 $current_version = $this->product->get_version();
121
122 if ( get_option( $version_key, '' ) === $current_version ) {
123 return;
124 }
125
126 if ( ! $this->acquire_lock() ) {
127 return;
128 }
129
130 // Another request may have completed while this request waited for the lock.
131 if ( get_option( $version_key, '' ) === $current_version ) {
132 $this->release_lock();
133 return;
134 }
135
136 if ( $this->execute_pending( $path ) ) {
137 update_option( $version_key, $current_version );
138 }
139
140 $this->release_lock();
141 }
142
143 /**
144 * Execute all pending migration files.
145 *
146 * @param string $path Absolute migrations directory path.
147 *
148 * @return bool True when every migration was handled successfully.
149 */
150 private function execute_pending( $path ) {
151 $files = glob( trailingslashit( $path ) . '*.php' );
152
153 if ( false === $files ) {
154 $this->log_error( 'discovery', 'failed to read the migrations directory' );
155 return false;
156 }
157
158 if ( empty( $files ) ) {
159 return true;
160 }
161
162 sort( $files ); // Alphabetical order = chronological order given timestamp naming.
163
164 $option_key = $this->product->get_key() . self::OPTION_SUFFIX;
165 $ran = get_option( $option_key, array() );
166 $ran = is_array( $ran ) ? $ran : array();
167
168 foreach ( $files as $file ) {
169 $name = basename( $file, '.php' );
170
171 if ( in_array( $name, $ran, true ) ) {
172 continue;
173 }
174
175 try {
176 $migration = require $file; // Migration files return an anonymous class instance.
177
178 if ( ! ( $migration instanceof Abstract_Migration ) ) {
179 $this->log_error( $name, 'migration file must return an Abstract_Migration instance' );
180 return false;
181 }
182
183 if ( ! $migration->should_run() ) {
184 continue;
185 }
186
187 $migration->up();
188 $ran[] = $name;
189 update_option( $option_key, $ran );
190 } catch ( \Throwable $e ) {
191 // Stop and leave the product version incomplete so the next request retries.
192 $this->log_error( $name, $e->getMessage() );
193 return false;
194 }
195 }
196
197 return true;
198 }
199
200 /**
201 * Acquire a cross-request migration lock.
202 *
203 * Persistent object caches provide an atomic add operation. Sites without
204 * one use an atomic database insert/compare-and-swap fallback.
205 *
206 * @return bool True when the lock was acquired.
207 */
208 private function acquire_lock() {
209 $this->lock_token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( '', true );
210
211 if ( function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() ) {
212 $acquired = wp_cache_add(
213 $this->get_lock_key(),
214 $this->lock_token,
215 self::LOCK_CACHE_GROUP,
216 self::LOCK_TTL
217 );
218
219 if ( $acquired ) {
220 $this->lock_driver = 'cache';
221 }
222
223 return $acquired;
224 }
225
226 return $this->acquire_database_lock();
227 }
228
229 /**
230 * Acquire the database lock used without a persistent object cache.
231 *
232 * @return bool True when the lock was acquired.
233 */
234 private function acquire_database_lock() {
235 global $wpdb;
236
237 $key = $this->get_lock_key();
238 $this->lock_value = wp_json_encode(
239 array(
240 'token' => $this->lock_token,
241 'expires' => time() + self::LOCK_TTL,
242 )
243 );
244
245 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
246 $inserted = $wpdb->query(
247 $wpdb->prepare(
248 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
249 $key,
250 $this->lock_value,
251 'no'
252 )
253 );
254
255 if ( 1 === (int) $inserted ) {
256 $this->lock_driver = 'database';
257 return true;
258 }
259
260 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
261 $existing = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $key ) );
262 $lock = json_decode( (string) $existing, true );
263
264 if ( is_array( $lock ) && ! empty( $lock['expires'] ) && (int) $lock['expires'] >= time() ) {
265 return false;
266 }
267
268 // Replace an expired lock only if it has not changed since it was read.
269 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
270 $updated = $wpdb->query(
271 $wpdb->prepare(
272 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
273 $this->lock_value,
274 $key,
275 $existing
276 )
277 );
278
279 if ( 1 === (int) $updated ) {
280 $this->lock_driver = 'database';
281 return true;
282 }
283
284 return false;
285 }
286
287 /**
288 * Release the lock owned by this instance.
289 *
290 * @return void
291 */
292 private function release_lock() {
293 if ( 'cache' === $this->lock_driver ) {
294 $current_token = wp_cache_get( $this->get_lock_key(), self::LOCK_CACHE_GROUP );
295
296 if ( $current_token === $this->lock_token ) {
297 wp_cache_delete( $this->get_lock_key(), self::LOCK_CACHE_GROUP );
298 }
299 }
300
301 if ( 'database' === $this->lock_driver ) {
302 global $wpdb;
303
304 // Delete only the lock value created by this instance.
305 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
306 $wpdb->query(
307 $wpdb->prepare(
308 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
309 $this->get_lock_key(),
310 $this->lock_value
311 )
312 );
313 }
314
315 $this->lock_token = '';
316 $this->lock_value = '';
317 $this->lock_driver = '';
318 }
319
320 /**
321 * Get the migration lock key.
322 *
323 * @return string Lock key.
324 */
325 private function get_lock_key() {
326 return $this->product->get_key() . self::LOCK_SUFFIX;
327 }
328
329 /**
330 * Log a migration failure.
331 *
332 * @param string $name Migration name or operation.
333 * @param string $message Error message.
334 *
335 * @return void
336 */
337 private function log_error( $name, $message ) {
338 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
339 error_log( 'ThemeIsle SDK Migrator: failed to run ' . $name . ': ' . $message );
340 }
341
342 /**
343 * Roll back a single migration by name.
344 *
345 * Calls down() on the migration and removes it from the ran list so it will
346 * be picked up again on the next upgrade. This method is never called
347 * automatically — products invoke it explicitly when needed.
348 *
349 * @param string $migration_name Migration basename without .php extension.
350 *
351 * @return bool True if rolled back successfully, false if not found or not previously run.
352 */
353 public function rollback( $migration_name ) {
354 $option_key = $this->product->get_key() . self::OPTION_SUFFIX;
355 $ran = get_option( $option_key, array() );
356
357 if ( ! in_array( $migration_name, $ran, true ) ) {
358 return false;
359 }
360
361 $path = $this->get_migrations_path();
362 $file = trailingslashit( $path ) . $migration_name . '.php';
363
364 if ( ! is_file( $file ) ) {
365 return false;
366 }
367
368 try {
369 $migration = require $file;
370
371 if ( ! ( $migration instanceof Abstract_Migration ) ) {
372 return false;
373 }
374
375 $migration->down();
376 update_option( $option_key, array_values( array_diff( $ran, array( $migration_name ) ) ) );
377
378 return true;
379 } catch ( \Throwable $e ) {
380 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
381 error_log( 'ThemeIsle SDK Migrator: failed to roll back ' . $migration_name . ': ' . $e->getMessage() );
382
383 return false;
384 }
385 }
386
387 /**
388 * Get the migrations directory path for the current product.
389 *
390 * Products register their path via the `{slug}_sdk_migrations_path` filter.
391 *
392 * @return string Absolute path to the migrations directory, or empty string.
393 */
394 private function get_migrations_path() {
395 return (string) apply_filters( $this->product->get_slug() . '_sdk_migrations_path', '' );
396 }
397 }
398