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.
Files changed (74) hide show
  1. package/README.md +464 -11
  2. package/dist/adapters/iti.d.mts +4 -4
  3. package/dist/adapters/iti.d.mts.map +1 -1
  4. package/dist/adapters/iti.mjs +20 -14
  5. package/dist/adapters/iti.mjs.map +1 -1
  6. package/dist/effect-CK4YZaX0.mjs +559 -0
  7. package/dist/effect-CK4YZaX0.mjs.map +1 -0
  8. package/dist/hono.d.mts +18 -13
  9. package/dist/hono.d.mts.map +1 -1
  10. package/dist/hono.mjs +9 -5
  11. package/dist/hono.mjs.map +1 -1
  12. package/dist/{index-DklNCz7w.d.mts → index-B1nH4luq.d.mts} +74 -7
  13. package/dist/index-B1nH4luq.d.mts.map +1 -0
  14. package/dist/index-BMdU_Z72.d.mts +18 -0
  15. package/dist/index-BMdU_Z72.d.mts.map +1 -0
  16. package/dist/{index-BafDna0B.d.mts → index-CX6iAoCB.d.mts} +168 -174
  17. package/dist/index-CX6iAoCB.d.mts.map +1 -0
  18. package/dist/index-GYJ4qm9K.d.mts +179 -0
  19. package/dist/index-GYJ4qm9K.d.mts.map +1 -0
  20. package/dist/{index-CULgSzUw.d.mts → index-heuaRmXR.d.mts} +5 -5
  21. package/dist/{index-CULgSzUw.d.mts.map → index-heuaRmXR.d.mts.map} +1 -1
  22. package/dist/index.d.mts +6 -5
  23. package/dist/index.mjs +9 -539
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/{internal-identity-DmUpBeeL.mjs → internal-identity-CbXoZIfw.mjs} +3 -3
  26. package/dist/internal-identity-CbXoZIfw.mjs.map +1 -0
  27. package/dist/{signal-C1bagvrO.mjs → layer-D56fRdLt.mjs} +62 -64
  28. package/dist/layer-D56fRdLt.mjs.map +1 -0
  29. package/dist/node-context-DWsCUBRa.d.mts +12 -0
  30. package/dist/node-context-DWsCUBRa.d.mts.map +1 -0
  31. package/dist/node-context-lggRF4bR.mjs +25 -0
  32. package/dist/node-context-lggRF4bR.mjs.map +1 -0
  33. package/dist/node.d.mts +51 -0
  34. package/dist/node.d.mts.map +1 -0
  35. package/dist/node.mjs +233 -0
  36. package/dist/node.mjs.map +1 -0
  37. package/dist/{index-C7KX5rAP.d.mts → outcome-CB43bnFW.d.mts} +140 -27
  38. package/dist/outcome-CB43bnFW.d.mts.map +1 -0
  39. package/dist/program-metadata-DhQCCirO.mjs +260 -0
  40. package/dist/program-metadata-DhQCCirO.mjs.map +1 -0
  41. package/dist/registration-drnZk0SY.mjs +182 -0
  42. package/dist/registration-drnZk0SY.mjs.map +1 -0
  43. package/dist/runtime/explicit.d.mts +1 -1
  44. package/dist/runtime/node.d.mts +3 -12
  45. package/dist/runtime/node.mjs +2 -24
  46. package/dist/runtime-CBMrX8Wl.mjs +776 -0
  47. package/dist/runtime-CBMrX8Wl.mjs.map +1 -0
  48. package/dist/signal-D2UTcaaG.mjs +105 -0
  49. package/dist/signal-D2UTcaaG.mjs.map +1 -0
  50. package/dist/{standard-services-DW-i4UuA.mjs → standard-services-GBZ32tll.mjs} +200 -16
  51. package/dist/standard-services-GBZ32tll.mjs.map +1 -0
  52. package/dist/standard-services.d.mts +3 -3
  53. package/dist/standard-services.mjs +3 -3
  54. package/dist/testing.d.mts +257 -2
  55. package/dist/testing.d.mts.map +1 -0
  56. package/dist/testing.mjs +1159 -2
  57. package/dist/testing.mjs.map +1 -0
  58. package/package.json +8 -3
  59. package/dist/effect-DAMqvegy.mjs +0 -544
  60. package/dist/effect-DAMqvegy.mjs.map +0 -1
  61. package/dist/errors-Dnjhzbt0.mjs +0 -99
  62. package/dist/errors-Dnjhzbt0.mjs.map +0 -1
  63. package/dist/index-BafDna0B.d.mts.map +0 -1
  64. package/dist/index-C7KX5rAP.d.mts.map +0 -1
  65. package/dist/index-DklNCz7w.d.mts.map +0 -1
  66. package/dist/internal-identity-DmUpBeeL.mjs.map +0 -1
  67. package/dist/map-layer-backend-gal-mcRv.mjs +0 -53
  68. package/dist/map-layer-backend-gal-mcRv.mjs.map +0 -1
  69. package/dist/map-layer-backend-rNwfH0Bz.d.mts +0 -42
  70. package/dist/map-layer-backend-rNwfH0Bz.d.mts.map +0 -1
  71. package/dist/runtime/node.d.mts.map +0 -1
  72. package/dist/runtime/node.mjs.map +0 -1
  73. package/dist/signal-C1bagvrO.mjs.map +0 -1
  74. 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. Their validated
233
- `c.req.valid(...)` inputs are combined and inferred without a manual `Input`
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`, `Effect.acquireRelease`, `Effect.add` and `Scope` make their lifetime explicit.
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
- The entrypoint also provides `Random`/`RandomSeeded`, `Logger`/`LoggerTest`,
455
- `Config`, `CurrentRequest`, and the compatible `CurrentAbortSignal` bridge. None
456
- is installed implicitly; compose a normal Layer or use the provided test
457
- helpers.
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
@@ -1,6 +1,6 @@
1
- import { N as AnyServiceToken } from "../index-CULgSzUw.mjs";
2
- import "../index-C7KX5rAP.mjs";
3
- import { a as LayerRegistration, n as LayerBackend } from "../map-layer-backend-rNwfH0Bz.mjs";
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":";;;;;;;;;;cAsBa,2BAA2B;UAC9B;mBAES;mBAEA;;;;;;mBAOA;UAET;;EAgBR,SAAS,cAAc;;EAuBvB,QAAQ,UAAU,iBAAiB,OAAO,IAAI,aAAa,KAAK,YAAY,aAAa;;EAiCnF,cAAc"}
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"}
@@ -1,6 +1,6 @@
1
- import { a as ServiceTagCollisionError, c as ServiceNotFoundError, t as DuplicateServiceError } from "../errors-Dnjhzbt0.mjs";
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-DmUpBeeL.mjs";
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 Set();
22
- keyFor(token) {
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 token = registration.service;
33
- const tag = token.serviceTag;
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(token);
38
- this.container = this.container.add({ [key]: registration.acquire });
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 registered = this.registered.get(token.serviceTag);
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(token);
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.add(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
- await Promise.allSettled(this.pending);
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();