opencode-effect-enforcer 0.2.6 → 0.3.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 +3 -3
- package/docs/effect-4.0.0-changelog.md +3213 -0
- package/docs/effect-4.0.0.md +110 -0
- package/guidance/effect-first-development.md +15 -305
- package/guidance/progressive-disclosure-guidance.md +18 -24
- package/package.json +2 -2
- package/patterns/avoid-any.md +2 -2
- package/patterns/avoid-direct-json.md +6 -6
- package/patterns/avoid-native-fetch.md +8 -6
- package/patterns/avoid-node-imports.md +2 -2
- package/patterns/avoid-non-null-assertion.md +2 -2
- package/patterns/avoid-object-type.md +2 -2
- package/patterns/avoid-platform-coupling.md +1 -1
- package/patterns/avoid-process-env.md +3 -4
- package/patterns/avoid-ts-ignore.md +1 -1
- package/patterns/context-tag-extends.md +11 -8
- package/patterns/effect-promise-vs-trypromise.md +6 -7
- package/patterns/prefer-arr-sort.md +2 -2
- package/patterns/prefer-effect-fn.md +21 -65
- package/patterns/prefer-schema-class.md +4 -4
- package/patterns/throw-in-effect-gen.md +1 -1
- package/patterns/use-clock-service.md +4 -0
- package/patterns/use-command-executor-service.md +2 -2
- package/patterns/use-http-client-service.md +8 -6
- package/patterns/use-random-service.md +6 -7
- package/skills/effect-ai-chat/SKILL.md +13 -7
- package/skills/effect-ai-language-model/SKILL.md +50 -21
- package/skills/effect-ai-prompt/SKILL.md +25 -14
- package/skills/effect-ai-provider/SKILL.md +50 -22
- package/skills/effect-ai-streaming/SKILL.md +27 -12
- package/skills/effect-ai-tool/SKILL.md +37 -28
- package/skills/effect-atom-rpc/SKILL.md +57 -36
- package/skills/effect-atom-state/SKILL.md +57 -19
- package/skills/effect-batching/SKILL.md +5 -3
- package/skills/effect-cache/SKILL.md +19 -7
- package/skills/effect-cli/SKILL.md +17 -8
- package/skills/effect-command-executor/SKILL.md +115 -64
- package/skills/effect-concurrency-testing/SKILL.md +26 -6
- package/skills/effect-config/SKILL.md +53 -2
- package/skills/effect-context-witness/SKILL.md +6 -6
- package/skills/effect-domain-modeling/SKILL.md +8 -1
- package/skills/effect-error-handling/SKILL.md +15 -2
- package/skills/effect-fiber/SKILL.md +20 -25
- package/skills/effect-filesystem/SKILL.md +69 -57
- package/skills/effect-http-api/SKILL.md +72 -22
- package/skills/effect-http-client/SKILL.md +25 -21
- package/skills/effect-http-server/SKILL.md +51 -21
- package/skills/effect-incremental-migration/SKILL.md +17 -8
- package/skills/effect-layer-design/SKILL.md +8 -0
- package/skills/effect-managed-runtime/SKILL.md +6 -0
- package/skills/effect-mcp-server/SKILL.md +64 -24
- package/skills/effect-observability/SKILL.md +61 -15
- package/skills/effect-parallelization/SKILL.md +24 -7
- package/skills/effect-path/SKILL.md +8 -2
- package/skills/effect-platform-abstraction/SKILL.md +88 -66
- package/skills/effect-platform-layers/SKILL.md +68 -67
- package/skills/effect-pubsub-event-bus/SKILL.md +56 -60
- package/skills/effect-react-composition/SKILL.md +19 -6
- package/skills/effect-rpc-api/SKILL.md +24 -24
- package/skills/effect-rpc-client/SKILL.md +33 -28
- package/skills/effect-rpc-cluster/SKILL.md +122 -78
- package/skills/effect-rpc-server/SKILL.md +56 -20
- package/skills/effect-scheduling/SKILL.md +29 -1
- package/skills/effect-schema-composition/SKILL.md +31 -13
- package/skills/effect-schema-v4/SKILL.md +94 -10
- package/skills/effect-scope/SKILL.md +13 -5
- package/skills/effect-service-implementation/SKILL.md +1 -1
- package/skills/effect-socket/SKILL.md +52 -8
- package/skills/effect-sql/SKILL.md +67 -33
- package/skills/effect-stream/SKILL.md +50 -5
- package/skills/effect-testing/SKILL.md +91 -2
- package/skills/effect-workflow/SKILL.md +76 -39
- package/src/guidance.ts +0 -1
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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<
|
|
226
|
+
declare const program: Effect.Effect<string, never, FileSystem.FileSystem>;
|
|
223
227
|
|
|
224
|
-
//
|
|
225
|
-
const CustomFS =
|
|
226
|
-
|
|
227
|
-
}
|
|
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,
|
|
251
|
+
import { expect, it } from '@effect/vitest';
|
|
246
252
|
|
|
247
253
|
declare const readConfig: Effect.Effect<string, never, FileSystem.FileSystem>;
|
|
248
254
|
|
|
249
|
-
//
|
|
250
|
-
// for
|
|
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
|
-
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
285
|
+
FileSystem.layerNoop({
|
|
280
286
|
readFileString: () => Effect.succeed('test')
|
|
281
|
-
|
|
282
|
-
} as FileSystem.FileSystem),
|
|
287
|
+
}),
|
|
283
288
|
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
program.pipe(Effect.provide(TestContext)
|
|
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 {
|
|
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
|
-
|
|
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)
|
|
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,
|
|
346
|
+
import { Effect, FileSystem, Layer, Path, Context } from 'effect';
|
|
347
|
+
import * as Schema from 'effect/Schema';
|
|
345
348
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
349
|
+
class Config extends Schema.Class<Config>('Config')({
|
|
350
|
+
name: Schema.String,
|
|
351
|
+
version: Schema.String
|
|
352
|
+
}) {}
|
|
350
353
|
|
|
351
|
-
|
|
354
|
+
const ConfigJson = Schema.fromJsonString(Config);
|
|
352
355
|
|
|
353
356
|
class ConfigError extends Schema.TaggedError<ConfigError>()(
|
|
354
357
|
'ConfigError',
|
|
355
358
|
{
|
|
356
|
-
|
|
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.
|
|
378
|
-
});
|
|
383
|
+
return yield* Schema.decodeUnknownEffect(ConfigJson)(content);
|
|
384
|
+
}).pipe(Effect.mapError((cause) => new ConfigError({ operation: 'load', cause })));
|
|
379
385
|
|
|
380
|
-
const save = (
|
|
381
|
-
|
|
386
|
+
const save = Effect.fn('Config.save')(
|
|
387
|
+
function* (config: Config) {
|
|
382
388
|
const configPath = path.join('config', 'app.json');
|
|
383
|
-
const content =
|
|
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
|
-
###
|
|
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 {
|
|
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
|
-
|
|
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*
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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. **
|
|
510
|
-
5. **Entry
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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/
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 {
|
|
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
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
282
|
-
yield*
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
-
|
|
299
|
-
-
|
|
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
|
-
|
|
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/
|
|
18
|
-
- AsyncResult source: `packages/effect/src/
|
|
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/
|
|
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/
|
|
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/
|
|
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 }) {
|