better-effect 0.10.0 → 0.12.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/README.md +464 -11
- package/dist/adapters/iti.d.mts +4 -4
- package/dist/adapters/iti.d.mts.map +1 -1
- package/dist/adapters/iti.mjs +20 -14
- package/dist/adapters/iti.mjs.map +1 -1
- package/dist/effect-CK4YZaX0.mjs +559 -0
- package/dist/effect-CK4YZaX0.mjs.map +1 -0
- package/dist/hono.d.mts +18 -13
- package/dist/hono.d.mts.map +1 -1
- package/dist/hono.mjs +9 -5
- package/dist/hono.mjs.map +1 -1
- package/dist/{index-DklNCz7w.d.mts → index-B1nH4luq.d.mts} +74 -7
- package/dist/index-B1nH4luq.d.mts.map +1 -0
- package/dist/index-BMdU_Z72.d.mts +18 -0
- package/dist/index-BMdU_Z72.d.mts.map +1 -0
- package/dist/{index-BafDna0B.d.mts → index-CX6iAoCB.d.mts} +168 -174
- package/dist/index-CX6iAoCB.d.mts.map +1 -0
- package/dist/index-GYJ4qm9K.d.mts +179 -0
- package/dist/index-GYJ4qm9K.d.mts.map +1 -0
- package/dist/{index-CULgSzUw.d.mts → index-heuaRmXR.d.mts} +5 -5
- package/dist/{index-CULgSzUw.d.mts.map → index-heuaRmXR.d.mts.map} +1 -1
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +9 -539
- package/dist/index.mjs.map +1 -1
- package/dist/{internal-identity-DmUpBeeL.mjs → internal-identity-CbXoZIfw.mjs} +3 -3
- package/dist/internal-identity-CbXoZIfw.mjs.map +1 -0
- package/dist/{signal-C1bagvrO.mjs → layer-D56fRdLt.mjs} +62 -64
- package/dist/layer-D56fRdLt.mjs.map +1 -0
- package/dist/node-context-DWsCUBRa.d.mts +12 -0
- package/dist/node-context-DWsCUBRa.d.mts.map +1 -0
- package/dist/node-context-lggRF4bR.mjs +25 -0
- package/dist/node-context-lggRF4bR.mjs.map +1 -0
- package/dist/node.d.mts +51 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +233 -0
- package/dist/node.mjs.map +1 -0
- package/dist/{index-C7KX5rAP.d.mts → outcome-CB43bnFW.d.mts} +140 -27
- package/dist/outcome-CB43bnFW.d.mts.map +1 -0
- package/dist/program-metadata-DhQCCirO.mjs +260 -0
- package/dist/program-metadata-DhQCCirO.mjs.map +1 -0
- package/dist/registration-drnZk0SY.mjs +182 -0
- package/dist/registration-drnZk0SY.mjs.map +1 -0
- package/dist/runtime/explicit.d.mts +1 -1
- package/dist/runtime/node.d.mts +3 -12
- package/dist/runtime/node.mjs +2 -24
- package/dist/runtime-CBMrX8Wl.mjs +776 -0
- package/dist/runtime-CBMrX8Wl.mjs.map +1 -0
- package/dist/signal-D2UTcaaG.mjs +105 -0
- package/dist/signal-D2UTcaaG.mjs.map +1 -0
- package/dist/{standard-services-DW-i4UuA.mjs → standard-services-GBZ32tll.mjs} +200 -16
- package/dist/standard-services-GBZ32tll.mjs.map +1 -0
- package/dist/standard-services.d.mts +3 -3
- package/dist/standard-services.mjs +3 -3
- package/dist/testing.d.mts +257 -2
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +1159 -2
- package/dist/testing.mjs.map +1 -0
- package/package.json +8 -3
- package/dist/effect-DAMqvegy.mjs +0 -544
- package/dist/effect-DAMqvegy.mjs.map +0 -1
- package/dist/errors-Dnjhzbt0.mjs +0 -99
- package/dist/errors-Dnjhzbt0.mjs.map +0 -1
- package/dist/index-BafDna0B.d.mts.map +0 -1
- package/dist/index-C7KX5rAP.d.mts.map +0 -1
- package/dist/index-DklNCz7w.d.mts.map +0 -1
- package/dist/internal-identity-DmUpBeeL.mjs.map +0 -1
- package/dist/map-layer-backend-gal-mcRv.mjs +0 -53
- package/dist/map-layer-backend-gal-mcRv.mjs.map +0 -1
- package/dist/map-layer-backend-rNwfH0Bz.d.mts +0 -42
- package/dist/map-layer-backend-rNwfH0Bz.d.mts.map +0 -1
- package/dist/runtime/node.d.mts.map +0 -1
- package/dist/runtime/node.mjs.map +0 -1
- package/dist/signal-C1bagvrO.mjs.map +0 -1
- package/dist/standard-services-DW-i4UuA.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -12,7 +12,9 @@ bun add better-effect better-result
|
|
|
12
12
|
|
|
13
13
|
The published Runtime entrypoint is officially supported on Node.js and Bun.
|
|
14
14
|
The tested runtime matrix is Node.js 24 and Bun 1.3.14, and the default runtime
|
|
15
|
-
context uses Node/Bun async context propagation.
|
|
15
|
+
context uses Node/Bun async context propagation. `bun run check` also deletes
|
|
16
|
+
and rebuilds `dist`, packs the result into a temporary consumer, and runs the
|
|
17
|
+
full Node/Bun `NodeRuntime` child-process suite.
|
|
16
18
|
|
|
17
19
|
## TypeScript knows what your application needs
|
|
18
20
|
|
|
@@ -99,6 +101,40 @@ const AppLive = Layer.merge(DatabaseLive, UserRepositoryLive)
|
|
|
99
101
|
const runtime = await Runtime.make(AppLive)
|
|
100
102
|
```
|
|
101
103
|
|
|
104
|
+
Use `Layer.empty` when a composition root intentionally has no providers. It is
|
|
105
|
+
stable and has the exact `Layer<never, never>` type:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const EmptyLive = Layer.empty
|
|
109
|
+
const runtime = await Runtime.make(EmptyLive)
|
|
110
|
+
await runtime.run(() => 'no Services required')
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
For a port/adapter boundary, `Layer.alias` exposes one compatible implementation
|
|
114
|
+
under another Service token without constructing, cloning, or proxying it:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
class SqlUserRepository extends Service<SqlUserRepository>()('SqlUserRepository') {
|
|
118
|
+
findById(id: string): string {
|
|
119
|
+
return `sql:${id}`
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class UserRepository extends Service<UserRepository>()('UserRepository') {
|
|
124
|
+
declare findById: SqlUserRepository['findById']
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const UserRepositoryPort = Layer.alias({
|
|
128
|
+
from: SqlUserRepository,
|
|
129
|
+
to: UserRepository
|
|
130
|
+
})
|
|
131
|
+
const ApplicationLive = Layer.merge(Layer.empty, Layer.make(SqlUserRepository), UserRepositoryPort)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The alias lazily resolves `from`, returns the same object under `to`, and checks
|
|
135
|
+
that the source satisfies the target's `Service.Contract`. Its source remains an
|
|
136
|
+
external Layer requirement until the source provider is composed.
|
|
137
|
+
|
|
102
138
|
And the contract does not disappear after startup.
|
|
103
139
|
|
|
104
140
|
A Runtime also knows which Services exist in its environment:
|
|
@@ -122,17 +158,130 @@ Scope.
|
|
|
122
158
|
Scope. `Effect.fn` captures the generator as a lazy `Program` for Runtime
|
|
123
159
|
boundaries; the callback form remains supported for compatibility.
|
|
124
160
|
|
|
161
|
+
Name a Program without executing it. The name is private diagnostic metadata,
|
|
162
|
+
so the value remains an ordinary callable function and can be used with `pipe`:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const loadUser = pipe(
|
|
166
|
+
Effect.fn(async function* () {
|
|
167
|
+
return Result.ok(await userRepository.load(userId))
|
|
168
|
+
}),
|
|
169
|
+
Program.named('user.load')
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const result = await runtime.run(loadUser, {
|
|
173
|
+
attributes: { userId, requestId }
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`Program.map`, `mapError`, `andThen`, `tap`, `tapError`, and `recover` preserve
|
|
178
|
+
their source name. A later `Program.named` call overrides it. Collection
|
|
179
|
+
Programs do not concatenate child names; give `Program.all`, `Program.forEach`,
|
|
180
|
+
or `Program.allResults` an optional `{ name }` when the collection itself needs
|
|
181
|
+
a diagnostic name.
|
|
182
|
+
|
|
125
183
|
`Program.all` keeps a collection lazy until the returned Program is run. Pass
|
|
126
184
|
`{ concurrency: n }` for a positive bounded FIFO worker pool; values retain
|
|
127
185
|
input order. If a Program returns an error or throws, scheduling stops, already-
|
|
128
186
|
started Programs are allowed to settle, and the deterministic primary failure remains selected;
|
|
129
187
|
there is no cancellation or Fiber scheduler.
|
|
130
188
|
|
|
189
|
+
Use `Program.forEach` when each item needs a lazy Program factory. The callback
|
|
190
|
+
receives the item and its input index, and the returned Program produces a
|
|
191
|
+
readonly collection in input order:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
const synchronized = Program.forEach(userIds, (userId, index) => synchronizeUser(userId, index), {
|
|
195
|
+
concurrency: 8
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const result = await runtime.run(synchronized)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Use `Program.allResults` when typed validation errors should be retained rather
|
|
202
|
+
than short-circuiting. It returns every exact child `Result` as a successful
|
|
203
|
+
collection element; defects still stop new work and reject the outer Program:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const validations = Program.allResults(
|
|
207
|
+
[validateIdentity, validateAddress, validateDocuments] as const,
|
|
208
|
+
{ concurrency: 3 }
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
const results = await runtime.run(validations)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Both helpers use the same lazy bounded scheduler as `Program.all`: indexes are
|
|
215
|
+
claimed in order, output order is stable, and already-started work is always
|
|
216
|
+
allowed to settle.
|
|
217
|
+
|
|
218
|
+
Use `Effect.*` to transform an already-created Result, and `Program.*` to compose
|
|
219
|
+
an `Effect.fn` Program without starting it. `Program.map`, `mapError`, `tap`, and
|
|
220
|
+
`tapError` preserve that laziness; `andThen` and `recover` accept an Effect, a
|
|
221
|
+
Promise of an Effect, or another Program only after their matching Result branch
|
|
222
|
+
is selected. `Program.andThen` unions its source and continuation error and
|
|
223
|
+
Service requirement channels. `Program.recover` handles and removes the source
|
|
224
|
+
`Err` channel, exposes the recovery error channel, and unions the source and
|
|
225
|
+
recovery Service requirements. Taps preserve the original Result object on
|
|
226
|
+
success.
|
|
227
|
+
|
|
131
228
|
`Runtime.make(AppLive)` and `Runtime.run(AppLive, program)` use the built-in
|
|
132
229
|
`MapLayerBackend`. Pass `{ backend: new ItiLayerBackend() }` when an external
|
|
133
230
|
container is needed; `MemoryLayerBackend` remains its compatibility alias from
|
|
134
231
|
`better-effect/testing`.
|
|
135
232
|
|
|
233
|
+
### Verify custom adapters
|
|
234
|
+
|
|
235
|
+
`better-effect/testing` provides runner-neutral conformance scenarios for
|
|
236
|
+
third-party `LayerBackend` and `RuntimeContextStorage` implementations. Each
|
|
237
|
+
scenario has a stable `name` and `run` callback, creates a fresh adapter, and
|
|
238
|
+
runs the optional adapter cleanup after every assertion outcome. Backends must
|
|
239
|
+
synchronously pass the actual readonly pending acquisition Promise collection to
|
|
240
|
+
`disposeAll`'s `onPendingAcquisitions` hook, await the callback, then await the
|
|
241
|
+
acquisitions before clearing state. Declare the backend's acquisition-failure
|
|
242
|
+
policy explicitly: `MapLayerBackend` retries,
|
|
243
|
+
while ITI keeps an asynchronous failure cached until disposal.
|
|
244
|
+
|
|
245
|
+
Register the scenarios with Bun:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { describe, test } from 'bun:test'
|
|
249
|
+
import { MapLayerBackend } from 'better-effect'
|
|
250
|
+
import { layerBackendContract } from 'better-effect/testing'
|
|
251
|
+
|
|
252
|
+
describe('My backend', () => {
|
|
253
|
+
for (const scenario of layerBackendContract({
|
|
254
|
+
makeBackend: () => new MapLayerBackend(),
|
|
255
|
+
acquisitionFailure: 'retry'
|
|
256
|
+
})) {
|
|
257
|
+
test(scenario.name, scenario.run)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The same scenarios work with Vitest without adding a runner dependency to the
|
|
263
|
+
published entrypoint:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
import { describe, it } from 'vitest'
|
|
267
|
+
import { NodeRuntimeContextStorage } from 'better-effect/runtime/node'
|
|
268
|
+
import { runtimeContextStorageContract } from 'better-effect/testing'
|
|
269
|
+
|
|
270
|
+
describe('My context storage', () => {
|
|
271
|
+
for (const scenario of runtimeContextStorageContract({
|
|
272
|
+
makeStorage: () => new NodeRuntimeContextStorage(),
|
|
273
|
+
concurrency: 'concurrent'
|
|
274
|
+
})) {
|
|
275
|
+
it(scenario.name, scenario.run)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Use `concurrency: 'sequential'` for a storage that rejects overlapping roots
|
|
281
|
+
with `RuntimeContextOverlapError`; pass `makeCompanionStorage` to also verify
|
|
282
|
+
that it does not leak frames into a Node or explicit storage. See the adapter
|
|
283
|
+
guide for the complete `LayerBackend` contract.
|
|
284
|
+
|
|
136
285
|
Runtimes are async disposables, so request-scoped code can use:
|
|
137
286
|
|
|
138
287
|
```ts
|
|
@@ -146,6 +295,42 @@ Or let `Runtime.use` own the lifetime:
|
|
|
146
295
|
const result = await Runtime.use(AppLive, (runtime) => runtime.run(program))
|
|
147
296
|
```
|
|
148
297
|
|
|
298
|
+
For isolated application tests, use the testing facade over that same Layer and
|
|
299
|
+
Runtime. It installs only the controlled Services you pass, records lifecycle
|
|
300
|
+
events, and disposes automatically:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { ClockTest, LoggerTest, TestRuntime } from 'better-effect/testing'
|
|
304
|
+
|
|
305
|
+
const logger = new LoggerTest()
|
|
306
|
+
const result = await TestRuntime.use(
|
|
307
|
+
AppLive,
|
|
308
|
+
{
|
|
309
|
+
overrides: [DatabaseTest],
|
|
310
|
+
clock: new ClockTest(Date.UTC(2026, 0, 1)),
|
|
311
|
+
logger
|
|
312
|
+
},
|
|
313
|
+
async (test) => {
|
|
314
|
+
const value = await test.run(loadDashboard)
|
|
315
|
+
expect(test.observer.executionEnds).toHaveLength(1)
|
|
316
|
+
return value
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
expect(logger.events).toHaveLength(1)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
A long-lived test boundary supports integration-style request Layers as well:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
await using test = await TestRuntime.make(AppLive, { overrides: [DatabaseTest] })
|
|
327
|
+
const result = await test.runWith(RequestLive, handleRequest)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`TestRuntime.use` preserves program-vs-cleanup failure precedence. The default
|
|
331
|
+
recorder is available as `test.observer`; use `test.runtime` only when an
|
|
332
|
+
advanced test explicitly needs the underlying Runtime.
|
|
333
|
+
|
|
149
334
|
Layer providers remain lazy unless startup validation is requested. Warm them
|
|
150
335
|
all before accepting work with either form:
|
|
151
336
|
|
|
@@ -159,7 +344,56 @@ await runtime.warmup()
|
|
|
159
344
|
Warmup failures include the Service and resolution path, release resources
|
|
160
345
|
already acquired, dispose the backend and reject the Runtime. Optional
|
|
161
346
|
observers expose Service resolution/acquisition, execution and Layer release
|
|
162
|
-
events without coupling the core to an observability SDK
|
|
347
|
+
events without coupling the core to an observability SDK. Execution events
|
|
348
|
+
carry one matching `executionId`, the optional Program name, copied readonly
|
|
349
|
+
attributes, and a monotonic `durationMs` measured through execution cleanup:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
const runtime = await Runtime.make(AppLive, {
|
|
353
|
+
observers: [
|
|
354
|
+
{
|
|
355
|
+
onExecutionStart: ({ executionId, name, attributes }) =>
|
|
356
|
+
console.debug('program.start', { executionId, name, attributes }),
|
|
357
|
+
onExecutionEnd: ({ executionId, outcome, durationMs }) =>
|
|
358
|
+
metrics.observe('program.duration_ms', durationMs, {
|
|
359
|
+
executionId,
|
|
360
|
+
outcome: outcome.status
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
]
|
|
364
|
+
})
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The Runtime does not serialize or inspect attribute values. Attributes are
|
|
368
|
+
shallow-copied and exposed as a readonly event view; do not attach secrets,
|
|
369
|
+
large objects, or mutable application state. Keep sensitive values out of
|
|
370
|
+
observer logs and metrics labels.
|
|
371
|
+
|
|
372
|
+
For coarse, synchronous diagnostics, use `runtime.inspect()`:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
const inspection = runtime.inspect()
|
|
376
|
+
// {
|
|
377
|
+
// state: 'active',
|
|
378
|
+
// warmup: 'idle',
|
|
379
|
+
// activeExecutions: 0,
|
|
380
|
+
// executions: [],
|
|
381
|
+
// services: ['Database', 'UserRepository'],
|
|
382
|
+
// shutdownSignalAborted: false
|
|
383
|
+
// }
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The returned snapshot and its arrays are detached and immutable. It contains
|
|
387
|
+
only public Service tags and execution IDs, names and start timestamps; it never
|
|
388
|
+
resolves Services, warms the Runtime, creates Scopes, invokes observers or
|
|
389
|
+
exposes providers, instances, signals, attributes or backend state. Execution
|
|
390
|
+
entries remain present until their execution Scope cleanup settles. Warmup is
|
|
391
|
+
reported as `idle`, `running`, `completed` or `failed`, and `state` reports
|
|
392
|
+
`active`, `disposing` or `disposed`.
|
|
393
|
+
|
|
394
|
+
`inspect()` is diagnostic information, not a lock, synchronization primitive or
|
|
395
|
+
readiness guarantee. It cannot cancel or force shutdown of any execution; use
|
|
396
|
+
`dispose()` and cooperative `AbortSignal` handling for lifecycle coordination.
|
|
163
397
|
|
|
164
398
|
Missing, circular, and provider-construction failures use the logical Service
|
|
165
399
|
tags in `ServiceNotFoundError`, `CircularDependencyError`, and
|
|
@@ -176,6 +410,57 @@ const runtime = await Runtime.make(AppLive, {
|
|
|
176
410
|
})
|
|
177
411
|
```
|
|
178
412
|
|
|
413
|
+
For lifecycle assertions, `better-effect/testing` provides a recorder and a
|
|
414
|
+
best-effort composition utility:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { RecordedRuntimeObserver, RuntimeObserver } from 'better-effect/testing'
|
|
418
|
+
|
|
419
|
+
const recorded = RecordedRuntimeObserver.make()
|
|
420
|
+
const runtime = await Runtime.make(AppTest, {
|
|
421
|
+
observers: [
|
|
422
|
+
RuntimeObserver.compose(recorded, {
|
|
423
|
+
onExecutionEnd: ({ outcome }) => console.debug(outcome.status)
|
|
424
|
+
})
|
|
425
|
+
]
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
await runtime.run(program)
|
|
429
|
+
const snapshot = recorded.snapshot()
|
|
430
|
+
expect(snapshot.executionEnds).toHaveLength(1)
|
|
431
|
+
expect(snapshot.timeline).toContain(snapshot.executionEnds[0])
|
|
432
|
+
|
|
433
|
+
await runtime.dispose()
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
`RecordedRuntimeObserver` preserves event identity in immutable category views
|
|
437
|
+
and its ordered `timeline`; call `clear()` to reuse it. Composition invokes
|
|
438
|
+
observers in declaration order and isolates thrown or rejected observer
|
|
439
|
+
failures from the Runtime result.
|
|
440
|
+
|
|
441
|
+
For a startup view of the graph actually observed by a Runtime, compose the
|
|
442
|
+
small graph observer and warm the Layer before accepting work:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
import { Runtime } from 'better-effect'
|
|
446
|
+
import { RuntimeGraphObserver } from 'better-effect/testing'
|
|
447
|
+
|
|
448
|
+
const graph = RuntimeGraphObserver.make({ rootLabel: 'Runtime' })
|
|
449
|
+
const runtime = await Runtime.make(AppLive, {
|
|
450
|
+
warmup: true,
|
|
451
|
+
observers: [graph]
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
console.log(graph.toJSON())
|
|
455
|
+
console.log(graph.toMermaid())
|
|
456
|
+
await runtime.dispose()
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
`RuntimeGraphObserver` uses only public resolution and acquisition events. Its
|
|
460
|
+
snapshot is sorted, detached and immutable; it records Service tags and counts,
|
|
461
|
+
not instances, scopes, causes or execution attributes. Providers that are never
|
|
462
|
+
resolved remain absent, and `clear()` starts a new diagnostic session.
|
|
463
|
+
|
|
179
464
|
Cancellation is cooperative and uses `AbortSignal`; no scheduler or fibers are
|
|
180
465
|
created. Pass a signal to one execution and read it from the program when an
|
|
181
466
|
I/O operation supports cancellation. Runtime disposal waits for active work;
|
|
@@ -190,6 +475,38 @@ const cancellableProgram = Effect.fn(async function* () {
|
|
|
190
475
|
})
|
|
191
476
|
```
|
|
192
477
|
|
|
478
|
+
For a Node.js or Bun CLI, use the host-specific `better-effect/node` entrypoint.
|
|
479
|
+
`NodeRuntime.runMain` validates its signal and callback options before installing
|
|
480
|
+
`SIGINT`/`SIGTERM` listeners, links the first signal to `CurrentAbortSignal`,
|
|
481
|
+
and disposes the Runtime exactly once:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
import { NodeRuntime } from 'better-effect/node'
|
|
485
|
+
|
|
486
|
+
const main = Effect.fn(async function* () {
|
|
487
|
+
const signal = yield* CurrentAbortSignal
|
|
488
|
+
return Result.ok(await runCommand({ signal }))
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
await NodeRuntime.runMain(AppLive, main, {
|
|
492
|
+
onFailure: (error) => {
|
|
493
|
+
console.error(error)
|
|
494
|
+
return 1
|
|
495
|
+
},
|
|
496
|
+
onSuccess: () => 0
|
|
497
|
+
})
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
`Result.err` uses `onFailure` (or exit code `1` by default), while thrown defects
|
|
501
|
+
remain rejected and may be reported with `onDefect`. Cleanup-only failures use
|
|
502
|
+
`onCleanupFailure`, remain observable, and still set a non-zero
|
|
503
|
+
`process.exitCode` after successful work. The first `SIGINT` or `SIGTERM`
|
|
504
|
+
immediately aborts `CurrentAbortSignal`; Runtime disposal then waits
|
|
505
|
+
cooperatively for the main execution. Listeners are removed in `finally`,
|
|
506
|
+
repeated signals are ignored, and the helper never calls `process.exit()`.
|
|
507
|
+
The Node boundary intentionally does not expose a second grace-period policy;
|
|
508
|
+
use `Runtime.dispose` directly when a managed Runtime needs one.
|
|
509
|
+
|
|
193
510
|
For request-local context or overrides, add a Layer only to that execution:
|
|
194
511
|
|
|
195
512
|
```ts
|
|
@@ -229,9 +546,9 @@ app.get(
|
|
|
229
546
|
)
|
|
230
547
|
```
|
|
231
548
|
|
|
232
|
-
Hono validators can precede the generator or handler callback
|
|
233
|
-
`c.req.valid(...)` inputs are
|
|
234
|
-
helper:
|
|
549
|
+
One or more Hono validators can precede the generator or handler callback, in
|
|
550
|
+
the order they should run. Their validated `c.req.valid(...)` inputs are
|
|
551
|
+
combined and inferred without a manual `Input` helper:
|
|
235
552
|
|
|
236
553
|
```ts
|
|
237
554
|
import { sValidator } from '@hono/standard-validator'
|
|
@@ -408,7 +725,9 @@ Some dependencies are values.
|
|
|
408
725
|
|
|
409
726
|
Others own connections, sessions, files or other resources.
|
|
410
727
|
|
|
411
|
-
`Layer.scoped`, `Layer.scopedGen`, `
|
|
728
|
+
`Layer.scoped`, `Layer.scopedGen`, `Layer.scopedDisposable`, `Effect.acquireRelease`,
|
|
729
|
+
`Effect.acquireReleaseResult`, `Effect.acquireDisposable`, `Effect.add` and `Scope` make their
|
|
730
|
+
lifetime explicit.
|
|
412
731
|
|
|
413
732
|
```ts
|
|
414
733
|
const DatabaseLive = Layer.scoped(
|
|
@@ -422,6 +741,35 @@ Runtime owns the application lifetime and safely releases scoped resources when
|
|
|
422
741
|
|
|
423
742
|
Resources acquired during an individual execution belong to that execution instead.
|
|
424
743
|
|
|
744
|
+
When an existing API already returns a `Result`, keep its typed failure channel while
|
|
745
|
+
registering only successful acquisitions:
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
const connection =
|
|
749
|
+
yield *
|
|
750
|
+
Effect.acquireReleaseResult(
|
|
751
|
+
() => pool.connect(),
|
|
752
|
+
(connection, outcome) => connection.close(outcome)
|
|
753
|
+
)
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
An `Err` is returned unchanged and is never released. Thrown or rejected acquisition
|
|
757
|
+
defects use the normal `UnhandledException` channel; release failures remain Scope
|
|
758
|
+
cleanup failures rather than widening the acquisition error type.
|
|
759
|
+
|
|
760
|
+
For values that implement JavaScript disposal, use the disposable helpers instead of
|
|
761
|
+
repeating a release callback. Async disposal is preferred when both protocols exist:
|
|
762
|
+
|
|
763
|
+
```ts
|
|
764
|
+
const file = yield * Effect.acquireDisposable(() => openFile(path))
|
|
765
|
+
|
|
766
|
+
const DatabaseLive = Layer.scopedDisposable(Database, () => Database.connect())
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
`Effect.acquireDisposable` belongs to the current execution Scope. `Layer.scopedDisposable`
|
|
770
|
+
keeps the client alive across executions and disposes it with the Runtime root; the DI
|
|
771
|
+
backend never owns that release.
|
|
772
|
+
|
|
425
773
|
### Keep your runtime choices
|
|
426
774
|
|
|
427
775
|
`better-effect` is not a replacement implementation of Effect.
|
|
@@ -451,10 +799,96 @@ const now = await runtime.run(async () => ServiceRuntime.resolve(Clock))
|
|
|
451
799
|
await runtime.dispose()
|
|
452
800
|
```
|
|
453
801
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
802
|
+
`IdGenerator` uses the host's cryptographic `crypto.randomUUID()` and is also
|
|
803
|
+
opt-in. Use `IdGeneratorTest` to make entity creation deterministic without
|
|
804
|
+
introducing a domain-specific ID type:
|
|
805
|
+
|
|
806
|
+
```ts
|
|
807
|
+
import { Result } from 'better-result'
|
|
808
|
+
import { Effect, Layer, Service } from 'better-effect'
|
|
809
|
+
import { IdGenerator } from 'better-effect/standard-services'
|
|
810
|
+
import { IdGeneratorTest, TestRuntime } from 'better-effect/testing'
|
|
811
|
+
|
|
812
|
+
class User extends Service<User>()('User') {
|
|
813
|
+
constructor(readonly id: string) {
|
|
814
|
+
super()
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const createUser = Effect.fn(async function* () {
|
|
819
|
+
const ids = yield* IdGenerator
|
|
820
|
+
return Result.ok(new User(ids.next()))
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
const result = await TestRuntime.use(
|
|
824
|
+
Layer.merge(),
|
|
825
|
+
{ idGenerator: new IdGeneratorTest(['user-1']) },
|
|
826
|
+
(test) => test.run(createUser)
|
|
827
|
+
)
|
|
828
|
+
// Result.ok(User { id: 'user-1' })
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
`IdGeneratorTest.from((index) => ...)` provides an unbounded deterministic
|
|
832
|
+
sequence; its first factory index is zero and increases monotonically. The
|
|
833
|
+
entrypoint also provides `Random`/`RandomSeeded`, `Logger`/`LoggerTest`, `Config`,
|
|
834
|
+
`CurrentRequest`, and the compatible `CurrentAbortSignal` bridge. None is
|
|
835
|
+
installed implicitly; compose a normal Layer or use the provided test helpers.
|
|
836
|
+
|
|
837
|
+
`Clock.sleep` keeps the original `clock.sleep(milliseconds)` form and accepts
|
|
838
|
+
an optional `AbortSignal`. Invalid delays still throw synchronously. Aborted
|
|
839
|
+
sleeps clear their timer and listener; a supplied `signal.reason` is rejected
|
|
840
|
+
unchanged, otherwise the rejection is an `AbortError`-named `DOMException`.
|
|
841
|
+
|
|
842
|
+
Use the same signal in polling, retry delays and expiration checks:
|
|
843
|
+
|
|
844
|
+
```ts
|
|
845
|
+
const poll = async (clock: Clock, signal: AbortSignal) => {
|
|
846
|
+
while (true) {
|
|
847
|
+
const status = await readStatus()
|
|
848
|
+
if (status.ready) return status
|
|
849
|
+
await clock.sleep(1_000, { signal })
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const retry = async <A>(operation: () => Promise<A>, clock: Clock, signal: AbortSignal) => {
|
|
854
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
855
|
+
try {
|
|
856
|
+
return await operation()
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (attempt === 2) throw error
|
|
859
|
+
await clock.sleep(100 * 2 ** attempt, { signal })
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const waitUntilExpired = async (clock: Clock, expiresAt: number, signal: AbortSignal) => {
|
|
865
|
+
while (clock.now().getTime() < expiresAt) {
|
|
866
|
+
await clock.sleep(Math.min(expiresAt - clock.now().getTime(), 1_000), { signal })
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
`ClockTest` orders sleeps by absolute deadline and FIFO for equal deadlines.
|
|
872
|
+
`pendingSleeps` is a readonly count. `advanceToNext()` returns `false` when
|
|
873
|
+
there is no pending sleep; `runAll({ maxSteps })` advances repeatedly and
|
|
874
|
+
awaits one microtask checkpoint between deadlines so resumed code can schedule
|
|
875
|
+
its next wait:
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
const clock = new ClockTest(0)
|
|
879
|
+
const task = (async () => {
|
|
880
|
+
await clock.sleep(100)
|
|
881
|
+
await clock.sleep(50)
|
|
882
|
+
})()
|
|
883
|
+
|
|
884
|
+
await clock.runAll({ maxSteps: 10 })
|
|
885
|
+
await task
|
|
886
|
+
// clock.now().getTime() === 150
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
`setTime` may move backward. Pending sleeps keep their absolute deadlines and
|
|
890
|
+
resolve only when reached. `ClockTest` does not virtualize `Date`, global
|
|
891
|
+
`setTimeout` or the JavaScript microtask queue.
|
|
458
892
|
|
|
459
893
|
For typed environment configuration, bind a Standard Schema directly to a
|
|
460
894
|
reusable descriptor:
|
|
@@ -572,7 +1006,26 @@ still rejects it when its Layer does not provide every required Service.
|
|
|
572
1006
|
|
|
573
1007
|
Observation helpers such as `Effect.tap`, `Effect.tapError`, and `Effect.tapBoth`
|
|
574
1008
|
run only the active branch and return the original Result, so logging or metrics
|
|
575
|
-
do not change the pipeline's value or requirement channel.
|
|
1009
|
+
do not change the pipeline's value or requirement channel. The async variants
|
|
1010
|
+
`Effect.tapAsync`, `Effect.tapErrorAsync`, and `Effect.tapBothAsync` accept
|
|
1011
|
+
`PromiseLike<void>` observers, always return a Promise, and preserve the source
|
|
1012
|
+
requirements. They delegate branch selection and defect handling to
|
|
1013
|
+
`better-result`; only the active observer runs, and a successful observation
|
|
1014
|
+
returns the exact original Result. They do not create a Scope or resolve
|
|
1015
|
+
Services inside the callback.
|
|
1016
|
+
|
|
1017
|
+
```ts
|
|
1018
|
+
const audited = pipe(
|
|
1019
|
+
loadUser(userId),
|
|
1020
|
+
Effect.tapAsync((user) => metrics.recordUserLoaded(user.id)),
|
|
1021
|
+
Effect.tapErrorAsync((error) => metrics.recordUserFailure(error))
|
|
1022
|
+
)
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
`Effect.matchError` exhaustively maps a tagged `Err` union, while
|
|
1026
|
+
`Effect.matchErrorPartial` maps selected tags and retains unhandled variants in
|
|
1027
|
+
the resulting error union. Both delegate to `better-result`'s tagged-error
|
|
1028
|
+
matchers and preserve the source success and requirement channels.
|
|
576
1029
|
|
|
577
1030
|
Use `Effect.recover` or `Effect.recoverAsync` for an explicit fallback Result;
|
|
578
1031
|
the fallback is evaluated only when the input is an `Err`, and its Service
|
package/dist/adapters/iti.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { N as AnyServiceToken } from "../index-
|
|
2
|
-
import "../
|
|
3
|
-
import
|
|
1
|
+
import { N as AnyServiceToken } from "../index-heuaRmXR.mjs";
|
|
2
|
+
import { S as LayerRegistration, b as LayerBackendDisposeOptions, y as LayerBackend } from "../outcome-CB43bnFW.mjs";
|
|
3
|
+
import "../index-BMdU_Z72.mjs";
|
|
4
4
|
//#region src/adapters/iti.d.ts
|
|
5
5
|
/**
|
|
6
6
|
* ITI-backed Layer backend.
|
|
@@ -24,7 +24,7 @@ declare class ItiLayerBackend implements LayerBackend {
|
|
|
24
24
|
/** Resolve a registered Service through the ITI container. */
|
|
25
25
|
resolve<T extends AnyServiceToken>(token: T): InstanceType<T> | PromiseLike<InstanceType<T>>;
|
|
26
26
|
/** Reset container-owned ITI state; Scope owns Layer provider releases. */
|
|
27
|
-
disposeAll(): Promise<void>;
|
|
27
|
+
disposeAll(options?: LayerBackendDisposeOptions): Promise<void>;
|
|
28
28
|
}
|
|
29
29
|
//#endregion
|
|
30
30
|
export { ItiLayerBackend };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"iti.d.mts","names":[],"sources":["../../src/adapters/iti.ts"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"iti.d.mts","names":[],"sources":["../../src/adapters/iti.ts"],"mappings":";;;;;;;;;;cAyBa,2BAA2B;UAC9B;mBAES;mBAEA;;;;;;mBAOA;UAET;;EAeR,SAAS,cAAc;;EAwBvB,QAAQ,UAAU,iBAAiB,OAAO,IAAI,aAAa,KAAK,YAAY,aAAa;;EAkCnF,WAAW,UAAU,6BAA6B"}
|
package/dist/adapters/iti.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { f as captureServiceTag, n as normalizeLayerRegistration, r as DuplicateServiceError, s as ServiceTagCollisionError, t as captureLayerRegistrationTag, u as ServiceNotFoundError } from "../registration-drnZk0SY.mjs";
|
|
2
2
|
import { t as isPromiseLike } from "../runtime-CDcCF5cb.mjs";
|
|
3
|
-
import { t as assertServiceCompatibility } from "../internal-identity-
|
|
3
|
+
import { t as assertServiceCompatibility } from "../internal-identity-CbXoZIfw.mjs";
|
|
4
4
|
import { createContainer } from "iti";
|
|
5
5
|
//#region src/adapters/iti.ts
|
|
6
6
|
/**
|
|
@@ -18,9 +18,8 @@ var ItiLayerBackend = class {
|
|
|
18
18
|
* ITI caches rejected acquisitions; replacing the container is the explicit
|
|
19
19
|
* retry boundary for that sticky failure behavior.
|
|
20
20
|
*/
|
|
21
|
-
pending = /* @__PURE__ */ new
|
|
22
|
-
keyFor(
|
|
23
|
-
const tag = token.serviceTag;
|
|
21
|
+
pending = /* @__PURE__ */ new Map();
|
|
22
|
+
keyFor(tag) {
|
|
24
23
|
const existing = this.keys.get(tag);
|
|
25
24
|
if (existing) return existing;
|
|
26
25
|
const key = `better-effect:${tag}`;
|
|
@@ -29,20 +28,22 @@ var ItiLayerBackend = class {
|
|
|
29
28
|
}
|
|
30
29
|
/** Register a Layer provider under its deterministic Service-tag key. */
|
|
31
30
|
register(registration) {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
31
|
+
const normalized = normalizeLayerRegistration(registration);
|
|
32
|
+
const token = normalized.service;
|
|
33
|
+
const tag = captureLayerRegistrationTag(normalized);
|
|
34
34
|
const existing = this.registered.get(tag);
|
|
35
35
|
if (existing === token) throw new DuplicateServiceError(token);
|
|
36
36
|
if (existing) throw new ServiceTagCollisionError(existing, token);
|
|
37
|
-
const key = this.keyFor(
|
|
38
|
-
this.container = this.container.add({ [key]:
|
|
37
|
+
const key = this.keyFor(tag);
|
|
38
|
+
this.container = this.container.add({ [key]: normalized.acquire });
|
|
39
39
|
this.registered.set(tag, token);
|
|
40
40
|
}
|
|
41
41
|
/** Resolve a registered Service through the ITI container. */
|
|
42
42
|
resolve(token) {
|
|
43
|
-
const
|
|
43
|
+
const tag = captureServiceTag(token);
|
|
44
|
+
const registered = this.registered.get(tag);
|
|
44
45
|
if (registered === void 0) throw new ServiceNotFoundError(token);
|
|
45
|
-
const key = this.keyFor(
|
|
46
|
+
const key = this.keyFor(tag);
|
|
46
47
|
const resolved = this.container.get(key);
|
|
47
48
|
const validate = (instance) => {
|
|
48
49
|
assertServiceCompatibility(token, registered, instance);
|
|
@@ -50,17 +51,22 @@ var ItiLayerBackend = class {
|
|
|
50
51
|
};
|
|
51
52
|
if (isPromiseLike(resolved)) {
|
|
52
53
|
const pending = Promise.resolve(resolved).then(validate);
|
|
53
|
-
this.pending.
|
|
54
|
+
this.pending.set(pending, registered);
|
|
54
55
|
pending.then(() => this.pending.delete(pending), () => this.pending.delete(pending));
|
|
55
56
|
return pending;
|
|
56
57
|
}
|
|
57
58
|
return validate(resolved);
|
|
58
59
|
}
|
|
59
60
|
/** Reset container-owned ITI state; Scope owns Layer provider releases. */
|
|
60
|
-
async disposeAll() {
|
|
61
|
+
async disposeAll(options) {
|
|
61
62
|
const container = this.container;
|
|
63
|
+
const acquisitions = [...this.pending.keys()];
|
|
62
64
|
try {
|
|
63
|
-
|
|
65
|
+
if (acquisitions.length > 0) {
|
|
66
|
+
const observePending = options?.onPendingAcquisitions;
|
|
67
|
+
if (observePending) await observePending(acquisitions);
|
|
68
|
+
await Promise.allSettled(acquisitions);
|
|
69
|
+
}
|
|
64
70
|
await container.disposeAll();
|
|
65
71
|
} finally {
|
|
66
72
|
this.container = createContainer();
|