From 81f2cb8a5043a8b0a31818aef70ab2b6aa96568c Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 18 Jan 2025 16:30:30 +0100 Subject: [PATCH 01/12] initial convert Blog to vertical --- api/src/Blog.controllers.ts | 87 ----------------------------------- api/src/Blog/Create.ts | 34 ++++++++++++++ api/src/Blog/Find.ts | 29 ++++++++++++ api/src/Blog/List.ts | 26 +++++++++++ api/src/Blog/Publish.ts | 74 +++++++++++++++++++++++++++++ api/src/controllers.ts | 3 +- api/src/lib/handler.ts | 21 +++++++++ api/src/resources/Blog.ts | 33 +++---------- frontend/pages/blog/[id].vue | 4 +- frontend/pages/blog/index.vue | 4 +- 10 files changed, 195 insertions(+), 120 deletions(-) delete mode 100644 api/src/Blog.controllers.ts create mode 100644 api/src/Blog/Create.ts create mode 100644 api/src/Blog/Find.ts create mode 100644 api/src/Blog/List.ts create mode 100644 api/src/Blog/Publish.ts create mode 100644 api/src/lib/handler.ts diff --git a/api/src/Blog.controllers.ts b/api/src/Blog.controllers.ts deleted file mode 100644 index 7218316fc..000000000 --- a/api/src/Blog.controllers.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { matchFor, Router } from "#api/lib/routing" -import { BlogPostRepo, Events, Operations, UserRepo } from "#api/services" -import { BlogPost } from "#models/Blog" -import { BlogRsc } from "#resources" -import { BogusEvent } from "#resources/Events" -import { Duration, Effect, Schedule } from "effect" -import { Option } from "effect-app" -import { NonEmptyString2k, NonNegativeInt } from "effect-app/Schema" -import { OperationsDefault } from "./lib/layers.js" - -export default Router(BlogRsc)({ - dependencies: [ - BlogPostRepo.Default, - UserRepo.Default, - OperationsDefault, - Events.Default - ], - effect: Effect.gen(function*() { - const blogPostRepo = yield* BlogPostRepo - const userRepo = yield* UserRepo - const events = yield* Events - const operations = yield* Operations - - return matchFor(BlogRsc)({ - FindPost: (req) => - blogPostRepo - .find(req.id) - .pipe(Effect.andThen(Option.getOrNull)), - GetPosts: blogPostRepo - .all - .pipe(Effect.andThen((items) => ({ items }))), - CreatePost: (req) => - userRepo - .getCurrentUser - .pipe( - Effect.andThen((author) => (new BlogPost({ ...req, author }, true))), - Effect.tap(blogPostRepo.save) - ), - PublishPost: (req) => - Effect.gen(function*() { - const post = yield* blogPostRepo.get(req.id) - - console.log("publishing post", post) - - const targets = [ - "google", - "twitter", - "facebook" - ] - - const done: string[] = [] - - const op = yield* operations.fork( - (opId) => - operations - .update(opId, { - total: NonNegativeInt(targets.length), - completed: NonNegativeInt(done.length) - }) - .pipe( - Effect.andThen(Effect.forEach(targets, (_) => - Effect - .sync(() => done.push(_)) - .pipe( - Effect.tap(() => - operations.update(opId, { - total: NonNegativeInt(targets.length), - completed: NonNegativeInt(done.length) - }) - ), - Effect.delay(Duration.seconds(4)) - ))), - Effect.andThen(() => "the answer to the universe is 41") - ), - // while operation is running... - (_opId) => - Effect - .suspend(() => events.publish(new BogusEvent())) - .pipe(Effect.schedule(Schedule.spaced(Duration.seconds(1)))), - NonEmptyString2k("post publishing") - ) - - return op.id - }) - }) - }) -}) diff --git a/api/src/Blog/Create.ts b/api/src/Blog/Create.ts new file mode 100644 index 000000000..c17d9ec50 --- /dev/null +++ b/api/src/Blog/Create.ts @@ -0,0 +1,34 @@ +import { handlerFor } from "#api/lib/handler" +import { OperationsDefault } from "#api/lib/layers" +import { S } from "#api/resources/lib" +import { BlogPostRepo, Events, UserRepo } from "#api/services" +import { BlogPost, BlogPostId } from "#models/Blog" +import { Effect } from "effect-app" +import { InvalidStateError, NotFoundError, OptimisticConcurrencyException } from "effect-app/client" + +export class Request extends S.Req()("Blog.CreatePost", BlogPost.pick("title", "body"), { + allowRoles: ["user"], + success: S.Struct({ id: BlogPostId }), + failure: S.Union(NotFoundError, InvalidStateError, OptimisticConcurrencyException) +}) {} + +export default handlerFor(Request)({ + dependencies: [ + BlogPostRepo.Default, + UserRepo.Default, + OperationsDefault, + Events.Default + ], + effect: Effect.gen(function*() { + const blogPostRepo = yield* BlogPostRepo + const userRepo = yield* UserRepo + + return (req) => + userRepo + .getCurrentUser + .pipe( + Effect.andThen((author) => (new BlogPost({ ...req, author }, true))), + Effect.tap(blogPostRepo.save) + ) + }) +}) diff --git a/api/src/Blog/Find.ts b/api/src/Blog/Find.ts new file mode 100644 index 000000000..536c68a32 --- /dev/null +++ b/api/src/Blog/Find.ts @@ -0,0 +1,29 @@ +import { handlerFor } from "#api/lib/handler" +import { S } from "#api/resources/lib" +import { BlogPostView } from "#api/resources/views" +import { BlogPostRepo } from "#api/services" +import { BlogPostId } from "#models/Blog" +import { Effect } from "effect" +import { Option } from "effect-app" + +export class Request extends S.Req()("Blog.FindPost", { + id: BlogPostId +}, { + allowAnonymous: true, + allowRoles: ["user"], + success: S.NullOr(BlogPostView) +}) {} + +export default handlerFor(Request)({ + dependencies: [ + BlogPostRepo.Default + ], + effect: Effect.gen(function*() { + const blogPostRepo = yield* BlogPostRepo + + return (req) => + blogPostRepo + .find(req.id) + .pipe(Effect.andThen(Option.getOrNull)) + }) +}) diff --git a/api/src/Blog/List.ts b/api/src/Blog/List.ts new file mode 100644 index 000000000..ec642422d --- /dev/null +++ b/api/src/Blog/List.ts @@ -0,0 +1,26 @@ +import { handlerFor } from "#api/lib/handler" +import { S } from "#api/resources/lib" +import { BlogPostView } from "#api/resources/views" +import { BlogPostRepo } from "#api/services" +import { Effect } from "effect-app" + +export class Request extends S.Req()("Blog.List", {}, { + allowAnonymous: true, + allowRoles: ["user"], + success: S.Struct({ + items: S.Array(BlogPostView) + }) +}) {} + +export default handlerFor(Request)({ + dependencies: [ + BlogPostRepo.Default + ], + effect: Effect.gen(function*() { + const blogPostRepo = yield* BlogPostRepo + + return blogPostRepo + .all + .pipe(Effect.andThen((items) => ({ items }))) + }) +}) diff --git a/api/src/Blog/Publish.ts b/api/src/Blog/Publish.ts new file mode 100644 index 000000000..97b4cbaf0 --- /dev/null +++ b/api/src/Blog/Publish.ts @@ -0,0 +1,74 @@ +import { handlerFor } from "#api/lib/handler" +import { OperationsDefault } from "#api/lib/layers" +import { BogusEvent } from "#api/resources/Events" +import { S } from "#api/resources/lib" +import { BlogPostRepo, Events, Operations } from "#api/services" +import { BlogPostId } from "#models/Blog" +import { Duration, Effect, Schedule } from "effect-app" +import { NotFoundError } from "effect-app/client" +import { OperationId } from "effect-app/Operations" +import { NonEmptyString2k, NonNegativeInt } from "effect-app/Schema" + +export class Request extends S.Req()("Blog.PublishPost", { + id: BlogPostId +}, { allowRoles: ["user"], success: OperationId, failure: S.Union(NotFoundError) }) {} + +export default handlerFor(Request)({ + dependencies: [ + BlogPostRepo.Default, + OperationsDefault, + Events.Default + ], + effect: Effect.gen(function*() { + const blogPostRepo = yield* BlogPostRepo + const operations = yield* Operations + const events = yield* Events + + return (req) => + Effect.gen(function*() { + const post = yield* blogPostRepo.get(req.id) + + console.log("publishing post", post) + + const targets = [ + "google", + "twitter", + "facebook" + ] + + const done: string[] = [] + + const op = yield* operations.fork( + (opId) => + operations + .update(opId, { + total: NonNegativeInt(targets.length), + completed: NonNegativeInt(done.length) + }) + .pipe( + Effect.andThen(Effect.forEach(targets, (_) => + Effect + .sync(() => done.push(_)) + .pipe( + Effect.tap(() => + operations.update(opId, { + total: NonNegativeInt(targets.length), + completed: NonNegativeInt(done.length) + }) + ), + Effect.delay(Duration.seconds(4)) + ))), + Effect.andThen(() => "the answer to the universe is 41") + ), + // while operation is running... + (_opId) => + Effect + .suspend(() => events.publish(new BogusEvent())) + .pipe(Effect.schedule(Schedule.spaced(Duration.seconds(1)))), + NonEmptyString2k("post publishing") + ) + + return op.id + }) + }) +}) diff --git a/api/src/controllers.ts b/api/src/controllers.ts index 0d5106915..4b741f3ab 100644 --- a/api/src/controllers.ts +++ b/api/src/controllers.ts @@ -1,9 +1,8 @@ // codegen:start {preset: barrel, include: ./*.controllers.ts, import: default} import accountsControllers from "./Accounts.controllers.js" -import blogControllers from "./Blog.controllers.js" import helloWorldControllers from "./HelloWorld.controllers.js" import operationsControllers from "./Operations.controllers.js" import usersControllers from "./Users.controllers.js" -export { accountsControllers, blogControllers, helloWorldControllers, operationsControllers, usersControllers } +export { accountsControllers, helloWorldControllers, operationsControllers, usersControllers } // codegen:end diff --git a/api/src/lib/handler.ts b/api/src/lib/handler.ts new file mode 100644 index 000000000..8dd36e0f8 --- /dev/null +++ b/api/src/lib/handler.ts @@ -0,0 +1,21 @@ +import type { Effect, Layer, S } from "effect-app" +import type { TaggedRequestClassAny } from "effect-app/client" + +// TODO +export declare const handlerFor: ( + r: Req +) => { + // TODO: constrain L to be strict; only layers that are used in effect, or the other way around + ( + hndlr: { + dependencies?: L + effect: Effect<(req: S.Schema.Type) => Effect, E2, R2>, E, R> + } + ): typeof hndlr + ( + hndlr: { + dependencies?: L + effect: Effect, E2, R2>, E, R> + } + ): typeof hndlr +} // TODO: as Layer? diff --git a/api/src/resources/Blog.ts b/api/src/resources/Blog.ts index 843cba4a4..98acd9264 100644 --- a/api/src/resources/Blog.ts +++ b/api/src/resources/Blog.ts @@ -1,30 +1,9 @@ -import { BlogPost, BlogPostId } from "#models/Blog" -import { InvalidStateError, NotFoundError, OptimisticConcurrencyException } from "effect-app/client" -import { OperationId } from "effect-app/Operations" -import { S } from "./lib.js" -import { BlogPostView } from "./views.js" - -export class CreatePost extends S.Req()("CreatePost", BlogPost.pick("title", "body"), { - allowRoles: ["user"], - success: S.Struct({ id: BlogPostId }), - failure: S.Union(NotFoundError, InvalidStateError, OptimisticConcurrencyException) -}) {} - -export class FindPost extends S.Req()("FindPost", { - id: BlogPostId -}, { allowAnonymous: true, allowRoles: ["user"], success: S.NullOr(BlogPostView) }) {} - -export class GetPosts extends S.Req()("GetPosts", {}, { - allowAnonymous: true, - allowRoles: ["user"], - success: S.Struct({ - items: S.Array(BlogPostView) - }) -}) {} - -export class PublishPost extends S.Req()("PublishPost", { - id: BlogPostId -}, { allowRoles: ["user"], success: OperationId, failure: S.Union(NotFoundError) }) {} +//// codegen:start {preset: barrel, include: ../Blog/*.ts, export: { as: 'PascalCase' }} +export { Request as Create } from "../Blog/Create.js" +export { Request as Find } from "../Blog/Find.js" +export { Request as List } from "../Blog/List.js" +export { Request as Publish } from "../Blog/Publish.js" +//// codegen:end // codegen:start {preset: meta, sourcePrefix: src/resources/} export const meta = { moduleName: "Blog" } as const diff --git a/frontend/pages/blog/[id].vue b/frontend/pages/blog/[id].vue index 8205b2010..d855ff253 100644 --- a/frontend/pages/blog/[id].vue +++ b/frontend/pages/blog/[id].vue @@ -8,7 +8,7 @@ const { id } = useRouteParams({ id: BlogPostId }) const blogClient = clientFor(BlogRsc) const opsClient = useOperationsClient() -const [r, , reloadPost] = useSafeQuery(blogClient.FindPost, { +const [r, , reloadPost] = useSafeQuery(blogClient.Find, { id, }) @@ -28,7 +28,7 @@ const progress = ref("") const [publishing, publish] = useAndHandleMutation( mapHandler( - blogClient.PublishPost, + blogClient.Publish, opsClient.refreshAndWaitForOperation(reloadPost(), op => { progress.value = `${op.progress?.completed}/${op.progress?.total}` }), diff --git a/frontend/pages/blog/index.vue b/frontend/pages/blog/index.vue index 89d55047a..b306db88f 100644 --- a/frontend/pages/blog/index.vue +++ b/frontend/pages/blog/index.vue @@ -4,8 +4,8 @@ import { S } from "effect-app" const blogClient = clientFor(BlogRsc) -const [, createPost] = useSafeMutation(blogClient.CreatePost) -const [r] = useSafeQuery(blogClient.GetPosts) +const [, createPost] = useSafeMutation(blogClient.Create) +const [r] = useSafeQuery(blogClient.List)