@nlozgachev/pipelined 0.22.0 → 0.24.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 +112 -52
- package/dist/{chunk-SSZXZTIX.mjs → chunk-UFQE2J63.mjs} +1 -1
- package/dist/core.d.mts +7 -6
- package/dist/core.d.ts +7 -6
- package/dist/core.js +1 -1
- package/dist/core.mjs +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# pipelined
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@nlozgachev/pipelined) [](https://github.com/nlozgachev/pipelined/actions/workflows/publish.yml) [](https://app.codecov.io/github/nlozgachev/pipelined)
|
|
3
|
+
[](https://www.npmjs.com/package/@nlozgachev/pipelined) [](https://github.com/nlozgachev/pipelined/actions/workflows/publish.yml) [](https://app.codecov.io/github/nlozgachev/pipelined)
|
|
4
|
+
|
|
4
5
|
|
|
5
6
|
Opinionated functional abstractions for TypeScript.
|
|
6
7
|
|
|
@@ -21,41 +22,41 @@ timeout, and cancellation built in. And, of course, there is more than that.
|
|
|
21
22
|
|
|
22
23
|
Full guides and API reference at **[pipelined.lozgachev.dev](https://pipelined.lozgachev.dev)**.
|
|
23
24
|
|
|
24
|
-
## Example
|
|
25
|
+
## Example: a single async call
|
|
25
26
|
|
|
26
27
|
A careful, production-minded attempt at "fetch with retry, timeout, and cancellation":
|
|
27
28
|
|
|
28
29
|
```ts
|
|
29
30
|
type UserResult =
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
| { ok: true; user: User; }
|
|
32
|
+
| { ok: false; error: "Timeout" | "NetworkError"; };
|
|
32
33
|
|
|
33
34
|
async function fetchUser(
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
id: string,
|
|
36
|
+
signal?: AbortSignal,
|
|
36
37
|
): Promise<UserResult> {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
38
|
+
async function attempt(n: number): Promise<UserResult> {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timerId = setTimeout(() => controller.abort(), 5000);
|
|
41
|
+
signal?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`/users/${id}`, { signal: controller.signal });
|
|
45
|
+
clearTimeout(timerId);
|
|
46
|
+
return { ok: true, user: await res.json() };
|
|
47
|
+
} catch (e) {
|
|
48
|
+
clearTimeout(timerId);
|
|
49
|
+
if ((e as Error).name === "AbortError" && !signal?.aborted) {
|
|
50
|
+
return { ok: false, error: "Timeout" };
|
|
51
|
+
}
|
|
52
|
+
if (n < 3) {
|
|
53
|
+
await new Promise((r) => setTimeout(r, n * 1000));
|
|
54
|
+
return attempt(n + 1);
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, error: "NetworkError" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return attempt(1);
|
|
59
60
|
}
|
|
60
61
|
```
|
|
61
62
|
|
|
@@ -70,14 +71,14 @@ import { pipe } from "@nlozgachev/pipelined/composition";
|
|
|
70
71
|
import { Result, TaskResult } from "@nlozgachev/pipelined/core";
|
|
71
72
|
|
|
72
73
|
const fetchUser = (id: string): TaskResult<ApiError, User> =>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
pipe(
|
|
75
|
+
TaskResult.tryCatch(
|
|
76
|
+
(signal) => fetch(`/users/${id}`, { signal }).then((r) => r.json()),
|
|
77
|
+
(e) => new ApiError(e),
|
|
78
|
+
),
|
|
79
|
+
TaskResult.timeout(5000, () => new ApiError("request timed out")),
|
|
80
|
+
TaskResult.retry({ attempts: 3, backoff: (n) => n * 1000 }),
|
|
81
|
+
);
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
`TaskResult<ApiError, User>` is a lazy function — nothing runs until called. The `AbortSignal`
|
|
@@ -89,38 +90,97 @@ const controller = new AbortController();
|
|
|
89
90
|
const result = await fetchUser("42")(controller.signal);
|
|
90
91
|
|
|
91
92
|
if (Result.isOk(result)) {
|
|
92
|
-
|
|
93
|
+
render(result.value); // User
|
|
93
94
|
} else {
|
|
94
|
-
|
|
95
|
+
showError(result.error); // ApiError, not unknown
|
|
95
96
|
}
|
|
96
97
|
```
|
|
97
98
|
|
|
99
|
+
## Example: repeated interactions
|
|
100
|
+
|
|
101
|
+
`TaskResult` handles one call well. Real UIs make the same call many times — a search input fires on every keystroke, a submit button gets clicked twice, a polling loop needs to stop when something newer starts. Each scenario has a different answer to the same question: *what happens to the previous call when a new one arrives?*
|
|
102
|
+
|
|
103
|
+
`Op` makes that question a one-word configuration choice.
|
|
104
|
+
|
|
105
|
+
**Search — cancel the previous call when the user types:**
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { Op } from "@nlozgachev/pipelined/core";
|
|
109
|
+
|
|
110
|
+
const searchOp = Op.create(
|
|
111
|
+
(signal) => (query: string) =>
|
|
112
|
+
fetch(`/search?q=${query}`, { signal }).then((r) => r.json() as Promise<SearchResult[]>),
|
|
113
|
+
(e) => new SearchError(e),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const search = Op.interpret(searchOp, {
|
|
117
|
+
strategy: "restartable", // new call cancels the previous one
|
|
118
|
+
retry: { attempts: 2, backoff: 300 },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
search.subscribe((state) => {
|
|
122
|
+
if (state.kind === "Pending") showSpinner();
|
|
123
|
+
if (state.kind === "Retrying") showSpinner(`retrying… attempt ${state.attempt}`);
|
|
124
|
+
if (state.kind === "Ok") showResults(state.value);
|
|
125
|
+
if (state.kind === "Err") showError(state.error);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
input.addEventListener("input", (e) => search.run(e.currentTarget.value));
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Form submit — drop concurrent submissions:**
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const submitOp = Op.create(
|
|
135
|
+
(signal) => (data: FormData) =>
|
|
136
|
+
fetch("/orders", { method: "POST", body: data, signal }).then((r) => r.json()),
|
|
137
|
+
(e) => new ApiError(e),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const submit = Op.interpret(submitOp, {
|
|
141
|
+
strategy: "exclusive", // in-flight? new calls are dropped immediately
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
submit.subscribe((state) => {
|
|
145
|
+
submitButton.disabled = state.kind === "Pending";
|
|
146
|
+
if (state.kind === "Ok") showConfirmation(state.value);
|
|
147
|
+
if (state.kind === "Err") showError(state.error);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
form.addEventListener("submit", (e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
submit.run(new FormData(form)); // double-clicks and rage-clicks are ignored
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`restartable`, `exclusive`, `debounced`, `throttled`, `queue`, `concurrent`, `keyed` — each strategy is a complete, tested answer to one concurrency scenario. Swap the word, keep the rest of the code.
|
|
157
|
+
|
|
98
158
|
## What's included?
|
|
99
159
|
|
|
100
|
-
`
|
|
101
|
-
applications: values that may be absent, operations that accumulate multiple errors, data that moves
|
|
102
|
-
through `NotAsked >> Loading >> ( Success | Failure )`, nested immutable updates, and computations that
|
|
103
|
-
share a common environment. Every type follows the same conventions — `map`, `chain`, `match`,
|
|
104
|
-
`getOrElse` — so moving between them feels familiar.
|
|
160
|
+
The library covers the states you encounter in real applications: values that may be absent, operations that accumulate multiple errors, data that moves through `NotAsked >> Loading >> ( Success | Failure )`, async interactions with concurrency policies, nested immutable updates, and computations that share a common environment. Every type follows the same conventions — `map`, `chain`, `match`, `getOrElse` — so moving between them feels familiar.
|
|
105
161
|
|
|
106
162
|
### pipelined/core
|
|
107
163
|
|
|
108
164
|
- **`Maybe<A>`** — a value that may not exist; propagates absence without null checks.
|
|
109
165
|
- **`Result<E, A>`** — an operation that succeeds or fails with a typed error.
|
|
110
|
-
- **`Validation<E, A>`** — like `Result`, but accumulates every failure instead of stopping at the
|
|
111
|
-
first.
|
|
166
|
+
- **`Validation<E, A>`** — like `Result`, but accumulates every failure instead of stopping at the first.
|
|
112
167
|
- **`Task<A>`** — a lazy, infallible async operation; nothing runs until called.
|
|
113
168
|
- **`TaskResult<E, A>`** — a lazy async operation that can fail with a typed error.
|
|
114
169
|
- **`TaskMaybe<A>`** — a lazy async operation that may produce nothing.
|
|
115
170
|
- **`TaskValidation<E, A>`** — a lazy async operation that accumulates validation errors.
|
|
116
|
-
- **`
|
|
117
|
-
- **`RemoteData<E, A>`** — the four states of a data fetch: `NotAsked`, `Loading`, `Failure`,
|
|
118
|
-
|
|
119
|
-
- **`Lens<S, A>`** — focus on a required field in a nested structure. Read, set, and modify
|
|
120
|
-
immutably.
|
|
171
|
+
- **`Op<I, E, A>`** — a managed async operation with a named concurrency strategy: `restartable`, `exclusive`, `debounced`, `throttled`, `queue`, `concurrent`, `keyed`, or `once`. Handles retry, timeout, cancellation, and state in one place.
|
|
172
|
+
- **`RemoteData<E, A>`** — the four states of a data fetch: `NotAsked`, `Loading`, `Failure`, `Success`.
|
|
173
|
+
- **`These<A, B>`** — an inclusive OR: holds a first value, a second, or both at once.
|
|
174
|
+
- **`Lens<S, A>`** — focus on a required field in a nested structure. Read, set, and modify immutably.
|
|
121
175
|
- **`Optional<S, A>`** — like `Lens`, but the target may be absent (nullable fields, array indices).
|
|
122
|
-
- **`Reader<R, A>`** — a computation that depends on an environment `R`, supplied once at the
|
|
123
|
-
|
|
176
|
+
- **`Reader<R, A>`** — a computation that depends on an environment `R`, supplied once at the boundary.
|
|
177
|
+
- **`State<S, A>`** — a computation that reads and updates a state value, threaded explicitly through the chain.
|
|
178
|
+
- **`Logged<W, A>`** — a computation that accumulates a log alongside its value; no console output, just data.
|
|
179
|
+
- **`Predicate<A>`** — a typed boolean function, composable with `and`, `or`, `not`, and `using`.
|
|
180
|
+
- **`Refinement<A, B>`** — a type predicate that narrows `A` to `B` at runtime; composes with `Predicate`.
|
|
181
|
+
- **`Resource<E, A>`** — an acquire/release pair for safe resource management in `TaskResult` pipelines.
|
|
182
|
+
- **`Deferred<A>`** — an infallible async value: a thenable that always resolves, never rejects.
|
|
183
|
+
- **`Tuple<A, B>`** — a typed pair with `first`, `second`, `map`, `swap`, and `fold`.
|
|
124
184
|
|
|
125
185
|
### pipelined/utils
|
|
126
186
|
|
|
@@ -853,7 +853,7 @@ var Op;
|
|
|
853
853
|
Op2.nil = (reason) => ({ kind: "Nil", reason });
|
|
854
854
|
Op2.create = (factory, onError) => ({
|
|
855
855
|
_factory: (input, signal) => Deferred.fromPromise(
|
|
856
|
-
factory(
|
|
856
|
+
factory(signal)(input).then((value) => Result.ok(value)).catch((e) => signal.aborted ? null : Result.err(onError(e)))
|
|
857
857
|
)
|
|
858
858
|
});
|
|
859
859
|
Op2.ok = (value) => ({ kind: "Ok", value });
|
package/dist/core.d.mts
CHANGED
|
@@ -412,7 +412,7 @@ declare namespace Logged {
|
|
|
412
412
|
* @example
|
|
413
413
|
* ```ts
|
|
414
414
|
* const fetchUser = Op.create(
|
|
415
|
-
* (id: string
|
|
415
|
+
* (signal) => (id: string) => fetch(`/users/${id}`, { signal }).then(r => r.json()),
|
|
416
416
|
* (e) => new ApiError(e),
|
|
417
417
|
* );
|
|
418
418
|
*
|
|
@@ -655,21 +655,22 @@ declare namespace Op {
|
|
|
655
655
|
/**
|
|
656
656
|
* Creates an `Op` from an async factory and an error mapper.
|
|
657
657
|
*
|
|
658
|
-
* The factory receives
|
|
659
|
-
* cancellation (like `fetch`) should
|
|
660
|
-
* converts any thrown value into a typed error; it is
|
|
658
|
+
* The factory receives an `AbortSignal` and returns a function that takes the input.
|
|
659
|
+
* Operations that support cancellation (like `fetch`) should capture the signal in the
|
|
660
|
+
* outer closure. The error mapper converts any thrown value into a typed error; it is
|
|
661
|
+
* never called for aborts.
|
|
661
662
|
*
|
|
662
663
|
* @example
|
|
663
664
|
* ```ts
|
|
664
665
|
* const saveProfile = Op.create(
|
|
665
|
-
* (data: ProfileData
|
|
666
|
+
* (signal) => (data: ProfileData) =>
|
|
666
667
|
* fetch("/profile", { method: "POST", body: JSON.stringify(data), signal })
|
|
667
668
|
* .then(r => r.json()),
|
|
668
669
|
* (e) => new ApiError(e),
|
|
669
670
|
* );
|
|
670
671
|
* ```
|
|
671
672
|
*/
|
|
672
|
-
const create: <I, E, A>(factory: (
|
|
673
|
+
const create: <I, E, A>(factory: (signal: AbortSignal) => (input: I) => Promise<A>, onError: (e: unknown) => E) => Op<I, E, A>;
|
|
673
674
|
/**
|
|
674
675
|
* Creates a successful Outcome.
|
|
675
676
|
*
|
package/dist/core.d.ts
CHANGED
|
@@ -412,7 +412,7 @@ declare namespace Logged {
|
|
|
412
412
|
* @example
|
|
413
413
|
* ```ts
|
|
414
414
|
* const fetchUser = Op.create(
|
|
415
|
-
* (id: string
|
|
415
|
+
* (signal) => (id: string) => fetch(`/users/${id}`, { signal }).then(r => r.json()),
|
|
416
416
|
* (e) => new ApiError(e),
|
|
417
417
|
* );
|
|
418
418
|
*
|
|
@@ -655,21 +655,22 @@ declare namespace Op {
|
|
|
655
655
|
/**
|
|
656
656
|
* Creates an `Op` from an async factory and an error mapper.
|
|
657
657
|
*
|
|
658
|
-
* The factory receives
|
|
659
|
-
* cancellation (like `fetch`) should
|
|
660
|
-
* converts any thrown value into a typed error; it is
|
|
658
|
+
* The factory receives an `AbortSignal` and returns a function that takes the input.
|
|
659
|
+
* Operations that support cancellation (like `fetch`) should capture the signal in the
|
|
660
|
+
* outer closure. The error mapper converts any thrown value into a typed error; it is
|
|
661
|
+
* never called for aborts.
|
|
661
662
|
*
|
|
662
663
|
* @example
|
|
663
664
|
* ```ts
|
|
664
665
|
* const saveProfile = Op.create(
|
|
665
|
-
* (data: ProfileData
|
|
666
|
+
* (signal) => (data: ProfileData) =>
|
|
666
667
|
* fetch("/profile", { method: "POST", body: JSON.stringify(data), signal })
|
|
667
668
|
* .then(r => r.json()),
|
|
668
669
|
* (e) => new ApiError(e),
|
|
669
670
|
* );
|
|
670
671
|
* ```
|
|
671
672
|
*/
|
|
672
|
-
const create: <I, E, A>(factory: (
|
|
673
|
+
const create: <I, E, A>(factory: (signal: AbortSignal) => (input: I) => Promise<A>, onError: (e: unknown) => E) => Op<I, E, A>;
|
|
673
674
|
/**
|
|
674
675
|
* Creates a successful Outcome.
|
|
675
676
|
*
|
package/dist/core.js
CHANGED
|
@@ -959,7 +959,7 @@ var Op;
|
|
|
959
959
|
Op2.nil = (reason) => ({ kind: "Nil", reason });
|
|
960
960
|
Op2.create = (factory, onError) => ({
|
|
961
961
|
_factory: (input, signal) => Deferred.fromPromise(
|
|
962
|
-
factory(
|
|
962
|
+
factory(signal)(input).then((value) => Result.ok(value)).catch((e) => signal.aborted ? null : Result.err(onError(e)))
|
|
963
963
|
)
|
|
964
964
|
});
|
|
965
965
|
Op2.ok = (value) => ({ kind: "Ok", value });
|
package/dist/core.mjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -1204,7 +1204,7 @@ var Op;
|
|
|
1204
1204
|
Op2.nil = (reason) => ({ kind: "Nil", reason });
|
|
1205
1205
|
Op2.create = (factory, onError) => ({
|
|
1206
1206
|
_factory: (input, signal) => Deferred.fromPromise(
|
|
1207
|
-
factory(
|
|
1207
|
+
factory(signal)(input).then((value) => Result.ok(value)).catch((e) => signal.aborted ? null : Result.err(onError(e)))
|
|
1208
1208
|
)
|
|
1209
1209
|
});
|
|
1210
1210
|
Op2.ok = (value) => ({ kind: "Ok", value });
|
package/dist/index.mjs
CHANGED