*/ private static array $ready_tables = []; public function __construct( private readonly \wpdb $wpdb ) {} private function table(): string { return $this->wpdb->prefix . 'shiftora_import_chunks'; } /** * The ledger's CREATE TABLE statement, in the plain form Installer::create_tables() * feeds to dbDelta (which will not alter a table declared IF NOT EXISTS). The * runtime path adds that clause via provision_ddl(). */ public static function table_ddl( string $table, string $charset_collate ): string { return "CREATE TABLE `{$table}` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, migration_id BIGINT UNSIGNED NOT NULL, transfer_uuid CHAR(32) NOT NULL DEFAULT '', chunk_key CHAR(64) NOT NULL, table_name VARCHAR(191) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT 'pending', rows_inserted BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY chunk (migration_id, transfer_uuid, chunk_key), KEY status (status) ) {$charset_collate}"; } /** * The self-creating variant: IF NOT EXISTS so ensure_table() does not fail on an * existing table. Fine raw because it only provisions. */ private static function provision_ddl( string $table, string $charset_collate ): string { return str_replace( 'CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS ', self::table_ddl( $table, $charset_collate ) ); } /** * Create the ledger table if it does not exist yet. Public so the caller can run * it before opening the chunk transaction: CREATE TABLE implicitly commits on * MySQL, which would end the transaction and make the rollback a no-op. * * @throws RuntimeException When the table cannot be created. */ public function ensure_table(): void { $table = $this->table(); $key = $this->wpdb->dbname . '|' . $table; if ( isset( self::$ready_tables[ $key ] ) ) { return; } $collate = $this->wpdb->get_charset_collate(); $ddl = self::provision_ddl( $table, $collate ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- this plugin's own CREATE TABLE IF NOT EXISTS, built from the site prefix; DDL cannot be bound. $result = $this->wpdb->query( $ddl ); if ( false === $result ) { throw new RuntimeException( "Failed to create import chunk ledger `{$table}`: {$this->wpdb->last_error}" ); } self::$ready_tables[ $key ] = true; } public function committed_rows( int $migration_id, string $chunk_key, ?string $transfer_uuid = null ): ?int { $this->ensure_table(); $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $value = $wpdb->get_var( $wpdb->prepare( "SELECT rows_inserted FROM %i WHERE migration_id = %d AND transfer_uuid = %s AND chunk_key = %s AND status = 'committed' LIMIT 1", $this->table(), $migration_id, (string) ( $transfer_uuid ?? '' ), $chunk_key ) ); // A failed read must be a failure, not "no receipt", or a chunk could // re-apply data the ledger already holds. if ( '' !== $this->wpdb->last_error ) { throw new RuntimeException( "Failed to read committed receipt for chunk {$chunk_key}: {$this->wpdb->last_error}" ); } return ( null === $value ) ? null : (int) $value; } public function has_pending( int $migration_id, string $chunk_key, ?string $transfer_uuid = null ): bool { $this->ensure_table(); $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE migration_id = %d AND transfer_uuid = %s AND chunk_key = %s AND status = 'pending'", $this->table(), $migration_id, (string) ( $transfer_uuid ?? '' ), $chunk_key ) ); if ( '' !== $this->wpdb->last_error ) { throw new RuntimeException( "Failed to read pending claim for chunk {$chunk_key}: {$this->wpdb->last_error}" ); } return $count > 0; } public function pending_updated_at( int $migration_id, string $chunk_key, ?string $transfer_uuid = null ): ?int { $this->ensure_table(); $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $value = $wpdb->get_var( $wpdb->prepare( "SELECT updated_at FROM %i WHERE migration_id = %d AND transfer_uuid = %s AND chunk_key = %s AND status = 'pending' LIMIT 1", $this->table(), $migration_id, (string) ( $transfer_uuid ?? '' ), $chunk_key ) ); if ( '' !== $this->wpdb->last_error ) { throw new RuntimeException( "Failed to read pending claim age for chunk {$chunk_key}: {$this->wpdb->last_error}" ); } if ( null === $value ) { return null; } // Accept both a raw timestamp and a MySQL DATETIME string. if ( is_numeric( $value ) ) { return (int) $value; } $ts = strtotime( (string) $value ); return ( false === $ts ) ? null : $ts; } public function clear_pending( int $migration_id, string $chunk_key, ?string $transfer_uuid = null ): void { $this->ensure_table(); $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $result = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE migration_id = %d AND transfer_uuid = %s AND chunk_key = %s AND status = 'pending'", $this->table(), $migration_id, (string) ( $transfer_uuid ?? '' ), $chunk_key ) ); if ( false === $result ) { throw new RuntimeException( "Failed to clear pending import receipt for chunk {$chunk_key}: {$this->wpdb->last_error}" ); } } public function set_pending( int $migration_id, string $chunk_key, string $table, ?string $transfer_uuid = null ): bool { $this->ensure_table(); $wpdb = $this->wpdb; $now = current_time( 'mysql', true ); // The identity is always bound as %s. Unscoped receipts use the empty // string, since a NULL would slip past the unique key and let a twin row // land beside the receipt. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (migration_id, transfer_uuid, chunk_key, table_name, status, rows_inserted, created_at, updated_at) VALUES (%d, %s, %s, %s, 'pending', 0, %s, %s)", $this->table(), $migration_id, (string) ( $transfer_uuid ?? '' ), $chunk_key, $table, $now, $now ) ); if ( false === $result ) { throw new RuntimeException( "Failed to mark import chunk {$chunk_key} pending: {$this->wpdb->last_error}" ); } // INSERT IGNORE returns the affected row count: 1 means this call created // the claim, 0 means the unique key already held a row for the chunk // (another worker claimed it, or it is committed). Only a winner may // execute. return 1 === $result; } public function set_committed( int $migration_id, string $chunk_key, int $rows_inserted, ?string $transfer_uuid = null ): void { $this->ensure_table(); $wpdb = $this->wpdb; $now = current_time( 'mysql', true ); // %s binding keeps the empty-string namespace. A NULL identity would make // the ON DUPLICATE arm unreachable and insert a separate committed row // beside the pending one. The update collapses the pending row in place. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $result = $wpdb->query( $wpdb->prepare( "INSERT INTO %i (migration_id, transfer_uuid, chunk_key, table_name, status, rows_inserted, created_at, updated_at) VALUES (%d, %s, %s, '', 'committed', %d, %s, %s) ON DUPLICATE KEY UPDATE status = 'committed', rows_inserted = VALUES(rows_inserted), updated_at = VALUES(updated_at)", $this->table(), $migration_id, (string) ( $transfer_uuid ?? '' ), $chunk_key, $rows_inserted, $now, $now ) ); // An unrecorded receipt must fail the chunk, never report success while // the ledger stays empty. Inside the import transaction this throws before // COMMIT, so the caller rolls the data back too. if ( false === $result ) { throw new RuntimeException( "Failed to record committed receipt for chunk {$chunk_key}: {$this->wpdb->last_error}" ); } } public function clear_migration( int $migration_id, ?string $transfer_uuid = null ): void { $this->ensure_table(); $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $result = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE migration_id = %d AND transfer_uuid = %s', $this->table(), $migration_id, (string) ( $transfer_uuid ?? '' ) ) ); if ( false === $result ) { throw new RuntimeException( "Failed to clear import receipts for migration {$migration_id}: {$this->wpdb->last_error}" ); } } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped }