0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

feat(cli): seed tables

This commit is contained in:
Gao Sun 2022-04-09 21:53:53 +08:00
parent fb65c65893
commit ada4da2fbf
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
17 changed files with 161 additions and 53 deletions

View file

@ -37,8 +37,10 @@
"prettier": "@silverhand/eslint-config/.prettierrc",
"dependencies": {
"@logto/schemas": "^0.1.0",
"decamelize": "^5.0.0",
"roarr": "^7.11.0",
"slonik": "^28.1.0",
"slonik-interceptor-preset": "^1.2.10",
"slonik-sql-tag-raw": "^1.1.4"
}
}

View file

@ -1,13 +1,19 @@
import { readdir, readFile } from 'fs/promises';
import path from 'path';
import { seeds } from '@logto/schemas';
import { createPool, sql } from 'slonik';
import { createInterceptors } from 'slonik-interceptor-preset';
import { raw } from 'slonik-sql-tag-raw';
import { insertInto } from './utilities';
const { managementResource, defaultSignInExperience, createDefaultSetting } = seeds;
const tableDirectory = 'node_modules/@logto/schemas/tables';
const domain = 'http://localhost:3001';
export const createDatabaseCli = (uri: string) => {
const pool = createPool(uri);
const pool = createPool(uri, { interceptors: createInterceptors() });
const createTables = async () => {
const directory = await readdir(tableDirectory);
@ -23,13 +29,22 @@ export const createDatabaseCli = (uri: string) => {
for (const [file, query] of queries) {
// eslint-disable-next-line no-await-in-loop
await pool.query(sql`${raw(query)}`);
console.log(`Run ${file} succeeded.`);
console.log(`Create Tables: Run ${file} succeeded.`);
}
};
return { createTables };
const seedTables = async () => {
await Promise.all([
pool.query(insertInto(managementResource, 'resources')),
pool.query(insertInto(createDefaultSetting(domain), 'settings')),
pool.query(insertInto(defaultSignInExperience, 'sign_in_experiences')),
]);
console.log('Seed Tables: Seed tables succeeded.');
};
return { createTables, seedTables };
};
// For testing purpose, will remove later
const cli = createDatabaseCli(process.env.DSN ?? '');
void cli.createTables();
void cli.seedTables();

View file

@ -0,0 +1,10 @@
declare module 'slonik-interceptor-preset' {
import { InterceptorType } from 'slonik';
export const createInterceptors: (config?: {
benchmarkQueries: boolean;
logQueries: boolean;
normaliseQueries: boolean;
transformFieldNames: boolean;
}) => readonly InterceptorType[];
}

View file

@ -0,0 +1,54 @@
// LOG-2133 Create `shared` package for common utilities
import { SchemaLike, SchemaValue, SchemaValuePrimitive } from '@logto/schemas';
import decamelize from 'decamelize';
import { sql, SqlToken } 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`, `
)})
`;
};

View file

@ -1,15 +1,14 @@
import { Application, ApplicationType, Setting } from '@logto/schemas';
import { Application, ApplicationType } from '@logto/schemas';
import React, { useState } from 'react';
import { useController, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import ModalLayout from '@/components/ModalLayout';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import TextInput from '@/components/TextInput';
import useApi, { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { applicationTypeI18nKey } from '@/types/applications';
import { GetStartedForm } from '@/types/get-started';
@ -40,11 +39,8 @@ const CreateForm = ({ onClose }: Props) => {
field: { onChange, value, name, ref },
} = useController({ name: 'type', control, rules: { required: true } });
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: setting } = useSWR<Setting, RequestError>('/api/settings');
const api = useApi();
const isGetStartedSkipped = setting?.adminConsole.applicationSkipGetStarted;
const closeModal = () => {
setIsGetStartedModalOpen(false);
onClose?.(createdApp);
@ -58,11 +54,7 @@ const CreateForm = ({ onClose }: Props) => {
const createdApp = await api.post('/api/applications', { json: data }).json<Application>();
setCreatedApp(createdApp);
if (isGetStartedSkipped) {
closeModal();
} else {
setIsGetStartedModalOpen(true);
}
setIsGetStartedModalOpen(true);
});
const onComplete = async (data: GetStartedForm) => {
@ -124,7 +116,7 @@ const CreateForm = ({ onClose }: Props) => {
<TextInput {...register('description')} />
</FormField>
</form>
{!isGetStartedSkipped && createdApp && (
{createdApp && (
<GetStartedModal
appName={createdApp.name}
isOpen={isGetStartedModalOpen}

View file

@ -43,9 +43,7 @@ export const mockRole: Role = {
export const mockSetting: Setting = {
id: 'foo setting',
customDomain: 'mock-logto.dev',
adminConsole: {
applicationSkipGetStarted: false,
},
adminConsole: {},
};
export const mockPasscode: Passcode = {

View file

@ -13,9 +13,7 @@ export const mockSignInExperience: SignInExperience = {
id: 'foo',
branding: {
primaryColor: '#000',
backgroundColor: '#fff',
darkMode: true,
darkBackgroundColor: '#000',
isDarkModeEnabled: true,
darkPrimaryColor: '#fff',
style: BrandingStyle.Logo,
logoUrl: 'http://logto.png',
@ -24,7 +22,6 @@ export const mockSignInExperience: SignInExperience = {
termsOfUse: {
enabled: false,
},
forgetPasswordEnabled: true,
languageInfo: {
autoDetect: true,
fallbackLanguage: Language.english,
@ -41,9 +38,7 @@ export const mockSignInExperience: SignInExperience = {
export const mockBranding: Branding = {
primaryColor: '#000',
backgroundColor: '#fff',
darkMode: true,
darkBackgroundColor: '#000',
isDarkModeEnabled: true,
darkPrimaryColor: '#fff',
style: BrandingStyle.Logo_Slogan,
logoUrl: 'http://silverhand.png',

View file

@ -30,7 +30,7 @@ describe('sign-in-experience query', () => {
it('findDefaultSignInExperience', async () => {
/* eslint-disable sql/no-unsafe-query */
const expectSql = `
select "id", "branding", "language_info", "terms_of_use", "forget_password_enabled", "sign_in_methods", "social_sign_in_connector_ids"
select "id", "branding", "language_info", "terms_of_use", "sign_in_methods", "social_sign_in_connector_ids"
from "sign_in_experiences"
where "id" = $1
`;

View file

@ -22,7 +22,6 @@ export type CreateSignInExperience = {
branding?: Branding;
languageInfo?: LanguageInfo;
termsOfUse?: TermsOfUse;
forgetPasswordEnabled?: boolean;
signInMethods?: SignInMethods;
socialSignInConnectorIds?: ConnectorIds;
};
@ -32,7 +31,6 @@ export type SignInExperience = {
branding: Branding;
languageInfo: LanguageInfo;
termsOfUse: TermsOfUse;
forgetPasswordEnabled: boolean;
signInMethods: SignInMethods;
socialSignInConnectorIds: ConnectorIds;
};
@ -42,7 +40,6 @@ const createGuard: Guard<CreateSignInExperience> = z.object({
branding: brandingGuard.optional(),
languageInfo: languageInfoGuard.optional(),
termsOfUse: termsOfUseGuard.optional(),
forgetPasswordEnabled: z.boolean().optional(),
signInMethods: signInMethodsGuard.optional(),
socialSignInConnectorIds: connectorIdsGuard.optional(),
});
@ -55,7 +52,6 @@ export const SignInExperiences: GeneratedSchema<CreateSignInExperience> = Object
branding: 'branding',
languageInfo: 'language_info',
termsOfUse: 'terms_of_use',
forgetPasswordEnabled: 'forget_password_enabled',
signInMethods: 'sign_in_methods',
socialSignInConnectorIds: 'social_sign_in_connector_ids',
},
@ -64,7 +60,6 @@ export const SignInExperiences: GeneratedSchema<CreateSignInExperience> = Object
'branding',
'languageInfo',
'termsOfUse',
'forgetPasswordEnabled',
'signInMethods',
'socialSignInConnectorIds',
],

View file

@ -76,9 +76,7 @@ export type UserLogPayload = z.infer<typeof userLogPayloadGuard>;
* Settings
*/
export const adminConsoleConfigGuard = z.object({
applicationSkipGetStarted: z.boolean(),
});
export const adminConsoleConfigGuard = z.object({});
export type AdminConsoleConfig = z.infer<typeof adminConsoleConfigGuard>;
@ -95,10 +93,8 @@ export const hexColorRegEx = /^#[\da-f]{3}([\da-f]{3})?$/i;
export const brandingGuard = z.object({
primaryColor: z.string().regex(hexColorRegEx),
backgroundColor: z.string().regex(hexColorRegEx),
darkMode: z.boolean(),
isDarkModeEnabled: z.boolean(),
darkPrimaryColor: z.string().regex(hexColorRegEx),
darkBackgroundColor: z.string().regex(hexColorRegEx),
style: z.nativeEnum(BrandingStyle),
logoUrl: z.string().url(),
slogan: z.string().nonempty().optional(),

View file

@ -2,3 +2,4 @@ export * from './foundations';
export * from './db-entries';
export * from './types';
export * from './api';
export * as seeds from './seeds';

View file

@ -0,0 +1,3 @@
export * from './resource';
export * from './setting';
export * from './sign-in-experience';

View file

@ -0,0 +1,7 @@
import { CreateResource } from '../db-entries';
export const managementResource: Readonly<CreateResource> = Object.freeze({
id: 'management-api',
indicator: 'https://logto.io/api',
name: 'Logto Management API',
});

View file

@ -0,0 +1,10 @@
import { CreateSetting } from '../db-entries';
export const defaultSettingId = 'default';
export const createDefaultSetting = (customDomain: string): Readonly<CreateSetting> =>
Object.freeze({
id: defaultSettingId,
customDomain,
adminConsole: {},
});

View file

@ -0,0 +1,28 @@
import { CreateSignInExperience } from '../db-entries';
import { BrandingStyle, Language, SignInMethodState } from '../foundations';
export const defaultSignInExperience: Readonly<CreateSignInExperience> = {
id: 'default',
branding: {
primaryColor: '#6139F6',
isDarkModeEnabled: false,
darkPrimaryColor: '#6139F6',
style: BrandingStyle.Logo,
logoUrl: 'https://logto.io/logo.svg',
},
languageInfo: {
autoDetect: true,
fallbackLanguage: Language.english,
fixedLanguage: Language.english,
},
termsOfUse: {
enabled: false,
},
signInMethods: {
username: SignInMethodState.primary,
email: SignInMethodState.disabled,
sms: SignInMethodState.disabled,
social: SignInMethodState.disabled,
},
socialSignInConnectorIds: [],
};

View file

@ -3,7 +3,6 @@ create table sign_in_experiences (
branding jsonb /* @use Branding */ not null default '{}'::jsonb,
language_info jsonb /* @use LanguageInfo */ not null default '{}'::jsonb,
terms_of_use jsonb /* @use TermsOfUse */ not null default '{}'::jsonb,
forget_password_enabled boolean not null default false,
sign_in_methods jsonb /* @use SignInMethods */ not null default '{}'::jsonb,
social_sign_in_connector_ids jsonb /* @use ConnectorIds */ not null default '[]'::jsonb,
primary key (id)

31
pnpm-lock.yaml generated
View file

@ -24,18 +24,22 @@ importers:
'@silverhand/eslint-config': ^0.10.2
'@silverhand/ts-config': ^0.10.2
'@types/node': '14'
decamelize: ^5.0.0
eslint: ^8.10.0
lint-staged: ^11.1.1
prettier: ^2.3.2
roarr: ^7.11.0
slonik: ^28.1.0
slonik-interceptor-preset: ^1.2.10
slonik-sql-tag-raw: ^1.1.4
ts-node: ^10.0.0
typescript: ^4.6.3
dependencies:
'@logto/schemas': link:../schemas
decamelize: 5.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
devDependencies:
'@silverhand/eslint-config': 0.10.2_bbe1a6794670f389df81805f22999709
@ -2338,6 +2342,7 @@ packages:
dependencies:
'@babel/helper-validator-identifier': 7.15.7
to-fast-properties: 2.0.0
dev: true
/@babel/types/7.17.0:
resolution: {integrity: sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==}
@ -6886,9 +6891,9 @@ packages:
/babel-plugin-macros/2.8.0:
resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==}
dependencies:
'@babel/runtime': 7.16.3
'@babel/runtime': 7.17.9
cosmiconfig: 6.0.0
resolve: 1.20.0
resolve: 1.22.0
dev: false
/babel-plugin-polyfill-corejs2/0.3.1_@babel+core@7.17.9:
@ -7314,6 +7319,7 @@ packages:
/camelcase/6.2.1:
resolution: {integrity: sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA==}
engines: {node: '>=10'}
dev: true
/camelcase/6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
@ -7947,11 +7953,6 @@ packages:
requiresBuild: true
dev: false
/core-js/3.19.3:
resolution: {integrity: sha512-LeLBMgEGSsG7giquSzvgBrTS7V5UL6ks3eQlUSbN8dJStlLFiRzUm5iqsRyzUB8carhfKjkJ2vzKqE6z1Vga9g==}
requiresBuild: true
dev: false
/core-js/3.21.1:
resolution: {integrity: sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==}
requiresBuild: true
@ -10937,7 +10938,7 @@ packages:
/inline-loops.macro/1.2.2:
resolution: {integrity: sha512-w5cOGQGnNoBTSibg6IzaIG2OG9sbXJxTn3uzYP717C/SvcJVEFz5Zu1dJwvCLlnwtBWQgcfnV2BNEQaRoIAfIw==}
dependencies:
'@babel/types': 7.16.0
'@babel/types': 7.17.0
babel-plugin-macros: 2.8.0
dev: false
@ -11070,6 +11071,7 @@ packages:
resolution: {integrity: sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==}
dependencies:
has: 1.0.3
dev: true
/is-core-module/2.8.1:
resolution: {integrity: sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==}
@ -16717,6 +16719,7 @@ packages:
dependencies:
is-core-module: 2.8.0
path-parse: 1.0.7
dev: true
/resolve/1.22.0:
resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==}
@ -17201,8 +17204,8 @@ packages:
peerDependencies:
slonik: '*'
dependencies:
camelcase: 6.2.1
core-js: 3.19.3
camelcase: 6.3.0
core-js: 3.21.1
slonik: 22.7.1
dev: false
@ -17223,7 +17226,7 @@ packages:
resolution: {integrity: sha512-f9jxhsu+8u0ssf2pdzLx1jSlGODkAitNbGrprJWGOjmnQzZKW4jWaq54DZGwyNv4HotOb9m4Lp0u9XQODKXyng==}
engines: {node: '>=8.0'}
dependencies:
core-js: 3.19.3
core-js: 3.21.1
pg-formatter: 1.3.0
pretty-ms: 6.0.1
slonik: 22.7.1
@ -17249,7 +17252,7 @@ packages:
resolution: {integrity: sha512-TAuWVFBVnq7I5KcEY/x1JgVgIVZ0yyyeRlMTzKs+u4wRYhszQW2hMIYnDak/UUfWR1h6wp3+hODiC4gKyBOUcg==}
engines: {node: '>=8.0'}
dependencies:
core-js: 3.19.3
core-js: 3.21.1
slonik: 22.7.1
transitivePeerDependencies:
- pg-native
@ -17279,11 +17282,11 @@ packages:
inline-loops.macro: 1.2.2
is-plain-object: 5.0.0
iso8601-duration: 1.3.0
pg: 8.7.1
pg: 8.7.3
pg-connection-string: 2.5.0
pg-copy-streams: 5.1.1
pg-copy-streams-binary: 2.2.0
pg-cursor: 2.7.1_pg@8.7.1
pg-cursor: 2.7.3_pg@8.7.3
pg-types: 3.0.1
postgres-array: 2.0.0
postgres-interval: 2.1.0