@nlozgachev/pipelined 0.21.0 → 0.23.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 CHANGED
@@ -1,7 +1,6 @@
1
1
  # pipelined
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@nlozgachev/pipelined?style=for-the-badge&color=000&logo=npm&label&logoColor=fff)](https://www.npmjs.com/package/@nlozgachev/pipelined) [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/nlozgachev/pipelined/publish.yml?style=for-the-badge&color=000&logo=githubactions&label&logoColor=fff)](https://github.com/nlozgachev/pipelined/actions/workflows/publish.yml) [![Codecov](https://img.shields.io/codecov/c/github/nlozgachev/pipelined?style=for-the-badge&color=000&logo=codecov&label&logoColor=fff)](https://app.codecov.io/github/nlozgachev/pipelined) [![TypeScript](https://img.shields.io/badge/-0?style=for-the-badge&color=000&logo=typescript&label&logoColor=fff)](https://www.typescriptlang.org)
4
-
3
+ [![npm](https://img.shields.io/npm/v/@nlozgachev/pipelined?style=for-the-badge&color=000&logo=npm&label&logoColor=fff)](https://www.npmjs.com/package/@nlozgachev/pipelined) [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/nlozgachev/pipelined/publish.yml?style=for-the-badge&color=000&logo=githubactions&label&logoColor=fff)](https://github.com/nlozgachev/pipelined/actions/workflows/publish.yml) [![Codecov](https://img.shields.io/codecov/c/github/nlozgachev/pipelined?style=for-the-badge&color=000&logo=codecov&label&logoColor=fff)](https://app.codecov.io/github/nlozgachev/pipelined)
5
4
  Opinionated functional abstractions for TypeScript.
6
5
 
7
6
  > **Note:** pipelined is pre-1.0. The API may change between minor versions until the 1.0 release.
@@ -21,41 +20,41 @@ timeout, and cancellation built in. And, of course, there is more than that.
21
20
 
22
21
  Full guides and API reference at **[pipelined.lozgachev.dev](https://pipelined.lozgachev.dev)**.
23
22
 
24
- ## Example
23
+ ## Example: a single async call
25
24
 
26
25
  A careful, production-minded attempt at "fetch with retry, timeout, and cancellation":
27
26
 
28
27
  ```ts
29
28
  type UserResult =
30
- | { ok: true; user: User; }
31
- | { ok: false; error: "Timeout" | "NetworkError"; };
29
+ | { ok: true; user: User; }
30
+ | { ok: false; error: "Timeout" | "NetworkError"; };
32
31
 
33
32
  async function fetchUser(
34
- id: string,
35
- signal?: AbortSignal,
33
+ id: string,
34
+ signal?: AbortSignal,
36
35
  ): Promise<UserResult> {
37
- async function attempt(n: number): Promise<UserResult> {
38
- const controller = new AbortController();
39
- const timerId = setTimeout(() => controller.abort(), 5000);
40
- signal?.addEventListener("abort", () => controller.abort(), { once: true });
41
-
42
- try {
43
- const res = await fetch(`/users/${id}`, { signal: controller.signal });
44
- clearTimeout(timerId);
45
- return { ok: true, user: await res.json() };
46
- } catch (e) {
47
- clearTimeout(timerId);
48
- if ((e as Error).name === "AbortError" && !signal?.aborted) {
49
- return { ok: false, error: "Timeout" };
50
- }
51
- if (n < 3) {
52
- await new Promise((r) => setTimeout(r, n * 1000));
53
- return attempt(n + 1);
54
- }
55
- return { ok: false, error: "NetworkError" };
56
- }
57
- }
58
- return attempt(1);
36
+ async function attempt(n: number): Promise<UserResult> {
37
+ const controller = new AbortController();
38
+ const timerId = setTimeout(() => controller.abort(), 5000);
39
+ signal?.addEventListener("abort", () => controller.abort(), { once: true });
40
+
41
+ try {
42
+ const res = await fetch(`/users/${id}`, { signal: controller.signal });
43
+ clearTimeout(timerId);
44
+ return { ok: true, user: await res.json() };
45
+ } catch (e) {
46
+ clearTimeout(timerId);
47
+ if ((e as Error).name === "AbortError" && !signal?.aborted) {
48
+ return { ok: false, error: "Timeout" };
49
+ }
50
+ if (n < 3) {
51
+ await new Promise((r) => setTimeout(r, n * 1000));
52
+ return attempt(n + 1);
53
+ }
54
+ return { ok: false, error: "NetworkError" };
55
+ }
56
+ }
57
+ return attempt(1);
59
58
  }
60
59
  ```
61
60
 
@@ -70,14 +69,14 @@ import { pipe } from "@nlozgachev/pipelined/composition";
70
69
  import { Result, TaskResult } from "@nlozgachev/pipelined/core";
71
70
 
72
71
  const fetchUser = (id: string): TaskResult<ApiError, User> =>
73
- pipe(
74
- TaskResult.tryCatch(
75
- (signal) => fetch(`/users/${id}`, { signal }).then((r) => r.json()),
76
- (e) => new ApiError(e),
77
- ),
78
- TaskResult.timeout(5000, () => new ApiError("request timed out")),
79
- TaskResult.retry({ attempts: 3, backoff: (n) => n * 1000 }),
80
- );
72
+ pipe(
73
+ TaskResult.tryCatch(
74
+ (signal) => fetch(`/users/${id}`, { signal }).then((r) => r.json()),
75
+ (e) => new ApiError(e),
76
+ ),
77
+ TaskResult.timeout(5000, () => new ApiError("request timed out")),
78
+ TaskResult.retry({ attempts: 3, backoff: (n) => n * 1000 }),
79
+ );
81
80
  ```
82
81
 
83
82
  `TaskResult<ApiError, User>` is a lazy function — nothing runs until called. The `AbortSignal`
@@ -89,38 +88,97 @@ const controller = new AbortController();
89
88
  const result = await fetchUser("42")(controller.signal);
90
89
 
91
90
  if (Result.isOk(result)) {
92
- render(result.value); // User
91
+ render(result.value); // User
93
92
  } else {
94
- showError(result.error); // ApiError, not unknown
93
+ showError(result.error); // ApiError, not unknown
95
94
  }
96
95
  ```
97
96
 
97
+ ## Example: repeated interactions
98
+
99
+ `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?*
100
+
101
+ `Op` makes that question a one-word configuration choice.
102
+
103
+ **Search — cancel the previous call when the user types:**
104
+
105
+ ```ts
106
+ import { Op } from "@nlozgachev/pipelined/core";
107
+
108
+ const searchOp = Op.create(
109
+ (signal) => (query: string) =>
110
+ fetch(`/search?q=${query}`, { signal }).then((r) => r.json() as Promise<SearchResult[]>),
111
+ (e) => new SearchError(e),
112
+ );
113
+
114
+ const search = Op.interpret(searchOp, {
115
+ strategy: "restartable", // new call cancels the previous one
116
+ retry: { attempts: 2, backoff: 300 },
117
+ });
118
+
119
+ search.subscribe((state) => {
120
+ if (state.kind === "Pending") showSpinner();
121
+ if (state.kind === "Retrying") showSpinner(`retrying… attempt ${state.attempt}`);
122
+ if (state.kind === "Ok") showResults(state.value);
123
+ if (state.kind === "Err") showError(state.error);
124
+ });
125
+
126
+ input.addEventListener("input", (e) => search.run(e.currentTarget.value));
127
+ ```
128
+
129
+ **Form submit — drop concurrent submissions:**
130
+
131
+ ```ts
132
+ const submitOp = Op.create(
133
+ (signal) => (data: FormData) =>
134
+ fetch("/orders", { method: "POST", body: data, signal }).then((r) => r.json()),
135
+ (e) => new ApiError(e),
136
+ );
137
+
138
+ const submit = Op.interpret(submitOp, {
139
+ strategy: "exclusive", // in-flight? new calls are dropped immediately
140
+ });
141
+
142
+ submit.subscribe((state) => {
143
+ submitButton.disabled = state.kind === "Pending";
144
+ if (state.kind === "Ok") showConfirmation(state.value);
145
+ if (state.kind === "Err") showError(state.error);
146
+ });
147
+
148
+ form.addEventListener("submit", (e) => {
149
+ e.preventDefault();
150
+ submit.run(new FormData(form)); // double-clicks and rage-clicks are ignored
151
+ });
152
+ ```
153
+
154
+ `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.
155
+
98
156
  ## What's included?
99
157
 
100
- `TaskResult` is one type. The library also covers the rest of the states you encounter in real
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.
158
+ 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
159
 
106
160
  ### pipelined/core
107
161
 
108
162
  - **`Maybe<A>`** — a value that may not exist; propagates absence without null checks.
109
163
  - **`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.
164
+ - **`Validation<E, A>`** — like `Result`, but accumulates every failure instead of stopping at the first.
112
165
  - **`Task<A>`** — a lazy, infallible async operation; nothing runs until called.
113
166
  - **`TaskResult<E, A>`** — a lazy async operation that can fail with a typed error.
114
167
  - **`TaskMaybe<A>`** — a lazy async operation that may produce nothing.
115
168
  - **`TaskValidation<E, A>`** — a lazy async operation that accumulates validation errors.
116
- - **`These<E, A>`** — an inclusive OR: holds an error, a value, or both at once.
117
- - **`RemoteData<E, A>`** — the four states of a data fetch: `NotAsked`, `Loading`, `Failure`,
118
- `Success`.
119
- - **`Lens<S, A>`** — focus on a required field in a nested structure. Read, set, and modify
120
- immutably.
169
+ - **`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.
170
+ - **`RemoteData<E, A>`** — the four states of a data fetch: `NotAsked`, `Loading`, `Failure`, `Success`.
171
+ - **`These<A, B>`** — an inclusive OR: holds a first value, a second, or both at once.
172
+ - **`Lens<S, A>`** — focus on a required field in a nested structure. Read, set, and modify immutably.
121
173
  - **`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
- boundary.
174
+ - **`Reader<R, A>`** — a computation that depends on an environment `R`, supplied once at the boundary.
175
+ - **`State<S, A>`** — a computation that reads and updates a state value, threaded explicitly through the chain.
176
+ - **`Logged<W, A>`** — a computation that accumulates a log alongside its value; no console output, just data.
177
+ - **`Predicate<A>`** — a typed boolean function, composable with `and`, `or`, `not`, and `using`.
178
+ - **`Refinement<A, B>`** — a type predicate that narrows `A` to `B` at runtime; composes with `Predicate`.
179
+ - **`Resource<E, A>`** — an acquire/release pair for safe resource management in `TaskResult` pipelines.
180
+ - **`Deferred<A>`** — an infallible async value: a thenable that always resolves, never rejects.
181
+ - **`Tuple<A, B>`** — a typed pair with `first`, `second`, `map`, `swap`, and `fold`.
124
182
 
125
183
  ### pipelined/utils
126
184
 
@@ -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(input, signal).then((value) => Result.ok(value)).catch((e) => signal.aborted ? null : Result.err(onError(e)))
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, signal) => fetch(`/users/${id}`, { signal }).then(r => r.json()),
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 the input and an `AbortSignal`. Operations that support
659
- * cancellation (like `fetch`) should thread the signal through. The error mapper
660
- * converts any thrown value into a typed error; it is never called for aborts.
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, signal) =>
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: (input: I, signal: AbortSignal) => Promise<A>, onError: (e: unknown) => E) => Op<I, E, A>;
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, signal) => fetch(`/users/${id}`, { signal }).then(r => r.json()),
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 the input and an `AbortSignal`. Operations that support
659
- * cancellation (like `fetch`) should thread the signal through. The error mapper
660
- * converts any thrown value into a typed error; it is never called for aborts.
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, signal) =>
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: (input: I, signal: AbortSignal) => Promise<A>, onError: (e: unknown) => E) => Op<I, E, A>;
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(input, signal).then((value) => Result.ok(value)).catch((e) => signal.aborted ? null : Result.err(onError(e)))
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
@@ -15,7 +15,7 @@ import {
15
15
  These,
16
16
  Tuple,
17
17
  Validation
18
- } from "./chunk-SSZXZTIX.mjs";
18
+ } from "./chunk-UFQE2J63.mjs";
19
19
  import {
20
20
  Deferred,
21
21
  Maybe,
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(input, signal).then((value) => Result.ok(value)).catch((e) => signal.aborted ? null : Result.err(onError(e)))
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
@@ -44,7 +44,7 @@ import {
44
44
  These,
45
45
  Tuple,
46
46
  Validation
47
- } from "./chunk-SSZXZTIX.mjs";
47
+ } from "./chunk-UFQE2J63.mjs";
48
48
  import {
49
49
  Arr,
50
50
  Dict,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nlozgachev/pipelined",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Opinionated functional abstractions for TypeScript",
5
5
  "license": "BSD-3-Clause",
6
6
  "homepage": "https://pipelined.lozgachev.dev",