1import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node"
2import { Context, Effect, Layer, Schema } from "effect"
3import { SqlClient, SqlModel, SqlSchema } from "effect/sql"
4import { User } from "../domain/User.ts"
5import type { UserId } from "../domain/User.ts"
6import { SearchQueryTooShort, UserNotFound, UsersError } from "../domain/UserErrors.ts"
7
8// The SqlClient layer determines which database the SQL implementation talks
9// to. Swap it for another driver package to target a different database.
10const SqlLayer = SqliteClient.layer({ filename: ":memory:" })
11
12// Migrations are effects keyed by `<id>_<name>` that run once, in id order. A
13// real application would keep each migration in its own file and load them
14// with `SqliteMigrator.fromFileSystem` instead of an inline record.
15const MigratorLayer = SqliteMigrator.layer({
16 loader: SqliteMigrator.fromRecord({
17 "0001_create_users": Effect.gen(function*() {
18 const sql = yield* SqlClient.SqlClient
19 yield* sql`
20 CREATE TABLE users (
21 id TEXT PRIMARY KEY,
22 name TEXT NOT NULL,
23 email TEXT NOT NULL,
24 createdAt TEXT NOT NULL,
25 updatedAt TEXT NOT NULL
26 )
27 `
28 })
29 })
30})
31
32export class Users extends Context.Service<Users, {
33 list(search: string | undefined): Effect.Effect<Array<User>, UsersError>
34 getById(id: UserId): Effect.Effect<User, UsersError>
35 create(input: typeof User.jsonCreate.Type): Effect.Effect<User, UsersError>
36 update(id: UserId, input: typeof User.jsonUpdate.Type): Effect.Effect<User, UsersError>
37}>()("acme/Users") {
38 // The SQL implementation only requires a `SqlClient`, so entrypoints and
39 // tests decide how the database is provided.
40 static readonly layerNoDeps = Layer.effect(
41 Users,
42 Effect.gen(function*() {
43 const sql = yield* SqlClient.SqlClient
44
45 // CRUD goes through a repository derived from the `User` model. Each
46 // operation uses the matching model variant to encode its input and
47 // decodes rows with the full model schema.
48 const repo = yield* SqlModel.makeRepository(User, {
49 tableName: "users",
50 spanPrefix: "Users",
51 idColumn: "id"
52 })
53
54 // Queries the repository does not cover are written with the `sql` tag
55 // and decoded with the model schema.
56 const listAll = SqlSchema.findAll({
57 Request: Schema.Void,
58 Result: User,
59 execute: () => sql`SELECT * FROM users ORDER BY createdAt`
60 })
61
62 const searchUsers = SqlSchema.findAll({
63 Request: Schema.String,
64 Result: User,
65 execute: (search) => {
66 const pattern = `%${search}%`
67 return sql`SELECT * FROM users WHERE name LIKE ${pattern} OR email LIKE ${pattern}`
68 }
69 })
70
71 const list = Effect.fn("Users.list")(function*(search: string | undefined) {
72 if (search === undefined || search.length === 0) {
73 return yield* Effect.orDie(listAll())
74 } else if (search.length < SearchQueryTooShort.minimumLength) {
75 return yield* new UsersError({
76 reason: new SearchQueryTooShort()
77 })
78 }
79 yield* Effect.annotateCurrentSpan({ search })
80 return yield* Effect.orDie(searchUsers(search))
81 })
82
83 const getById = Effect.fn("Users.getById")((id: UserId) =>
84 repo.findById(id).pipe(
85 Effect.catchTags({
86 NoSuchElementError: () => new UsersError({ reason: new UserNotFound() }),
87 // Database and encoding failures are unexpected, so treat them as
88 // defects to keep the service interface focused on domain errors.
89 SchemaError: Effect.die,
90 SqlError: Effect.die
91 })
92 )
93 )
94
95 const create = Effect.fn("Users.create")((input: typeof User.jsonCreate.Type) =>
96 // `User.insert.makeEffect` fills in the generated id and timestamps
97 // using the Effect clock, so tests can control them with `TestClock`.
98 User.insert.makeEffect(input).pipe(
99 Effect.flatMap(repo.insert),
100 Effect.orDie
101 )
102 )
103
104 const update = Effect.fn("Users.update")(function*(id: UserId, input: typeof User.jsonUpdate.Type) {
105 // Ensure the user exists first, so a missing id fails with the domain
106 // error instead of a defect.
107 yield* getById(id)
108 const update = yield* User.update.makeEffect({ id, ...input }).pipe(Effect.orDie)
109 return yield* repo.update(update).pipe(Effect.orDie)
110 })
111
112 return Users.of({ list, getById, create, update })
113 })
114 )
115
116 // The fully provided SQL implementation: the database client and migrations
117 // are implementation details, so this layer requires nothing.
118 static readonly layer: Layer.Layer<Users> = this.layerNoDeps.pipe(
119 Layer.provide(MigratorLayer.pipe(Layer.provideMerge(SqlLayer))),
120 Layer.orDie
121 )
122
123 // An in-memory implementation for tests, so the HTTP stack can be exercised
124 // without a database.
125 static readonly layerMemory = Layer.effect(
126 Users,
127 Effect.gen(function*() {
128 const users = new Map<UserId, User>()
129
130 const makeUser = (input: typeof User.jsonCreate.Type) =>
131 User.insert.makeEffect(input).pipe(
132 Effect.map((user) => new User(user)),
133 Effect.orDie
134 )
135
136 const admin = yield* makeUser({ name: "Admin", email: "admin@acme.dev" })
137 users.set(admin.id, admin)
138
139 const list = Effect.fn("Users.list")(function*(search: string | undefined) {
140 const allUsers = Array.from(users.values())
141 if (search === undefined || search.length === 0) {
142 return allUsers
143 } else if (search.length < SearchQueryTooShort.minimumLength) {
144 return yield* new UsersError({
145 reason: new SearchQueryTooShort()
146 })
147 }
148 yield* Effect.annotateCurrentSpan({ search })
149 const normalized = search.toLowerCase()
150 return allUsers.filter((user) =>
151 user.name.toLowerCase().includes(normalized) || user.email.toLowerCase().includes(normalized)
152 )
153 })
154
155 const getById = Effect.fn("Users.getById")(function*(id: UserId) {
156 yield* Effect.annotateCurrentSpan({ id })
157 const user = users.get(id)
158 if (user === undefined) {
159 return yield* new UsersError({
160 reason: new UserNotFound()
161 })
162 }
163 return user
164 })
165
166 const create = Effect.fn("Users.create")(function*(input: typeof User.jsonCreate.Type) {
167 const user = yield* makeUser(input)
168 users.set(user.id, user)
169 return user
170 })
171
172 const update = Effect.fn("Users.update")(function*(id: UserId, input: typeof User.jsonUpdate.Type) {
173 const existing = yield* getById(id)
174 const update = yield* User.update.makeEffect({ id, ...input }).pipe(Effect.orDie)
175 const updated = new User({ ...existing, ...update })
176 users.set(id, updated)
177 return updated
178 })
179
180 return Users.of({ list, getById, create, update })
181 })
182 )
183}