diff --git a/.vscode/model.code-snippets b/.vscode/model.code-snippets index be19411a..3d9e52e9 100644 --- a/.vscode/model.code-snippets +++ b/.vscode/model.code-snippets @@ -14,7 +14,7 @@ "ModelComplete": { "prefix": "moc", "body": [ - "export class $1 extends S.ExtendedClass<$1, $1.Encoded>()({", + "export class $1 extends S.Class<$1, $1.Encoded>()({", "$2", "}) {}" ], diff --git a/README.md b/README.md index d377912e..7c5656f0 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 diff --git a/api/src/Blog.controllers.ts b/api/src/Blog.controllers.ts new file mode 100644 index 00000000..5a771eaa --- /dev/null +++ b/api/src/Blog.controllers.ts @@ -0,0 +1,80 @@ +import { Router } from "#lib/routing" +import { BlogPost } from "#models/Blog" +import { BlogRsc } from "#resources" +import { PublishComplete, PublishProgress } from "#resources/Blog" +import { BogusEvent } from "#resources/Events" +import { BlogPostRepo, Events, UserRepo } from "#services" +import { Duration, Effect, Stream } from "effect" +import { Option } from "effect-app" +import { NonEmptyString, NonNegativeInt } from "effect-app/Schema" + +export default Router(BlogRsc)({ + dependencies: [ + BlogPostRepo.Default, + UserRepo.Default, + Events.Default + ], + *effect(match) { + const blogPostRepo = yield* BlogPostRepo + const userRepo = yield* UserRepo + const events = yield* Events + + return match({ + FindPost: (req) => + blogPostRepo + .find(req.id) + .pipe(Effect.map(Option.getOrNull)), + GetPosts: blogPostRepo + .all + .pipe(Effect.map((items) => ({ items }))), + CreatePost: (req) => + userRepo + .getCurrentUser + .pipe( + Effect.map((author) => (BlogPost.make({ ...req, authorId: author.id }))), + Effect.tap(blogPostRepo.save) + ), + PublishPost: (req) => + Stream.unwrap( + blogPostRepo.get(req.id).pipe( + Effect.map((post) => { + console.log("publishing post", post) + + const targets = ["google", "twitter", "facebook"] as const + const total = NonNegativeInt(targets.length) + + return Stream + .make(new PublishProgress({ completed: NonNegativeInt(0), total })) + .pipe( + Stream.concat( + Stream.fromIterable(targets).pipe( + Stream.zipWithIndex, + Stream.mapEffect(([, idx]) => + Effect + .sleep(Duration.seconds(4)) + .pipe( + Effect.tap(() => events.publish(new BogusEvent())), + Effect.as( + new PublishProgress({ + completed: NonNegativeInt(idx + 1), + total + }) + ) + ) + ) + ) + ), + Stream.concat( + Stream.make( + new PublishComplete({ + result: NonEmptyString("the answer to the universe is 41") + }) + ) + ) + ) + }) + ) + ) + }) + } +}) diff --git a/api/src/Users.controllers.ts b/api/src/Users.controllers.ts new file mode 100644 index 00000000..a4c1cdb2 --- /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.map((users) => ({ + users: Array.sort(users, Order.mapInput(Order.String, (_: UserView) => _.displayName)) + }))) + }) + } +}) diff --git a/api/src/config/api.ts b/api/src/config/api.ts index 085019b9..b264370f 100644 --- a/api/src/config/api.ts +++ b/api/src/config/api.ts @@ -8,7 +8,7 @@ const STORAGE_VERSION = "1" export const storage = Config.all({ url: secretURL("url") .pipe( - Config.withDefault(SecretURL.fromString("sqlite://")), + Config.withDefault(SecretURL.fromString("disk://.data")), Config.nested("storage") ), dbName: Config.all({ env, serviceName }).pipe( diff --git a/api/src/config/base.ts b/api/src/config/base.ts index 97be4a5f..aea9401c 100644 --- a/api/src/config/base.ts +++ b/api/src/config/base.ts @@ -16,6 +16,7 @@ export const sendgrid = C.all({ apiKey: C.redacted("sendgridApiKey").pipe(C.withDefault( Redacted.make("") )), + fakeMailAddress: C.string().pipe(C.withDefault("fake-{i}@example.com")), defaultFrom: C.succeed(FROM), subjectPrefix: env.pipe(C.map((env) => env === "prod" ? "" : `[${serviceName_}] [${env}] `)) }) diff --git a/api/src/controllers.ts b/api/src/controllers.ts index 21be5610..0d510691 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 00000000..e207e6a6 --- /dev/null +++ b/api/src/models/Blog.ts @@ -0,0 +1,31 @@ +import { S } from "effect-app" +import { UserId } 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.Opaque()( + S + .Struct({ + id: BlogPostId.withDefault, + title: S.NonEmptyString255, + body: S.NonEmptyString2k, + createdAt: S.Date.withDefault, + authorId: UserId + //author: UserFromId + }) + //.pipe(S.encodeKeys({ author: "authorId" })) +) {} + +// codegen:start {preset: model} +// +/* eslint-disable */ +export namespace BlogPost { + export interface Encoded extends S.StructNestedEncoded {} +} +/* eslint-enable */ +// +// codegen:end diff --git a/api/src/models/User.ts b/api/src/models/User.ts index afd287ee..867ee90e 100644 --- a/api/src/models/User.ts +++ b/api/src/models/User.ts @@ -67,7 +67,7 @@ export class User extends S.Class("User")({ name: FullName, email: S.Email, role: Role, - passwordHash: S.NonEmptyString255 +// passwordHash: S.NonEmptyString255 }) { get displayName() { return S.NonEmptyString2k(this.name.firstName + " " + this.name.lastName) diff --git a/api/src/resources.ts b/api/src/resources.ts index cdc16c6d..f9a464c1 100644 --- a/api/src/resources.ts +++ b/api/src/resources.ts @@ -2,6 +2,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 00000000..6bca2abf --- /dev/null +++ b/api/src/resources/Blog.ts @@ -0,0 +1,44 @@ +import { BlogPost, BlogPostId } from "#models/Blog" +import { InvalidStateError, NotFoundError, OptimisticConcurrencyException } from "effect-app/client" +import { S, TaggedRequestFor } from "./lib.js" +import { BlogPostView } from "./views.js" +import { Struct } from "effect-app" + +// codegen:start {preset: meta, sourcePrefix: src/resources/} +const Req = TaggedRequestFor("Blog") +// codegen:end + +export class CreatePost extends Req.Command()("CreatePost", Struct.pick(BlogPost.fields, ["title", "body"]), { + allowRoles: ["user"], + success: S.Struct({ id: BlogPostId }), + error: S.Union([NotFoundError, InvalidStateError, OptimisticConcurrencyException]) +}) {} + +export class FindPost extends Req.Query()("FindPost", { + id: BlogPostId +}, { allowAnonymous: true, allowRoles: ["user"], success: S.NullOr(BlogPostView) }) {} + +export class GetPosts extends Req.Query()("GetPosts", {}, { + allowAnonymous: true, + allowRoles: ["user"], + success: S.Struct({ + items: S.Array(BlogPostView) + }) +}) {} + +export class PublishProgress extends S.TaggedClass()("PublishProgress", { + completed: S.NonNegativeInt, + total: S.NonNegativeInt +}) {} + +export class PublishComplete extends S.TaggedClass()("PublishComplete", { + result: S.NonEmptyString +}) {} + +export class PublishPost extends Req.Stream()("PublishPost", { + id: BlogPostId +}, { + allowRoles: ["user"], + success: S.Union([PublishProgress, PublishComplete]), + error: S.Union([NotFoundError]) +}) {} diff --git a/api/src/resources/Operations.ts b/api/src/resources/Operations.ts index 659c4b71..8cba626f 100644 --- a/api/src/resources/Operations.ts +++ b/api/src/resources/Operations.ts @@ -61,12 +61,12 @@ export const OperationsClient = Effect.gen(function*() { self: Effect.Effect, cb?: (op: Operation) => void ) { - return Effect.andThen(self, (r) => _waitForOperation(r, cb)) + return Effect.flatMap(self, (r) => _waitForOperation(r, cb)) } function waitForOperation_(cb?: (op: Operation) => void) { return (self: (req: Req) => Effect.Effect) => (req: Req) => - Effect.andThen(self(req), (r) => _waitForOperation(r, cb)) + Effect.flatMap(self(req), (r) => _waitForOperation(r, cb)) } const isFailure = S.is(OperationFailure) diff --git a/api/src/resources/Users.ts b/api/src/resources/Users.ts new file mode 100644 index 00000000..b3df2289 --- /dev/null +++ b/api/src/resources/Users.ts @@ -0,0 +1,20 @@ +import { UserId } from "#models/User" +import { S, TaggedRequestFor } from "./lib.js" +import { UserView } from "./views/UserView.js" + + +// codegen:start {preset: meta, sourcePrefix: src/resources/} +const Req = TaggedRequestFor("Users") +// codegen:end + +export class IndexUsers extends Req.Query()("IndexUsers", { + filterByIds: S.NonEmptyArray(UserId) +}, { + allowAnonymous: true, + allowRoles: ["user"], + success: S.Struct({ + users: S.Array(UserView) + }) +}) {} + + diff --git a/api/src/resources/resolvers/UserResolver.ts b/api/src/resources/resolvers/UserResolver.ts new file mode 100644 index 00000000..b07dcb71 --- /dev/null +++ b/api/src/resources/resolvers/UserResolver.ts @@ -0,0 +1,95 @@ +import { UserId } from "#models/User" +import { clientFor } from "#resources/lib" +import { Context, Effect, Exit, Request, RequestResolver, SchemaGetter, SchemaIssue } from "effect" +import { Array, type NonEmptyArray, Option, S } from "effect-app" +import { ApiClientFactory, type NotFoundError } from "effect-app/client" +import * as UsersRsc from "../Users.js" +import { UserView } from "../views/UserView.js" +import { NonEmptyString255 } from "effect-app/Schema" + + +const makeUserViews = Effect.fn(function*() { + const apiClientFactory = yield* ApiClientFactory + + class GetUserViewById extends Request.TaggedClass("GetUserViewById")< + { + readonly id: UserId + }, + UserView, + NotFoundError<"User"> + > {} + + const client = clientFor(UsersRsc) + + const getUserViewByIdResolver = yield* RequestResolver + .make((entries: NonEmptyArray>) => + client.pipe( + Effect.provideService(ApiClientFactory, apiClientFactory), + Effect.flatMap( + (userClient) => + Array.toNonEmptyArray(entries.map((_) => _.request)).pipe( + Option.map((_) => + userClient.IndexUsers.handler({ filterByIds: _.map((_) => _.id) }).pipe( + Effect.map((_) => _.users), + Effect.orDie + ) + ), + Option.getOrElse(() => Effect.succeed([])) + ) + ), + Effect.flatMap( + (users) => + Effect.forEach(entries, (entry) => { + const u = users.find((_) => _.id === entry.request.id) + return Request.complete( + entry, + u + ? Exit.succeed(u) + : Exit.succeed( + UserView.make({ + id: entry.request.id, + displayName: NonEmptyString255("(entfernt)"), + role: "user" + }) + ) // Exit.fail(new NotFoundError({ type: "User", id: r.id })) + ) + }, { discard: true }) + ), + Effect.provideContext(entries[0].context), + Effect.orDie, + Effect.catchCause((cause) => + Effect.forEach( + entries, + (entry) => Request.failCause(entry, cause), + { discard: true } + ) + ) + ) + ) + .pipe( + RequestResolver.batchN(25), + RequestResolver.withCache({ capacity: 1_000 }) + ) + + return (id: UserId) => + Effect.request(new GetUserViewById({ id }), getUserViewByIdResolver).pipe( + Effect.orDie, + Effect.withSpan("UserViewFromIdResolver.getById " + id) + ) +}) + +export class UserViews + extends Context.Service>>()("UserViews") +{ + static readonly make = makeUserViews +} + +export const UserViewFromId: S.Codec = UserId.pipe( + S.decodeTo(S.toType(UserView), { + decode: SchemaGetter.transformOrFail((id) => UserViews.use((_) => _(id))), + encode: SchemaGetter.transformOrFail( + (u) => + Effect.try({ try: () => u.id, catch: (e) => new SchemaIssue.InvalidValue(Option.none(), { message: `${e}` }) }) + ) + }) +) \ No newline at end of file diff --git a/api/src/resources/views.ts b/api/src/resources/views.ts index 45c17b9c..7f1765a0 100644 --- a/api/src/resources/views.ts +++ b/api/src/resources/views.ts @@ -1,4 +1,5 @@ // codegen:start {preset: barrel, include: ./views/*.ts} +export * from "./views/PostView.js" export * from "./views/UserItem.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 00000000..7959288a --- /dev/null +++ b/api/src/resources/views/PostView.ts @@ -0,0 +1,18 @@ +import { BlogPost } from "#models/Blog" +import { S } from "#resources/lib" + +export class BlogPostView extends S.Opaque()(S.Struct({ + ...BlogPost.fields + //...BlogPost.to.fields, + // author: UserViewFromId +})) {} //.pipe(S.encodeKeys({ author: "authorId"}))) {} + +// codegen:start {preset: model} +// +/* eslint-disable */ +export namespace BlogPostView { + export interface Encoded extends S.StructNestedEncoded {} +} +/* eslint-enable */ +// +// codegen:end diff --git a/api/src/services/DBContext.ts b/api/src/services/DBContext.ts index cce638b5..f3dec88d 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 00000000..eb5b922b --- /dev/null +++ b/api/src/services/DBContext/BlogPostRepo.ts @@ -0,0 +1,51 @@ +import { RepoDefault } from "#lib/layers" +import { BlogPost } from "#models/Blog" +import { UserFromIdResolver } from "#models/User" +import { Model } from "@effect-app/infra" +import { Effect, Layer } 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 Context.Service()("BlogPostRepo", { + make: 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.map((users) => + users + .flatMap((_) => [_, _]) + .map((user, i) => + BlogPost.make({ + title: NonEmptyString255("Test post " + i), + body: NonEmptyString2k("imma test body"), + authorId: user.id + }) + ) + ) + ) + : Effect.succeed([]) + ) + + return yield* Model.makeRepo( + "BlogPost", + BlogPost, + { + makeInitial, + schemaContext: Context.make(UserFromIdResolver, resolver) + } + ) + }) +}) { + static readonly Default = Layer.effect(BlogPostRepo, this.make).pipe( + Layer.provide([RepoDefault, UserRepo.Default, UserRepo.UserFromIdLayer]) + ) +} diff --git a/api/src/services/DBContext/UserRepo.ts b/api/src/services/DBContext/UserRepo.ts index 5c699178..c3fcc0e9 100644 --- a/api/src/services/DBContext/UserRepo.ts +++ b/api/src/services/DBContext/UserRepo.ts @@ -5,11 +5,9 @@ import { Model } from "@effect-app/infra" import { NotFoundError, NotLoggedInError } from "@effect-app/infra/errors" import { generate } from "@effect-app/infra/test" import { Array, Context, Effect, Exit, Layer, Option, pipe, Request, RequestResolver, S } from "effect-app" -import { fakerArb } from "effect-app/faker" -import { Email } from "effect-app/Schema" -import fc from "fast-check" import { Q } from "../lib.js" import { UserProfile } from "../UserProfile.js" +import { StringId } from "effect-app/Schema" export interface UserPersistenceModel extends S.Codec.Encoded { _etag: string | undefined @@ -28,14 +26,15 @@ export class UserRepo extends Context.Service()("UserRepo", { .range(1, 8) .map((_, i): User => { const g = generate(S.toArbitrary(User)).value - const emailArb = fakerArb((_) => () => - _ - .internet - .exampleEmail({ firstName: g.name.firstName, lastName: g.name.lastName }) - ) + // const emailArb = fakerArb((_) => () => + // _ + // .internet + // .exampleEmail({ firstName: g.name.firstName, lastName: g.name.lastName }) + // ) return new User({ ...g, - email: Email(generate(emailArb(fc)).value), + id: StringId.make(), + //email: Email(generate(emailArb(fc)).value), role: i === 0 || i === 1 ? "manager" : "user" }) }), diff --git a/frontend/composables/client.ts b/frontend/composables/client.ts index 37f309f1..6a186526 100644 --- a/frontend/composables/client.ts +++ b/frontend/composables/client.ts @@ -12,6 +12,8 @@ import { Effect, Layer, ManagedRuntime } from "effect-app" import { useToast } from "vue-toastification" import type { RT } from "~/plugins/runtime" import { useIntl } from "./intl" +import { UserViews } from "#resources/resolvers/UserResolver" +import type { makeIntl } from "@effect-app/vue" export { useToast } from "vue-toastification" @@ -31,7 +33,8 @@ export const run = ( export const runSync = (effect: Effect.Effect) => useRuntime().runSync(effect) -const intlLayer = I18n.toLayer(Effect.sync(useIntl)) +const intlLayer = I18n.toLayer(Effect.sync(useIntl as ReturnType["useIntl"])) + // TODO: use optional CurrentToastId to auto assign toastId when not null? const toastLayer = Toast_.Toast.toLayer( Effect.sync(() => { @@ -50,9 +53,9 @@ const commanderLayer = Commander.Default.pipe( Layer.provide([intlLayer, toastLayer]) ) -const globalLayers = Effect.sync(() => useRuntime().globalLayers).pipe( - Layer.unwrap -) +const globalLayers = Layer.effect(UserViews, UserViews.make()).pipe( + Layer.provideMerge(Effect.sync(() => useRuntime().globalLayers).pipe(Layer.unwrap +))) const viewLayers = Layer.mergeAll(Router.Default, intlLayer, toastLayer) const provideLayers = Layer .mergeAll( diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 2a8a4fe0..adf66946 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -35,6 +35,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 00000000..b304de6d --- /dev/null +++ b/frontend/pages/blog/[id].vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/pages/blog/index.vue b/frontend/pages/blog/index.vue new file mode 100644 index 00000000..8616f622 --- /dev/null +++ b/frontend/pages/blog/index.vue @@ -0,0 +1,48 @@ + + + \ No newline at end of file