diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f683c85b0aa..f816b5812f3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -136,6 +136,39 @@ jobs: - uses: ./.github/actions/run-api-tests with: dbOptions: '--dbclient=postgres --dbhost=localhost --dbport=5432 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi' + api_ce_crdb: + runs-on: ubuntu-latest + needs: [lint, unit_back, unit_front] + name: '[CE] API Integration (cockroachdb, node: ${{ matrix.node }})' + strategy: + matrix: + node: [14, 16, 18] + services: + cockroachdb: + # Docker Hub image + image: timveil/cockroachdb-single-node:latest + # Set health checks to wait until cockroachdb has started + options: >- + --health-cmd "curl http://cockroachdb:8080/health?ready=1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 26257:26257 + - 8080:8080 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + - uses: actions/cache@v3 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn install --frozen-lockfile + - uses: ./.github/actions/run-api-tests + with: + dbOptions: '--dbclient=cockroachdb --dbhost=localhost --dbport=26257 --dbname=defaultdb --dbusername=root --dbpassword=""' api_ce_mysql: runs-on: ubuntu-latest @@ -280,6 +313,43 @@ jobs: with: dbOptions: '--dbclient=postgres --dbhost=localhost --dbport=5432 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi' runEE: true + api_ee_crdb: + runs-on: ubuntu-latest + needs: [lint, unit_back, unit_front] + name: '[EE] API Integration (cockroachdb, node: ${{ matrix.node }})' + if: github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') + env: + STRAPI_LICENSE: ${{ secrets.strapiLicense }} + strategy: + matrix: + node: [14, 16, 18] + services: + cockroachdb: + # Docker Hub image + image: timveil/cockroachdb-single-node:latest + # Set health checks to wait until cockroachdb has started + options: >- + --health-cmd "curl http://cockroachdb:8080/health?ready=1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 26257:26257 + - 8080:8080 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + - uses: actions/cache@v3 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn install --frozen-lockfile + - uses: ./.github/actions/run-api-tests + with: + dbOptions: '--dbclient=cockroachdb --dbhost=localhost --dbport=26257 --dbname=defaultdb --dbusername=root --dbpassword=""' + runEE: true api_ee_mysql: runs-on: ubuntu-latest diff --git a/packages/core/database/lib/dialects/cockroachdb/index.js b/packages/core/database/lib/dialects/cockroachdb/index.js new file mode 100644 index 00000000000..00939050f8e --- /dev/null +++ b/packages/core/database/lib/dialects/cockroachdb/index.js @@ -0,0 +1,70 @@ +'use strict'; + +const errors = require('../../errors'); +const { Dialect } = require('../dialect'); +const CockroachdbSchemaInspector = require('./schema-inspector'); + +class CockroachDialect extends Dialect { + constructor(db) { + super(db); + + this.schemaInspector = new CockroachdbSchemaInspector(db); + } + + useReturning() { + return true; + } + + initialize() { + this.db.connection.client.driver.types.setTypeParser( + this.db.connection.client.driver.types.builtins.DATE, + 'text', + (v) => v + ); // Don't cast DATE string to Date() + this.db.connection.client.driver.types.setTypeParser( + this.db.connection.client.driver.types.builtins.NUMERIC, + 'text', + parseFloat + ); + // Don't parse JSONB automatically + this.db.connection.client.driver.types.setTypeParser( + this.db.connection.client.driver.types.builtins.JSONB, + 'text', + (v) => v + ); + // sets default int to 32 bit and sets serial normalization to sql_sequence to mimic postgres + this.db.connection.client.pool.on('acquireSuccess', async (eventId, resource) => { + resource.query('SET serial_normalization = "sql_sequence";'); + resource.query('SET default_int_size = 4;'); + resource.query('SET default_transaction_isolation = "READ COMMITTED";'); + }); + } + + usesForeignKeys() { + return true; + } + + getSqlType(type) { + switch (type) { + case 'timestamp': { + return 'datetime'; + } + default: { + return type; + } + } + } + + transformErrors(error) { + switch (error.code) { + case '23502': { + throw new errors.NotNullConstraint({ column: error.column }); + } + default: { + super.transformErrors(error); + } + } + } +} + +module.exports = CockroachDialect; diff --git a/packages/core/database/lib/dialects/cockroachdb/schema-inspector.js b/packages/core/database/lib/dialects/cockroachdb/schema-inspector.js new file mode 100644 index 00000000000..4cd5f589094 --- /dev/null +++ b/packages/core/database/lib/dialects/cockroachdb/schema-inspector.js @@ -0,0 +1,243 @@ +'use strict'; + +const SQL_QUERIES = { + TABLE_LIST: /* sql */ ` + SELECT * + FROM information_schema.tables + WHERE + table_schema = ? + AND table_type = 'BASE TABLE' + AND table_name != 'geometry_columns' + AND table_name != 'spatial_ref_sys'; + `, + LIST_COLUMNS: /* sql */ ` + SELECT data_type, column_name, character_maximum_length, column_default, is_nullable + FROM information_schema.columns + WHERE table_schema = ? AND table_name = ?; + `, + INDEX_LIST: /* sql */ ` + SELECT + ix.indexrelid, + i.relname as index_name, + a.attname as column_name, + ix.indisunique as is_unique, + ix.indisprimary as is_primary + FROM + pg_class t, + pg_namespace s, + pg_class i, + pg_index ix, + pg_attribute a + WHERE + t.oid = ix.indrelid + AND i.oid = ix.indexrelid + AND a.attrelid = t.oid + AND a.attnum = ANY(ix.indkey) + AND t.relkind = 'r' + AND t.relnamespace = s.oid + AND s.nspname = ? + AND t.relname = ?; + `, + FOREIGN_KEY_LIST: /* sql */ ` + SELECT + tco."constraint_name" as constraint_name, + kcu."column_name" as column_name, + rel_kcu."table_name" as foreign_table, + rel_kcu."column_name" as fk_column_name, + rco.update_rule as on_update, + rco.delete_rule as on_delete + FROM information_schema.table_constraints tco + JOIN information_schema.key_column_usage kcu + ON tco.constraint_schema = kcu.constraint_schema + AND tco.constraint_name = kcu.constraint_name + JOIN information_schema.referential_constraints rco + ON tco.constraint_schema = rco.constraint_schema + AND tco.constraint_name = rco.constraint_name + JOIN information_schema.key_column_usage rel_kcu + ON rco.unique_constraint_schema = rel_kcu.constraint_schema + AND rco.unique_constraint_name = rel_kcu.constraint_name + AND kcu.ordinal_position = rel_kcu.ordinal_position + WHERE + tco.constraint_type = 'FOREIGN KEY' + AND tco.constraint_schema = ? + AND tco.table_name = ? + ORDER BY kcu.table_schema, kcu.table_name, kcu.ordinal_position, kcu.constraint_name; + `, +}; + +const toStrapiType = (column) => { + const rootType = column.data_type.toLowerCase().match(/[^(), ]+/)[0]; + + switch (rootType) { + case 'integer': { + // find a way to figure out the increments + return { type: 'integer' }; + } + case 'text': { + return { type: 'text', args: ['longtext'] }; + } + case 'boolean': { + return { type: 'boolean' }; + } + case 'character': { + return { type: 'string', args: [column.character_maximum_length] }; + } + case 'timestamp': { + return { type: 'datetime', args: [{ useTz: false, precision: 6 }] }; + } + case 'date': { + return { type: 'date' }; + } + case 'time': { + return { type: 'time', args: [{ precision: 3 }] }; + } + case 'numeric': { + return { type: 'decimal', args: [10, 2] }; + } + case 'real': + case 'double': { + return { type: 'double' }; + } + case 'bigint': { + return { type: 'bigInteger' }; + } + case 'jsonb': { + return { type: 'jsonb' }; + } + default: { + return { type: 'specificType', args: [column.data_type] }; + } + } +}; +const getIndexType = (index) => { + if (index.is_primary) { + return 'primary'; + } + + if (index.is_unique) { + return 'unique'; + } + + return null; +}; + +class CockroachdbSchemaInspector { + constructor(db) { + this.db = db; + } + + async getSchema() { + const schema = { tables: [] }; + + const tables = await this.getTables(); + + schema.tables = await Promise.all( + tables.map(async (tableName) => { + const columns = await this.getColumns(tableName); + const indexes = await this.getIndexes(tableName); + const foreignKeys = await this.getForeignKeys(tableName); + + return { + name: tableName, + columns, + indexes, + foreignKeys, + }; + }) + ); + + return schema; + } + + getDatabaseSchema() { + return this.db.connection.getSchemaName() || 'public'; + } + + async getTables() { + const { rows } = await this.db.connection.raw(SQL_QUERIES.TABLE_LIST, [ + this.getDatabaseSchema(), + ]); + + return rows.map((row) => row.table_name); + } + + async getColumns(tableName) { + const { rows } = await this.db.connection.raw(SQL_QUERIES.LIST_COLUMNS, [ + this.getDatabaseSchema(), + tableName, + ]); + + return rows.map((row) => { + const { type, args = [], ...rest } = toStrapiType(row); + + const defaultTo = + row.column_default && row.column_default.includes('nextval(') ? null : row.column_default; + + return { + type, + args, + defaultTo, + name: row.column_name, + notNullable: row.is_nullable === 'NO', + unsigned: false, + ...rest, + }; + }); + } + + async getIndexes(tableName) { + const { rows } = await this.db.connection.raw(SQL_QUERIES.INDEX_LIST, [ + this.getDatabaseSchema(), + tableName, + ]); + + const ret = {}; + + for (const index of rows) { + if (index.column_name === 'id') { + continue; + } + + if (!ret[index.indexrelid]) { + ret[index.indexrelid] = { + columns: [index.column_name], + name: index.index_name, + type: getIndexType(index), + }; + } else { + ret[index.indexrelid].columns.push(index.column_name); + } + } + + return Object.values(ret); + } + + async getForeignKeys(tableName) { + const { rows } = await this.db.connection.raw(SQL_QUERIES.FOREIGN_KEY_LIST, [ + this.getDatabaseSchema(), + tableName, + ]); + + const ret = {}; + + for (const fk of rows) { + if (!ret[fk.constraint_name]) { + ret[fk.constraint_name] = { + name: fk.constraint_name, + columns: [fk.column_name], + referencedColumns: [fk.fk_column_name], + referencedTable: fk.foreign_table, + onUpdate: fk.on_update.toUpperCase(), + onDelete: fk.on_delete.toUpperCase(), + }; + } else { + ret[fk.constraint_name].columns.push(fk.column_name); + ret[fk.constraint_name].referencedColumns.push(fk.fk_column_name); + } + } + + return Object.values(ret); + } +} + +module.exports = CockroachdbSchemaInspector; diff --git a/packages/core/database/lib/dialects/index.js b/packages/core/database/lib/dialects/index.js index a0b44e77cd6..1cc59fda65d 100644 --- a/packages/core/database/lib/dialects/index.js +++ b/packages/core/database/lib/dialects/index.js @@ -4,6 +4,8 @@ const getDialectClass = (client) => { switch (client) { case 'postgres': return require('./postgresql'); + case 'cockroachdb': + return require('./cockroachdb'); case 'mysql': return require('./mysql'); case 'sqlite': diff --git a/packages/core/database/lib/query/helpers/search.js b/packages/core/database/lib/query/helpers/search.js index e7d784758c6..b039f715f6a 100644 --- a/packages/core/database/lib/query/helpers/search.js +++ b/packages/core/database/lib/query/helpers/search.js @@ -30,6 +30,7 @@ const applySearch = (knex, query, ctx) => { } switch (db.dialect.client) { + case 'cockroachdb': case 'postgres': { searchColumns.forEach((attr) => { const columnName = toColumnName(meta, attr); diff --git a/packages/core/database/lib/query/helpers/where.js b/packages/core/database/lib/query/helpers/where.js index 6d2971ad45d..4aaa1bbc080 100644 --- a/packages/core/database/lib/query/helpers/where.js +++ b/packages/core/database/lib/query/helpers/where.js @@ -377,7 +377,7 @@ const applyWhere = (qb, where) => { const fieldLowerFn = (qb) => { // Postgres requires string to be passed - if (qb.client.config.client === 'postgres') { + if (['postgres', 'cockroachdb'].includes(qb.client.config.client)) { return 'LOWER(CAST(?? AS VARCHAR))'; } diff --git a/packages/core/database/lib/schema/builder.js b/packages/core/database/lib/schema/builder.js index 83fa028fc42..9c03258ae38 100644 --- a/packages/core/database/lib/schema/builder.js +++ b/packages/core/database/lib/schema/builder.js @@ -294,8 +294,15 @@ const createHelpers = (db) => { debug(`Updating column ${updatedColumn.name}`); const { object } = updatedColumn; - - if (object.type === 'increments') { + if (db.dialect.client === 'cockroachdb' && object.isAlterType) { + // CRDB does not support alter types + console.log( + 'Altering column type not supported in CockroachDB. Remove the field and restart Strapi. Then add the field back. ' + ); + console.log( + `Trying to alter "${object.name}" to "${object.type}" on table "${table.name}"` + ); + } else if (object.type === 'increments') { createColumn(tableBuilder, { ...object, type: 'integer' }).alter(); } else { createColumn(tableBuilder, object).alter(); diff --git a/packages/core/database/lib/schema/diff.js b/packages/core/database/lib/schema/diff.js index 63c9eeef068..abe75c00763 100644 --- a/packages/core/database/lib/schema/diff.js +++ b/packages/core/database/lib/schema/diff.js @@ -53,7 +53,8 @@ module.exports = (db) => { const diffIndexes = (oldIndex, index) => { const changes = []; - if (!_.isEqual(oldIndex.columns, index.columns)) { + // we need to sort the array because sometimes the column indexes are returned in a different order + if (!_.isEqual(oldIndex.columns.sort(), index.columns.sort())) { changes.push('columns'); } @@ -142,6 +143,10 @@ module.exports = (db) => { const type = db.dialect.getSqlType(column.type); if (oldType !== type && !isIgnoredType) { + // alter type isn't suppored in crdb + if (db.dialect.client === 'cockroachdb') { + column.isAlterType = true; + } changes.push('type'); } diff --git a/packages/core/strapi/lib/utils/startup-logger.js b/packages/core/strapi/lib/utils/startup-logger.js index 8d80bc4f2a3..616fe9fc9a2 100644 --- a/packages/core/strapi/lib/utils/startup-logger.js +++ b/packages/core/strapi/lib/utils/startup-logger.js @@ -32,6 +32,16 @@ module.exports = (app) => { console.log(); console.log(chalk.black.bgWhite(_.padEnd(columns, ' Actions available'))); console.log(); + if (app.db.dialect.client === 'cockroachdb') { + console.log( + chalk.black.bgRed( + _.padEnd( + columns, + ' WARNING: CockroachDB is currently experimental. Do not use in production.' + ) + ) + ); + } }, logFirstStartupMessage() { diff --git a/packages/core/upload/server/controllers/admin-folder-file.js b/packages/core/upload/server/controllers/admin-folder-file.js index 995347d746c..0db9b1d1760 100644 --- a/packages/core/upload/server/controllers/admin-folder-file.js +++ b/packages/core/upload/server/controllers/admin-folder-file.js @@ -147,6 +147,7 @@ module.exports = { case 'sqlite': replaceQuery = '? || SUBSTRING(??, ?)'; break; + case 'cockroachdb': case 'postgres': replaceQuery = 'CONCAT(?::TEXT, SUBSTRING(??, ?::INTEGER))'; break; diff --git a/packages/generators/app/lib/create-customized-project.js b/packages/generators/app/lib/create-customized-project.js index 082466d2bd9..8192075de13 100644 --- a/packages/generators/app/lib/create-customized-project.js +++ b/packages/generators/app/lib/create-customized-project.js @@ -133,7 +133,7 @@ async function askDatabaseInfos(scope) { type: 'list', name: 'client', message: 'Choose your default database client', - choices: ['sqlite', 'postgres', 'mysql'], + choices: ['sqlite', 'postgres', 'mysql', 'cockroachdb'], default: 'sqlite', }, ]); diff --git a/packages/generators/app/lib/resources/templates/database-templates/cockroachdb.template b/packages/generators/app/lib/resources/templates/database-templates/cockroachdb.template new file mode 100644 index 00000000000..2650bcedf6d --- /dev/null +++ b/packages/generators/app/lib/resources/templates/database-templates/cockroachdb.template @@ -0,0 +1,8 @@ +# Database +DATABASE_CLIENT=<%= client %> +DATABASE_HOST=<%= connection.host %> +DATABASE_PORT=<%= connection.port %> +DATABASE_NAME=<%= connection.database %> +DATABASE_USERNAME=<%= connection.username %> +DATABASE_PASSWORD=<%= connection.password %> +DATABASE_SSL=<%= connection.ssl %> diff --git a/packages/generators/app/lib/resources/templates/database-templates/js/database.template b/packages/generators/app/lib/resources/templates/database-templates/js/database.template index 9a97b6cd15b..ffa68df4959 100644 --- a/packages/generators/app/lib/resources/templates/database-templates/js/database.template +++ b/packages/generators/app/lib/resources/templates/database-templates/js/database.template @@ -49,6 +49,29 @@ module.exports = ({ env }) => { }, pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, }, + cockroachdb: { + connection: { + connectionString: env('DATABASE_URL'), + host: env('DATABASE_HOST', 'localhost'), + port: env.int('DATABASE_PORT', 26257), + database: env('DATABASE_NAME', 'strapi'), + user: env('DATABASE_USERNAME', 'strapi'), + password: env('DATABASE_PASSWORD', 'strapi'), + ssl: env.bool('DATABASE_SSL', false) && { + key: env('DATABASE_SSL_KEY', undefined), + cert: env('DATABASE_SSL_CERT', undefined), + ca: env('DATABASE_SSL_CA', undefined), + capath: env('DATABASE_SSL_CAPATH', undefined), + cipher: env('DATABASE_SSL_CIPHER', undefined), + rejectUnauthorized: env.bool( + 'DATABASE_SSL_REJECT_UNAUTHORIZED', + true + ), + }, + schema: env('DATABASE_SCHEMA', 'public'), + }, + pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, + }, sqlite: { connection: { filename: path.join( diff --git a/packages/generators/app/lib/resources/templates/database-templates/ts/database.template b/packages/generators/app/lib/resources/templates/database-templates/ts/database.template index 5480932101e..05c9a7f34af 100644 --- a/packages/generators/app/lib/resources/templates/database-templates/ts/database.template +++ b/packages/generators/app/lib/resources/templates/database-templates/ts/database.template @@ -49,6 +49,29 @@ export default ({ env }) => { }, pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, }, + cockroachdb: { + connection: { + connectionString: env('DATABASE_URL'), + host: env('DATABASE_HOST', 'localhost'), + port: env.int('DATABASE_PORT', 26257), + database: env('DATABASE_NAME', 'strapi'), + user: env('DATABASE_USERNAME', 'strapi'), + password: env('DATABASE_PASSWORD', 'strapi'), + ssl: env.bool('DATABASE_SSL', false) && { + key: env('DATABASE_SSL_KEY', undefined), + cert: env('DATABASE_SSL_CERT', undefined), + ca: env('DATABASE_SSL_CA', undefined), + capath: env('DATABASE_SSL_CAPATH', undefined), + cipher: env('DATABASE_SSL_CIPHER', undefined), + rejectUnauthorized: env.bool( + 'DATABASE_SSL_REJECT_UNAUTHORIZED', + true + ), + }, + schema: env('DATABASE_SCHEMA', 'public'), + }, + pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, + }, sqlite: { connection: { filename: path.join( diff --git a/packages/generators/app/lib/utils/db-client-dependencies.js b/packages/generators/app/lib/utils/db-client-dependencies.js index 6f72034cbb0..7acdc11b24b 100644 --- a/packages/generators/app/lib/utils/db-client-dependencies.js +++ b/packages/generators/app/lib/utils/db-client-dependencies.js @@ -4,6 +4,7 @@ const sqlClientModule = { mysql: { mysql: '2.18.1' }, postgres: { pg: '8.8.0' }, sqlite: { 'better-sqlite3': '8.0.1' }, + cockroachdb: { pg: '8.8.0' }, 'sqlite-legacy': { sqlite3: '^5.0.2' }, }; @@ -15,6 +16,7 @@ module.exports = ({ client }) => { case 'sqlite': case 'sqlite-legacy': case 'postgres': + case 'cockroachdb': case 'mysql': return { ...sqlClientModule[client], diff --git a/packages/generators/app/lib/utils/db-configs.js b/packages/generators/app/lib/utils/db-configs.js index fd0a5e4897d..986a9da9753 100644 --- a/packages/generators/app/lib/utils/db-configs.js +++ b/packages/generators/app/lib/utils/db-configs.js @@ -11,5 +11,6 @@ module.exports = { useNullAsDefault: true, }, postgres: {}, + cockroachdb: {}, mysql: {}, }; diff --git a/packages/generators/app/lib/utils/db-questions.js b/packages/generators/app/lib/utils/db-questions.js index 23bdf4fba52..24db9f8b974 100644 --- a/packages/generators/app/lib/utils/db-questions.js +++ b/packages/generators/app/lib/utils/db-questions.js @@ -2,6 +2,7 @@ const DEFAULT_PORTS = { postgres: 5432, + cockroachdb: 26257, mysql: 3306, }; @@ -63,5 +64,6 @@ const filename = () => ({ module.exports = { sqlite: [filename], postgres: [database, host, port, username, password, ssl], + cockroachdb: [database, host, port, username, password, ssl], mysql: [database, host, port, username, password, ssl], }; diff --git a/packages/generators/app/lib/utils/parse-db-arguments.js b/packages/generators/app/lib/utils/parse-db-arguments.js index 6d71d9fc1df..710fd52308a 100644 --- a/packages/generators/app/lib/utils/parse-db-arguments.js +++ b/packages/generators/app/lib/utils/parse-db-arguments.js @@ -5,7 +5,7 @@ const stopProcess = require('./stop-process'); const DB_ARGS = ['dbclient', 'dbhost', 'dbport', 'dbname', 'dbusername', 'dbpassword']; -const VALID_CLIENTS = ['sqlite', 'mysql', 'postgres']; +const VALID_CLIENTS = ['sqlite', 'mysql', 'postgres', 'cockroachdb']; module.exports = function parseDatabaseArguments({ scope, args }) { const argKeys = Object.keys(args); diff --git a/test/api.js b/test/api.js index 18ca66721ea..8db6e2c1516 100644 --- a/test/api.js +++ b/test/api.js @@ -23,6 +23,17 @@ const databases = { schema: 'myschema', }, }, + cockroachdb: { + client: 'cockroachdb', + connection: { + host: '127.0.0.1', + port: 26257, + database: 'strapi_test', + username: 'root', + password: '', + schema: 'public', + }, + }, mysql: { client: 'mysql', connection: { diff --git a/test/create-test-app.js b/test/create-test-app.js index ebd5b09eadb..4c0876f8b36 100644 --- a/test/create-test-app.js +++ b/test/create-test-app.js @@ -19,6 +19,17 @@ const databases = { schema: 'myschema', }, }, + cockroachdb: { + client: 'cockroachdb', + connection: { + host: '127.0.0.1', + port: 26257, + database: 'strapi_test', + username: 'root', + password: '', + schema: 'public', + }, + }, mysql: { client: 'mysql', connection: {