restorer = $restorer ?? new StreamingSqlRestorer( $this->wpdb ); } /** * Creates a manual (on-demand) backup; migration_id is stored as 0. * * With no tables given, the scope is the install's own prefix minus Shiftora's * operational and Action Scheduler tables, passed through `shiftora_backup_tables`. * * @param array{ * tables?: string[], * label?: string, * exclude_spam?: bool, * exclude_transients?: bool, * compress?: bool, * } $options Backup options. * @throws RuntimeException On failure. */ public function create_manual( array $options ): BackupRecord { $wpdb = $this->wpdb; // Default to the install's own tables; other prefixes sharing the database are // not part of this site. Base tables only: views have no CREATE TABLE to dump. if ( empty( $options['tables'] ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $all = $wpdb->get_col( "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'" ); $all = is_array( $all ) ? $all : []; $options['tables'] = array_values( array_filter( $all, fn( string $table ): bool => str_starts_with( $table, (string) $wpdb->prefix ) ) ); } $tables = $this->filter_backup_tables( array_map( 'strval', $options['tables'] ) ); $tables = (array) apply_filters( 'shiftora_backup_tables', $tables ); $options['tables'] = array_map( 'strval', $tables ); if ( empty( $options['tables'] ) ) { throw new RuntimeException( 'No tables found to back up.' ); } $label = sanitize_text_field( $options['label'] ?? ( 'manual-' . gmdate( 'Ymd-His' ) ) ); $compress = ! empty( $options['compress'] ); $ext = $compress ? '.sql.gz' : '.sql'; $dir = $this->backup_dir(); $filename = PrivateFileName::make( 'shiftora-manual-' . gmdate( 'Ymd-His' ), $ext ); $filepath = $dir . DIRECTORY_SEPARATOR . $filename; $dump_options = [ 'exclude_spam' => ! empty( $options['exclude_spam'] ), 'exclude_transients' => ! empty( $options['exclude_transients'] ), 'compress' => $compress, ]; $file_size = $this->dumper->dump( $options['tables'], $filepath, $dump_options ); $tables_json = wp_json_encode( $options['tables'] ); $inserted = $this->wpdb->insert( $this->wpdb->prefix . 'shiftora_backups', [ 'migration_id' => 0, 'label' => $label, 'file_path' => $filepath, 'file_size' => $file_size, 'tables' => $tables_json, 'tables_json' => $tables_json, 'status' => 'complete', 'created_at' => current_time( 'mysql', true ), ], [ '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s' ] ); if ( ! $inserted ) { wp_delete_file( $filepath ); throw new RuntimeException( 'Failed to record manual backup in database.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $record = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_backups', $wpdb->insert_id ) ); if ( ! $record ) { throw new RuntimeException( 'Could not retrieve newly created backup record.' ); } return BackupRecord::from_row( $record ); } /** * Restores a backup by running its SQL dump against the local DB. * * A dump the ledger already records as promoted is skipped, so an automatic * recovery that runs twice is harmless. An operator pressing Restore again * wants it reapplied, so $again clears that record first. * * @param bool $again Restore even when this dump was already promoted. * @return string self::OUTCOME_RESTORED, or self::OUTCOME_SKIPPED when already promoted. * @throws RuntimeException If the file is missing or SQL execution fails. */ public function restore( BackupRecord $record, bool $again = false ): string { if ( ! $record->is_restorable() ) { throw new RuntimeException( $record->file_exists() ? sprintf( 'Backup file size mismatch: found %d bytes, record reports %d.', (int) @filesize( $record->file_path ), $record->file_size ) : "Backup file not found: {$record->file_path}" ); } // A staged restore rebuilds the dump in temporary tables and promotes them // only once every table verifies, so live tables are never half applied; the // ledger lets a retry resume. Only a committed row for this dump means // promotion already ran: backup_id is reused after the backups table is // restored from an earlier copy, so the id alone could skip a wanted restore. $ledger = new RestoreTable( $this->wpdb ); $dump = RestoreTable::key_for_dump( $record->file_path, $record->file_size ); if ( $ledger->committed_dump( $record->id, $dump ) ) { if ( ! $again ) { return self::OUTCOME_SKIPPED; } $ledger->forget( $record->id ); } $this->restorer->restore_from_file( $record->file_path, [ 'skip_errors' => false, 'staged' => true, 'ledger' => $ledger, 'backup_id' => $record->id, 'dump_key' => $dump, ] ); // A persistent object cache would keep serving the data the restore replaced. CacheFlusher::flush_silently(); return self::OUTCOME_RESTORED; } /** * Creates a backup of the given tables for a migration. * * @param string[] $tables Table names to back up. * @throws RuntimeException On failure. */ public function create( int $migration_id, array $tables ): BackupRecord { $tables = $this->filter_backup_tables( array_map( 'strval', $tables ) ); if ( empty( $tables ) ) { throw new RuntimeException( 'No tables provided for backup.' ); } $dir = $this->backup_dir(); $filename = PrivateFileName::make( sprintf( 'shiftora-backup-%d-%s', $migration_id, gmdate( 'Ymd-His' ) ), '.sql' ); $file_path = $dir . DIRECTORY_SEPARATOR . $filename; $file_size = $this->dumper->dump( $tables, $file_path ); $tables_json = wp_json_encode( $tables ); $inserted = $this->wpdb->insert( $this->wpdb->prefix . 'shiftora_backups', [ 'migration_id' => $migration_id, 'label' => 'migration-' . $migration_id, 'file_path' => $file_path, 'file_size' => $file_size, 'tables' => $tables_json, 'tables_json' => $tables_json, 'status' => 'complete', 'created_at' => current_time( 'mysql', true ), ], [ '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s' ] ); if ( ! $inserted ) { // Do not leave an orphaned dump file behind. wp_delete_file( $file_path ); throw new RuntimeException( 'Failed to record backup in database.' ); } $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $record = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_backups', $wpdb->insert_id ) ); if ( ! $record ) { throw new RuntimeException( 'Could not retrieve newly created backup record.' ); } return BackupRecord::from_row( $record ); } /** Retrieves a backup record by ID. */ public function find( int $id ): ?BackupRecord { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_backups', $id ) ); return $row ? BackupRecord::from_row( $row ) : null; } /** * Lists all backups for a migration, newest first. * * @return BackupRecord[] */ public function list_for_migration( int $migration_id ): array { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE migration_id = %d ORDER BY created_at DESC', $wpdb->prefix . 'shiftora_backups', $migration_id ) ); return array_map( fn( $row ) => BackupRecord::from_row( $row ), $rows ?? [] ); } /** Deletes a backup record and its dump file. */ public function delete( int $id ): void { $record = $this->find( $id ); if ( ! $record ) { return; } if ( file_exists( $record->file_path ) ) { wp_delete_file( $record->file_path ); } $this->wpdb->delete( $this->wpdb->prefix . 'shiftora_backups', [ 'id' => $id ], [ '%d' ] ); } /** * Returns the local tables that can be backed up. A fresh pull destination may * lack the requested tables; those are skipped so the migration can still start. * * @param string[] $tables * @return string[] */ public function prepare_backup_tables( array $tables ): array { $tables = $this->filter_backup_tables( $tables ); return array_values( array_filter( $tables, fn( string $table ): bool => $this->table_exists( $table ) ) ); } /** * Drops Shiftora's operational tables and Action Scheduler tables from a * candidate list; both hold per-install state that should not move. * * @param string[] $tables * @return string[] */ private function filter_backup_tables( array $tables ): array { return array_values( array_filter( $tables, fn( string $table ): bool => ! $this->is_shiftora_operational_table( $table ) && ! $this->is_action_scheduler_table( $table ) ) ); } /** Whether a table is owned by Shiftora's local bookkeeping. */ private function is_shiftora_operational_table( string $table ): bool { return str_starts_with( strtolower( $table ), strtolower( (string) $this->wpdb->prefix . 'shiftora_' ) ); } /** Whether a table belongs to Action Scheduler, which may sit under any prefix, so the name segment is matched. */ private function is_action_scheduler_table( string $table ): bool { $table = strtolower( $table ); return str_contains( $table, 'action_scheduler_' ) || str_contains( $table, 'actionscheduler_' ); } /** * Returns the absolute path to the backup directory, creating it if needed. * * @throws RuntimeException If the directory cannot be created or is not writable. */ public function backup_dir(): string { $dir = DataDirectory::ensure( DataDirectory::BACKUPS ); if ( '' === $dir ) { throw new RuntimeException( 'Cannot create or access the backup directory under uploads.' ); } clearstatcache( true, $dir ); if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) { throw new RuntimeException( "Backup directory is not writable: {$dir}" ); } return $dir; } /** Whether a local table exists and can be backed up. */ private function table_exists( string $table ): bool { $table = trim( $table ); if ( '' === $table ) { return false; } $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $result = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); return is_string( $result ) && '' !== $result; } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_is_writable }