diff --git a/.ncurc.json b/.ncurc.json index ebfca6a34..041af406e 100644 --- a/.ncurc.json +++ b/.ncurc.json @@ -8,6 +8,6 @@ "@types/redis", "eslint-plugin-codegen", "vue-toastification", - "@sentry/*" + "@opentelemetry/*" ] } \ No newline at end of file diff --git a/README.md b/README.md index 5064f4aec..6121d8a5f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# @effect-app-boilerplate +# Effect App Sample + +Demo basic usage of Models, Resources, Controllers, Clients, long running tasks and some SSE. ## Setup @@ -30,21 +32,6 @@ Notes - Make sure you don't have the old Vue/Vetur vs code plugin installed, but the new ones only: "Vue.volar", "Vue.vscode-typescript-vue-plugin" -### Helpful editor hints - -Add to keybinds: - -```json -{ - "key": "ctrl+shift+i", - "command": "editor.action.sourceAction", - "args": { - "kind": "source.addMissingImports", - "apply": "first" - } -} -``` - ## Framework documentation [WIP](https://github.com/effect-ts-app/docs) diff --git a/api/src/Blog.controllers.ts b/api/src/Blog.controllers.ts new file mode 100644 index 000000000..aeb30e415 --- /dev/null +++ b/api/src/Blog.controllers.ts @@ -0,0 +1,86 @@ +import { Router } from "#lib/routing" +import { BlogPost } from "#models/Blog" +import { BlogRsc } from "#resources" +import { BogusEvent } from "#resources/Events" +import { BlogPostRepo, Events, Operations, UserRepo } from "#services" +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(match) { + const blogPostRepo = yield* BlogPostRepo + const userRepo = yield* UserRepo + const events = yield* Events + const operations = yield* Operations + + return match({ + 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) { + 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/HelloWorld.controllers.ts b/api/src/HelloWorld.controllers.ts index d4a207912..e3c3ef95e 100644 --- a/api/src/HelloWorld.controllers.ts +++ b/api/src/HelloWorld.controllers.ts @@ -15,22 +15,21 @@ export default Router(HelloWorldRsc)({ return match({ *GetHelloWorld({ echo }) { const context = yield* getRequestContext - return yield* userRepo + const user = yield* userRepo .tryGetCurrentUser .pipe( Effect.catchTags({ "NotLoggedInError": () => Effect.succeed(null), "NotFoundError": () => Effect.succeed(null) - }), - Effect.andThen((user) => - new GetHelloWorld.success({ - context, - echo, - currentUser: user, - randomUser: generate(S.A.make(User)).value - }) - ) + }) ) + + return new GetHelloWorld.success({ + context, + echo, + currentUser: user, + randomUser: generate(S.A.make(User)).value + }) } }) } diff --git a/api/src/Users.controllers.ts b/api/src/Users.controllers.ts new file mode 100644 index 000000000..cd327df94 --- /dev/null +++ b/api/src/Users.controllers.ts @@ -0,0 +1,22 @@ +import { Router } from "#lib/routing" +import { UsersRsc } from "#resources" +import type { UserView } from "#resources/views" +import { Q, UserRepo } from "#services" +import { Array } from "effect" +import { Effect, Order } from "effect-app" + +export default Router(UsersRsc)({ + dependencies: [UserRepo.Default], + *effect(match) { + const userRepo = yield* UserRepo + + return match({ + IndexUsers: (req) => + userRepo + .query(Q.where("id", "in", req.filterByIds)) + .pipe(Effect.andThen((users) => ({ + users: Array.sort(users, Order.mapInput(Order.string, (_: UserView) => _.displayName)) + }))) + }) + } +}) diff --git a/api/src/controllers.ts b/api/src/controllers.ts index 21be5610b..0d5106915 100644 --- a/api/src/controllers.ts +++ b/api/src/controllers.ts @@ -1,7 +1,9 @@ // 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, helloWorldControllers, operationsControllers } +export { accountsControllers, blogControllers, helloWorldControllers, operationsControllers, usersControllers } // codegen:end diff --git a/api/src/models/Blog.ts b/api/src/models/Blog.ts new file mode 100644 index 000000000..ffb31e216 --- /dev/null +++ b/api/src/models/Blog.ts @@ -0,0 +1,26 @@ +import { S } from "effect-app" +import { UserFromId } from "./User.js" + +export const BlogPostId = S.prefixedStringId()("post", "BlogPostId") +export interface BlogPostIdBrand { + readonly BlogPostId: unique symbol +} +export type BlogPostId = S.StringId & BlogPostIdBrand & `post-${string}` + +export class BlogPost extends S.ExtendedClass()({ + id: BlogPostId.withDefault, + title: S.NonEmptyString255, + body: S.NonEmptyString2k, + createdAt: S.Date.withDefault, + author: S.propertySignature(UserFromId).pipe(S.fromKey("authorId")) +}) {} + +// codegen:start {preset: model} +// +/* eslint-disable */ +export namespace BlogPost { + export interface Encoded extends S.Struct.Encoded {} +} +/* eslint-enable */ +// +// codegen:end diff --git a/api/src/resources.ts b/api/src/resources.ts index e54eda6b3..4faf9961a 100644 --- a/api/src/resources.ts +++ b/api/src/resources.ts @@ -4,6 +4,8 @@ export { ClientEvents } from "./resources/Events.js" // codegen:start {preset: barrel, include: ./resources/*.ts, exclude: [./resources/index.ts, ./resources/lib.ts, ./resources/integrationEvents.ts, ./resources/Messages.ts, ./resources/views.ts, ./resources/Events.ts], export: { as: 'PascalCase', postfix: 'Rsc' }} export * as AccountsRsc from "./resources/Accounts.js" +export * as BlogRsc from "./resources/Blog.js" export * as HelloWorldRsc from "./resources/HelloWorld.js" export * as OperationsRsc from "./resources/Operations.js" +export * as UsersRsc from "./resources/Users.js" // codegen:end diff --git a/api/src/resources/Blog.ts b/api/src/resources/Blog.ts new file mode 100644 index 000000000..843cba4a4 --- /dev/null +++ b/api/src/resources/Blog.ts @@ -0,0 +1,31 @@ +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: meta, sourcePrefix: src/resources/} +export const meta = { moduleName: "Blog" } as const +// codegen:end diff --git a/api/src/resources/Users.ts b/api/src/resources/Users.ts new file mode 100644 index 000000000..c73252369 --- /dev/null +++ b/api/src/resources/Users.ts @@ -0,0 +1,17 @@ +import { UserId } from "#models/User" +import { S } from "./lib.js" +import { UserView } from "./views/UserView.js" + +export class IndexUsers extends S.Req()("IndexUsers", { + filterByIds: S.NonEmptyArray(UserId) +}, { + allowAnonymous: true, + allowRoles: ["user"], + success: S.Struct({ + users: S.Array(UserView) + }) +}) {} + +// codegen:start {preset: meta, sourcePrefix: src/resources/} +export const meta = { moduleName: "Users" } as const +// codegen:end diff --git a/api/src/resources/resolvers/UserResolver.ts b/api/src/resources/resolvers/UserResolver.ts new file mode 100644 index 000000000..155d9c9ae --- /dev/null +++ b/api/src/resources/resolvers/UserResolver.ts @@ -0,0 +1,53 @@ +import { UserId } from "#models/User" +import { clientFor } from "#resources/lib" +import { Effect, Exit, Request, RequestResolver } from "effect" +import { Array, Option, pipe, S } from "effect-app" +import { ApiClientFactory, NotFoundError } from "effect-app/client" +import { type Schema } from "effect-app/Schema" +import * as UsersRsc from "../Users.js" +import { UserView } from "../views/UserView.js" + +interface GetUserViewById extends Request.Request> { + readonly _tag: "GetUserViewById" + readonly id: UserId +} +const GetUserViewById = Request.tagged("GetUserViewById") + +const getUserViewByIdResolver = RequestResolver + .makeBatched((requests: GetUserViewById[]) => + clientFor(UsersRsc).pipe( + Effect.flatMap((client) => + client + .IndexUsers + .handler({ filterByIds: pipe(requests.map((_) => _.id), Array.toNonEmptyArray, Option.getOrUndefined)! }) + ), + Effect.andThen(({ users }) => + Effect.forEach(requests, (r) => + Request.complete( + r, + Array + .findFirst(users, (_) => _.id === r.id ? Option.some(Exit.succeed(_)) : Option.none()) + .pipe(Option.getOrElse(() => Exit.fail(new NotFoundError({ type: "User", id: r.id })))) + ), { discard: true }) + ), + Effect.orDie, + Effect.catchAllCause((cause) => + Effect.forEach( + requests, + (request) => Request.failCause(request, cause), + { discard: true } + ) + ) + ) + ) + .pipe(RequestResolver.batchN(25), RequestResolver.contextFromServices(ApiClientFactory)) + +// TODO: How to globally cache - right now we had to move the RequestCache from the runtime to clientFor +export const UserViewFromId: Schema = S.transformOrFail( + UserId, + S.typeSchema(UserView), + { + decode: (id) => Effect.request(GetUserViewById({ id }), getUserViewByIdResolver).pipe(Effect.orDie), + encode: (u) => Effect.succeed(u.id) + } +) diff --git a/api/src/resources/views.ts b/api/src/resources/views.ts index 21b92695b..b09859be2 100644 --- a/api/src/resources/views.ts +++ b/api/src/resources/views.ts @@ -1,3 +1,4 @@ // codegen:start {preset: barrel, include: ./views/*.ts} +export * from "./views/PostView.js" export * from "./views/UserView.js" // codegen:end diff --git a/api/src/resources/views/PostView.ts b/api/src/resources/views/PostView.ts new file mode 100644 index 000000000..7aa56a9c9 --- /dev/null +++ b/api/src/resources/views/PostView.ts @@ -0,0 +1,18 @@ +import { BlogPost } from "#models/Blog" +import { S } from "#resources/lib" +import { UserViewFromId } from "../resolvers/UserResolver.js" + +export class BlogPostView extends S.ExtendedClass()({ + ...BlogPost.omit("author"), + author: S.propertySignature(UserViewFromId).pipe(S.fromKey("authorId")) +}) {} + +// codegen:start {preset: model} +// +/* eslint-disable */ +export namespace BlogPostView { + export interface Encoded extends S.Struct.Encoded {} +} +/* eslint-enable */ +// +// codegen:end diff --git a/api/src/router.ts b/api/src/router.ts index 209c9b257..954f51591 100644 --- a/api/src/router.ts +++ b/api/src/router.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as MW from "#lib/middleware" +import { Events } from "#services" import { Router } from "@effect-app/infra/api/routing" import { reportError } from "@effect-app/infra/errorReporter" import { RpcSerialization } from "@effect/rpc" @@ -7,7 +8,6 @@ import { FiberRef, flow } from "effect" import { Console, Effect, Layer } from "effect-app" import { HttpMiddleware, HttpRouter, HttpServer } from "effect-app/http" import { BaseConfig, MergedConfig } from "./config.js" -import { Events } from "./services.js" const prodOrigins: string[] = [] const demoOrigins: string[] = [] diff --git a/api/src/services/DBContext.ts b/api/src/services/DBContext.ts index cce638b54..f3dec88d7 100644 --- a/api/src/services/DBContext.ts +++ b/api/src/services/DBContext.ts @@ -1,3 +1,4 @@ // codegen:start {preset: barrel, include: ./DBContext/* } +export * from "./DBContext/BlogPostRepo.js" export * from "./DBContext/UserRepo.js" // codegen:end diff --git a/api/src/services/DBContext/BlogPostRepo.ts b/api/src/services/DBContext/BlogPostRepo.ts new file mode 100644 index 000000000..b35e929e6 --- /dev/null +++ b/api/src/services/DBContext/BlogPostRepo.ts @@ -0,0 +1,49 @@ +import { RepoDefault } from "#lib/layers" +import { BlogPost } from "#models/Blog" +import { UserFromIdResolver } from "#models/User" +import { Model } from "@effect-app/infra" +import { Effect } from "effect" +import { Context } from "effect-app" +import { NonEmptyString255, NonEmptyString2k } from "effect-app/Schema" +import { UserRepo } from "./UserRepo.js" + +export type BlogPostSeed = "sample" | "" + +export class BlogPostRepo extends Effect.Service()("BlogPostRepo", { + dependencies: [RepoDefault, UserRepo.Default, UserRepo.UserFromIdLayer], + effect: Effect.gen(function*() { + const seed = "sample" + const userRepo = yield* UserRepo + const resolver = yield* UserFromIdResolver + + const makeInitial = yield* Effect.cached( + seed === "sample" + ? userRepo + .all + .pipe( + Effect.andThen((users) => + users + .flatMap((_) => [_, _]) + .map((user, i) => + new BlogPost({ + title: NonEmptyString255("Test post " + i), + body: NonEmptyString2k("imma test body"), + author: user + }, true) + ) + ) + ) + : Effect.succeed([]) + ) + + return yield* Model.makeRepo( + "BlogPost", + BlogPost, + { + makeInitial, + schemaContext: Context.make(UserFromIdResolver, resolver) + } + ) + }) +}) { +} diff --git a/api/src/services/DBContext/UserRepo.ts b/api/src/services/DBContext/UserRepo.ts index 823cb330e..342efba7f 100644 --- a/api/src/services/DBContext/UserRepo.ts +++ b/api/src/services/DBContext/UserRepo.ts @@ -11,10 +11,6 @@ import fc from "fast-check" import { Q } from "../lib.js" import { UserProfile } from "../UserProfile.js" -export interface UserPersistenceModel extends User.Encoded { - _etag: string | undefined -} - export type UserSeed = "sample" | "" export class UserRepo extends Effect.Service()("UserRepo", { @@ -83,7 +79,15 @@ export class UserRepo extends Effect.Service()("UserRepo", { .pipe(Option.getOrElse(() => Exit.fail(new NotFoundError({ type: "User", id: r.id })))) ), { discard: true }) )) - ) + ).pipe( + Effect.orDie, + Effect.catchAllCause((cause) => + Effect.forEach( + requests, + (request) => Request.failCause(request, cause), + { discard: true } + ) + )) ) .pipe( RequestResolver.batchN(25), diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 5502cb27f..cebd9775d 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -22,6 +22,8 @@ const router = useRouter() Home + | + Blog
{{ router.currentRoute.value.name }}
diff --git a/frontend/pages/blog/[id].vue b/frontend/pages/blog/[id].vue new file mode 100644 index 000000000..8205b2010 --- /dev/null +++ b/frontend/pages/blog/[id].vue @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/frontend/pages/blog/index.vue b/frontend/pages/blog/index.vue new file mode 100644 index 000000000..89d55047a --- /dev/null +++ b/frontend/pages/blog/index.vue @@ -0,0 +1,41 @@ + + + \ No newline at end of file