table = $wpdb->prefix . 'shiftora_restore_ledger'; } /** * The CREATE TABLE statement for dbDelta. It omits IF NOT EXISTS because * dbDelta cannot diff a declaration that has it, and upgrades silently no-op. */ public static function table_ddl( string $table, string $charset_collate ): string { return "CREATE TABLE `{$table}` ( backup_id BIGINT UNSIGNED NOT NULL, dump_key VARCHAR(64) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT 'loading', verified_tables_json TEXT NOT NULL, plan_json TEXT NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (backup_id) ) {$charset_collate}"; } /** * Opens or resets a session for a backup. A retry drops the earlier verified * list, which is right because this only runs when the dump is about to be (re)loaded. * * @param list $plan Bare table names in dump order. */ public function begin( int $backup_id, array $plan, string $dump_key = '' ): void { $this->upsert( $backup_id, 'loading', [], $plan, $dump_key ); } /** Records that a staging table's row count matched the dump. */ public function mark_verified( int $backup_id, string $table ): void { $row = $this->row( $backup_id ); if ( null === $row ) { throw new RuntimeException( "Restore ledger has no session for backup {$backup_id}; begin() must run first." ); } $verified = $this->decode_list( $row->verified_tables_json ); if ( ! in_array( $table, $verified, true ) ) { $verified[] = $table; $this->upsert( $backup_id, (string) $row->status, $verified, $this->decode_list( $row->plan_json ), (string) ( $row->dump_key ?? '' ) ); } } /** Marks every staged table verified, immediately before promotion. */ public function mark_ready( int $backup_id ): void { $this->set_status( $backup_id, 'ready' ); } /** Records a finished promotion so a later call knows the work is done. */ public function commit( int $backup_id ): void { $this->set_status( $backup_id, 'committed' ); } /** Drops the session for a backup so the next restore loads the dump from the start. */ public function forget( int $backup_id ): void { $result = $this->wpdb->delete( $this->table, [ 'backup_id' => $backup_id ], [ '%d' ] ); if ( false === $result ) { throw new RuntimeException( "Failed to clear restore ledger for backup {$backup_id}: {$this->wpdb->last_error}" ); } } /** Current session status, or null when the backup has no session yet. */ public function status( int $backup_id ): ?string { $row = $this->row( $backup_id ); return null === $row ? null : (string) $row->status; } /** The dump identity this session is restoring, or '' for a row without one. */ public function dump_key( int $backup_id ): string { $row = $this->row( $backup_id ); return null === $row ? '' : (string) ( $row->dump_key ?? '' ); } /** * Whether $dump_key is the dump this session already promoted. * * backup_id alone is not enough: it is reused after the backups table is * restored from an earlier copy or truncated, so an old committed row can * claim the id of a different dump. An empty key never matches: re-promoting * is redundant work at worst, skipping is silent data loss. */ public function committed_dump( int $backup_id, string $dump_key ): bool { $row = $this->row( $backup_id ); return null !== $row && 'committed' === (string) $row->status && '' !== $dump_key && hash_equals( $dump_key, (string) ( $row->dump_key ?? '' ) ); } /** Identity of a dump: the filename carries a timestamp and random suffix, and the size catches a replaced file. */ public static function key_for_dump( string $file_path, int $file_size ): string { return substr( hash( 'sha256', $file_path . '|' . $file_size ), 0, 64 ); } /** * Tables already verified, in confirmation order; a retry skips reloading them. * * @return list */ public function verified( int $backup_id ): array { $row = $this->row( $backup_id ); return null === $row ? [] : $this->decode_list( $row->verified_tables_json ); } /** * The table plan recorded with begin(). * * @return list */ public function plan( int $backup_id ): array { $row = $this->row( $backup_id ); return null === $row ? [] : $this->decode_list( $row->plan_json ); } private function set_status( int $backup_id, string $status ): void { $row = $this->row( $backup_id ); if ( null === $row ) { throw new RuntimeException( "Restore ledger has no session for backup {$backup_id}; begin() must run first." ); } $this->upsert( $backup_id, $status, $this->decode_list( $row->verified_tables_json ), $this->decode_list( $row->plan_json ), (string) ( $row->dump_key ?? '' ) ); } /** * @param list $verified * @param list $plan */ private function upsert( int $backup_id, string $status, array $verified, array $plan, string $dump_key = '' ): void { $wpdb = $this->wpdb; $now = current_time( 'mysql', true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $result = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (backup_id, dump_key, status, verified_tables_json, plan_json, updated_at) VALUES (%d, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE dump_key = VALUES(dump_key), status = VALUES(status), verified_tables_json = VALUES(verified_tables_json), plan_json = VALUES(plan_json), updated_at = VALUES(updated_at)', $this->table, $backup_id, $dump_key, $status, wp_json_encode( $verified ), wp_json_encode( $plan ), $now ) ); if ( false === $result ) { throw new RuntimeException( "Failed to write restore ledger for backup {$backup_id}: {$this->wpdb->last_error}" ); } } private function row( int $backup_id ): ?object { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $row = $wpdb->get_row( $wpdb->prepare( 'SELECT backup_id, dump_key, status, verified_tables_json, plan_json FROM %i WHERE backup_id = %d', $this->table, $backup_id ) ); if ( '' !== $this->wpdb->last_error ) { throw new RuntimeException( "Failed to read restore ledger for backup {$backup_id}: {$this->wpdb->last_error}" ); } return is_object( $row ) ? $row : null; } /** * @param string $json JSON encoded by wp_json_encode(). * @return list */ private function decode_list( string $json ): array { $decoded = json_decode( $json, true ); return is_array( $decoded ) ? array_values( $decoded ) : []; } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped }