diff --git a/client/package.json b/client/package.json index 491bf0b..5611ba8 100644 --- a/client/package.json +++ b/client/package.json @@ -14,8 +14,8 @@ "@mui/icons-material": "^5.14.12", "@mui/material": "^5.14.9", "@mui/styles": "^5.15.13", - "@pvi/core": "workspace:^", - "@pvi/react": "workspace:^", + "@policy-maker/core": "workspace:^", + "@policy-maker/next": "workspace:^", "@tanstack/react-query": "^5.4.3", "@types/node": "20.6.0", "@types/react": "18.2.21", diff --git a/client/src/app/article/[articleId]/ArticleDetail.css b/client/src/app/article/[articleId]/ArticleDetail.css new file mode 100644 index 0000000..8251129 --- /dev/null +++ b/client/src/app/article/[articleId]/ArticleDetail.css @@ -0,0 +1,13 @@ +.page { + background-color: black; +} + +.header { + margin: 0 0 var(--primitives_spacing-4) 0; +} + +.body { + color: white; + padding: 0 24%; + margin-bottom: var(--primitives_spacing-10); +} diff --git a/client/src/app/article/[articleId]/page.tsx b/client/src/app/article/[articleId]/page.tsx new file mode 100644 index 0000000..3384509 --- /dev/null +++ b/client/src/app/article/[articleId]/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import "./ArticleDetail.css"; +import Header from "@components/Header"; + +import Footer from "@components/Footer"; +import { useEffect, useState } from "react"; +import { z } from "zod"; +import typography from "@styles/typography.module.css"; + +type ArticleDetailProps = { + params: { + articleId: number; + }; +}; + +export default function ArticleDetail(props: ArticleDetailProps) { + const id = props.params.articleId; + const [result, setResult] = useState(""); + + useEffect(() => { + fetch("http://localhost:8080/articles/" + id) + .then((res) => res.json()) + .then(z.object({ content: z.string() }).parse) + .then(({ content }) => setResult(content)); + }, []); + + return ( +
+
+
+
+
+
{result}
+
+
+
+
+ ); +} + +// 서버 데이터로 대체 예정입니다. +const samplePostData = { + id: 1, + title: "지원 사업 관련 질문 있습니다!", + content: + "나무들이 서로 속삭이는 듯한 소리가 숲속을 가득 채우고 있었다. 간간히 부는 바람이 나뭇잎을 흔들며, 그 소리는 마치 오래된 이야기를 들려주는 것 같았다. 이 숲의 한가운데서, 한 소년이 고개를 들어 하늘을 바라보았다. 햇빛이 나뭇가지 사이로 비치며, 그의 얼굴에 따스한 빛을 더했다. 소년은 숲이 주는 평화로움 속에서 잠시의 여유를 즐기며, 모험을 꿈꾸었다. 이 순간, 그는 어떠한 걱정도, 두려움도 잊고 오직 순수한 기쁨을 느끼며, 자연과 하나가 되었다.", + postType: "INFORMATION" as const, + likesCount: 20, + viewsCount: 100, + commentsCount: 5, + createdTime: new Date("2021-08-01"), + lastModifiedTime: new Date("2021-08-01"), + createdUser: { + id: 1, + name: "날아오르는 고라파덕", + thumbnailImage: null, + }, + tags: [ + { + id: 1, + name: "정보", + }, + { + id: 2, + name: "월간Best", + }, + ], +}; + +const sampleCommentData = [ + { + id: 1, + content: "너무 좋은 글이네요! 감사합니다.", + likesCount: 20, + createdTime: new Date("2021-08-01"), + lastModifiedTime: new Date("2021-08-01"), + createdUser: { + id: 1, + name: "날아오르는 고라파덕", + thumbnailImage: null, + }, + }, + { + id: 2, + content: "너무 좋은 글이네요! 감사합니다.", + likesCount: 20, + createdTime: new Date("2021-08-01"), + lastModifiedTime: new Date("2021-08-01"), + createdUser: { + id: 1, + name: "날아오르는 고라파덕", + thumbnailImage: null, + }, + }, + { + id: 3, + content: "너무 좋은 글이네요! 감사합니다.", + likesCount: 20, + createdTime: new Date("2021-08-01"), + lastModifiedTime: new Date("2021-08-01"), + createdUser: { + id: 1, + name: "날아오르는 고라파덕", + thumbnailImage: null, + }, + }, +]; diff --git a/client/src/app/article/write/ArticleWrite.css b/client/src/app/article/write/ArticleWrite.css new file mode 100644 index 0000000..8c2ef0a --- /dev/null +++ b/client/src/app/article/write/ArticleWrite.css @@ -0,0 +1,32 @@ +.page { + background-color: black; +} + +.header { + margin: 0 0 var(--primitives_spacing-4) 0; +} + +.body { + color: white; + padding: 0 24%; + margin-bottom: var(--primitives_spacing-10); +} + +.body input { + border: none; + outline: none; + background-color: var(--surface-primary); + padding: 0; + height: 50px; + margin-right: var(--primitives_spacing-4); +} + +.body button { + border: none; + outline: none; + cursor: pointer; + background-color: var(--surface-primary); + color: var(--text-secondary); + text-align: left; + padding: 10px; +} diff --git a/client/src/app/article/write/page.tsx b/client/src/app/article/write/page.tsx new file mode 100644 index 0000000..0c09522 --- /dev/null +++ b/client/src/app/article/write/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import "./ArticleWrite.css"; +import Header from "@components/Header"; + +import Footer from "@components/Footer"; +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; + +import typography from "@styles/typography.module.css"; + +export default function ArticleWrite() { + const [data, setData] = useState(""); + const router = useRouter(); + + const onSubmit = useCallback(() => { + fetch("http://localhost:8080/articles", { + method: "POST", + body: JSON.stringify({ content: data }), + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => res.json()) + .then( + z.object({ + insertedId: z.number(), + }).parse, + ) + .then(({ insertedId }) => router.push(`/article/${insertedId}`)); + }, [data, router]); + + return ( +
+
+
+
+
+
+ setData(e.target.value)} /> + +
+
+
+
+
+ ); +} diff --git a/client/src/app/dev/page.tsx b/client/src/app/dev/page.tsx index da830e9..f877a92 100644 --- a/client/src/app/dev/page.tsx +++ b/client/src/app/dev/page.tsx @@ -11,8 +11,17 @@ import styled from "@emotion/styled"; import Lottie from "lottie-react"; import logoAnimation from "../../../public/logo_animation.json"; import layout from "../../styles/layout"; +import { useView } from "library/policy-maker/next"; +import viewPolicy from "@core/policy/view"; +import { UserRepository } from "@core/repository/user"; export default function Dev() { + const { view } = useView({ + policy: viewPolicy.user.user(1), + from: () => UserRepository.getUser(1), + }); + + console.log(view); return (
COMPONENTS diff --git a/client/src/app/error.tsx b/client/src/app/error.tsx new file mode 100644 index 0000000..b0c13c4 --- /dev/null +++ b/client/src/app/error.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
+

Something went wrong!

+ +
+ ); +} diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index 6fb5f88..f1e1963 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; -import Providers from "@/utils/next-query-resolver/Providers"; import "./globals.css"; import "./fonts.css"; +import { Provider } from "@policy-maker/next"; export const metadata: Metadata = { title: "Create Next App", @@ -14,10 +14,10 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + {children} - + ); } diff --git a/client/src/app/loading.tsx b/client/src/app/loading.tsx new file mode 100644 index 0000000..fc80ef0 --- /dev/null +++ b/client/src/app/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
; +} diff --git a/client/src/app/login/page.tsx b/client/src/app/login/page.tsx index 2398624..250e3c7 100644 --- a/client/src/app/login/page.tsx +++ b/client/src/app/login/page.tsx @@ -8,25 +8,39 @@ import { Snackbar, } from "@mui/material"; import "./Login.css"; -import { UserApi } from "@core/api/user"; -import { useView } from "library/policy-maker-2/react"; -import { VPMe } from "@core/policy/user/view/me"; + +import { useIntentInput, useIntentSubmit } from "library/policy-maker/next"; +import intentPolicy from "@core/policy/intent"; +import { UserRepository } from "@core/repository/user"; +import { useRouter } from "next/navigation"; function Login() { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); + const { submit, isValid } = useIntentSubmit({ + policy: intentPolicy.user.login(), + to: UserRepository.postLogin, + }); + + const { + set, + values: { email, password }, + } = useIntentInput({ + policy: intentPolicy.user.login(), + initialValue: () => ({ email: "", password: "" }), + }); + + // const [email, setEmail] = useState(""); + // const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [openSnackbar, setOpenSnackbar] = useState(false); - const { view } = useView({ - policy: VPMe(), - from: () => UserApi.getMe.client({}).catch(() => null), - }); + const router = useRouter(); const handleLogin = () => { - UserApi.postSignIn - .client({ body: { email, password } }) - .then((data) => { - localStorage.setItem("xctoken", data.token); + if (!isValid) alert("이메일 또는 비밀번호를 확인해주세요"); + + submit() + .then(({ token }) => { + localStorage.setItem("xctoken", token); + router.push("/"); }) .catch((e) => { setError(e.message); @@ -51,8 +65,8 @@ function Login() { fullWidth variant="outlined" margin="normal" - value={email} - onChange={(e) => setEmail(e.target.value)} + value={email.value} + onChange={(e) => set({ email: e.target.value })} /> setPassword(e.target.value)} + value={password.value} + onChange={(e) => set({ password: e.target.value })} /> diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index 15da1a8..0527424 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -1,95 +1,11 @@ -import Image from "next/image"; -import styles from "./page.module.css"; +import Link from "next/link"; +import MeTest from "@/modules/home/MeTest/MeTest"; export default function Home() { return ( -
-
-

- Get started by editing  - src/app/page.tsx -

- -
- -
- Next.js Logo -
- - +
+ + 로그인
); } diff --git a/client/src/app/register/page.tsx b/client/src/app/register/page.tsx index 862a392..e4942ea 100644 --- a/client/src/app/register/page.tsx +++ b/client/src/app/register/page.tsx @@ -19,7 +19,7 @@ function Register() { //const navigate = useNavigate() const handleJoin = () => { - fetch("../api/mock/user/register/route", { + fetch("http://localhost:8080/users/register", { method: "POST", headers: { "Content-Type": "application/json", @@ -28,8 +28,10 @@ function Register() { }) .then((response) => { if (response.ok) { + alert(`회원가입 성공`); return response.json(); } else { + alert(`회원가입 실패`); throw new Error("Register fail"); } }) diff --git a/client/src/app/serverTest/error.tsx b/client/src/app/serverTest/error.tsx new file mode 100644 index 0000000..6f4f3f1 --- /dev/null +++ b/client/src/app/serverTest/error.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
+

Something went wrong2

+ +
+ ); +} diff --git a/client/src/app/serverTest/page.tsx b/client/src/app/serverTest/page.tsx index 3087230..bbc37b2 100644 --- a/client/src/app/serverTest/page.tsx +++ b/client/src/app/serverTest/page.tsx @@ -1,11 +1,15 @@ "use client"; -import { api } from "@/api"; -import { PostApi } from "@core/api/post"; -import { useEffect } from "react"; +import viewPolicy from "@core/policy/view"; +import { UserRepository } from "@core/repository/user"; +import { delay, error } from "library/fetch"; +import { useView } from "library/policy-maker/next"; + export default function ServerTest() { - useEffect(() => { - api.get("/posts").then(console.log); - }, []); - return
; + const { view } = useView({ + policy: viewPolicy.user.user(1), + from: () => UserRepository.getUser(1).then(delay(1000)).then(error(50)), + }); + + return
{view.name}
; } diff --git a/client/src/modules/home/MeTest/MeTest.tsx b/client/src/modules/home/MeTest/MeTest.tsx new file mode 100644 index 0000000..0a6471b --- /dev/null +++ b/client/src/modules/home/MeTest/MeTest.tsx @@ -0,0 +1,16 @@ +"use client"; + +import viewPolicy from "@core/policy/view"; +import { UserRepository } from "@core/repository/user"; +import { useViewMaybe } from "library/policy-maker/next"; + +export default function MeTest() { + const { view } = useViewMaybe({ + policy: viewPolicy.user.me(), + from: UserRepository.getMe, + }); + + if (!view) return
로그인을 해주세요
; + + return
{view.name}로 로그인 되었습니다
; +} diff --git a/core/constant/post/text.ts b/core/constant/post/text.ts index 407e96a..d770fe6 100644 --- a/core/constant/post/text.ts +++ b/core/constant/post/text.ts @@ -3,7 +3,6 @@ import { z } from "zod"; export const POST_TEXT = { TITLE: z.string().min(1).max(100), CONTENT: z.string().min(1), - COMMENT_CONTENT: z.string().min(1), }; export type POST_TEXT = { [key in keyof typeof POST_TEXT]: z.infer<(typeof POST_TEXT)[key]>; @@ -12,3 +11,8 @@ export type POST_TEXT = { export const POST_COMMENT_TEXT = { content: z.string().min(1), }; +export type POST_COMMENT_TEXT = { + [key in keyof typeof POST_COMMENT_TEXT]: z.infer< + (typeof POST_COMMENT_TEXT)[key] + >; +}; diff --git a/core/constant/user/text.ts b/core/constant/user/text.ts new file mode 100644 index 0000000..62586e6 --- /dev/null +++ b/core/constant/user/text.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const USER_TEXT = { + NAME: z.string().min(1).max(30), + BIO: z.string().max(500), +}; diff --git a/core/constant/user/userEmail.ts b/core/constant/user/userEmail.ts new file mode 100644 index 0000000..6979a94 --- /dev/null +++ b/core/constant/user/userEmail.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const USER_EMAIL = z.string().email().max(255); diff --git a/core/constant/user/userPassword.ts b/core/constant/user/userPassword.ts new file mode 100644 index 0000000..8947b12 --- /dev/null +++ b/core/constant/user/userPassword.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const USER_PASSWORD = z.string().min(8).max(45); diff --git a/core/dto/user/index.ts b/core/dto/user/index.ts new file mode 100644 index 0000000..ec96c41 --- /dev/null +++ b/core/dto/user/index.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; +import { User } from "@core/entity/user"; + +export const UserDto = User; +export type UserDto = z.infer; diff --git a/core/dto/user/request.ts b/core/dto/user/request.ts new file mode 100644 index 0000000..e51f942 --- /dev/null +++ b/core/dto/user/request.ts @@ -0,0 +1,15 @@ +import { USER_TEXT } from "@core/constant/user/text"; +import { z } from "zod"; + +export const PostRegisterBody = z.object({ + email: z.string().email(), + password: z.string().min(8), + name: USER_TEXT.NAME, +}); +export type PostRegisterBody = z.infer; + +export const PostLoginBody = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); +export type PostLoginBody = z.infer; diff --git a/core/entity/post/index.ts b/core/entity/post/index.ts index 7480e7f..5f6000f 100644 --- a/core/entity/post/index.ts +++ b/core/entity/post/index.ts @@ -15,10 +15,10 @@ export const Post = z postType: POST_TYPE, title: POST_TEXT.TITLE, content: POST_TEXT.CONTENT, - tags: Tag.array(), - likesCount: z.number().int().nonnegative(), - viewsCount: z.number().int().nonnegative(), - commentsCount: z.number().int().nonnegative(), + tags: Tag.array().default([]), + likesCount: z.number().default(0), + viewsCount: z.number().default(0), + commentsCount: z.number().default(0), }) .extend(Creatable.shape); export type Post = z.infer; diff --git a/core/entity/post/summary.ts b/core/entity/post/summary.ts index 3c6e1af..9e1b3d5 100644 --- a/core/entity/post/summary.ts +++ b/core/entity/post/summary.ts @@ -1,9 +1,13 @@ -import { Post } from "."; +import { z } from "zod"; +import { ID } from "@core/constant/common/id"; +import { POST_TEXT } from "@core/constant/post/text"; +import { Creatable } from "../utility/creatable"; + +export const PostSummary = z + .object({ + id: ID.POST, + title: POST_TEXT.TITLE, + }) + .extend(Creatable.shape); -export const PostSummary = Post.pick({ - id: true, - title: true, - content: true, - createdUser: true, -}); export type PostSummary = typeof PostSummary; diff --git a/core/entity/user/index.ts b/core/entity/user/index.ts index 8086147..9b43768 100644 --- a/core/entity/user/index.ts +++ b/core/entity/user/index.ts @@ -1,18 +1,7 @@ import { z } from "zod"; -import { Authorization } from "../../utility/Authorization"; -import { PublicId } from "../../utility/Id"; import { ID } from "@core/constant/common/id"; import { USER_TYPE } from "@core/constant/user/userType"; - -/* - * Old - */ -export type UserEntity = { - id: PublicId; - name: string; // required - email: string; // required - authorization: Authorization; -}; +import { USER_TEXT } from "@core/constant/user/text"; /** * @entity User @@ -22,11 +11,10 @@ export const User = z.object({ id: ID.USER, type: USER_TYPE.default("USER"), email: z.string().email(), - name: z.string().min(1).max(30), - bio: z.string().nullable(), + name: USER_TEXT.NAME, + bio: USER_TEXT.BIO.nullable(), // image thumbnailImage: z.string().nullable(), - backgroundImage: z.string().nullable(), }); export type User = z.infer; diff --git a/core/entity/user/summary.ts b/core/entity/user/summary.ts index 14de969..dbfd4ae 100644 --- a/core/entity/user/summary.ts +++ b/core/entity/user/summary.ts @@ -1,13 +1,20 @@ +import { ID } from "@core/constant/common/id"; +import { USER_TEXT } from "@core/constant/user/text"; import { z } from "zod"; -import { User } from "."; /** * @entity User Summary * @description 사용자의 요약 정보입니다. */ -export const UserSummary = User.pick({ - id: true, - name: true, - thumbnailImage: true, +export const UserSummary = z.object({ + id: ID.USER, + name: USER_TEXT.NAME, + thumbnailImage: z.string().nullable(), }); export type UserSummary = z.infer; + +export const UnknownUserSummary: UserSummary = { + id: -1, + name: "알 수 없는 사용자", + thumbnailImage: null, +}; diff --git a/core/entity/utility/creatable.ts b/core/entity/utility/creatable.ts index 1603e73..3aee805 100644 --- a/core/entity/utility/creatable.ts +++ b/core/entity/utility/creatable.ts @@ -1,9 +1,8 @@ import { z } from "zod"; -import { UserSummary } from "../user/summary"; -import { UNKNOWN_USER_SUMMARY } from "../../constant/user/unknownUser"; +import { UnknownUserSummary, UserSummary } from "../user/summary"; export const Creatable = z.object({ - createdUser: UserSummary.default(UNKNOWN_USER_SUMMARY), + createdUser: UserSummary.default(UnknownUserSummary), createdTime: z.instanceof(Date), lastModifiedTime: z.instanceof(Date).nullable(), }); diff --git a/core/package.json b/core/package.json index bc3d433..4828f99 100644 --- a/core/package.json +++ b/core/package.json @@ -3,8 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "@policy-maker-2/core": "workspace:^", - "@pvi/core": "workspace:^", + "@policy-maker/core": "workspace:^", "fetch": "workspace:^", "qs": "^6.12.1", "zod": "^3.23.8" diff --git a/core/policy/intent.ts b/core/policy/intent.ts index d740afa..1dc442d 100644 --- a/core/policy/intent.ts +++ b/core/policy/intent.ts @@ -1,13 +1,9 @@ -import { IPLikePost } from "./post/intent/likePost"; -import { IPLikePostComment } from "./post/intent/likePostComment"; -import { IPWritePost } from "./post/intent/writePost"; -import { IPWritePostComment } from "./post/intent/writePostComment"; +import { IPLogin } from "./user/intent/login"; -export const intentPolicy = { - post: { - writePost: IPWritePost, - writePostComment: IPWritePostComment, - likePost: IPLikePost, - likePostComment: IPLikePostComment, +const intentPolicy = { + user: { + login: IPLogin, }, }; + +export default intentPolicy; diff --git a/core/policy/post/intent/likePost.ts b/core/policy/post/intent/likePost.ts deleted file mode 100644 index 9c4a44f..0000000 --- a/core/policy/post/intent/likePost.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ID } from "@core/constant/common/id"; -import { IntentPolicy } from "@policy-maker-2/core"; -import { z } from "zod"; - -export const IPLikePost = IntentPolicy((postId: ID["POST"]) => ({ - key: { name: "likePost", postId }, - model: { input: z.never(), output: z.unknown() }, - next: () => [], -})); diff --git a/core/policy/post/intent/likePostComment.ts b/core/policy/post/intent/likePostComment.ts deleted file mode 100644 index f79e090..0000000 --- a/core/policy/post/intent/likePostComment.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ID } from "@core/constant/common/id"; -import { IntentPolicy } from "@policy-maker-2/core"; -import { z } from "zod"; - -export const IPLikePostComment = IntentPolicy( - (postCommentId: ID["POST_COMMENT"]) => ({ - key: { name: "likePost", postCommentId }, - model: { input: z.never(), output: z.unknown() }, - next: () => [], - }), -); diff --git a/core/policy/post/intent/writePost.ts b/core/policy/post/intent/writePost.ts deleted file mode 100644 index 8429636..0000000 --- a/core/policy/post/intent/writePost.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ID } from "@core/constant/common/id"; -import { POST_TYPE } from "@core/constant/post/postType"; -import { POST_TEXT } from "@core/constant/post/text"; -import { Post } from "@core/entity/post"; -import { IntentPolicy } from "@policy-maker-2/core"; -import { z } from "zod"; - -const input = z.object({ - type: POST_TYPE, - title: POST_TEXT.TITLE, - content: POST_TEXT.CONTENT, -}); - -export const IPWritePost = IntentPolicy((postId: ID["POST"]) => ({ - key: { name: "writePost", postId }, - model: { input, output: Post }, - next: () => [], -})); diff --git a/core/policy/post/intent/writePostComment.ts b/core/policy/post/intent/writePostComment.ts deleted file mode 100644 index 873435c..0000000 --- a/core/policy/post/intent/writePostComment.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ID } from "@core/constant/common/id"; -import { POST_TEXT } from "@core/constant/post/text"; -import { PostComment } from "@core/entity/comment/post"; -import { IntentPolicy } from "@policy-maker-2/core"; -import { z } from "zod"; - -const input = z.object({ - content: POST_TEXT.COMMENT_CONTENT, -}); - -export const IPWritePostComment = IntentPolicy((postId: ID["POST"]) => ({ - key: { name: "writePost", postId }, - model: { input, output: PostComment }, - next: () => [], -})); diff --git a/core/policy/post/view/posts.ts b/core/policy/post/view/posts.ts deleted file mode 100644 index 75d0175..0000000 --- a/core/policy/post/view/posts.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Paginated } from "@core/api/util"; -import { Post } from "@core/entity/post"; -import { ViewPolicy } from "@policy-maker-2/core"; - -export const VPPosts = ViewPolicy((page: number) => ({ - key: ["posts"], - model: Paginated(Post), -})); diff --git a/core/policy/post/view/trendingPosts.ts b/core/policy/post/view/trendingPosts.ts deleted file mode 100644 index 4eacb97..0000000 --- a/core/policy/post/view/trendingPosts.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PostTrending } from "@core/entity/post/trending"; -import { ViewPolicy } from "@policy-maker-2/core"; - -export const VPTrendingPosts = ViewPolicy((page: number) => ({ - key: { name: "posts", page }, - model: PostTrending.array(), -})); diff --git a/core/policy/user/intent/login.ts b/core/policy/user/intent/login.ts new file mode 100644 index 0000000..c025f5e --- /dev/null +++ b/core/policy/user/intent/login.ts @@ -0,0 +1,15 @@ +import { USER_EMAIL } from "@core/constant/user/userEmail"; +import { USER_PASSWORD } from "@core/constant/user/userPassword"; +import { User } from "@core/entity/user"; +import { IntentPolicy } from "@policy-maker/core"; +import { z } from "zod"; + +export const LoginInput = z.object({ + email: USER_EMAIL, + password: USER_PASSWORD, +}); + +export const IPLogin = IntentPolicy(() => ({ + key: ["login"], + model: { input: LoginInput, output: z.object({ token: z.string() }) }, +})); diff --git a/core/policy/user/intent/signIn.ts b/core/policy/user/intent/signIn.ts deleted file mode 100644 index ee4ee49..0000000 --- a/core/policy/user/intent/signIn.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IntentPolicy } from "library/policy-maker-2/core"; -import { z } from "zod"; -import { VPMe } from "../view/me"; - -export const IPSignin = IntentPolicy(() => ({ - key: ["signIn"], - model: { - input: z.object({ email: z.string(), password: z.string() }), - output: z.unknown(), - }, - next: () => [VPMe().invalidate()], -})); diff --git a/core/policy/user/view/me.ts b/core/policy/user/view/me.ts index 2899e54..9623f67 100644 --- a/core/policy/user/view/me.ts +++ b/core/policy/user/view/me.ts @@ -1,8 +1,7 @@ import { User } from "@core/entity/user"; -import { ViewPolicy } from "library/policy-maker-2/core"; -import { z } from "zod"; +import { ViewPolicy } from "@policy-maker/core"; export const VPMe = ViewPolicy(() => ({ key: ["me"], - model: User.extend({ token: z.string() }).nullable(), + model: User, })); diff --git a/core/policy/user/view/user.ts b/core/policy/user/view/user.ts index e69de29..e8cf755 100644 --- a/core/policy/user/view/user.ts +++ b/core/policy/user/view/user.ts @@ -0,0 +1,8 @@ +import { ID } from "@core/constant/common/id"; +import { User } from "@core/entity/user"; +import { ViewPolicy } from "@policy-maker/core"; + +export const VPUser = ViewPolicy((id: ID["USER"]) => ({ + key: ["user", { user: id }], + model: User, +})); diff --git a/core/policy/view.ts b/core/policy/view.ts index 54a4b17..f0892c4 100644 --- a/core/policy/view.ts +++ b/core/policy/view.ts @@ -1,9 +1,11 @@ -import { VPPosts } from "./post/view/posts"; -import { VPTrendingPosts } from "./post/view/trendingPosts"; +import { VPMe } from "./user/view/me"; +import { VPUser } from "./user/view/user"; -export const viewPolicy = { - post: { - posts: VPPosts, - trendingPosts: VPTrendingPosts, +const viewPolicy = { + user: { + me: VPMe, + user: VPUser, }, }; + +export default viewPolicy; diff --git a/core/repository/user/index.ts b/core/repository/user/index.ts new file mode 100644 index 0000000..275b84e --- /dev/null +++ b/core/repository/user/index.ts @@ -0,0 +1,18 @@ +import { api, authApi } from "@core/api"; +import { ID } from "@core/constant/common/id"; +import { PostLoginBody } from "@core/dto/user/request"; +import { User } from "@core/entity/user"; +import { z } from "zod"; + +const getMe = () => authApi.get("/users/me").then(User.parse); + +const getUser = (id: ID["USER"]) => api.get(`/users/${id}`).then(User.parse); + +const postLogin = (body: PostLoginBody) => + api.post("/users/login", body).then(z.object({ token: z.string() }).parse); + +export const UserRepository = { + getMe, + getUser, + postLogin, +}; diff --git a/library/policy-maker-2/core/example/sample.ts b/library/policy-maker-2/core/example/sample.ts deleted file mode 100644 index 0ca775f..0000000 --- a/library/policy-maker-2/core/example/sample.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from "zod"; -import { IntentPolicy } from "../src/intent"; -import { ViewPolicy } from "../src/view"; - -const VPMe = ViewPolicy(() => ({ - key: { name: "me" }, - model: z.object({ - id: z.number(), - name: z.string(), - }), -})); - -const IPEditMe = IntentPolicy(() => ({ - key: { name: "me" }, - model: { - input: z.object({ - id: z.number(), - name: z.string(), - }), - output: z.object({ - id: z.number(), - name: z.string(), - }), - }, - next: ({ output }) => [ - VPMe().set(() => output), - VPMe().invalidate(), - VPMe().reset(), - ], -})); diff --git a/library/policy-maker-2/core/index.ts b/library/policy-maker-2/core/index.ts deleted file mode 100644 index e6170ae..0000000 --- a/library/policy-maker-2/core/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./src/store"; -export * from "./src/intent"; -export * from "./src/view"; diff --git a/library/policy-maker-2/core/package.json b/library/policy-maker-2/core/package.json deleted file mode 100644 index 60d4556..0000000 --- a/library/policy-maker-2/core/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@policy-maker-2/core", - "version:": "0.0.1", - "type": "module", - "main": "index.ts", - "peerDependencies": { - "zod": "^3.23.0" - }, - "dependencies": { - "nanoid": "^5.0.7" - } -} diff --git a/library/policy-maker-2/core/tsconfig.json b/library/policy-maker-2/core/tsconfig.json deleted file mode 100644 index 834098d..0000000 --- a/library/policy-maker-2/core/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["index.ts", "**/*.ts"] -} diff --git a/library/policy-maker/README.md b/library/policy-maker/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/library/policy-maker/core/README.md b/library/policy-maker/core/README.md deleted file mode 100644 index 3f008d3..0000000 --- a/library/policy-maker/core/README.md +++ /dev/null @@ -1,33 +0,0 @@ -```ts -export const useView = (policy, repository) => { - const store = useStore(); - const data = useMemo(() => store.get(policy.key, repository), [policy.key]); - return { data: repository }; -}; -``` - -```tsx -import styles from "./TodoList.module.css"; - -export default function TodoList() { - const { data } = useView(view.todo.todos(), TodoRepository.getTodos()); - - return ( -
    - {data.map((todo) => ( - - ))} -
- ); -} -``` - -```tsx -export default function TodoDetail() { - const todoDomain = useTodoDomain(); - const { data } = useView({ - policy: viewPolicy.todo.todo(todoDomain), - from: () => TodoRepository.getTodo(todoDomain), - }); -} -``` diff --git a/library/policy-maker/core/function/common.ts b/library/policy-maker/core/function/common.ts deleted file mode 100644 index 5837166..0000000 --- a/library/policy-maker/core/function/common.ts +++ /dev/null @@ -1 +0,0 @@ -export type PolicyKey = readonly unknown[]; diff --git a/library/policy-maker/core/function/intent.ts b/library/policy-maker/core/function/intent.ts deleted file mode 100644 index 917a4d5..0000000 --- a/library/policy-maker/core/function/intent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TypeOf, ZodObject, ZodRawShape, ZodType } from "zod"; -import { PolicyKey } from "./common"; -import { ViewConnectionInterface } from "./view"; - -type ZodAnyObject = ZodObject; - -export type IntentModel = { input: ZodAnyObject; output: ZodType }; - -type Result = { - input: TypeOf; - output: TypeOf; -}; - -export type IntentPolicy = ( - ...deps: Deps -) => { - key: PolicyKey; - model: Model; - connect: ( - result: Result, - ) => (ViewConnectionInterface | null | undefined | false)[]; -}; - -export const IP = ( - input: IntentPolicy, -) => input; - -export type ImplementedIntentPolicy = ReturnType< - IntentPolicy ->; diff --git a/library/policy-maker/core/function/view.ts b/library/policy-maker/core/function/view.ts deleted file mode 100644 index a5e82b6..0000000 --- a/library/policy-maker/core/function/view.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { TypeOf, ZodType } from "zod"; -import { PolicyKey } from "./common"; - -export type ViewModel = ZodType; - -type ViewMapFn = ( - prev: TypeOf, -) => TypeOf; -type ViewInvalidateInterface = { - type: "invalidate"; - key: PolicyKey; -}; -type ViewMapInterface = { - type: "map"; - key: PolicyKey; - mapFn: (prev: unknown) => unknown; -}; -type ViewSetInterface = { - type: "set"; - key: PolicyKey; - data: unknown; -}; -type ViewResetInterface = { - type: "reset"; - key: PolicyKey; -}; - -export type ViewConnectionInterface = - | ViewInvalidateInterface - | ViewMapInterface - | ViewResetInterface - | ViewSetInterface; - -type ViewPolicyParam = ( - ...args: Deps -) => { - key: PolicyKey; - model: Model; -}; - -export type ViewPolicy = ( - ...deps: Deps -) => { - key: PolicyKey; - model: Model; - invalidate: () => ViewInvalidateInterface; - map: (mapFn: ViewMapFn) => ViewMapInterface; - set: (data: TypeOf) => ViewSetInterface; - reset: () => ViewResetInterface; -}; - -export const VP = - ( - policy: ViewPolicyParam, - ): ViewPolicy => - (...deps: Deps) => { - const injected = policy(...deps); - const invalidate = () => - ({ type: "invalidate", key: injected.key }) as const; - const map = (mapFn: ViewMapFn) => - ({ - type: "map", - key: injected.key, - mapFn, - }) as const; - const set = (data: TypeOf) => - ({ type: "set", key: injected.key, data }) as const; - const reset = () => ({ type: "reset", key: injected.key }) as const; - return { ...injected, invalidate, map, set, reset }; - }; - -export type ImplementedViewPolicy = ReturnType< - ViewPolicy ->; diff --git a/library/policy-maker/core/index.ts b/library/policy-maker/core/index.ts index 937d7b9..e6170ae 100644 --- a/library/policy-maker/core/index.ts +++ b/library/policy-maker/core/index.ts @@ -1,32 +1,3 @@ -import { PolicyKey } from "./function/common"; -import { - IP, - IntentPolicy, - IntentModel, - ImplementedIntentPolicy, -} from "./function/intent"; -import { - VP, - ViewPolicy, - ViewModel, - ImplementedViewPolicy, -} from "./function/view"; -import { ViewConnectionInterface } from "./function/view"; -/** - * functions - */ -export { VP, IP }; - -/** - * types - */ -export type { - ViewPolicy, - ImplementedViewPolicy, - IntentPolicy, - ImplementedIntentPolicy, - PolicyKey, - ViewModel, - IntentModel, - ViewConnectionInterface, -}; +export * from "./src/store"; +export * from "./src/intent"; +export * from "./src/view"; diff --git a/library/policy-maker/core/package.json b/library/policy-maker/core/package.json index a2b2941..0d0d1c3 100644 --- a/library/policy-maker/core/package.json +++ b/library/policy-maker/core/package.json @@ -1,9 +1,12 @@ { "name": "@policy-maker/core", - "version": "0.0.1", - "private": true, - "main": "./index.ts", + "version:": "0.0.1", + "type": "module", + "main": "index.ts", "peerDependencies": { - "zod": "^3.23.5" + "zod": "^3.23.0" + }, + "dependencies": { + "nanoid": "^5.0.7" } } diff --git a/library/policy-maker-2/core/src/intent.ts b/library/policy-maker/core/src/intent.ts similarity index 58% rename from library/policy-maker-2/core/src/intent.ts rename to library/policy-maker/core/src/intent.ts index 4d5e71c..e02276d 100644 --- a/library/policy-maker-2/core/src/intent.ts +++ b/library/policy-maker/core/src/intent.ts @@ -19,26 +19,43 @@ type Reset = { predicate: Predicate; }; -export type IntentNext = Set | Invalidate | Reset | undefined | null | false; +type Execute = { + type: "EXECUTE"; + callback: () => void; +}; + +export type IntentNext = + | Set + | Invalidate + | Reset + | Execute + | undefined /* = Falsy */ + | null /* = Falsy */ + | false; /* = Falsy */ export const IntentPolicy = ( init: (...args: Args) => { key: Key[]; model: { input: Input; output: Output }; - next: (result: { + next?: (result: { input: TypeOf; output: TypeOf; }) => IntentNext[]; + catch?: (result: { + input: TypeOf; + error: unknown; + }) => IntentNext[]; }, ) => (...args: Args): IntentPolicy, TypeOf> => { - const { key, model, next } = init(...args); - return { key: hashKeys(key), model, next }; + const { key, model, next, catch: _catch } = init(...args); + return { key: hashKeys(key), model, next, catch: _catch }; }; export type IntentPolicy = { key: string; model: { input: ZodType; output: ZodType }; - next: (result: { input: Input; output: Output }) => IntentNext[]; + next?: (result: { input: Input; output: Output }) => IntentNext[]; + catch?: (result: { input: Input; error: unknown }) => IntentNext[]; }; diff --git a/library/policy-maker-2/core/src/store.ts b/library/policy-maker/core/src/store.ts similarity index 90% rename from library/policy-maker-2/core/src/store.ts rename to library/policy-maker/core/src/store.ts index 8b99a91..6c33ea1 100644 --- a/library/policy-maker-2/core/src/store.ts +++ b/library/policy-maker/core/src/store.ts @@ -160,37 +160,6 @@ const reject = (key: string, error: unknown) => { }); }; -// const freshCached = (key: string) => { -// const cached = _get(key); -// if (!cached || !isCached(cached)) return; -// window.clearTimeout(cached.gcTimer); -// if (cached.status === "ERROR_CACHED") -// return _set(key, { ...cached, status: "REJECTED" }); -// return _set(key, { -// ...cached, -// status: "FRESH", -// gcTimer: undefined, -// }); -// }; - -// const refreshExisting = (key: string) => { -// const prev = _get(key); -// if (!prev || !isWithData(prev) || isFetching(prev)) return prev; -// const promise = Promise.resolve(prev.from()).then( -// (resolved) => () => resolved, -// ); -// promise.then((resolved) => freshExisting(key, resolved)); -// return _set(key, { -// ...prev, -// status: "REFRESHING", -// pending: promise, -// }); -// }; - -// /* STALE */ - -// /* CACHE */ - /* * Public API */ @@ -321,8 +290,23 @@ const subscribe = ( return { unsubscribe: () => unsubscribe(key, subscriptionKey) }; }; +const subscribeSync = ( + key: string, + subscriptionKey: string, + listener: () => void, + from: () => T, + config: StoreConfig, +) => { + const prev = _get(key); + if (!prev || prev.status === "REJECTED") initSync(key, from, config); + prev?.subscriptions.set(subscriptionKey, listener); + listener(); + return { unsubscribe: () => unsubscribe(key, subscriptionKey) }; +}; + const parseIntent = (next: Next) => { if (!next) return; + if (next.type === "EXECUTE") return next.callback(); _store.forEach((_, key) => { if (next.predicate(key)) { switch (next.type) { @@ -348,6 +332,7 @@ export const store = { setAsync, invalidate, subscribe, + subscribeSync, unsubscribe, parseIntent, }; diff --git a/library/policy-maker-2/core/src/util.ts b/library/policy-maker/core/src/util.ts similarity index 100% rename from library/policy-maker-2/core/src/util.ts rename to library/policy-maker/core/src/util.ts diff --git a/library/policy-maker-2/core/src/view.ts b/library/policy-maker/core/src/view.ts similarity index 100% rename from library/policy-maker-2/core/src/view.ts rename to library/policy-maker/core/src/view.ts diff --git a/library/policy-maker-2/react/index.ts b/library/policy-maker/next/index.ts similarity index 76% rename from library/policy-maker-2/react/index.ts rename to library/policy-maker/next/index.ts index c26d5ab..6e60d88 100644 --- a/library/policy-maker-2/react/index.ts +++ b/library/policy-maker/next/index.ts @@ -7,6 +7,7 @@ export * from "./src/useStore"; * View */ export * from "./src/useView"; +export * from "./src/useViewMaybe"; /* * Input @@ -19,4 +20,8 @@ export * from "./src/useInput"; export * from "./src/useIntent"; export * from "./src/useIntentInput"; export * from "./src/useIntentSubmit"; -export * from "./src/set"; + +/* + * SSR + */ +export * from "./src/Provider"; diff --git a/library/policy-maker-2/react/package.json b/library/policy-maker/next/package.json similarity index 68% rename from library/policy-maker-2/react/package.json rename to library/policy-maker/next/package.json index 91e7ba3..c6d5d36 100644 --- a/library/policy-maker-2/react/package.json +++ b/library/policy-maker/next/package.json @@ -1,10 +1,10 @@ { - "name": "@policy-maker-2/react", + "name": "@policy-maker/next", "version:": "0.0.1", "type": "module", "main": "index.ts", "peerDependencies": { - "@policy-maker-2/core": "workspace:*", + "@policy-maker/core": "workspace:*", "react": "^18.0.0", "react-dom": "^18.0.0", "zod": "^3.23.0" diff --git a/library/policy-maker/next/src/Provider.tsx b/library/policy-maker/next/src/Provider.tsx new file mode 100644 index 0000000..ec3d60f --- /dev/null +++ b/library/policy-maker/next/src/Provider.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { PropsWithChildren, useRef } from "react"; +import { createStore } from "./createStore"; +import { StoreContext } from "./storeContext"; + +export function Provider({ children }: PropsWithChildren) { + const store = useRef(createStore()); + return ( + + {children} + + ); +} diff --git a/library/policy-maker/next/src/createStore.ts b/library/policy-maker/next/src/createStore.ts new file mode 100644 index 0000000..627a8df --- /dev/null +++ b/library/policy-maker/next/src/createStore.ts @@ -0,0 +1,343 @@ +import { IntentNext } from "@policy-maker/core"; + +/* + * Types + */ + +/* Basics */ +export type LifecycleConfig = { + staleTime: number | null; + gcTime: number | null; +}; +export type LifecycleContext = { + lastFetchedAt: number; + gcTimer: number; +}; +export type StoreConfig = LifecycleConfig; +export type StoreContext = Partial; + +export type Subscriber = () => void; +export type Subscriptions = Map; +export type Subscribable = { subscriptions: Subscriptions }; + +export type Fetchable = { + from: () => T | Promise; +}; + +/* States */ +/** Dataless State **/ +type Rejected = Subscribable & + StoreContext & + Fetchable & { + status: "REJECTED"; + error: unknown; + }; + +type Pending = Subscribable & + StoreContext & + Fetchable & { + status: "PENDING"; + pending: Promise; + error: unknown; + }; + +/** Dataful State **/ +type Resolved = Subscribable & + StoreContext & + Fetchable & { + status: "RESOLVED"; + value: T; + error: unknown; + }; + +type Updating = Subscribable & + StoreContext & + Fetchable & { + status: "UPDATING"; + value: T; + pending: Promise; + error: unknown; + isInvalid: boolean; + }; + +/** Stored State **/ +export type StoreStatus = "REJECTED" | "PENDING" | "RESOLVED" | "UPDATING"; +export type Stored = Rejected | Pending | Resolved | Updating; +export type StoredSync = Resolved | Updating; +/* + * Predicates + */ +const isCached = (snapshot: Stored) => snapshot.subscriptions.size === 0; + +const isFetching = ( + snapshot: Stored, +): snapshot is Pending | Updating => + snapshot.status === "PENDING" || snapshot.status === "UPDATING"; + +const isStale = (snapshot: Stored) => { + if (isFetching(snapshot)) return false; + if (!snapshot.staleTime || !snapshot.lastFetchedAt) return true; + return snapshot.lastFetchedAt + snapshot.staleTime < Date.now(); +}; + +export const createStore = () => { + /* + * Store Manupulation + */ + const _store = new Map>(); + const _get = (key: string) => _store.get(key) as Stored | undefined; + const _set = = Stored>(key: string, value: S) => { + _store.set(key, value); + value.subscriptions.forEach((listener) => listener()); + return value; + }; + const _delete = (key: string) => _store.delete(key); + + /* + * State Transformer + */ + + /* PENDING */ + + // const pendExisting = (key: string, existing: StoredWithData) => { + // if (isBusy(snapshot)) return snapshot; + // const value = Promise.resolve(snapshot.from()); + // value.then((resolved) => freshSnapshot(key, resolved)); + // console.log(value); + // return _set(key, { + // ...snapshot, + // status: "PENDING", + // error: undefined, + // value, + // }); + // }; + + const pend = ( + key: string, + from: () => T | Promise, + subscribers: Subscriber[], + config: StoreConfig, + ) => { + const prev = _get(key); + if (prev) return prev; + const subscriptions = new Map(); + subscribers.forEach((listener) => + subscriptions.set(listener.name, listener), + ); + + const promise = Promise.resolve(from()); + promise + .then((resolved) => resolve(key, resolved)) + .catch((error) => reject(key, error)); + + return _set(key, { + status: "PENDING", + error: undefined, + pending: promise, + from, + subscriptions, + ...config, + }); + }; + + const resolve = (key: string, value: T): Resolved | undefined => { + const prev = _get(key); + if (!prev) return; + return _set>(key, { + ...prev, + status: "RESOLVED", + value: value, + error: undefined, + lastFetchedAt: Date.now(), + }); + }; + + const reject = (key: string, error: unknown) => { + const prev = _get(key); + if (!prev) return; + return _set>(key, { + ...prev, + status: "REJECTED", + error, + lastFetchedAt: Date.now(), + }); + }; + + /* + * Public API + */ + + /* getter / setter */ + const get = (key: string): Stored | undefined => { + const snapshot = _get(key); + if (!snapshot) return snapshot; + if (isStale(snapshot)) return refresh(key); + return snapshot; + }; + + const initSync = (key: string, from: () => T, config: StoreConfig) => { + return _set>(key, { + status: "RESOLVED", + value: from(), + error: undefined, + from, + subscriptions: new Map(), + ...config, + lastFetchedAt: Date.now(), + }); + }; + + const initAsync = ( + key: string, + from: () => T | Promise, + config: StoreConfig, + ) => pend(key, from, [], config); + + const setSync = ( + key: string, + updater: (prev?: T) => T, + objectKeys?: keyof T, + ) => { + const prev = _get(key); + if (!prev || isFetching(prev)) return; + const previousValue = prev.status === "REJECTED" ? undefined : prev.value; + if (!objectKeys) return resolve(key, updater(previousValue)); + const empty = objectKeys ? { [objectKeys]: undefined } : {}; + return resolve(key, { ...empty, ...updater(previousValue) }); + }; + + const setAsync = (key: string, updater: (prev?: T) => T | Promise) => { + const prev = _get(key); + if (!prev || isFetching(prev)) return; + const previousValue = prev.status === "REJECTED" ? undefined : prev.value; + const promise = Promise.resolve(updater(previousValue)); + + promise.then((resolved) => resolve(key, resolved)); + + if (prev.status === "REJECTED") + return _set(key, { + ...prev, + status: "PENDING", + pending: promise, + }); + + return _set(key, { + ...prev, + status: "UPDATING", + pending: promise, + isInvalid: false, + }); + }; + + const refresh = (key: string) => { + const prev = _get(key); + if (!prev || isFetching(prev)) return prev; + const promise = Promise.resolve(prev.from()); + promise.then((resolved) => resolve(key, resolved)); + if (prev.status === "REJECTED") + return _set(key, { + ...prev, + status: "PENDING", + pending: promise, + }); + return _set(key, { + ...prev, + status: "UPDATING", + pending: promise, + isInvalid: true, + }); + }; + + const staleCached = (key: string) => { + const cached = _get(key); + if (!cached || !isCached(cached)) return; + return _set(key, { ...cached, staleTime: 0 }); + }; + + /* Intent Next */ + const invalidate = (key: string) => { + const prev = _get(key); + if (!prev || isFetching(prev)) return; + if (isCached(prev)) return staleCached(key); + return refresh(key); + }; + + /* subscription */ + const unsubscribe = (key: string, subscriptionKey: string) => { + const prev = _get(key); + if (!prev) return; + + prev.subscriptions.delete(subscriptionKey); + if (prev.subscriptions.size > 0) return; + + if (!prev.gcTime) return _delete(key); + + if (!Number.isFinite(prev.gcTime)) return; + + const timer = window.setTimeout(() => _delete(key), prev.gcTime); + _set(key, { ...prev, gcTimer: timer }); + }; + + const subscribe = ( + key: string, + subscriptionKey: string, + listener: () => void, + from: () => T | Promise, + config: StoreConfig, + ) => { + const prev = _get(key); + + if (!prev || prev.status === "REJECTED") + pend(key, from, [listener], config); + else prev.subscriptions.set(subscriptionKey, listener); + listener(); // TODO: do we need this? + return { unsubscribe: () => unsubscribe(key, subscriptionKey) }; + }; + + const subscribeSync = ( + key: string, + subscriptionKey: string, + listener: () => void, + from: () => T, + config: StoreConfig, + ) => { + const prev = _get(key); + if (!prev || prev.status === "REJECTED") initSync(key, from, config); + prev?.subscriptions.set(subscriptionKey, listener); + listener(); + return { unsubscribe: () => unsubscribe(key, subscriptionKey) }; + }; + + const parseIntent = (next: Next) => { + if (!next) return; + if (next.type === "EXECUTE") return next.callback(); + _store.forEach((_, key) => { + if (next.predicate(key)) { + switch (next.type) { + case "SET": + setSync(key, next.fn); + return; + case "INVALIDATE": + invalidate(key); + return; + case "RESET": + reject(key, new Error("RESET")); + return; + } + } + }); + }; + + return { + initSync, + initAsync, + get, + setSync, + setAsync, + invalidate, + subscribe, + subscribeSync, + unsubscribe, + parseIntent, + }; +}; diff --git a/library/policy-maker/next/src/storeContext.ts b/library/policy-maker/next/src/storeContext.ts new file mode 100644 index 0000000..80890ce --- /dev/null +++ b/library/policy-maker/next/src/storeContext.ts @@ -0,0 +1,11 @@ +import { store } from "@policy-maker/core"; +import { createContext, useContext } from "react"; + +export const StoreContext = createContext(null); + +export const useStoreContext = () => { + const context = useContext(StoreContext); + if (!context) + throw new Error("useStoreContext must be used within a Provider"); + return context; +}; diff --git a/library/policy-maker/next/src/useInput.ts b/library/policy-maker/next/src/useInput.ts new file mode 100644 index 0000000..9feb24d --- /dev/null +++ b/library/policy-maker/next/src/useInput.ts @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ZodObject, ZodType } from "zod"; +import { useSyncStore } from "./useStore"; +import { isDeepEqual } from "./util/isDeepEqual"; +import { pickValidValues } from "./util/pickValidValues"; +import { isEmpty } from "./util/isEmpty"; + +/* + * Config + */ +export type InputConfig = { + compareDiff: boolean; + useInitialValue: boolean; + allowEmpty: boolean; +}; +const defaultInputConfig: InputConfig = { + compareDiff: false, + useInitialValue: false, + allowEmpty: false, +}; + +/* + * Validation + */ +export type Inputable = Record; +export type ValidatedValue = + | { isValid: true; value: T; error: null } + | { isValid: false; value: T; error: unknown }; +export type Validator> = ZodObject<{ + [key in keyof T]: ZodType; +}>; +type ValidatedInput = { + [key in keyof T]: ValidatedValue; +}; + +/* + * Hook Types + */ + +type Setter = ( + setter: Partial | ((prev?: Partial) => Partial), +) => void; + +/* + * Util + */ +const getDiff = ( + original: T, + target: Partial, +): Partial => { + return Object.keys(target).reduce((acc, key) => { + if (isDeepEqual(original[key], target[key])) + return { ...acc, [key]: undefined }; + return { ...acc, [key]: target[key] }; + }, {} as Partial); +}; + +/* + * Hook + */ +export const useInput = < + Input extends Inputable, + Slice extends Partial, +>({ + key, + validator, + initialValue, + config: inputConfig, +}: { + key: string; + validator: Validator; + initialValue: (prev?: Partial) => Required; + config?: Partial; +}) => { + type ExactSlice = { + [key in keyof Required]: key extends keyof Input + ? Required[key] + : never; + }; + + /* + * Init + */ + const config = useMemo( + () => ({ ...defaultInputConfig, ...inputConfig }), + [key], + ); + const [store, setStoreValue] = useSyncStore>( + "input_" + key, + () => ({}), + ); + const [cachedInitialValue, setCachedInitialValue] = useState(initialValue()); + + /* + * Calculate Value + */ + const currentValue = useMemo( + () => ({ ...initialValue(store.value), ...pickValidValues(store.value) }), + [key, store.value], + ) as ExactSlice; + + const values = useMemo(() => { + return Object.keys(currentValue).reduce((acc, key: keyof Input) => { + const value = currentValue[key]; + const result = validator.shape[key].safeParse(value); + if (result.success) + return { ...acc, [key]: { value, isValid: true, error: undefined } }; + else + return { + ...acc, + [key]: { value, isValid: false, error: result.error }, + }; + }, {} as ValidatedInput); + }, [key, currentValue]); + + const isValid = useMemo(() => { + if (!config.allowEmpty && isEmpty(store.value)) return false; + return validator.safeParse(store.value).success; + }, [store.value]); + + /* + * Set & Reset + */ + const set: Setter = useCallback( + (setter) => { + const setterFn = typeof setter === "function" ? setter : () => setter; + setStoreValue((prev) => { + const partialValueToSet = setterFn(prev as Partial); + const comparedPartialValueToSet = config.compareDiff + ? getDiff(cachedInitialValue, partialValueToSet) + : partialValueToSet; + return { ...prev, ...comparedPartialValueToSet }; + }); + }, + [key, setStoreValue, cachedInitialValue], + ); + + const reset = useCallback( + () => setStoreValue(() => (config.useInitialValue ? initialValue() : {})), + [key, setStoreValue], + ); + + /* + * Calculate & Apply Initial Value + */ + useEffect(() => { + if (config.useInitialValue) { + set(initialValue); + } + }, [key, cachedInitialValue]); + + useEffect(() => { + // TODO: optimize logic + if (!isDeepEqual(initialValue(), cachedInitialValue)) + setCachedInitialValue(initialValue()); + }, [key, initialValue]); + + return { values, inputValues: store.value, isValid, set, reset }; +}; + +export const useInputValue = ({ + key, + initialValue, +}: { + key: string; + initialValue: T; +}): { value: T } => { + const [{ value }] = useSyncStore("input_" + key, () => initialValue); + + return { value }; +}; diff --git a/library/policy-maker-2/react/src/useIntent.ts b/library/policy-maker/next/src/useIntent.ts similarity index 80% rename from library/policy-maker-2/react/src/useIntent.ts rename to library/policy-maker/next/src/useIntent.ts index a997144..1be5c80 100644 --- a/library/policy-maker-2/react/src/useIntent.ts +++ b/library/policy-maker/next/src/useIntent.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { useSyncStore } from "./useStore"; -import { IntentPolicy, store } from "@policy-maker-2/core"; +import { IntentPolicy, store } from "@policy-maker/core"; type IntentMeta = { isWorking: boolean; @@ -26,11 +26,14 @@ export const useIntent = ({ set((prev) => ({ isWorking: true, error: prev?.error ?? null })); const raw = await to(policy.model.input.parse(input)); const output = policy.model.output.parse(raw); - policy.next({ input, output }).forEach(store.parseIntent); + policy.next && + policy.next({ input, output }).forEach(store.parseIntent); set(() => ({ isWorking: false, error: null })); return output; } catch (e) { set(() => ({ isWorking: false, error: e })); + policy.catch && + policy.catch({ input, error: e }).forEach(store.parseIntent); return Promise.reject(e); } }, diff --git a/library/policy-maker-2/react/src/useIntentInput.ts b/library/policy-maker/next/src/useIntentInput.ts similarity index 92% rename from library/policy-maker-2/react/src/useIntentInput.ts rename to library/policy-maker/next/src/useIntentInput.ts index 8f6f6bf..8b60abd 100644 --- a/library/policy-maker-2/react/src/useIntentInput.ts +++ b/library/policy-maker/next/src/useIntentInput.ts @@ -1,4 +1,4 @@ -import { IntentPolicy } from "@policy-maker-2/core"; +import { IntentPolicy } from "@policy-maker/core"; import { InputConfig, Validator, useInput } from "./useInput"; export const useIntentInput = < diff --git a/library/policy-maker-2/react/src/useIntentSubmit.ts b/library/policy-maker/next/src/useIntentSubmit.ts similarity index 97% rename from library/policy-maker-2/react/src/useIntentSubmit.ts rename to library/policy-maker/next/src/useIntentSubmit.ts index 0806199..cdbad1e 100644 --- a/library/policy-maker-2/react/src/useIntentSubmit.ts +++ b/library/policy-maker/next/src/useIntentSubmit.ts @@ -1,4 +1,4 @@ -import { IntentPolicy } from "@policy-maker-2/core"; +import { IntentPolicy } from "@policy-maker/core"; import { useIntent } from "./useIntent"; import { useSyncStore } from "./useStore"; import { useCallback, useMemo } from "react"; diff --git a/library/policy-maker/next/src/useStore.ts b/library/policy-maker/next/src/useStore.ts new file mode 100644 index 0000000..cc913ce --- /dev/null +++ b/library/policy-maker/next/src/useStore.ts @@ -0,0 +1,92 @@ +"use client"; + +import { StoreConfig } from "@policy-maker/core"; +import { useMemo, useEffect, useCallback, useState } from "react"; +import { nanoid } from "nanoid"; +import { useStoreContext } from "./storeContext"; + +const defaultStoreConfig: StoreConfig = { + staleTime: Infinity, + gcTime: null, +}; + +export const useStore = ( + key: string, + from: (prev?: T) => T | Promise, + inputConfig?: Partial, +) => { + const store = useStoreContext(); + const config = useMemo( + () => ({ ...defaultStoreConfig, ...inputConfig }), + [key], + ); + const subscriptionKey = useMemo(() => key + nanoid(), [key]); + const [count, rerender] = useState(0); + + const get = useMemo( + () => store.get(key) ?? store.initAsync(key, from, config), + [key, count], + ); + + const set = useCallback( + (setter: (prev?: T) => T | Promise) => store.setAsync(key, setter), + [key], + ); + + useEffect(() => { + const { unsubscribe } = store.subscribe( + key, + subscriptionKey, + () => rerender((prev) => prev + 1), + from, + config, + ); + return () => { + unsubscribe(); + }; + }, [key]); + + return [get, set] as const; +}; + +const defaultSyncStoreConfig: StoreConfig = { + staleTime: Infinity, + gcTime: null, +}; + +export const useSyncStore = (key: string, from: (prev?: T) => T) => { + const store = useStoreContext(); + const subscriptionKey = useMemo(() => key + nanoid(), [key]); + const [count, rerender] = useState(0); + + const get = useMemo(() => { + const stored = store.get(key); + if (!stored) return store.initSync(key, from, defaultSyncStoreConfig); + if (stored.status === "REJECTED" || stored.status === "PENDING") + throw stored.error; + return stored; + }, [key, count]); + + const set = useCallback( + (setter: (prev?: T) => T, objectKeys?: keyof T) => + store.setSync(key, setter, objectKeys), + [key], + ); + + useEffect(() => { + const { unsubscribe } = store.subscribeSync( + key, + subscriptionKey, + () => { + rerender((prev) => prev + 1); + }, + from, + defaultSyncStoreConfig, + ); + return () => { + unsubscribe(); + }; + }, [key]); + + return [get, set] as const; +}; diff --git a/library/policy-maker/next/src/useView.ts b/library/policy-maker/next/src/useView.ts new file mode 100644 index 0000000..df44e8c --- /dev/null +++ b/library/policy-maker/next/src/useView.ts @@ -0,0 +1,39 @@ +"use client"; + +import { ViewPolicy } from "@policy-maker/core"; +import { useCallback } from "react"; +import { useStore } from "./useStore"; + +export const useView = ({ + policy, + from, + config, +}: { + policy: ViewPolicy; + from: (prev?: T) => T | Promise; + config?: { mapError: (e: unknown) => any }; +}) => { + const [get, set] = useStore(policy.key, from, policy.config); + + const update = useCallback(() => { + set(from); + }, [policy.key]); + + const refresh = useCallback(() => { + set(get.from); + }, [policy.key]); + + if (get.status === "PENDING") throw get.pending; + if (get.status === "REJECTED") + throw config?.mapError ? config.mapError(get.error) : get.error; + + return { + status: get.status, + view: get.value, + update, + refresh, + isUpdating: get.status === "UPDATING", + isRefreshing: get.status === "UPDATING" && get.isInvalid, + isProceeding: get.status === "UPDATING" && !get.isInvalid, + }; +}; diff --git a/library/policy-maker/next/src/useViewMaybe.ts b/library/policy-maker/next/src/useViewMaybe.ts new file mode 100644 index 0000000..38fc4c3 --- /dev/null +++ b/library/policy-maker/next/src/useViewMaybe.ts @@ -0,0 +1,38 @@ +"use client"; + +import { ViewPolicy } from "@policy-maker/core"; +import { useCallback } from "react"; +import { useStore } from "./useStore"; + +export const useViewMaybe = ({ + policy, + from, +}: { + policy: ViewPolicy; + from: (prev?: T) => T | Promise; + config?: { mapError: (e: unknown) => any }; +}) => { + const [get, set] = useStore(policy.key, from, policy.config); + + const update = useCallback(() => { + set(from); + }, [policy.key]); + + const refresh = useCallback(() => { + set(get.from); + }, [policy.key]); + + if (get.status === "PENDING") throw get.pending; + if (get.status === "REJECTED") + return { status: get.status, error: get.error, view: null }; + + return { + status: get.status, + view: get.value, + update, + refresh, + isUpdating: get.status === "UPDATING", + isRefreshing: get.status === "UPDATING" && get.isInvalid, + isProceeding: get.status === "UPDATING" && !get.isInvalid, + }; +}; diff --git a/library/policy-maker-2/react/src/util/isDeepEqual.ts b/library/policy-maker/next/src/util/isDeepEqual.ts similarity index 93% rename from library/policy-maker-2/react/src/util/isDeepEqual.ts rename to library/policy-maker/next/src/util/isDeepEqual.ts index df6e3a0..110b06a 100644 --- a/library/policy-maker-2/react/src/util/isDeepEqual.ts +++ b/library/policy-maker/next/src/util/isDeepEqual.ts @@ -1,6 +1,7 @@ export const isDeepEqual = (a: unknown, b: unknown): boolean => { if (typeof a === "object" && typeof b === "object") { if (a === null) return b === null; + if (b === null) return false; if (Array.isArray(a)) { if (!Array.isArray(b) || a.length !== b.length) return false; return a.every((v, i) => isDeepEqual(v, b[i])); diff --git a/library/policy-maker-2/react/src/util/isEmpty.ts b/library/policy-maker/next/src/util/isEmpty.ts similarity index 86% rename from library/policy-maker-2/react/src/util/isEmpty.ts rename to library/policy-maker/next/src/util/isEmpty.ts index 19026b1..4828357 100644 --- a/library/policy-maker-2/react/src/util/isEmpty.ts +++ b/library/policy-maker/next/src/util/isEmpty.ts @@ -3,6 +3,7 @@ export const isEmpty = (value: unknown): boolean => { case "number": return Number.isNaN(value); case "object": + if (value === null) return false; // TODO: is it okay? return ( value === null || Object.keys(value).filter( diff --git a/library/policy-maker-2/react/src/util/pickValidValues.ts b/library/policy-maker/next/src/util/pickValidValues.ts similarity index 100% rename from library/policy-maker-2/react/src/util/pickValidValues.ts rename to library/policy-maker/next/src/util/pickValidValues.ts diff --git a/library/policy-maker-2/react/tsconfig.json b/library/policy-maker/next/tsconfig.json similarity index 100% rename from library/policy-maker-2/react/tsconfig.json rename to library/policy-maker/next/tsconfig.json diff --git a/library/policy-maker/react/hooks/useInput.ts b/library/policy-maker/react/hooks/useInput.ts deleted file mode 100644 index 8588a8c..0000000 --- a/library/policy-maker/react/hooks/useInput.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { IntentModel } from "@policy-maker/core"; -import { useCallback, useMemo, useState } from "react"; -import { TypeOf, z } from "zod"; - -type Validable = - | { isValid: false; value: T; error: unknown } - | { isValid: true; value: T; error: null }; -type AnyInput = IntentModel["input"]; -type SetInput = (input: Partial>) => void; -type Values = { - [key in keyof Required>]: Validable< - NonNullable[key]> - >; -}; -type InputValues = Partial>; - -export type InputConfig = { - compareDiff?: boolean; -}; - -const defaultConfig = {} as const; - -export type InputState = { - set: SetInput; - values: Values; - reset: () => void; -} & ( - | { - inputValues: InputValues; - isValid: false; - } - | { - inputValues: TypeOf; - isValid: true; - } -); - -const getDiff = >( - original: T, - target: Partial, -): Partial => { - return Object.keys(target).reduce((acc, key) => { - if (original[key] === target[key]) return acc; - return { ...acc, [key]: target[key] }; - }, {} as Partial); -}; - -export const useInput = ( - input: Input, - initialValue: Required>, - inputConfig?: InputConfig, -): InputState => { - const config = useMemo( - () => ({ ...defaultConfig, ...inputConfig }), - [inputConfig?.compareDiff], - ); - const [inputValues, setInputValues] = useState>( - config.compareDiff ? {} : initialValue, - ); - const merged: Required> = useMemo( - () => ({ ...initialValue, ...inputValues }), - [inputValues], - ); - const values: Values = useMemo(() => { - return Object.keys(input.shape).reduce((acc, key) => { - const value = merged[key]; - const result = input.shape[key].safeParse(value); - if (result.success) - return { ...acc, [key]: { value, isValid: true, error: undefined } }; - return { ...acc, [key]: { value, isValid: false, error: result.error } }; - }, {} as Values); - }, [merged]); - const isValid = useMemo(() => { - if (z.object({}).strict().safeParse(inputValues).success) return false; - return input.safeParse(inputValues).success; - }, [inputValues]); - const set: SetInput = useCallback( - (partial) => - setInputValues((prev) => { - const merged = { ...prev, ...partial }; - const inputValue = config.compareDiff - ? getDiff(initialValue, merged) - : merged; - return inputValue; - }), - [config, initialValue], - ); - const reset = () => setInputValues({}); - - return { - values, - inputValues, - isValid, - set, - reset, - }; -}; diff --git a/library/policy-maker/react/hooks/useIntent.ts b/library/policy-maker/react/hooks/useIntent.ts deleted file mode 100644 index 39a601c..0000000 --- a/library/policy-maker/react/hooks/useIntent.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { IntentModel, ImplementedIntentPolicy } from "@policy-maker/core"; -import { TypeOf } from "zod"; -import { InputConfig, InputState, useInput } from "./useInput"; -import { useCallback, useMemo, useState } from "react"; -import { hashKey, useQueryClient } from "@tanstack/react-query"; - -type IntentConfig = Partial<{ - immediateReset: boolean; -}> & - InputConfig; - -type Param = { - policy: ImplementedIntentPolicy; - repository: ( - input: TypeOf, - ) => Promise>; - initialData: Required>; - config?: IntentConfig; -}; - -type Send = ( - input: TypeOf, -) => Promise>; - -type Input = InputState; - -type Submit = () => Promise>; - -type Return = Input & { - send: Send; - submit: Submit; - isWorking: boolean; -}; - -const defaultConfig: IntentConfig = {}; - -export const useIntent = ({ - policy, - repository, - initialData, - config: inputConfig, -}: Param): Return => { - const hashedKey = hashKey(policy.key); - const config = useMemo( - () => ({ ...defaultConfig, ...inputConfig }), - [inputConfig?.compareDiff, inputConfig?.immediateReset], - ); - const queryClient = useQueryClient(); - const [isWorking, setIsWorking] = useState(false); - const inputs = useInput( - policy.model.input, - initialData, - config, - ); - - const send: Send = useCallback( - (input) => { - if (isWorking) return Promise.reject(); - setIsWorking(true); - return repository(input) - .then((output) => { - policy.connect({ input, output }).forEach((connection) => { - if (!connection) return; - if (connection.type === "invalidate") - queryClient.invalidateQueries({ queryKey: connection.key }); - if (connection.type === "map") - queryClient.setQueriesData( - { queryKey: connection.key }, - (prev: unknown) => { - if (!prev) return prev; - return connection.mapFn(prev); - }, - ); - if (connection.type === "set") - queryClient.setQueriesData( - { queryKey: connection.key }, - connection.data, - ); - if (connection.type === "reset") - queryClient.resetQueries({ queryKey: connection.key }); - }); - setIsWorking(false); - return output; - }) - .catch((e) => { - setIsWorking(false); - return Promise.reject(e); - }); - }, - [hashedKey], - ); - - const submit: Submit = useCallback(() => { - if (!inputs.isValid || isWorking) return Promise.reject(); - setIsWorking(true); - const cachedInput = { ...inputs.inputValues }; - if (config.immediateReset) inputs.reset(); - return repository(cachedInput) - .then((output) => { - policy - .connect({ input: inputs.inputValues, output }) - .forEach((connection) => { - if (!connection) return; - if (connection.type === "invalidate") - queryClient.invalidateQueries({ queryKey: connection.key }); - if (connection.type === "map") - queryClient.setQueriesData( - { queryKey: connection.key }, - (prev: unknown) => { - if (!prev) return prev; - return connection.mapFn(prev); - }, - ); - if (connection.type === "set") - queryClient.setQueriesData( - { queryKey: connection.key }, - connection.data, - ); - if (connection.type === "reset") - queryClient.resetQueries({ queryKey: connection.key }); - }); - setIsWorking(false); - if (!config.immediateReset) inputs.reset(); - return output; - }) - .catch((e) => { - setIsWorking(false); - return Promise.reject(e); - }); - }, [hashedKey]); - return { ...inputs, send, submit, isWorking }; -}; diff --git a/library/policy-maker/react/hooks/useSimpleIntent.ts b/library/policy-maker/react/hooks/useSimpleIntent.ts deleted file mode 100644 index 32e69ae..0000000 --- a/library/policy-maker/react/hooks/useSimpleIntent.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { IntentModel, ImplementedIntentPolicy } from "@policy-maker/core"; -import { TypeOf } from "zod"; -import { useCallback, useState } from "react"; -import { hashKey, useQueryClient } from "@tanstack/react-query"; - -// type IntentSimpleConfig = Partial<{}>; - -type Param = { - policy: ImplementedIntentPolicy; - repository: ( - input: TypeOf, - ) => Promise>; - // config?: IntentSimpleConfig; -}; - -type Send = ( - input: TypeOf, -) => Promise>; - -type Return = { - send: Send; - isWorking: boolean; -}; - -// const defaultConfig: IntentSimpleConfig = {}; - -export const useSimpleIntent = ({ - policy, - repository, - // config, -}: Param): Return => { - // const mergedConfig = { ...defaultConfig, ...config }; - const hashedKey = hashKey(policy.key); - const queryClient = useQueryClient(); - const [isWorking, setIsWorking] = useState(false); - - const send: Send = useCallback( - (input) => { - if (isWorking) return Promise.reject(); - setIsWorking(true); - return repository(input) - .then((output) => { - policy.connect({ input, output }).forEach((connection) => { - console.log(connection); - if (!connection) return; - if (connection.type === "invalidate") - queryClient.invalidateQueries({ queryKey: connection.key }); - if (connection.type === "map") { - queryClient.setQueriesData( - { queryKey: connection.key }, - (prev: unknown) => { - if (!prev) return prev; - return connection.mapFn(prev); - }, - ); - } - if (connection.type === "set") - queryClient.setQueriesData( - { queryKey: connection.key }, - connection.data, - ); - if (connection.type === "reset") - queryClient.resetQueries({ queryKey: connection.key }); - }); - setIsWorking(false); - return output; - }) - .catch((e) => { - setIsWorking(false); - return Promise.reject(e); - }); - }, - [hashedKey], - ); - - return { send, isWorking }; -}; diff --git a/library/policy-maker/react/hooks/useStoredView.ts b/library/policy-maker/react/hooks/useStoredView.ts deleted file mode 100644 index d129d93..0000000 --- a/library/policy-maker/react/hooks/useStoredView.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { ImplementedViewPolicy, ViewModel } from "library/policy-maker/core"; -import { useMemo } from "react"; -import { TypeOf } from "zod"; - -type Param = { - policy: ImplementedViewPolicy; -}; - -export const useStoredView = ({ - policy, -}: Param): TypeOf | null => { - const { data } = useQuery({ - queryKey: policy.key, - staleTime: Infinity, - gcTime: 0, - }); - - const result: TypeOf | null = useMemo(() => { - try { - return policy.model.parse(data); - } catch (e) { - console.log(e); - return null; - } - }, [data]); - return result; -}; diff --git a/library/policy-maker/react/hooks/useView.ts b/library/policy-maker/react/hooks/useView.ts deleted file mode 100644 index 5577c77..0000000 --- a/library/policy-maker/react/hooks/useView.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { ViewModel, ImplementedViewPolicy } from "@policy-maker/core"; -import { hashKey, useSuspenseQuery } from "@tanstack/react-query"; -import { TypeOf } from "zod"; -import { useStore } from "../store/useStore"; -import { useCallback, useEffect, useState } from "react"; - -export type ViewConfig = { - staleTime?: number; - gcTime?: number; - retry?: number; -}; -type ContinueFetch = (fn: (prev: T) => Promise) => Promise; - -const defaultViewConfig: ViewConfig = { - staleTime: Infinity, - gcTime: 60 * 1000, - retry: 0, -}; - -type Param = { - policy: ImplementedViewPolicy; - repository: () => Promise>; - initialData?: TypeOf; - config?: ViewConfig; -}; -type Return = { - data: Data; - isFetching: boolean; - isRefetching: boolean; - isContinueFetching: boolean; - continueFetch: ContinueFetch; - error: unknown; -}; - -export const useView = ({ - policy, - repository, - initialData, - config, -}: Param): Return> => { - const hashedKey = hashKey(policy.key); - const [isContinueFetching, setIsContinueFetching] = useState(false); - const [error, setError] = useState(null); - const { get, set } = useStore(policy.key, policy.model); - const { data, isFetching: isRefetching } = useSuspenseQuery({ - queryKey: policy.key, - queryFn: repository, - initialData, - ...defaultViewConfig, - ...config, - }); - const continueFetch: ContinueFetch> = useCallback( - async (fn) => { - try { - setIsContinueFetching(true); - const prev = get(); - const fetched = await (prev ? fn(prev) : repository()); - setIsContinueFetching(false); - set(() => fetched); - setError(null); - } catch (e) { - setIsContinueFetching(false); - setError(e); - throw e; - } - }, - [hashedKey], - ); - - useEffect(() => { - setError(null); - }, [hashedKey]); - - return { - data, - isFetching: isContinueFetching || isRefetching, - isRefetching, - isContinueFetching, - continueFetch, - error, - }; -}; - -export const useViewState = useView; diff --git a/library/policy-maker/react/hooks/useViewQuery.ts b/library/policy-maker/react/hooks/useViewQuery.ts deleted file mode 100644 index ef137fe..0000000 --- a/library/policy-maker/react/hooks/useViewQuery.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { ViewModel, ImplementedViewPolicy } from "@policy-maker/core"; -import { hashKey, useSuspenseQuery } from "@tanstack/react-query"; -import { TypeOf } from "zod"; -import { useStore } from "../store/useStore"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useDebounce } from "use-debounce"; -import equal from "fast-deep-equal"; - -export type ViewQueryConfig = { - staleTime?: number; - gcTime?: number; - retry?: number; - debounceTime?: number; -}; -type ContinueFetchWithQuery = ( - fn: (prev: T, query: Query) => Promise, -) => Promise; - -const defaultViewQueryConfig: Required = { - staleTime: Infinity, - gcTime: 60 * 1000, - retry: 0, - debounceTime: 500, -}; - -type Param> = { - policy: ImplementedViewPolicy; - repository: (query: Query) => Promise>; - initialQuery: Query; - config?: ViewQueryConfig; - injectedQuery?: Query; -}; -type Return> = { - data: Data; - query: Query; - setQuery: (query: Partial) => void; - isFetching: boolean; - isRefetching: boolean; - isContinueFetching: boolean; - continueFetch: ContinueFetchWithQuery; - error: unknown; -}; - -export const useViewQuery = < - Model extends ViewModel, - Query extends Record, ->({ - policy, - repository, - initialQuery, - injectedQuery, - config, -}: Param): Return, Query> => { - const hashedKey = hashKey(policy.key); - const { debounceTime, ...reactQueryConfig } = { - ...defaultViewQueryConfig, - ...config, - }; - const [isContinueFetching, setIsContinueFetching] = useState(false); - const [error, setError] = useState(null); - const [queryState, setQueryState] = useState(initialQuery); - const [debouncedQueryState] = useDebounce(queryState, debounceTime); - const queriedKeys = [...policy.key, debouncedQueryState]; - const { get, set } = useStore(queriedKeys, policy.model); - const { data, isFetching: isRefetching } = useSuspenseQuery({ - queryKey: queriedKeys, - queryFn: () => repository(debouncedQueryState), - ...reactQueryConfig, - }); - - const continueFetch: ContinueFetchWithQuery< - TypeOf, - Query - > = useCallback( - async (fn) => { - try { - setIsContinueFetching(true); - const prev = get(); - const fetched = await (prev - ? fn(prev, debouncedQueryState) - : repository(debouncedQueryState)); - setIsContinueFetching(false); - set(() => fetched); - setError(null); - } catch (e) { - setIsContinueFetching(false); - setError(e); - throw e; - } - }, - [debouncedQueryState], - ); - - const setQuery = useCallback( - (input: Partial) => setQueryState((prev) => ({ ...prev, ...input })), - [hashedKey], - ); - - const isDebouncing = useMemo( - () => !equal(queryState, debouncedQueryState), - [hashedKey, queryState, debouncedQueryState], - ); - - useEffect(() => { - if (!injectedQuery) return; - if (!equal(injectedQuery, queryState)) setQueryState(injectedQuery); - }, [injectedQuery]); - - useEffect(() => { - setQueryState(initialQuery); - setError(null); - }, [hashedKey]); - - return { - data, - query: queryState, - setQuery, - isFetching: isContinueFetching || isRefetching || isDebouncing, - isRefetching: isRefetching || isDebouncing, - isContinueFetching, - continueFetch, - error, - }; -}; diff --git a/library/policy-maker/react/index.ts b/library/policy-maker/react/index.ts index d683cf2..c26d5ab 100644 --- a/library/policy-maker/react/index.ts +++ b/library/policy-maker/react/index.ts @@ -1,15 +1,22 @@ -import { useInput } from "./hooks/useInput"; -import { useView } from "./hooks/useView"; -import { useIntent } from "./hooks/useIntent"; -import { useViewQuery } from "./hooks/useViewQuery"; -import { useSimpleIntent } from "./hooks/useSimpleIntent"; -import { useStoredView } from "./hooks/useStoredView"; +/* + * Store + */ +export * from "./src/useStore"; -export { - useInput, - useView, - useViewQuery, - useStoredView, - useIntent, - useSimpleIntent, -}; +/* + * View + */ +export * from "./src/useView"; + +/* + * Input + */ +export * from "./src/useInput"; + +/* + * Intent + */ +export * from "./src/useIntent"; +export * from "./src/useIntentInput"; +export * from "./src/useIntentSubmit"; +export * from "./src/set"; diff --git a/library/policy-maker/react/package.json b/library/policy-maker/react/package.json index 35ff4f6..0ae4769 100644 --- a/library/policy-maker/react/package.json +++ b/library/policy-maker/react/package.json @@ -1,16 +1,12 @@ { "name": "@policy-maker/react", - "version": "0.0.1", - "private": true, - "main": "./index.ts", + "version:": "0.0.1", + "type": "module", + "main": "index.ts", "peerDependencies": { - "@policy-maker/core": "workspace:^", - "@tanstack/react-query": "^5.28.9", - "react": "^18.2.0", - "zod": "^3.23.5" - }, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "use-debounce": "^10.0.0" + "@policy-maker/core": "workspace:*", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "zod": "^3.23.0" } } diff --git a/library/policy-maker-2/react/src/set.ts b/library/policy-maker/react/src/set.ts similarity index 74% rename from library/policy-maker-2/react/src/set.ts rename to library/policy-maker/react/src/set.ts index 0abf463..ac440ee 100644 --- a/library/policy-maker-2/react/src/set.ts +++ b/library/policy-maker/react/src/set.ts @@ -1,4 +1,4 @@ -import { ViewPolicy, store } from "@policy-maker-2/core"; +import { ViewPolicy, store } from "@policy-maker/core"; export const setViewValue = (policy: ViewPolicy, data: T) => Promise.resolve( diff --git a/library/policy-maker-2/react/src/useInput.ts b/library/policy-maker/react/src/useInput.ts similarity index 100% rename from library/policy-maker-2/react/src/useInput.ts rename to library/policy-maker/react/src/useInput.ts diff --git a/library/policy-maker/react/src/useIntent.ts b/library/policy-maker/react/src/useIntent.ts new file mode 100644 index 0000000..1be5c80 --- /dev/null +++ b/library/policy-maker/react/src/useIntent.ts @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import { useSyncStore } from "./useStore"; +import { IntentPolicy, store } from "@policy-maker/core"; + +type IntentMeta = { + isWorking: boolean; + error: unknown; +}; + +export const useIntent = ({ + policy, + to, +}: { + policy: IntentPolicy; + to: (input: Input) => Promise; +}) => { + const [get, set] = useSyncStore(policy.key, () => ({ + isWorking: false, + error: null, + })); + + const send = useCallback( + async (input: Input) => { + try { + if (get.value.isWorking) throw new Error("Already working"); + set((prev) => ({ isWorking: true, error: prev?.error ?? null })); + const raw = await to(policy.model.input.parse(input)); + const output = policy.model.output.parse(raw); + policy.next && + policy.next({ input, output }).forEach(store.parseIntent); + set(() => ({ isWorking: false, error: null })); + return output; + } catch (e) { + set(() => ({ isWorking: false, error: e })); + policy.catch && + policy.catch({ input, error: e }).forEach(store.parseIntent); + return Promise.reject(e); + } + }, + [policy.key], + ); + + return { ...get.value, send, validator: policy.model.input }; +}; diff --git a/library/policy-maker/react/src/useIntentInput.ts b/library/policy-maker/react/src/useIntentInput.ts new file mode 100644 index 0000000..8b60abd --- /dev/null +++ b/library/policy-maker/react/src/useIntentInput.ts @@ -0,0 +1,25 @@ +import { IntentPolicy } from "@policy-maker/core"; +import { InputConfig, Validator, useInput } from "./useInput"; + +export const useIntentInput = < + Input extends Record, + Slice extends Partial, + Output, +>({ + policy, + initialValue, + config, +}: { + policy: IntentPolicy; + initialValue: (prev?: Partial) => Required; + config?: Partial; +}) => { + const { values, inputValues, isValid, set, reset } = useInput({ + key: policy.key, + validator: policy.model.input as unknown as Validator, + initialValue, + config, + }); + + return { values, inputValues, isValid, set, reset }; +}; diff --git a/library/policy-maker/react/src/useIntentSubmit.ts b/library/policy-maker/react/src/useIntentSubmit.ts new file mode 100644 index 0000000..cdbad1e --- /dev/null +++ b/library/policy-maker/react/src/useIntentSubmit.ts @@ -0,0 +1,69 @@ +import { IntentPolicy } from "@policy-maker/core"; +import { useIntent } from "./useIntent"; +import { useSyncStore } from "./useStore"; +import { useCallback, useMemo } from "react"; +import { isEmpty } from "./util/isEmpty"; + +type SubmitConfig = { + resetImmediate: boolean; + allowEmpty: boolean; +}; +const defaultSubmitConfig: SubmitConfig = { + resetImmediate: false, + allowEmpty: false, +}; + +export const useIntentSubmit = , Output>({ + policy, + to, + config: inputConfig, +}: { + policy: IntentPolicy; + to: (input: Input) => Promise; + config?: Partial; +}) => { + const config = useMemo( + () => ({ ...defaultSubmitConfig, ...inputConfig }), + [inputConfig], + ); + const { send, validator, isWorking } = useIntent({ policy, to }); + const [get, set] = useSyncStore>( + "input_" + policy.key, + () => ({}), + ); + + const { error, isValid } = useMemo(() => { + if (!config.allowEmpty && isEmpty(get.value)) + return { error: new Error("empty is not allowed"), isValid: false }; + const result = validator.safeParse(get.value); + return result.success + ? { error: undefined, isValid: true } + : { error: result.error, isValid: false }; + }, [policy.key, get.value]); + + const reset = useCallback(() => set(() => ({})), [policy.key]); + + const submit = useCallback(async () => { + try { + const submitValue = { ...get.value }; + if (!config.allowEmpty && isEmpty(get.value)) + throw new Error("Empty input is not allowed"); + if (config.resetImmediate) reset(); + const parsed = policy.model.input.parse(submitValue); + const output = await send(parsed); + if (!config.resetImmediate) reset(); + return output; + } catch (e) { + return Promise.reject(e); + } + }, [policy.key, get.value]); + + return { + inputValues: get.value, + validator, + isValid, + error, + isWorking, + submit, + }; +}; diff --git a/library/policy-maker-2/react/src/useStore.ts b/library/policy-maker/react/src/useStore.ts similarity index 94% rename from library/policy-maker-2/react/src/useStore.ts rename to library/policy-maker/react/src/useStore.ts index 6a2c8aa..86f2b70 100644 --- a/library/policy-maker-2/react/src/useStore.ts +++ b/library/policy-maker/react/src/useStore.ts @@ -1,4 +1,4 @@ -import { StoreConfig, store } from "@policy-maker-2/core"; +import { StoreConfig, store } from "@policy-maker/core"; import { useMemo, useEffect, useCallback, useState } from "react"; import { nanoid } from "nanoid"; @@ -69,7 +69,7 @@ export const useSyncStore = (key: string, from: (prev?: T) => T) => { ); useEffect(() => { - const { unsubscribe } = store.subscribe( + const { unsubscribe } = store.subscribeSync( key, subscriptionKey, () => { diff --git a/library/policy-maker-2/react/src/useView.ts b/library/policy-maker/react/src/useView.ts similarity index 77% rename from library/policy-maker-2/react/src/useView.ts rename to library/policy-maker/react/src/useView.ts index b57d0fe..b9f65b3 100644 --- a/library/policy-maker-2/react/src/useView.ts +++ b/library/policy-maker/react/src/useView.ts @@ -1,13 +1,15 @@ -import { ViewPolicy } from "@policy-maker-2/core"; +import { ViewPolicy } from "@policy-maker/core"; import { useCallback } from "react"; import { useStore } from "./useStore"; export const useView = ({ policy, from, + config, }: { policy: ViewPolicy; from: (prev?: T) => T | Promise; + config?: { mapError: (e: unknown) => any }; }) => { const [get, set] = useStore(policy.key, from, policy.config); @@ -20,7 +22,8 @@ export const useView = ({ }, [policy.key]); if (get.status === "PENDING") throw get.pending; - if (get.status === "REJECTED") throw get.error; + if (get.status === "REJECTED") + throw config?.mapError ? config.mapError(get.error) : get.error; return { status: get.status, diff --git a/library/policy-maker/react/src/util/isDeepEqual.ts b/library/policy-maker/react/src/util/isDeepEqual.ts new file mode 100644 index 0000000..110b06a --- /dev/null +++ b/library/policy-maker/react/src/util/isDeepEqual.ts @@ -0,0 +1,17 @@ +export const isDeepEqual = (a: unknown, b: unknown): boolean => { + if (typeof a === "object" && typeof b === "object") { + if (a === null) return b === null; + if (b === null) return false; + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + return a.every((v, i) => isDeepEqual(v, b[i])); + } + return Object.keys(a).every((key) => + isDeepEqual( + a[key as keyof typeof a], + (b as Record)[key], + ), + ); + } + return a === b; +}; diff --git a/library/policy-maker/react/src/util/isEmpty.ts b/library/policy-maker/react/src/util/isEmpty.ts new file mode 100644 index 0000000..4828357 --- /dev/null +++ b/library/policy-maker/react/src/util/isEmpty.ts @@ -0,0 +1,16 @@ +export const isEmpty = (value: unknown): boolean => { + switch (typeof value) { + case "number": + return Number.isNaN(value); + case "object": + if (value === null) return false; // TODO: is it okay? + return ( + value === null || + Object.keys(value).filter( + (key) => !isEmpty(value[key as keyof typeof value]), + ).length === 0 + ); + default: + return value === null || value === undefined; + } +}; diff --git a/library/policy-maker/react/src/util/pickValidValues.ts b/library/policy-maker/react/src/util/pickValidValues.ts new file mode 100644 index 0000000..8331823 --- /dev/null +++ b/library/policy-maker/react/src/util/pickValidValues.ts @@ -0,0 +1,9 @@ +export const pickValidValues = >( + value?: T, +) => { + if (!value) return {}; + return Object.keys(value).reduce((acc, key) => { + if (value[key as keyof T] === undefined) return acc; + return { ...acc, [key]: value[key as keyof T] }; + }, {} as T); +}; diff --git a/library/policy-maker/react/store/useStore.ts b/library/policy-maker/react/store/useStore.ts deleted file mode 100644 index 2289012..0000000 --- a/library/policy-maker/react/store/useStore.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { PolicyKey } from "@policy-maker/core"; -import { useQueryClient } from "@tanstack/react-query"; -import { TypeOf, ZodType } from "zod"; -import { useCallback } from "react"; - -type Getter = () => T | undefined; -type Setter = (setter: (prev?: T) => T) => void; - -export const useStore = ( - key: PolicyKey, - model: Model, -): { get: Getter>; set: Setter> } => { - const queryClient = useQueryClient(); - - const get = useCallback(() => { - const data = queryClient.getQueryData(key); - const result = model.safeParse(data); - return result.success ? result.data : undefined; - }, [key]); - - const set = (setter: (prev?: T) => T) => { - queryClient.setQueriesData({ queryKey: key }, setter); - }; - - return { get, set }; -}; diff --git a/library/policy-maker/react/tsconfig.json b/library/policy-maker/react/tsconfig.json index 92c5daf..ee66292 100644 --- a/library/policy-maker/react/tsconfig.json +++ b/library/policy-maker/react/tsconfig.json @@ -13,13 +13,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "jsx": "react-jsx" }, - "include": ["index.ts", "**/*.ts"] + "include": ["index.ts", "src"] } diff --git a/library/pvi/README.md b/library/pvi/README.md deleted file mode 100644 index 3b1732f..0000000 --- a/library/pvi/README.md +++ /dev/null @@ -1,203 +0,0 @@ -# PVI - -## PVI란? - -Policy-View-Intent(간단히 PVI)는 선언적 코드 스타일과 단방향 데이터 흐름을 중시하는 오늘날의 프론트엔드 환경에 도입할 수 있는 아키텍쳐입니다. MVI(Model-View-Intent) 아키텍쳐를 기반으로, 서버와 클라이언트가 분리된 환경에서 사용하기 좋게 불필요한 부분을 줄였습니다. - -### 핵심 가치 - -PVI가 지향하는 핵심 가치는 다음과 같습니다. - -#### 다른 개발자 / 기획자 / 디자이너와의 협업이 원할하면서도 자율적이어야 한다 - -로직과 화면을 통합해 사용자가 직접 대면하는 인터페이스를 생성하는 프론트엔드 개발은 다른 분야와의 원활한 협업이 중요합니다. 화면을 그리는 데에는 디자인이 필요하고, 로직을 완성하는 데에는 서버의 API가 필요합니다. 그러나 외부의 작업에 과하게 의존하게 되면, 프론트엔드의 개발 효율이 떨어져 오히려 협업에 방해가 될 수 있습니다. - -PVI는 프론트엔드 개발자가 다른 분야의 작업물(이를테면 서버 API나 디자인, 기획 세부사항 등)에 의존할 부분과 의존하지 않을 부분을 구분하고 통제할 수 있게 합니다. 프론트엔드 개발자는 다른 분야의 진척 상황에 맞게 작업을 설계하고 추진할 수 있습니다. - -#### 아키텍쳐는 최소한의 의존성만을 가져야 한다 - -#### 개발자의 창의성은 필수가 아니여야 한다 - -좋은 아키텍쳐는 개발자를 덜 생각하게끔 만듭니다. 필수적이고 당연한 일들을 할 때 고민을 많이 하다보면 진짜 창의적인 접근이 필요한 부분에서 피로해지기 마련입니다. PVI 라이브러리를 사용하면 타입스크립트 Intellisense를 기반으로 프로젝트의 구조를 파악하기 쉽게 하여 크게 고민하지 않고도 작업의 맥락을 파악하고 코드 스타일을 유지할 수 있게 해줍니다. 비교적 단순한 데이터의 흐름은 빠르게 확정하고, 유려한 디자인 컴포넌트를 생성하거나 복잡한 로직을 처리하는 등의 작업에 집중할 수 있습니다. - -### 사전 지식 - -### MVI - -(추후 추가 예정) - -### Zod - -Zod는 JS/TS를 위한 데이터 파싱 라이브러리로, 어떤 값을 런타임에서 동적으로 검사할 수 있게 합니다. Zod는 여타 검증 라이브러리와 다르게, 검사가 끝난 값의 타입을 검사 내용에 따라 고정시켜줍니다. 따라서 서버 요청이나 사용자의 입력과 같이 그 값을 확신할 수 없는 데이터를 다룰 경우, Zod를 이용해 값을 검증하므로써 타입이 확정되었다 가정하고 이후의 로직을 작업할 수 있습니다. 만약 검증에 실패했다면 자동으로 ZodError가 throw되므로 이에 따라 다른 방식으로 데이터를 처리할 수 있습니다. - -PVI의 핵심은 정책을 먼저 작성하고, 작성된 정책에 따라 View와 Intent의 흐름이 엄격하게 통제되게 만드는 것입니다. 이를 위해서는 정책이 강력한 타입 가드를 포함할 필요가 있습니다. 따라서 PVI는 Zod에 강력히 의존하며, 언젠가 의존성에서 벗어나더라도 다른 방식의 데이터 파싱 방안을 마련해야 할 것입니다. - -(추후 추가 예정) -[읽을거리1](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) - -## 개념과 사용법 - -### Policy - -정책은 코드로 표현된 기획입니다. 매장의 상품 목록을 조회하고 관리하는 서비스가 있다고 해봅시다. 기본적으로는 상품 목록을 보는 정책과, 상품 목록에 항목을 추가, 수정, 삭제하는 정책이 있겠죠. 기획을 좀 더 자세히 살펴보면 이런 정책에 더 많은 내용을 적을 수 있을 겁니다. 가령 상품 목록에는 어떤 정보가 표시되어야 하는 지, 또는 상품을 생성하려면 어떤 정보를 입력해야 하는 지도 각각의 정책에 명시되어야 합니다. 어떤 정책들은 서로 연결되어 있습니다. 예를 들어 상품 목록에 항목을 추가하면 상품 목록을 조회할 때 표시될 항목이 하나 늘어납니다. - -이 정책들을 코드로 옮긴다고 생각해봅시다. 쉽게 생각하면 상품 목록을 어딘가에 데이터로 저장하고, 이를 가져오는 정책과 여기에 항목을 추가하는 정책 등을 간단한 코드로 작성할 수 있겠죠. 문제는 이렇게 작동할 수 있는 프론트엔드 프로젝트가 많지 않다는 겁니다. 프론트엔드 코드베이스에서는 데이터를 직접 조작하지 못합니다. 조작하는 것은 백엔드(서버)의 로직으로 저장되어 있고, 프론트엔드는 요청을 보내거나 받는 일만을 수행합니다. - -따라서 프론트엔드의 입장에서 정책은 어떤 로직이 아닙니다. 요청을 보낼 때 필요한 데이터 타입, 응답이 올 것이라 기대되는 데이터 타입, 그리고 정책 간의 연결만을 명시해놓은 함수입니다. 함수의 인자로는 해당 정책의 내용을 특정하는 데 필요한 의존성만이 명시됩니다. 상품 항목을 수정하는 정책의 경우 '어떤 상품을 수정할 것인 지 특정하는 값'이 의존성이라 할 수 있겠습니다. - -PVI에서 정책은 크게 View Policy와 Intent Policy로 구분됩니다. 구분을 한 이유는 데이터의 흐름을 단방향으로 통제하기 위해서입니다. View Policy는 사용자에게 보이는 화면을 결정하는 정책입니다. 사용자가 화면을 보고 요청하는 바는 Intent Policy로 명시됩니다. 사용자의 의도가 제대로 전달되면 화면의 일부가 바뀔 것입니다. 따라서 Intent Policy는 특정 View Policy와 연결될 수 있지만, 반대로는 불가능합니다. 화면을 보고 의도를 결정하는 것은 사용자가 할 일이지, 개발자가 할 일은 아니기 때문입니다. - -### View Policy - -View Policy는 어떤 데이터가 화면에 뿌려질 지를 명시합니다. 자체적으로 map과 invalidate 함수를 갖고 있는데, 이는 Intent Policy에서 연결 관계를 명시할 때 사용하는 인터페이스로 직접 조작하는 것은 권장되지 않습니다. - -View Policy를 생성하기 위해서는 `createViewPolicy`로 먼저 정책 생성 함수(이하 VP)를 선언해야 합니다. - -```ts -export const VP = createViewPolicy(); -``` - -View Policy는 다음과 같이 생성합니다. `key`는 각 정책별로 고유한 식별자(QueryKey), `model`은 데이터 타입을 명시한 Zod 객체입니다. - -```ts -const VPTodos = VP(() => ({ - key: ["todos"], - model: Todo.array(), -})); -``` - -VP 함수의 인자는 정책의 의존성을 표현합니다. 타입 캐스팅으로 의존성의 타입을 선언할 수 있습니다. - -```ts -const VPTodo = PVI.view((todoId: Todo["id"]) => ({ - key: [{ todo: todoId }], - model: Todo, -})); -``` - -ViewPolicy는 주제 별로 나뉘어진 객체로 묶어서 관리되어야 합니다 - -```ts -const viewPolicy = { - me: { - information: VPInformation, - todoCounts: VPTodoCounts, - } - todo: { - todo: VPTodo, - todos: VPTodos - } -}; -``` - -### Intent Policy - -Intent Policy 역시 IP라는 생성 함수를 만들어 사용할 수 있습니다. Intent Policy는 View Policy와 연결될 수 있기 때문에, viewPolicy의 객체를 주입받습니다. - -```ts -export const IP = PVI.createIntentPolicyFactory(viewPolicy); -``` - -Intent Policy는 View Policy처럼 인자로 의존성을 명시하고, key와 model을 가지고 있지만, model이 `{ input: ZodType, output:ZodType }`으로 묶인 객체라는 점이 다릅니다. 그리고 connect 프로퍼티를 통해 viewPolicy와의 관계성을 명시할 수 있습니다. - -connect 프로퍼티는 `(intent의 결과물) => (view의 변화)[]` 꼴의 함수입니다. `intent의 결과물`은 다음과 같습니다: - -- model의 input. 즉 요청을 보낼 때 필요한 값 -- model의 output. 요청의 응답으로 받을 값 -- Intent Policy의 의존성 - -`view의 변화`는 다음과 같습니다: - -- invalidate: 다시 서버에 요청을 보내 view의 값을 갱신합니다. -- map: 이전의 view를 받아 새로운 view를 반환하는 함수입니다. - -다음은 Todo의 content를 입력받아 Todo를 생성하고, 성공하면 todos라는 view에 새로 생성된 Todo를 하나 추가하면서 동시에 todoCounts라는 view를 값을 새로고침하는 정책(AddTodo)의 예시입니다. - -```ts -const input = Todo.pick({ content: true }); -const output = Todo; - -const IPAddTodo = PVI.intent(() => ({ - key: ["addTodo"], - model: { input, output }, - connect: ({ view, output }) => [ - view.todo.todos.map((prev) => [...prev, output]), - view.me.todoCounts.invalidate(), - ], -})); -``` - -주의할 것은, 이렇게 VP와 IP 함수로 생성한 정책들은 정책의 초안(Draft)이라는 겁니다. 실제로 특정 환경에서 사용하기 위해서는 필요한 의존성과 인터페이스를 주입할 필요가 있습니다. 현재는 `@pvi/react` 패키지를 설치하여 React 프로젝트에서 사용할 수 있습니다. - -### Usage with React - -사용을 위해서는 core 뿐 아니라 React용 PVI 모듈을 설치해야 합니다. - -```shell -yarn add @pvi/core @pvi/react -``` - -생성한 정책의 초안들은 주제 별로 나눠서 관리되고 있을 겁니다. 이 묶음을 `integrateWithReact` 함수를 이용하여 의존성이 주입된 실제 정책으로 만들어야 합니다. - -```ts -export const { - policy, - hooks: { useView, useStaticView, useViewState, useIntent }, -} = integrateWithReact({ - viewPolicy, - intentPolicy, - queryClient, -}); -``` - -`integrateWithReact` 함수는 policy와 hooks를 반환합니다. policy는 `{view: viewPolicy, intent: intentPolicy}` 꼴로 묶인 전체 정책의 모음이고, hooks는 리액트 컴포넌트 내에서 정책을 사용할 때 이용 가능한 함수입니다. - -View Policy는 `useView` 또는 `useViewState`로 가져올 수 있습니다. useView는 React Suspense 환경에 적합하고, `useViewState`는 `useQuery`와 비슷한 방식으로 작동합니다. - -```tsx -export default function MyInformation() { - const { data } = useView((view) => ({ - policy: view.user.me(), - repository: UserRepository.me, - })); - return
{data.name}
; -} -``` - -여기서 repository는 해당 정책의 model에 대하여 `()=>Promise<{data: Model, context: unknown}>` 꼴의 함수를 의미합니다. context를 별도로 둔 이유는 정책의 흐름과 무관하지만 Repository의 작동을 위하 필요한 값(Pagination 관련 정보 등)을 임시로 저장해두기 위해서입니다. - -Intent Policy는 `useIntent`로 사용할 수 있습니다. Intent를 작동시키는 방법은 크게 두 가지입니다. 하나는 `input`을 입력하고, `submit`으로 해당 정보를 묶어서 요청을 보내는 것. 다른 하나는 `send`로 값을 입력함과 함께 요청을 보내는 것. form 형식의 화면에는 전자, 클릭 하나로 요청을 보내는 토글 등에는 후자가 적합합니다. 아래는 input과 submit으로 메시지를 입력 후 전송하는 컴포넌트의 예시입니다. - -```tsx -export default function MessageInput() { - const { - input: { values:{ text }, set }, - submit: sendMessage, - isValid, - } = useIntent((intent) => ({ - policy: intent.message.sendMessage, - repository: MessageRepository.sendMessage, - })); - - return ( -
{ - e.preventDefault(); - if (isValid) sendMessage() - } - > - 메시지 작성: - set({ text: e.target.value })} - /> - -
- ); -} -``` diff --git a/library/pvi/core/adapter/zod/types.ts b/library/pvi/core/adapter/zod/types.ts deleted file mode 100644 index 22f5ff6..0000000 --- a/library/pvi/core/adapter/zod/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ZodObject, ZodRawShape } from "zod"; - -export type ZodAnyObject = ZodObject; diff --git a/library/pvi/core/class/index.ts b/library/pvi/core/class/index.ts deleted file mode 100644 index ca6a888..0000000 --- a/library/pvi/core/class/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -// import { ZodType } from "zod"; -// import { AnyViewPolicyRecords } from "../types/typesAny"; -// import { ZodAnyObject } from "../adapter/zod/types"; -// import { ViewModel, ViewPolicyParam } from "../types/view"; -// import { IntentModel, IntentPolicyParam, IntentPolicy } from "../types/intent"; -// import { ViewPolicy } from "./view"; - -// const createViewPolicyFactory = () => { -// const createViewPolicy = ( -// param: ViewPolicyParam, -// ) => { -// new ViewPolicy(param); -// }; -// return createViewPolicy; -// }; -// const createIntentPolicyFactory = ( -// viewPolicies: View, -// ) => { -// const view = viewPolicies; -// const createIntentPolicy = < -// I extends ZodAnyObject, -// O extends ZodType, -// Deps extends unknown[], -// Model extends IntentModel, -// >( -// param: IntentPolicyParam, -// ): IntentPolicy => { -// const intentPolicy = (...deps: Deps) => { -// const { key, model, connect } = param(...deps); -// const connectInjected: ReturnType< -// IntentPolicy -// >["connect"] = (io) => (connect ? connect({ ...io, view }) : []); -// return { key, model, connect: connectInjected }; -// }; -// return intentPolicy; -// }; -// return createIntentPolicy; -// }; - -// export { createViewPolicyFactory, createIntentPolicyFactory }; diff --git a/library/pvi/core/class/intent.ts b/library/pvi/core/class/intent.ts deleted file mode 100644 index c524ae2..0000000 --- a/library/pvi/core/class/intent.ts +++ /dev/null @@ -1,47 +0,0 @@ -// import { ZodAnyObject } from "../adapter/zod/types"; -// import { PolicyKey } from "../types/common"; -// import { IntentModel, IntentPolicyParam } from "../types/intent"; -// import { TypeOf, ZodType } from "zod"; -// import { AnyViewPolicyRecords } from "../types/typesAny"; - -// export class IntentPolicy< -// I extends ZodAnyObject, -// O extends ZodType, -// Deps extends unknown[], -// Model extends IntentModel, -// ViewPolicies extends AnyViewPolicyRecords, -// > { -// private _key: (...deps: Deps) => PolicyKey; -// private _model: (...deps: Deps) => Model; -// private _connect: (...deps: Deps) => -// | (( -// view: ViewPolicies, -// io: { -// input: TypeOf; -// output: TypeOf; -// }, -// ) => Promise[]) -// | undefined; -// private _viewPolicies: ViewPolicies; - -// constructor( -// viewPolicies: ViewPolicies, -// param: IntentPolicyParam, -// ) { -// this._key = (...deps: Deps) => param(...deps).key; -// this._model = (...deps: Deps) => param(...deps).model; -// this._connect = (...deps: Deps) => param(...deps).connect; -// this._viewPolicies = viewPolicies; -// } - -// public inject(viewPolicies: ViewPolicies) { -// this._viewPolicies = viewPolicies; -// } - -// public read(...deps: Deps) { -// return { -// key: this._key(...deps), -// model: this._model(...deps), -// }; -// } -// } diff --git a/library/pvi/core/class/view.ts b/library/pvi/core/class/view.ts deleted file mode 100644 index b5fddff..0000000 --- a/library/pvi/core/class/view.ts +++ /dev/null @@ -1,38 +0,0 @@ -// import { PolicyKey } from "../types/common"; -// import { ViewModel } from "../types/view"; - -// export class ViewPolicy { -// private _key: (...deps: Deps) => PolicyKey; -// private _model: (...deps: Deps) => Model; -// private _map: ( -// ...deps: Deps -// ) => (mapFn: (prev: Model) => Model) => Promise; -// private _invalidate: (...deps: Deps) => () => Promise; - -// constructor(param: (...deps: Deps) => { key: PolicyKey; model: Model }) { -// this._key = (...deps: Deps) => param(...deps).key; -// this._model = (...deps: Deps) => param(...deps).model; -// this._invalidate = () => () => Promise.resolve(); -// this._map = () => (mapFn: (prev: Model) => Model) => Promise.resolve(); -// } - -// public inject( -// invalidate: (...deps: Deps) => () => Promise, -// map: (...deps: Deps) => (mapFn: (prev: Model) => Model) => Promise, -// ) { -// this._invalidate = invalidate; -// this._map = map; -// } - -// public read(...deps: Deps) { -// return { key: this._key(...deps), model: this._model(...deps) }; -// } - -// public map(...deps: Deps) { -// return this._map(...deps); -// } - -// public invalidate(...deps: Deps) { -// return this._invalidate(...deps); -// } -// } diff --git a/library/pvi/core/create/intent.ts b/library/pvi/core/create/intent.ts deleted file mode 100644 index f24ac7d..0000000 --- a/library/pvi/core/create/intent.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ZodAnyObject } from "../adapter/zod/types"; -import { ZodType, TypeOf } from "zod"; -import { - IntentModel, - IntentPolicyDraft, - IntentPolicyParam, -} from "../types/intent"; -import { AnyViewPolicyDraftRecords } from "../types/typesAny"; -import { ViewPolicyRecords } from "../types/view"; - -export const createIntentPolicy = < - ViewPolicyDrafts extends AnyViewPolicyDraftRecords, ->( - _: ViewPolicyDrafts, -) => { - const createFn = < - I extends ZodAnyObject, - O extends ZodType, - Deps extends unknown[], - Model extends IntentModel, - >( - param: IntentPolicyParam< - I, - O, - Deps, - Model, - ViewPolicyRecords - >, - ): IntentPolicyDraft< - I, - O, - Deps, - Model, - ViewPolicyRecords - > => { - const intentPolicy = ( - viewPolicies: ViewPolicyRecords, - ) => { - return (...deps: Deps) => { - const { key, model, connect } = param(...deps); - const connectInjected = (io: { - input: TypeOf; - output: TypeOf; - }) => (connect ? connect(viewPolicies, io) : []); - return { key, model, connect: connectInjected }; - }; - }; - return intentPolicy; - }; - return createFn; -}; diff --git a/library/pvi/core/create/view.ts b/library/pvi/core/create/view.ts deleted file mode 100644 index 4b6ce1e..0000000 --- a/library/pvi/core/create/view.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ViewModel, ViewPolicyDraft, ViewPolicyParam } from "../types/view"; - -export const createViewPolicy = () => { - const createFn = ( - param: ViewPolicyParam, - ): ViewPolicyDraft => { - const viewPolicy = - ({ invalidater, mapper }: Parameters>[0]) => - (...deps: Deps) => { - const { key, model } = param(...deps); - return { key, model, invalidate: invalidater(key), map: mapper(key) }; - }; - return viewPolicy; - }; - return createFn; -}; diff --git a/library/pvi/core/index.ts b/library/pvi/core/index.ts deleted file mode 100644 index bed906e..0000000 --- a/library/pvi/core/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createViewPolicy } from "./create/view"; -import { createIntentPolicy } from "./create/intent"; - -export { createViewPolicy, createIntentPolicy }; diff --git a/library/pvi/core/package.json b/library/pvi/core/package.json deleted file mode 100644 index 0ce1915..0000000 --- a/library/pvi/core/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@pvi/core", - "version": "0.0.0", - "type": "module", - "main": "index.ts", - "dependencies": { - "zod": "^3.22.4" - } -} diff --git a/library/pvi/core/types/common.ts b/library/pvi/core/types/common.ts deleted file mode 100644 index 5837166..0000000 --- a/library/pvi/core/types/common.ts +++ /dev/null @@ -1 +0,0 @@ -export type PolicyKey = readonly unknown[]; diff --git a/library/pvi/core/types/intent.ts b/library/pvi/core/types/intent.ts deleted file mode 100644 index e5ab4ca..0000000 --- a/library/pvi/core/types/intent.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { TypeOf, ZodType } from "zod"; -import { ZodAnyObject } from "../adapter/zod/types"; -import { PolicyKey } from "./common"; -import { - AnyIntentPolicyDraft, - AnyIntentPolicyDraftRecords, - AnyViewPolicyDraftRecords, -} from "./typesAny"; -import { ViewPolicyRecords } from "./view"; - -/* - * Intent - */ -export type IntentModel = { - input: I; - output: O; -}; -export type IntentPolicyParam< - I extends ZodAnyObject, - O extends ZodType, - Deps extends unknown[], - Model extends IntentModel, - ViewPolicies extends ViewPolicyRecords, -> = (...deps: Deps) => { - key: PolicyKey; - model: Model; - connect?: ( - view: ViewPolicies, - io: { - input: TypeOf; - output: TypeOf; - }, - ) => Promise[]; -}; -export type IntentPolicyDraft< - I extends ZodAnyObject, - O extends ZodType, - Deps extends unknown[], - Model extends IntentModel, - ViewPolicies extends ViewPolicyRecords, -> = (viewPolicies: ViewPolicies) => (...deps: Deps) => { - key: PolicyKey; - model: Model; - connect?: (io: { input: TypeOf; output: TypeOf }) => Promise[]; -}; -export type IntentPolicy< - I extends ZodAnyObject, - O extends ZodType, - Deps extends unknown[], - Model extends IntentModel, -> = ReturnType< - IntentPolicyDraft< - I, - O, - Deps, - Model, - ViewPolicyRecords - > ->; - -export type IntentPolicyRecords< - ViewPolicies extends ViewPolicyRecords, - Drafts extends AnyIntentPolicyDraftRecords, -> = { - [K in keyof Drafts]: { - [P in keyof Drafts[K]]: Drafts[K][P] extends AnyIntentPolicyDraft - ? ReturnType - : never; - }; -}; diff --git a/library/pvi/core/types/typesAny.ts b/library/pvi/core/types/typesAny.ts deleted file mode 100644 index 13b8c13..0000000 --- a/library/pvi/core/types/typesAny.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ZodAnyObject } from "../adapter/zod/types"; -import { ZodType } from "zod"; -import { - ViewModel, - ViewPolicy, - ViewPolicyDraft, - ViewPolicyRecords, -} from "./view"; -import { IntentModel, IntentPolicy, IntentPolicyDraft } from "./intent"; - -export type AnyViewPolicyDraft = ViewPolicyDraft; -export type AnyViewPolicy = ViewPolicy; -export type AnyViewPolicyDraftRecords = Record< - string, - Record ->; -export type AnyIntentPolicyDraft< - ViewPolicies extends ViewPolicyRecords, -> = IntentPolicyDraft< - ZodAnyObject, - ZodType, - any[], - IntentModel, - ViewPolicies ->; -export type AnyIntentPolicy = IntentPolicy< - ZodAnyObject, - ZodType, - any[], - IntentModel ->; -export type AnyIntentPolicyDraftRecords< - ViewPolicies extends ViewPolicyRecords, -> = Record>>; diff --git a/library/pvi/core/types/view.ts b/library/pvi/core/types/view.ts deleted file mode 100644 index 713b5d3..0000000 --- a/library/pvi/core/types/view.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ZodType, TypeOf } from "zod"; -import { PolicyKey } from "./common"; -import { AnyViewPolicyDraft, AnyViewPolicyDraftRecords } from "./typesAny"; - -export type ViewModel = ZodType; -export type ViewPolicyParam = ( - ...deps: Deps -) => { - key: PolicyKey; - model: Model; -}; -export type ViewPolicyDraft< - Deps extends unknown[], - Model extends ViewModel, - Invalidator extends (key: PolicyKey) => () => Promise = ( - key: PolicyKey, - ) => () => Promise, - Mapper extends ( - key: PolicyKey, - ) => (mapFn: (prev: TypeOf) => TypeOf) => Promise = ( - key: PolicyKey, - ) => (mapFn: (prev: TypeOf) => TypeOf) => Promise, -> = (store: { invalidater: Invalidator; mapper: Mapper }) => ( - ...deps: Deps -) => { - key: PolicyKey; - model: Model; - invalidate: ReturnType; - map: ReturnType; -}; -export type ViewPolicy< - Deps extends unknown[], - Model extends ViewModel, -> = ReturnType>; -export type ViewPolicyRecords = { - [K in keyof Drafts]: { - [P in keyof Drafts[K]]: Drafts[K][P] extends AnyViewPolicyDraft - ? ReturnType - : never; - }; -}; diff --git a/library/pvi/react/README.md b/library/pvi/react/README.md deleted file mode 100644 index dd978f6..0000000 --- a/library/pvi/react/README.md +++ /dev/null @@ -1,194 +0,0 @@ -# PVI - -## PVI란? -Policy-View-Intent(간단히 PVI)는 선언적 코드 스타일과 단방향 데이터 흐름을 중시하는 오늘날의 프론트엔드 환경에 도입할 수 있는 아키텍쳐입니다. MVI(Model-View-Intent) 아키텍쳐를 기반으로, 서버와 클라이언트가 분리된 환경에서 사용하기 좋게 불필요한 부분을 줄였습니다. - -### 핵심 가치 -PVI가 지향하는 핵심 가치는 다음과 같습니다. - -#### 다른 개발자 / 기획자 / 디자이너와의 협업이 원할하면서도 자율적이어야 한다 -로직과 화면을 통합해 사용자가 직접 대면하는 인터페이스를 생성하는 프론트엔드 개발은 다른 분야와의 원활한 협업이 중요합니다. 화면을 그리는 데에는 디자인이 필요하고, 로직을 완성하는 데에는 서버의 API가 필요합니다. 그러나 외부의 작업에 과하게 의존하게 되면, 프론트엔드의 개발 효율이 떨어져 오히려 협업에 방해가 될 수 있습니다. - -PVI는 프론트엔드 개발자가 다른 분야의 작업물(이를테면 서버 API나 디자인, 기획 세부사항 등)에 의존할 부분과 의존하지 않을 부분을 구분하고 통제할 수 있게 합니다. 프론트엔드 개발자는 다른 분야의 진척 상황에 맞게 작업을 설계하고 추진할 수 있습니다. - -#### 아키텍쳐는 최소한의 의존성만을 가져야 한다 - -#### 개발자의 창의성은 필수가 아니여야 한다 -좋은 아키텍쳐는 개발자를 덜 생각하게끔 만듭니다. 필수적이고 당연한 일들을 할 때 고민을 많이 하다보면 진짜 창의적인 접근이 필요한 부분에서 피로해지기 마련입니다. PVI 라이브러리를 사용하면 타입스크립트 Intellisense를 기반으로 프로젝트의 구조를 파악하기 쉽게 하여 크게 고민하지 않고도 작업의 맥락을 파악하고 코드 스타일을 유지할 수 있게 해줍니다. 비교적 단순한 데이터의 흐름은 빠르게 확정하고, 유려한 디자인 컴포넌트를 생성하거나 복잡한 로직을 처리하는 등의 작업에 집중할 수 있습니다. - - -### 사전 지식 - -### MVI - -(추후 추가 예정) - -### Zod - -Zod는 JS/TS를 위한 데이터 파싱 라이브러리로, 어떤 값을 런타임에서 동적으로 검사할 수 있게 합니다. Zod는 여타 검증 라이브러리와 다르게, 검사가 끝난 값의 타입을 검사 내용에 따라 고정시켜줍니다. 따라서 서버 요청이나 사용자의 입력과 같이 그 값을 확신할 수 없는 데이터를 다룰 경우, Zod를 이용해 값을 검증하므로써 타입이 확정되었다 가정하고 이후의 로직을 작업할 수 있습니다. 만약 검증에 실패했다면 자동으로 ZodError가 throw되므로 이에 따라 다른 방식으로 데이터를 처리할 수 있습니다. - -PVI의 핵심은 정책을 먼저 작성하고, 작성된 정책에 따라 View와 Intent의 흐름이 엄격하게 통제되게 만드는 것입니다. 이를 위해서는 정책이 강력한 타입 가드를 포함할 필요가 있습니다. 따라서 PVI는 Zod에 강력히 의존하며, 언젠가 의존성에서 벗어나더라도 다른 방식의 데이터 파싱 방안을 마련해야 할 것입니다. - -(추후 추가 예정) -[읽을거리1](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) - - -## 개념과 사용법 - -### Policy -정책은 코드로 표현된 기획입니다. 매장의 상품 목록을 조회하고 관리하는 서비스가 있다고 해봅시다. 기본적으로는 상품 목록을 보는 정책과, 상품 목록에 항목을 추가, 수정, 삭제하는 정책이 있겠죠. 기획을 좀 더 자세히 살펴보면 이런 정책에 더 많은 내용을 적을 수 있을 겁니다. 가령 상품 목록에는 어떤 정보가 표시되어야 하는 지, 또는 상품을 생성하려면 어떤 정보를 입력해야 하는 지도 각각의 정책에 명시되어야 합니다. 어떤 정책들은 서로 연결되어 있습니다. 예를 들어 상품 목록에 항목을 추가하면 상품 목록을 조회할 때 표시될 항목이 하나 늘어납니다. - -이 정책들을 코드로 옮긴다고 생각해봅시다. 쉽게 생각하면 상품 목록을 어딘가에 데이터로 저장하고, 이를 가져오는 정책과 여기에 항목을 추가하는 정책 등을 간단한 코드로 작성할 수 있겠죠. 문제는 이렇게 작동할 수 있는 프론트엔드 프로젝트가 많지 않다는 겁니다. 프론트엔드 코드베이스에서는 데이터를 직접 조작하지 못합니다. 조작하는 것은 백엔드(서버)의 로직으로 저장되어 있고, 프론트엔드는 요청을 보내거나 받는 일만을 수행합니다. - -따라서 프론트엔드의 입장에서 정책은 어떤 로직이 아닙니다. 요청을 보낼 때 필요한 데이터 타입, 응답이 올 것이라 기대되는 데이터 타입, 그리고 정책 간의 연결만을 명시해놓은 함수입니다. 함수의 인자로는 해당 정책의 내용을 특정하는 데 필요한 의존성만이 명시됩니다. 상품 항목을 수정하는 정책의 경우 '어떤 상품을 수정할 것인 지 특정하는 값'이 의존성이라 할 수 있겠습니다. - -PVI에서 정책은 크게 View Policy와 Intent Policy로 구분됩니다. 구분을 한 이유는 데이터의 흐름을 단방향으로 통제하기 위해서입니다. View Policy는 사용자에게 보이는 화면을 결정하는 정책입니다. 사용자가 화면을 보고 요청하는 바는 Intent Policy로 명시됩니다. 사용자의 의도가 제대로 전달되면 화면의 일부가 바뀔 것입니다. 따라서 Intent Policy는 특정 View Policy와 연결될 수 있지만, 반대로는 불가능합니다. 화면을 보고 의도를 결정하는 것은 사용자가 할 일이지, 개발자가 할 일은 아니기 때문입니다. - - -### View Policy -View Policy는 어떤 데이터가 화면에 뿌려질 지를 명시합니다. - -PVI-React는 상태 관리 라이브러리로 react-query를 사용하고 있기 때문에, View Policy를 생성하기 위해서는 `queryClient`를 주입한 정책 선언 함수(이하 VP)를 선언해야 합니다. - -```ts -export const VP = PVI.createViewPolicyFactory(queryClient); -``` - -View Policy는 다음과 같이 생성합니다. `key`는 각 정책별로 고유한 식별자(QueryKey), `model`은 데이터 타입을 명시한 Zod 객체입니다. - -```ts -const VPTodos = VP(() => ({ - key: ["todos"], - model: Todo.array(), -})); -``` - -VP 함수의 인자는 정책의 의존성을 표현합니다. 타입 캐스팅으로 의존성의 타입을 선언할 - -```ts -const VPTodo = PVI.view((todoId: Todo["id"]) => ({ - key: [{ todo: todoId }], - model: Todo, -})); -``` - -ViewPolicy는 주제 별로 나뉘어진 객체로 묶어서 관리되어야 합니다 -```ts -const viewPolicy = { - me: { - information: VPInformation, - todoCounts: VPTodoCounts, - } - todo: { - todo: VPTodo, - todos: VPTodos - } -}; -``` - -### Intent Policy - -Intent Policy 역시 IP라는 생성 함수를 만들어 사용할 수 있습니다. Intent Policy는 View Policy와 연결될 수 있기 때문에, viewPolicy의 객체를 주입받습니다. - -```ts -export const IP = PVI.createIntentPolicyFactory(viewPolicy); -``` - -Intent Policy는 View Policy처럼 인자로 의존성을 명시하고, key와 model을 가지고 있지만, model이 `{ input: ZodType, output:ZodType }`으로 묶인 객체라는 점이 다릅니다. 그리고 connect 프로퍼티를 통해 viewPolicy와의 관계성을 명시할 수 있습니다. - -connect 프로퍼티는 `(intent의 결과물) => (view의 변화)[]` 꼴의 함수입니다. `intent의 결과물`은 다음과 같습니다: - - -- model의 input. 즉 요청을 보낼 때 필요한 값 -- model의 output. 요청의 응답으로 받을 값 -- Intent Policy의 의존성 - -`view의 변화`는 다음과 같습니다: - -- invalidate: 다시 서버에 요청을 보내 view의 값을 갱신합니다. -- map: 이전의 view를 받아 새로운 view를 반환하는 함수입니다. - -다음은 Todo의 content를 입력받아 Todo를 생성하고, 성공하면 todos라는 view에 새로 생성된 Todo를 하나 추가하면서 동시에 todoCounts라는 view를 값을 새로고침하는 정책(AddTodo)의 예시입니다. - -```ts -const input = Todo.pick({ content: true }); -const output = Todo; - -const IPAddTodo = PVI.intent(() => ({ - key: ["addTodo"], - model: { input, output }, - connect: ({ view, output }) => [ - view.todo.todos.map((prev) => [...prev, output]), - view.me.todoCounts.invalidate(), - ], -})); -``` - -### Usage with React - -정책을 모두 명시했으면, 리액트 컴포넌트 내에서 사용할 수 있습니다. PVI-React는 편하게 사용하기 위해 몇 가지 Hook을 기본으로 제공합니다. Hook은 policy를 선언한 후에 생성할 수 있습니다. - -```ts -import PVI from "@pvi/react"; -import { intentPolicy } from "./intentPolicy"; -import { viewPolicy } from "./viewPolicy"; - -export const { useView, useStaticView, useViewState } = - PVI.createViewHooks(viewPolicy); -export const { useIntent } = PVI.createIntentHooks(intentPolicy); - -export const policy = { view: viewPolicy, intent: intentPolicy }; -``` - -View Policy는 `useView` 또는 `useViewState`로 가져올 수 있습니다. useView는 React Suspense 환경에 적합하고, `useViewState`는 `useQuery`와 비슷한 방식으로 작동합니다. - -```tsx -export default function MyInformation() { - const { data } = useView((view) => ({ - policy: view.user.me(), - repository: UserRepository.me, - })); - return
{data.name}
; -} -``` - -여기서 repository는 해당 정책의 model에 대하여 `()=>Promise<{data: Model, context: unknown}>` 꼴의 함수를 의미합니다. context를 별도로 둔 이유는 정책의 흐름과 무관하지만 Repository의 작동을 위하 필요한 값(Pagination 관련 정보 등)을 임시로 저장해두기 위해서입니다. - -Intent Policy는 `useIntent`로 사용할 수 있습니다. Intent를 작동시키는 방법은 크게 두 가지입니다. 하나는 `input`을 입력하고, `submit`으로 해당 정보를 묶어서 요청을 보내는 것. 다른 하나는 `send`로 값을 입력함과 함께 요청을 보내는 것. form 형식의 화면에는 전자, 클릭 하나로 요청을 보내는 토글 등에는 후자가 적합합니다. 아래는 input과 submit으로 메시지를 입력 후 전송하는 컴포넌트의 예시입니다. - -```tsx -export default function MessageInput() { - const { - input: { values:{ text }, set }, - submit: sendMessage, - isValid, - } = useIntent((intent) => ({ - policy: intent.message.sendMessage, - repository: MessageRepository.sendMessage, - })); - - return ( -
{ - e.preventDefault(); - if (isValid) sendMessage() - } - > - 메시지 작성: - set({ text: e.target.value })} - /> - -
- ); -} -``` - - - diff --git a/library/pvi/react/adapter/react-query/configs.ts b/library/pvi/react/adapter/react-query/configs.ts deleted file mode 100644 index c02bd4e..0000000 --- a/library/pvi/react/adapter/react-query/configs.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; -export type SuspenseQueryConfigs = Pick< - Parameters[0], - "retry" | "staleTime" | "gcTime" ->; - -export type QueryConfigs = Pick< - Parameters[0], - "retry" | "staleTime" | "gcTime" ->; diff --git a/library/pvi/react/hooks/index.ts b/library/pvi/react/hooks/index.ts deleted file mode 100644 index 99e4459..0000000 --- a/library/pvi/react/hooks/index.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; -import { ZodAnyObject } from "@pvi/core/adapter/zod/types"; -import { IntentModel, IntentPolicyRecords } from "@pvi/core/types/intent"; -import { ViewModel, ViewPolicyRecords } from "@pvi/core/types/view"; -import { SuspenseQueryConfigs } from "../adapter/react-query/configs"; -import { - ViewHookParam, - ViewHookReturn, - StaticViewHookParam, - StaticViewHookReturn, - ViewStateHookParam, - ViewStateHookReturn, - IntentHookParam, - IntentHookReturn, -} from "../types/hooks"; -import { - AnyIntentPolicyDraftRecords, - AnyViewPolicyDraftRecords, -} from "../../core/types/typesAny"; -import { TypeOf, ZodType } from "zod"; - -const defaultViewQueryConfigs: SuspenseQueryConfigs = { - retry: 0, - staleTime: 1000, - gcTime: 0, -}; - -const defaultLocalViewQueryConfigs: SuspenseQueryConfigs = { - retry: 0, - staleTime: Infinity, - gcTime: Infinity, -}; - -const createViewHooks = < - Records extends ViewPolicyRecords, ->( - records: Records, -) => { - const recordsCache = records; - const useView = ( - param: ViewHookParam, - ): ViewHookReturn => { - const { policy, repository, queryOptions } = param(recordsCache); - const { data, isFetching } = useSuspenseQuery<{ - data: TypeOf; - context: unknown; - }>({ - queryKey: policy.key, - queryFn: async () => { - try { - const { data, context } = await repository(); - const parsedData = policy.model.parse(data); - return { data: parsedData, context }; - } catch (e) { - return Promise.reject(e); - } - }, - ...defaultViewQueryConfigs, - ...queryOptions, - }); - return { ...data, isUpdating: isFetching }; - }; - - const useStaticView = ( - param: StaticViewHookParam, - ): StaticViewHookReturn => { - const { policy, initialData, queryOptions } = param(recordsCache); - const { data } = useSuspenseQuery<{ - data: TypeOf; - context: unknown; - }>({ - queryKey: policy.key, - initialData: { data: initialData.data, context: initialData.context }, - ...defaultLocalViewQueryConfigs, - ...queryOptions, - }); - return { ...data }; - }; - - const useViewState = ( - param: ViewStateHookParam, - ): ViewStateHookReturn => { - const { policy, repository, queryOptions } = param(recordsCache); - const { data, error, isSuccess, isError, isFetching } = useQuery<{ - data: TypeOf; - context: unknown; - }>({ - queryKey: policy.key, - queryFn: async () => { - try { - const { data, context } = await repository(); - const parsedData = policy.model.parse(data); - return { data: parsedData, context }; - } catch (e) { - return Promise.reject(e); - } - }, - ...defaultViewQueryConfigs, - ...queryOptions, - }); - - const state: ViewStateHookReturn = useMemo(() => { - if (isError) - return { - status: "FAIL", - data: null, - context: null, - error, - isLoaded: false, - isFetching: false, - }; - if (isSuccess) { - if (isFetching) - return { - status: "UPDATING", - ...data, - error: null, - isLoaded: true, - isFetching: true, - }; - return { - status: "SUCCESS", - ...data, - error: null, - isLoaded: true, - isFetching: false, - }; - } - if (isFetching) - return { - status: "LOADING", - data: null, - context: null, - error: null, - isFetching: true, - isLoaded: false, - }; - return { - status: "IDLE", - data: null, - context: null, - error: null, - isFetching: false, - isLoaded: false, - }; - }, [isError, isSuccess, isFetching, data, error]); - - return state; - }; - - return { useView, useStaticView, useViewState }; -}; - -const createIntentHooks = < - Records extends IntentPolicyRecords< - ViewPolicyRecords, - AnyIntentPolicyDraftRecords> - >, ->( - records: Records, -) => { - const recordsCache = records; - const useIntent = >( - param: IntentHookParam, - ): IntentHookReturn => { - type Intent = IntentHookReturn; - const { policy, repository, placeholder } = param(recordsCache); - const [value, setValue] = useState( - placeholder ?? {}, - ); - - const set: Intent["input"]["set"] = (value) => - setValue((prev) => ({ ...prev, ...value })); - - const submit: Intent["submit"] = async () => { - try { - const input = policy.model.input.parse(value); - setValue(placeholder ?? {}); - const response = await repository(input); - const output = policy.model.output.parse(response); - if (policy.connect) - await Promise.all(policy.connect({ input, output })); - return output; - } catch (e) { - return Promise.reject(e); - } - }; - - const send: Intent["send"] = async (request) => { - try { - const input = policy.model.input.parse(request); - const response = await repository(input); - const output = policy.model.output.parse(response); - if (policy.connect) { - await Promise.all(policy.connect({ input, output })); - } - return output; - } catch (e) { - return Promise.reject(e); - } - }; - - const isValid = policy.model.input.safeParse(value).success; - - return { - input: { value, set }, - isValid, - submit: submit, - send, - }; - }; - return { useIntent }; -}; - -export { createViewHooks, createIntentHooks }; diff --git a/library/pvi/react/index.ts b/library/pvi/react/index.ts deleted file mode 100644 index 9567ada..0000000 --- a/library/pvi/react/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { integrateWithReact } from "./integrate"; - -export { integrateWithReact }; diff --git a/library/pvi/react/integrate/index.ts b/library/pvi/react/integrate/index.ts deleted file mode 100644 index f960e16..0000000 --- a/library/pvi/react/integrate/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - AnyIntentPolicyDraftRecords, - AnyViewPolicyDraftRecords, -} from "@pvi/core/types/typesAny"; -import { createIntentHooks, createViewHooks } from "../hooks"; -import { IntegrateParam, IntegrateReturn } from "../types/integrate"; -import { ViewModel, ViewPolicyRecords } from "@pvi/core/types/view"; -import { IntentPolicyRecords } from "@pvi/core/types/intent"; - -export const integrateWithReact = < - ViewPolicyDrafts extends AnyViewPolicyDraftRecords, - IntentPolicyDrafts extends AnyIntentPolicyDraftRecords< - ViewPolicyRecords - >, ->({ - viewPolicy, - intentPolicy, - queryClient, -}: IntegrateParam): IntegrateReturn< - ViewPolicyDrafts, - IntentPolicyDrafts -> => { - const store: Parameters[0] = { - mapper: (key) => (mapper) => { - const prev = queryClient.getQueryData(key) as { data: ViewModel }; - const next = { ...prev, data: mapper(prev.data) }; - return Promise.resolve(queryClient.setQueryData(key, next)); - }, - invalidater: (key) => () => - queryClient.invalidateQueries({ queryKey: key }), - }; - const injectedViewPolicy = Object.entries(viewPolicy).reduce( - (root, [entityName, policies]) => { - const injectedPolicies = Object.entries(policies).reduce( - (rootPolicies, [key, policyDraft]) => { - const policy = policyDraft(store); - return { ...rootPolicies, [key]: policy }; - }, - {}, - ); - const mergedRoot = { ...root, [entityName]: injectedPolicies }; - return mergedRoot; - }, - {} as ViewPolicyRecords, - ); - const injectedIntentPolicy = Object.entries(intentPolicy).reduce( - (root, [entityName, policies]) => { - const injectedPolicies = Object.entries(policies).reduce( - (rootPolicies, [key, policyDraft]) => { - const policy = policyDraft(injectedViewPolicy); - return { ...rootPolicies, [key]: policy }; - }, - {}, - ); - const mergedRoot = { ...root, [entityName]: injectedPolicies }; - return mergedRoot; - }, - {} as IntentPolicyRecords< - ViewPolicyRecords, - IntentPolicyDrafts - >, - ); - const viewHooks = createViewHooks(injectedViewPolicy); - const intentHooks = createIntentHooks(injectedIntentPolicy); - - return { - policy: { view: injectedViewPolicy, intent: injectedIntentPolicy }, - hooks: { ...viewHooks, ...intentHooks }, - }; -}; diff --git a/library/pvi/react/package.json b/library/pvi/react/package.json deleted file mode 100644 index e7dc99d..0000000 --- a/library/pvi/react/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@pvi/react", - "version": "0.0.0", - "type": "module", - "main": "index.ts", - "peerDependencies": { - "@pvi/core": "workspace:^", - "@tanstack/react-query": "^5.14.1", - "react": "^18.2.0" - } -} diff --git a/library/pvi/react/types/hooks.ts b/library/pvi/react/types/hooks.ts deleted file mode 100644 index 6d1340b..0000000 --- a/library/pvi/react/types/hooks.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - QueryConfigs, - SuspenseQueryConfigs, -} from "../adapter/react-query/configs"; -import { ZodAnyObject } from "../../core/adapter/zod/types"; -import { ViewModel, ViewPolicy, ViewPolicyRecords } from "@pvi/core/types/view"; -import { - IntentModel, - IntentPolicy, - IntentPolicyRecords, -} from "@pvi/core/types/intent"; - -import { TypeOf, ZodType } from "zod"; -import { ViewStateEnum } from "./stateEnum"; -import { - AnyIntentPolicyDraftRecords, - AnyViewPolicyDraftRecords, -} from "@pvi/core/types/typesAny"; - -/* - * View - */ - -// useView -export type ViewHookParam< - Records extends ViewPolicyRecords, - Model extends ViewModel, -> = (view: Records) => { - policy: ReturnType>; - repository: () => Promise<{ - data: TypeOf; - context?: unknown; - }>; - queryOptions?: SuspenseQueryConfigs; -}; - -export type ViewHookReturn = { - data: TypeOf; - context?: unknown; - isUpdating: boolean; -}; - -export type ViewHook< - Records extends ViewPolicyRecords, -> = ( - param: ViewHookParam, -) => ViewHookReturn; - -// useStaticView -export type StaticViewHookParam< - Records extends ViewPolicyRecords, - Model extends ViewModel, -> = (view: Records) => { - policy: ReturnType>; - initialData: { data: TypeOf; context?: unknown }; - queryOptions?: SuspenseQueryConfigs; -}; - -export type StaticViewHookReturn = { - data: TypeOf; - context?: unknown; -}; -export type StaticViewHook< - Records extends ViewPolicyRecords, -> = ( - param: StaticViewHookParam, -) => StaticViewHookReturn; - -// useViewState -export type ViewStateHookParam< - Records extends ViewPolicyRecords, - Model extends ViewModel, -> = (view: Records) => { - policy: ReturnType>; - repository: () => Promise<{ - data: TypeOf; - context?: unknown; - }>; - queryOptions?: QueryConfigs; -}; - -export type ViewStateHookReturn = - ViewStateEnum & { - context?: unknown; - }; - -export type ViewStateHook< - Records extends ViewPolicyRecords, -> = ( - param: ViewStateHookParam, -) => ViewStateHookReturn; - -/* - * Intent - */ -export type IntentHookParam< - Records extends IntentPolicyRecords< - ViewPolicyRecords, - AnyIntentPolicyDraftRecords> - >, - Model extends IntentModel, -> = (intent: Records) => { - policy: ReturnType< - IntentPolicy - >; - repository: ( - input: TypeOf, - ) => Promise>; - placeholder?: Partial>; -}; -export type IntentHookReturn> = - { - input: { - value: Partial>; - set: (value: Partial>) => void; - }; - submit: () => Promise>; - send: (request: TypeOf) => Promise>; - isValid: boolean; - }; -export type IntentHook< - Records extends IntentPolicyRecords< - ViewPolicyRecords, - AnyIntentPolicyDraftRecords> - >, -> = >( - param: IntentHookParam, -) => IntentHookReturn; diff --git a/library/pvi/react/types/integrate.ts b/library/pvi/react/types/integrate.ts deleted file mode 100644 index 15efee6..0000000 --- a/library/pvi/react/types/integrate.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import { - AnyIntentPolicyDraftRecords, - AnyViewPolicyDraftRecords, -} from "@pvi/core/types/typesAny"; -import { IntentHook, StaticViewHook, ViewHook, ViewStateHook } from "./hooks"; -import { ViewPolicyRecords } from "@pvi/core/types/view"; -import { IntentPolicyRecords } from "@pvi/core/types/intent"; - -export type IntegrateParam< - ViewPolicyDrafts extends AnyViewPolicyDraftRecords, - IntentPolicyDrafts extends AnyIntentPolicyDraftRecords< - ViewPolicyRecords - >, -> = { - viewPolicy: ViewPolicyDrafts; - intentPolicy: IntentPolicyDrafts; - queryClient: QueryClient; -}; - -export type IntegrateReturn< - ViewPolicyDrafts extends AnyViewPolicyDraftRecords, - IntentPolicyDrafts extends AnyIntentPolicyDraftRecords< - ViewPolicyRecords - >, -> = { - policy: { - view: ViewPolicyRecords; - intent: IntentPolicyRecords< - ViewPolicyRecords, - IntentPolicyDrafts - >; - }; - hooks: { - useView: ViewHook>; - useViewState: ViewStateHook>; - useStaticView: StaticViewHook>; - useIntent: IntentHook< - IntentPolicyRecords< - ViewPolicyRecords, - IntentPolicyDrafts - > - >; - }; -}; diff --git a/library/pvi/react/types/stateEnum.ts b/library/pvi/react/types/stateEnum.ts deleted file mode 100644 index 6420d64..0000000 --- a/library/pvi/react/types/stateEnum.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TypeOf } from "zod"; -import { ViewModel } from "../../core/types/view"; - -export type ViewStateEnum = - | { - status: "IDLE"; - data: null; - error: null; - isLoaded: false; - isFetching: false; - } - | { - status: "LOADING"; - data: null; - error: null; - isLoaded: false; - isFetching: true; - } - | { - status: "SUCCESS"; - data: TypeOf; - error: null; - isLoaded: true; - isFetching: false; - } - | { - status: "FAIL"; - data: null; - error: unknown; - isLoaded: false; - isFetching: false; - } - | { - status: "UPDATING"; - data: TypeOf; - error: null; - isLoaded: true; - isFetching: true; - }; diff --git a/server/src/index.ts b/server/src/index.ts index 1ad3e9b..b8521c3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,7 +4,6 @@ import express from "express"; import user from "./routers/users"; import post from "./routers/posts"; import article from "./routers/articles"; -import test from "./routers/test"; // create express app const app = express(); @@ -20,7 +19,6 @@ app.get("/", (_, res) => { }); // routes -app.use("/tests", test); app.use("/users", user); app.use("/posts", post); app.use("/articles", article); diff --git a/server/src/repository/users/index.ts b/server/src/repository/users/index.ts index 11586ad..b2f81bc 100644 --- a/server/src/repository/users/index.ts +++ b/server/src/repository/users/index.ts @@ -1,16 +1,14 @@ import { ID } from "@core/constant/common/id"; import { z } from "zod"; import { as, oneLine } from "../util"; -import { userTypes } from "./userTypes"; export const UserSchema = z.object({ id: ID.USER, - userType: userTypes.schema.shape.value, + userTypeId: z.number(), email: z.string(), name: z.string(), bio: z.string().nullable(), thumbnailImage: z.string().nullable(), - backgroundImage: z.string().nullable(), }); export type UserSchema = z.infer; diff --git a/server/src/routers/articles/index.ts b/server/src/routers/articles/index.ts index d425894..d7f2dda 100644 --- a/server/src/routers/articles/index.ts +++ b/server/src/routers/articles/index.ts @@ -13,13 +13,13 @@ router.get( wrap(async (req, res) => { const articleId = Number(req.params.articleId); - const article = await db.select.byId({ + const article = await db.select.byId({ from: "articles", schema: ArticleSchema, id: articleId, }); - const { content } = await db.select.byIdRaw({ + const { content } = await db.select.byIdRaw({ from: "articleContents", schema: ArticleContentSchema, id: articleId, @@ -32,7 +32,9 @@ router.get( router.post( "/", wrap(async (req, res) => { + console.log(req); const { content } = z.object({ content: z.string() }).parse(req.body); + const insertedId = await ( await connection ) diff --git a/server/src/routers/posts/index.ts b/server/src/routers/posts/index.ts index 2d9e734..4b868b3 100644 --- a/server/src/routers/posts/index.ts +++ b/server/src/routers/posts/index.ts @@ -1,19 +1,15 @@ -import { toPostType } from "@/schema/posts/enum"; -import { PostSchema } from "@/schema/posts"; -import { connection } from "@/utils/db/init"; -import { obj } from "@/utils/operator/obj"; -import { sql } from "@/utils/db/sql"; +import { PostCommentSchema, PostSchema } from "@/schema/posts"; import wrap from "@/utils/wrap"; import { PostApi } from "@core/api/post"; -import { UserSummary } from "@core/entity/user/summary"; import { Router } from "express"; import { arr } from "@/utils/operator/arr"; -import { RowDataPacket } from "mysql2"; import db from "@/utils/db/manipulate"; +import { obj } from "@/utils/operator/obj"; +import { toPostType } from "@/schema/posts/enum"; const router = Router(); -const { getPosts, getPost, getPostComments, postPost } = PostApi; +const { getPosts, postPost } = PostApi; /* * GET "/posts" @@ -31,100 +27,112 @@ router.get( pageNumber, }); + if (posts.length === 0) { + res.json([]); + return; + } + const ids = posts.map(({ id }) => id); const postLikesCounts = await db.count.byIds({ from: "postLikes", key: "postId", as: "likesCount", - targets: ids, + ids, }); const postCommentsCounts = await db.count.byIds({ from: "postComments", key: "postId", as: "commentsCount", - targets: ids, + ids, }); - const joined = arr.joinById(posts, postLikesCounts, postCommentsCounts); + const joined = arr + .joinById(posts, postLikesCounts, postCommentsCounts) + .map(obj.mapKey("postTypeId", toPostType, "postType")); res.json(joined); }), ); /* - * getPost + * GET "/posts/:postId" */ router.get( "/:postId", wrap(async (req, res) => { - const { params } = getPost.server.parseRequest(req); - - /* - * TABLE: posts - */ - const postsResult = await ( - await connection - ) - .execute( - ` - SELECT ${sql.pick("posts", Object.keys(PostSchema.shape))}, ${sql.pick("users", Object.keys(UserSummary.shape), "createdUser")} - FROM posts - ${sql.joinById("users", "posts.createdUserId")} - WHERE posts.id = ${params.postId} - `, - ) - .then(([data]) => - data - .map(obj.group("createdUser")) - .map((row) => - PostSchema.omit({ createdUserId: true }) - .extend({ createdUser: UserSummary }) - .parse(row), - ), - ) - .then((data) => - data.map(obj.mapKey("postTypeId", toPostType, "postType")), - ); - - if (postsResult.length === 0) { - throw new Error("Post not found"); - } + const postId = Number(req.params.postId); + + const post = await db.select + .byId({ + from: "posts", + schema: PostSchema, + id: postId, + }) + .then(obj.mapKey("postTypeId", toPostType, "postType")); - res.json({ ...postsResult }); + const likesCount = await db.count.byId({ + from: "postLikes", + key: "postId", + id: postId, + }); + + const commentsCount = await db.count.byId({ + from: "postComments", + key: "postId", + id: postId, + }); + + res.json({ ...post, likesCount, commentsCount }); }), ); /* - * getPosts + * GET "/posts/:postId/comments" */ router.get( - getPostComments.server.endpoint[1], + "/:postId/comments", wrap(async (req, res) => { const postId = Number(req.params.postId); - const post = await db.select.byId({ + const comments = await db.select.page({ from: "postComments", - schema: PostSchema, - id: postId, + schema: PostCommentSchema, + pageNumber: 1, + where: `postId = ${postId}`, }); - res.json(post); - }), -); + if (comments.length === 0) { + res.json([]); + return; + } -/* - * postPost - */ -router.post( - postPost.server.endpoint[1], - wrap(async (req, res) => { - const { body } = postPost.server.parseRequest(req); - if (!body) throw new Error("Invalid body"); + const ids = comments.map(({ id }) => id); + + const commentLikesCounts = await db.count.byIds({ + from: "postCommentLikes", + key: "postCommentId", + as: "likesCount", + ids, + }); + + const joined = arr.joinById(comments, commentLikesCounts); - res.json({ body }); + res.json(joined); }), -); +), + /* + * postPost + */ + router.post( + postPost.server.endpoint[1], + wrap(async (req, res) => { + const { body } = postPost.server.parseRequest(req); + if (!body) throw new Error("Invalid body"); + + res.json({ body }); + }), + ); export default router; diff --git a/server/src/routers/test.ts b/server/src/routers/test.ts deleted file mode 100644 index 2ec67b3..0000000 --- a/server/src/routers/test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import wrap from "@/utils/wrap"; -import { Router } from "express"; - -const router = Router(); - -router.get( - "", - wrap(async (_, res) => { - const result = ["test1", "test2", "test3"]; - res.json({ data: result }); - }), -); - -export default router; diff --git a/server/src/routers/users/index.ts b/server/src/routers/users/index.ts index ca8dcfd..4ba2262 100644 --- a/server/src/routers/users/index.ts +++ b/server/src/routers/users/index.ts @@ -1,91 +1,110 @@ -import { UserSchema, users } from "@/repository/users"; +import { UserSchema } from "@/repository/users"; import { connection } from "@/utils/db/init"; import wrap from "@/utils/wrap"; -import { UserApi } from "@core/api/user"; import { Router } from "express"; +import { PostLoginBody } from "@core/dto/user/request"; import * as jwt from "jsonwebtoken"; +import db from "@/utils/db/manipulate"; +import { PostRegisterBody } from "../../../../core/dto/user/request"; const router = Router(); -const { getMe, getUserById, postSignIn } = UserApi; - +/* + * GET "/users" + */ router.get( "", wrap(async (_, res) => { const [result] = await ( await connection ).execute(` - SELECT users.id, name, bio, thumbnailImage, backgroundImage, userTypes.value AS userType + SELECT id, name, bio, thumbnailImage FROM users - LEFT JOIN userTypes ON users.userTypeId = userTypes.id `); res.json({ data: result }); }), ); +/* + * GET "/users/me" + */ router.get( - getMe.server.endpoint[1], + "/me", wrap(async (req, res) => { const token = req.headers.authorization; - const sliced = token?.slice(7); - const userId = jwt.verify(sliced ?? "", process.env.JWT_SECRET as string); - const [result] = await ( - await connection - ).execute(` - SELECT ${users.SELECT()} - FROM users - ${users.JOIN()} - WHERE users.id = ${userId} - `); - res.json({ data: result }); + if (!token) throw new Error("Token is empty"); + + const userId = Number( + jwt.verify(token.slice(7), process.env.JWT_SECRET as string), + ); + + const user = await db.select.byIdRaw({ + from: "users", + schema: UserSchema, + id: userId, + }); + + res.json(user); }), ); +/* + * GET "/users/:userId" + */ router.get( - getUserById.server.endpoint[1], + "/:userId", wrap(async (req, res) => { - const [result] = await ( - await connection - ).execute(` - SELECT ${users.SELECT()} - FROM users - ${users.JOIN()} - WHERE users.id = ${req.params.userId} - `); - res.json({ data: result }); + const userId = Number(req.params.userId); + + const user = await db.select.byIdRaw({ + from: "users", + schema: UserSchema, + id: userId, + }); + + res.json(user); }), ); +/* + * POST "/users/login" + */ router.post( - postSignIn.server.endpoint[1], + "/login", wrap(async (req, res) => { - const body = postSignIn.server.parseRequest(req).body; + const body = PostLoginBody.parse(req.body); if (!body) throw new Error("Body is empty"); const { email, password } = body; - const result = await ( - await connection - ) - .execute( - ` - SELECT ${users.SELECT()} - FROM users - ${users.JOIN()} - WHERE email = '${email}' AND password = '${password}' - `, - ) - .then(([result]) => result as any[]) - .then(([data]) => data as any[]) - .then(UserSchema.parse); + const result = await db.select.oneRaw({ + from: "users", + schema: UserSchema, + where: `email = "${email}" AND password = "${password}"`, + }); + + if (!result) throw new Error("User not found"); - if (result) { - const token = jwt.sign( - result.id.toString(), - process.env.JWT_SECRET as string, - ); - res.json({ data: { ...result, token } }); - } else { - res.status(404).json({ message: "Not Found" }); - } + const token = jwt.sign( + result.id.toString(), + process.env.JWT_SECRET as string, + ); + + res.json({ ...result, token }); + }), +); + +/* + * POST "/users/register" + */ +router.post( + "/register", + wrap(async (req, res) => { + const { email, password, name } = PostRegisterBody.parse(req.body); + await ( + await connection + ).execute(` + INSERT INTO users(email, password, name) VALUES ("${email}", "${password}", "${name}") + `); + res.send("Register Success"); }), ); diff --git a/server/src/schema/posts/index.ts b/server/src/schema/posts/index.ts index b442454..0a9bbd6 100644 --- a/server/src/schema/posts/index.ts +++ b/server/src/schema/posts/index.ts @@ -10,5 +10,12 @@ export const PostSchema = z viewsCount: z.number(), }) .merge(CreatableSchema); - export type PostSchema = z.infer; + +export const PostCommentSchema = z + .object({ + id: z.number(), + content: z.string(), + }) + .merge(CreatableSchema); +export type PostCommentSchema = z.infer; diff --git a/server/src/schema/users/index.ts b/server/src/schema/users/index.ts index 50599cc..28bc631 100644 --- a/server/src/schema/users/index.ts +++ b/server/src/schema/users/index.ts @@ -1,5 +1,14 @@ import { z } from "zod"; +export const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + bio: z.string().nullable(), + thumbnailImage: z.string().nullable(), +}); +export type UserSchema = z.infer; + export const UserSummarySchema = z.object({ id: z.number(), name: z.string(), diff --git a/server/src/utils/db/manipulate/count.ts b/server/src/utils/db/manipulate/count.ts index 94f005e..61b44b4 100644 --- a/server/src/utils/db/manipulate/count.ts +++ b/server/src/utils/db/manipulate/count.ts @@ -4,23 +4,29 @@ import { connection } from "../init"; import { obj } from "@/utils/operator/obj"; import { z } from "zod"; -const byIds = async ({ +const byIds = async ({ from, key, as, - targets, + ids, }: { from: string; key: string; - as: string; - targets: number[]; + as: AS; + ids: number[]; }) => { + console.log(` + SELECT ${key}, count(*) AS ${as} from ${from} + WHERE ${sql.inArray(key, ids)} + GROUP BY ${key} + `); return (await connection) .execute( ` - SELECT ${key}, count(*) AS ${as} from ${from} where ${sql.inArray(key, targets)} - GROUP BY ${key} - `, + SELECT ${key}, count(*) AS ${as} from ${from} + WHERE ${sql.inArray(key, ids)} + GROUP BY ${key} + `, ) .then(([data]) => data @@ -31,6 +37,32 @@ const byIds = async ({ ); }; +const byId = async ({ + from, + key, + id, +}: { + from: string; + key: string; + id: number; +}) => { + return (await connection) + .execute( + ` + SELECT ${key}, count(*) AS counts from ${from} + WHERE ${key} = ${id} + GROUP BY ${key} + `, + ) + .then(([data]) => { + if (data.length === 0) return 0; + if (data.length > 2) throw new Error("Too many results"); + const { counts } = z.object({ counts: z.number() }).parse(data[0]); + return counts; + }); +}; + export const _db_count = { byIds, + byId, }; diff --git a/server/src/utils/db/manipulate/select.ts b/server/src/utils/db/manipulate/select.ts index 54babfe..3b584c5 100644 --- a/server/src/utils/db/manipulate/select.ts +++ b/server/src/utils/db/manipulate/select.ts @@ -9,11 +9,12 @@ const page = async >({ from, schema, pageNumber = 1, + where, }: { from: string; schema: Schema; pageNumber?: number; - isNotCreatable?: boolean; + where?: string; }) => { return (await connection) .execute( @@ -21,6 +22,7 @@ const page = async >({ SELECT ${sql.pick(from, Object.keys(schema.shape))}, ${sql.pick("users", Object.keys(UserSummarySchema.shape), "createdUser")} FROM ${from} ${sql.joinById("users", `${from}.createdUserId`)} + ${where ? `WHERE ${where}` : ""} LIMIT ${(pageNumber - 1) * 20}, 20 `, ) @@ -67,6 +69,32 @@ const byId = async >({ }); }; +const oneRaw = async >({ + from, + schema, + where, +}: { + from: string; + schema: SchemaRaw; + where: string; +}) => { + return (await connection) + .execute( + ` + SELECT ${sql.pick(from, Object.keys(schema.shape))} + FROM ${from} + WHERE ${where} + `, + ) + .then(([data]) => { + if (data.length === 0) throw new Error("Not found"); + if (data.length > 2) throw new Error("Too many results"); + return Promise.resolve(data[0]) + .then(obj.group("createdUser")) + .then(schema.parse); + }); +}; + const byIdRaw = async >({ from, schema, @@ -91,4 +119,4 @@ const byIdRaw = async >({ }); }; -export const _db_select = { page, byId, byIdRaw }; +export const _db_select = { page, byId, oneRaw, byIdRaw }; diff --git a/yarn.lock b/yarn.lock index a9d227f..b2e4fb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -689,9 +689,9 @@ __metadata: languageName: node linkType: hard -"@policy-maker-2/core@workspace:^, @policy-maker-2/core@workspace:library/policy-maker-2/core": +"@policy-maker/core@workspace:^, @policy-maker/core@workspace:library/policy-maker/core": version: 0.0.0-use.local - resolution: "@policy-maker-2/core@workspace:library/policy-maker-2/core" + resolution: "@policy-maker/core@workspace:library/policy-maker/core" dependencies: nanoid: ^5.0.7 peerDependencies: @@ -699,36 +699,25 @@ __metadata: languageName: unknown linkType: soft -"@policy-maker-2/react@workspace:library/policy-maker-2/react": +"@policy-maker/next@workspace:^, @policy-maker/next@workspace:library/policy-maker/next": version: 0.0.0-use.local - resolution: "@policy-maker-2/react@workspace:library/policy-maker-2/react" + resolution: "@policy-maker/next@workspace:library/policy-maker/next" peerDependencies: - "@policy-maker-2/core": "workspace:*" + "@policy-maker/core": "workspace:*" react: ^18.0.0 react-dom: ^18.0.0 zod: ^3.23.0 languageName: unknown linkType: soft -"@policy-maker/core@workspace:library/policy-maker/core": - version: 0.0.0-use.local - resolution: "@policy-maker/core@workspace:library/policy-maker/core" - peerDependencies: - zod: ^3.23.5 - languageName: unknown - linkType: soft - "@policy-maker/react@workspace:library/policy-maker/react": version: 0.0.0-use.local resolution: "@policy-maker/react@workspace:library/policy-maker/react" - dependencies: - fast-deep-equal: ^3.1.3 - use-debounce: ^10.0.0 peerDependencies: - "@policy-maker/core": "workspace:^" - "@tanstack/react-query": ^5.28.9 - react: ^18.2.0 - zod: ^3.23.5 + "@policy-maker/core": "workspace:*" + react: ^18.0.0 + react-dom: ^18.0.0 + zod: ^3.23.0 languageName: unknown linkType: soft @@ -739,24 +728,6 @@ __metadata: languageName: node linkType: hard -"@pvi/core@workspace:^, @pvi/core@workspace:library/pvi/core": - version: 0.0.0-use.local - resolution: "@pvi/core@workspace:library/pvi/core" - dependencies: - zod: ^3.22.4 - languageName: unknown - linkType: soft - -"@pvi/react@workspace:^, @pvi/react@workspace:library/pvi/react": - version: 0.0.0-use.local - resolution: "@pvi/react@workspace:library/pvi/react" - peerDependencies: - "@pvi/core": "workspace:^" - "@tanstack/react-query": ^5.14.1 - react: ^18.2.0 - languageName: unknown - linkType: soft - "@rushstack/eslint-patch@npm:^1.1.3": version: 1.10.2 resolution: "@rushstack/eslint-patch@npm:1.10.2" @@ -1642,8 +1613,8 @@ __metadata: "@mui/icons-material": ^5.14.12 "@mui/material": ^5.14.9 "@mui/styles": ^5.15.13 - "@pvi/core": "workspace:^" - "@pvi/react": "workspace:^" + "@policy-maker/core": "workspace:^" + "@policy-maker/next": "workspace:^" "@tanstack/react-query": ^5.4.3 "@types/node": 20.6.0 "@types/react": 18.2.21 @@ -1786,8 +1757,7 @@ __metadata: version: 0.0.0-use.local resolution: "core@workspace:core" dependencies: - "@policy-maker-2/core": "workspace:^" - "@pvi/core": "workspace:^" + "@policy-maker/core": "workspace:^" fetch: "workspace:^" qs: ^6.12.1 zod: ^3.23.8 @@ -5749,15 +5719,6 @@ __metadata: languageName: node linkType: hard -"use-debounce@npm:^10.0.0": - version: 10.0.0 - resolution: "use-debounce@npm:10.0.0" - peerDependencies: - react: ">=16.8.0" - checksum: b296fedba916ca721eeb7117f29a13c252966d03411abccc5e5894ed4de0f32b9cb6f6b150555b8b4cb9b0acf53b84022ad4cd43b89253389e9cd13779eebca7 - languageName: node - linkType: hard - "utils-merge@npm:1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" @@ -5959,13 +5920,6 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4": - version: 3.22.4 - resolution: "zod@npm:3.22.4" - checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f - languageName: node - linkType: hard - "zod@npm:^3.23.8": version: 3.23.8 resolution: "zod@npm:3.23.8"