opencode-effect-enforcer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-platform-layers
|
|
3
|
+
description: Structure Effect platform layer provision for cross-platform applications using Effect platform abstractions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Platform Layers
|
|
7
|
+
|
|
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
|
+
|
|
10
|
+
## The Golden Rule
|
|
11
|
+
|
|
12
|
+
**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`.**
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
// Application code - platform agnostic
|
|
16
|
+
import { Effect, FileSystem, Path, pipe } from 'effect';
|
|
17
|
+
|
|
18
|
+
const readConfig = Effect.gen(function* () {
|
|
19
|
+
const fs = yield* FileSystem.FileSystem;
|
|
20
|
+
const path = yield* Path.Path;
|
|
21
|
+
const configPath = path.join('config', 'app.json');
|
|
22
|
+
return yield* fs.readFileString(configPath);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Entry point - platform specific
|
|
26
|
+
import { NodeServices, NodeRuntime } from '@effect/platform-node';
|
|
27
|
+
|
|
28
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
29
|
+
|
|
30
|
+
pipe(program, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// WRONG - platform-specific imports in application code
|
|
35
|
+
import { readFileSync } from 'fs'; // Ties code to Node.js
|
|
36
|
+
import { FileSystem } from '@effect/platform-node'; // Platform-specific
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Runtime-facing adapter modules may own their platform wiring directly:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
export const defaultLayer = layer.pipe(
|
|
43
|
+
Layer.provide(NodeFileSystem.layer),
|
|
44
|
+
Layer.provide(NodePath.layer)
|
|
45
|
+
);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The important boundary is that downstream callers still depend on the abstract service, not on Node/Bun modules.
|
|
49
|
+
|
|
50
|
+
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
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { Context, Effect, Layer } from 'effect';
|
|
54
|
+
import { FetchHttpClient, HttpClient } from 'effect/unstable/http';
|
|
55
|
+
|
|
56
|
+
export class ProviderGateway extends Context.Service<ProviderGateway, {
|
|
57
|
+
readonly health: Effect.Effect<void>;
|
|
58
|
+
}>()('app/ProviderGateway') {}
|
|
59
|
+
|
|
60
|
+
const makeProviderGateway = Effect.gen(function* () {
|
|
61
|
+
yield* HttpClient.HttpClient;
|
|
62
|
+
return ProviderGateway.of({ health: Effect.void });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const layer: Layer.Layer<ProviderGateway, never, HttpClient.HttpClient> =
|
|
66
|
+
Layer.effect(ProviderGateway, makeProviderGateway);
|
|
67
|
+
|
|
68
|
+
export const defaultLayer: Layer.Layer<ProviderGateway> = layer.pipe(
|
|
69
|
+
Layer.provide(FetchHttpClient.layer)
|
|
70
|
+
);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use `layer` when the application or test owns transport selection. Use `defaultLayer` only when this runtime-facing adapter intentionally owns the default transport. Do not provide the transport inside `layer`, because that erases the dependency graph and prevents straightforward substitution.
|
|
74
|
+
|
|
75
|
+
## Platform Import Patterns
|
|
76
|
+
|
|
77
|
+
### Node.js
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { NodeServices, NodeRuntime } from '@effect/platform-node';
|
|
81
|
+
import { Effect, pipe } from 'effect';
|
|
82
|
+
|
|
83
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
84
|
+
|
|
85
|
+
pipe(program, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Bun
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
import { BunServices, BunRuntime } from '@effect/platform-bun';
|
|
92
|
+
import { Effect, pipe } from 'effect';
|
|
93
|
+
|
|
94
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
95
|
+
|
|
96
|
+
pipe(program, Effect.provide(BunServices.layer), BunRuntime.runMain);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Browser
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import { BrowserRuntime } from '@effect/platform-browser';
|
|
103
|
+
import { Effect, pipe } from 'effect';
|
|
104
|
+
|
|
105
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
106
|
+
|
|
107
|
+
pipe(program, BrowserRuntime.runMain);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`BrowserRuntime.runMain` keeps the main fiber alive when a `pagehide` event is persisted for the browser back/forward cache. It interrupts the fiber on non-persisted `pagehide`, when the document is actually being discarded. Browser teardown is best-effort, so asynchronous finalizers are not guaranteed to finish before the page disappears.
|
|
111
|
+
|
|
112
|
+
## Context Layer Services
|
|
113
|
+
|
|
114
|
+
Each platform context (`NodeServices.layer`, `BunServices.layer`) provides these services:
|
|
115
|
+
|
|
116
|
+
| Service | Tag | Description | Import from |
|
|
117
|
+
| ----------------------- | ----------------------------------------- | --------------------------------------------------- | ------------------------- |
|
|
118
|
+
| **FileSystem** | `FileSystem.FileSystem` | File I/O operations (read, write, stat, etc.) | `effect` |
|
|
119
|
+
| **Path** | `Path.Path` | Path manipulation (join, normalize, relative, etc.) | `effect` |
|
|
120
|
+
| **Stdio** | `Stdio.Stdio` | Standard I/O streams (stdin, stdout, stderr) | `effect` |
|
|
121
|
+
| **Terminal** | `Terminal.Terminal` | Terminal/console I/O with ANSI support | `effect` |
|
|
122
|
+
| **Crypto** | `Crypto.Crypto` | Cryptographic random bytes, UUIDs, and digests | `effect` |
|
|
123
|
+
| **ChildProcessSpawner** | `ChildProcessSpawner.ChildProcessSpawner` | Spawn and manage child processes | `effect/unstable/process` |
|
|
124
|
+
|
|
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.
|
|
126
|
+
|
|
127
|
+
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
|
+
|
|
129
|
+
`Migrator.fromFileSystem` now requires both `FileSystem.FileSystem` and `Path.Path`. `NodeServices.layer` and `BunServices.layer` already satisfy both. If a migration runtime provides only an individual file-system layer, add the matching host path layer too; on Windows, core `Path.layer` is not a substitute for a platform-aware path implementation because it uses POSIX semantics.
|
|
130
|
+
|
|
131
|
+
### Redis Layers
|
|
132
|
+
|
|
133
|
+
Redis is deliberately outside the aggregate platform layers. `NodeRedis.layer` and `NodeRedis.layerConfig` use `redis` (node-redis), with a supported peer range of `redis >=5.0.0 <7.0.0`, and accept node-redis `RedisClientOptions`. When migrating from `ioredis`:
|
|
134
|
+
|
|
135
|
+
- Move host, port, TLS, and reconnect settings under `socket`.
|
|
136
|
+
- Rename `db` to `database`.
|
|
137
|
+
- Use node-redis camel-cased commands such as `hLen` and `lRange` on `NodeRedis.NodeRedis.client`.
|
|
138
|
+
- Use `sendCommand` for arbitrary raw commands.
|
|
139
|
+
- Do not force a RESP protocol unless required; protocol selection follows the installed node-redis default.
|
|
140
|
+
|
|
141
|
+
The Node layer connects while it is built and therefore can fail with `Redis.RedisError`. The initial connection fails fast by default; supplying `socket.reconnectStrategy` opts into caller-defined initial retry behavior. After the client first becomes ready, the built-in strategy uses node-redis exponential backoff and stops on socket timeouts. Scope finalization calls `close()`, which waits for in-flight and blocking commands and can delay layer shutdown.
|
|
142
|
+
|
|
143
|
+
### Usage Example
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { Console, Crypto, Effect, FileSystem, Path, Stream, Terminal } from 'effect';
|
|
147
|
+
import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
|
|
148
|
+
|
|
149
|
+
const buildProject = Effect.gen(function* () {
|
|
150
|
+
const fs = yield* FileSystem.FileSystem;
|
|
151
|
+
const path = yield* Path.Path;
|
|
152
|
+
const terminal = yield* Terminal.Terminal;
|
|
153
|
+
const crypto = yield* Crypto.Crypto;
|
|
154
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
155
|
+
|
|
156
|
+
// Use Path for cross-platform paths
|
|
157
|
+
const outDir = path.join('dist', 'bundle');
|
|
158
|
+
|
|
159
|
+
// Use FileSystem for I/O
|
|
160
|
+
yield* fs.makeDirectory(outDir, { recursive: true });
|
|
161
|
+
|
|
162
|
+
// Use Terminal dimensions and Crypto for output metadata
|
|
163
|
+
const columns = yield* terminal.columns;
|
|
164
|
+
const rows = yield* terminal.rows;
|
|
165
|
+
const buildId = yield* crypto.randomUUIDv7;
|
|
166
|
+
yield* terminal.display(`Building project ${buildId} (${columns}x${rows})...\n`);
|
|
167
|
+
|
|
168
|
+
// Use ChildProcessSpawner for processes
|
|
169
|
+
const handle = yield* spawner.spawn(
|
|
170
|
+
ChildProcess.make('npm', ['run', 'build'])
|
|
171
|
+
);
|
|
172
|
+
yield* handle.all.pipe(
|
|
173
|
+
Stream.decodeText(),
|
|
174
|
+
Stream.splitLines,
|
|
175
|
+
Stream.runForEach((line) => Console.log(`[build] ${line}`))
|
|
176
|
+
);
|
|
177
|
+
return yield* handle.exitCode;
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Layer Composition Patterns
|
|
182
|
+
|
|
183
|
+
### Basic Provision
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import { NodeServices, NodeRuntime } from '@effect/platform-node';
|
|
187
|
+
import { Effect, pipe } from 'effect';
|
|
188
|
+
|
|
189
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
190
|
+
|
|
191
|
+
// Single platform context provides all services
|
|
192
|
+
pipe(program, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Adding Custom Services
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
import { NodeServices, NodeRuntime } from '@effect/platform-node';
|
|
199
|
+
import { Effect, Layer, pipe } from 'effect';
|
|
200
|
+
|
|
201
|
+
declare const DatabaseLive: Layer.Layer<never, never, never>;
|
|
202
|
+
declare const ConfigServiceLive: Layer.Layer<never, never, never>;
|
|
203
|
+
declare const LoggerLive: Layer.Layer<never, never, never>;
|
|
204
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
205
|
+
|
|
206
|
+
const AppLayer = Layer.mergeAll(DatabaseLive, ConfigServiceLive, LoggerLive);
|
|
207
|
+
|
|
208
|
+
pipe(
|
|
209
|
+
program,
|
|
210
|
+
Effect.provide(AppLayer),
|
|
211
|
+
Effect.provide(NodeServices.layer), // Platform services last
|
|
212
|
+
NodeRuntime.runMain
|
|
213
|
+
);
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Overriding Platform Services
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
import { NodeServices, NodeRuntime } from '@effect/platform-node';
|
|
220
|
+
import { Effect, FileSystem, Layer, pipe } from 'effect';
|
|
221
|
+
|
|
222
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
223
|
+
|
|
224
|
+
// Custom FileSystem implementation
|
|
225
|
+
const CustomFS = Layer.succeed(FileSystem.FileSystem, {
|
|
226
|
+
/* custom implementation */
|
|
227
|
+
} as FileSystem.FileSystem);
|
|
228
|
+
|
|
229
|
+
pipe(
|
|
230
|
+
program,
|
|
231
|
+
Effect.provide(NodeServices.layer),
|
|
232
|
+
Effect.provide(CustomFS), // Override after platform layer
|
|
233
|
+
NodeRuntime.runMain
|
|
234
|
+
);
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Testing with Mock Layers
|
|
238
|
+
|
|
239
|
+
**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.
|
|
240
|
+
|
|
241
|
+
### Mocking FileSystem
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import { Effect, FileSystem, Layer } from 'effect';
|
|
245
|
+
import { expect, test } from 'vitest';
|
|
246
|
+
|
|
247
|
+
declare const readConfig: Effect.Effect<string, never, FileSystem.FileSystem>;
|
|
248
|
+
|
|
249
|
+
// Use FileSystem.makeNoop for testing — provides default "NotFound" stubs
|
|
250
|
+
// for all methods, then override only the ones you need
|
|
251
|
+
const MockFileSystem = Layer.succeed(
|
|
252
|
+
FileSystem.FileSystem,
|
|
253
|
+
FileSystem.makeNoop({
|
|
254
|
+
readFileString: (path) => Effect.succeed(`mock content for ${path}`),
|
|
255
|
+
exists: (path) => Effect.succeed(true)
|
|
256
|
+
})
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
test('should read config', () =>
|
|
260
|
+
Effect.gen(function* () {
|
|
261
|
+
const result = yield* readConfig;
|
|
262
|
+
expect(result).toContain('mock content');
|
|
263
|
+
}).pipe(Effect.provide(MockFileSystem), Effect.runPromise));
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Mocking Multiple Services
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { Effect, FileSystem, Layer, Path, Terminal } from 'effect';
|
|
270
|
+
import { test } from 'vitest';
|
|
271
|
+
|
|
272
|
+
declare const program: Effect.Effect<
|
|
273
|
+
void,
|
|
274
|
+
never,
|
|
275
|
+
FileSystem.FileSystem | Path.Path | Terminal.Terminal
|
|
276
|
+
>;
|
|
277
|
+
|
|
278
|
+
const TestContext = Layer.mergeAll(
|
|
279
|
+
Layer.succeed(FileSystem.FileSystem, {
|
|
280
|
+
readFileString: () => Effect.succeed('test')
|
|
281
|
+
// ...
|
|
282
|
+
} as FileSystem.FileSystem),
|
|
283
|
+
|
|
284
|
+
Layer.succeed(Path.Path, {
|
|
285
|
+
join: (...parts) => parts.join('/'),
|
|
286
|
+
normalize: (path) => path
|
|
287
|
+
// ...
|
|
288
|
+
} as Path.Path),
|
|
289
|
+
|
|
290
|
+
Layer.succeed(
|
|
291
|
+
Terminal.Terminal,
|
|
292
|
+
Terminal.make({
|
|
293
|
+
columns: Effect.succeed(80),
|
|
294
|
+
rows: Effect.succeed(24),
|
|
295
|
+
readInput: Effect.dieMessage('readInput not used in this test'),
|
|
296
|
+
readLine: Effect.succeed('test input'),
|
|
297
|
+
display: () => Effect.void
|
|
298
|
+
})
|
|
299
|
+
)
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
test('integration test', () =>
|
|
303
|
+
program.pipe(Effect.provide(TestContext), Effect.runPromise));
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Using layerNoop for Convenient Test Layers
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
import { Effect, FileSystem } from 'effect';
|
|
310
|
+
import { test } from 'vitest';
|
|
311
|
+
|
|
312
|
+
// FileSystem.layerNoop wraps makeNoop in a Layer for convenience
|
|
313
|
+
const TestFS = FileSystem.layerNoop({
|
|
314
|
+
readFileString: () => Effect.succeed('test content'),
|
|
315
|
+
writeFileString: () => Effect.void,
|
|
316
|
+
exists: () => Effect.succeed(true)
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('with layerNoop', () =>
|
|
320
|
+
Effect.gen(function* () {
|
|
321
|
+
const fs = yield* FileSystem.FileSystem;
|
|
322
|
+
yield* fs.writeFileString('test.txt', 'content');
|
|
323
|
+
}).pipe(Effect.provide(TestFS), Effect.runPromise));
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Architecture Patterns
|
|
327
|
+
|
|
328
|
+
### Layered Application Structure
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
src/
|
|
332
|
+
├── domain/ # Pure domain logic (no platform deps)
|
|
333
|
+
├── services/ # Business services (uses abstract platform)
|
|
334
|
+
├── infrastructure/ # Platform adapters (if needed)
|
|
335
|
+
└── main/
|
|
336
|
+
├── main.ts # Entry point with NodeServices
|
|
337
|
+
└── main.test.ts # Tests with mock contexts
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Service Implementation
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
// services/ConfigService.ts
|
|
344
|
+
import { Effect, FileSystem, Layer, Path, Schema, Context } from 'effect';
|
|
345
|
+
|
|
346
|
+
interface Config {
|
|
347
|
+
readonly name: string;
|
|
348
|
+
readonly version: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
declare const ConfigSchema: Schema.Schema<Config>;
|
|
352
|
+
|
|
353
|
+
class ConfigError extends Schema.TaggedError<ConfigError>()(
|
|
354
|
+
'ConfigError',
|
|
355
|
+
{
|
|
356
|
+
message: Schema.String
|
|
357
|
+
}
|
|
358
|
+
) {}
|
|
359
|
+
|
|
360
|
+
export class ConfigService extends Context.Service<
|
|
361
|
+
ConfigService,
|
|
362
|
+
{
|
|
363
|
+
readonly load: Effect.Effect<Config, ConfigError>;
|
|
364
|
+
save(config: Config): Effect.Effect<void, ConfigError>;
|
|
365
|
+
}
|
|
366
|
+
>()('ConfigService') {}
|
|
367
|
+
|
|
368
|
+
export const ConfigServiceLive = Layer.effect(
|
|
369
|
+
ConfigService,
|
|
370
|
+
Effect.gen(function* () {
|
|
371
|
+
const fs = yield* FileSystem.FileSystem;
|
|
372
|
+
const path = yield* Path.Path;
|
|
373
|
+
|
|
374
|
+
const load = Effect.gen(function* () {
|
|
375
|
+
const configPath = path.join('config', 'app.json');
|
|
376
|
+
const content = yield* fs.readFileString(configPath);
|
|
377
|
+
return yield* Schema.decode(ConfigSchema)(JSON.parse(content));
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const save = (config: Config) =>
|
|
381
|
+
Effect.gen(function* () {
|
|
382
|
+
const configPath = path.join('config', 'app.json');
|
|
383
|
+
const content = JSON.stringify(config, null, 2);
|
|
384
|
+
yield* fs.writeFileString(configPath, content);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
return { load, save };
|
|
388
|
+
})
|
|
389
|
+
);
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Entry Point
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
// main/main.ts
|
|
396
|
+
import { NodeServices, NodeRuntime } from '@effect/platform-node';
|
|
397
|
+
import { Effect, Layer, pipe } from 'effect';
|
|
398
|
+
import { ConfigService, ConfigServiceLive } from '../services/ConfigService.js';
|
|
399
|
+
|
|
400
|
+
const MainLayer = Layer.mergeAll(
|
|
401
|
+
ConfigServiceLive
|
|
402
|
+
// ... other services
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const program = Effect.gen(function* () {
|
|
406
|
+
const config = yield* ConfigService;
|
|
407
|
+
yield* config.load;
|
|
408
|
+
// ... application logic
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
pipe(
|
|
412
|
+
program,
|
|
413
|
+
Effect.provide(MainLayer),
|
|
414
|
+
Effect.provide(NodeServices.layer),
|
|
415
|
+
NodeRuntime.runMain
|
|
416
|
+
);
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## Common Patterns
|
|
420
|
+
|
|
421
|
+
### Conditional Platform Loading
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
import { NodeServices, NodeRuntime } from '@effect/platform-node';
|
|
425
|
+
import { BunServices } from '@effect/platform-bun';
|
|
426
|
+
import { Effect, pipe } from 'effect';
|
|
427
|
+
|
|
428
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
429
|
+
|
|
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
|
+
);
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Scoped Platform Resources
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
import { Effect, FileSystem, Path } from 'effect';
|
|
444
|
+
|
|
445
|
+
const withTempDirectory = Effect.gen(function* () {
|
|
446
|
+
const fs = yield* FileSystem.FileSystem;
|
|
447
|
+
const path = yield* Path.Path;
|
|
448
|
+
|
|
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
|
+
);
|
|
457
|
+
|
|
458
|
+
return tempDir;
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
## Anti-Patterns
|
|
463
|
+
|
|
464
|
+
### Platform-Specific Imports in Application Code
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
// WRONG - ties application to Node.js
|
|
468
|
+
import * as fs from 'fs';
|
|
469
|
+
import * as path from 'path';
|
|
470
|
+
|
|
471
|
+
const readConfig = () => {
|
|
472
|
+
const content = fs.readFileSync(path.join('config', 'app.json'), 'utf8');
|
|
473
|
+
return JSON.parse(content);
|
|
474
|
+
};
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### Direct Platform Module Usage
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
// WRONG - bypasses Effect abstractions
|
|
481
|
+
import { FileSystem } from '@effect/platform-node';
|
|
482
|
+
import { Effect } from 'effect';
|
|
483
|
+
|
|
484
|
+
const program = Effect.gen(function* () {
|
|
485
|
+
const fs = yield* FileSystem.FileSystem;
|
|
486
|
+
// ...
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Providing Platform Layers in Application Code
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
// WRONG - application code should not know about platform
|
|
494
|
+
import { NodeServices } from '@effect/platform-node';
|
|
495
|
+
import { Effect } from 'effect';
|
|
496
|
+
|
|
497
|
+
declare const program: Effect.Effect<void, never, never>;
|
|
498
|
+
|
|
499
|
+
export const myService = program.pipe(
|
|
500
|
+
Effect.provide(NodeServices.layer) // Should be at entry point only
|
|
501
|
+
);
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## Key Principles
|
|
505
|
+
|
|
506
|
+
1. **Import abstractions, provide implementations**: Application code imports from `effect` (e.g. `FileSystem`, `Path`, `Terminal`), entry points provide platform-specific contexts
|
|
507
|
+
2. **One platform layer per runtime**: Use exactly one of `NodeServices.layer` or `BunServices.layer`
|
|
508
|
+
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
|
|
511
|
+
6. **Keep HTTP transport requirements visible**: Provider adapter `layer` requires `HttpClient.HttpClient`; an optional `defaultLayer` may provide the chosen transport
|
|
512
|
+
7. **Make adapters own the boundary**: Named adapters classify status before schema decoding, map typed failures, retain redacted evidence, and expose retry exhaustion
|
|
513
|
+
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
|
|
514
|
+
9. **Do not hold transactions across providers**: Complete network calls before opening the database transaction used to persist their result
|