/** * @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")({ // 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", { 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 `_` 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 rename(id: GroupId, name: string): Effect.Effect findById(id: GroupId): Effect.Effect readonly list: Effect.Effect> }>()("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 )