Repository navigation
Expand file tree
/
Copy path10_basics.ts
More file actions
175 lines (155 loc) · 6.59 KB
/
Copy path10_basics.ts
File metadata and controls
175 lines (155 loc) · 6.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
/**
* @title Getting started with SQL
*
* Define a schema-backed domain model, run migrations against a SQLite
* database, and expose a derived repository through a service.
*/
import { NodeRuntime } from "@effect/platform-node"
import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Model } from "effect/schema"
import { SqlClient, SqlModel, SqlSchema } from "effect/sql"
// Use a branded string for the group id, so it cannot be mixed up with other
// string ids in the application.
export const GroupId = Schema.String.pipe(Schema.brand("GroupId"))
export type GroupId = typeof GroupId.Type
// `Model.Class` defines a domain model with variants for the database and JSON
// boundaries. The single field declaration is the source of truth, and each
// variant only contains the fields that make sense for that operation:
//
// - `Group` / `Group.insert` / `Group.update` for the database
// - `Group.json` / `Group.jsonCreate` / `Group.jsonUpdate` for JSON APIs
export class Group extends Model.Class<Group>("Group")({
// A UUID v4 primary key that is generated by the application on insert
id: Model.UuidV4Insert(GroupId),
name: Schema.NonEmptyString,
// `Model.FieldExcept` removes a field from the given variants. The slug is
// chosen when the group is created and is immutable afterwards, so it is
// removed from the update variants.
slug: Schema.NonEmptyString.pipe(Model.FieldExcept(["update", "jsonUpdate"])),
// `Model.FieldOnly` keeps a field in only the given variants. Internal notes
// live in the database and are never exposed through the JSON variants.
notes: Schema.NullOr(Schema.String).pipe(Model.FieldOnly(["select", "insert"])),
// `Model.Field` gives full control over the individual variants. The member
// count is maintained by the database, so the application can read it but
// never writes it.
memberCount: Model.Field({
select: Schema.Int,
json: Schema.Int
}),
// `createdAt` is set to the current time on insert, and `updatedAt` is
// refreshed on every update. Both are stored as strings, which suits SQLite.
createdAt: Model.DateTimeInsert,
updatedAt: Model.DateTimeUpdate
}) {}
export class GroupNotFound extends Schema.TaggedError<GroupNotFound>()("GroupNotFound", {
id: GroupId
}) {}
// The SqlClient layer determines which database you are talking to. Swap this
// layer for `@effect/sql-pg`, `@effect/sql-mysql2` etc. to target another
// database without changing the rest of the code.
const SqlLayer = SqliteClient.layer({ filename: ":memory:" })
// Migrations are effects keyed by `<id>_<name>` that run once, in id order. A
// real application would keep each migration in its own file and load them
// with `SqliteMigrator.fromFileSystem` instead of an inline record.
const MigratorLayer = SqliteMigrator.layer({
loader: SqliteMigrator.fromRecord({
"0001_create_groups": Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
yield* sql`
CREATE TABLE groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
notes TEXT,
memberCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`
})
})
})
// Combine the database client with the migrations, so anything built on top of
// `SqlLive` sees a fully migrated database.
const SqlLive = MigratorLayer.pipe(Layer.provideMerge(SqlLayer))
// Wrap data access in a service, so the rest of the application depends on
// `Groups` instead of the database directly.
export class Groups extends Context.Service<Groups, {
create(name: string, slug: string): Effect.Effect<Group>
rename(id: GroupId, name: string): Effect.Effect<Group, GroupNotFound>
findById(id: GroupId): Effect.Effect<Group, GroupNotFound>
readonly list: Effect.Effect<Array<Group>>
}>()("app/Groups") {
static readonly layer = Layer.effect(
Groups,
Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
// `SqlModel.makeRepository` derives insert / update / findById / delete
// operations from the model, using the matching variant schema for each
// operation.
const repo = yield* SqlModel.makeRepository(Group, {
tableName: "groups",
spanPrefix: "Groups",
idColumn: "id"
})
// For queries the repository does not cover, combine the `sql` tag with
// `SqlSchema` to decode the rows using the model schema.
const listAll = SqlSchema.findAll({
Request: Schema.Void,
Result: Group,
execute: () => sql`SELECT * FROM groups ORDER BY createdAt`
})
// Use `Effect.fn` to give each method a named span for observability.
const create = Effect.fn("Groups.create")((name: string, slug: string) =>
// `Group.insert.makeEffect` fills in the generated id and timestamps
// using the Effect clock, so tests can control them with `TestClock`.
Group.insert.makeEffect({ name, slug, notes: null }).pipe(
Effect.flatMap(repo.insert),
// Database and encoding failures are unexpected here, so treat
// them as defects to keep the service interface focused on domain
// errors.
Effect.orDie
)
)
const rename = Effect.fn("Groups.rename")((id: GroupId, name: string) =>
Group.update.makeEffect({ id, name }).pipe(
Effect.flatMap(repo.update),
Effect.orDie
)
)
const findById = Effect.fn("Groups.findById")((id: GroupId) =>
repo.findById(id).pipe(
Effect.catchTags({
NoSuchElementError: () => new GroupNotFound({ id }),
SchemaError: Effect.die,
SqlError: Effect.die
})
)
)
const list = listAll().pipe(
Effect.orDie,
Effect.withSpan("Groups.list")
)
return Groups.of({ create, rename, findById, list })
})
).pipe(
// Provide the layers locally, so lots of messy wiring doesn't need to
// happen in the "main" entrypoint of the application.
Layer.provide(SqlLive)
)
}
const program = Effect.gen(function*() {
const groups = yield* Groups
const engineering = yield* groups.create("Engineering", "engineering")
const design = yield* groups.create("Design", "design")
yield* groups.rename(design.id, "Product Design")
const found = yield* groups.findById(engineering.id)
yield* Effect.log("found group", found)
const all = yield* groups.list
yield* Effect.log(`total groups: ${all.length}`)
})
program.pipe(
Effect.provide(Groups.layer),
NodeRuntime.runMain
)