diff --git a/.changeset-staged/loud-snakes-cross.md b/.changeset-staged/loud-snakes-cross.md new file mode 100644 index 000000000..5420a8eaa --- /dev/null +++ b/.changeset-staged/loud-snakes-cross.md @@ -0,0 +1,5 @@ +--- +"@logto/cli": minor +--- + +Add CLI command to get/set db's system table value diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index 08a4ef5fc..e0479ee7b 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -4,11 +4,13 @@ import type { CommandModule } from 'yargs'; import alteration from './alteration/index.js'; import config from './config.js'; import seed from './seed/index.js'; +import system from './system.js'; const database: CommandModule = { command: ['database', 'db'], describe: 'Commands for Logto database', - builder: (yargs) => yargs.command(config).command(seed).command(alteration).demandCommand(1), + builder: (yargs) => + yargs.command(config).command(seed).command(alteration).command(system).demandCommand(1), handler: noop, }; diff --git a/packages/cli/src/commands/database/system.ts b/packages/cli/src/commands/database/system.ts new file mode 100644 index 000000000..2f6e93e96 --- /dev/null +++ b/packages/cli/src/commands/database/system.ts @@ -0,0 +1,87 @@ +import type { SystemKey } from '@logto/schemas'; +import { systemGuards, systemKeys } from '@logto/schemas'; +import { noop } from '@silverhand/essentials'; +import chalk from 'chalk'; +import type { CommandModule } from 'yargs'; + +import { createPoolFromConfig } from '../../database.js'; +import { getRowByKey, updateValueByKey } from '../../queries/system.js'; +import { log } from '../../utils.js'; + +const validKeysDisplay = chalk.green(systemKeys.join(', ')); + +type ValidateKeysFunction = { + (keys: string[]): asserts keys is SystemKey[]; + (key: string): asserts key is SystemKey; +}; + +const validateKey: ValidateKeysFunction = (key) => { + // Using `.includes()` will result a type error + // eslint-disable-next-line unicorn/prefer-includes + if (!systemKeys.some((element) => element === key)) { + log.error(`Invalid config key ${chalk.red(key)} found, expected one of ${validKeysDisplay}`); + } +}; + +const getConfig: CommandModule = { + command: 'get ', + describe: 'Get system value of the given key in Logto database', + builder: (yargs) => + yargs.positional('key', { + describe: `The key to get from database system table, one of ${validKeysDisplay}`, + type: 'string', + demandOption: true, + }), + handler: async ({ key }) => { + validateKey(key); + + const pool = await createPoolFromConfig(); + const row = await getRowByKey(pool, key); + await pool.end(); + + const value = row?.value; + + console.log( + chalk.magenta(key) + + '=' + + (value === undefined ? chalk.gray(value) : chalk.green(JSON.stringify(value))) + ); + }, +}; + +const setConfig: CommandModule = { + command: 'set ', + describe: 'Set config value of the given key in Logto database', + builder: (yargs) => + yargs + .positional('key', { + describe: `The key to get from database system table, one of ${validKeysDisplay}`, + type: 'string', + demandOption: true, + }) + .positional('value', { + describe: 'The value to set, should be a valid JSON string', + type: 'string', + demandOption: true, + }), + handler: async ({ key, value }) => { + validateKey(key); + + const guarded = systemGuards[key].parse(JSON.parse(value)); + + const pool = await createPoolFromConfig(); + await updateValueByKey(pool, key, guarded); + await pool.end(); + + log.info(`Update ${chalk.green(key)} succeeded`); + }, +}; + +const system: CommandModule = { + command: ['system'], + describe: 'Commands for Logto system config', + builder: (yargs) => yargs.command(getConfig).command(setConfig).demandCommand(1), + handler: noop, +}; + +export default system; diff --git a/packages/cli/src/queries/system.ts b/packages/cli/src/queries/system.ts index f5ccd18f8..5a9f61680 100644 --- a/packages/cli/src/queries/system.ts +++ b/packages/cli/src/queries/system.ts @@ -1,4 +1,4 @@ -import type { AlterationState, System } from '@logto/schemas'; +import type { AlterationState, System, SystemKey } from '@logto/schemas'; import { systemGuards, Systems, AlterationStateKey } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import type { Nullable } from '@silverhand/essentials'; @@ -6,7 +6,7 @@ import type { CommonQueryMethods, DatabaseTransactionConnection } from 'slonik'; import { sql } from 'slonik'; import { z } from 'zod'; -const { fields } = convertToIdentifiers(Systems); +const { fields, table } = convertToIdentifiers(Systems); const doesTableExist = async (pool: CommonQueryMethods, table: string) => { const { rows } = await pool.query<{ regclass: Nullable }>( @@ -67,3 +67,23 @@ export const updateDatabaseTimestamp = async ( ` ); }; + +export const getRowByKey = async (pool: CommonQueryMethods, key: SystemKey) => + pool.maybeOne(sql` + select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} + where ${fields.key} = ${key} + `); + +export const updateValueByKey = async ( + pool: CommonQueryMethods, + key: T, + value: z.infer<(typeof systemGuards)[T]> +) => + pool.query( + sql` + insert into ${table} (${fields.key}, ${fields.value}) + values (${key}, ${sql.jsonb(value)}) + on conflict (${fields.key}) + do update set ${fields.value}=excluded.${fields.value} + ` + );