Skip to content

Commit 82e6674

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 82e6674

4 files changed

Lines changed: 251 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: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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 is a scope. You create a scope once, initialize your
9+
stateful resource inside it, and keep that scope alive until shutdown. Then you
10+
expose plain methods that call `scope.run(() => operation)`. `scope.run()`
11+
returns a promise, so clients stay in normal `async/await`, while your internal
12+
work stays under structured concurrency.
13+
14+
Startup is the place most libraries get this wrong. Initialization is async,
15+
method calls can arrive early, and now you have races between "pool is ready"
16+
and "query is called." `withResolvers()` gives you a single readiness operation
17+
that every public method can `yield*` before touching state.
18+
19+
```typescript
20+
import {
21+
createScope,
22+
resource,
23+
spawn,
24+
sleep,
25+
suspend,
26+
until,
27+
withResolvers,
28+
type Operation,
29+
type Task,
30+
} from "effection";
31+
32+
interface QueryResult {
33+
rows: unknown[];
34+
}
35+
36+
interface Connection {
37+
id: string;
38+
execute(query: string): Operation<QueryResult>;
39+
}
40+
41+
interface PoolState {
42+
connections: Connection[];
43+
available: Connection[];
44+
acquire(): Operation<Connection>;
45+
release(conn: Connection): void;
46+
}
47+
48+
// stand-in for real IO
49+
function connectToDatabase(): Promise<{ close(): void }> {
50+
return Promise.resolve({ close() {} });
51+
}
52+
53+
// Connection with proper lifecycle via resource()
54+
function connection(id: string): Operation<Connection> {
55+
return resource(function* (provide) {
56+
const socket = yield* until(connectToDatabase());
57+
58+
const conn: Connection = {
59+
id,
60+
*execute(_query: string) {
61+
return { rows: [] };
62+
},
63+
};
64+
65+
try {
66+
yield* provide(conn);
67+
} finally {
68+
socket.close();
69+
}
70+
});
71+
}
72+
73+
// Pool as a resource
74+
function connectionPool(maxConnections: number): Operation<PoolState> {
75+
return resource(function* (provide) {
76+
const connections: Connection[] = [];
77+
const available: Connection[] = [];
78+
79+
for (let i = 0; i < maxConnections; i++) {
80+
const conn = yield* connection(`conn-${i}`);
81+
connections.push(conn);
82+
available.push(conn);
83+
}
84+
85+
const state: PoolState = {
86+
connections,
87+
available,
88+
*acquire() {
89+
while (available.length === 0) {
90+
yield* sleep(50);
91+
}
92+
return available.pop()!;
93+
},
94+
release(conn) {
95+
available.push(conn);
96+
},
97+
};
98+
99+
yield* provide(state);
100+
});
101+
}
102+
103+
// PUBLIC: Promise-based API
104+
export interface ConnectionPool extends AsyncDisposable {
105+
query(sql: string): Promise<QueryResult>;
106+
queryAll(queries: string[]): Promise<QueryResult[]>;
107+
shutdown(): Promise<void>;
108+
}
109+
110+
export function createPool(
111+
options: { maxConnections: number },
112+
): ConnectionPool {
113+
const [scope, destroy] = createScope();
114+
const { operation: poolReady, resolve, reject } = withResolvers<PoolState>();
115+
116+
let closed = false;
117+
118+
scope.run(function* () {
119+
try {
120+
const pool = yield* connectionPool(options.maxConnections);
121+
resolve(pool);
122+
yield* suspend();
123+
} catch (e) {
124+
reject(e instanceof Error ? e : new Error(String(e)));
125+
}
126+
});
127+
128+
function* executeQuery(sql: string): Operation<QueryResult> {
129+
if (closed) {
130+
throw new Error("Pool is shut down");
131+
}
132+
133+
const pool = yield* poolReady;
134+
const conn = yield* pool.acquire();
135+
try {
136+
return yield* conn.execute(sql);
137+
} finally {
138+
pool.release(conn);
139+
}
140+
}
141+
142+
async function shutdown(): Promise<void> {
143+
if (closed) return;
144+
closed = true;
145+
await destroy();
146+
}
147+
148+
return {
149+
query(sql: string): Promise<QueryResult> {
150+
return scope.run(() => executeQuery(sql));
151+
},
152+
153+
queryAll(queries: string[]): Promise<QueryResult[]> {
154+
return scope.run(function* () {
155+
const tasks: Task<QueryResult>[] = [];
156+
for (const sql of queries) {
157+
tasks.push(yield* spawn(() => executeQuery(sql)));
158+
}
159+
160+
const results: QueryResult[] = [];
161+
for (const task of tasks) {
162+
results.push(yield* task);
163+
}
164+
return results;
165+
});
166+
},
167+
168+
shutdown,
169+
170+
[Symbol.asyncDispose](): Promise<void> {
171+
return shutdown();
172+
},
173+
};
174+
}
175+
```
176+
177+
Two lines here carry the weight.
178+
179+
First, `spawn()` returns an operation, and `yield* spawn(...)` starts it.
180+
Operations are lazy, promises are eager.
181+
182+
Second, `scope.run(() => executeQuery(sql))` keeps each query inside the same
183+
owning scope as the pool. Returning a promise to the caller does not let that
184+
work escape. If shutdown starts, in-flight work is halted with the rest of
185+
the scope.
186+
187+
`resource()` and `suspend()` are what make this stable over time. `resource()`
188+
gives setup-before-use and cleanup in `finally`. `suspend()` keeps the scope
189+
resident so resources stay alive between calls. `destroy()` is teardown
190+
completion. Await it, or you can return from shutdown before `finally` blocks
191+
finish.
192+
193+
You can expose explicit shutdown, or add async disposal so callers can use
194+
language-level cleanup.
195+
196+
```typescript
197+
// Manual cleanup
198+
const pool = createPool({ maxConnections: 10 });
199+
const result = await pool.query("SELECT * FROM users");
200+
await pool.shutdown();
201+
202+
// Automatic cleanup with await using
203+
await using autoPool = createPool({ maxConnections: 10 });
204+
const result2 = await autoPool.query("SELECT * FROM users");
205+
// cleanup runs automatically
206+
```
207+
208+
## Gotchas
209+
210+
**Guard against post-shutdown calls.** `scope.run()` can still be invoked after
211+
`destroy()` completes. Track a `closed` flag and reject new calls after
212+
shutdown, as shown in the example above.
213+
214+
**Await destruction.** `destroy()` returns a future. If you don't await it,
215+
teardown may not complete before your process exits. Always `await shutdown()`
216+
or use `await using`.
217+
218+
**In-flight work during shutdown.** When `destroy()` is called, any operations
219+
currently running via `scope.run()` are halted. Their `finally` blocks still
220+
execute, so cleanup happens, but the promises returned to callers will reject
221+
with a "halted" error.
222+
223+
**Startup failures propagate.** If initialization throws, `reject()` is called
224+
once, and every caller waiting on `poolReady` receives the same error. This is
225+
deterministic and usually what you want.
226+
227+
That is the whole pattern: one integration scope, one readiness gate, internal
228+
resources as operations, external methods as promises. Your users write `await`,
229+
you get structured shutdown.
230+
231+
[createScope]: /api/v4/createScope
232+
[resource]: /api/v4/resource
233+
[withResolvers]: /api/v4/withResolvers
234+
[suspend]: /api/v4/suspend
235+
[until]: /api/v4/until
236+
[Scope]: /api/v4/Scope
237+
[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)