@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 +14 -0
- package/CLAUDE.md +14 -0
- package/README.md +1 -1
- package/ai-docs/src/40_sql/10_basics.ts +175 -0
- package/ai-docs/src/40_sql/index.md +6 -0
- package/ai-docs/src/51_http-server/10_basics.ts +3 -1
- package/ai-docs/src/51_http-server/20_testing.ts +100 -0
- package/ai-docs/src/51_http-server/fixtures/api/Users.ts +30 -14
- package/ai-docs/src/51_http-server/fixtures/domain/User.ts +15 -6
- package/ai-docs/src/51_http-server/fixtures/server/Authorization.ts +11 -10
- package/ai-docs/src/51_http-server/fixtures/server/Users/http.ts +41 -32
- package/ai-docs/src/51_http-server/fixtures/server/Users.ts +141 -20
- package/ai-docs/src/70_cli/10_basics.ts +27 -3
- package/dist/NodeRedis.d.ts +40 -15
- package/dist/NodeRedis.d.ts.map +1 -1
- package/dist/NodeRedis.js +66 -16
- package/dist/NodeRedis.js.map +1 -1
- package/package.json +6 -5
- package/src/NodeRedis.ts +86 -26
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
|
@@ -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.
|
|
@@ -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
|
-
|
|
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
|
|
43
|
-
// Schema.
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
69
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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:
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
78
|
+
}
|
|
79
|
+
yield* Effect.annotateCurrentSpan({ search })
|
|
80
|
+
return yield* Effect.orDie(searchUsers(search))
|
|
81
|
+
})
|
|
24
82
|
|
|
25
|
-
const
|
|
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("
|
|
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("
|
|
53
|
-
const
|
|
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
|
-
|
|
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*({
|
|
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
|
)
|
package/dist/NodeRedis.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node.js Redis integration backed by `
|
|
2
|
+
* Node.js Redis integration backed by `redis` (node-redis).
|
|
3
3
|
*
|
|
4
|
-
* This module creates a scoped `
|
|
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
|
|
7
|
-
* while `layerConfig` reads them from Effect config.
|
|
8
|
-
*
|
|
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
|
|
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:
|
|
20
|
-
readonly use: <A>(f: (client:
|
|
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
|
-
* `
|
|
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
|
|
34
|
-
* created with the supplied options
|
|
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?:
|
|
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
|
|
42
|
-
* options,
|
|
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<
|
|
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
|
package/dist/NodeRedis.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NodeRedis.d.ts","sourceRoot":"","sources":["../src/NodeRedis.ts"],"names":[],"mappings":"AAAA
|
|
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 `
|
|
2
|
+
* Node.js Redis integration backed by `redis` (node-redis).
|
|
3
3
|
*
|
|
4
|
-
* This module creates a scoped `
|
|
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
|
|
7
|
-
* while `layerConfig` reads them from Effect config.
|
|
8
|
-
*
|
|
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
|
|
19
|
+
import { createClient, SocketTimeoutError } from "redis";
|
|
20
20
|
/**
|
|
21
21
|
* Service tag for the Node Redis integration, exposing the underlying
|
|
22
|
-
* `
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
const client =
|
|
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.
|
|
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
|
|
55
|
-
* created with the supplied options
|
|
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
|
|
63
|
-
* options,
|
|
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
|
package/dist/NodeRedis.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NodeRedis.js","names":[],"sources":["../src/NodeRedis.ts"],"sourcesContent":[null],"mappings":"AAAA
|
|
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.
|
|
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.
|
|
57
|
+
"@effect/platform-node-shared": "^4.0.0-rc.109"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"
|
|
61
|
-
"effect": "^4.0.0-rc.
|
|
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
|
-
"
|
|
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 `
|
|
2
|
+
* Node.js Redis integration backed by `redis` (node-redis).
|
|
3
3
|
*
|
|
4
|
-
* This module creates a scoped `
|
|
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
|
|
7
|
-
* while `layerConfig` reads them from Effect config.
|
|
8
|
-
*
|
|
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
|
|
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
|
-
* `
|
|
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:
|
|
31
|
-
readonly use: <A>(f: (client:
|
|
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?:
|
|
38
|
+
options?: NodeRedisClientOptions
|
|
36
39
|
) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const client =
|
|
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:
|
|
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.
|
|
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
|
|
67
|
-
* created with the supplied options
|
|
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?:
|
|
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
|
|
78
|
-
* options,
|
|
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<
|
|
85
|
-
) => Layer.Layer<Redis.Redis | NodeRedis, Config.ConfigError> = (
|
|
86
|
-
options: Config.Wrap<
|
|
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)
|