Sfoglia il codice sorgente

feat: US-004 - PostgreSQL Node - CRUD Operations

Add full CRUD operations support for PostgreSQL node:
- Operation selector: Select, Insert, Update, Delete, Upsert, Bulk Insert, Custom SQL
- Select: column selection, WHERE conditions, LIMIT, ORDER BY
- Insert: map input fields to table columns with RETURNING *
- Update: map fields to columns with WHERE conditions
- Delete: specify WHERE conditions (required for safety)
- Upsert: PostgreSQL ON CONFLICT syntax with conflict key and update fields
- Bulk Insert: accepts array of objects, batched inserts
- Custom SQL: free-text SQL with $1, $2 parameterized query support
- Output: array of objects plus metadata (affectedRows, insertId, rowCount)
- All operations use parameterized queries to prevent SQL injection

Also adds PostgreSQL credential type to the WebUI credentials page for
creating and managing PostgreSQL connection credentials.
fszontagh 6 mesi fa
parent
commit
e5aabbe784

+ 3 - 3
.ralph-tui/session-meta.json

@@ -2,13 +2,13 @@
   "id": "ecd9252a-67c5-45c7-b431-6ef24fdff189",
   "status": "running",
   "startedAt": "2026-01-28T14:25:22.717Z",
-  "updatedAt": "2026-01-28T14:57:29.932Z",
+  "updatedAt": "2026-01-28T15:00:06.449Z",
   "agentPlugin": "claude",
   "trackerPlugin": "json",
   "prdPath": "./tasks/prd.json",
-  "currentIteration": 1,
+  "currentIteration": 2,
   "maxIterations": 10,
   "totalTasks": 0,
-  "tasksCompleted": 1,
+  "tasksCompleted": 2,
   "cwd": "/data/smartbotic"
 }

+ 15 - 5
.ralph-tui/session.json

@@ -3,10 +3,10 @@
   "sessionId": "ecd9252a-67c5-45c7-b431-6ef24fdff189",
   "status": "running",
   "startedAt": "2026-01-28T14:25:25.505Z",
-  "updatedAt": "2026-01-28T15:00:05.958Z",
-  "currentIteration": 1,
+  "updatedAt": "2026-01-28T15:24:48.964Z",
+  "currentIteration": 2,
   "maxIterations": 10,
-  "tasksCompleted": 1,
+  "tasksCompleted": 2,
   "isPaused": false,
   "agentPlugin": "claude",
   "trackerState": {
@@ -23,8 +23,8 @@
       {
         "id": "US-002",
         "title": "MySQL Node - CRUD Operations",
-        "status": "open",
-        "completedInSession": false
+        "status": "completed",
+        "completedInSession": true
       },
       {
         "id": "US-003",
@@ -110,6 +110,16 @@
       "durationMs": 1860213,
       "startedAt": "2026-01-28T14:25:29.073Z",
       "endedAt": "2026-01-28T14:56:29.286Z"
+    },
+    {
+      "iteration": 2,
+      "status": "completed",
+      "taskId": "US-002",
+      "taskTitle": "MySQL Node - CRUD Operations",
+      "taskCompleted": true,
+      "durationMs": 155018,
+      "startedAt": "2026-01-28T14:57:30.935Z",
+      "endedAt": "2026-01-28T15:00:05.953Z"
     }
   ],
   "skippedTaskIds": [],

+ 11 - 0
CMakeLists.txt

@@ -230,6 +230,11 @@ if(MYSQL_CLIENT_FOUND)
     list(APPEND RUNNER_SOURCES src/runner/mysql/mysql_client.cpp)
 endif()
 
+# Add PostgreSQL client if library is found
+if(POSTGRESQL_CLIENT_FOUND)
+    list(APPEND RUNNER_SOURCES src/runner/postgresql/postgresql_client.cpp)
+endif()
+
 add_executable(smartbotic-runner ${RUNNER_SOURCES})
 target_include_directories(smartbotic-runner PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src
@@ -253,6 +258,12 @@ if(MYSQL_CLIENT_FOUND)
     target_compile_definitions(smartbotic-runner PRIVATE MYSQL_SUPPORT=1)
 endif()
 
+# Add PostgreSQL client library if found
+if(POSTGRESQL_CLIENT_FOUND)
+    target_link_libraries(smartbotic-runner PRIVATE postgresql_client)
+    target_compile_definitions(smartbotic-runner PRIVATE POSTGRESQL_SUPPORT=1)
+endif()
+
 # Migration tool
 add_executable(smartbotic-migrate-nodes
     src/tools/migrate_nodes.cpp

+ 11 - 0
cmake/FindPackages.cmake

@@ -50,3 +50,14 @@ else()
         message(STATUS "MySQL/MariaDB client library not found - MySQL node support disabled")
     endif()
 endif()
+
+# PostgreSQL client (libpq)
+pkg_check_modules(libpq QUIET IMPORTED_TARGET libpq)
+if(libpq_FOUND)
+    add_library(postgresql_client ALIAS PkgConfig::libpq)
+    set(POSTGRESQL_CLIENT_FOUND TRUE CACHE BOOL "PostgreSQL client library found")
+    message(STATUS "Found PostgreSQL client library (libpq)")
+else()
+    set(POSTGRESQL_CLIENT_FOUND FALSE CACHE BOOL "PostgreSQL client library found")
+    message(STATUS "PostgreSQL client library not found - PostgreSQL node support disabled")
+endif()

+ 36 - 0
lib/credentials/credential_client.cpp

@@ -117,6 +117,42 @@ Result<MysqlData> CredentialClient::getMysqlCredentials(const std::string& crede
     return data;
 }
 
+Result<PostgresqlData> CredentialClient::getPostgresqlCredentials(const std::string& credential_id,
+                                                                   const std::string& workflow_id) {
+    proto::GetPostgresqlCredentialsRequest request;
+    request.set_credential_id(credential_id);
+    request.set_workflow_id(workflow_id);
+
+    proto::GetPostgresqlCredentialsResponse response;
+    grpc::ClientContext context;
+
+    // Set timeout
+    auto deadline = std::chrono::system_clock::now() +
+                   std::chrono::milliseconds(config_.timeout_ms);
+    context.set_deadline(deadline);
+
+    grpc::Status status = stub_->GetPostgresqlCredentials(&context, request, &response);
+
+    if (!status.ok()) {
+        return Error(ErrorCode::Unavailable,
+                    "Credential service error: " + status.error_message());
+    }
+
+    if (!response.success()) {
+        return Error(ErrorCode::NotFound, response.error());
+    }
+
+    PostgresqlData data;
+    data.host = response.host();
+    data.port = response.port();
+    data.username = response.username();
+    data.password = response.password();
+    data.database = response.database();
+    data.use_ssl = response.use_ssl();
+
+    return data;
+}
+
 Result<std::vector<CredentialInfo>> CredentialClient::listCredentials(const std::string& workflow_id) {
     proto::ListCredentialsRequest request;
     request.set_workflow_id(workflow_id);

+ 4 - 0
lib/credentials/credential_client.hpp

@@ -34,6 +34,10 @@ public:
     common::Result<MysqlData> getMysqlCredentials(const std::string& credential_id,
                                                    const std::string& workflow_id = "");
 
+    // Get PostgreSQL credentials
+    common::Result<PostgresqlData> getPostgresqlCredentials(const std::string& credential_id,
+                                                             const std::string& workflow_id = "");
+
     // List available credentials
     common::Result<std::vector<CredentialInfo>> listCredentials(const std::string& workflow_id = "");
 

+ 58 - 0
lib/credentials/credential_store.cpp

@@ -153,6 +153,21 @@ Result<CredentialInfo> CredentialStore::create(const CreateCredentialRequest& re
             };
             break;
         }
+        case CredentialType::Postgresql: {
+            auto data = PostgresqlData::fromJson(request.data);
+            if (data.host.empty() || data.username.empty() || data.password.empty()) {
+                return Error(ErrorCode::InvalidArgument, "PostgreSQL requires host, username, and password");
+            }
+            data_to_encrypt = data.toJson();
+            public_data = {
+                {"host", data.host},
+                {"port", data.port},
+                {"username", data.username},
+                {"database", data.database},
+                {"use_ssl", data.use_ssl}
+            };
+            break;
+        }
         default:
             return Error(ErrorCode::InvalidArgument, "Unsupported credential type");
     }
@@ -271,6 +286,18 @@ Result<void> CredentialStore::update(const std::string& id, const UpdateCredenti
                 };
                 break;
             }
+            case CredentialType::Postgresql: {
+                auto data = PostgresqlData::fromJson(request.data);
+                data_to_encrypt = data.toJson();
+                public_data = {
+                    {"host", data.host},
+                    {"port", data.port},
+                    {"username", data.username},
+                    {"database", data.database},
+                    {"use_ssl", data.use_ssl}
+                };
+                break;
+            }
         }
 
         auto encrypt_result = encryptData(data_to_encrypt);
@@ -390,6 +417,8 @@ Result<HttpAuth> CredentialStore::getHttpAuth(const std::string& id, const std::
             return Error(ErrorCode::InvalidArgument, "IMAP credentials cannot be used for HTTP authentication");
         case CredentialType::Mysql:
             return Error(ErrorCode::InvalidArgument, "MySQL credentials cannot be used for HTTP authentication");
+        case CredentialType::Postgresql:
+            return Error(ErrorCode::InvalidArgument, "PostgreSQL credentials cannot be used for HTTP authentication");
         default:
             return Error(ErrorCode::Internal, "Unsupported credential type");
     }
@@ -453,6 +482,35 @@ Result<MysqlData> CredentialStore::getMysqlCredentials(const std::string& id, co
     return MysqlData::fromJson(decrypt_result.value());
 }
 
+Result<PostgresqlData> CredentialStore::getPostgresqlCredentials(const std::string& id, const std::string& workflow_id) {
+    // Get document
+    auto get_result = storage_.get(COLLECTION, id);
+    if (get_result.failed()) {
+        return Error(ErrorCode::NotFound, "Credential not found: " + id);
+    }
+
+    auto doc = CredentialDocument::fromJson(get_result.value());
+
+    // Check it's a PostgreSQL credential
+    if (doc.metadata.type != CredentialType::Postgresql) {
+        return Error(ErrorCode::InvalidArgument, "Credential is not a PostgreSQL credential");
+    }
+
+    // Check workflow access
+    if (!hasWorkflowAccess(doc.metadata, workflow_id)) {
+        return Error(ErrorCode::PermissionDenied,
+            "Workflow " + workflow_id + " does not have access to credential " + id);
+    }
+
+    // Decrypt data
+    auto decrypt_result = decryptData(doc.encrypted_data);
+    if (decrypt_result.failed()) {
+        return decrypt_result.error();
+    }
+
+    return PostgresqlData::fromJson(decrypt_result.value());
+}
+
 // Curl callback for response
 static size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
     auto* response = static_cast<std::string*>(userdata);

+ 3 - 0
lib/credentials/credential_store.hpp

@@ -40,6 +40,9 @@ public:
     // Get MySQL credentials (checks workflow access)
     common::Result<MysqlData> getMysqlCredentials(const std::string& id, const std::string& workflow_id = "");
 
+    // Get PostgreSQL credentials (checks workflow access)
+    common::Result<PostgresqlData> getPostgresqlCredentials(const std::string& id, const std::string& workflow_id = "");
+
     // OAuth2 token refresh
     common::Result<void> refreshOAuth2Token(const std::string& id);
 

+ 25 - 0
lib/credentials/credential_types.cpp

@@ -15,6 +15,7 @@ std::string credentialTypeToString(CredentialType type) {
         case CredentialType::OAuth2: return "oauth2";
         case CredentialType::Imap: return "imap";
         case CredentialType::Mysql: return "mysql";
+        case CredentialType::Postgresql: return "postgresql";
         default: return "unknown";
     }
 }
@@ -26,6 +27,7 @@ CredentialType credentialTypeFromString(const std::string& str) {
     if (str == "oauth2") return CredentialType::OAuth2;
     if (str == "imap") return CredentialType::Imap;
     if (str == "mysql") return CredentialType::Mysql;
+    if (str == "postgresql") return CredentialType::Postgresql;
     throw std::invalid_argument("Unknown credential type: " + str);
 }
 
@@ -208,6 +210,29 @@ MysqlData MysqlData::fromJson(const nlohmann::json& j) {
     return data;
 }
 
+// PostgresqlData
+nlohmann::json PostgresqlData::toJson() const {
+    return {
+        {"host", host},
+        {"port", port},
+        {"username", username},
+        {"password", password},
+        {"database", database},
+        {"useSsl", use_ssl}
+    };
+}
+
+PostgresqlData PostgresqlData::fromJson(const nlohmann::json& j) {
+    PostgresqlData data;
+    data.host = j.value("host", "");
+    data.port = j.value("port", 5432);
+    data.username = j.value("username", "");
+    data.password = j.value("password", "");
+    data.database = j.value("database", "");
+    data.use_ssl = j.value("useSsl", j.value("use_ssl", false));
+    return data;
+}
+
 // CredentialMetadata
 nlohmann::json CredentialMetadata::toJson() const {
     // Note: id, createdAt, updatedAt are managed by database as _id, _createdAt, _updatedAt

+ 15 - 1
lib/credentials/credential_types.hpp

@@ -14,7 +14,8 @@ enum class CredentialType {
     ApiKey,     // API Key: header name + key value
     OAuth2,     // OAuth2: client credentials with token refresh
     Imap,       // IMAP: host + port + username + password + SSL
-    Mysql       // MySQL: host + port + username + password + database
+    Mysql,      // MySQL: host + port + username + password + database
+    Postgresql  // PostgreSQL: host + port + username + password + database
 };
 
 std::string credentialTypeToString(CredentialType type);
@@ -105,6 +106,19 @@ struct MysqlData {
     static MysqlData fromJson(const nlohmann::json& j);
 };
 
+// PostgreSQL data (stored encrypted)
+struct PostgresqlData {
+    std::string host;
+    int port = 5432;              // Default PostgreSQL port
+    std::string username;
+    std::string password;
+    std::string database;
+    bool use_ssl = false;         // Use SSL/TLS
+
+    nlohmann::json toJson() const;
+    static PostgresqlData fromJson(const nlohmann::json& j);
+};
+
 // Credential metadata (not encrypted, stored directly)
 struct CredentialMetadata {
     std::string id;

+ 648 - 0
nodes/postgresql/postgresql-query.js

@@ -0,0 +1,648 @@
+/**
+ * @node postgresql-query
+ * @name PostgreSQL Query
+ * @category database
+ * @version 1.0.0
+ * @description Execute CRUD operations on PostgreSQL databases with parameterized queries
+ * @icon database
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        credentialId: {
+            type: 'string',
+            title: 'PostgreSQL Credential',
+            description: 'Select the PostgreSQL credential to use for connection',
+            dynamicOptions: {
+                source: 'credentials',
+                filter: { type: 'postgresql' }
+            }
+        },
+        database: {
+            type: 'string',
+            title: 'Database',
+            description: 'Database name (overrides credential default if set)'
+        },
+        table: {
+            type: 'string',
+            title: 'Table',
+            description: 'Table name for query operations'
+        },
+        operation: {
+            type: 'string',
+            title: 'Operation',
+            description: 'Type of operation to perform',
+            enum: ['select', 'insert', 'update', 'delete', 'upsert', 'bulkInsert', 'customSql'],
+            enumLabels: ['Select', 'Insert', 'Update', 'Delete', 'Upsert', 'Bulk Insert', 'Custom SQL'],
+            default: 'select'
+        },
+        columns: {
+            type: 'array',
+            title: 'Columns',
+            description: 'Columns to select (empty for all)',
+            items: {
+                type: 'string'
+            },
+            showWhen: { field: 'operation', value: 'select' }
+        },
+        whereConditions: {
+            type: 'array',
+            title: 'WHERE Conditions',
+            description: 'Filter conditions for the query',
+            items: {
+                type: 'object',
+                properties: {
+                    column: { type: 'string', title: 'Column' },
+                    operator: {
+                        type: 'string',
+                        title: 'Operator',
+                        enum: ['=', '!=', '<', '>', '<=', '>=', 'LIKE', 'ILIKE', 'NOT LIKE', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL'],
+                        default: '='
+                    },
+                    value: { type: 'string', title: 'Value' }
+                }
+            },
+            showWhen: { field: 'operation', valueIn: ['select', 'update', 'delete'] }
+        },
+        orderBy: {
+            type: 'array',
+            title: 'ORDER BY',
+            description: 'Sorting options',
+            items: {
+                type: 'object',
+                properties: {
+                    column: { type: 'string', title: 'Column' },
+                    direction: {
+                        type: 'string',
+                        title: 'Direction',
+                        enum: ['ASC', 'DESC'],
+                        default: 'ASC'
+                    }
+                }
+            },
+            showWhen: { field: 'operation', value: 'select' }
+        },
+        limit: {
+            type: 'number',
+            title: 'Limit',
+            description: 'Maximum number of rows to return (0 for no limit)',
+            default: 100,
+            showWhen: { field: 'operation', value: 'select' }
+        },
+        offset: {
+            type: 'number',
+            title: 'Offset',
+            description: 'Number of rows to skip',
+            default: 0,
+            showWhen: { field: 'operation', value: 'select' }
+        },
+        insertData: {
+            type: 'string',
+            title: 'Data to Insert (JSON)',
+            description: 'JSON object with column:value pairs. Supports {{variable}} interpolation.',
+            showWhen: { field: 'operation', value: 'insert' }
+        },
+        updateData: {
+            type: 'string',
+            title: 'Data to Update (JSON)',
+            description: 'JSON object with column:value pairs to set. Supports {{variable}} interpolation.',
+            showWhen: { field: 'operation', value: 'update' }
+        },
+        upsertData: {
+            type: 'string',
+            title: 'Data to Upsert (JSON)',
+            description: 'JSON object with column:value pairs. Supports {{variable}} interpolation.',
+            showWhen: { field: 'operation', value: 'upsert' }
+        },
+        conflictKey: {
+            type: 'array',
+            title: 'Conflict Key Columns',
+            description: 'Columns that form the unique/primary key for conflict detection',
+            items: {
+                type: 'string'
+            },
+            showWhen: { field: 'operation', value: 'upsert' }
+        },
+        updateOnConflict: {
+            type: 'array',
+            title: 'Update Columns on Conflict',
+            description: 'Columns to update when a conflict is detected (empty for all non-key columns)',
+            items: {
+                type: 'string'
+            },
+            showWhen: { field: 'operation', value: 'upsert' }
+        },
+        bulkData: {
+            type: 'string',
+            title: 'Bulk Data (JSON Array)',
+            description: 'JSON array of objects to insert. Supports {{variable}} interpolation.',
+            showWhen: { field: 'operation', value: 'bulkInsert' }
+        },
+        batchSize: {
+            type: 'number',
+            title: 'Batch Size',
+            description: 'Number of rows per INSERT statement (0 for all at once)',
+            default: 100,
+            showWhen: { field: 'operation', value: 'bulkInsert' }
+        },
+        customQuery: {
+            type: 'string',
+            title: 'Custom SQL',
+            description: 'Custom SQL query with $1, $2, etc. placeholders for parameters. Supports {{variable}} interpolation.',
+            showWhen: { field: 'operation', value: 'customSql' }
+        },
+        parameters: {
+            type: 'array',
+            title: 'Query Parameters',
+            description: 'Parameters for $1, $2, etc. placeholders in custom SQL',
+            items: {
+                type: 'string'
+            },
+            showWhen: { field: 'operation', value: 'customSql' }
+        }
+    },
+    required: ['credentialId', 'operation']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: {
+            type: 'any',
+            description: 'Input data available for interpolation in queries'
+        },
+        parameters: {
+            type: 'array',
+            description: 'Override query parameters from input'
+        },
+        rows: {
+            type: 'array',
+            description: 'Array of objects for bulk insert (alternative to bulkData config)'
+        }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        rows: { type: 'array', description: 'Query result rows as array of objects' },
+        metadata: {
+            type: 'object',
+            description: 'Operation metadata',
+            properties: {
+                affectedRows: { type: 'number', description: 'Number of affected rows' },
+                insertId: { type: 'number', description: 'Last auto-generated insert ID (if using RETURNING)' },
+                rowCount: { type: 'number', description: 'Number of rows returned' }
+            }
+        },
+        columns: { type: 'array', description: 'Column names from the result' },
+        query: { type: 'string', description: 'The executed SQL query (for debugging)' }
+    }
+};
+
+const outputs = [
+    { name: 'main', displayName: 'Query Result', type: 'object', color: '#336791' }
+];
+
+function parseJsonData(jsonString, data, fieldName) {
+    if (!jsonString) {
+        throw new Error(fieldName + ' is required');
+    }
+
+    let parsed = jsonString;
+    if (typeof parsed === 'string') {
+        parsed = smartbotic.utils.interpolate(parsed, data);
+        try {
+            parsed = JSON.parse(parsed);
+        } catch (e) {
+            throw new Error(fieldName + ' must be valid JSON: ' + e.message);
+        }
+    }
+
+    return parsed;
+}
+
+function escapeIdentifier(name) {
+    // PostgreSQL uses double quotes for identifiers
+    return '"' + name.replace(/"/g, '""') + '"';
+}
+
+function buildWhereClause(conditions, data, paramOffset) {
+    if (!conditions || conditions.length === 0) {
+        return { clause: '', params: [], nextParam: paramOffset };
+    }
+
+    const clauses = [];
+    const params = [];
+    let paramIdx = paramOffset;
+
+    for (const condition of conditions) {
+        if (!condition.column) continue;
+
+        const col = escapeIdentifier(condition.column);
+        const op = condition.operator || '=';
+        let value = condition.value;
+
+        if (typeof value === 'string') {
+            value = smartbotic.utils.interpolate(value, data);
+        }
+
+        if (op === 'IS NULL') {
+            clauses.push(col + ' IS NULL');
+        } else if (op === 'IS NOT NULL') {
+            clauses.push(col + ' IS NOT NULL');
+        } else if (op === 'IN' || op === 'NOT IN') {
+            let values = value;
+            if (typeof values === 'string') {
+                try {
+                    values = JSON.parse(values);
+                } catch (e) {
+                    values = values.split(',').map(v => v.trim());
+                }
+            }
+            if (!Array.isArray(values)) {
+                values = [values];
+            }
+            const placeholders = values.map(() => '$' + (paramIdx++)).join(', ');
+            clauses.push(col + ' ' + op + ' (' + placeholders + ')');
+            params.push(...values);
+        } else {
+            clauses.push(col + ' ' + op + ' $' + (paramIdx++));
+            params.push(value);
+        }
+    }
+
+    return {
+        clause: clauses.length > 0 ? ' WHERE ' + clauses.join(' AND ') : '',
+        params,
+        nextParam: paramIdx
+    };
+}
+
+function buildSelectQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for SELECT operations');
+    }
+
+    const columns = config.columns && config.columns.length > 0
+        ? config.columns.map(escapeIdentifier).join(', ')
+        : '*';
+
+    let query = 'SELECT ' + columns + ' FROM ' + escapeIdentifier(table);
+    const params = [];
+    let paramIdx = 1;
+
+    const where = buildWhereClause(config.whereConditions, data, paramIdx);
+    query += where.clause;
+    params.push(...where.params);
+    paramIdx = where.nextParam;
+
+    if (config.orderBy && config.orderBy.length > 0) {
+        const orderClauses = config.orderBy
+            .filter(o => o.column)
+            .map(o => escapeIdentifier(o.column) + ' ' + (o.direction || 'ASC'));
+        if (orderClauses.length > 0) {
+            query += ' ORDER BY ' + orderClauses.join(', ');
+        }
+    }
+
+    if (config.limit && config.limit > 0) {
+        query += ' LIMIT $' + (paramIdx++);
+        params.push(config.limit);
+
+        if (config.offset && config.offset > 0) {
+            query += ' OFFSET $' + (paramIdx++);
+            params.push(config.offset);
+        }
+    }
+
+    return { query, params };
+}
+
+function buildInsertQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for INSERT operations');
+    }
+
+    const insertData = parseJsonData(config.insertData, data, 'Insert data');
+
+    if (!insertData || typeof insertData !== 'object' || Array.isArray(insertData)) {
+        throw new Error('Insert data must be a JSON object');
+    }
+
+    const columns = Object.keys(insertData);
+    if (columns.length === 0) {
+        throw new Error('Insert data cannot be empty');
+    }
+
+    const values = Object.values(insertData);
+    const placeholders = columns.map((_, i) => '$' + (i + 1)).join(', ');
+
+    // Use RETURNING to get the inserted ID if available
+    const query = 'INSERT INTO ' + escapeIdentifier(table) +
+        ' (' + columns.map(escapeIdentifier).join(', ') + ')' +
+        ' VALUES (' + placeholders + ')' +
+        ' RETURNING *';
+
+    return { query, params: values };
+}
+
+function buildUpdateQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for UPDATE operations');
+    }
+
+    const updateData = parseJsonData(config.updateData, data, 'Update data');
+
+    if (!updateData || typeof updateData !== 'object' || Array.isArray(updateData)) {
+        throw new Error('Update data must be a JSON object');
+    }
+
+    const columns = Object.keys(updateData);
+    if (columns.length === 0) {
+        throw new Error('Update data cannot be empty');
+    }
+
+    const values = Object.values(updateData);
+    let paramIdx = 1;
+    const setClause = columns.map(col => escapeIdentifier(col) + ' = $' + (paramIdx++)).join(', ');
+
+    let query = 'UPDATE ' + escapeIdentifier(table) + ' SET ' + setClause;
+    const params = [...values];
+
+    const where = buildWhereClause(config.whereConditions, data, paramIdx);
+    if (!where.clause) {
+        throw new Error('WHERE conditions are required for UPDATE operations to prevent accidental data modification');
+    }
+    query += where.clause;
+    params.push(...where.params);
+
+    return { query, params };
+}
+
+function buildDeleteQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for DELETE operations');
+    }
+
+    let query = 'DELETE FROM ' + escapeIdentifier(table);
+    const params = [];
+
+    const where = buildWhereClause(config.whereConditions, data, 1);
+    if (!where.clause) {
+        throw new Error('WHERE conditions are required for DELETE operations to prevent accidental data loss');
+    }
+    query += where.clause;
+    params.push(...where.params);
+
+    return { query, params };
+}
+
+function buildUpsertQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for UPSERT operations');
+    }
+
+    const upsertData = parseJsonData(config.upsertData, data, 'Upsert data');
+
+    if (!upsertData || typeof upsertData !== 'object' || Array.isArray(upsertData)) {
+        throw new Error('Upsert data must be a JSON object');
+    }
+
+    const columns = Object.keys(upsertData);
+    if (columns.length === 0) {
+        throw new Error('Upsert data cannot be empty');
+    }
+
+    const values = Object.values(upsertData);
+    const placeholders = columns.map((_, i) => '$' + (i + 1)).join(', ');
+
+    const conflictKeys = config.conflictKey || [];
+    if (conflictKeys.length === 0) {
+        throw new Error('Conflict key columns are required for UPSERT operations in PostgreSQL');
+    }
+
+    let updateColumns = config.updateOnConflict || [];
+
+    if (updateColumns.length === 0) {
+        updateColumns = columns.filter(col => !conflictKeys.includes(col));
+    }
+
+    if (updateColumns.length === 0) {
+        updateColumns = columns;
+    }
+
+    const conflictClause = conflictKeys.map(escapeIdentifier).join(', ');
+    const updateClause = updateColumns
+        .map(col => escapeIdentifier(col) + ' = EXCLUDED.' + escapeIdentifier(col))
+        .join(', ');
+
+    // PostgreSQL ON CONFLICT syntax
+    const query = 'INSERT INTO ' + escapeIdentifier(table) +
+        ' (' + columns.map(escapeIdentifier).join(', ') + ')' +
+        ' VALUES (' + placeholders + ')' +
+        ' ON CONFLICT (' + conflictClause + ')' +
+        ' DO UPDATE SET ' + updateClause +
+        ' RETURNING *';
+
+    return { query, params: values };
+}
+
+function buildBulkInsertQueries(config, data, input) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for BULK INSERT operations');
+    }
+
+    let bulkData = input.rows;
+    if (!bulkData) {
+        bulkData = parseJsonData(config.bulkData, data, 'Bulk data');
+    }
+
+    if (!Array.isArray(bulkData)) {
+        throw new Error('Bulk data must be a JSON array of objects');
+    }
+
+    if (bulkData.length === 0) {
+        throw new Error('Bulk data array cannot be empty');
+    }
+
+    const columns = Object.keys(bulkData[0]);
+    if (columns.length === 0) {
+        throw new Error('Bulk data objects cannot be empty');
+    }
+
+    const batchSize = config.batchSize > 0 ? config.batchSize : bulkData.length;
+    const queries = [];
+
+    for (let i = 0; i < bulkData.length; i += batchSize) {
+        const batch = bulkData.slice(i, i + batchSize);
+        const allValues = [];
+        const rowPlaceholders = [];
+        let paramIdx = 1;
+
+        for (const row of batch) {
+            const rowValues = columns.map(col => {
+                const val = row[col];
+                return val !== undefined ? val : null;
+            });
+            allValues.push(...rowValues);
+            const rowParams = columns.map(() => '$' + (paramIdx++));
+            rowPlaceholders.push('(' + rowParams.join(', ') + ')');
+        }
+
+        const query = 'INSERT INTO ' + escapeIdentifier(table) +
+            ' (' + columns.map(escapeIdentifier).join(', ') + ')' +
+            ' VALUES ' + rowPlaceholders.join(', ');
+
+        queries.push({ query, params: allValues, rowCount: batch.length });
+    }
+
+    return queries;
+}
+
+function buildCustomQuery(config, data, input) {
+    if (!config.customQuery) {
+        throw new Error('Custom SQL query is required');
+    }
+
+    const query = smartbotic.utils.interpolate(config.customQuery, data);
+
+    let params = input.parameters || config.parameters || [];
+    if (params.length > 0) {
+        params = params.map(p => {
+            if (typeof p === 'string') {
+                return smartbotic.utils.interpolate(p, data);
+            }
+            return p;
+        });
+    }
+
+    return { query, params };
+}
+
+module.exports = {
+    configSchema,
+    inputSchema,
+    outputSchema,
+    outputs,
+
+    async execute(config, input) {
+        const credentialId = config.credentialId;
+        const data = input.data || input;
+
+        if (!credentialId) {
+            throw new Error('PostgreSQL credential is required');
+        }
+
+        const operation = config.operation || 'select';
+        let totalAffectedRows = 0;
+        let lastInsertId = null;
+        let executedQuery = '';
+
+        if (operation === 'bulkInsert') {
+            const queries = buildBulkInsertQueries(config, data, input);
+
+            smartbotic.log.info('PostgreSQL BULK INSERT: ' + queries.length + ' batch(es)');
+
+            for (const queryInfo of queries) {
+                const options = {
+                    credentialId,
+                    database: config.database,
+                    query: queryInfo.query,
+                    params: queryInfo.params
+                };
+
+                const result = smartbotic.postgresql.query(options);
+
+                if (!result.success) {
+                    smartbotic.log.error('PostgreSQL bulk insert failed: ' + result.error);
+                    throw new Error('PostgreSQL bulk insert failed: ' + result.error);
+                }
+
+                totalAffectedRows += result.affectedRows || 0;
+                if (result.insertId) {
+                    lastInsertId = result.insertId;
+                }
+                executedQuery = queryInfo.query;
+            }
+
+            smartbotic.log.info('PostgreSQL BULK INSERT completed: ' + totalAffectedRows + ' rows inserted');
+
+            return {
+                rows: [],
+                metadata: {
+                    affectedRows: totalAffectedRows,
+                    insertId: lastInsertId,
+                    rowCount: 0
+                },
+                columns: [],
+                query: executedQuery
+            };
+        }
+
+        let queryInfo;
+
+        switch (operation) {
+            case 'select':
+                queryInfo = buildSelectQuery(config, data);
+                break;
+            case 'insert':
+                queryInfo = buildInsertQuery(config, data);
+                break;
+            case 'update':
+                queryInfo = buildUpdateQuery(config, data);
+                break;
+            case 'delete':
+                queryInfo = buildDeleteQuery(config, data);
+                break;
+            case 'upsert':
+                queryInfo = buildUpsertQuery(config, data);
+                break;
+            case 'customSql':
+                queryInfo = buildCustomQuery(config, data, input);
+                break;
+            default:
+                throw new Error('Invalid operation: ' + operation);
+        }
+
+        const { query, params } = queryInfo;
+
+        smartbotic.log.info('PostgreSQL ' + operation.toUpperCase() + ': ' + query.substring(0, 100) + (query.length > 100 ? '...' : ''));
+
+        const options = {
+            credentialId,
+            database: config.database,
+            query,
+            params
+        };
+
+        const result = smartbotic.postgresql.query(options);
+
+        if (!result.success) {
+            smartbotic.log.error('PostgreSQL query failed: ' + result.error);
+            throw new Error('PostgreSQL query failed: ' + result.error);
+        }
+
+        smartbotic.log.info('PostgreSQL ' + operation.toUpperCase() + ' completed: ' +
+            (result.affectedRows || 0) + ' affected, ' +
+            (result.rows || []).length + ' rows returned');
+
+        return {
+            rows: result.rows || [],
+            metadata: {
+                affectedRows: result.affectedRows || 0,
+                insertId: result.insertId || null,
+                rowCount: (result.rows || []).length
+            },
+            columns: result.columns || [],
+            query: query
+        };
+    }
+};

+ 22 - 1
proto/credentials.proto

@@ -15,6 +15,9 @@ service CredentialService {
     // Get MySQL credentials for a credential
     rpc GetMysqlCredentials(GetMysqlCredentialsRequest) returns (GetMysqlCredentialsResponse);
 
+    // Get PostgreSQL credentials for a credential
+    rpc GetPostgresqlCredentials(GetPostgresqlCredentialsRequest) returns (GetPostgresqlCredentialsResponse);
+
     // List available credentials (metadata only)
     rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse);
 }
@@ -37,7 +40,7 @@ message GetCredentialAuthResponse {
 message CredentialInfo {
     string id = 1;
     string name = 2;
-    string type = 3;  // "basic", "bearer", "api_key", "oauth2", "imap"
+    string type = 3;  // "basic", "bearer", "api_key", "oauth2", "imap", "mysql", "postgresql"
     string description = 4;
 }
 
@@ -76,6 +79,24 @@ message GetMysqlCredentialsResponse {
     string error = 8;  // Error message if success is false
 }
 
+// Request to get PostgreSQL credentials
+message GetPostgresqlCredentialsRequest {
+    string credential_id = 1;
+    string workflow_id = 2;  // For access control verification
+}
+
+// Response with PostgreSQL credentials
+message GetPostgresqlCredentialsResponse {
+    bool success = 1;
+    string host = 2;
+    int32 port = 3;
+    string username = 4;
+    string password = 5;
+    string database = 6;
+    bool use_ssl = 7;
+    string error = 8;  // Error message if success is false
+}
+
 // Request to list credentials
 message ListCredentialsRequest {
     string workflow_id = 1;  // Optional: filter by workflow access

+ 152 - 1
src/runner/engine/script_engine.cpp

@@ -5,6 +5,9 @@
 #ifdef MYSQL_SUPPORT
 #include "runner/mysql/mysql_client.hpp"
 #endif
+#ifdef POSTGRESQL_SUPPORT
+#include "runner/postgresql/postgresql_client.hpp"
+#endif
 #include <quickjs.h>
 #include <chrono>
 #include <thread>
@@ -2283,6 +2286,151 @@ static void setupMysqlAPI(JSContext* ctx, JSValue smartbotic) {
 }
 #endif
 
+#ifdef POSTGRESQL_SUPPORT
+// postgresql.query(options) - Execute PostgreSQL query
+static JSValue js_postgresql_query(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
+    const ScriptContext* script_ctx = getScriptContext(ctx);
+    if (!script_ctx || !script_ctx->postgresql_query) {
+        JSValue response = JS_NewObject(ctx);
+        JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, "PostgreSQL API not available"));
+        return response;
+    }
+
+    if (argc < 1 || !JS_IsObject(argv[0])) {
+        JSValue response = JS_NewObject(ctx);
+        JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, "postgresql.query requires an options object"));
+        return response;
+    }
+
+    JSValue options = argv[0];
+
+    // Get credential ID
+    JSValue cred_val = JS_GetPropertyStr(ctx, options, "credentialId");
+    if (JS_IsUndefined(cred_val)) {
+        JS_FreeValue(ctx, cred_val);
+        JSValue response = JS_NewObject(ctx);
+        JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, "postgresql.query requires credentialId"));
+        return response;
+    }
+    const char* cred_id = JS_ToCString(ctx, cred_val);
+    std::string credential_id(cred_id ? cred_id : "");
+    JS_FreeCString(ctx, cred_id);
+    JS_FreeValue(ctx, cred_val);
+
+    // Get optional database override
+    std::string database;
+    JSValue db_val = JS_GetPropertyStr(ctx, options, "database");
+    if (!JS_IsUndefined(db_val)) {
+        const char* db_str = JS_ToCString(ctx, db_val);
+        database = db_str ? db_str : "";
+        JS_FreeCString(ctx, db_str);
+    }
+    JS_FreeValue(ctx, db_val);
+
+    // Get query
+    JSValue query_val = JS_GetPropertyStr(ctx, options, "query");
+    if (JS_IsUndefined(query_val)) {
+        JS_FreeValue(ctx, query_val);
+        JSValue response = JS_NewObject(ctx);
+        JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, "postgresql.query requires query"));
+        return response;
+    }
+    const char* query_cstr = JS_ToCString(ctx, query_val);
+    std::string query(query_cstr ? query_cstr : "");
+    JS_FreeCString(ctx, query_cstr);
+    JS_FreeValue(ctx, query_val);
+
+    // Get optional parameters array
+    std::vector<nlohmann::json> params;
+    JSValue params_val = JS_GetPropertyStr(ctx, options, "params");
+    if (JS_IsArray(ctx, params_val)) {
+        uint32_t length;
+        JSValue len_val = JS_GetPropertyStr(ctx, params_val, "length");
+        JS_ToUint32(ctx, &length, len_val);
+        JS_FreeValue(ctx, len_val);
+
+        for (uint32_t i = 0; i < length; i++) {
+            JSValue elem = JS_GetPropertyUint32(ctx, params_val, i);
+
+            // Convert JS value to nlohmann::json
+            if (JS_IsNull(elem)) {
+                params.push_back(nullptr);
+            } else if (JS_IsBool(elem)) {
+                params.push_back(JS_ToBool(ctx, elem) ? true : false);
+            } else if (JS_IsNumber(elem)) {
+                double num;
+                JS_ToFloat64(ctx, &num, elem);
+                // Check if it's an integer
+                if (num == static_cast<int64_t>(num)) {
+                    params.push_back(static_cast<int64_t>(num));
+                } else {
+                    params.push_back(num);
+                }
+            } else {
+                const char* str = JS_ToCString(ctx, elem);
+                params.push_back(std::string(str ? str : ""));
+                JS_FreeCString(ctx, str);
+            }
+
+            JS_FreeValue(ctx, elem);
+        }
+    }
+    JS_FreeValue(ctx, params_val);
+
+    // Build query options
+    PostgresqlQueryOptions query_opts;
+    query_opts.credential_id = credential_id;
+    query_opts.database = database;
+    query_opts.query = query;
+    query_opts.params = params;
+
+    // Execute query
+    PostgresqlQueryResult result = script_ctx->postgresql_query(query_opts);
+
+    // Build response
+    JSValue response = JS_NewObject(ctx);
+    JS_SetPropertyStr(ctx, response, "success", result.success ? JS_TRUE : JS_FALSE);
+
+    if (result.success) {
+        // Rows array
+        JSValue rows = JS_NewArray(ctx);
+        for (size_t i = 0; i < result.rows.size(); i++) {
+            std::string row_json = result.rows[i].dump();
+            JSValue row = JS_ParseJSON(ctx, row_json.c_str(), row_json.size(), "<row>");
+            JS_SetPropertyUint32(ctx, rows, i, row);
+        }
+        JS_SetPropertyStr(ctx, response, "rows", rows);
+
+        // Columns array
+        JSValue columns = JS_NewArray(ctx);
+        for (size_t i = 0; i < result.columns.size(); i++) {
+            JS_SetPropertyUint32(ctx, columns, i, JS_NewString(ctx, result.columns[i].c_str()));
+        }
+        JS_SetPropertyStr(ctx, response, "columns", columns);
+
+        JS_SetPropertyStr(ctx, response, "affectedRows", JS_NewInt64(ctx, result.affected_rows));
+        JS_SetPropertyStr(ctx, response, "insertId", JS_NewInt64(ctx, result.insert_id));
+    } else {
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, result.error.c_str()));
+    }
+
+    return response;
+}
+
+// Setup PostgreSQL API on smartbotic namespace
+static void setupPostgresqlAPI(JSContext* ctx, JSValue smartbotic) {
+    JSValue postgresql = JS_NewObject(ctx);
+
+    JS_SetPropertyStr(ctx, postgresql, "query", JS_NewCFunction(ctx, js_postgresql_query, "query", 1));
+
+    JS_SetPropertyStr(ctx, smartbotic, "postgresql", postgresql);
+}
+#endif
+
 ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContext& context) {
     ScriptResult result;
     result.success = true;
@@ -2305,7 +2453,7 @@ ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContex
 
     JSValue global = JS_GetGlobalObject(context_);
 
-    // Set up storage, credentials, IMAP, and MySQL APIs on smartbotic namespace
+    // Set up storage, credentials, IMAP, MySQL, and PostgreSQL APIs on smartbotic namespace
     JSValue smartbotic = JS_GetPropertyStr(context_, global, "smartbotic");
     if (JS_IsObject(smartbotic)) {
         setupStorageAPI(context_, smartbotic);
@@ -2313,6 +2461,9 @@ ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContex
         setupImapAPI(context_, smartbotic);
 #ifdef MYSQL_SUPPORT
         setupMysqlAPI(context_, smartbotic);
+#endif
+#ifdef POSTGRESQL_SUPPORT
+        setupPostgresqlAPI(context_, smartbotic);
 #endif
     }
     JS_FreeValue(context_, smartbotic);

+ 32 - 0
src/runner/engine/script_engine.hpp

@@ -75,6 +75,34 @@ struct MysqlQueryResult {
     std::string error;
 };
 
+// PostgreSQL credential data for JS API
+struct PostgresqlCredential {
+    std::string host;
+    int port = 5432;
+    std::string username;
+    std::string password;
+    std::string database;
+    bool use_ssl = false;
+};
+
+// PostgreSQL query options
+struct PostgresqlQueryOptions {
+    std::string credential_id;
+    std::string database;  // Override credential database
+    std::string query;
+    std::vector<nlohmann::json> params;
+};
+
+// PostgreSQL query result
+struct PostgresqlQueryResult {
+    bool success = false;
+    std::vector<nlohmann::json> rows;
+    std::vector<std::string> columns;
+    int64_t affected_rows = 0;
+    int64_t insert_id = 0;
+    std::string error;
+};
+
 // Script execution context
 struct ScriptContext {
     std::string execution_id;
@@ -100,9 +128,13 @@ struct ScriptContext {
     std::function<common::Result<CredentialAuth>(const std::string&)> credentials_get;
     std::function<common::Result<ImapCredential>(const std::string&)> credentials_get_imap;
     std::function<common::Result<MysqlCredential>(const std::string&)> credentials_get_mysql;
+    std::function<common::Result<PostgresqlCredential>(const std::string&)> credentials_get_postgresql;
 
     // MySQL operations
     std::function<MysqlQueryResult(const MysqlQueryOptions&)> mysql_query;
+
+    // PostgreSQL operations
+    std::function<PostgresqlQueryResult(const PostgresqlQueryOptions&)> postgresql_query;
 };
 
 // Script execution result

+ 388 - 0
src/runner/postgresql/postgresql_client.cpp

@@ -0,0 +1,388 @@
+#include "postgresql_client.hpp"
+#include "logging/logger.hpp"
+#include <libpq-fe.h>
+#include <sstream>
+#include <cstring>
+
+namespace smartbotic::runner::postgresql {
+
+using namespace common;
+
+PostgresqlClient::PostgresqlClient(const PostgresqlCredentials& credentials)
+    : credentials_(credentials) {
+}
+
+PostgresqlClient::~PostgresqlClient() {
+    disconnect();
+}
+
+Result<void> PostgresqlClient::connect() {
+    if (connected_) {
+        return Result<void>();
+    }
+
+    // Build connection string
+    std::ostringstream conn_str;
+    conn_str << "host=" << credentials_.host;
+    conn_str << " port=" << credentials_.port;
+    conn_str << " user=" << credentials_.username;
+    conn_str << " password=" << credentials_.password;
+    if (!credentials_.database.empty()) {
+        conn_str << " dbname=" << credentials_.database;
+    }
+    if (credentials_.use_ssl) {
+        conn_str << " sslmode=require";
+    } else {
+        conn_str << " sslmode=prefer";
+    }
+    conn_str << " connect_timeout=10";
+
+    PGconn* conn = PQconnectdb(conn_str.str().c_str());
+
+    if (PQstatus(conn) != CONNECTION_OK) {
+        std::string error = PQerrorMessage(conn);
+        PQfinish(conn);
+        return Error(ErrorCode::Unavailable, "PostgreSQL connection failed: " + error);
+    }
+
+    // Set client encoding to UTF-8
+    PQsetClientEncoding(conn, "UTF8");
+
+    connection_ = conn;
+    connected_ = true;
+
+    LOG_DEBUG("PostgreSQL connected to {}:{}/{}", credentials_.host, credentials_.port, credentials_.database);
+
+    return Result<void>();
+}
+
+void PostgresqlClient::disconnect() {
+    if (connection_) {
+        PQfinish(static_cast<PGconn*>(connection_));
+        connection_ = nullptr;
+        connected_ = false;
+    }
+}
+
+bool PostgresqlClient::isConnected() const {
+    if (!connected_ || !connection_) {
+        return false;
+    }
+    // Check connection status
+    return PQstatus(static_cast<PGconn*>(connection_)) == CONNECTION_OK;
+}
+
+std::string PostgresqlClient::escape(const std::string& str) {
+    if (!connection_) {
+        return str;
+    }
+
+    PGconn* conn = static_cast<PGconn*>(connection_);
+    char* escaped = PQescapeLiteral(conn, str.c_str(), str.size());
+    if (!escaped) {
+        return "'" + str + "'";
+    }
+    std::string result(escaped);
+    PQfreemem(escaped);
+    return result;
+}
+
+std::string PostgresqlClient::formatValue(const nlohmann::json& value) {
+    if (value.is_null()) {
+        return "NULL";
+    }
+    if (value.is_boolean()) {
+        return value.get<bool>() ? "TRUE" : "FALSE";
+    }
+    if (value.is_number_integer()) {
+        return std::to_string(value.get<int64_t>());
+    }
+    if (value.is_number_float()) {
+        return std::to_string(value.get<double>());
+    }
+    if (value.is_string()) {
+        return escape(value.get<std::string>());
+    }
+    // For arrays/objects, convert to JSON string
+    return escape(value.dump());
+}
+
+QueryResult PostgresqlClient::query(const std::string& sql, const std::vector<nlohmann::json>& params) {
+    QueryResult result;
+
+    // Auto-connect if not connected
+    if (!connected_) {
+        auto conn_result = connect();
+        if (conn_result.failed()) {
+            result.error = conn_result.error().message();
+            return result;
+        }
+    }
+
+    PGconn* conn = static_cast<PGconn*>(connection_);
+
+    // Replace $1, $2, etc. placeholders with actual values (PostgreSQL uses $n notation)
+    // Also support ? placeholders for compatibility and convert them
+    std::string final_sql = sql;
+    size_t param_idx = 0;
+
+    // First, convert ? placeholders to $n format if present
+    size_t pos = 0;
+    int dollar_idx = 1;
+    while ((pos = final_sql.find('?', pos)) != std::string::npos) {
+        std::string replacement = "$" + std::to_string(dollar_idx++);
+        final_sql.replace(pos, 1, replacement);
+        pos += replacement.size();
+    }
+
+    // Now substitute $n parameters with actual values
+    for (size_t i = 0; i < params.size(); i++) {
+        std::string placeholder = "$" + std::to_string(i + 1);
+        pos = final_sql.find(placeholder);
+        if (pos != std::string::npos) {
+            std::string value_str = formatValue(params[i]);
+            final_sql.replace(pos, placeholder.size(), value_str);
+        }
+    }
+
+    LOG_DEBUG("PostgreSQL query: {}", final_sql.substr(0, 200));
+
+    // Execute query
+    PGresult* res = PQexec(conn, final_sql.c_str());
+    ExecStatusType status = PQresultStatus(res);
+
+    if (status != PGRES_COMMAND_OK && status != PGRES_TUPLES_OK) {
+        result.error = PQerrorMessage(conn);
+        LOG_ERROR("PostgreSQL query failed: {}", result.error);
+        PQclear(res);
+        return result;
+    }
+
+    if (status == PGRES_TUPLES_OK) {
+        // SELECT query with results
+        int num_fields = PQnfields(res);
+        int num_rows = PQntuples(res);
+
+        // Get column names
+        for (int i = 0; i < num_fields; i++) {
+            result.columns.push_back(PQfname(res, i));
+        }
+
+        // Fetch rows
+        for (int row = 0; row < num_rows; row++) {
+            nlohmann::json row_obj = nlohmann::json::object();
+
+            for (int col = 0; col < num_fields; col++) {
+                const char* col_name = PQfname(res, col);
+
+                if (PQgetisnull(res, row, col)) {
+                    row_obj[col_name] = nullptr;
+                } else {
+                    const char* value = PQgetvalue(res, row, col);
+                    Oid type_oid = PQftype(res, col);
+
+                    // Determine value type based on OID
+                    // Common PostgreSQL type OIDs:
+                    // 16 = bool, 20 = int8, 21 = int2, 23 = int4
+                    // 700 = float4, 701 = float8
+                    // 1700 = numeric
+                    switch (type_oid) {
+                        case 16:  // bool
+                            row_obj[col_name] = (value[0] == 't' || value[0] == 'T' || value[0] == '1');
+                            break;
+                        case 20:   // int8 (bigint)
+                        case 21:   // int2 (smallint)
+                        case 23:   // int4 (integer)
+                        case 26:   // oid
+                            try {
+                                row_obj[col_name] = std::stoll(value);
+                            } catch (...) {
+                                row_obj[col_name] = value;
+                            }
+                            break;
+                        case 700:  // float4
+                        case 701:  // float8
+                        case 1700: // numeric
+                            try {
+                                row_obj[col_name] = std::stod(value);
+                            } catch (...) {
+                                row_obj[col_name] = value;
+                            }
+                            break;
+                        default:
+                            row_obj[col_name] = value;
+                            break;
+                    }
+                }
+            }
+
+            result.rows.push_back(row_obj);
+        }
+
+        result.affected_rows = static_cast<int64_t>(num_rows);
+    } else {
+        // Non-SELECT query (INSERT, UPDATE, DELETE, etc.)
+        const char* affected = PQcmdTuples(res);
+        if (affected && affected[0] != '\0') {
+            result.affected_rows = std::stoll(affected);
+        }
+
+        // Try to get the last inserted ID from RETURNING clause (if present)
+        // PostgreSQL doesn't have auto-increment ID retrieval like MySQL's last_insert_id
+        // Applications should use RETURNING clause for this
+        if (PQntuples(res) > 0 && PQnfields(res) > 0) {
+            const char* val = PQgetvalue(res, 0, 0);
+            if (val && val[0] != '\0') {
+                try {
+                    result.insert_id = std::stoll(val);
+                } catch (...) {
+                    // Not an integer, ignore
+                }
+            }
+        }
+    }
+
+    PQclear(res);
+
+    result.success = true;
+    LOG_DEBUG("PostgreSQL query completed: {} rows, {} affected", result.rows.size(), result.affected_rows);
+
+    return result;
+}
+
+Result<std::vector<std::string>> PostgresqlClient::listDatabases() {
+    auto conn_result = connect();
+    if (conn_result.failed()) {
+        return conn_result.error();
+    }
+
+    auto query_result = query("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname");
+    if (!query_result.success) {
+        return Error(ErrorCode::Internal, query_result.error);
+    }
+
+    std::vector<std::string> databases;
+    for (const auto& row : query_result.rows) {
+        if (row.contains("datname")) {
+            databases.push_back(row["datname"].get<std::string>());
+        }
+    }
+
+    return databases;
+}
+
+Result<std::vector<TableInfo>> PostgresqlClient::listTables(const std::string& database) {
+    // If different database specified, reconnect
+    if (!database.empty() && database != credentials_.database) {
+        auto use_result = useDatabase(database);
+        if (use_result.failed()) {
+            return use_result.error();
+        }
+    }
+
+    auto conn_result = connect();
+    if (conn_result.failed()) {
+        return conn_result.error();
+    }
+
+    // Query tables and views from the public schema
+    auto query_result = query(
+        "SELECT table_name, table_type FROM information_schema.tables "
+        "WHERE table_schema = 'public' ORDER BY table_name"
+    );
+    if (!query_result.success) {
+        return Error(ErrorCode::Internal, query_result.error);
+    }
+
+    std::vector<TableInfo> tables;
+    for (const auto& row : query_result.rows) {
+        TableInfo info;
+        info.name = row.value("table_name", "");
+        std::string type = row.value("table_type", "");
+        info.type = (type == "VIEW") ? "VIEW" : "TABLE";
+
+        if (!info.name.empty()) {
+            tables.push_back(info);
+        }
+    }
+
+    return tables;
+}
+
+Result<std::vector<ColumnInfo>> PostgresqlClient::listColumns(const std::string& table, const std::string& database) {
+    // If different database specified, reconnect
+    if (!database.empty() && database != credentials_.database) {
+        auto use_result = useDatabase(database);
+        if (use_result.failed()) {
+            return use_result.error();
+        }
+    }
+
+    auto conn_result = connect();
+    if (conn_result.failed()) {
+        return conn_result.error();
+    }
+
+    // Query columns using information_schema
+    std::string sql = R"(
+        SELECT
+            c.column_name,
+            c.data_type,
+            c.is_nullable,
+            c.column_default,
+            CASE
+                WHEN pk.column_name IS NOT NULL THEN 'PRI'
+                WHEN uk.column_name IS NOT NULL THEN 'UNI'
+                ELSE ''
+            END as key_type,
+            CASE
+                WHEN c.column_default LIKE 'nextval%' THEN 'auto_increment'
+                ELSE ''
+            END as extra
+        FROM information_schema.columns c
+        LEFT JOIN (
+            SELECT kcu.column_name
+            FROM information_schema.table_constraints tc
+            JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
+            WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY'
+        ) pk ON c.column_name = pk.column_name
+        LEFT JOIN (
+            SELECT kcu.column_name
+            FROM information_schema.table_constraints tc
+            JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
+            WHERE tc.table_name = $1 AND tc.constraint_type = 'UNIQUE'
+        ) uk ON c.column_name = uk.column_name
+        WHERE c.table_name = $1 AND c.table_schema = 'public'
+        ORDER BY c.ordinal_position
+    )";
+
+    auto query_result = query(sql, {table});
+    if (!query_result.success) {
+        return Error(ErrorCode::Internal, query_result.error);
+    }
+
+    std::vector<ColumnInfo> columns;
+    for (const auto& row : query_result.rows) {
+        ColumnInfo info;
+        info.name = row.value("column_name", "");
+        info.type = row.value("data_type", "");
+        info.nullable = row.value("is_nullable", "NO") == "YES";
+        info.key = row.value("key_type", "");
+        info.default_value = row.contains("column_default") && !row["column_default"].is_null()
+            ? row["column_default"].get<std::string>() : "";
+        info.extra = row.value("extra", "");
+
+        columns.push_back(info);
+    }
+
+    return columns;
+}
+
+Result<void> PostgresqlClient::useDatabase(const std::string& database) {
+    // PostgreSQL requires reconnection to switch databases
+    disconnect();
+    credentials_.database = database;
+    return connect();
+}
+
+} // namespace smartbotic::runner::postgresql

+ 83 - 0
src/runner/postgresql/postgresql_client.hpp

@@ -0,0 +1,83 @@
+#pragma once
+
+#include <string>
+#include <vector>
+#include <nlohmann/json.hpp>
+#include "common/error.hpp"
+
+namespace smartbotic::runner::postgresql {
+
+// PostgreSQL connection credentials
+struct PostgresqlCredentials {
+    std::string host;
+    int port = 5432;
+    std::string username;
+    std::string password;
+    std::string database;
+    bool use_ssl = false;
+};
+
+// Query result
+struct QueryResult {
+    bool success = false;
+    std::vector<nlohmann::json> rows;
+    std::vector<std::string> columns;
+    int64_t affected_rows = 0;
+    int64_t insert_id = 0;
+    std::string error;
+};
+
+// Schema introspection result
+struct TableInfo {
+    std::string name;
+    std::string type;  // TABLE or VIEW
+};
+
+struct ColumnInfo {
+    std::string name;
+    std::string type;
+    bool nullable;
+    std::string key;
+    std::string default_value;
+    std::string extra;
+};
+
+// PostgreSQL client class
+class PostgresqlClient {
+public:
+    explicit PostgresqlClient(const PostgresqlCredentials& credentials);
+    ~PostgresqlClient();
+
+    // Disable copy
+    PostgresqlClient(const PostgresqlClient&) = delete;
+    PostgresqlClient& operator=(const PostgresqlClient&) = delete;
+
+    // Connection
+    common::Result<void> connect();
+    void disconnect();
+    bool isConnected() const;
+
+    // Query execution
+    QueryResult query(const std::string& sql, const std::vector<nlohmann::json>& params = {});
+
+    // Schema introspection
+    common::Result<std::vector<std::string>> listDatabases();
+    common::Result<std::vector<TableInfo>> listTables(const std::string& database = "");
+    common::Result<std::vector<ColumnInfo>> listColumns(const std::string& table, const std::string& database = "");
+
+    // Use specific database (reconnects with new database)
+    common::Result<void> useDatabase(const std::string& database);
+
+private:
+    PostgresqlCredentials credentials_;
+    void* connection_ = nullptr;  // PGconn* handle
+    bool connected_ = false;
+
+    // Escape string for SQL
+    std::string escape(const std::string& str);
+
+    // Format value for SQL
+    std::string formatValue(const nlohmann::json& value);
+};
+
+} // namespace smartbotic::runner::postgresql

+ 60 - 0
src/runner/runner_service.cpp

@@ -10,6 +10,9 @@
 #ifdef MYSQL_SUPPORT
 #include "runner/mysql/mysql_client.hpp"
 #endif
+#ifdef POSTGRESQL_SUPPORT
+#include "runner/postgresql/postgresql_client.hpp"
+#endif
 
 namespace smartbotic::runner {
 
@@ -406,6 +409,63 @@ RunnerService::RunnerService(const RunnerServiceConfig& config)
             return result;
         });
 #endif
+
+#ifdef POSTGRESQL_SUPPORT
+    // Set up PostgreSQL credential callback for workflow engine
+    engine_->setPostgresqlCredentialCallback(
+        [this](const std::string& credential_id, const std::string& workflow_id)
+            -> common::Result<engine::PostgresqlCredential> {
+            auto result = credential_client_->getPostgresqlCredentials(credential_id, workflow_id);
+            if (result.failed()) {
+                return result.error();
+            }
+            engine::PostgresqlCredential cred;
+            cred.host = result.value().host;
+            cred.port = result.value().port;
+            cred.username = result.value().username;
+            cred.password = result.value().password;
+            cred.database = result.value().database;
+            cred.use_ssl = result.value().use_ssl;
+            return cred;
+        });
+
+    // Set up PostgreSQL query callback for workflow engine
+    engine_->setPostgresqlQueryCallback(
+        [this](const engine::PostgresqlQueryOptions& options, const std::string& workflow_id)
+            -> engine::PostgresqlQueryResult {
+            engine::PostgresqlQueryResult result;
+
+            // Get PostgreSQL credentials
+            auto cred_result = credential_client_->getPostgresqlCredentials(options.credential_id, workflow_id);
+            if (cred_result.failed()) {
+                result.success = false;
+                result.error = cred_result.error().message();
+                return result;
+            }
+
+            // Build connection credentials
+            postgresql::PostgresqlCredentials creds;
+            creds.host = cred_result.value().host;
+            creds.port = cred_result.value().port;
+            creds.username = cred_result.value().username;
+            creds.password = cred_result.value().password;
+            creds.database = options.database.empty() ? cred_result.value().database : options.database;
+            creds.use_ssl = cred_result.value().use_ssl;
+
+            // Create client and execute query
+            postgresql::PostgresqlClient client(creds);
+            auto query_result = client.query(options.query, options.params);
+
+            result.success = query_result.success;
+            result.rows = query_result.rows;
+            result.columns = query_result.columns;
+            result.affected_rows = query_result.affected_rows;
+            result.insert_id = query_result.insert_id;
+            result.error = query_result.error;
+
+            return result;
+        });
+#endif
 }
 
 RunnerService::~RunnerService() {

+ 22 - 0
src/runner/workflow_engine.cpp

@@ -865,6 +865,28 @@ NodeExecutionResult WorkflowEngine::executeNode(const WorkflowNode& node,
         return mysql_query_callback_(options, workflow.id);
     };
 
+    // PostgreSQL Credential API callback
+    ctx.credentials_get_postgresql = [this, &workflow](const std::string& credential_id)
+        -> common::Result<engine::PostgresqlCredential> {
+        if (!postgresql_credential_callback_) {
+            return common::Error(common::ErrorCode::Unavailable,
+                "PostgreSQL Credentials API not configured");
+        }
+        return postgresql_credential_callback_(credential_id, workflow.id);
+    };
+
+    // PostgreSQL Query API callback
+    ctx.postgresql_query = [this, &workflow](const engine::PostgresqlQueryOptions& options)
+        -> engine::PostgresqlQueryResult {
+        if (!postgresql_query_callback_) {
+            engine::PostgresqlQueryResult result;
+            result.success = false;
+            result.error = "PostgreSQL API not configured";
+            return result;
+        }
+        return postgresql_query_callback_(options, workflow.id);
+    };
+
     // Execute with retry logic
     int max_retries = 0;
     if (node.config.contains("retries")) {

+ 16 - 0
src/runner/workflow_engine.hpp

@@ -129,6 +129,14 @@ using MysqlCredentialCallback = std::function<common::Result<engine::MysqlCreden
 using MysqlQueryCallback = std::function<engine::MysqlQueryResult(
     const engine::MysqlQueryOptions& options, const std::string& workflow_id)>;
 
+// PostgreSQL credential callback type
+using PostgresqlCredentialCallback = std::function<common::Result<engine::PostgresqlCredential>(
+    const std::string& credential_id, const std::string& workflow_id)>;
+
+// PostgreSQL query callback type
+using PostgresqlQueryCallback = std::function<engine::PostgresqlQueryResult(
+    const engine::PostgresqlQueryOptions& options, const std::string& workflow_id)>;
+
 // Workflow execution engine
 class WorkflowEngine {
 public:
@@ -164,6 +172,12 @@ public:
     // Set MySQL query callback (called by runner service to execute MySQL queries)
     void setMysqlQueryCallback(MysqlQueryCallback callback) { mysql_query_callback_ = callback; }
 
+    // Set PostgreSQL credential callback (called by runner service to provide PostgreSQL credential access)
+    void setPostgresqlCredentialCallback(PostgresqlCredentialCallback callback) { postgresql_credential_callback_ = callback; }
+
+    // Set PostgreSQL query callback (called by runner service to execute PostgreSQL queries)
+    void setPostgresqlQueryCallback(PostgresqlQueryCallback callback) { postgresql_query_callback_ = callback; }
+
 private:
     void buildExecutionGraph(const Workflow& workflow,
                             std::unordered_map<std::string, std::vector<std::string>>& dependencies,
@@ -236,6 +250,8 @@ private:
     ImapCredentialCallback imap_credential_callback_;
     MysqlCredentialCallback mysql_credential_callback_;
     MysqlQueryCallback mysql_query_callback_;
+    PostgresqlCredentialCallback postgresql_credential_callback_;
+    PostgresqlQueryCallback postgresql_query_callback_;
 
     std::unordered_map<std::string, ExecutionResult> active_executions_;
     std::unordered_set<std::string> cancelled_executions_;

+ 28 - 0
src/webserver/grpc/credential_service.cpp

@@ -87,6 +87,34 @@ CredentialServiceImpl::CredentialServiceImpl(CredentialStore& credential_store)
     return ::grpc::Status::OK;
 }
 
+::grpc::Status CredentialServiceImpl::GetPostgresqlCredentials(
+    ::grpc::ServerContext* context,
+    const proto::GetPostgresqlCredentialsRequest* request,
+    proto::GetPostgresqlCredentialsResponse* response) {
+
+    LOG_DEBUG("GetPostgresqlCredentials request: credential={} workflow={}",
+              request->credential_id(), request->workflow_id());
+
+    auto result = credential_store_.getPostgresqlCredentials(request->credential_id(), request->workflow_id());
+
+    if (result.failed()) {
+        response->set_success(false);
+        response->set_error(result.error().message());
+        LOG_WARN("GetPostgresqlCredentials failed: {}", result.error().message());
+        return ::grpc::Status::OK;  // Return OK status, error is in response
+    }
+
+    response->set_success(true);
+    response->set_host(result.value().host);
+    response->set_port(result.value().port);
+    response->set_username(result.value().username);
+    response->set_password(result.value().password);
+    response->set_database(result.value().database);
+    response->set_use_ssl(result.value().use_ssl);
+
+    return ::grpc::Status::OK;
+}
+
 ::grpc::Status CredentialServiceImpl::ListCredentials(
     ::grpc::ServerContext* context,
     const proto::ListCredentialsRequest* request,

+ 5 - 0
src/webserver/grpc/credential_service.hpp

@@ -29,6 +29,11 @@ public:
         const proto::GetMysqlCredentialsRequest* request,
         proto::GetMysqlCredentialsResponse* response) override;
 
+    ::grpc::Status GetPostgresqlCredentials(
+        ::grpc::ServerContext* context,
+        const proto::GetPostgresqlCredentialsRequest* request,
+        proto::GetPostgresqlCredentialsResponse* response) override;
+
     ::grpc::Status ListCredentials(
         ::grpc::ServerContext* context,
         const proto::ListCredentialsRequest* request,

+ 4 - 3
tasks/prd.json

@@ -67,14 +67,15 @@
         "Node appears in the workflow editor node palette under a Database category"
       ],
       "priority": 1,
-      "passes": false,
+      "passes": true,
       "dependsOn": [],
       "labels": [
         "database",
         "postgresql",
         "backend",
         "frontend"
-      ]
+      ],
+      "completionNotes": "Completed by agent"
     },
     {
       "id": "US-004",
@@ -337,6 +338,6 @@
     }
   ],
   "metadata": {
-    "updatedAt": "2026-01-28T15:00:05.954Z"
+    "updatedAt": "2026-01-28T15:24:48.960Z"
   }
 }

+ 12 - 3
webui/src/api/credentials.ts

@@ -1,6 +1,6 @@
 import { api } from './client'
 
-export type CredentialType = 'basic' | 'bearer' | 'api_key' | 'oauth2' | 'imap' | 'mysql'
+export type CredentialType = 'basic' | 'bearer' | 'api_key' | 'oauth2' | 'imap' | 'mysql' | 'postgresql'
 
 export interface CredentialInfo {
   id: string
@@ -57,18 +57,27 @@ export interface MysqlData {
   use_ssl: boolean
 }
 
+export interface PostgresqlData {
+  host: string
+  port: number
+  username: string
+  password: string
+  database: string
+  use_ssl: boolean
+}
+
 export interface CreateCredentialRequest {
   name: string
   description?: string
   type: CredentialType
-  data: BasicAuthData | BearerTokenData | ApiKeyData | OAuth2Data | ImapData | MysqlData
+  data: BasicAuthData | BearerTokenData | ApiKeyData | OAuth2Data | ImapData | MysqlData | PostgresqlData
   allowedWorkflows?: string[]
 }
 
 export interface UpdateCredentialRequest {
   name?: string
   description?: string
-  data?: BasicAuthData | BearerTokenData | ApiKeyData | OAuth2Data | ImapData | MysqlData
+  data?: BasicAuthData | BearerTokenData | ApiKeyData | OAuth2Data | ImapData | MysqlData | PostgresqlData
   allowedWorkflows?: string[]
 }
 

+ 99 - 0
webui/src/pages/CredentialsPage.tsx

@@ -75,6 +75,11 @@ const CREDENTIAL_TYPE_INFO: Record<
     icon: Database,
     description: 'MySQL database connection',
   },
+  postgresql: {
+    label: 'PostgreSQL',
+    icon: Database,
+    description: 'PostgreSQL database connection',
+  },
 }
 
 export default function CredentialsPage() {
@@ -371,6 +376,14 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
   const [mysqlDatabase, setMysqlDatabase] = useState((pd.database as string) || '')
   const [mysqlUseSsl, setMysqlUseSsl] = useState((pd.use_ssl as boolean) ?? false)
 
+  // PostgreSQL fields
+  const [postgresqlHost, setPostgresqlHost] = useState((pd.host as string) || '')
+  const [postgresqlPort, setPostgresqlPort] = useState((pd.port as number) || 5432)
+  const [postgresqlUsername, setPostgresqlUsername] = useState((pd.username as string) || '')
+  const [postgresqlPassword, setPostgresqlPassword] = useState('')
+  const [postgresqlDatabase, setPostgresqlDatabase] = useState((pd.database as string) || '')
+  const [postgresqlUseSsl, setPostgresqlUseSsl] = useState((pd.use_ssl as boolean) ?? false)
+
   const createMutation = useMutation({
     mutationFn: (data: CreateCredentialRequest) => credentialsApi.create(data),
     onSuccess: () => {
@@ -426,6 +439,15 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
           database: mysqlDatabase,
           use_ssl: mysqlUseSsl,
         }
+      case 'postgresql':
+        return {
+          host: postgresqlHost,
+          port: postgresqlPort,
+          username: postgresqlUsername,
+          password: postgresqlPassword,
+          database: postgresqlDatabase,
+          use_ssl: postgresqlUseSsl,
+        }
     }
   }
 
@@ -870,6 +892,83 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
                 </div>
               </>
             )}
+
+            {type === 'postgresql' && (
+              <>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    PostgreSQL Host *
+                  </label>
+                  <input
+                    type="text"
+                    value={postgresqlHost}
+                    onChange={(e) => setPostgresqlHost(e.target.value)}
+                    placeholder="localhost"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Port
+                  </label>
+                  <input
+                    type="number"
+                    value={postgresqlPort}
+                    onChange={(e) => setPostgresqlPort(parseInt(e.target.value) || 5432)}
+                    placeholder="5432"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Username *
+                  </label>
+                  <input
+                    type="text"
+                    value={postgresqlUsername}
+                    onChange={(e) => setPostgresqlUsername(e.target.value)}
+                    placeholder="postgres"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Password *
+                  </label>
+                  <input
+                    type={showSecrets ? 'text' : 'password'}
+                    value={postgresqlPassword}
+                    onChange={(e) => setPostgresqlPassword(e.target.value)}
+                    placeholder={credential ? '(unchanged)' : 'Enter password'}
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Database
+                  </label>
+                  <input
+                    type="text"
+                    value={postgresqlDatabase}
+                    onChange={(e) => setPostgresqlDatabase(e.target.value)}
+                    placeholder="postgres"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div className="flex items-center gap-2">
+                  <input
+                    type="checkbox"
+                    id="postgresqlUseSsl"
+                    checked={postgresqlUseSsl}
+                    onChange={(e) => setPostgresqlUseSsl(e.target.checked)}
+                    className="w-4 h-4 text-primary-600 bg-white dark:bg-slate-700 border-gray-300 dark:border-slate-600 rounded focus:ring-primary-500"
+                  />
+                  <label htmlFor="postgresqlUseSsl" className="text-sm font-medium text-gray-700 dark:text-gray-300">
+                    Use SSL/TLS
+                  </label>
+                </div>
+              </>
+            )}
           </div>
 
           <div className="p-6 border-t border-gray-200 dark:border-slate-700 flex justify-end gap-2">