@effect/platform-node 4.0.0-rc.108 → 4.0.0-rc.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -327,6 +327,17 @@ if (Predicate.isObject(thing)) {
327
327
  }
328
328
  ```
329
329
 
330
+ ## Working with SQL databases
331
+
332
+ Use the `effect/unstable/sql` modules together with a driver package such as
333
+ `@effect/sql-sqlite-node` to access SQL databases. Define domain models with
334
+ `Model.Class` to derive schemas for the database and JSON boundaries, run
335
+ migrations, and write type-safe queries.
336
+
337
+ - **[Getting started with SQL](./ai-docs/src/40_sql/10_basics.ts)**:
338
+ Define a schema-backed domain model, run migrations against a SQLite
339
+ database, and expose a derived repository through a service.
340
+
330
341
  ## Effect HttpClient
331
342
 
332
343
  Build http clients with the `HttpClient` module.
@@ -340,6 +351,9 @@ Build http clients with the `HttpClient` module.
340
351
  - **[Getting started with HttpApi](./ai-docs/src/51_http-server/10_basics.ts)**:
341
352
  Define a schema-first API, implement handlers, secure endpoints with
342
353
  middleware, serve it over HTTP, and call it using a generated typed client.
354
+ - **[Testing HttpApi implementations](./ai-docs/src/51_http-server/20_testing.ts)**:
355
+ Test handlers through an in-memory typed client with `HttpApiTest`, without
356
+ starting an HTTP server or touching a real database.
343
357
 
344
358
  ## Working with child processes
345
359
 
package/CLAUDE.md CHANGED
@@ -327,6 +327,17 @@ if (Predicate.isObject(thing)) {
327
327
  }
328
328
  ```
329
329
 
330
+ ## Working with SQL databases
331
+
332
+ Use the `effect/unstable/sql` modules together with a driver package such as
333
+ `@effect/sql-sqlite-node` to access SQL databases. Define domain models with
334
+ `Model.Class` to derive schemas for the database and JSON boundaries, run
335
+ migrations, and write type-safe queries.
336
+
337
+ - **[Getting started with SQL](./ai-docs/src/40_sql/10_basics.ts)**:
338
+ Define a schema-backed domain model, run migrations against a SQLite
339
+ database, and expose a derived repository through a service.
340
+
330
341
  ## Effect HttpClient
331
342
 
332
343
  Build http clients with the `HttpClient` module.
@@ -340,6 +351,9 @@ Build http clients with the `HttpClient` module.
340
351
  - **[Getting started with HttpApi](./ai-docs/src/51_http-server/10_basics.ts)**:
341
352
  Define a schema-first API, implement handlers, secure endpoints with
342
353
  middleware, serve it over HTTP, and call it using a generated typed client.
354
+ - **[Testing HttpApi implementations](./ai-docs/src/51_http-server/20_testing.ts)**:
355
+ Test handlers through an in-memory typed client with `HttpApiTest`, without
356
+ starting an HTTP server or touching a real database.
343
357
 
344
358
  ## Working with child processes
345
359
 
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ## Installation
6
6
 
7
7
  ```sh
8
- npm install effect@beta @effect/platform-node@beta
8
+ npm install effect@rc @effect/platform-node@rc
9
9
  ```
10
10
 
11
11
  ## Documentation
@@ -0,0 +1,175 @@
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
+ */
7
+ import { NodeRuntime } from "@effect/platform-node"
8
+ import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node"
9
+ import { Context, Effect, Layer, Schema } from "effect"
10
+ import { Model } from "effect/unstable/schema"
11
+ import { SqlClient, SqlModel, SqlSchema } from "effect/unstable/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.
15
+ export const GroupId = Schema.String.pipe(Schema.brand("GroupId"))
16
+ export 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
24
+ export 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
+
48
+ export 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.
55
+ const 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.
60
+ const 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.
81
+ const 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.
85
+ export 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
+
157
+ const 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
+
172
+ program.pipe(
173
+ Effect.provide(Groups.layer),
174
+ NodeRuntime.runMain
175
+ )
@@ -0,0 +1,6 @@
1
+ ## Working with SQL databases
2
+
3
+ Use the `effect/unstable/sql` modules together with a driver package such as
4
+ `@effect/sql-sqlite-node` to access SQL databases. Define domain models with
5
+ `Model.Class` to derive schemas for the database and JSON boundaries, run
6
+ migrations, and write type-safe queries.
@@ -24,7 +24,9 @@ const SystemApiHandlers = HttpApiBuilder.group(
24
24
  Api,
25
25
  "system",
26
26
  Effect.fn(function*(handlers) {
27
- return handlers.handle("health", () => Effect.void)
27
+ return handlers.handleAll({
28
+ health: () => Effect.void
29
+ })
28
30
  })
29
31
  )
30
32
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @title Testing HttpApi implementations
3
+ *
4
+ * Test handlers through an in-memory typed client with `HttpApiTest`, without
5
+ * starting an HTTP server or touching a real database.
6
+ */
7
+ import { assert, layer } from "@effect/vitest"
8
+ import { Effect, Layer } from "effect"
9
+ import { HttpClientRequest, HttpServer } from "effect/unstable/http"
10
+ import { HttpApiMiddleware, HttpApiTest } from "effect/unstable/httpapi"
11
+ import { Api } from "./fixtures/api/Api.ts"
12
+ import { Authorization } from "./fixtures/api/Authorization.ts"
13
+ import { UserId } from "./fixtures/domain/User.ts"
14
+ import { AuthorizationLayer } from "./fixtures/server/Authorization.ts"
15
+ import { Users } from "./fixtures/server/Users.ts"
16
+ import { UsersApiHandlersNoDeps } from "./fixtures/server/Users/http.ts"
17
+
18
+ // Provide the handlers with the in-memory `Users` implementation, so the full
19
+ // HTTP pipeline is exercised without any SQL. The Authorization middleware is
20
+ // provided with `Layer.provideMerge`, because the HTTP pipeline also resolves
21
+ // it when the routes are built.
22
+ const HandlersLayer = UsersApiHandlersNoDeps.pipe(
23
+ Layer.provide(Users.layerMemory),
24
+ Layer.provideMerge(AuthorizationLayer)
25
+ )
26
+
27
+ // The client-side Authorization middleware supplies the bearer token.
28
+ // Providing different middleware implementations lets the tests cover both
29
+ // authorized and unauthorized requests.
30
+ const AuthorizationMiddlewareGood = HttpApiMiddleware.layerClient(
31
+ Authorization,
32
+ ({ next, request }) => next(HttpClientRequest.bearerToken(request, "dev-token"))
33
+ )
34
+
35
+ const AuthorizationMiddlewareBad = HttpApiMiddleware.layerClient(
36
+ Authorization,
37
+ // Forward the request without attaching a token
38
+ ({ next, request }) => next(request)
39
+ )
40
+
41
+ // `HttpApiTest.groups` builds a typed client wired directly to the handlers of
42
+ // the selected groups, using the same request encoding, routing, and response
43
+ // decoding as a real server.
44
+ const makeClient = HttpApiTest.groups(Api, ["users"])
45
+
46
+ // `HttpServer.layerServices` provides the platform services the HTTP pipeline
47
+ // needs in tests.
48
+ layer(Layer.mergeAll(HandlersLayer, HttpServer.layerServices))("UsersApi", (it) => {
49
+ it.effect("lists, fetches, and creates users", () =>
50
+ Effect.gen(function*() {
51
+ const client = yield* makeClient
52
+
53
+ const created = yield* client.users.create({
54
+ payload: { name: "Alice", email: "alice@acme.dev" }
55
+ })
56
+ assert.strictEqual(created.name, "Alice")
57
+
58
+ const fetched = yield* client.users.getById({
59
+ params: { id: created.id }
60
+ })
61
+ assert.deepStrictEqual(fetched, created)
62
+
63
+ const all = yield* client.users.list({ query: {} })
64
+ assert.isTrue(all.some((user) => user.id === created.id))
65
+ }).pipe(Effect.provide(AuthorizationMiddlewareGood)))
66
+
67
+ it.effect("returns a 404 for a missing user", () =>
68
+ Effect.gen(function*() {
69
+ const client = yield* makeClient
70
+
71
+ // Use Effect.flip to assert on the error channel
72
+ const error = yield* client.users.getById({
73
+ params: { id: UserId.make("019845e1-682f-4b02-a706-3b2422d13aec") }
74
+ }).pipe(Effect.flip)
75
+ assert.strictEqual(error._tag, "UserNotFound")
76
+ }).pipe(Effect.provide(AuthorizationMiddlewareGood)))
77
+
78
+ it.effect("rejects requests without a valid bearer token", () =>
79
+ Effect.gen(function*() {
80
+ const client = yield* makeClient
81
+
82
+ const error = yield* client.users.list({ query: {} }).pipe(Effect.flip)
83
+ assert.strictEqual(error._tag, "Unauthorized")
84
+ }).pipe(Effect.provide(AuthorizationMiddlewareBad)))
85
+
86
+ it.effect("rejects requests with an invalid bearer token", () =>
87
+ Effect.gen(function*() {
88
+ const client = yield* makeClient
89
+
90
+ const error = yield* client.users.getById({
91
+ params: { id: UserId.make("019845e1-682f-4b02-a706-3b2422d13aec") }
92
+ }).pipe(Effect.flip)
93
+ assert.strictEqual(error._tag, "Unauthorized")
94
+ }).pipe(
95
+ Effect.provide(HttpApiMiddleware.layerClient(
96
+ Authorization,
97
+ ({ next, request }) => next(HttpClientRequest.bearerToken(request, "wrong-token"))
98
+ ))
99
+ ))
100
+ })
@@ -10,7 +10,10 @@ export class UsersApiGroup extends HttpApiGroup.make("users")
10
10
  query: {
11
11
  search: Schema.optional(Schema.String)
12
12
  },
13
- success: Schema.Array(User)
13
+ // Use the `json` variant of the model for API responses. It shares the
14
+ // field declarations with the database variants, but can encode values
15
+ // differently where needed.
16
+ success: Schema.Array(User.json)
14
17
  }),
15
18
  HttpApiEndpoint.get("search", "/search", {
16
19
  // For get requests, payload uses the query string
@@ -18,7 +21,7 @@ export class UsersApiGroup extends HttpApiGroup.make("users")
18
21
  search: Schema.String
19
22
  },
20
23
  success: [
21
- Schema.Array(User),
24
+ Schema.Array(User.json),
22
25
  Schema.String.pipe(HttpApiSchema.asText({
23
26
  contentType: "text/csv"
24
27
  }))
@@ -39,13 +42,12 @@ export class UsersApiGroup extends HttpApiGroup.make("users")
39
42
  }),
40
43
  HttpApiEndpoint.get("getById", "/:id", {
41
44
  params: {
42
- // Path parameter schemas need to be able to decode from strings.
43
- // Schema.decodeTo can be used to "bridge" between schemas
44
- id: Schema.FiniteFromString.pipe(
45
- Schema.decodeTo(UserId)
46
- )
45
+ // Path parameter values are automatically coerced from their string
46
+ // form using `Schema.toCodecStringTree`, so schemas that decode from
47
+ // other types (like numbers) work here as well.
48
+ id: UserId
47
49
  },
48
- success: User,
50
+ success: User.json,
49
51
  error: UserNotFound.pipe(
50
52
  // If you want an error to return no content, you can use
51
53
  // `HttpApiSchema.asNoContent` and provide a decoder that transforms the
@@ -59,14 +61,28 @@ export class UsersApiGroup extends HttpApiGroup.make("users")
59
61
  // For post requests, payload uses the request body. It defaults to JSON,
60
62
  // but you can specify other content types as well using
61
63
  // `HttpApiSchema.asText`, `HttpApiSchema.asMultipart`, etc.
62
- payload: Schema.Struct({
63
- name: Schema.String,
64
- email: Schema.String
65
- }),
66
- success: User
64
+ //
65
+ // The `jsonCreate` variant only exposes the fields clients are allowed
66
+ // to provide, so the generated id and timestamps cannot be set here.
67
+ payload: User.jsonCreate,
68
+ success: User.json
69
+ }),
70
+ HttpApiEndpoint.patch("update", "/:id", {
71
+ params: {
72
+ id: UserId
73
+ },
74
+ // The `jsonUpdate` variant similarly excludes the id and the managed
75
+ // timestamps from the update payload.
76
+ payload: User.jsonUpdate,
77
+ success: User.json,
78
+ error: UserNotFound.pipe(
79
+ HttpApiSchema.asNoContent({
80
+ decode: () => new UserNotFound()
81
+ })
82
+ )
67
83
  }),
68
84
  HttpApiEndpoint.get("me", "/me", {
69
- success: User,
85
+ success: User.json,
70
86
  error: UserNotFound.pipe(HttpApiSchema.status(404))
71
87
  })
72
88
  )
@@ -1,12 +1,21 @@
1
1
  import { Schema } from "effect"
2
+ import { Model } from "effect/unstable/schema"
2
3
 
3
- export const UserId = Schema.Int.pipe(
4
- Schema.brand("UserId")
5
- )
4
+ export const UserId = Schema.String.pipe(Schema.brand("UserId"))
6
5
  export type UserId = typeof UserId.Type
7
6
 
8
- export class User extends Schema.Class<User>("User")({
9
- id: UserId,
7
+ // `Model.Class` derives variants for the database (`User`, `User.insert`,
8
+ // `User.update`) and the JSON API (`User.json`, `User.jsonCreate`,
9
+ // `User.jsonUpdate`) from a single field declaration.
10
+ export class User extends Model.Class<User>("User")({
11
+ // A UUID v4 primary key generated by the application on insert. It is
12
+ // excluded from the `jsonCreate` / `jsonUpdate` variants, so API clients can
13
+ // never set it.
14
+ id: Model.UuidV4Insert(UserId),
10
15
  name: Schema.String,
11
- email: Schema.String
16
+ email: Schema.String,
17
+ // Timestamps are managed by the model: set on insert, refreshed on update,
18
+ // and also excluded from the JSON create / update variants.
19
+ createdAt: Model.DateTimeInsert,
20
+ updatedAt: Model.DateTimeUpdate
12
21
  }) {}
@@ -1,7 +1,16 @@
1
- import { Effect, Layer, Redacted } from "effect"
1
+ import { DateTime, Effect, Layer, Redacted } from "effect"
2
2
  import { Authorization, CurrentUser, Unauthorized } from "../api/Authorization.ts"
3
3
  import { User, UserId } from "../domain/User.ts"
4
4
 
5
+ const fixedTimestamp = DateTime.makeUnsafe("2026-01-01T00:00:00Z")
6
+ const devUser = new User({
7
+ id: UserId.make("bf3dbe33-0ad2-4c9c-9c9e-733e57bdcbee"),
8
+ name: "Dev User",
9
+ email: "dev@acme.com",
10
+ createdAt: fixedTimestamp,
11
+ updatedAt: fixedTimestamp
12
+ })
13
+
5
14
  // The implementation of the Authorization middleware. It is seperate from the
6
15
  // service definition to avoid leaking it into a client.
7
16
  export const AuthorizationLayer = Layer.effect(
@@ -21,15 +30,7 @@ export const AuthorizationLayer = Layer.effect(
21
30
 
22
31
  // Provide the current user to the rest of the stack. This will be
23
32
  // available in any endpoint or middleware that runs after this one.
24
- return yield* Effect.provideService(
25
- httpEffect,
26
- CurrentUser,
27
- new User({
28
- id: UserId.make(1),
29
- name: "Dev User",
30
- email: "dev@acme.com"
31
- })
32
- )
33
+ return yield* Effect.provideService(httpEffect, CurrentUser, devUser)
33
34
  })
34
35
  })
35
36
  })
@@ -5,49 +5,48 @@ import { CurrentUser } from "../../api/Authorization.ts"
5
5
  import { AuthorizationLayer } from "../Authorization.ts"
6
6
  import { Users } from "../Users.ts"
7
7
 
8
- export const UsersApiHandlers = HttpApiBuilder.group(
8
+ // The handlers without their dependencies provided, so tests can supply an
9
+ // alternative `Users` implementation.
10
+ export const UsersApiHandlersNoDeps = HttpApiBuilder.group(
9
11
  Api,
10
12
  "users",
11
13
  Effect.fn(function*(handlers) {
12
14
  const users = yield* Users
13
15
 
14
- return handlers
15
- .handle("list", ({ query }) =>
16
+ return handlers.handleAll({
17
+ list: ({ query }) =>
16
18
  users.list(query.search).pipe(
17
19
  // The list endpoint expects no errors, so we convert any potential
18
20
  // errors into a 500 Internal Server Error.
19
21
  Effect.orDie
20
- ))
21
- .handle(
22
- "search",
23
- Effect.fn(function*({ payload }) {
24
- if (payload.search === "bad-request") {
25
- // You can use the built in error types like any other
26
- // Schema.TaggedError
27
- return yield* new HttpApiError.RequestTimeout()
28
- }
29
- return yield* users.list(payload.search).pipe(
30
- Effect.catchReason(
31
- "UsersError",
32
- "SearchQueryTooShort",
33
- // Re-fail the "SearchQueryTooShort" reason
34
- Effect.fail,
35
- // All other reasons are unexpected, so we convert them into a 500
36
- // Internal Server Error.
37
- Effect.die
38
- )
22
+ ),
23
+ search: Effect.fn(function*({ payload }) {
24
+ if (payload.search === "bad-request") {
25
+ // You can use the built in error types like any other
26
+ // Schema.TaggedError
27
+ return yield* new HttpApiError.RequestTimeout()
28
+ }
29
+ return yield* users.list(payload.search).pipe(
30
+ Effect.catchReason(
31
+ "UsersError",
32
+ "SearchQueryTooShort",
33
+ // Re-fail the "SearchQueryTooShort" reason
34
+ Effect.fail,
35
+ // All other reasons are unexpected, so we convert them into a 500
36
+ // Internal Server Error.
37
+ Effect.die
39
38
  )
40
- })
41
- )
42
- .handle("getById", ({ params }) =>
39
+ )
40
+ }),
41
+ getById: ({ params }) =>
43
42
  users.getById(params.id).pipe(
44
43
  // You can also use Effect.catchReasons to handle multiple error
45
44
  // reasons at once
46
45
  Effect.catchReasons("UsersError", {
47
46
  UserNotFound: (e) => Effect.fail(e)
48
47
  }, Effect.die)
49
- ))
50
- .handle("create", ({ payload }) =>
48
+ ),
49
+ create: ({ payload }) =>
51
50
  users.create(payload).pipe(
52
51
  Effect.orDie
53
52
  // You could alse use Effect.unwrapReason to moves rror reasons up to
@@ -59,13 +58,23 @@ export const UsersApiHandlers = HttpApiBuilder.group(
59
58
  // UserNotFound: Effect.die,
60
59
  // SearchQueryTooShort: Effect.die
61
60
  // })
62
- ))
63
- .handle("me", () =>
61
+ ),
62
+ update: ({ params, payload }) =>
63
+ users.update(params.id, payload).pipe(
64
+ Effect.catchReasons("UsersError", {
65
+ UserNotFound: (e) => Effect.fail(e)
66
+ }, Effect.die)
67
+ ),
68
+ me: () =>
64
69
  // The Authorization middleware provides the CurrentUser service, so we
65
70
  // can access it here.
66
- CurrentUser)
71
+ CurrentUser
72
+ })
67
73
  })
68
- ).pipe(
69
- // Provide the dependencies for the handlers.
74
+ )
75
+
76
+ // The handlers with all dependencies provided, ready to serve. The SQL-backed
77
+ // `Users.layer` keeps the database wiring out of the server entrypoint.
78
+ export const UsersApiHandlers = UsersApiHandlersNoDeps.pipe(
70
79
  Layer.provide([Users.layer, AuthorizationLayer])
71
80
  )
@@ -1,28 +1,142 @@
1
- import { Context, Effect, Layer, Ref } from "effect"
2
- import { User, UserId } from "../domain/User.ts"
1
+ import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node"
2
+ import { Context, Effect, Layer, Schema } from "effect"
3
+ import { SqlClient, SqlModel, SqlSchema } from "effect/unstable/sql"
4
+ import { User } from "../domain/User.ts"
5
+ import type { UserId } from "../domain/User.ts"
3
6
  import { SearchQueryTooShort, UserNotFound, UsersError } from "../domain/UserErrors.ts"
4
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.
10
+ const 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.
15
+ const 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
+
5
32
  export class Users extends Context.Service<Users, {
6
33
  list(search: string | undefined): Effect.Effect<Array<User>, UsersError>
7
34
  getById(id: UserId): Effect.Effect<User, UsersError>
8
- create(input: { readonly name: string; readonly email: string }): 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>
9
37
  }>()("acme/Users") {
10
- static readonly layer = Layer.effect(
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(
11
41
  Users,
12
42
  Effect.gen(function*() {
13
- const users = new Map<number, User>([
14
- [
15
- 1,
16
- new User({
17
- id: UserId.make(1),
18
- name: "Admin",
19
- email: "admin@acme.dev"
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()
20
77
  })
21
- ]
22
- ])
23
- const nextId = yield* Ref.make(2)
78
+ }
79
+ yield* Effect.annotateCurrentSpan({ search })
80
+ return yield* Effect.orDie(searchUsers(search))
81
+ })
24
82
 
25
- const list = Effect.fn("UsersRepo.list")(function*(search: string | undefined) {
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) {
26
140
  const allUsers = Array.from(users.values())
27
141
  if (search === undefined || search.length === 0) {
28
142
  return allUsers
@@ -38,7 +152,7 @@ export class Users extends Context.Service<Users, {
38
152
  )
39
153
  })
40
154
 
41
- const getById = Effect.fn("UsersRepo.getById")(function*(id: UserId) {
155
+ const getById = Effect.fn("Users.getById")(function*(id: UserId) {
42
156
  yield* Effect.annotateCurrentSpan({ id })
43
157
  const user = users.get(id)
44
158
  if (user === undefined) {
@@ -49,14 +163,21 @@ export class Users extends Context.Service<Users, {
49
163
  return user
50
164
  })
51
165
 
52
- const create = Effect.fn("UsersRepo.create")(function*(input: { readonly name: string; readonly email: string }) {
53
- const id = yield* Ref.getAndUpdate(nextId, (current) => current + 1)
54
- const user = new User({ id: UserId.make(id), ...input })
166
+ const create = Effect.fn("Users.create")(function*(input: typeof User.jsonCreate.Type) {
167
+ const user = yield* makeUser(input)
55
168
  users.set(user.id, user)
56
169
  return user
57
170
  })
58
171
 
59
- return Users.of({ list, getById, create })
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 })
60
181
  })
61
182
  )
62
183
  }
@@ -5,7 +5,7 @@
5
5
  * handlers into a single executable command.
6
6
  */
7
7
  import { NodeRuntime, NodeServices } from "@effect/platform-node"
8
- import { Console, Effect } from "effect"
8
+ import { Console, Effect, Option, Schema } from "effect"
9
9
  import { Argument, Command, Flag } from "effect/unstable/cli"
10
10
 
11
11
  // You can define flags outside of commands and reuse them across multiple
@@ -29,18 +29,34 @@ const tasks = Command.make("tasks").pipe(
29
29
  Command.withDescription("Track and manage tasks")
30
30
  )
31
31
 
32
+ // Arguments and flags parse plain strings; use `withSchema` to validate or
33
+ // transform the parsed value with any schema.
34
+ const Email = Schema.String.pipe(
35
+ Schema.check(Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
36
+ message: "Expected a valid email address"
37
+ }))
38
+ )
39
+
32
40
  const create = Command.make(
33
41
  "create",
34
42
  {
35
43
  title: Argument.string("title").pipe(
36
- Argument.withDescription("Task title")
44
+ Argument.withDescription("Task title"),
45
+ // Reject empty titles at parse time, so the handler only ever sees
46
+ // valid input
47
+ Argument.withSchema(Schema.NonEmptyString)
37
48
  ),
38
49
  priority: Flag.choice("priority", ["low", "normal", "high"]).pipe(
39
50
  Flag.withDescription("Priority for the new task"),
40
51
  Flag.withDefault("normal")
52
+ ),
53
+ assignee: Flag.string("assignee").pipe(
54
+ Flag.withDescription("Email address of the person to assign"),
55
+ Flag.withSchema(Email),
56
+ Flag.optional
41
57
  )
42
58
  },
43
- Effect.fn(function*({ title, priority }) {
59
+ Effect.fn(function*({ assignee, priority, title }) {
44
60
  // Subcommands can read parent command input by yielding the parent command.
45
61
  const root = yield* tasks
46
62
 
@@ -49,6 +65,10 @@ const create = Command.make(
49
65
  }
50
66
 
51
67
  yield* Console.log(`Created "${title}" in ${root.workspace} with ${priority} priority`)
68
+
69
+ if (Option.isSome(assignee)) {
70
+ yield* Console.log(`Assigned to ${assignee.value}`)
71
+ }
52
72
  })
53
73
  ).pipe(
54
74
  Command.withDescription("Create a task"),
@@ -56,6 +76,10 @@ const create = Command.make(
56
76
  {
57
77
  command: "tasks create \"Ship 4.0\" --priority high",
58
78
  description: "Create a high-priority task"
79
+ },
80
+ {
81
+ command: "tasks create \"Ship 4.0\" --assignee dev@acme.com",
82
+ description: "Create a task assigned to a team member"
59
83
  }
60
84
  ])
61
85
  )
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Node.js Redis integration backed by `ioredis`.
2
+ * Node.js Redis integration backed by `redis` (node-redis).
3
3
  *
4
- * This module creates a scoped `ioredis` client and exposes it in two forms:
4
+ * This module creates a scoped `node-redis` client and exposes it in two forms:
5
5
  * the generic `Redis` service and the {@link NodeRedis} service for direct
6
- * access to the underlying client. `layer` accepts ioredis options directly,
7
- * while `layerConfig` reads them from Effect config. Both layers close the
8
- * client when the layer scope ends.
6
+ * access to the underlying client. `layer` accepts node-redis client options
7
+ * directly, while `layerConfig` reads them from Effect config. `node-redis`
8
+ * connects explicitly, so layer construction can fail with a `RedisError`.
9
+ * Both layers close the client when the layer scope ends.
9
10
  *
10
11
  * @since 4.0.0
11
12
  */
@@ -14,14 +15,16 @@ import * as Context from "effect/Context";
14
15
  import * as Effect from "effect/Effect";
15
16
  import * as Layer from "effect/Layer";
16
17
  import * as Redis from "effect/unstable/persistence/Redis";
17
- import * as IoRedis from "ioredis";
18
+ import { createClient } from "redis";
19
+ type NodeRedisClient = ReturnType<typeof createClient>;
20
+ type NodeRedisClientOptions = NonNullable<Parameters<typeof createClient>[0]>;
18
21
  declare const NodeRedis_base: Context.ServiceClass<NodeRedis, "@effect/platform-node/NodeRedis", {
19
- readonly client: IoRedis.Redis;
20
- readonly use: <A>(f: (client: IoRedis.Redis) => Promise<A>) => Effect.Effect<A, Redis.RedisError>;
22
+ readonly client: NodeRedisClient;
23
+ readonly use: <A>(f: (client: NodeRedisClient) => Promise<A>) => Effect.Effect<A, Redis.RedisError>;
21
24
  }>;
22
25
  /**
23
26
  * Service tag for the Node Redis integration, exposing the underlying
24
- * `ioredis` client and a `use` helper that maps client failures to
27
+ * `node-redis` client and a `use` helper that maps client failures to
25
28
  * `RedisError`.
26
29
  *
27
30
  * @category services
@@ -30,20 +33,42 @@ declare const NodeRedis_base: Context.ServiceClass<NodeRedis, "@effect/platform-
30
33
  export declare class NodeRedis extends NodeRedis_base {
31
34
  }
32
35
  /**
33
- * Provides `Redis` and `NodeRedis` services backed by an `ioredis` client
34
- * created with the supplied options and closed when the layer scope ends.
36
+ * Provides `Redis` and `NodeRedis` services backed by a `node-redis` client
37
+ * created with the supplied options, connected when the layer is built and
38
+ * closed when the layer scope ends.
39
+ *
40
+ * **Details**
41
+ *
42
+ * By default, the initial connection fails on its first connection error. A
43
+ * caller-supplied `socket.reconnectStrategy` is used instead when present. Once
44
+ * the client has emitted `ready`, the default reconnect strategy uses
45
+ * node-redis' exponential backoff and stops on socket timeouts.
46
+ *
47
+ * Scope finalization calls `close()`, which waits for in-flight commands,
48
+ * including blocking commands, and can therefore delay scope closure.
35
49
  *
36
50
  * @category layers
37
51
  * @since 4.0.0
38
52
  */
39
- export declare const layer: (options?: IoRedis.RedisOptions | undefined) => Layer.Layer<Redis.Redis | NodeRedis>;
53
+ export declare const layer: (options?: NodeRedisClientOptions | undefined) => Layer.Layer<Redis.Redis | NodeRedis, Redis.RedisError>;
40
54
  /**
41
- * Provides `Redis` and `NodeRedis` services from `Config`-backed ioredis
42
- * options, closing the client when the layer scope ends.
55
+ * Provides `Redis` and `NodeRedis` services from `Config`-backed node-redis
56
+ * client options, connecting the client when the layer is built and closing it
57
+ * when the layer scope ends.
58
+ *
59
+ * **Details**
60
+ *
61
+ * By default, the initial connection fails on its first connection error. A
62
+ * caller-supplied `socket.reconnectStrategy` is used instead when present. Once
63
+ * the client has emitted `ready`, the default reconnect strategy uses
64
+ * node-redis' exponential backoff and stops on socket timeouts.
65
+ *
66
+ * Scope finalization calls `close()`, which waits for in-flight commands,
67
+ * including blocking commands, and can therefore delay scope closure.
43
68
  *
44
69
  * @category layers
45
70
  * @since 4.0.0
46
71
  */
47
- export declare const layerConfig: (options: Config.Wrap<IoRedis.RedisOptions>) => Layer.Layer<Redis.Redis | NodeRedis, Config.ConfigError>;
72
+ export declare const layerConfig: (options: Config.Wrap<NodeRedisClientOptions>) => Layer.Layer<Redis.Redis | NodeRedis, Redis.RedisError | Config.ConfigError>;
48
73
  export {};
49
74
  //# sourceMappingURL=NodeRedis.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"NodeRedis.d.ts","sourceRoot":"","sources":["../src/NodeRedis.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,OAAO,MAAM,gBAAgB,CAAA;AACzC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAEvC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AAErC,OAAO,KAAK,KAAK,MAAM,mCAAmC,CAAA;AAC1D,OAAO,KAAK,OAAO,MAAM,SAAS,CAAA;;qBAWf,OAAO,CAAC,KAAK;kBAChB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC;;AAVnG;;;;;;;GAOG;AACH,qBAAa,SAAU,SAAQ,cAGQ;CAAG;AAiC1C;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,aACN,OAAO,CAAC,YAAY,GAAG,SAAS,KACzC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,CAAuC,CAAA;AAE7E;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,CACxB,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,KACvC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,EAAE,MAAM,CAAC,WAAW,CAOzD,CAAA"}
1
+ {"version":3,"file":"NodeRedis.d.ts","sourceRoot":"","sources":["../src/NodeRedis.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,OAAO,MAAM,gBAAgB,CAAA;AACzC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAEvC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AACrC,OAAO,KAAK,KAAK,MAAM,mCAAmC,CAAA;AAC1D,OAAO,EAAE,YAAY,EAAsB,MAAM,OAAO,CAAA;AAExD,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAA;AACtD,KAAK,sBAAsB,GAAG,WAAW,CAAC,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;;qBAW1D,eAAe;kBAClB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC;;AAVrG;;;;;;;GAOG;AACH,qBAAa,SAAU,SAAQ,cAGQ;CAAG;AAoE1C;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,KAAK,aACN,sBAAsB,GAAG,SAAS,KAC3C,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,EAAE,KAAK,CAAC,UAAU,CAAuC,CAAA;AAE/F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,WAAW,EAAE,CACxB,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,sBAAsB,CAAC,KACzC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,EAAE,KAAK,CAAC,UAAU,GAAG,MAAM,CAAC,WAAW,CAO5E,CAAA"}
package/dist/NodeRedis.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Node.js Redis integration backed by `ioredis`.
2
+ * Node.js Redis integration backed by `redis` (node-redis).
3
3
  *
4
- * This module creates a scoped `ioredis` client and exposes it in two forms:
4
+ * This module creates a scoped `node-redis` client and exposes it in two forms:
5
5
  * the generic `Redis` service and the {@link NodeRedis} service for direct
6
- * access to the underlying client. `layer` accepts ioredis options directly,
7
- * while `layerConfig` reads them from Effect config. Both layers close the
8
- * client when the layer scope ends.
6
+ * access to the underlying client. `layer` accepts node-redis client options
7
+ * directly, while `layerConfig` reads them from Effect config. `node-redis`
8
+ * connects explicitly, so layer construction can fail with a `RedisError`.
9
+ * Both layers close the client when the layer scope ends.
9
10
  *
10
11
  * @since 4.0.0
11
12
  */
@@ -14,12 +15,11 @@ import * as Context from "effect/Context";
14
15
  import * as Effect from "effect/Effect";
15
16
  import * as Fn from "effect/Function";
16
17
  import * as Layer from "effect/Layer";
17
- import * as Scope from "effect/Scope";
18
18
  import * as Redis from "effect/unstable/persistence/Redis";
19
- import * as IoRedis from "ioredis";
19
+ import { createClient, SocketTimeoutError } from "redis";
20
20
  /**
21
21
  * Service tag for the Node Redis integration, exposing the underlying
22
- * `ioredis` client and a `use` helper that maps client failures to
22
+ * `node-redis` client and a `use` helper that maps client failures to
23
23
  * `RedisError`.
24
24
  *
25
25
  * @category services
@@ -27,9 +27,37 @@ import * as IoRedis from "ioredis";
27
27
  */
28
28
  export class NodeRedis extends /*#__PURE__*/Context.Service()("@effect/platform-node/NodeRedis") {}
29
29
  const make = /*#__PURE__*/Effect.fnUntraced(function* (options) {
30
- const scope = yield* Effect.scope;
31
- yield* Scope.addFinalizer(scope, Effect.promise(() => client.quit()));
32
- const client = new IoRedis.Redis(options ?? {});
30
+ let ready = false;
31
+ const socket = options?.socket;
32
+ const client = yield* Effect.acquireRelease(Effect.sync(() => createClient({
33
+ ...options,
34
+ socket: socket?.reconnectStrategy === undefined ? {
35
+ ...socket,
36
+ reconnectStrategy: (retries, cause) => {
37
+ if (!ready) return cause;
38
+ if (cause instanceof SocketTimeoutError) return false;
39
+ const jitter = Math.floor(Math.random() * 200);
40
+ const delay = Math.min(2 ** retries * 50, 2000);
41
+ return delay + jitter;
42
+ }
43
+ } : socket
44
+ })), client => Effect.ignoreCause(Effect.promise(() => client.close())));
45
+ client.once("ready", () => {
46
+ ready = true;
47
+ });
48
+ // node-redis rethrows `error` events that have no listener, which would crash
49
+ // the process on a transient socket failure. Command failures are still
50
+ // reported as `RedisError`.
51
+ const runSync = Effect.runSyncWith(yield* Effect.context());
52
+ client.on("error", cause => {
53
+ runSync(Effect.logWarning("NodeRedis client error", cause));
54
+ });
55
+ yield* Effect.tryPromise({
56
+ try: () => client.connect(),
57
+ catch: cause => new Redis.RedisError({
58
+ cause
59
+ })
60
+ });
33
61
  const use = f => Effect.tryPromise({
34
62
  try: () => f(client),
35
63
  catch: cause => new Redis.RedisError({
@@ -38,7 +66,7 @@ const make = /*#__PURE__*/Effect.fnUntraced(function* (options) {
38
66
  });
39
67
  const redis = yield* Redis.make({
40
68
  send: (command, ...args) => Effect.tryPromise({
41
- try: () => client.call(command, ...args),
69
+ try: () => client.sendCommand([command, ...args]),
42
70
  catch: cause => new Redis.RedisError({
43
71
  cause
44
72
  })
@@ -51,16 +79,38 @@ const make = /*#__PURE__*/Effect.fnUntraced(function* (options) {
51
79
  return Context.make(NodeRedis, nodeRedis).pipe(Context.add(Redis.Redis, redis));
52
80
  });
53
81
  /**
54
- * Provides `Redis` and `NodeRedis` services backed by an `ioredis` client
55
- * created with the supplied options and closed when the layer scope ends.
82
+ * Provides `Redis` and `NodeRedis` services backed by a `node-redis` client
83
+ * created with the supplied options, connected when the layer is built and
84
+ * closed when the layer scope ends.
85
+ *
86
+ * **Details**
87
+ *
88
+ * By default, the initial connection fails on its first connection error. A
89
+ * caller-supplied `socket.reconnectStrategy` is used instead when present. Once
90
+ * the client has emitted `ready`, the default reconnect strategy uses
91
+ * node-redis' exponential backoff and stops on socket timeouts.
92
+ *
93
+ * Scope finalization calls `close()`, which waits for in-flight commands,
94
+ * including blocking commands, and can therefore delay scope closure.
56
95
  *
57
96
  * @category layers
58
97
  * @since 4.0.0
59
98
  */
60
99
  export const layer = options => Layer.effectContext(make(options));
61
100
  /**
62
- * Provides `Redis` and `NodeRedis` services from `Config`-backed ioredis
63
- * options, closing the client when the layer scope ends.
101
+ * Provides `Redis` and `NodeRedis` services from `Config`-backed node-redis
102
+ * client options, connecting the client when the layer is built and closing it
103
+ * when the layer scope ends.
104
+ *
105
+ * **Details**
106
+ *
107
+ * By default, the initial connection fails on its first connection error. A
108
+ * caller-supplied `socket.reconnectStrategy` is used instead when present. Once
109
+ * the client has emitted `ready`, the default reconnect strategy uses
110
+ * node-redis' exponential backoff and stops on socket timeouts.
111
+ *
112
+ * Scope finalization calls `close()`, which waits for in-flight commands,
113
+ * including blocking commands, and can therefore delay scope closure.
64
114
  *
65
115
  * @category layers
66
116
  * @since 4.0.0
@@ -1 +1 @@
1
- {"version":3,"file":"NodeRedis.js","names":[],"sources":["../src/NodeRedis.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;AAWA,OAAO,KAAK,MAAM,MAAM,eAAe;AACvC,OAAO,KAAK,OAAO,MAAM,gBAAgB;AACzC,OAAO,KAAK,MAAM,MAAM,eAAe;AACvC,OAAO,KAAK,EAAE,MAAM,iBAAiB;AACrC,OAAO,KAAK,KAAK,MAAM,cAAc;AACrC,OAAO,KAAK,KAAK,MAAM,cAAc;AACrC,OAAO,KAAK,KAAK,MAAM,mCAAmC;AAC1D,OAAO,KAAK,OAAO,MAAM,SAAS;AAElC;;;;;;;;AAQA,OAAM,MAAO,SAAU,sBAAQ,OAAO,CAAC,OAAO,EAG1C,CAAC,iCAAiC,CAAC;AAEvC,MAAM,IAAI,gBAAG,MAAM,CAAC,UAAU,CAAC,WAC7B,OAA8B;EAE9B,MAAM,KAAK,GAAG,OAAO,MAAM,CAAC,KAAK;EACjC,OAAO,KAAK,CAAC,YAAY,CAAC,KAAK,EAAE,MAAM,CAAC,OAAO,CAAC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;EACrE,MAAM,MAAM,GAAG,IAAI,OAAO,CAAC,KAAK,CAAC,OAAO,IAAI,EAAE,CAAC;EAE/C,MAAM,GAAG,GAAO,CAAwC,IACtD,MAAM,CAAC,UAAU,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC;IACpB,KAAK,EAAG,KAAK,IAAK,IAAI,KAAK,CAAC,UAAU,CAAC;MAAE;IAAK,CAAE;GACjD,CAAC;EAEJ,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC,IAAI,CAAC;IAC9B,IAAI,EAAE,CAAc,OAAe,EAAE,GAAG,IAA2B,KACjE,MAAM,CAAC,UAAU,CAAC;MAChB,GAAG,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,IAAI,CAAe;MACtD,KAAK,EAAG,KAAK,IAAK,IAAI,KAAK,CAAC,UAAU,CAAC;QAAE;MAAK,CAAE;KACjD;GACJ,CAAC;EAEF,MAAM,SAAS,GAAG,EAAE,CAAC,QAAQ,CAAuB;IAClD,MAAM;IACN;GACD,CAAC;EAEF,OAAO,OAAO,CAAC,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,IAAI,CAC5C,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,CAChC;AACH,CAAC,CAAC;AAEF;;;;;;;AAOA,OAAO,MAAM,KAAK,GAChB,OAA0C,IACD,KAAK,CAAC,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AAE7E;;;;;;;AAOA,OAAO,MAAM,WAAW,GAGtB,OAA0C,IAE1C,KAAK,CAAC,aAAa,CACjB,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CACzB,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CACrB,CACF","ignoreList":[]}
1
+ {"version":3,"file":"NodeRedis.js","names":[],"sources":["../src/NodeRedis.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;AAYA,OAAO,KAAK,MAAM,MAAM,eAAe;AACvC,OAAO,KAAK,OAAO,MAAM,gBAAgB;AACzC,OAAO,KAAK,MAAM,MAAM,eAAe;AACvC,OAAO,KAAK,EAAE,MAAM,iBAAiB;AACrC,OAAO,KAAK,KAAK,MAAM,cAAc;AACrC,OAAO,KAAK,KAAK,MAAM,mCAAmC;AAC1D,SAAS,YAAY,EAAE,kBAAkB,QAAQ,OAAO;AAKxD;;;;;;;;AAQA,OAAM,MAAO,SAAU,sBAAQ,OAAO,CAAC,OAAO,EAG1C,CAAC,iCAAiC,CAAC;AAEvC,MAAM,IAAI,gBAAG,MAAM,CAAC,UAAU,CAAC,WAC7B,OAAgC;EAEhC,IAAI,KAAK,GAAG,KAAK;EACjB,MAAM,MAAM,GAAG,OAAO,EAAE,MAAM;EAC9B,MAAM,MAAM,GAAG,OAAO,MAAM,CAAC,cAAc,CACzC,MAAM,CAAC,IAAI,CAAC,MACV,YAAY,CAAC;IACX,GAAG,OAAO;IACV,MAAM,EAAE,MAAM,EAAE,iBAAiB,KAAK,SAAS,GAC3C;MACA,GAAG,MAAM;MACT,iBAAiB,EAAE,CAAC,OAAO,EAAE,KAAK,KAAI;QACpC,IAAI,CAAC,KAAK,EAAE,OAAO,KAAK;QACxB,IAAI,KAAK,YAAY,kBAAkB,EAAE,OAAO,KAAK;QACrD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,GAAG,CAAC;QAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,OAAO,GAAG,EAAE,EAAE,IAAI,CAAC;QAC/C,OAAO,KAAK,GAAG,MAAM;MACvB;KACD,GACC;GACL,CAAC,CACH,EACA,MAAM,IAAK,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CACrE;EACD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAK;IACxB,KAAK,GAAG,IAAI;EACd,CAAC,CAAC;EAEF;EACA;EACA;EACA,MAAM,OAAO,GAAG,MAAM,CAAC,WAAW,CAAC,OAAO,MAAM,CAAC,OAAO,EAAS,CAAC;EAClE,MAAM,CAAC,EAAE,CAAC,OAAO,EAAG,KAAK,IAAI;IAC3B,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,wBAAwB,EAAE,KAAK,CAAC,CAAC;EAC7D,CAAC,CAAC;EAEF,OAAO,MAAM,CAAC,UAAU,CAAC;IACvB,GAAG,EAAE,MAAM,MAAM,CAAC,OAAO,EAAE;IAC3B,KAAK,EAAG,KAAK,IAAK,IAAI,KAAK,CAAC,UAAU,CAAC;MAAE;IAAK,CAAE;GACjD,CAAC;EAEF,MAAM,GAAG,GAAO,CAA0C,IACxD,MAAM,CAAC,UAAU,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC;IACpB,KAAK,EAAG,KAAK,IAAK,IAAI,KAAK,CAAC,UAAU,CAAC;MAAE;IAAK,CAAE;GACjD,CAAC;EAEJ,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC,IAAI,CAAC;IAC9B,IAAI,EAAE,CAAc,OAAe,EAAE,GAAG,IAA2B,KACjE,MAAM,CAAC,UAAU,CAAC;MAChB,GAAG,EAAE,MAAM,MAAM,CAAC,WAAW,CAAC,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,CAAe;MAC/D,KAAK,EAAG,KAAK,IAAK,IAAI,KAAK,CAAC,UAAU,CAAC;QAAE;MAAK,CAAE;KACjD;GACJ,CAAC;EAEF,MAAM,SAAS,GAAG,EAAE,CAAC,QAAQ,CAAuB;IAClD,MAAM;IACN;GACD,CAAC;EAEF,OAAO,OAAO,CAAC,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,IAAI,CAC5C,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,CAChC;AACH,CAAC,CAAC;AAEF;;;;;;;;;;;;;;;;;;AAkBA,OAAO,MAAM,KAAK,GAChB,OAA4C,IACe,KAAK,CAAC,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AAE/F;;;;;;;;;;;;;;;;;;AAkBA,OAAO,MAAM,WAAW,GAGtB,OAA4C,IAE5C,KAAK,CAAC,aAAa,CACjB,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CACzB,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CACrB,CACF","ignoreList":[]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@effect/platform-node",
3
3
  "type": "module",
4
- "version": "4.0.0-rc.108",
4
+ "version": "4.0.0-rc.109",
5
5
  "license": "MIT",
6
6
  "description": "Platform specific implementations for the Node.js runtime",
7
7
  "homepage": "https://effect.website",
@@ -54,18 +54,19 @@
54
54
  "dependencies": {
55
55
  "mime": "^4.1.0",
56
56
  "undici": "^8.7.0",
57
- "@effect/platform-node-shared": "^4.0.0-rc.108"
57
+ "@effect/platform-node-shared": "^4.0.0-rc.109"
58
58
  },
59
59
  "peerDependencies": {
60
- "ioredis": ">=5.7.0 <6.0.0",
61
- "effect": "^4.0.0-rc.108"
60
+ "redis": ">=5.0.0 <7.0.0",
61
+ "effect": "^4.0.0-rc.109"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@testcontainers/mysql": "^12.0.4",
65
65
  "@testcontainers/postgresql": "^12.0.4",
66
66
  "@testcontainers/redis": "^12.0.4",
67
67
  "@types/node": "^26.1.2",
68
- "effect": "^4.0.0-rc.108"
68
+ "redis": "^6.2.1",
69
+ "effect": "^4.0.0-rc.109"
69
70
  },
70
71
  "scripts": {
71
72
  "codegen": "effect-utils codegen",
package/src/NodeRedis.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Node.js Redis integration backed by `ioredis`.
2
+ * Node.js Redis integration backed by `redis` (node-redis).
3
3
  *
4
- * This module creates a scoped `ioredis` client and exposes it in two forms:
4
+ * This module creates a scoped `node-redis` client and exposes it in two forms:
5
5
  * the generic `Redis` service and the {@link NodeRedis} service for direct
6
- * access to the underlying client. `layer` accepts ioredis options directly,
7
- * while `layerConfig` reads them from Effect config. Both layers close the
8
- * client when the layer scope ends.
6
+ * access to the underlying client. `layer` accepts node-redis client options
7
+ * directly, while `layerConfig` reads them from Effect config. `node-redis`
8
+ * connects explicitly, so layer construction can fail with a `RedisError`.
9
+ * Both layers close the client when the layer scope ends.
9
10
  *
10
11
  * @since 4.0.0
11
12
  */
@@ -14,31 +15,68 @@ import * as Context from "effect/Context"
14
15
  import * as Effect from "effect/Effect"
15
16
  import * as Fn from "effect/Function"
16
17
  import * as Layer from "effect/Layer"
17
- import * as Scope from "effect/Scope"
18
18
  import * as Redis from "effect/unstable/persistence/Redis"
19
- import * as IoRedis from "ioredis"
19
+ import { createClient, SocketTimeoutError } from "redis"
20
+
21
+ type NodeRedisClient = ReturnType<typeof createClient>
22
+ type NodeRedisClientOptions = NonNullable<Parameters<typeof createClient>[0]>
20
23
 
21
24
  /**
22
25
  * Service tag for the Node Redis integration, exposing the underlying
23
- * `ioredis` client and a `use` helper that maps client failures to
26
+ * `node-redis` client and a `use` helper that maps client failures to
24
27
  * `RedisError`.
25
28
  *
26
29
  * @category services
27
30
  * @since 4.0.0
28
31
  */
29
32
  export class NodeRedis extends Context.Service<NodeRedis, {
30
- readonly client: IoRedis.Redis
31
- readonly use: <A>(f: (client: IoRedis.Redis) => Promise<A>) => Effect.Effect<A, Redis.RedisError>
33
+ readonly client: NodeRedisClient
34
+ readonly use: <A>(f: (client: NodeRedisClient) => Promise<A>) => Effect.Effect<A, Redis.RedisError>
32
35
  }>()("@effect/platform-node/NodeRedis") {}
33
36
 
34
37
  const make = Effect.fnUntraced(function*(
35
- options?: IoRedis.RedisOptions
38
+ options?: NodeRedisClientOptions
36
39
  ) {
37
- const scope = yield* Effect.scope
38
- yield* Scope.addFinalizer(scope, Effect.promise(() => client.quit()))
39
- const client = new IoRedis.Redis(options ?? {})
40
+ let ready = false
41
+ const socket = options?.socket
42
+ const client = yield* Effect.acquireRelease(
43
+ Effect.sync((): NodeRedisClient =>
44
+ createClient({
45
+ ...options,
46
+ socket: socket?.reconnectStrategy === undefined
47
+ ? {
48
+ ...socket,
49
+ reconnectStrategy: (retries, cause) => {
50
+ if (!ready) return cause
51
+ if (cause instanceof SocketTimeoutError) return false
52
+ const jitter = Math.floor(Math.random() * 200)
53
+ const delay = Math.min(2 ** retries * 50, 2000)
54
+ return delay + jitter
55
+ }
56
+ }
57
+ : socket
58
+ })
59
+ ),
60
+ (client) => Effect.ignoreCause(Effect.promise(() => client.close()))
61
+ )
62
+ client.once("ready", () => {
63
+ ready = true
64
+ })
65
+
66
+ // node-redis rethrows `error` events that have no listener, which would crash
67
+ // the process on a transient socket failure. Command failures are still
68
+ // reported as `RedisError`.
69
+ const runSync = Effect.runSyncWith(yield* Effect.context<never>())
70
+ client.on("error", (cause) => {
71
+ runSync(Effect.logWarning("NodeRedis client error", cause))
72
+ })
73
+
74
+ yield* Effect.tryPromise({
75
+ try: () => client.connect(),
76
+ catch: (cause) => new Redis.RedisError({ cause })
77
+ })
40
78
 
41
- const use = <A>(f: (client: IoRedis.Redis) => Promise<A>) =>
79
+ const use = <A>(f: (client: NodeRedisClient) => Promise<A>) =>
42
80
  Effect.tryPromise({
43
81
  try: () => f(client),
44
82
  catch: (cause) => new Redis.RedisError({ cause })
@@ -47,7 +85,7 @@ const make = Effect.fnUntraced(function*(
47
85
  const redis = yield* Redis.make({
48
86
  send: <A = unknown>(command: string, ...args: ReadonlyArray<string>) =>
49
87
  Effect.tryPromise({
50
- try: () => client.call(command, ...args) as Promise<A>,
88
+ try: () => client.sendCommand([command, ...args]) as Promise<A>,
51
89
  catch: (cause) => new Redis.RedisError({ cause })
52
90
  })
53
91
  })
@@ -63,28 +101,50 @@ const make = Effect.fnUntraced(function*(
63
101
  })
64
102
 
65
103
  /**
66
- * Provides `Redis` and `NodeRedis` services backed by an `ioredis` client
67
- * created with the supplied options and closed when the layer scope ends.
104
+ * Provides `Redis` and `NodeRedis` services backed by a `node-redis` client
105
+ * created with the supplied options, connected when the layer is built and
106
+ * closed when the layer scope ends.
107
+ *
108
+ * **Details**
109
+ *
110
+ * By default, the initial connection fails on its first connection error. A
111
+ * caller-supplied `socket.reconnectStrategy` is used instead when present. Once
112
+ * the client has emitted `ready`, the default reconnect strategy uses
113
+ * node-redis' exponential backoff and stops on socket timeouts.
114
+ *
115
+ * Scope finalization calls `close()`, which waits for in-flight commands,
116
+ * including blocking commands, and can therefore delay scope closure.
68
117
  *
69
118
  * @category layers
70
119
  * @since 4.0.0
71
120
  */
72
121
  export const layer = (
73
- options?: IoRedis.RedisOptions | undefined
74
- ): Layer.Layer<Redis.Redis | NodeRedis> => Layer.effectContext(make(options))
122
+ options?: NodeRedisClientOptions | undefined
123
+ ): Layer.Layer<Redis.Redis | NodeRedis, Redis.RedisError> => Layer.effectContext(make(options))
75
124
 
76
125
  /**
77
- * Provides `Redis` and `NodeRedis` services from `Config`-backed ioredis
78
- * options, closing the client when the layer scope ends.
126
+ * Provides `Redis` and `NodeRedis` services from `Config`-backed node-redis
127
+ * client options, connecting the client when the layer is built and closing it
128
+ * when the layer scope ends.
129
+ *
130
+ * **Details**
131
+ *
132
+ * By default, the initial connection fails on its first connection error. A
133
+ * caller-supplied `socket.reconnectStrategy` is used instead when present. Once
134
+ * the client has emitted `ready`, the default reconnect strategy uses
135
+ * node-redis' exponential backoff and stops on socket timeouts.
136
+ *
137
+ * Scope finalization calls `close()`, which waits for in-flight commands,
138
+ * including blocking commands, and can therefore delay scope closure.
79
139
  *
80
140
  * @category layers
81
141
  * @since 4.0.0
82
142
  */
83
143
  export const layerConfig: (
84
- options: Config.Wrap<IoRedis.RedisOptions>
85
- ) => Layer.Layer<Redis.Redis | NodeRedis, Config.ConfigError> = (
86
- options: Config.Wrap<IoRedis.RedisOptions>
87
- ): Layer.Layer<Redis.Redis | NodeRedis, Config.ConfigError> =>
144
+ options: Config.Wrap<NodeRedisClientOptions>
145
+ ) => Layer.Layer<Redis.Redis | NodeRedis, Redis.RedisError | Config.ConfigError> = (
146
+ options: Config.Wrap<NodeRedisClientOptions>
147
+ ): Layer.Layer<Redis.Redis | NodeRedis, Redis.RedisError | Config.ConfigError> =>
88
148
  Layer.effectContext(
89
149
  Config.unwrap(options).pipe(
90
150
  Effect.flatMap(make)