opencode-effect-enforcer 0.2.0
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/LICENSE +21 -0
- package/README.md +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-batching
|
|
3
|
+
description: Implement automatic request batching and deduplication using Effect's Request, RequestResolver, and SqlResolver APIs. Use this skill when solving N+1 query problems, building batched data-fetching layers, or integrating request caching with resolvers.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an Effect TypeScript expert specializing in request batching, deduplication, and efficient data-fetching patterns.
|
|
7
|
+
|
|
8
|
+
## Effect Source Reference
|
|
9
|
+
|
|
10
|
+
The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
|
|
11
|
+
Browse and read files there directly to look up APIs, types, and implementations.
|
|
12
|
+
|
|
13
|
+
Reference this for:
|
|
14
|
+
|
|
15
|
+
- `Request` and `Request.Class` definitions (`packages/effect/src/Request.ts`)
|
|
16
|
+
- `RequestResolver` constructors and combinators (`packages/effect/src/RequestResolver.ts`)
|
|
17
|
+
- `SqlResolver` for SQL-specific batching (`packages/effect/src/unstable/sql/SqlResolver.ts`)
|
|
18
|
+
- Batching tutorial (`ai-docs/src/05_batching/10_request-resolver.ts`)
|
|
19
|
+
|
|
20
|
+
## The N+1 Problem
|
|
21
|
+
|
|
22
|
+
Naive data fetching executes one query per item. Fetching 100 users by ID produces 100 separate queries. Effect's batching system solves this automatically: individual `Effect.request` calls made concurrently within a batch window are collected and resolved together in a single batch.
|
|
23
|
+
|
|
24
|
+
The key insight: calling code writes single-item lookups, but the runtime collects them and hands the resolver an array. No manual batching logic leaks into business code.
|
|
25
|
+
|
|
26
|
+
## Select by Backend Capability
|
|
27
|
+
|
|
28
|
+
Use `RequestResolver` batching only when the backend can answer many distinct keys in one operation, such as SQL `IN (...)`, a DataLoader-style endpoint, or a batch GET API. The resolver should collapse a batch into fewer wire/database calls.
|
|
29
|
+
|
|
30
|
+
If the backend exposes only per-item endpoints, a resolver that loops over entries is not backend batching. Prefer `Effect.forEach(items, lookup, { concurrency: n })`, optionally with `Cache` for repeated-key memoization and in-flight deduplication. Use `RequestResolver.batchN` to respect a real batch endpoint's maximum request size, and `makeGrouped` when entries must be routed to different backend targets.
|
|
31
|
+
|
|
32
|
+
Selection guide:
|
|
33
|
+
|
|
34
|
+
- Repeated same key, concurrently or over time: `Cache`.
|
|
35
|
+
- Many distinct keys with a real batch endpoint: `Effect.request` + `RequestResolver`.
|
|
36
|
+
- Many distinct keys with per-item endpoints only: bounded `Effect.forEach`, optionally through `Cache`.
|
|
37
|
+
|
|
38
|
+
## Request Definition
|
|
39
|
+
|
|
40
|
+
A `Request<Success, Error, Services>` describes a single lookup. Define requests using `Request.Class`:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { Effect, Exit, Request, RequestResolver, Schema } from 'effect';
|
|
44
|
+
|
|
45
|
+
// Domain types
|
|
46
|
+
class User extends Schema.Class<User>('User')({
|
|
47
|
+
id: Schema.Number,
|
|
48
|
+
name: Schema.String,
|
|
49
|
+
email: Schema.String
|
|
50
|
+
}) {}
|
|
51
|
+
|
|
52
|
+
class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
|
53
|
+
'UserNotFound',
|
|
54
|
+
{
|
|
55
|
+
id: Schema.Number
|
|
56
|
+
}
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
// Request definition using Request.Class
|
|
60
|
+
// Type params: { payload fields }, Success, Error, Services
|
|
61
|
+
class GetUserById extends Request.Class<
|
|
62
|
+
{ readonly id: number },
|
|
63
|
+
User,
|
|
64
|
+
UserNotFound,
|
|
65
|
+
never
|
|
66
|
+
> {}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Alternative: Interface + tagged constructor
|
|
70
|
+
|
|
71
|
+
For simpler cases or when you don't need a class:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
interface GetUserById extends Request.Request<User, UserNotFound> {
|
|
75
|
+
readonly _tag: 'GetUserById';
|
|
76
|
+
readonly id: number;
|
|
77
|
+
}
|
|
78
|
+
const GetUserById = Request.tagged<GetUserById>('GetUserById');
|
|
79
|
+
|
|
80
|
+
// Usage:
|
|
81
|
+
const req = GetUserById({ id: 42 });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Request equality
|
|
85
|
+
|
|
86
|
+
Requests use structural equality by default (via `Equal` trait). Two `GetUserById({ id: 1 })` instances are considered equal, enabling automatic deduplication within a batch window.
|
|
87
|
+
|
|
88
|
+
## RequestResolver
|
|
89
|
+
|
|
90
|
+
A `RequestResolver<A>` handles batched execution of requests of type `A`. The resolver receives all collected requests as a `NonEmptyArray<Request.Entry<A>>` and must complete every entry.
|
|
91
|
+
|
|
92
|
+
### Basic resolver with `RequestResolver.make`
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
const resolver = RequestResolver.make<GetUserById>(
|
|
96
|
+
Effect.fn(function* (entries) {
|
|
97
|
+
// `entries` is NonEmptyArray<Request.Entry<GetUserById>>
|
|
98
|
+
// Each entry has:
|
|
99
|
+
// - entry.request: the original request (e.g. { id: 1 })
|
|
100
|
+
// - entry.context: captured Context with request-scoped services
|
|
101
|
+
// - entry.completeUnsafe(exit): complete with Exit value
|
|
102
|
+
|
|
103
|
+
const ids = entries.map((e) => e.request.id);
|
|
104
|
+
const users = yield* fetchUsersByIds(ids); // single batched call
|
|
105
|
+
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const user = users.find((u) => u.id === entry.request.id);
|
|
108
|
+
if (user) {
|
|
109
|
+
entry.completeUnsafe(Exit.succeed(user));
|
|
110
|
+
} else {
|
|
111
|
+
entry.completeUnsafe(
|
|
112
|
+
Exit.fail(new UserNotFound({ id: entry.request.id }))
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Completing entries
|
|
121
|
+
|
|
122
|
+
Every entry in the batch MUST be completed. Failing to do so causes a `QueryFailure` error at runtime.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
// Complete with success
|
|
126
|
+
entry.completeUnsafe(Exit.succeed(value));
|
|
127
|
+
|
|
128
|
+
// Complete with typed error
|
|
129
|
+
entry.completeUnsafe(Exit.fail(new UserNotFound({ id: entry.request.id })));
|
|
130
|
+
|
|
131
|
+
// Complete with defect
|
|
132
|
+
entry.completeUnsafe(Exit.die(new Error('unexpected')));
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Pure resolvers
|
|
136
|
+
|
|
137
|
+
For simple cases:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// Per-request pure function
|
|
141
|
+
const SquareResolver = RequestResolver.fromFunction<GetSquare>(
|
|
142
|
+
(entry) => entry.request.value * entry.request.value
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Batched pure function (results must match request order)
|
|
146
|
+
const DoubleResolver = RequestResolver.fromFunctionBatched<GetDouble>(
|
|
147
|
+
(entries) => entries.map((entry) => entry.request.value * 2)
|
|
148
|
+
);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Per-request effectful resolver
|
|
152
|
+
|
|
153
|
+
When each request needs its own effect (no batching optimization, but still benefits from deduplication):
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
const UserResolver = RequestResolver.fromEffect<GetUserById>((entry) =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const result = yield* httpClient.get(`/users/${entry.request.id}`);
|
|
159
|
+
return result;
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Tagged resolver (multiple request types)
|
|
165
|
+
|
|
166
|
+
Handle different request types in a single resolver:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
type AppRequest = GetUser | GetPost;
|
|
170
|
+
|
|
171
|
+
const AppResolver = RequestResolver.fromEffectTagged<AppRequest>()({
|
|
172
|
+
GetUser: (entries) =>
|
|
173
|
+
Effect.succeed(entries.map((e) => `User ${e.request.id}`)),
|
|
174
|
+
GetPost: (entries) =>
|
|
175
|
+
Effect.succeed(entries.map((e) => `Post ${e.request.id}`))
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Grouped resolver
|
|
180
|
+
|
|
181
|
+
Group requests by a key so each group is resolved separately:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
const resolver = RequestResolver.makeGrouped<GetUserByRole, string>({
|
|
185
|
+
key: ({ request }) => request.role,
|
|
186
|
+
resolver: (entries, role) =>
|
|
187
|
+
Effect.sync(() => {
|
|
188
|
+
console.log(
|
|
189
|
+
`Processing ${entries.length} requests for role: ${role}`
|
|
190
|
+
);
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
entry.completeUnsafe(
|
|
193
|
+
Exit.succeed(`User ${entry.request.id} with role ${role}`)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Using Requests with Effect.request
|
|
201
|
+
|
|
202
|
+
`Effect.request` connects a request instance to its resolver, returning a normal `Effect`:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
const getUserById = (id: number) =>
|
|
206
|
+
Effect.request(new GetUserById({ id }), resolver);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The resolver can also be an `Effect` that produces a resolver (useful when the resolver is constructed within a service layer):
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const getUserById = (id: number) =>
|
|
213
|
+
Effect.request(new GetUserById({ id }), resolverEffect);
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Automatic batching
|
|
217
|
+
|
|
218
|
+
When multiple `Effect.request` calls run concurrently, they are automatically batched:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// These 5 lookups produce ONE call to the resolver
|
|
222
|
+
// Duplicate IDs (1, 2) are deduplicated
|
|
223
|
+
const result =
|
|
224
|
+
yield*
|
|
225
|
+
Effect.forEach([1, 2, 1, 3, 2], (id) => getUserById(id), {
|
|
226
|
+
concurrency: 'unbounded'
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Batch Window Configuration
|
|
231
|
+
|
|
232
|
+
### `RequestResolver.setDelay`
|
|
233
|
+
|
|
234
|
+
Controls how long the resolver waits to collect requests before executing. More delay = larger batches but higher latency.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
const resolver = RequestResolver.make<GetUserById>(/* ... */).pipe(
|
|
238
|
+
// Wait 10ms to collect more requests before flushing
|
|
239
|
+
RequestResolver.setDelay('10 millis')
|
|
240
|
+
);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Default behavior (no `setDelay`): the resolver uses `Effect.yieldNow` which flushes after the current microtask, batching only requests that are already queued.
|
|
244
|
+
|
|
245
|
+
### `RequestResolver.setDelayEffect`
|
|
246
|
+
|
|
247
|
+
For custom delay logic (e.g., logging, dynamic delays):
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
const resolver = pipe(
|
|
251
|
+
baseResolver,
|
|
252
|
+
RequestResolver.setDelayEffect(
|
|
253
|
+
Effect.gen(function* () {
|
|
254
|
+
yield* Effect.log('Waiting before processing batch...');
|
|
255
|
+
yield* Effect.sleep('50 millis');
|
|
256
|
+
})
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### `RequestResolver.batchN`
|
|
262
|
+
|
|
263
|
+
Limit maximum batch size. Larger batches are split into multiple resolver calls:
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
const resolver = pipe(
|
|
267
|
+
baseResolver,
|
|
268
|
+
RequestResolver.batchN(100) // max 100 requests per batch
|
|
269
|
+
);
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Caching
|
|
273
|
+
|
|
274
|
+
### `RequestResolver.withCache`
|
|
275
|
+
|
|
276
|
+
Adds an in-memory LRU or FIFO cache to a resolver. Cached requests skip the resolver entirely on subsequent lookups:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
const resolver =
|
|
280
|
+
yield*
|
|
281
|
+
RequestResolver.make<GetUserById>(/* ... */).pipe(
|
|
282
|
+
RequestResolver.withCache({ capacity: 1024 })
|
|
283
|
+
// or: RequestResolver.withCache({ capacity: 1024, strategy: "fifo" })
|
|
284
|
+
);
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Note: `withCache` returns an `Effect<RequestResolver>` (it allocates mutable state), so use `yield*` when constructing.
|
|
288
|
+
|
|
289
|
+
Cache behavior:
|
|
290
|
+
|
|
291
|
+
- First lookup: request goes to resolver, result is cached
|
|
292
|
+
- Subsequent lookup for same request: served from cache immediately
|
|
293
|
+
- When capacity is exceeded, oldest entries are evicted (LRU or FIFO)
|
|
294
|
+
- In-flight deduplication: if the same request is pending, new callers attach to the pending result
|
|
295
|
+
|
|
296
|
+
### `RequestResolver.asCache`
|
|
297
|
+
|
|
298
|
+
Converts a resolver into a `Cache` instance for more control (TTL, etc.):
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
const userCache =
|
|
302
|
+
yield*
|
|
303
|
+
pipe(
|
|
304
|
+
resolver,
|
|
305
|
+
RequestResolver.asCache({
|
|
306
|
+
capacity: 1024,
|
|
307
|
+
timeToLive: (exit, request) => '5 minutes'
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Cache operations are module functions in Effect v4
|
|
312
|
+
const user = yield* Cache.get(userCache, new GetUserById({ id: 1 }));
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Observability
|
|
316
|
+
|
|
317
|
+
### `RequestResolver.withSpan`
|
|
318
|
+
|
|
319
|
+
Adds a tracing span around the resolver execution with automatic span links from each request's parent span:
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
const resolver = pipe(
|
|
323
|
+
baseResolver,
|
|
324
|
+
RequestResolver.withSpan('Users.getUserById.resolver')
|
|
325
|
+
);
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
The span automatically includes a `batchSize` attribute and links to each request's parent span, giving full visibility into batching behavior in your tracing backend.
|
|
329
|
+
|
|
330
|
+
Combine with `Effect.withSpan` on the individual request for end-to-end traces:
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
const getUserById = (id: number) =>
|
|
334
|
+
Effect.request(new GetUserById({ id }), resolver).pipe(
|
|
335
|
+
Effect.withSpan('Users.getUserById', { attributes: { userId: id } })
|
|
336
|
+
);
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Accessing request services
|
|
340
|
+
|
|
341
|
+
Inside a resolver, each `Request.Entry` carries its captured `Context` with request-scoped services:
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
import { Context, Tracer } from 'effect';
|
|
345
|
+
|
|
346
|
+
const resolver = RequestResolver.make<GetUserById>(
|
|
347
|
+
Effect.fn(function* (entries) {
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
const requestSpan = Context.getOption(
|
|
350
|
+
entry.context,
|
|
351
|
+
Tracer.ParentSpan
|
|
352
|
+
);
|
|
353
|
+
// ... use span for correlation
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Complete Service Pattern
|
|
360
|
+
|
|
361
|
+
The idiomatic pattern wraps request + resolver + caching inside a service layer:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import {
|
|
365
|
+
Effect,
|
|
366
|
+
Exit,
|
|
367
|
+
Layer,
|
|
368
|
+
Request,
|
|
369
|
+
RequestResolver,
|
|
370
|
+
Schema,
|
|
371
|
+
Context
|
|
372
|
+
} from 'effect';
|
|
373
|
+
|
|
374
|
+
class User extends Schema.Class<User>('User')({
|
|
375
|
+
id: Schema.Number,
|
|
376
|
+
name: Schema.String,
|
|
377
|
+
email: Schema.String
|
|
378
|
+
}) {}
|
|
379
|
+
|
|
380
|
+
class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
|
381
|
+
'UserNotFound',
|
|
382
|
+
{
|
|
383
|
+
id: Schema.Number
|
|
384
|
+
}
|
|
385
|
+
) {}
|
|
386
|
+
|
|
387
|
+
class Users extends Context.Service<
|
|
388
|
+
Users,
|
|
389
|
+
{
|
|
390
|
+
getUserById(id: number): Effect.Effect<User, UserNotFound>;
|
|
391
|
+
}
|
|
392
|
+
>()('app/Users') {
|
|
393
|
+
static readonly layer = Layer.effect(
|
|
394
|
+
Users,
|
|
395
|
+
Effect.gen(function* () {
|
|
396
|
+
class GetUserById extends Request.Class<
|
|
397
|
+
{ readonly id: number },
|
|
398
|
+
User,
|
|
399
|
+
UserNotFound,
|
|
400
|
+
never
|
|
401
|
+
> {}
|
|
402
|
+
|
|
403
|
+
const resolver = yield* RequestResolver.make<GetUserById>(
|
|
404
|
+
Effect.fn(function* (entries) {
|
|
405
|
+
const ids = entries.map((e) => e.request.id);
|
|
406
|
+
const users = yield* fetchBatch(ids);
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
const user = users.find(
|
|
409
|
+
(u) => u.id === entry.request.id
|
|
410
|
+
);
|
|
411
|
+
entry.completeUnsafe(
|
|
412
|
+
user
|
|
413
|
+
? Exit.succeed(user)
|
|
414
|
+
: Exit.fail(
|
|
415
|
+
new UserNotFound({
|
|
416
|
+
id: entry.request.id
|
|
417
|
+
})
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
).pipe(
|
|
423
|
+
RequestResolver.setDelay('10 millis'),
|
|
424
|
+
RequestResolver.withSpan('Users.getUserById.resolver'),
|
|
425
|
+
RequestResolver.withCache({ capacity: 1024 })
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const getUserById = (id: number) =>
|
|
429
|
+
Effect.request(new GetUserById({ id }), resolver).pipe(
|
|
430
|
+
Effect.withSpan('Users.getUserById', {
|
|
431
|
+
attributes: { userId: id }
|
|
432
|
+
})
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
return { getUserById } as const;
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## SQL Integration with SqlResolver
|
|
442
|
+
|
|
443
|
+
`SqlResolver` (from `effect/unstable/sql`) provides schema-validated, batched SQL resolvers. Import:
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
import { SqlResolver } from 'effect/unstable/sql';
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### SqlResolver.ordered
|
|
450
|
+
|
|
451
|
+
Results map 1:1 to requests in order. Errors if result count doesn't match:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
const Insert = SqlResolver.ordered({
|
|
455
|
+
Request: Schema.String,
|
|
456
|
+
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
|
|
457
|
+
execute: (names) =>
|
|
458
|
+
sql`INSERT INTO users ${sql.insert(names.map((name) => ({ name })))} RETURNING *`
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const insertUser = SqlResolver.request(Insert);
|
|
462
|
+
|
|
463
|
+
// Batched: these two inserts become one SQL statement
|
|
464
|
+
const results =
|
|
465
|
+
yield*
|
|
466
|
+
Effect.all(
|
|
467
|
+
{
|
|
468
|
+
one: insertUser('alice'),
|
|
469
|
+
two: insertUser('bob')
|
|
470
|
+
},
|
|
471
|
+
{ concurrency: 'unbounded' }
|
|
472
|
+
);
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### SqlResolver.grouped
|
|
476
|
+
|
|
477
|
+
Returns multiple results per request, grouped by key:
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
const FindByName = SqlResolver.grouped({
|
|
481
|
+
Request: Schema.String,
|
|
482
|
+
RequestGroupKey: (name) => name,
|
|
483
|
+
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
|
|
484
|
+
ResultGroupKey: (result) => result.name,
|
|
485
|
+
execute: (names) => sql`SELECT * FROM users WHERE name IN ${sql.in(names)}`
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const findByName = SqlResolver.request(FindByName);
|
|
489
|
+
// Returns NonEmptyArray<User> or fails with NoSuchElementError
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### SqlResolver.findById
|
|
493
|
+
|
|
494
|
+
Resolves single results by ID. Returns `NoSuchElementError` for missing entries:
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
const FindById = SqlResolver.findById({
|
|
498
|
+
Id: Schema.Number,
|
|
499
|
+
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
|
|
500
|
+
ResultId: (result) => result.id,
|
|
501
|
+
execute: (ids) => sql`SELECT * FROM users WHERE id IN ${sql.in(ids)}`
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const findById = SqlResolver.request(FindById);
|
|
505
|
+
// findById(1) => Effect<User, NoSuchElementError | SqlError | Schema.SchemaError>
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### SqlResolver.void
|
|
509
|
+
|
|
510
|
+
For side-effect-only operations (inserts/updates with no return value):
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
const DeleteUser = SqlResolver.void({
|
|
514
|
+
Request: Schema.Number,
|
|
515
|
+
execute: (ids) => sql`DELETE FROM users WHERE id IN ${sql.in(ids)}`
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const deleteUser = SqlResolver.request(DeleteUser);
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### Transaction awareness
|
|
522
|
+
|
|
523
|
+
`SqlResolver` automatically groups requests by the transaction connection captured in each `Request.Entry.context`, so requests within a transaction are batched separately from those outside one. This depends on using the same `SqlClient` service instance that opened the transaction; requests executed with another client or a manually reserved connection do not join that transaction.
|
|
524
|
+
|
|
525
|
+
Encoding failures are completed as their underlying schema errors before batch execution. If every request in a batch fails encoding, the non-empty execute callback is not invoked; duplicate `findById` requests are all completed rather than surfacing a resolver-incomplete defect.
|
|
526
|
+
|
|
527
|
+
## Resolver Combinators
|
|
528
|
+
|
|
529
|
+
### `RequestResolver.around`
|
|
530
|
+
|
|
531
|
+
Execute setup/teardown around each batch:
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
const timedResolver = RequestResolver.around(
|
|
535
|
+
resolver,
|
|
536
|
+
(entries) => Effect.sync(() => Date.now()),
|
|
537
|
+
(entries, startTime) =>
|
|
538
|
+
Effect.log(
|
|
539
|
+
`Batch of ${entries.length} completed in ${Date.now() - startTime}ms`
|
|
540
|
+
)
|
|
541
|
+
);
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### `RequestResolver.grouped`
|
|
545
|
+
|
|
546
|
+
Transform a resolver to group requests by a dynamic key:
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
const byDepartment = RequestResolver.grouped(
|
|
550
|
+
resolver,
|
|
551
|
+
({ request }) => request.department
|
|
552
|
+
);
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### `RequestResolver.race`
|
|
556
|
+
|
|
557
|
+
Race two resolvers, returning whichever completes first:
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
const fast = RequestResolver.race(cacheResolver, dbResolver);
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
## Common Anti-Patterns
|
|
564
|
+
|
|
565
|
+
### WRONG: Completing only some entries
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
// BAD - entries without matches are never completed -> QueryFailure
|
|
569
|
+
const resolver = RequestResolver.make<GetUserById>(
|
|
570
|
+
Effect.fn(function* (entries) {
|
|
571
|
+
for (const entry of entries) {
|
|
572
|
+
const user = users.get(entry.request.id);
|
|
573
|
+
if (user) {
|
|
574
|
+
entry.completeUnsafe(Exit.succeed(user)); // What about misses?
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
);
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### CORRECT: Always complete every entry
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
const resolver = RequestResolver.make<GetUserById>(
|
|
585
|
+
Effect.fn(function* (entries) {
|
|
586
|
+
for (const entry of entries) {
|
|
587
|
+
const user = users.get(entry.request.id);
|
|
588
|
+
entry.completeUnsafe(
|
|
589
|
+
user
|
|
590
|
+
? Exit.succeed(user)
|
|
591
|
+
: Exit.fail(new UserNotFound({ id: entry.request.id }))
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
})
|
|
595
|
+
);
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### WRONG: Using Effect.forEach without concurrency
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
// BAD - sequential execution, no batching occurs
|
|
602
|
+
yield* Effect.forEach([1, 2, 3], getUserById);
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### CORRECT: Enable concurrency for batching
|
|
606
|
+
|
|
607
|
+
```typescript
|
|
608
|
+
// GOOD - concurrent execution triggers batching
|
|
609
|
+
yield* Effect.forEach([1, 2, 3], getUserById, { concurrency: 'unbounded' });
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### WRONG: Calling per-item endpoints from a "batched" resolver
|
|
613
|
+
|
|
614
|
+
This still makes N backend calls. Use bounded `Effect.forEach` directly and add `Cache` when repeated-key deduplication is useful. Introduce a resolver only after selecting a backend endpoint that truly accepts multiple keys.
|