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