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