mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor: move cli -> core
This commit is contained in:
parent
a10b427c87
commit
f5c7faf775
16 changed files with 122 additions and 238 deletions
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "root",
|
||||
"name": "@logto/root",
|
||||
"private": true,
|
||||
"license": "MPL-2.0",
|
||||
"scripts": {
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
require('../');
|
|
@ -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 <address>',
|
||||
`Your Postgres DSN *WITHOUT* database name.\n${versionNote}`
|
||||
)
|
||||
.env('DSN')
|
||||
.makeOptionMandatory(),
|
||||
domain: new Option('--domain <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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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;
|
|
@ -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<SchemaValue> | null
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
): NonNullable<SchemaValuePrimitive> | 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 = <T extends SchemaLike>(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 });
|
|
@ -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[];
|
||||
}
|
|
@ -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);
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"declaration": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
},
|
||||
|
|
9
packages/core/src/database/row-count.ts
Normal file
9
packages/core/src/database/row-count.ts
Normal file
|
@ -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}
|
||||
`);
|
|
@ -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 = <T extends SchemaLike>(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 };
|
||||
};
|
|
@ -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 = <T extends Table>(
|
|||
});
|
||||
|
||||
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}
|
||||
`);
|
||||
|
|
|
@ -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<string>, 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<string>(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<string>(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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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}`
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue