@flowfuse/flowfuse
Version:
An open source low-code development platform
412 lines (404 loc) • 19.8 kB
JavaScript
const axios = require('axios')
const { generatePassword } = require('../../../../lib/userTeam')
const libPg = require('./lib/pg.js')
module.exports = {
init: async function (app, options) {
this._app = app
this._options = options
if (!options.backend) {
throw new Error('Postgres Supavisor driver requires backend options to be provided')
}
if (!options.supavisor) {
throw new Error('Postgres Supavisor driver requires supavisor options to be provided')
}
this._adminClient = libPg.newClient(options.backend || {})
this._adminClient.on('error', (err) => {
this._app.log.error(`Postgres Supavisor driver error: ${err.toString()}`)
})
try {
await this._adminClient.connect()
} catch (err) {
app.log.error(`Failed to connect to Postgres: ${err.toString()}`)
}
app.log.info('Postgres Supavisor driver initialized')
},
shutdown: async function (app) {
try {
this._app.log.info('Shutting down Postgres Supavisor driver')
await this._adminClient.end()
} catch (err) {
this._app.log.debug(`Error shutting down Postgres Supavisor driver: ${err.toString()}`)
}
},
getDatabases: async function (team) {
const tables = await this._app.db.models.Table.byTeamId(team.id)
if (tables && tables.length > 0) {
return tables
} else {
return []
}
},
getDatabase: async function (team, databaseId) {
const table = await this._app.db.models.Table.byId(team.id, databaseId)
if (table) {
return table
} else {
throw new Error(`Database ${databaseId} for team ${team.hashid} does not exist`)
}
},
createDatabase: async function (team, name) {
const existing = await this._app.db.models.Table.byTeamId(team.id)
// Will need removing when we support multiple databases per team
if (existing && existing.length > 0) {
throw new Error('Database already exists')
}
const res = await this._adminClient.query('SELECT datname FROM pg_database WHERE datistemplate = false AND datname = $1', [team.hashid])
if (res.rows.length > 0) {
throw new Error('Database already exists')
} else {
const password = generatePassword(16)
try {
const escapedDatabaseName = libPg.pg.escapeIdentifier(team.hashid)
await this._adminClient.query(`CREATE DATABASE ${escapedDatabaseName}`)
await this._adminClient.query(`REVOKE connect ON DATABASE ${escapedDatabaseName} FROM PUBLIC;`)
await this._adminClient.query(`ALTER DATABASE ${escapedDatabaseName} SET statement_timeout='30s';`)
const teamClient = libPg.newClient({ ...this._options.backend, database: team.hashid })
try {
await teamClient.connect()
// Escape identifiers for role and database names
const escapedRoleName = libPg.pg.escapeIdentifier(`${team.hashid}-role`)
const escapedUserName = libPg.pg.escapeIdentifier(team.hashid)
// Escape the password literal for direct inclusion in DDL
const escapedPassword = libPg.pg.escapeLiteral(password)
// Create everything needed for the team
await teamClient.query(`CREATE ROLE ${escapedRoleName} WITH LOGIN`)
await teamClient.query(`GRANT CONNECT ON DATABASE ${escapedDatabaseName} TO ${escapedRoleName}`)
await teamClient.query(`GRANT ALL PRIVILEGES ON DATABASE ${escapedDatabaseName} TO ${escapedRoleName}`)
await teamClient.query(`GRANT CREATE ON SCHEMA public TO ${escapedRoleName}`)
await teamClient.query(`GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${escapedRoleName}`)
await teamClient.query(`GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${escapedRoleName}`)
await teamClient.query(`CREATE USER ${escapedUserName} WITH PASSWORD ${escapedPassword}`)
await teamClient.query(`GRANT ${escapedRoleName} TO ${escapedUserName}`)
} finally {
await teamClient.end()
}
const tenant = {
tenant: {
db_host: this._options.backend.supavisorHost ? this._options.backend.supavisorHost : this._options.backend.host,
db_port: this._options.backend.port,
db_database: team.hashid,
external_id: team.hashid,
ip_version: 'auto',
upstream_ssl: !!this._options.backend.ssl,
upstream_verify: this._options.backend.ssl ? 'none' : undefined,
require_user: true,
auth_query: 'SELECT rolname, rolpassword FROM pg_authid WHERE rolname=$1;',
// sni_hostname: `${team.slug}.${this._options.supavisor.domain}`,
users: [
{
db_user: team.hashid,
db_password: password,
mode_type: 'transaction',
is_manager: true,
pool_size: 10,
max_clients: 10
}
]
}
}
this._app.log.debug(`FF Tables creating tenant:\n${JSON.stringify(tenant, null, 2)}`)
const response = await axios.put(`${this._options.supavisor.url}/api/tenants/${team.hashid}`, tenant, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this._options.supavisor.token}`
}
})
if (response.status === 201) {
this._app.log.info(`Database created for team ${team.hashid}`)
const credentials = {
host: `${this._options.supavisor.domain}`,
port: this._options.supavisor.port,
ssl: this._options.supavisor.ssl,
database: team.hashid,
user: `${team.hashid}.${team.hashid}`,
password
}
const meta = {
host: this._options.backend.host,
port: this._options.backend.port,
ssl: this._options.backend.ssl,
database: this._options.backend.database
}
const table = await this._app.db.models.Table.create({
TeamId: team.id,
name,
credentials,
meta
})
return table
} else {
this._app.log.error(`Failed to create database\n${JSON.stringify(response, null, 2)}\n${JSON.stringify(tenant, null, 2)}`)
throw new Error(`Failed to create database for team ${team.hashid}: ${response.statusText}`)
}
} catch (err) {
// console.log(err)
this._app.log.error(`Failed to create database\n${this._options.supavisor.url}/api/tenants/${team.hashid}\n${err.toString()}`)
}
}
},
destroyDatabase: async function (team, databaseId) {
const db = await this._app.db.models.Table.byId(team.id, databaseId)
if (!db) {
throw new Error(`Database ${databaseId} for team ${team.hashid} does not exist`)
}
try {
await axios.delete(`${this._options.supavisor.url}/api/tenants/${team.hashid}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this._options.supavisor.token}`
}
})
} catch (err) {
// console.log(err)
}
const res = await this._adminClient.query('SELECT datname FROM pg_database WHERE datistemplate = false AND datname = $1', [team.hashid])
if (res.rows.length === 1) {
try {
await this._adminClient.query(`DROP DATABASE IF EXISTS ${libPg.pg.escapeIdentifier(team.hashid)}`)
await this._adminClient.query(`DROP USER IF EXISTS ${libPg.pg.escapeIdentifier(team.hashid)}`)
await this._adminClient.query(`DROP ROLE IF EXISTS ${libPg.pg.escapeIdentifier(team.hashid + '-role')}`)
await db.destroy()
} catch (err) {
// console.log(err)
}
} else {
throw new Error(`Database ${team.hashid} does not exist`)
}
},
getTables: async function (team, databaseId, paginationOptions) {
// SELECT * FROM pg_catalog.pg_tables;
const databaseExists = await this._app.db.models.Table.byId(team.id, databaseId)
if (!databaseExists || databaseExists.TeamId !== team.id) {
throw new Error(`Database ${databaseId} for team ${team.hashid} does not exist`)
}
try {
const teamClient = libPg.newClient({ ...this._options.backend, database: team.hashid })
try {
await teamClient.connect()
const res = await teamClient.query('SELECT "tablename" FROM "pg_catalog"."pg_tables" WHERE "schemaname" != \'pg_catalog\' AND "schemaname" != \'information_schema\'')
if (res.rows && res.rows.length > 0) {
const tables = res.rows.map(row => {
return {
name: row.tablename,
schema: row.schemaname
}
})
return {
count: tables.length,
tables,
meta: {}
}
} else {
return {
count: 0,
tables: [],
meta: {}
}
}
} finally {
teamClient.end()
}
} catch (err) {
console.error('Error retrieving tables:', err)
throw new Error(`Failed to retrieve tables for team ${team.hashid}: ${err.message}`)
}
},
getTable: async function (team, databaseId, tableName) {
// SELECT column_name, data_type, is_nullable, column_default
// FROM information_schema.columns
// WHERE table_name = 'your_table_name';
const databaseExists = await this._app.db.models.Table.byId(team.id, databaseId)
if (!databaseExists || databaseExists.TeamId !== team.id) {
throw new Error(`Database ${databaseId} for team ${team.hashid} does not exist`)
}
try {
const teamClient = libPg.newClient({ ...this._options.backend, database: team.hashid })
try {
await teamClient.connect()
const res = await teamClient.query('SELECT column_name, udt_name, is_nullable, column_default, character_maximum_length, is_generated FROM information_schema.columns WHERE table_name = $1', [tableName])
if (res.rows && res.rows.length > 0) {
return res.rows.map(row => {
const col = {
name: row.column_name,
type: row.udt_name,
nullable: row.is_nullable === 'YES',
default: row.column_default,
generated: row.is_generated === 'ALWAYS'
}
if (row.character_maximum_length) {
col.maxLength = row.character_maximum_length
}
return col
})
} else {
return null
}
} finally {
teamClient.end()
}
} catch (err) {
console.error('Error retrieving table:', err)
throw new Error(`Failed to retrieve table ${tableName} for team ${team.hashid}: ${err.message}`)
}
},
getTableData: async function (team, database, table, pagination) {
const rows = Math.min(parseInt(pagination.limit) || 10, 10)
const databaseExists = await this._app.db.models.Table.byId(team.id, database)
if (!databaseExists || databaseExists.TeamId !== team.id) {
throw new Error(`Database ${database} for team ${team.hashid} does not exist`)
}
try {
const teamClient = libPg.newClient({ ...this._options.backend, database: team.hashid })
try {
await teamClient.connect()
const escapedTable = libPg.pg.escapeIdentifier(table)
const query = `SELECT * FROM ${escapedTable} LIMIT $1`
const res = await teamClient.query(query, [rows || 10])
if (res.rows && res.rows.length > 0) {
return {
count: res.rows.length,
rows: res.rows,
meta: {}
}
} else {
return {
count: 0,
rows: [],
meta: {}
}
}
} finally {
teamClient.end()
}
} catch (err) {
console.error('Error retrieving table:', err)
throw new Error(`Failed to retrieve table ${table} for team ${team.hashid}: ${err.message}`)
}
},
/**
* Query data in tables
* IMPORTANT: this method is primarily intended for internal usage (like getting AI schema hints)
* @param {Object} team - The team object
* @param {String} databaseId - The database ID
* @param {String} queryText - The SQL query to execute
* @param {Array} params - The parameters for the query
* @returns {String} - The result(s) of the query
* @throws {Error} - If the database does not exist
*/
query: async function (team, databaseId, queryText, params = undefined) {
const databaseExists = await this._app.db.models.Table.byId(team.id, databaseId)
if (!databaseExists || databaseExists.TeamId !== team.id) {
throw new Error(`Database ${databaseId} for team ${team.hashid} does not exist`)
}
const teamClient = libPg.newClient({ ...this._options.backend, database: team.hashid })
try {
await teamClient.connect()
const result = await teamClient.query(queryText, params)
return result
} catch (error) {
console.error('Error running query:', error)
throw error
} finally {
await teamClient.end()
}
},
createTable: async function (team, databaseId, tableName, columns) {
const databaseExists = await this._app.db.models.Table.byId(team.id, databaseId)
if (!databaseExists || databaseExists.TeamId !== team.id) {
throw new Error(`Database ${databaseId} for team ${team.hashid} does not exist`)
}
try {
const options = {
host: this._options.backend.host,
port: this._options.backend.port,
ssl: this._options.backend.ssl,
database: team.hashid,
user: team.hashid,
password: databaseExists.credentials.password
}
const teamClient = libPg.newClient(options)
try {
await teamClient.connect()
let query = `CREATE TABLE IF NOT EXISTS ${libPg.pg.escapeIdentifier(tableName)} (\n`
for (const [i, col] of columns.entries()) {
if (col.name.length === 0 || col.type.length === 0) {
continue
}
let column = `${libPg.pg.escapeIdentifier(col.name)} `
if (['bigint', 'bigserial', 'boolean', 'date', 'timestamptz', 'real', 'double precision', 'text'].includes(col.type)) {
column += `${col.type} `
} else {
throw new Error('Unsupported column type')
}
column += `${col.nullable ? '' : 'NOT NULL'} `
if (col.default) {
if (typeof col.default === 'string' && col.type === 'text') {
column += `DEFAULT ${libPg.pg.escapeLiteral(col.default)}`
} else if (col.type === 'bigint') {
column += `DEFAULT ${parseInt(col.default)}`
} else if (['real', 'double precision'].includes(col.type)) {
column += `DEFAULT ${parseFloat(column.default)}`
} else if (col.type === 'boolean') {
column += `DEFAULT ${column.default === 'true'}`
} else if (col.type === 'timestamptz') {
column += 'DEFAULT NOW()'
}
}
if (i + 1 !== columns.length) {
query += column + ',\n'
} else {
query += column + '\n'
}
}
if (query.endsWith(' ,\n')) {
query = query.replace(/ ,\n$/, '\n')
}
query += ')'
await teamClient.query(query)
} finally {
teamClient.end()
}
} catch (err) {
console.error(err)
throw new Error(`Failed to create table ${tableName} for team ${team.hashid}: ${err.message}`)
}
},
dropTable: async function (team, databaseId, tableName) {
const databaseExists = await this._app.db.models.Table.byId(team.id, databaseId)
if (!databaseExists || databaseExists.TeamId !== team.id) {
throw new Error(`Database ${databaseId} for team ${team.hashid} does not exist`)
}
try {
const options = {
host: this._options.backend.host,
port: this._options.backend.port,
ssl: this._options.backend.ssl,
database: team.hashid,
user: this._options.backend.user,
password: this._options.backend.password
}
const teamClient = libPg.newClient(options)
try {
await teamClient.connect()
await teamClient.query(`DROP TABLE ${libPg.pg.escapeIdentifier(tableName)}`)
} finally {
teamClient.end()
}
} catch (err) {
console.error('Error retrieving table:', err)
throw new Error(`Failed to drop table ${tableName} for team ${team.hashid}: ${err.message}`)
}
},
createColumn: async function (team, database, table, column) {},
removeColumn: async function (team, database, table, column) {}
}