diff --git a/package.json b/package.json index 519c296d7..478dc6a14 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "root", + "name": "@logto/root", "private": true, "license": "MPL-2.0", "scripts": { diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js deleted file mode 100755 index ade335088..000000000 --- a/packages/cli/bin/run.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node - -require('../'); diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts deleted file mode 100644 index c8dcd173d..000000000 --- a/packages/cli/src/commands/database/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Argument, Command, Option } from 'commander'; - -import { createDatabase, createDatabaseCli } from './library'; -import { replaceDsnDatabase } from './utilities'; - -const versionNote = 'Note Logto requires Postgres v14 or higher.'; -const database = new Command('database'); - -const args = { - database: new Argument('[database]', 'The Logto database name.').default('logto'), -}; - -const options = { - dsn: new Option( - '-d --dsn
', - `Your Postgres DSN *WITHOUT* database name.\n${versionNote}` - ) - .env('DSN') - .makeOptionMandatory(), - domain: new Option('--domain ', 'The Logto domain for table seeding.') - .env('DOMAIN') - .makeOptionMandatory(), -}; - -database.alias('db'); - -database - .command('create') - .description('Create a Logto databse.') - .addArgument(args.database) - .addOption(options.dsn) - .action(async (databaseName: unknown, options: Record) => { - await createDatabase(String(options.dsn), String(databaseName)); - }); - -database - .command('create-tables') - .description('Create Logto database tables without data.') - .addArgument(args.database) - .addOption(options.dsn) - .action(async (databaseName: unknown, options: Record) => { - const cli = createDatabaseCli(replaceDsnDatabase(String(options.dsn), String(databaseName))); - await cli.createTables(); - await cli.end(); - }); - -database - .command('seed-tables') - .description('Seed tables with necessary data to run Logto.') - .addArgument(args.database) - .addOption(options.dsn) - .addOption(options.domain) - .action(async (databaseName: unknown, options: Record) => { - const cli = createDatabaseCli(replaceDsnDatabase(String(options.dsn), String(databaseName))); - await cli.seedTables(String(options.domain)); - await cli.end(); - }); - -database - .command('init') - .description(`Create and initialize a Logto database with tables and data in the DSN.`) - .addArgument(args.database) - .addOption(options.dsn) - .addOption(options.domain) - .action(async (databaseName: unknown, options: Record) => { - const dsn = await createDatabase(String(options.dsn), String(databaseName)); - const cli = createDatabaseCli(dsn); - await cli.createTables(); - await cli.seedTables(String(options.domain)); - await cli.end(); - }); - -export default database; diff --git a/packages/cli/src/commands/database/utilities.ts b/packages/cli/src/commands/database/utilities.ts deleted file mode 100644 index 2a7813ded..000000000 --- a/packages/cli/src/commands/database/utilities.ts +++ /dev/null @@ -1,57 +0,0 @@ -// LOG-2133 Create `shared` package for common utilities - -import { SchemaLike, SchemaValue, SchemaValuePrimitive } from '@logto/schemas'; -import decamelize from 'decamelize'; -import { parseDsn, sql, SqlToken, stringifyDsn } from 'slonik'; - -/** - * Note `undefined` is removed from the acceptable list, - * since you should NOT call this function if ignoring the field is the desired behavior. - * Calling this function with `null` means an explicit `null` setting in database is expected. - * @param key The key of value. Will treat as `timestamp` if it ends with `_at` or 'At' AND value is a number; - * @param value The value to convert. - * @returns A primitive that can be saved into database. - */ -export const convertToPrimitiveOrSql = ( - key: string, - // eslint-disable-next-line @typescript-eslint/ban-types - value: NonNullable | null - // eslint-disable-next-line @typescript-eslint/ban-types -): NonNullable | SqlToken | null => { - if (value === null) { - return null; - } - - if (typeof value === 'object') { - return JSON.stringify(value); - } - - if (['_at', 'At'].some((value) => key.endsWith(value)) && typeof value === 'number') { - return sql`to_timestamp(${value / 1000})`; - } - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } - - throw new Error(`Cannot convert ${key} to primitive`); -}; - -export const insertInto = (object: T, table: string) => { - const keys = Object.keys(object); - - return sql` - insert into ${sql.identifier([table])} - (${sql.join( - keys.map((key) => sql.identifier([decamelize(key)])), - sql`, ` - )}) - values (${sql.join( - keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)), - sql`, ` - )}) - `; -}; - -export const replaceDsnDatabase = (dsn: string, databaseName: string): string => - stringifyDsn({ ...parseDsn(dsn), databaseName }); diff --git a/packages/cli/src/include.d/slonik-interceptor-preset.d.ts b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts deleted file mode 100644 index bd5cb79a9..000000000 --- a/packages/cli/src/include.d/slonik-interceptor-preset.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -declare module 'slonik-interceptor-preset' { - import { InterceptorType } from 'slonik'; - - export const createInterceptors: (config?: { - benchmarkQueries: boolean; - logQueries: boolean; - normaliseQueries: boolean; - transformFieldNames: boolean; - }) => readonly InterceptorType[]; -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts deleted file mode 100644 index d863c04e6..000000000 --- a/packages/cli/src/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Command } from 'commander'; -import dotenv from 'dotenv'; - -import database from './commands/database'; - -dotenv.config(); - -const program = new Command(); - -program.addCommand(database); -program.parse(process.argv); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json deleted file mode 100644 index ec160f030..000000000 --- a/packages/cli/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@silverhand/ts-config/tsconfig.base", - "compilerOptions": { - "outDir": "lib", - "declaration": true - }, - "include": [ - "src" - ] -} diff --git a/packages/core/package.json b/packages/core/package.json index 463a89e8d..ba4224a00 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,7 @@ "@logto/phrases": "^0.1.0", "@logto/schemas": "^0.1.0", "@silverhand/essentials": "^1.1.0", + "chalk": "^4", "dayjs": "^1.10.5", "decamelize": "^5.0.0", "deepmerge": "^4.2.2", @@ -45,8 +46,10 @@ "oidc-provider": "^7.10.0", "p-retry": "^4.6.1", "query-string": "^7.0.1", + "roarr": "^7.11.0", "slonik": "^28.0.0", "slonik-interceptor-preset": "^1.2.10", + "slonik-sql-tag-raw": "^1.1.4", "snakecase-keys": "^5.1.0", "zod": "^3.14.3" }, diff --git a/packages/core/src/database/row-count.ts b/packages/core/src/database/row-count.ts new file mode 100644 index 000000000..56b705adc --- /dev/null +++ b/packages/core/src/database/row-count.ts @@ -0,0 +1,9 @@ +import { IdentifierSqlToken, sql } from 'slonik'; + +import envSet from '@/env-set'; + +export const getTotalRowCount = async (table: IdentifierSqlToken) => + envSet.pool.one<{ count: number }>(sql` + select count(*) + from ${table} + `); diff --git a/packages/cli/src/commands/database/library.ts b/packages/core/src/database/seed.ts similarity index 72% rename from packages/cli/src/commands/database/library.ts rename to packages/core/src/database/seed.ts index f0eb7378d..feb60105d 100644 --- a/packages/cli/src/commands/database/library.ts +++ b/packages/core/src/database/seed.ts @@ -1,17 +1,21 @@ import { readdir, readFile } from 'fs/promises'; import path from 'path'; -import { seeds } from '@logto/schemas'; +import { SchemaLike, seeds } from '@logto/schemas'; import chalk from 'chalk'; -import { createPool, sql } from 'slonik'; +import decamelize from 'decamelize'; +import { createPool, parseDsn, sql, stringifyDsn } from 'slonik'; import { createInterceptors } from 'slonik-interceptor-preset'; import { raw } from 'slonik-sql-tag-raw'; -import { insertInto, replaceDsnDatabase } from './utilities'; +import { convertToPrimitiveOrSql } from './utils'; const { managementResource, defaultSignInExperience, createDefaultSetting } = seeds; const tableDirectory = 'node_modules/@logto/schemas/tables'; +export const replaceDsnDatabase = (dsn: string, databaseName: string): string => + stringifyDsn({ ...parseDsn(dsn), databaseName }); + /** * Create a database. * @returns DSN with the created database name. @@ -23,8 +27,6 @@ export const createDatabase = async (dsn: string, databaseName: string): Promise create database ${sql.identifier([databaseName])} with encoding = 'UTF8' - lc_collate = 'C' - lc_ctype = 'en_US.utf8' connection_limit = -1; `); await pool.end(); @@ -34,6 +36,22 @@ export const createDatabase = async (dsn: string, databaseName: string): Promise return replaceDsnDatabase(dsn, databaseName); }; +export const insertInto = (object: T, table: string) => { + const keys = Object.keys(object); + + return sql` + insert into ${sql.identifier([table])} + (${sql.join( + keys.map((key) => sql.identifier([decamelize(key)])), + sql`, ` + )}) + values (${sql.join( + keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)), + sql`, ` + )}) + `; +}; + export const createDatabaseCli = (dsn: string) => { const pool = createPool(dsn, { interceptors: createInterceptors() }); @@ -64,5 +82,5 @@ export const createDatabaseCli = (dsn: string) => { console.log(`${chalk.blue('[seed-tables]')} Seed tables succeeded.`); }; - return { createTables, seedTables, end: pool.end }; + return { createTables, seedTables, pool }; }; diff --git a/packages/core/src/database/utils.ts b/packages/core/src/database/utils.ts index 10fbacca0..f865cd5db 100644 --- a/packages/core/src/database/utils.ts +++ b/packages/core/src/database/utils.ts @@ -1,9 +1,7 @@ import { SchemaValuePrimitive, SchemaValue } from '@logto/schemas'; import { Falsy, notFalsy } from '@silverhand/essentials'; import dayjs from 'dayjs'; -import { sql, SqlSqlToken, SqlToken, IdentifierSqlToken } from 'slonik'; - -import envSet from '@/env-set'; +import { sql, SqlSqlToken, SqlToken } from 'slonik'; import { FieldIdentifiers, Table } from './types'; @@ -70,9 +68,3 @@ export const convertToIdentifiers = ( }); export const convertToTimestamp = (time = dayjs()) => sql`to_timestamp(${time.valueOf() / 1000})`; - -export const getTotalRowCount = async (table: IdentifierSqlToken) => - envSet.pool.one<{ count: number }>(sql` - select count(*) - from ${table} - `); diff --git a/packages/core/src/env-set/create-pool-by-env.ts b/packages/core/src/env-set/create-pool-by-env.ts index c3bfd83f2..b0ac898ca 100644 --- a/packages/core/src/env-set/create-pool-by-env.ts +++ b/packages/core/src/env-set/create-pool-by-env.ts @@ -1,12 +1,61 @@ -import { assertEnv } from '@silverhand/essentials'; +import { assertEnv, conditional, conditionalString, Optional } from '@silverhand/essentials'; import inquirer from 'inquirer'; import { createPool } from 'slonik'; import { createInterceptors } from 'slonik-interceptor-preset'; +import { createDatabase, createDatabaseCli } from '@/database/seed'; + import { appendDotEnv } from './dot-env'; import { noInquiry } from './parameters'; -const createPoolByEnv = async (isTest: boolean) => { +const defaultDatabaseUrl = 'postgres://localhost:5432'; +const defaultDatabaseName = 'logto'; + +const inquireForLogtoDsn = async (key: string): Promise<[Optional, boolean]> => { + const setUp = await inquirer.prompt({ + type: 'confirm', + name: 'value', + message: `No Postgres DSN (${key}) found in env variables. Would you like to set up a new Logto database?`, + }); + + if (!setUp.value) { + const dsn = await inquirer.prompt({ + name: 'value', + default: new URL(defaultDatabaseName, defaultDatabaseUrl).href, + message: 'Please input the DSN which points to an existing Logto database:', + }); + + return [conditional(dsn.value && String(dsn.value)), false]; + } + + const hasEmptyDatabase = await inquirer.prompt({ + type: 'confirm', + name: 'value', + default: false, + message: 'Do you have an empty databse for Logto?', + }); + + const dsnAnswer = await inquirer.prompt({ + name: 'value', + default: new URL(hasEmptyDatabase.value ? defaultDatabaseName : '', defaultDatabaseUrl).href, + message: `Please input the DSN _WITH${conditionalString( + !hasEmptyDatabase.value && 'OUT' + )}_ database name:`, + }); + const dsn = conditional(dsnAnswer.value && String(dsnAnswer.value)); + + if (!dsn) { + return [dsn, false]; + } + + if (!hasEmptyDatabase.value) { + return [await createDatabase(dsn, defaultDatabaseName), true]; + } + + return [dsn, true]; +}; + +const createPoolByEnv = async (isTest: boolean, localhostUrl: string) => { // Database connection is disabled in unit test environment if (isTest) { return; @@ -24,18 +73,28 @@ const createPoolByEnv = async (isTest: boolean) => { throw error; } - const answer = await inquirer.prompt({ - name: 'dsn', - message: `No Postgres DSN (${key}) found in env variables. Please input the DSN which points to Logto database:`, - }); + const [dsn, needsSeed] = await inquireForLogtoDsn(key); - if (!answer.dsn) { + if (!dsn) { throw error; } - appendDotEnv(key, answer.dsn); + const cli = createDatabaseCli(dsn); - return createPool(answer.dsn, { interceptors }); + if (needsSeed) { + const domain = await inquirer.prompt({ + name: 'value', + default: localhostUrl, + message: 'Enter your domain for Logto:', + }); + + await cli.createTables(); + await cli.seedTables(domain.value); + } + + appendDotEnv(key, dsn); + + return cli.pool; } }; diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 558a6dd80..5797f381b 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -1,4 +1,4 @@ -import { getEnv, Optional } from '@silverhand/essentials'; +import { conditionalString, getEnv, Optional } from '@silverhand/essentials'; import { DatabasePool } from 'slonik'; import createPoolByEnv from './create-pool-by-env'; @@ -19,6 +19,7 @@ const loadEnvValues = async () => { return Object.freeze({ isTest, isProduction, + isHttpsEnabled: Boolean(process.env.HTTPS_CERT && process.env.HTTPS_KEY), httpsCert: process.env.HTTPS_CERT, httpsKey: process.env.HTTPS_KEY, port, @@ -58,7 +59,10 @@ function createEnvSet() { load: async () => { values = await loadEnvValues(); - pool = await createPoolByEnv(values.isTest); + pool = await createPoolByEnv( + values.isTest, + `http${conditionalString(values.isHttpsEnabled && 's')}://localhost:${values.port}` + ); }, }; } diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 4e1e8f349..be6852d28 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -2,13 +2,9 @@ import { Application, CreateApplication, Applications } from '@logto/schemas'; import { sql } from 'slonik'; import { buildInsertInto } from '@/database/insert-into'; +import { getTotalRowCount } from '@/database/row-count'; import { buildUpdateWhere } from '@/database/update-where'; -import { - convertToIdentifiers, - OmitAutoSetFields, - getTotalRowCount, - conditionalSql, -} from '@/database/utils'; +import { convertToIdentifiers, OmitAutoSetFields, conditionalSql } from '@/database/utils'; import envSet from '@/env-set'; import { DeletionError } from '@/errors/SlonikError'; diff --git a/packages/core/src/queries/resource.ts b/packages/core/src/queries/resource.ts index fa6298271..4d3afbd9f 100644 --- a/packages/core/src/queries/resource.ts +++ b/packages/core/src/queries/resource.ts @@ -2,13 +2,9 @@ import { Resource, CreateResource, Resources } from '@logto/schemas'; import { sql } from 'slonik'; import { buildInsertInto } from '@/database/insert-into'; +import { getTotalRowCount } from '@/database/row-count'; import { buildUpdateWhere } from '@/database/update-where'; -import { - convertToIdentifiers, - OmitAutoSetFields, - getTotalRowCount, - conditionalSql, -} from '@/database/utils'; +import { convertToIdentifiers, OmitAutoSetFields, conditionalSql } from '@/database/utils'; import envSet from '@/env-set'; import { DeletionError } from '@/errors/SlonikError'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ca5ba46c..5d19aafee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,7 @@ importers: '@types/node': ^16.3.1 '@types/oidc-provider': ^7.8.0 '@types/supertest': ^2.0.11 + chalk: ^4 copyfiles: ^2.4.1 dayjs: ^1.10.5 decamelize: ^5.0.0 @@ -300,8 +301,10 @@ importers: p-retry: ^4.6.1 prettier: ^2.3.2 query-string: ^7.0.1 + roarr: ^7.11.0 slonik: ^28.0.0 slonik-interceptor-preset: ^1.2.10 + slonik-sql-tag-raw: ^1.1.4 snake-case: ^3.0.4 snakecase-keys: ^5.1.0 supertest: ^6.2.2 @@ -313,6 +316,7 @@ importers: '@logto/phrases': link:../phrases '@logto/schemas': link:../schemas '@silverhand/essentials': 1.1.2 + chalk: 4.1.2 dayjs: 1.10.7 decamelize: 5.0.1 deepmerge: 4.2.2 @@ -335,8 +339,10 @@ importers: oidc-provider: 7.10.4 p-retry: 4.6.1 query-string: 7.0.1 + roarr: 7.11.0 slonik: 28.1.0 slonik-interceptor-preset: 1.2.10 + slonik-sql-tag-raw: 1.1.4_roarr@7.11.0+slonik@28.1.0 snakecase-keys: 5.1.2 zod: 3.14.3 devDependencies: @@ -7956,11 +7962,6 @@ packages: engines: {node: '>= 12'} dev: true - /commander/9.1.0: - resolution: {integrity: sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==} - engines: {node: ^12.20.0 || >=14} - dev: false - /commondir/1.0.1: resolution: {integrity: sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=} dev: true @@ -18982,36 +18983,6 @@ packages: yn: 3.1.1 dev: true - /ts-node/10.4.0_e6a8a9b497f380f485f6d23f5cd591ca: - resolution: {integrity: sha512-g0FlPvvCXSIO1JDF6S232P5jPYqBkRL9qly81ZgAOSU7rwI0stphCgd2kLiCrU9DjQCrJMWEqcNSjQL02s6d8A==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - dependencies: - '@cspotcode/source-map-support': 0.7.0 - '@tsconfig/node10': 1.0.8 - '@tsconfig/node12': 1.0.9 - '@tsconfig/node14': 1.0.1 - '@tsconfig/node16': 1.0.2 - '@types/node': 16.11.12 - acorn: 8.6.0 - acorn-walk: 8.2.0 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 4.6.3 - yn: 3.1.1 - dev: true - /ts-node/10.7.0_e6a8a9b497f380f485f6d23f5cd591ca: resolution: {integrity: sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==} hasBin: true