Skip to content

Commit 6fd23cf

Browse files
author
Taras Mankovski
committed
πŸ“ Add Library Integration guide for Promise-based API pattern
Add documentation for using Effection inside a library while exposing a traditional Promise-based API to clients. The pattern uses createScope(), withResolvers(), resource(), and suspend() to bridge between Effection's structured concurrency and async/await. - Create docs/library-integration.mdx with full pattern and example - Add to Advanced section in structure.json - Add cross-reference from scope.mdx - Add FAQ entry pointing to the guide
1 parent 6cabc7a commit 6fd23cf

4 files changed

Lines changed: 316 additions & 1 deletion

File tree

β€Ždocs/faq.mdxβ€Ž

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,12 @@ class Foo {
142142
const foo = new Foo();
143143
console.log(yield * foo.getData());
144144
```
145+
146+
## How do I use Effection inside a library while exposing a Promise-based API?
147+
148+
Use `createScope()` to create an integration point, `withResolvers()` to
149+
synchronize initialization, and `scope.run()` to bridge between Effection
150+
operations and Promises.
151+
152+
See the full [Library Integration](/docs/library-integration) guide for a
153+
complete example.

β€Ždocs/library-integration.mdxβ€Ž

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
You build a library with a Promise API. Your users call `await client.query()`
2+
and move on. Internally, though, you need stronger lifecycle guarantees than
3+
promises can give you. You need requests, sockets, subscriptions, and cleanup
4+
tied to one owner. You need work to stop when the library shuts down.
5+
6+
This is where Effection fits behind a Promise API.
7+
8+
## The Integration Boundary
9+
10+
The core of the pattern is a scope that lives for the lifetime of your library.
11+
You create it once, initialize your stateful resources inside it, and keep it
12+
alive until shutdown. `withResolvers()` gives you a readiness gate that
13+
prevents race conditions between "pool is ready" and "first query arrives."
14+
15+
```typescript
16+
import { createScope, suspend, withResolvers } from "effection";
17+
18+
const [scope, destroy] = createScope();
19+
const { operation: poolReady, resolve, reject } = withResolvers<PoolState>();
20+
21+
let closed = false;
22+
23+
scope.run(function* () {
24+
try {
25+
const pool = yield* connectionPool(options.maxConnections);
26+
resolve(pool); // signal: ready for use
27+
yield* suspend(); // keep scope alive until destroy()
28+
} catch (e) {
29+
reject(e instanceof Error ? e : new Error(String(e)));
30+
}
31+
});
32+
```
33+
34+
`suspend()` is what keeps the scope resident between calls. Without it, the
35+
scope would exit after initialization and tear down your resources.
36+
37+
## Resources for Lifecycle
38+
39+
Use `resource()` for anything with setup and cleanup. The value is available
40+
after `provide()`, and cleanup in `finally` runs when the scope exits.
41+
42+
```typescript
43+
function connection(id: string): Operation<Connection> {
44+
return resource(function* (provide) {
45+
const socket = yield* until(connectToDatabase());
46+
47+
const conn: Connection = {
48+
id,
49+
*execute(query: string) {
50+
// ... query implementation
51+
return { rows: [] };
52+
},
53+
};
54+
55+
try {
56+
yield* provide(conn);
57+
} finally {
58+
socket.close();
59+
}
60+
});
61+
}
62+
```
63+
64+
Resources can depend on other resources. A connection pool that creates
65+
multiple connections will see all of them cleaned up automatically when
66+
the pool's scope exitsβ€”in reverse order of creation.
67+
68+
## The Promise Bridge
69+
70+
Expose plain methods that call `scope.run(() => operation)`. This returns a
71+
promise, so clients stay in normal `async/await`, while your internal work
72+
stays under structured concurrency.
73+
74+
```typescript
75+
function* executeQuery(sql: string): Operation<QueryResult> {
76+
if (closed) {
77+
throw new Error("Pool is shut down");
78+
}
79+
80+
const pool = yield* poolReady; // blocks until init complete
81+
const conn = yield* pool.acquire();
82+
try {
83+
return yield* conn.execute(sql);
84+
} finally {
85+
pool.release(conn);
86+
}
87+
}
88+
89+
return {
90+
query(sql: string): Promise<QueryResult> {
91+
return scope.run(() => executeQuery(sql));
92+
},
93+
94+
queryAll(queries: string[]): Promise<QueryResult[]> {
95+
return scope.run(function* () {
96+
return yield* all(queries.map((sql) => executeQuery(sql)));
97+
});
98+
},
99+
100+
shutdown,
101+
[Symbol.asyncDispose](): Promise<void> {
102+
return shutdown();
103+
},
104+
};
105+
```
106+
107+
Adding `Symbol.asyncDispose` lets callers use `await using` for automatic
108+
cleanup:
109+
110+
```typescript
111+
// Manual cleanup
112+
const pool = createPool({ maxConnections: 10 });
113+
const result = await pool.query("SELECT * FROM users");
114+
await pool.shutdown();
115+
116+
// Automatic cleanup with await using
117+
await using pool = createPool({ maxConnections: 10 });
118+
const result = await pool.query("SELECT * FROM users");
119+
// cleanup runs automatically
120+
```
121+
122+
## Gotchas
123+
124+
**Await destruction.** `destroy()` returns a future. If you don't await it,
125+
teardown may not complete before your process exits. Always `await shutdown()`
126+
or use `await using`.
127+
128+
**In-flight work during shutdown.** When `destroy()` is called, any operations
129+
currently running via `scope.run()` are halted. Their `finally` blocks still
130+
execute, so cleanup happens, but the promises returned to callers will reject
131+
with a "halted" error.
132+
133+
**Startup failures propagate.** If initialization throws, `reject()` is called
134+
once, and every caller waiting on `poolReady` receives the same error. This is
135+
deterministic and usually what you want.
136+
137+
## Complete Example
138+
139+
<details>
140+
<summary>Full working example</summary>
141+
142+
```typescript
143+
import {
144+
all,
145+
createScope,
146+
resource,
147+
sleep,
148+
suspend,
149+
until,
150+
withResolvers,
151+
type Operation,
152+
} from "effection";
153+
154+
interface QueryResult {
155+
rows: unknown[];
156+
}
157+
158+
interface Connection {
159+
id: string;
160+
execute(query: string): Operation<QueryResult>;
161+
}
162+
163+
interface PoolState {
164+
connections: Connection[];
165+
available: Connection[];
166+
acquire(): Operation<Connection>;
167+
release(conn: Connection): void;
168+
}
169+
170+
function connectToDatabase(): Promise<{ close(): void }> {
171+
return Promise.resolve({ close() {} });
172+
}
173+
174+
function connection(id: string): Operation<Connection> {
175+
return resource(function* (provide) {
176+
const socket = yield* until(connectToDatabase());
177+
178+
const conn: Connection = {
179+
id,
180+
*execute(_query: string) {
181+
return { rows: [] };
182+
},
183+
};
184+
185+
try {
186+
yield* provide(conn);
187+
} finally {
188+
socket.close();
189+
}
190+
});
191+
}
192+
193+
function connectionPool(maxConnections: number): Operation<PoolState> {
194+
return resource(function* (provide) {
195+
const connections: Connection[] = [];
196+
const available: Connection[] = [];
197+
198+
for (let i = 0; i < maxConnections; i++) {
199+
const conn = yield* connection(`conn-${i}`);
200+
connections.push(conn);
201+
available.push(conn);
202+
}
203+
204+
const state: PoolState = {
205+
connections,
206+
available,
207+
*acquire() {
208+
while (available.length === 0) {
209+
yield* sleep(50);
210+
}
211+
return available.pop()!;
212+
},
213+
release(conn) {
214+
available.push(conn);
215+
},
216+
};
217+
218+
yield* provide(state);
219+
});
220+
}
221+
222+
export interface ConnectionPool extends AsyncDisposable {
223+
query(sql: string): Promise<QueryResult>;
224+
queryAll(queries: string[]): Promise<QueryResult[]>;
225+
shutdown(): Promise<void>;
226+
}
227+
228+
export function createPool(
229+
options: { maxConnections: number },
230+
): ConnectionPool {
231+
const [scope, destroy] = createScope();
232+
const { operation: poolReady, resolve, reject } = withResolvers<PoolState>();
233+
234+
let closed = false;
235+
236+
scope.run(function* () {
237+
try {
238+
const pool = yield* connectionPool(options.maxConnections);
239+
resolve(pool);
240+
yield* suspend();
241+
} catch (e) {
242+
reject(e instanceof Error ? e : new Error(String(e)));
243+
}
244+
});
245+
246+
function* executeQuery(sql: string): Operation<QueryResult> {
247+
if (closed) {
248+
throw new Error("Pool is shut down");
249+
}
250+
251+
const pool = yield* poolReady;
252+
const conn = yield* pool.acquire();
253+
try {
254+
return yield* conn.execute(sql);
255+
} finally {
256+
pool.release(conn);
257+
}
258+
}
259+
260+
async function shutdown(): Promise<void> {
261+
if (closed) return;
262+
closed = true;
263+
await destroy();
264+
}
265+
266+
return {
267+
query(sql: string): Promise<QueryResult> {
268+
return scope.run(() => executeQuery(sql));
269+
},
270+
271+
queryAll(queries: string[]): Promise<QueryResult[]> {
272+
return scope.run(function* () {
273+
return yield* all(queries.map((sql) => executeQuery(sql)));
274+
});
275+
},
276+
277+
shutdown,
278+
279+
[Symbol.asyncDispose](): Promise<void> {
280+
return shutdown();
281+
},
282+
};
283+
}
284+
```
285+
286+
</details>
287+
288+
See also: [GitHub Gist](https://gist.github.com/taras/a8e3861d34d6005781069fdfb16de475)
289+
290+
---
291+
292+
That is the whole pattern: one integration scope, one readiness gate, internal
293+
resources as operations, external methods as promises. Your users write `await`,
294+
you get structured shutdown.
295+
296+
[createScope]: /api/v4/createScope
297+
[resource]: /api/v4/resource
298+
[withResolvers]: /api/v4/withResolvers
299+
[suspend]: /api/v4/suspend
300+
[until]: /api/v4/until
301+
[Scope]: /api/v4/Scope
302+
[spawn]: /api/v4/spawn

β€Ždocs/scope.mdxβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,9 @@ Whenever you use `createScope()` in this way, it is important that you
381381
`await` the destruction operation when the scope is no longer needed
382382
since it will not be destroyed implicitly.
383383

384+
> πŸ’‘ For a complete example of using `createScope()` to build a library that
385+
> exposes a Promise-based API, see the [Library Integration](/docs/library-integration) guide.
386+
384387
[spawn]: /api/v4/spawn
385388
[use-abort-signal]: /api/v4/useAbortSignal
386389
[scope]: /api/v4/Scope

β€Ždocs/structure.jsonβ€Ž

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"Advanced": [
2121
["faq.mdx", "FAQ"],
2222
["scope.mdx", "Scope"],
23-
["processes.mdx", "Processes"]
23+
["processes.mdx", "Processes"],
24+
["library-integration.mdx", "Library Integration"]
2425
]
2526
}

0 commit comments

Comments
Β (0)