opencode-effect-enforcer 0.2.8 → 0.4.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 +6 -5
  2. package/docs/effect-4.0.0-changelog.md +3213 -0
  3. package/docs/effect-4.0.0.md +110 -0
  4. package/guidance/effect-first-development.md +8 -6
  5. package/guidance/progressive-disclosure-guidance.md +15 -7
  6. package/package.json +6 -2
  7. package/patterns/avoid-any.md +2 -2
  8. package/patterns/avoid-direct-json.md +6 -6
  9. package/patterns/avoid-native-fetch.md +8 -6
  10. package/patterns/avoid-node-imports.md +2 -2
  11. package/patterns/avoid-non-null-assertion.md +2 -2
  12. package/patterns/avoid-object-type.md +2 -2
  13. package/patterns/avoid-platform-coupling.md +1 -1
  14. package/patterns/avoid-process-env.md +3 -4
  15. package/patterns/avoid-ts-ignore.md +1 -1
  16. package/patterns/context-tag-extends.md +11 -8
  17. package/patterns/effect-promise-vs-trypromise.md +6 -7
  18. package/patterns/prefer-arr-sort.md +1 -1
  19. package/patterns/prefer-effect-fn.md +21 -65
  20. package/patterns/prefer-schema-class.md +3 -3
  21. package/patterns/throw-in-effect-gen.md +1 -1
  22. package/patterns/use-clock-service.md +4 -0
  23. package/patterns/use-command-executor-service.md +2 -2
  24. package/patterns/use-http-client-service.md +8 -6
  25. package/patterns/use-random-service.md +6 -7
  26. package/skills/effect-ai-chat/SKILL.md +13 -7
  27. package/skills/effect-ai-decision-model/SKILL.md +301 -0
  28. package/skills/effect-ai-decision-model/openrouter.md +70 -0
  29. package/skills/effect-ai-language-model/SKILL.md +53 -21
  30. package/skills/effect-ai-prompt/SKILL.md +25 -14
  31. package/skills/effect-ai-provider/SKILL.md +53 -22
  32. package/skills/effect-ai-streaming/SKILL.md +27 -12
  33. package/skills/effect-ai-tool/SKILL.md +37 -28
  34. package/skills/effect-atom-rpc/SKILL.md +57 -36
  35. package/skills/effect-atom-state/SKILL.md +57 -19
  36. package/skills/effect-batching/SKILL.md +5 -3
  37. package/skills/effect-cache/SKILL.md +19 -7
  38. package/skills/effect-cli/SKILL.md +17 -8
  39. package/skills/effect-command-executor/SKILL.md +115 -64
  40. package/skills/effect-concurrency-testing/SKILL.md +26 -6
  41. package/skills/effect-config/SKILL.md +53 -2
  42. package/skills/effect-context-witness/SKILL.md +6 -6
  43. package/skills/effect-domain-modeling/SKILL.md +8 -1
  44. package/skills/effect-error-handling/SKILL.md +15 -2
  45. package/skills/effect-fiber/SKILL.md +20 -25
  46. package/skills/effect-filesystem/SKILL.md +69 -57
  47. package/skills/effect-http-api/SKILL.md +72 -22
  48. package/skills/effect-http-client/SKILL.md +25 -21
  49. package/skills/effect-http-server/SKILL.md +51 -21
  50. package/skills/effect-incremental-migration/SKILL.md +17 -8
  51. package/skills/effect-layer-design/SKILL.md +8 -0
  52. package/skills/effect-managed-runtime/SKILL.md +6 -0
  53. package/skills/effect-mcp-server/SKILL.md +64 -24
  54. package/skills/effect-observability/SKILL.md +61 -15
  55. package/skills/effect-parallelization/SKILL.md +24 -7
  56. package/skills/effect-path/SKILL.md +8 -2
  57. package/skills/effect-platform-abstraction/SKILL.md +88 -66
  58. package/skills/effect-platform-layers/SKILL.md +68 -67
  59. package/skills/effect-pubsub-event-bus/SKILL.md +56 -60
  60. package/skills/effect-react-composition/SKILL.md +19 -6
  61. package/skills/effect-rpc-api/SKILL.md +24 -24
  62. package/skills/effect-rpc-client/SKILL.md +33 -28
  63. package/skills/effect-rpc-cluster/SKILL.md +122 -78
  64. package/skills/effect-rpc-server/SKILL.md +56 -20
  65. package/skills/effect-scheduling/SKILL.md +29 -1
  66. package/skills/effect-schema-composition/SKILL.md +31 -13
  67. package/skills/effect-schema-v4/SKILL.md +94 -10
  68. package/skills/effect-scope/SKILL.md +13 -5
  69. package/skills/effect-service-implementation/SKILL.md +1 -1
  70. package/skills/effect-socket/SKILL.md +52 -8
  71. package/skills/effect-sql/SKILL.md +67 -33
  72. package/skills/effect-stream/SKILL.md +50 -5
  73. package/skills/effect-testing/SKILL.md +91 -2
  74. package/skills/effect-workflow/SKILL.md +76 -39
@@ -7,6 +7,8 @@ description: Structure Effect platform layer provision for cross-platform applic
7
7
 
8
8
  Master Effect platform layer provision for cross-platform applications. Use this skill when structuring applications that use Effect platform abstractions to ensure portability across Node.js and Bun environments.
9
9
 
10
+ Targets **Effect 4.0.0**. Keep `effect` and `@effect/*` packages on the same version and inspect the `effect@4.0.0` source tag. APIs explicitly marked `@stability unstable` can change in minor releases, including third-party client surfaces such as NodeRedis.
11
+
10
12
  ## The Golden Rule
11
13
 
12
14
  **Application code uses abstract interfaces. Platform-specific layers are provided either at the program entry point or inside a runtime-facing adapter module's `defaultLayer`.**
@@ -49,9 +51,10 @@ The important boundary is that downstream callers still depend on the abstract s
49
51
 
50
52
  For HTTP provider adapters, the abstract dependency is `HttpClient.HttpClient`. Keep it visible on the adapter's raw layer and name the adapter after the upstream it owns:
51
53
 
54
+ <!-- typecheck -->
52
55
  ```typescript
53
56
  import { Context, Effect, Layer } from 'effect';
54
- import { FetchHttpClient, HttpClient } from 'effect/unstable/http';
57
+ import { FetchHttpClient, HttpClient } from 'effect/http';
55
58
 
56
59
  export class ProviderGateway extends Context.Service<ProviderGateway, {
57
60
  readonly health: Effect.Effect<void>;
@@ -120,9 +123,9 @@ Each platform context (`NodeServices.layer`, `BunServices.layer`) provides these
120
123
  | **Stdio** | `Stdio.Stdio` | Standard I/O streams (stdin, stdout, stderr) | `effect` |
121
124
  | **Terminal** | `Terminal.Terminal` | Terminal/console I/O with ANSI support | `effect` |
122
125
  | **Crypto** | `Crypto.Crypto` | Cryptographic random bytes, UUIDs, and digests | `effect` |
123
- | **ChildProcessSpawner** | `ChildProcessSpawner.ChildProcessSpawner` | Spawn and manage child processes | `effect/unstable/process` |
126
+ | **ChildProcessSpawner** | `ChildProcessSpawner.ChildProcessSpawner` | Spawn and manage child processes | `effect/process` |
124
127
 
125
- `Crypto.Crypto` is included in the Node/Bun aggregate layers; browser applications can provide `BrowserCrypto.layer` when they need the crypto service. These aggregate layers are core service bundles: they do **not** provide specialized integrations such as HTTP clients/servers, sockets, workers, or Redis. For sockets, import `Socket.Socket` / `SocketServer.SocketServer` from `effect/unstable/socket` and provide socket-specific layers such as `NodeSocket.layerWebSocket(...)`, `NodeSocket.layerNet(...)`, `BunSocket.layerWebSocket(...)`, `BrowserSocket.layerWebSocket(...)`, or Node/Bun socket-server layers as appropriate.
128
+ `Crypto.Crypto` is included in the Node/Bun aggregate layers; browser applications can provide `BrowserCrypto.layer` when they need the crypto service. These aggregate layers are core service bundles: they do **not** provide specialized integrations such as HTTP clients/servers, sockets, workers, or Redis. For sockets, import `Socket.Socket` / `SocketServer.SocketServer` from `effect/socket` and provide socket-specific layers such as `NodeSocket.layerWebSocket(...)`, `NodeSocket.layerNet(...)`, `BunSocket.layerWebSocket(...)`, `BrowserSocket.layerWebSocket(...)`, or Node/Bun socket-server layers as appropriate.
126
129
 
127
130
  Runtime application/provider HTTP belongs behind Effect `HttpClient`, not raw `fetch`. Only a named low-level platform transport adapter may use fetch directly, with a documented justification and full ownership of interruption, status-before-decode, schema decoding, and typed errors. Provider adapters also own redacted diagnostic evidence and retry exhaustion; provider calls run outside database transactions, and retries apply only to proven-idempotent operations. In particular, do not decorate a shared client with automatic retry when it can execute ordinary non-idempotent POST/PATCH requests.
128
131
 
@@ -142,9 +145,10 @@ The Node layer connects while it is built and therefore can fail with `Redis.Red
142
145
 
143
146
  ### Usage Example
144
147
 
148
+ <!-- typecheck -->
145
149
  ```typescript
146
150
  import { Console, Crypto, Effect, FileSystem, Path, Stream, Terminal } from 'effect';
147
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
151
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
148
152
 
149
153
  const buildProject = Effect.gen(function* () {
150
154
  const fs = yield* FileSystem.FileSystem;
@@ -175,7 +179,7 @@ const buildProject = Effect.gen(function* () {
175
179
  Stream.runForEach((line) => Console.log(`[build] ${line}`))
176
180
  );
177
181
  return yield* handle.exitCode;
178
- });
182
+ }).pipe(Effect.scoped);
179
183
  ```
180
184
 
181
185
  ## Layer Composition Patterns
@@ -219,21 +223,23 @@ pipe(
219
223
  import { NodeServices, NodeRuntime } from '@effect/platform-node';
220
224
  import { Effect, FileSystem, Layer, pipe } from 'effect';
221
225
 
222
- declare const program: Effect.Effect<void, never, never>;
226
+ declare const program: Effect.Effect<string, never, FileSystem.FileSystem>;
223
227
 
224
- // Custom FileSystem implementation
225
- const CustomFS = Layer.succeed(FileSystem.FileSystem, {
226
- /* custom implementation */
227
- } as FileSystem.FileSystem);
228
+ // A complete test double without asserting an incomplete object.
229
+ const CustomFS = FileSystem.layerNoop({
230
+ readFileString: () => Effect.succeed('custom content')
231
+ });
228
232
 
229
233
  pipe(
230
234
  program,
235
+ Effect.provide(CustomFS), // Innermost provision wins for program reads
231
236
  Effect.provide(NodeServices.layer),
232
- Effect.provide(CustomFS), // Override after platform layer
233
237
  NodeRuntime.runMain
234
238
  );
235
239
  ```
236
240
 
241
+ Provision nests: `program.pipe(Effect.provide(A), Effect.provide(B))` builds A inside B, and A wins for overlapping services seen by `program`. An already-built aggregate such as `NodeServices.layer` wires its own internal dependencies; replacing the FileSystem seen by application code does not rewire its child-process spawner. Compose individual platform layers when those internal dependencies must also be replaced.
242
+
237
243
  ## Testing with Mock Layers
238
244
 
239
245
  **CRITICAL**: Prefer mock abstract services for unit tests. For runtime-adapter or layer-composition tests, it is acceptable to provide the real Node/Bun layers directly when that is the behavior under review.
@@ -242,12 +248,12 @@ pipe(
242
248
 
243
249
  ```typescript
244
250
  import { Effect, FileSystem, Layer } from 'effect';
245
- import { expect, test } from 'vitest';
251
+ import { expect, it } from '@effect/vitest';
246
252
 
247
253
  declare const readConfig: Effect.Effect<string, never, FileSystem.FileSystem>;
248
254
 
249
- // Use FileSystem.makeNoop for testing — provides default "NotFound" stubs
250
- // for all methods, then override only the ones you need
255
+ // makeNoop supplies NotFound for many operations, exists=false, remove=void,
256
+ // and defects for directory/temp creation. Override each operation the test uses.
251
257
  const MockFileSystem = Layer.succeed(
252
258
  FileSystem.FileSystem,
253
259
  FileSystem.makeNoop({
@@ -256,18 +262,18 @@ const MockFileSystem = Layer.succeed(
256
262
  })
257
263
  );
258
264
 
259
- test('should read config', () =>
265
+ it.effect('should read config', () =>
260
266
  Effect.gen(function* () {
261
267
  const result = yield* readConfig;
262
268
  expect(result).toContain('mock content');
263
- }).pipe(Effect.provide(MockFileSystem), Effect.runPromise));
269
+ }).pipe(Effect.provide(MockFileSystem)));
264
270
  ```
265
271
 
266
272
  ### Mocking Multiple Services
267
273
 
268
274
  ```typescript
269
275
  import { Effect, FileSystem, Layer, Path, Terminal } from 'effect';
270
- import { test } from 'vitest';
276
+ import { it } from '@effect/vitest';
271
277
 
272
278
  declare const program: Effect.Effect<
273
279
  void,
@@ -276,38 +282,33 @@ declare const program: Effect.Effect<
276
282
  >;
277
283
 
278
284
  const TestContext = Layer.mergeAll(
279
- Layer.succeed(FileSystem.FileSystem, {
285
+ FileSystem.layerNoop({
280
286
  readFileString: () => Effect.succeed('test')
281
- // ...
282
- } as FileSystem.FileSystem),
287
+ }),
283
288
 
284
- Layer.succeed(Path.Path, {
285
- join: (...parts) => parts.join('/'),
286
- normalize: (path) => path
287
- // ...
288
- } as Path.Path),
289
+ Path.layer, // Complete, deterministic POSIX path operations
289
290
 
290
291
  Layer.succeed(
291
292
  Terminal.Terminal,
292
293
  Terminal.make({
293
294
  columns: Effect.succeed(80),
294
295
  rows: Effect.succeed(24),
295
- readInput: Effect.die('readInput not used in this test'),
296
+ readInput: Effect.die('readInput not used in this test'),
296
297
  readLine: Effect.succeed('test input'),
297
298
  display: () => Effect.void
298
299
  })
299
300
  )
300
301
  );
301
302
 
302
- test('integration test', () =>
303
- program.pipe(Effect.provide(TestContext), Effect.runPromise));
303
+ it.effect('integration test', () =>
304
+ program.pipe(Effect.provide(TestContext)));
304
305
  ```
305
306
 
306
307
  ### Using layerNoop for Convenient Test Layers
307
308
 
308
309
  ```typescript
309
310
  import { Effect, FileSystem } from 'effect';
310
- import { test } from 'vitest';
311
+ import { it } from '@effect/vitest';
311
312
 
312
313
  // FileSystem.layerNoop wraps makeNoop in a Layer for convenience
313
314
  const TestFS = FileSystem.layerNoop({
@@ -316,11 +317,11 @@ const TestFS = FileSystem.layerNoop({
316
317
  exists: () => Effect.succeed(true)
317
318
  });
318
319
 
319
- test('with layerNoop', () =>
320
+ it.effect('with layerNoop', () =>
320
321
  Effect.gen(function* () {
321
322
  const fs = yield* FileSystem.FileSystem;
322
323
  yield* fs.writeFileString('test.txt', 'content');
323
- }).pipe(Effect.provide(TestFS), Effect.runPromise));
324
+ }).pipe(Effect.provide(TestFS)));
324
325
  ```
325
326
 
326
327
  ## Architecture Patterns
@@ -339,24 +340,28 @@ src/
339
340
 
340
341
  ### Service Implementation
341
342
 
343
+ <!-- typecheck -->
342
344
  ```typescript
343
345
  // services/ConfigService.ts
344
- import { Effect, FileSystem, Layer, Path, Schema, Context } from 'effect';
346
+ import { Effect, FileSystem, Layer, Path, Context } from 'effect';
347
+ import * as Schema from 'effect/Schema';
345
348
 
346
- interface Config {
347
- readonly name: string;
348
- readonly version: string;
349
- }
349
+ class Config extends Schema.Class<Config>('Config')({
350
+ name: Schema.String,
351
+ version: Schema.String
352
+ }) {}
350
353
 
351
- declare const ConfigSchema: Schema.Schema<Config>;
354
+ const ConfigJson = Schema.fromJsonString(Config);
352
355
 
353
356
  class ConfigError extends Schema.TaggedError<ConfigError>()(
354
357
  'ConfigError',
355
358
  {
356
- message: Schema.String
359
+ operation: Schema.Literals(['load', 'save']),
360
+ cause: Schema.Defect()
357
361
  }
358
362
  ) {}
359
363
 
364
+ /** Loads and saves schema-validated application configuration. */
360
365
  export class ConfigService extends Context.Service<
361
366
  ConfigService,
362
367
  {
@@ -365,6 +370,7 @@ export class ConfigService extends Context.Service<
365
370
  }
366
371
  >()('ConfigService') {}
367
372
 
373
+ /** Requires abstract file-system and path services; preserves boundary failures. */
368
374
  export const ConfigServiceLive = Layer.effect(
369
375
  ConfigService,
370
376
  Effect.gen(function* () {
@@ -374,15 +380,18 @@ export const ConfigServiceLive = Layer.effect(
374
380
  const load = Effect.gen(function* () {
375
381
  const configPath = path.join('config', 'app.json');
376
382
  const content = yield* fs.readFileString(configPath);
377
- return yield* Schema.decode(ConfigSchema)(JSON.parse(content));
378
- });
383
+ return yield* Schema.decodeUnknownEffect(ConfigJson)(content);
384
+ }).pipe(Effect.mapError((cause) => new ConfigError({ operation: 'load', cause })));
379
385
 
380
- const save = (config: Config) =>
381
- Effect.gen(function* () {
386
+ const save = Effect.fn('Config.save')(
387
+ function* (config: Config) {
382
388
  const configPath = path.join('config', 'app.json');
383
- const content = JSON.stringify(config, null, 2);
389
+ const content = yield* Schema.encodeEffect(ConfigJson)(config);
390
+ yield* fs.makeDirectory(path.dirname(configPath), { recursive: true });
384
391
  yield* fs.writeFileString(configPath, content);
385
- });
392
+ },
393
+ Effect.mapError((cause) => new ConfigError({ operation: 'save', cause }))
394
+ );
386
395
 
387
396
  return { load, save };
388
397
  })
@@ -418,27 +427,23 @@ pipe(
418
427
 
419
428
  ## Common Patterns
420
429
 
421
- ### Conditional Platform Loading
430
+ ### Runtime-Specific Entry Points
422
431
 
423
432
  ```typescript
433
+ // main-node.ts
424
434
  import { NodeServices, NodeRuntime } from '@effect/platform-node';
425
- import { BunServices } from '@effect/platform-bun';
426
- import { Effect, pipe } from 'effect';
435
+ import { Effect } from 'effect';
427
436
 
428
437
  declare const program: Effect.Effect<void, never, never>;
429
438
 
430
- const PlatformContext =
431
- process.env.RUNTIME === 'bun' ? BunServices.layer : NodeServices.layer;
432
-
433
- pipe(
434
- program,
435
- Effect.provide(PlatformContext),
436
- NodeRuntime.runMain // Runtime matches context
437
- );
439
+ program.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
438
440
  ```
439
441
 
442
+ Use `BunServices.layer` with `BunRuntime.runMain` in the Bun entry point. Separate entry points avoid eagerly loading adapters for a different runtime or selecting a layer independently of its runner.
443
+
440
444
  ### Scoped Platform Resources
441
445
 
446
+ <!-- typecheck -->
442
447
  ```typescript
443
448
  import { Effect, FileSystem, Path } from 'effect';
444
449
 
@@ -446,17 +451,13 @@ const withTempDirectory = Effect.gen(function* () {
446
451
  const fs = yield* FileSystem.FileSystem;
447
452
  const path = yield* Path.Path;
448
453
 
449
- const tempDir = yield* Effect.acquireRelease(
450
- Effect.gen(function* () {
451
- const dir = path.join('temp', `${Date.now()}`);
452
- yield* fs.makeDirectory(dir, { recursive: true });
453
- return dir;
454
- }),
455
- (dir) => fs.remove(dir, { recursive: true })
456
- );
454
+ const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: 'app-' });
455
+ const file = path.join(tempDir, 'result.txt');
456
+ yield* fs.writeFileString(file, 'result');
457
457
 
458
- return tempDir;
459
- });
458
+ // Return a durable value, not the path to a resource about to be deleted.
459
+ return yield* fs.readFileString(file);
460
+ }).pipe(Effect.scoped);
460
461
  ```
461
462
 
462
463
  ## Anti-Patterns
@@ -506,8 +507,8 @@ export const myService = program.pipe(
506
507
  1. **Import abstractions, provide implementations**: Application code imports from `effect` (e.g. `FileSystem`, `Path`, `Terminal`), entry points provide platform-specific contexts
507
508
  2. **One platform layer per runtime**: Use exactly one of `NodeServices.layer` or `BunServices.layer`
508
509
  3. **Platform layer last**: Provide custom services first, platform context last
509
- 4. **Mock in tests**: Use `Layer.succeed` with mock implementations, never import platform-specific modules in tests
510
- 5. **Entry point decides platform**: Only `main.ts` (or equivalent entry) should import platform-specific modules
510
+ 4. **Test through services**: Use complete test layers for unit tests; real platform layers belong in runtime-adapter or composition tests
511
+ 5. **Own platform wiring**: Entry points and runtime-facing adapter `defaultLayer` exports may import platform-specific modules
511
512
  6. **Keep HTTP transport requirements visible**: Provider adapter `layer` requires `HttpClient.HttpClient`; an optional `defaultLayer` may provide the chosen transport
512
513
  7. **Make adapters own the boundary**: Named adapters classify status before schema decoding, map typed failures, retain redacted evidence, and expose retry exhaustion
513
514
  8. **Do not retry by accident**: Restrict retrying/rate-limited clients to proven-idempotent operations; non-idempotent calls need an explicit provider guarantee or idempotency key
@@ -192,41 +192,48 @@ const make = Effect.gen(function* () {
192
192
 
193
193
  ## Pattern: Graceful Shutdown Event
194
194
 
195
- Publish a final event before shutting down PubSub channels so subscribers can perform cleanup:
195
+ Use `PubSub.end(pubsub, finalEvent)` to deliver a terminal event after buffered
196
+ messages. The terminal event occupies no capacity, cannot be dropped by a full
197
+ bounded channel, and reaches future subscribers too (after any replay values).
198
+ Pending backpressured publishers and later publishes return `false`.
196
199
 
197
- ```typescript
198
- yield*
199
- Effect.addFinalizer(() =>
200
- Effect.gen(function* () {
201
- // Notify all subscribers that the bus is shutting down
202
- yield* PubSub.publish(
203
- wildcard,
204
- new InstanceDisposed({ reason: 'scope-closed' })
205
- );
206
- // Then shut down the channel
207
- yield* PubSub.shutdown(wildcard);
208
- })
209
- );
210
- ```
211
-
212
- Subscribers can detect this event and perform teardown:
200
+ The terminal event is **sticky**: further `take` calls return it again. Raw
201
+ subscribers must stop, and `Stream.fromPubSub` consumers must use a terminal
202
+ predicate such as `Stream.takeUntil`. `take`, `takeAll`, and `takeBetween` deliver
203
+ it; non-suspending `takeUpTo` does not.
213
204
 
205
+ <!-- typecheck -->
214
206
  ```typescript
215
- yield*
216
- bus.subscribeAll.pipe(
217
- Stream.takeUntil((evt) => evt instanceof InstanceDisposed),
218
- Stream.runForEach(handleEvent),
219
- Effect.forkScoped
220
- );
207
+ import { Effect, PubSub } from 'effect';
208
+
209
+ const program = Effect.gen(function* () {
210
+ const pubsub = yield* PubSub.bounded<string>(1);
211
+ yield* Effect.addFinalizer(() => PubSub.shutdown(pubsub));
212
+ const subscription = yield* PubSub.subscribe(pubsub);
213
+ yield* PubSub.publish(pubsub, 'work');
214
+ yield* PubSub.end(pubsub, 'finished'); // succeeds even with the buffer full
215
+ const work = yield* PubSub.take(subscription);
216
+ const terminal = yield* PubSub.take(subscription);
217
+ const late = yield* PubSub.subscribe(pubsub);
218
+ const lateTerminal = yield* PubSub.take(late);
219
+ return { work, terminal, lateTerminal };
220
+ }).pipe(Effect.scoped);
221
221
  ```
222
222
 
223
+ For a domain bus, use a tagged terminal variant rather than a string sentinel.
224
+ End the bus while consumers are still alive, then await their completion before
225
+ closing their scope. `PubSub.shutdown` interrupts subscribers and discards their
226
+ ability to drain; calling it immediately after publishing or ending does not
227
+ guarantee the final event is handled. Keep shutdown as the final cleanup step,
228
+ not the graceful notification mechanism.
229
+
223
230
  ## Redis Pub/Sub
224
231
 
225
232
  For cross-process pub/sub, use the portable `Redis.Redis` service rather than modeling Redis as an in-memory `PubSub`. `redis.subscribe(channel)` is scoped and returns a dequeue whose values contain both `channel` and `message`.
226
233
 
227
234
  ```typescript
228
235
  import { Effect, Stream } from 'effect';
229
- import { Redis } from 'effect/unstable/persistence';
236
+ import { Redis } from 'effect/persistence';
230
237
 
231
238
  declare const handleRedisMessage: (
232
239
  channel: string,
@@ -249,54 +256,38 @@ The subscription uses a dedicated client and is released when the enclosing scop
249
256
 
250
257
  ## Testing PubSub Services
251
258
 
252
- Testing PubSub subscriptions requires specific choreography:
253
-
254
- 1. Fork the consumer fiber
255
- 2. Wait for subscriber readiness explicitly when possible; otherwise use a tiny registration barrier
256
- 3. Publish events
257
- 4. Gate on a `Deferred` for synchronization
259
+ Acquire the subscription before publishing, then fork consumption if needed.
260
+ For a service that exposes only `Stream`, provide a test seam that signals actual
261
+ subscription readiness. Starting a fiber, or signaling before the subscription
262
+ is acquired, is not a registration barrier.
258
263
 
259
264
  ```typescript
260
- import { Deferred, Effect, PubSub, Stream } from 'effect';
265
+ import { Effect, Fiber, PubSub, Stream } from 'effect';
266
+ import * as Arr from 'effect/Array';
261
267
 
262
268
  it.effect('should receive published events', () =>
263
269
  Effect.gen(function* () {
264
- const bus = yield* Bus.Service;
265
- const received: Array<string> = [];
266
- const done = yield* Deferred.make<void>();
267
-
268
- // 1. Fork the consumer
269
- yield* bus.subscribe(FileChanged).pipe(
270
- Stream.runForEach((evt) =>
271
- Effect.gen(function* () {
272
- received.push(evt.path);
273
- if (received.length === 2) {
274
- yield* Deferred.succeed(done, undefined);
275
- }
276
- })
277
- ),
278
- Effect.forkScoped
270
+ const pubsub = yield* PubSub.unbounded<FileChanged>();
271
+ yield* Effect.addFinalizer(() => PubSub.shutdown(pubsub));
272
+ const subscription = yield* PubSub.subscribe(pubsub);
273
+ const consumer = yield* Stream.fromEffectRepeat(PubSub.take(subscription)).pipe(
274
+ Stream.take(2),
275
+ Stream.runCollect,
276
+ Effect.forkChild
279
277
  );
280
278
 
281
- // 2. Registration barrier
282
- yield* Effect.sleep('10 millis');
283
-
284
- // 3. Publish events
285
- yield* bus.publish(new FileChanged({ path: 'a.ts', kind: 'modified' }));
286
- yield* bus.publish(new FileChanged({ path: 'b.ts', kind: 'created' }));
287
-
288
- // 4. Wait for events to be received
289
- yield* Deferred.await(done);
290
-
291
- expect(received).toEqual(['a.ts', 'b.ts']);
292
- }).pipe(Effect.provide(Bus.layer))
279
+ yield* PubSub.publish(pubsub, new FileChanged({ path: 'a.ts', kind: 'modified' }));
280
+ yield* PubSub.publish(pubsub, new FileChanged({ path: 'b.ts', kind: 'created' }));
281
+ const received = yield* Fiber.join(consumer);
282
+ expect(Arr.map(received, (event) => event.path)).toEqual(['a.ts', 'b.ts']);
283
+ })
293
284
  );
294
285
  ```
295
286
 
296
287
  **Notes:**
297
288
 
298
- - The `runForEach` handler is effectful, so complete the gate with `yield* Deferred.succeed(done, undefined)`. There is no `Deferred.unsafeDone` in v4; reach for the low-level `Deferred.doneUnsafe(done, Effect.void)` only inside a truly synchronous callback that has no surrounding effect.
299
- - The tiny sleep above is an acceptable fallback for `Stream.fromPubSub` registration when no explicit readiness hook exists. If you control the consumer stream, prefer a readiness `Deferred` or latch instead.
289
+ - `PubSub.subscribe` is already scoped; `it.effect` supplies its lifetime. No manual unsubscribe or sleeps are needed.
290
+ - If a service uses a readiness `Deferred`, complete it only after `PubSub.subscribe` has returned. Acquiring a stream pull alone does not prove its lazy subscription has started.
300
291
  - To drain a `PubSub` subscription for assertions, prefer `PubSub.takeUpTo(sub, n)`: it returns immediately with whatever is buffered (possibly an empty array). `PubSub.takeAll(sub)` **suspends when the subscription is empty** and returns a `NonEmptyArray`, so it cannot be used to assert “no more events” — it would hang waiting for one.
301
292
 
302
293
  ## PubSub Configuration
@@ -330,6 +321,11 @@ Choose based on your use case:
330
321
 
331
322
  **PubSub is not an event log.** Messages are delivered to *active* subscribers only. A subscriber that attaches after a value was published does not see that value unless a `replay` buffer is configured, and `replay` only retains the most recent N values — it is bounded, recent-only, and not durable storage. If you need every consumer to observe the full history, subscribe before publishing (see the testing choreography above) or persist events separately.
332
323
 
324
+ The final value passed to `PubSub.end` is the exception: every subscriber sees
325
+ it even without replay. An `Infinity` capacity behaves as unbounded; use
326
+ `PubSub.unbounded` when that is the intended policy. `PubSub.isPubSub(value)` is
327
+ the runtime guard for unknown values.
328
+
333
329
  ## DO / DON'T
334
330
 
335
331
  ### DO: Use `Stream.fromPubSub` + `forkScoped` for subscriptions
@@ -7,15 +7,19 @@ description: Build composable React components using Effect Atom for state manag
7
7
 
8
8
  Build React UIs using compositional patterns, Effect Atom for state management, and the component module pattern. Use this skill when creating React applications that integrate with Effect's ecosystem.
9
9
 
10
+ `@effect/atom-react@4.0.0` requires React `>=19.0.0 <20.0.0` and the same version
11
+ of `effect`. Core `effect/reactivity` APIs remain `@stability unstable` and can
12
+ change incompatibly in minor releases.
13
+
10
14
  ## Effect Source Reference
11
15
 
12
16
  The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
13
- Browse and read files there directly to look up APIs, types, and implementations.
17
+ Use `git show effect@4.0.0:<path>` there for this baseline; main may be ahead.
14
18
 
15
19
  Reference this for:
16
20
 
17
- - Atom reactivity: `packages/effect/src/unstable/reactivity/`
18
- - AsyncResult source: `packages/effect/src/unstable/reactivity/AsyncResult.ts`
21
+ - Atom reactivity: `packages/effect/src/reactivity/`
22
+ - AsyncResult source: `packages/effect/src/reactivity/AsyncResult.ts`
19
23
  - Effect source: `packages/effect/src/`
20
24
 
21
25
  ## When to Use This Skill
@@ -291,11 +295,20 @@ function ExternalButton() {
291
295
 
292
296
  Effect Atom provides reactive state management that integrates seamlessly with React.
293
297
 
298
+ Share a `RegistryProvider` across components that should share atom state. Its
299
+ registry options are initialization-only; changing them after first render does
300
+ not rebuild the registry. Cleanup is delayed briefly on unmount and canceled on
301
+ quick remount, supporting React's development lifecycle. Unused atoms follow
302
+ their idle TTL/registry policy, so an unmount is not a synchronous reset signal.
303
+ Use `Atom.keepAlive` intentionally for state that should outlive subscriptions,
304
+ and keep external-resource cleanup in atom finalizers. See `effect-atom-state`
305
+ for SWR, concurrent function atoms, and batching semantics.
306
+
294
307
  ### Pattern: Basic Atom State
295
308
 
296
309
  ```typescript
297
310
  // state/Cart.ts
298
- import * as Atom from 'effect/unstable/reactivity/Atom';
311
+ import * as Atom from 'effect/reactivity/Atom';
299
312
  import { Effect } from 'effect';
300
313
 
301
314
  /**
@@ -496,7 +509,7 @@ Use `runtime.atom` and `Atom.family` for query-like data. The runtime wraps the
496
509
 
497
510
  ```typescript
498
511
  // state/User.ts
499
- import * as Atom from 'effect/unstable/reactivity/Atom';
512
+ import * as Atom from 'effect/reactivity/Atom';
500
513
  import { Effect } from 'effect';
501
514
  import { UserService } from '@/services/UserService';
502
515
 
@@ -522,7 +535,7 @@ export const userData = Atom.family((userId: string) =>
522
535
 
523
536
  ```tsx
524
537
  import { useAtomValue } from '@effect/atom-react';
525
- import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
538
+ import * as AsyncResult from 'effect/reactivity/AsyncResult';
526
539
  import * as User from '@/state/User';
527
540
 
528
541
  function UserProfile({ userId }: { userId: string }) {